Skip to content

Commit 1393ecd

Browse files
authored
Support explicit Flow run abandonment (#211)
Preserve bound runs across inbox changes, support explicit repository-named abandonment, isolate active Flow receipts by worktree, and reset completed delivery state before replacement.
1 parent 0d18779 commit 1393ecd

15 files changed

Lines changed: 399 additions & 24 deletions

File tree

boatstack/cmd/boatstack-helper/flow_runtime.go

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import (
1414

1515
"github.com/operatorstack/boatstack/boatstack/controlprogram"
1616
softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery"
17+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable"
18+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects"
1719
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
20+
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant"
1821
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
1922
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces"
2023
)
@@ -59,6 +62,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions,
5962
if options.flowProgramFingerprint != "" && options.flowProgramFingerprint != compiled.Fingerprint {
6063
return commandOptions{}, fmt.Errorf("FLOW_PROGRAM_DRIFT: run fingerprint does not match the current artifact")
6164
}
65+
options.flowProgramFingerprint = compiled.Fingerprint
6266
objective, err := softwareflow.ObjectiveForEntry(ctx, compiled, resolver, options.entryID)
6367
if err != nil {
6468
return commandOptions{}, err
@@ -67,27 +71,35 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions,
6771
if !ok {
6872
return commandOptions{}, fmt.Errorf("FLOW_ENTRY_UNKNOWN: %s", options.entryID)
6973
}
70-
plan, deliveryID, err := resolveBoundPlan(repository, entry, options)
74+
options, err = bindActiveFlowContext(ctx, repository, options, objective)
7175
if err != nil {
7276
return commandOptions{}, err
7377
}
74-
planRaw, err := os.ReadFile(plan)
75-
if err != nil {
76-
return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err)
77-
}
78-
planDigest := sha256.Sum256(planRaw)
79-
planFingerprint := hex.EncodeToString(planDigest[:])
80-
repositoryIdentity, err := flowRepositoryIdentity(repository)
78+
plan, deliveryID, err := resolveBoundPlan(repository, entry, objective, options)
8179
if err != nil {
8280
return commandOptions{}, err
8381
}
84-
runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint)
85-
if options.runID != "" && options.runID != runID {
86-
return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository")
82+
planFingerprint := ""
83+
if plan != "" {
84+
planRaw, readErr := os.ReadFile(plan)
85+
if readErr != nil {
86+
return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", readErr)
87+
}
88+
planDigest := sha256.Sum256(planRaw)
89+
planFingerprint = hex.EncodeToString(planDigest[:])
90+
repositoryIdentity, identityErr := flowRepositoryIdentity(repository)
91+
if identityErr != nil {
92+
return commandOptions{}, identityErr
93+
}
94+
runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint)
95+
if options.runID != "" && options.runID != runID {
96+
return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository")
97+
}
98+
options.runID = runID
99+
} else if options.runID == "" {
100+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity")
87101
}
88102
options.repository = repository
89-
options.flowProgramFingerprint = compiled.Fingerprint
90-
options.runID = runID
91103
if options.objectiveKind == "" {
92104
options.objectiveKind = string(objective)
93105
}
@@ -137,6 +149,80 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions,
137149
return options, nil
138150
}
139151

152+
func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective model.ObjectiveKind) (commandOptions, error) {
153+
if options.runID != "" && entryObjective != model.ObjectiveAbandoned {
154+
return options, nil
155+
}
156+
resolver, err := plant.NewResolver("")
157+
if err != nil {
158+
return commandOptions{}, err
159+
}
160+
host := options.host
161+
if host == "" {
162+
host = "cli"
163+
}
164+
invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-entry-resume")
165+
if err != nil {
166+
common, commonErr := flowRepositoryIdentity(repository)
167+
if commonErr == nil {
168+
if _, stateErr := os.Stat(filepath.Join(common, "boatstack", "v2")); os.IsNotExist(stateErr) {
169+
return options, nil
170+
}
171+
}
172+
if _, stateErr := os.Stat(filepath.Join(repository, ".git", "boatstack")); stateErr != nil {
173+
return options, nil
174+
}
175+
return commandOptions{}, err
176+
}
177+
layout, _, err := resolver.ResolveLayout(ctx, invocation)
178+
if err != nil {
179+
return commandOptions{}, err
180+
}
181+
raw, err := os.ReadFile(layout.StatePath)
182+
if os.IsNotExist(err) {
183+
return options, nil
184+
}
185+
if err != nil {
186+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: read durable state: %w", err)
187+
}
188+
state, err := durable.DecodeState(raw)
189+
if err != nil {
190+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: decode durable state: %w", err)
191+
}
192+
active, ok := state.ActiveObjective()
193+
if !ok {
194+
return options, nil
195+
}
196+
prefix := "objective-" + options.programID + "-" + options.entryID + "-"
197+
receipt, found, findErr := effects.FindLatestCommittedFlowForObjective(layout, invocation, active, state.Revision)
198+
if findErr != nil {
199+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: inspect committed flow receipts: %w", findErr)
200+
}
201+
if !found || !strings.HasPrefix(receipt.FlowID, "run-") {
202+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity")
203+
}
204+
if active.Kind == entryObjective && strings.HasPrefix(active.ID, prefix) {
205+
options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID
206+
options.objectiveID, options.objectiveKind = active.ID, string(active.Kind)
207+
options.activeFlowBound = true
208+
return options, nil
209+
}
210+
if entryObjective == model.ObjectiveAbandoned {
211+
repositoryIdentity, identityErr := flowRepositoryIdentity(repository)
212+
if identityErr != nil {
213+
return commandOptions{}, identityErr
214+
}
215+
expectedRunID := flowRunID(repositoryIdentity, options.flowProgramFingerprint, options.entryID, active.DeliveryID, "active-run:"+receipt.FlowID)
216+
if options.runID != "" && options.runID != expectedRunID {
217+
return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the active delivery")
218+
}
219+
options.runID = expectedRunID
220+
options.deliveryID, options.activeFlowBound = active.DeliveryID, true
221+
return options, nil
222+
}
223+
return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_CONFLICT: delivery %q is active under objective %q; abandon it before selecting another inbox plan", active.DeliveryID, active.ID)
224+
}
225+
140226
func validateResolvedParameter(parameters protocol.Parameters, name, expected string) error {
141227
if actual, exists := parameters.Get(name); exists && actual != expected {
142228
return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name)
@@ -186,8 +272,11 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R
186272
return request, nil
187273
}
188274

189-
func resolveBoundPlan(repository string, entry controlprogram.Entry, options commandOptions) (string, string, error) {
190-
if options.runID == "" {
275+
func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObjective model.ObjectiveKind, options commandOptions) (string, string, error) {
276+
if options.activeFlowBound && entryObjective == model.ObjectiveAbandoned {
277+
return "", options.deliveryID, nil
278+
}
279+
if options.runID == "" && options.deliveryID == "" {
191280
return resolvePlanInput(repository, entry)
192281
}
193282
if !flowSegment.MatchString(options.deliveryID) {

boatstack/cmd/boatstack-helper/flow_runtime_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,19 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) {
820820
}
821821
}
822822

823+
func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) {
824+
entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"}
825+
plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, model.ObjectiveAbandoned, commandOptions{
826+
entryID: "cancel", activeFlowBound: true, deliveryID: "delivery-one",
827+
})
828+
if err != nil {
829+
t.Fatal(err)
830+
}
831+
if plan != "" || delivery != "delivery-one" {
832+
t.Fatalf("repository-named abandonment resolved plan=%q delivery=%q", plan, delivery)
833+
}
834+
}
835+
823836
func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) {
824837
// control-law: one-flow-run-binds-the-exact-selected-plan-bytes
825838
repository := flowRepository(t)

boatstack/cmd/boatstack-helper/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type commandOptions struct {
4545
deliveryID string
4646
programID string
4747
flowProgramFingerprint string
48+
activeFlowBound bool
4849
entryID string
4950
runID string
5051
transitionID string

boatstack/flow/softwaredelivery/definition.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim
8888
if len(transition.ObjectiveKinds) == 0 {
8989
return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q supports none of the declared entry objectives", declaration.ID)
9090
}
91+
if transition.ID == "plan.abandon" && objectives[model.ObjectiveAbandoned] {
92+
// A repository Flow that explicitly exposes a safely-abandoned entry
93+
// makes abandonment progress for that objective only. Human authority
94+
// remains mandatory and other objectives cannot select this transition.
95+
transition.SelectionClass = delivery.SelectionProgramProgress
96+
}
9197
sort.Slice(transition.ObjectiveKinds, func(i, j int) bool { return transition.ObjectiveKinds[i] < transition.ObjectiveKinds[j] })
9298
selected = append(selected, transition)
9399
}

boatstack/flow/softwaredelivery/definition_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,60 @@ func TestRepositoryTransitionCannotWidenTrustedObjectiveKinds(t *testing.T) {
151151
}
152152
}
153153

154+
func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) {
155+
truth := true
156+
resolver, err := softwareflow.NewResolver(context.Background())
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
document := controlprogram.Document{
161+
SchemaVersion: controlprogram.SchemaVersion,
162+
Program: controlprogram.Program{ID: "product-delivery", Version: "1"},
163+
Facets: []controlprogram.Facet{
164+
{ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"},
165+
{ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"},
166+
{ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"},
167+
},
168+
Operators: []controlprogram.Operator{
169+
{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}},
170+
{ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}},
171+
},
172+
Transitions: []controlprogram.Transition{
173+
{ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77},
174+
{ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31},
175+
},
176+
Targets: []controlprogram.Target{
177+
{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open")}}},
178+
{ID: "safely-abandoned", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("delivery", "discarded"), {Fact: &controlprogram.FactPredicate{Facet: "workspace", Statuses: []string{"known"}, Values: []string{"abandoned", "absent"}}}}}},
179+
},
180+
Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}, {ID: "abandon", Target: "safely-abandoned"}},
181+
}
182+
compiled, err := controlprogram.Compile(document, resolver)
183+
if err != nil {
184+
t.Fatal(err)
185+
}
186+
definition, err := softwareflow.NewDefinition(compiled, resolver)
187+
if err != nil {
188+
t.Fatal(err)
189+
}
190+
manifest, err := definition.RuntimeManifest(context.Background())
191+
if err != nil {
192+
t.Fatal(err)
193+
}
194+
for _, transition := range manifest.Transitions {
195+
if transition.ID == "plan.abandon" {
196+
if transition.SelectionClass != delivery.SelectionProgramProgress || len(transition.ObjectiveKinds) != 1 || transition.ObjectiveKinds[0] != delivery.ObjectiveAbandoned {
197+
t.Fatalf("abandonment transition = %#v", transition)
198+
}
199+
if transition.Priority != 31 {
200+
t.Fatalf("priority = %d, want 31", transition.Priority)
201+
}
202+
return
203+
}
204+
}
205+
t.Fatal("trusted plan.abandon transition was not selected")
206+
}
207+
154208
func TestCompiledBindingDriftFailsClosed(t *testing.T) {
155209
truth := true
156210
compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth})

boatstack/flow/softwaredelivery/skills.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s
4848
description = "Run repository Flow entry " + entry.ID + " to target " + entry.Target + "."
4949
}
5050
description += " Use only when the user explicitly selects this repository Flow entry."
51+
supersession := ""
52+
if entry.Target == "published-pr" {
53+
abandonmentSkill, ok := targetEntrySkill(compiled.Document.Program.ID, compiled.Document.Entries, "safely-abandoned")
54+
if ok {
55+
supersession = fmt.Sprintf(`
56+
If the user requests different work, never retarget this run. When no objective
57+
binding receipt exists, stop this unbound attempt and allow the inbox plan to be
58+
replaced. Once the objective is bound, require explicit use of $%s for
59+
the same delivery and wait for its abandonment receipt before selecting a new
60+
plan and starting a new run.
61+
`, abandonmentSkill)
62+
}
63+
}
5164
return []byte(fmt.Sprintf(`---
5265
name: %s
5366
description: %q
@@ -67,11 +80,21 @@ Apply only the exact immediately preceding prescription and its declared
6780
parameters. A question suspends this run: ask the user, submit only the typed
6881
answer evidence, and resume the same run ID. Nothing continues in the
6982
background while input is missing. Never synthesize authority.
83+
%s
7084
7185
Stop only when Boatstack reports the marked target, a typed blocker, refusal,
7286
unresolved recovery, or missing authority. This entry grants no merge or deploy
7387
authority.
74-
`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host))
88+
`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, supersession))
89+
}
90+
91+
func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) {
92+
for _, entry := range entries {
93+
if entry.Target == target {
94+
return flowSkillSlug(programID, entry.ID), true
95+
}
96+
}
97+
return "", false
7598
}
7699

77100
func title(value string) string {

boatstack/flow/softwaredelivery/skills_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,32 @@ func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) {
7575
}
7676
}
7777

78+
func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) {
79+
compiled := controlprogram.Compiled{Document: controlprogram.Document{
80+
Program: controlprogram.Program{ID: "product-delivery"},
81+
Entries: []controlprogram.Entry{
82+
{ID: "run", Target: "published-pr"},
83+
{ID: "cancel", Target: "safely-abandoned"},
84+
},
85+
}}
86+
files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"})
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if len(files) != 6 {
91+
t.Fatalf("generated file count = %d, want 6", len(files))
92+
}
93+
run := string(files[".agents/skills/product-delivery-run/SKILL.md"])
94+
for _, contract := range []string{"never retarget this run", "$product-delivery-cancel", "abandonment receipt", "starting a new run"} {
95+
if !strings.Contains(run, contract) {
96+
t.Fatalf("generated run skill lacks %q", contract)
97+
}
98+
}
99+
if _, ok := files[".agents/skills/product-delivery-cancel/SKILL.md"]; !ok {
100+
t.Fatal("abandonment entry skill was not generated")
101+
}
102+
}
103+
78104
func TestGeneratedSkillsRejectKernelMaintenanceIdentity(t *testing.T) {
79105
compiled := controlprogram.Compiled{Document: controlprogram.Document{
80106
Program: controlprogram.Program{ID: "boatstack"},

boatstack/flow/standard/supervisor_parity_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,14 @@ func TestExplicitPostTerminalCleanupRemainsAdmissible(t *testing.T) {
114114
func TestTerminalEvidenceForOldObjectiveDoesNotTerminateNewObjective(t *testing.T) {
115115
// control-law: terminal-evidence-is-bound-to-exact-objective-not-local-phase
116116
s := New(testprogram.StandardRegistry(), testObjectiveContracts())
117-
newObjective := model.Objective{ID: "next-objective", Kind: model.ObjectiveOpenPR, DeliveryID: "delivery"}
118-
decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), newObjective, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "objective.bind")
117+
snapshot := snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished)
118+
snapshot.Workspace = model.Known(model.WorkspacePublished, snapshot.Workspace.Evidence[0])
119+
snapshot.Publication = model.Known(model.PublicationOpen, snapshot.Publication.Evidence[0])
120+
snapshot = recanonicalize(t, snapshot)
121+
newObjective := model.Objective{ID: "next-objective", Kind: model.ObjectiveOpenPR, DeliveryID: "next-delivery"}
122+
decision := s.Resolve(snapshot, newObjective, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "")
119123
if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" {
120-
t.Fatalf("decision=%#v, want exact new-objective configuration", decision)
124+
t.Fatalf("untargeted terminal replacement decision=%#v, want exact new-objective configuration", decision)
121125
}
122126
}
123127

boatstack/internal/softwaredelivery/durable/state.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ func (s State) ConfigurationPolicy() model.ConfigurationPolicy {
163163
}.Canonical()
164164
}
165165

166+
func (s State) ActiveObjective() (model.Objective, bool) {
167+
return s.Objective, s.Objective.ID != "" && s.Terminal == model.TerminalNonterminal
168+
}
169+
166170
func EncodeState(state State) ([]byte, error) {
167171
state = state.Canonical()
168172
if err := state.Validate(); err != nil {

0 commit comments

Comments
 (0)