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 }