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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-29 20:07:23 +04:00
parent bdc0d4d5be
commit de18f372d3
4 changed files with 29 additions and 11 deletions
+16 -7
View File
@@ -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
}