carlos / platform Public

settings-4 task 2: fix round 1: no request bookkeeping on an unread build state; a failed head completes the request failed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 10f721d2523245998b59baad7a8354d741743f14 parent 9ac7317
2 files changed, +88 −28
  • internal/release/updatetarget.go +37 −22
  • internal/release/updatetarget_test.go +51 −6
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go
index 1f54ae604..8bf3d2ebb 100644
--- a/internal/release/updatetarget.go
+++ b/internal/release/updatetarget.go
@@ -71,30 +71,46 @@ func Target(in TargetInput) TargetDecision {
// whose T failed completes failed. A pin then decides what runs, and the
// resolution waits, so clearing the pin later installs this T, not a
// head that moved on. A completed request never enters here again.
+ //
+ // None of it happens while the build state is unread (!Build.Known): the
+ // watermark then sees only the consumed file, so a request that already
+ // completed would look pending on a fresh box (R-TARGET-UNKNOWN). The pin,
+ // held and adoption rules decide as before, with no resolution and no
+ // failure completion.
var (
resolve *ResolvedRequest
failedReq uint64
reqTarget string // a resolved T, not failed, above the watermark
)
- if u.InstallRequest > wm {
- if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil {
- if failed(res.Target) {
- failedReq = res.ID // completes failed, once; the rules below decide what runs
- } else {
- reqTarget = res.Target
+ if in.Build.Known {
+ if u.InstallRequest > wm {
+ if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil {
+ if failed(res.Target) {
+ failedReq = res.ID // completes failed, once; the rules below decide what runs
+ } else {
+ reqTarget = res.Target
+ }
+ } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) {
+ // The eligible head is the request's T. When this box holds that
+ // head as failed the request is resolved and completed failed in
+ // the same pass and installs nothing (R-TARGET-FAILEDHEAD); the
+ // later rules decide what runs.
+ resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head}
+ if failed(head) {
+ failedReq = u.InstallRequest
+ } else {
+ reqTarget = head
+ }
}
- } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) && !failed(head) {
- resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head}
- reqTarget = head
+ // Unresolved only while the head is baking here: it blocks nothing.
}
- // Unresolved (the head is baking here): it blocks nothing.
- }
- // A completed request whose target failed after it first ran (a later
- // health, settle or hold failure) is recorded completed-failed, once,
- // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin.
- if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) {
- if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) {
- failedReq = res.ID
+ // A completed request whose target failed after it first ran (a later
+ // health, settle or hold failure) is recorded completed-failed, once,
+ // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin.
+ if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) {
+ if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) {
+ failedReq = res.ID
+ }
}
}
carry := func(d TargetDecision) TargetDecision {
@@ -147,18 +163,17 @@ func Target(in TargetInput) TargetDecision {
if failed(d.Version) {
d = TargetDecision{Reason: ReasonFailed, Detail: d.Version}
}
- d.Fail = failedReq
- return d
+ return carry(d)
}
// Rule 4: today's adoption.
if head == "" {
- return TargetDecision{Fail: failedReq}
+ return carry(TargetDecision{})
}
if failed(head) {
- return TargetDecision{Reason: ReasonFailed, Detail: head, Fail: failedReq}
+ return carry(TargetDecision{Reason: ReasonFailed, Detail: head})
}
- return TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head, Fail: failedReq}
+ return carry(TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head})
}
// completedFailed reports whether either source already records request id
diff --git a/internal/release/updatetarget_test.go b/internal/release/updatetarget_test.go
index 5ce21bf17..6c963ee1e 100644
--- a/internal/release/updatetarget_test.go
+++ b/internal/release/updatetarget_test.go
@@ -43,15 +43,15 @@ func TestTargetRules(t *testing.T) {
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
// Astra I3 #6: the request is still resolved at the start of the pass,
// pin or no pin, onto the head eligible now; the pin decides what runs.
- {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}},
+ {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}, Build: known(false, nil)},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 5, Target: "v3"}}},
{"pin, request already resolved to T: the pin runs, T is kept for later", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2},
Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
{"pin, the request's T failed: completes failed under the pin", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2},
- Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9, Build: known(false, nil)},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Fail: 2}},
- {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}},
+ {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, Build: known(false, nil)},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
{"pending request, head eligible: resolves to the head", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"},
@@ -63,10 +63,10 @@ func TestTargetRules(t *testing.T) {
Build: known(true, &BuildState{Completed: 1}), Installed: "v1"},
TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer, Resolve: &ResolvedRequest{ID: 2, Target: "v3"}}},
{"resolved request, T no longer the head: a record", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
- Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}, Installed: "v1"},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}, Installed: "v1", Build: known(false, nil)},
TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}},
{"resolved request ignores manual and major", TargetInput{Pointer: head("v3"), HeadTag: "2.0.0", Settings: UpdateSettings{Updates: UpdatesManual, Major: ip(1), InstallRequest: 2},
- Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, Build: known(false, nil)},
TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}},
{"resolved request whose T failed: completes failed, installs nothing", TargetInput{Pointer: head("v9"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}}, Failed: failedV9, Build: known(true, &BuildState{LastExecuted: "v1"}), Installed: "v1"},
@@ -145,7 +145,7 @@ func TestIndependentTargetsNeedNoPointer(t *testing.T) {
{"pin, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
{"resolved request, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
- Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, Build: known(false, nil)},
TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}},
{"held recovery, no pointer", TargetInput{Settings: manual(), Build: known(true, &BuildState{LastExecuted: "v1"})},
TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}},
@@ -164,3 +164,48 @@ func TestAPendingTOutranksLastExecuted(t *testing.T) {
Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v3"},
TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}}})
}
+
+// R-TARGET-UNKNOWN: with the build state unread the watermark sees only the
+// consumed file, so a request that already completed would look pending on a
+// fresh box. Target then does no request bookkeeping at all.
+func TestAnUnreadBuildStateDoesNoRequestBookkeeping(t *testing.T) {
+ runTargetCases(t, []tcase{
+ {"held, completed request looks pending: unknown, nothing resolved", TargetInput{Pointer: head("v7"),
+ Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 5}, Build: BuildStateCopy{}},
+ TargetDecision{Reason: ReasonUnknown}},
+ {"auto, FastTrack, baking head: today's adoption, nothing resolved", TargetInput{Pointer: head("v7"), HeadHeld: "held 20h",
+ Settings: UpdateSettings{Updates: UpdatesAuto, InstallRequest: 5, InstallFastTrack: true}, Build: BuildStateCopy{}},
+ TargetDecision{Version: "v7", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}},
+ {"pin, pending request: the pin, nothing resolved", TargetInput{Pointer: head("v7"),
+ Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 5}, Build: BuildStateCopy{}},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
+ {"resolved T failed, build state unread: no failure completion", TargetInput{Pointer: head("v7"),
+ Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 5},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 5, Target: "v9"}}, Failed: func(v string) bool { return v == "v9" }},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
+ })
+}
+
+// R-TARGET-FAILEDHEAD: a pending request whose eligible head this box holds
+// as failed is resolved to that head and completed failed in the same pass.
+// It installs nothing itself; the later rules decide what runs, and the
+// failed head never becomes the Version.
+func TestAPendingRequestOnAFailedHeadCompletesFailed(t *testing.T) {
+ failedV9 := func(v string) bool { return v == "v9" }
+ runTargetCases(t, []tcase{
+ {"manual: the held build runs, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9,
+ Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
+ Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"},
+ TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}},
+ {"auto: nothing new, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9,
+ Settings: UpdateSettings{Updates: UpdatesAuto, InstallRequest: 2}, Build: known(true, nil), Installed: "v2"},
+ TargetDecision{Reason: ReasonFailed, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}},
+ {"pin: the pin runs, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9,
+ Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, Build: known(true, nil)},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}},
+ {"baking failed head: still unresolved", TargetInput{Pointer: head("v9"), Failed: failedV9, HeadHeld: "held 20h",
+ Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
+ Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"},
+ TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}},
+ })
+}