From de18f372d350bbecc0e0ded4a7bc2c984aabc115 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 29 Aug 2026 20:07:23 +0400 Subject: [PATCH] Fence the two coordinator-side stops nobody had leased F65, found live on run 20. A plan mismatch asking for a human decision recorded its observation, then failed to block the task: Store.Append fences every lifecycle event on a leased task against the current owner and epoch, and this TaskBlocked carried neither. The task kept implementing while the contradiction sat durable in the log, and the agent was told its report was refused. The trajectory gate had the same omission. The human-decision path already did this correctly and explained why in a comment. That comment is now a helper all three call. The tests could not have caught it. planWith never leased its task, so every plan test ran in a state no agent can be in, which is exactly what the lease helper's own comment warns against. It leases now, and the mismatch block test fails without the fence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1 --- internal/operations/human_decision.go | 23 ++++++++++++++++------- internal/operations/planmismatch.go | 6 ++++-- internal/operations/planprogress_test.go | 5 +++++ internal/operations/trajectory.go | 6 ++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/internal/operations/human_decision.go b/internal/operations/human_decision.go index ca2224c..c5ba852 100644 --- a/internal/operations/human_decision.go +++ b/internal/operations/human_decision.go @@ -58,18 +58,27 @@ func RequestHumanDecision(s *store.Store, project registry.Project, taskID strin return blockTask(s, t, domain.BlockReasonHumanDecision, req.Render(), &req) } +// fenceToLease binds a lifecycle event to the lease that is producing it. +// Store.Append fences every lifecycle event on a leased task against the +// current owner and epoch, so an event that omits them is a conflict rather +// than a block. Every coordinator-side stop goes through here: two of them did +// not, and both failed silently against a live lease (F65). A task stops for a +// human only while some session is running, so the leased case is the only one +// that ever mattered. +func fenceToLease(payload map[string]any, t domain.Task) { + if t.Lease == nil { + return + } + payload["harness_id"] = t.Lease.HarnessID + payload["lease_epoch"] = t.Lease.Epoch +} + func blockTask(s *store.Store, t domain.Task, reason domain.BlockReason, blocker string, req *domain.DecisionRequest) (domain.Event, error) { payload := map[string]any{ "blocker": blocker, "block_reason": string(reason), "lifecycle_phase": "awaiting_human", } - if t.Lease != nil { - // Store.Append fences every lifecycle event on a leased task against - // the current owner and epoch. A question from a session that no - // longer owns the task is a conflict, not a block. - payload["harness_id"] = t.Lease.HarnessID - payload["lease_epoch"] = t.Lease.Epoch - } + fenceToLease(payload, t) if req != nil { payload["decision_request"] = req } diff --git a/internal/operations/planmismatch.go b/internal/operations/planmismatch.go index 7e795d7..db413e0 100644 --- a/internal/operations/planmismatch.go +++ b/internal/operations/planmismatch.go @@ -154,11 +154,13 @@ func blockForPlanMismatch(s *store.Store, taskID string, m domain.PlanMismatch) packet += "- evidence: " + oneLine(e) + "\n" } packet += "\nReply to say how to proceed. Your reply becomes a recorded decision and outranks the plan. If it resolves the contradiction, the task resumes on the same plan; say so explicitly if you want the plan rewritten instead.\n" - b, err := json.Marshal(map[string]any{ + payload := map[string]any{ "blocker": packet, "block_reason": string(domain.BlockReasonPlanMismatch), "lifecycle_phase": "awaiting_human", - }) + } + fenceToLease(payload, t) + b, err := json.Marshal(payload) if err != nil { return err } diff --git a/internal/operations/planprogress_test.go b/internal/operations/planprogress_test.go index 7433285..3a41746 100644 --- a/internal/operations/planprogress_test.go +++ b/internal/operations/planprogress_test.go @@ -28,6 +28,11 @@ func planProject() registry.Project { func planWith(t *testing.T, markdown string) (*store.Store, registry.Project, string) { t.Helper() s, id := phaseStore(t) + // Leased, because everything these tests drive comes from a live implement + // session. Skipping it hid F65: two coordinator-side stops omitted the + // fencing fields Store.Append requires on a leased task, and every test + // passed because no test ever leased one. + lease(t, s, id) project := planProject() if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil { t.Fatal(err) diff --git a/internal/operations/trajectory.go b/internal/operations/trajectory.go index 835518c..0086b6d 100644 --- a/internal/operations/trajectory.go +++ b/internal/operations/trajectory.go @@ -67,11 +67,13 @@ func raiseTrajectoryGate(s *store.Store, t domain.Task, from, to domain.WorkPhas if err != nil { return err } - b, err := json.Marshal(map[string]any{ + payload := map[string]any{ "blocker": packet, "block_reason": string(domain.BlockReasonTrajectoryGate), "lifecycle_phase": "awaiting_human", - }) + } + fenceToLease(payload, t) + b, err := json.Marshal(payload) if err != nil { return err }