2 Commits

Author SHA1 Message Date
kami de18f372d3 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
2026-08-29 20:07:23 +04:00
kami bdc0d4d5be Say that a coordinator deploy leaves the console behind
The ethos console sat undeployed for hours while two coordinator deploys
went out, because both used --no-deps orchestra-api and the web image is
built separately. Checking the commit does not catch it; checking the
served bundle does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:02:29 +04:00
5 changed files with 37 additions and 11 deletions
+8
View File
@@ -66,6 +66,14 @@ the live herdr instance and check.
`docker logs orchestra-api`. **Deploying a code change means rebuilding the
compose images** (`up -d --build`) — the running image can silently predate
recent commits, so compare its build time against `git log`.
- **A coordinator deploy does not deploy the console.** They are two images
built from the same repo, and the usual `up -d --no-deps orchestra-api`
leaves `orchestra-web-ui` on whatever it was. This has bitten twice: the
ethos console landed in the repo on 2026-08-29 02:44 and was still serving a
2026-07-30 image hours later, through two coordinator deploys. Rebuild it
explicitly from the same clean worktree (`docker build` in `web/`, then
`up -d --no-deps orchestra-web-ui`), and check the served bundle rather than
the commit: `curl -s http://127.0.0.1:19145/assets/<css> | grep 8F7AE5`.
- `orchestra.service` was the **previous** deployment; the unit file was
deleted from `deploy/` on 2026-07-31 along with `redeploy.sh` (which
`sudo install`ed to `/usr/local/bin` and restarted it). If a stale copy is
+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
}
+4 -2
View File
@@ -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
}
+5
View File
@@ -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)
+4 -2
View File
@@ -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
}