From 5de7f2b989f0d4c1759f29414d29c8349da586c1 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 15:41:55 +0100 Subject: [PATCH] fix: continue repository-bound flow candidates --- .../boatstack-helper/delegation_command.go | 53 +++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 83 +++++++++++++++++++ ...2026-08-13-flow-continuation-parameters.md | 3 + 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 release-notes/2026-08-13-flow-continuation-parameters.md diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index a322c5a..01a1fb6 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -255,7 +255,38 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa err = settleErr } if err != nil || resolved.Prescription == nil { - return resolved, err + if err != nil { + return resolved, err + } + rebound, changed, rebindErr := bindContinuationCandidate(ctx, bound, resolved) + if rebindErr != nil { + return surfaces.Response{}, rebindErr + } + if !changed { + return resolved, nil + } + resolveRequest, err = buildRequest(surfaces.OperationResolve, rebound) + if err != nil { + return surfaces.Response{}, err + } + _, delegationResponse, err = prepareDelegation(ctx, &resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if delegationResponse != nil { + return *delegationResponse, nil + } + kernel, err = standardKernel(ctx, resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + resolved, err = kernel.Handle(ctx, resolveRequest) + if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { + err = settleErr + } + if err != nil || resolved.Prescription == nil { + return resolved, err + } } applyRequest := resolveRequest applyRequest.Operation = surfaces.OperationApply @@ -304,6 +335,26 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return applied, nil } +func bindContinuationCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { + if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || response.Decision.Kind != supervisor.DecisionCandidate || response.Decision.Transition == nil || len(response.Decision.Candidates) != 1 { + return bound, false, nil + } + candidate := response.Decision.Transition.ID + if response.Decision.Candidates[0] != candidate { + return bound, false, nil + } + rebound := bound + rebound.transitionID = string(candidate) + rebound, err := bindFlowEntry(ctx, rebound) + if err != nil { + return commandOptions{}, false, err + } + if len(rebound.parameters) <= len(bound.parameters) { + return bound, false, nil + } + return rebound, true, nil +} + func advanceContinuation(options *commandOptions, response surfaces.Response) error { if response.Receipt == nil { return nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 3324212..f7cfd0f 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -21,6 +21,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -855,6 +856,88 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { + // control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan")) + bound, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + objectiveBind := catalog.Transition{ID: "objective.bind", Parameters: []catalog.ParameterSpec{{Name: "target_id", Required: true}, {Name: "delivery_id", Required: true}}} + rebound, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ + Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}, + }}) + if err != nil || !changed || rebound.transitionID != "objective.bind" { + t.Fatalf("repository candidate rebind = options=%#v changed=%t err=%v", rebound, changed, err) + } + parameters, err := parseParameters(rebound.parameters) + if err != nil { + t.Fatal(err) + } + if target, ok := parameters.Get("target_id"); !ok || target != "published-pr" { + t.Fatalf("bound target = %q, %t", target, ok) + } + if delivery, ok := parameters.Get("delivery_id"); !ok || delivery != "delivery-one" { + t.Fatalf("bound delivery = %q, %t", delivery, ok) + } + planCreate := catalog.Transition{ID: "plan.create", Parameters: []catalog.ParameterSpec{{Name: "source_path", Required: true}, {Name: "source_fingerprint", Required: true}, {Name: "delivery_id", Required: true}}} + planBound, planChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ + Kind: supervisor.DecisionCandidate, Transition: &planCreate, Candidates: []catalog.TransitionID{"plan.create"}, + }}) + if err != nil || !planChanged || planBound.transitionID != "plan.create" { + t.Fatalf("plan candidate rebind = options=%#v changed=%t err=%v", planBound, planChanged, err) + } + planParameters, err := parseParameters(planBound.parameters) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"source_path", "source_fingerprint", "delivery_id"} { + if _, ok := planParameters.Get(name); !ok { + t.Fatalf("plan parameter %q was not bound", name) + } + } + + for name, decision := range map[string]supervisor.Decision{ + "ambiguous": { + Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, + Candidates: []catalog.TransitionID{"objective.bind", "plan.create"}, + }, + "mismatched": { + Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, + Candidates: []catalog.TransitionID{"plan.create"}, + }, + "human-question": { + Kind: supervisor.DecisionCandidate, + Transition: &catalog.Transition{ID: "plan.approve", Parameters: []catalog.ParameterSpec{{Name: "plan_fingerprint", Required: true}}, Prescription: catalog.Prescription{AuthorityPrompt: "Approve exact plan bytes"}}, + Candidates: []catalog.TransitionID{"plan.approve"}, + }, + } { + t.Run(name, func(t *testing.T) { + result, reboundChanged, reboundErr := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &decision}) + if reboundErr != nil || reboundChanged || result.transitionID != "" || len(result.parameters) != 0 { + t.Fatalf("unsafe candidate rebound = options=%#v changed=%t err=%v", result, reboundChanged, reboundErr) + } + }) + } + + explicit := bound + explicit.transitionID = "objective.bind" + if _, changed, err := bindContinuationCandidate(context.Background(), explicit, surfaces.Response{Decision: &supervisor.Decision{ + Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}, + }}); err != nil || changed { + t.Fatalf("explicit transition rebound changed=%t err=%v", changed, err) + } + if _, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{ + Decision: &supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}}, + Prescription: &protocol.Prescription{TransitionID: "objective.bind"}, + }); err != nil || changed { + t.Fatalf("prescribed response rebound changed=%t err=%v", changed, err) + } +} + func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) { entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"} plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, softwareflow.EntryObjective{ diff --git a/release-notes/2026-08-13-flow-continuation-parameters.md b/release-notes/2026-08-13-flow-continuation-parameters.md new file mode 100644 index 0000000..7f0c4a6 --- /dev/null +++ b/release-notes/2026-08-13-flow-continuation-parameters.md @@ -0,0 +1,3 @@ +### Continue repository-resolved Flow steps + +Repository Flow runs now bind declared repository inputs when the supervisor selects a single parameterized transition. Human questions and ambiguous candidates remain suspended.