Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "|"),
}, "·")
Expand Down
Original file line number Diff line number Diff line change
@@ -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++
}
Loading
Loading