From 3f3d67a03fbd29c1c89520bccf0dd501f5e7b995 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 28 Jul 2026 17:54:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(boatstack):=20goal=20escapes=20=E2=80=94?= =?UTF-8?q?=20the=20merged=20pursuit=20is=20bounded=20and=20demotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged-terminal pursuit now runs inside an explicit contract: at most three recorded post-publish fix cycles (offline counter on the published slice, mirroring RepairAttempt), no changes-requested review verdict, and a cleanly merging branch (DIRTY fires; BEHIND deliberately does not). Any ended contract fires a goal escape: the actor demotes to operator, nothing further is prescribed (demote-and-stop), the reason explains the pause, and the demotion is cached best-effort on the slice so it holds offline in a fresh session — the same bounded bypass as the terminal PRState cache, and the registry note now names it. Recording the next correction is the explicit reset (escape clears, cycle restarts at one). The published default evaluates and writes nothing. control-law: goal-escape-demotes-to-operator-and-stops Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../2026-07-28-bounded-merge-pursuit.md | 5 + .../product-engineering-loop/delivery.go | 23 ++ .../product-engineering-loop/flow_control.go | 10 +- .../product-engineering-loop/flow_frontier.go | 9 +- .../product-engineering-loop/flow_watch.go | 1 + .../product-engineering-loop/goal_escape.go | 100 +++++++++ .../goal_escape_conformance_test.go | 199 ++++++++++++++++++ .../internal/deliverycontrol/registry.go | 2 +- .../product-engineering-loop/next.go | 23 +- 9 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-bounded-merge-pursuit.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/goal_escape.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/goal_escape_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-bounded-merge-pursuit.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-bounded-merge-pursuit.md new file mode 100644 index 000000000..3381389b5 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-bounded-merge-pursuit.md @@ -0,0 +1,5 @@ +### The merged-goal pursuit now has explicit limits, and stops when it hits one + +With `delivery.terminal: merged`, the flow pursues your pull request only inside a clear contract: at most three recorded post-publish fix cycles, no reviewer asking for changes, and a branch that merges cleanly. When any of those ends — the budget is spent, changes are requested, the base conflicts — the pursuit pauses: the step comes back to you, nothing further is prescribed, and the pause is remembered so it still holds tomorrow in a fresh session, even offline. + +Recording the next correction is the explicit reset that starts a fresh cycle. The status reason always tells you why the pursuit paused. With the default `published` goal, none of this bookkeeping is evaluated or written. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index dde618dfa..7c2ca6d09 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -51,6 +51,14 @@ type DeliverySlice struct { // updatable in place while non-terminal; once terminal, in-place correction // is refused and a corrective child delivery is the bounded forward actuator. PRState string `json:"pr_state,omitempty"` + // PostPublishFixAttempts counts the post-publish correction cycles + // recorded against this published slice, and GoalEscape caches a fired + // merged-goal demotion (sticky offline until the next recorded correction + // clears it). Both are written only under delivery.terminal "merged"; a + // default-terminal state file never carries them. + // control-law: goal-escape-demotes-to-operator-and-stops + PostPublishFixAttempts int `json:"post_publish_fix_attempts,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` } type DeliveryState struct { @@ -654,7 +662,19 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if err := appendChangeObservation(repo, observation); err != nil { return ChangeObservation{}, DeliveryState{}, err } + // Under the merged terminal, a recorded post-publish correction advances + // the targeted published slice's fix-cycle bookkeeping (and, after an + // escape, is the operator's explicit reset for a fresh cycle). The + // published default records nothing — its state files stay byte-stable. + // control-law: goal-escape-demotes-to-operator-and-stops + trackPostPublishCycle := resolveDeliveryTerminal(repo, options.Feature) == TerminalMerged if published { + if trackPostPublishCycle && len(state.Slices) > 0 { + bumpPostPublishFixCycle(&state.Slices[len(state.Slices)-1]) + if err := saveDeliveryState(repo, state); err != nil { + return ChangeObservation{}, DeliveryState{}, err + } + } return observation, state, nil } if publishedOpen { @@ -683,6 +703,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } else { slice.Status = StatusBuild } + if trackPostPublishCycle { + bumpPostPublishFixCycle(slice) + } if err := saveDeliveryState(repo, state); err != nil { return ChangeObservation{}, DeliveryState{}, err } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go index 0f1093ad7..254d90850 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go @@ -166,7 +166,10 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor { // unknown position — stays the operator's. Fail-closed: the zero // Terminal behaves as published. // control-law: turn-ends-only-at-the-operator-frontier - if next.Terminal == TerminalMerged { + // A fired goal escape demotes unconditionally: the pursuit contract + // ended, so no phase can hand the step back to the agent. + // control-law: goal-escape-demotes-to-operator-and-stops + if next.Terminal == TerminalMerged && status.GoalEscape == "" { switch PRPhase(status.PRPhase) { case PRPhaseChecksPending, PRPhaseChecksFailing, PRPhaseMergeEligible: return NextActorAgent @@ -457,6 +460,11 @@ func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTermi if terminal != TerminalMerged || status.ObservedStage != "PUBLISHED" || status.Lifecycle == "PUBLISHED_MERGED" { return nil, "" } + // A fired escape prescribes nothing: demote-and-stop, never + // demote-and-suggest. control-law: goal-escape-demotes-to-operator-and-stops + if status.GoalEscape != "" { + return nil, "" + } var repoArgs []string if repo != "" && repo != "." { repoArgs = []string{"--repo", repo} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go index 6acbe7c30..84f9b87f4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go @@ -24,6 +24,7 @@ type FrontierRow struct { TotalSlices int `json:"total_slices,omitempty"` Stage string `json:"stage"` Lifecycle string `json:"lifecycle,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` PRPhase string `json:"pr_phase,omitempty"` PRFailingChecks []string `json:"pr_failing_checks,omitempty"` PRURL string `json:"pr_url,omitempty"` @@ -85,7 +86,7 @@ func ResolveFrontier(repoPath string) (FlowFrontier, error) { continue } branch, _, prURL := deliveryBranchAndSlice(state) - status := publishedNextStatus(state, observePRTarget(repo, prURL, branch)) + status := publishedNextStatus(state, observePRTarget(repo, prURL, branch), resolveDeliveryTerminal(repo, state.Feature)) frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status)) } for _, row := range frontier.Rows { @@ -139,6 +140,9 @@ func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow { PRMergeState: observation.MergeState, PRFailingChecks: observation.FailingChecks, Reason: fmt.Sprintf("Slice %q is published with an open pull request while a later slice is active.", slice.ID), } + if resolveDeliveryTerminal(repo, state.Feature) == TerminalMerged && observation.Lifecycle != "PUBLISHED_MERGED" { + sliceStatus.GoalEscape = evaluateGoalEscape(slice, observation) + } rows = append(rows, frontierRowFromStatus(repo, sliceStatus)) } return rows @@ -153,7 +157,8 @@ func frontierRowFromStatus(repo string, status NextStatus) FrontierRow { Feature: status.Feature, Slice: status.ActiveSlice, SliceIndex: status.SliceIndex, TotalSlices: status.TotalSlices, Stage: status.ObservedStage, Lifecycle: status.Lifecycle, - PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks, + GoalEscape: status.GoalEscape, + PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks, PRURL: status.PRURL, NextOperation: status.NextOperation, Reason: status.Reason, Blocked: status.VerificationStatus == "BLOCKED", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go index df86bcafd..2312c1c5f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go @@ -130,6 +130,7 @@ func frontierSignatures(frontier FlowFrontier) map[string]string { key := row.Feature + "/" + row.Slice signatures[key] = strings.Join([]string{ row.Stage, row.Lifecycle, row.PRPhase, row.Actor, row.NextOperation, + row.GoalEscape, fmt.Sprintf("blocked=%t", row.Blocked), strings.Join(row.PRFailingChecks, "|"), }, "·") diff --git a/labs/12-product-engineering-loop/product-engineering-loop/goal_escape.go b/labs/12-product-engineering-loop/product-engineering-loop/goal_escape.go new file mode 100644 index 000000000..eb8e338bc --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/goal_escape.go @@ -0,0 +1,100 @@ +package boatstack + +import "strings" + +// Goal escapes bound the merged-terminal pursuit: pursuing a merge +// autonomously is trustworthy only while the world stays inside the contract +// the goal was granted under. When a disturbance ends that contract — the fix +// budget is spent, a reviewer requested changes, the base conflicts — the +// pursuit DEMOTES to the operator and stops: the actor becomes operator, +// nothing further is prescribed, and the demotion is persisted best-effort so +// it holds offline in a fresh session. An escape is cleared only by the next +// explicit correction cycle (record-change), which is an operator-authorized +// act in the protocol. Escapes exist only under the merged terminal; the +// published default never evaluates or records them. +// control-law: goal-escape-demotes-to-operator-and-stops +const ( + EscapeFixAttemptsExhausted = "fix_attempts_exhausted" + EscapeChangesRequested = "changes_requested" + EscapeBaseConflicts = "base_conflicts" +) + +// postPublishFixBudget bounds how many post-publish correction cycles a +// published slice may consume before the pursuit hands back to the operator. +// It mirrors the active-slice repair budget (RepairAttempt < 3). +const postPublishFixBudget = 3 + +// evaluateGoalEscape derives the escape for one published slice from its +// persisted counters and one live observation. Pure and offline-safe: a +// previously persisted escape is sticky regardless of what gh says now (a +// re-approval without a recorded correction does not silently re-arm the +// pursuit), and the attempts bound needs no network at all. +func evaluateGoalEscape(slice DeliverySlice, pr publishedPRObservation) string { + if persisted := strings.TrimSpace(slice.GoalEscape); persisted != "" { + return persisted + } + if slice.PostPublishFixAttempts >= postPublishFixBudget { + return EscapeFixAttemptsExhausted + } + if strings.EqualFold(strings.TrimSpace(pr.ReviewDecision), "CHANGES_REQUESTED") { + return EscapeChangesRequested + } + // DIRTY is GitHub's "the branch conflicts with the base". BEHIND is + // deliberately not an escape: it is often auto-resolvable and already + // classifies to an operator-owned Unknown phase without stickiness. + if strings.EqualFold(strings.TrimSpace(pr.MergeState), "DIRTY") { + return EscapeBaseConflicts + } + return "" +} + +// goalEscapeReason renders one escape as the operator-facing explanation. +func goalEscapeReason(escape string) string { + switch escape { + case EscapeFixAttemptsExhausted: + return "the post-publish fix budget is spent" + case EscapeChangesRequested: + return "a reviewer requested changes" + case EscapeBaseConflicts: + return "the branch conflicts with its base" + default: + return "the pursuit contract ended" + } +} + +// persistGoalEscape caches a fired escape on the slice it belongs to, exactly +// like persistObservedTerminalPRState caches a terminal lifecycle: a bounded, +// best-effort write of an already-derived fact, so the demotion is sticky in +// a fresh offline session. Failures are swallowed — the demotion holds for +// this resolution regardless. +func persistGoalEscape(repo string, state DeliveryState, escape string) { + if strings.TrimSpace(escape) == "" { + return + } + for i := len(state.Slices) - 1; i >= 0; i-- { + slice := state.Slices[i] + if slice.Status != "PUBLISHED" { + continue + } + if strings.TrimSpace(slice.GoalEscape) == escape { + return + } + state.Slices[i].GoalEscape = escape + _ = saveDeliveryState(repo, state) + return + } +} + +// bumpPostPublishFixCycle advances the per-slice correction-cycle bookkeeping +// when a post-publish correction is explicitly recorded. Recording a +// correction after an escape is the operator's reset: the escape clears and +// the new cycle starts at one. Without an escape, the cycle count advances +// toward the budget. +func bumpPostPublishFixCycle(slice *DeliverySlice) { + if strings.TrimSpace(slice.GoalEscape) != "" { + slice.GoalEscape = "" + slice.PostPublishFixAttempts = 1 + return + } + slice.PostPublishFixAttempts++ +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/goal_escape_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/goal_escape_conformance_test.go new file mode 100644 index 000000000..f23926b7b --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/goal_escape_conformance_test.go @@ -0,0 +1,199 @@ +package boatstack + +// control-law: goal-escape-demotes-to-operator-and-stops +// +// The merged-terminal pursuit is bounded by an explicit contract: it runs +// only while the fix budget holds, no reviewer has requested changes, and the +// branch merges cleanly. Any of those disturbances fires a goal escape, and a +// fired escape demotes unconditionally — the actor becomes operator, nothing +// further is prescribed (demote-and-stop, never demote-and-suggest), and the +// demotion persists best-effort so it holds OFFLINE in a fresh session. The +// only reset is the next explicitly recorded correction cycle. Under the +// published default no escape is ever evaluated or written. +// +// Test classes: positive (each escape condition → operator + no +// prescription + explanatory reason), relation (a persisted escape demotes +// with gh unavailable — sticky offline; recording a correction clears it and +// restarts the cycle at one), negative (a budget not yet spent does not +// escape; the default terminal records nothing), bypass (an escaped delivery +// never gets the merge prescribed again until reset, even under a live +// merge-eligible observation). + +import ( + "errors" + "strings" + "testing" +) + +func recordCIObservation(t *testing.T, repo, feature string) { + t.Helper() + if _, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "check failed", SourceStage: "ci", + Classification: "implementation_repair", + }); err != nil { + t.Fatal(err) + } +} + +// Positive: each live disturbance fires its escape — operator actor, no +// prescription, and a reason that explains the pause. +func TestLiveDisturbancesFireEscapes(t *testing.T) { + for _, test := range []struct { + name string + payload func(string, ...string) (string, error) + wantEscape string + }{ + {"changes_requested", phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass), EscapeChangesRequested}, + {"base_conflicts", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass), EscapeBaseConflicts}, + } { + t.Run(test.name, func(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, test.payload) + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != test.wantEscape { + t.Fatalf("escape = %q, want %q", status.GoalEscape, test.wantEscape) + } + if !strings.Contains(status.Reason, "paused") { + t.Fatalf("reason does not explain the pause: %q", status.Reason) + } + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("escape did not demote-and-stop: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + }) + } +} + +// Positive + relation: the fix budget is offline — three recorded cycles +// exhaust it with no live signal at all, the demotion is sticky with gh +// unavailable, and the next recorded correction is the reset that starts a +// fresh cycle at one. +func TestFixBudgetExhaustsDemotesOfflineAndResets(t *testing.T) { + repo := mergedTerminalRepo(t) + for i := 0; i < postPublishFixBudget; i++ { + recordCIObservation(t, repo, "shipped") + } + state, err := LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last := state.Slices[len(state.Slices)-1] + if last.PostPublishFixAttempts != postPublishFixBudget { + t.Fatalf("attempts = %d, want %d", last.PostPublishFixAttempts, postPublishFixBudget) + } + + // gh is unavailable: the escape must fire from the persisted counter alone. + withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("offline") }) + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != EscapeFixAttemptsExhausted { + t.Fatalf("offline escape = %q", status.GoalEscape) + } + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("offline demotion failed: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + // The fired escape was cached; a fresh load shows it without any observation. + state, err = LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if state.Slices[len(state.Slices)-1].GoalEscape != EscapeFixAttemptsExhausted { + t.Fatalf("escape not persisted: %#v", state.Slices) + } + + // The reset: recording the next correction clears the escape and starts a + // new cycle at one. + recordCIObservation(t, repo, "shipped") + state, err = LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last = state.Slices[len(state.Slices)-1] + if last.GoalEscape != "" || last.PostPublishFixAttempts != 1 { + t.Fatalf("reset failed: %#v", last) + } +} + +// Negative: a budget not yet spent does not escape, and the pursuit still +// prescribes the fix. +func TestUnspentBudgetKeepsPrescribing(t *testing.T) { + repo := mergedTerminalRepo(t) + for i := 0; i < postPublishFixBudget-1; i++ { + recordCIObservation(t, repo, "shipped") + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorAgent || next.Prescribed == nil || next.Prescribed.Verb != "record-change" { + t.Fatalf("unspent budget stopped prescribing: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } +} + +// Negative: the published default never evaluates, surfaces, or writes an +// escape — its state files stay byte-stable through a correction cycle. +func TestPublishedDefaultRecordsNoEscapeState(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "DIRTY", rollupCheckRunFail)) + + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if status.GoalEscape != "" { + t.Fatalf("default terminal surfaced an escape: %q", status.GoalEscape) + } + recordCIObservation(t, repo, "shipped") + state, err := LoadDeliveryState(repo, "shipped") + if err != nil { + t.Fatal(err) + } + last := state.Slices[len(state.Slices)-1] + if last.PostPublishFixAttempts != 0 || last.GoalEscape != "" { + t.Fatalf("default terminal wrote pursuit bookkeeping: %#v", last) + } +} + +// Bypass: once escaped, even a live merge-eligible observation cannot get the +// merge prescribed again — the contract stays ended until the recorded reset. +func TestEscapedDeliveryNeverGetsMergePrescribed(t *testing.T) { + repo := mergedTerminalRepo(t) + // Fire and persist an escape. + withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass)) + if _, err := ResolveNext(repo, "shipped"); err != nil { + t.Fatal(err) + } + // The world now looks perfect — but the escape is sticky. + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("sticky escape bypassed: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } + // After the recorded reset, the pursuit re-arms from a fresh observation. + recordCIObservation(t, repo, "shipped") + next, err = NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Prescribed == nil || next.Prescribed.Program != "gh" { + t.Fatalf("reset did not re-arm the pursuit: %#v", next.Prescribed) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go index 039d7bd3f..3e5133f41 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/internal/deliverycontrol/registry.go @@ -80,7 +80,7 @@ var registry = []TransitionDescriptor{ ID: "delivery.next", From: nil, To: "", Kind: KindObserve, CostClass: CostObserve, Reversible: false, HandlerRef: "ResolveNext", CLIVerb: "next-status", - Note: "Derives the recommended next move. Read-only, except that the published branch caches an observed terminal PRState as a best-effort side effect (a known bypass, modeled not fixed).", + Note: "Derives the recommended next move. Read-only, except that the published branch caches an observed terminal PRState — and, under the merged terminal, a fired goal-escape demotion — as a best-effort side effect (a known bypass, modeled not fixed).", }, { ID: "delivery.recovery_status", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: "", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index 53a16c73f..b9e406578 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -27,6 +27,7 @@ type NextStatus struct { Reason string `json:"reason"` BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"` Lifecycle string `json:"lifecycle,omitempty"` + GoalEscape string `json:"goal_escape,omitempty"` PRPhase string `json:"pr_phase,omitempty"` PRReviewDecision string `json:"pr_review_decision,omitempty"` PRMergeState string `json:"pr_merge_state,omitempty"` @@ -144,14 +145,22 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { func nextForPublished(repo string, state DeliveryState) NextStatus { pr := observePublishedPR(repo, state) persistObservedTerminalPRState(repo, state, pr) - return publishedNextStatus(state, pr) + terminal := resolveDeliveryTerminal(repo, state.Feature) + status := publishedNextStatus(state, pr, terminal) + // A fired escape is cached best-effort so the demotion holds offline in a + // fresh session — the same bounded bypass as the terminal PRState cache. + // control-law: goal-escape-demotes-to-operator-and-stops + if terminal == TerminalMerged && status.GoalEscape != "" && status.Lifecycle != "PUBLISHED_MERGED" { + persistGoalEscape(repo, state, status.GoalEscape) + } + return status } // publishedNextStatus is the pure mapping from one live PR observation to the // published NextStatus. Split from nextForPublished so the frontier report can // present the same projection without nextForPublished's best-effort terminal // cache write. control-law: frontier-reports-never-mutates -func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextStatus { +func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal) NextStatus { _, sliceID, _ := deliveryBranchAndSlice(state) status := NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", @@ -162,6 +171,13 @@ func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextSta PRPhase: string(pr.Phase), PRReviewDecision: pr.ReviewDecision, PRMergeState: pr.MergeState, PRFailingChecks: pr.FailingChecks, } + if terminal == TerminalMerged && pr.Lifecycle != "PUBLISHED_MERGED" && len(state.Slices) > 0 { + index := state.ActiveIndex + if index >= len(state.Slices) { + index = len(state.Slices) - 1 + } + status.GoalEscape = evaluateGoalEscape(state.Slices[index], pr) + } switch pr.Lifecycle { case "PUBLISHED_MERGED": status.ObservedStage = "FEATURE_COMPLETE" @@ -189,6 +205,9 @@ func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextSta default: status.Reason = fmt.Sprintf("Feature %q is published, but its PR state could not be verified.", state.Feature) } + if status.GoalEscape != "" { + status.Reason = fmt.Sprintf("Feature %q is published; the merged-goal pursuit is paused because %s. Record the correction to start a new cycle, or handle the pull request yourself.", state.Feature, goalEscapeReason(status.GoalEscape)) + } return status }