Skip to content

feat(reconcile): wake the lead when ready work has no actor - #63

Merged
LeTuR merged 1 commit into
mainfrom
feat/notify-on-ready
Sep 10, 2026
Merged

feat(reconcile): wake the lead when ready work has no actor#63
LeTuR merged 1 commit into
mainfrom
feat/notify-on-ready

Conversation

@LeTuR

@LeTuR LeTuR commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Intent

Close the gap where a fleet task that becomes ready has no actor and the lead is never woken. Measured failure: in orchestration/reconcile/reconcile.log, forge-agnostic/01-01-forge-seam landed at 2026-09-10T01:37:16 (PR #62 merged), clearing the semantic-dependency blocker on forge-agnostic/02-02-gitlab-adapter; that task was only dispatched at 08:05:07, because the operator typed 'Status' and the lead looked. Six hours 28 minutes ready and unclaimed.

This is NOT a bug in the reconciler and must not be 'fixed' by giving it the power to act. AGENTS.md states the loop reconciles and does not decide; scripts/reconcile-selftest.sh asserts the queue verbs it calls. The lead is the only actor that may dispatch, and it is an interactive session that acts when spoken to. reconcile.sh nudge wakes the LOOP; nothing went the other way.

Deliberate design decisions a reviewer reading only the diff would not know:

  1. HARD CONSTRAINT, from the task brief: the reconciler must never gain the power to dispatch. Notifying is allowed precisely because it changes nothing. If any part of this ends with the loop calling dispatch, it is wrong. Do not suggest that.
  2. The four-verb assertion in reconcile-selftest.sh is load-bearing and the brief forbade simply deleting it. The brief offered two options: extend the asserted set to five and state in the selftest WHY plan is admissible, or get the ready set without a fifth verb if watch/collect output already carries it. I checked: collect only prints the hint string 'Run — a blocker clears when the task it names LANDS' (scripts/lib/queue.py, in reap()); it does not carry the ready set, and neither does watch or shepherd. Also, a task can become ready without any landing (a freshly added task with no blockers), which collect could never report. So option one was taken deliberately: the asserted set is widened to five and the selftest now argues in place that the list is a list of things the loop is ALLOWED TO WANT, that plan is a pure read, and that dispatch/add/block/archive/reap change what runs and may never join it.
  3. Fires on the TRANSITION, not on every pass — a loop that messages the lead every 20s while a task sits ready is worse than silence because the operator turns it off. Delivered refs are remembered and the remembered set is pruned to the intersection with the current ready set each pass, so a task that leaves and re-enters the ready set is news again while a task that simply stays ready is not.
  4. Does not wake a working lead. AT_REST is an allowlist of exactly 'idle' and 'done'. 'blocked' is deliberately excluded because text sent to a session sitting on a permission dialog ANSWERS the dialog rather than starting a turn; 'running'/'uncovered'/'unreported' are excluded because per .agents/skills/thurbox-session/SKILL.md they are not the agent saying it is at rest. shepherd already declines to touch a working session for the same reason.
  5. A wake that cannot be delivered WAITS rather than being dropped: the delivered set is only recorded on a successful send, so the next pass retries. That was a deliberate choice over the brief's alternative of 'skip and catch it on the next transition', which could lose the wake permanently.
  6. The loop still writes no record. What-was-told is held in orchestration/reconcile/notified.json — the reconciler's own runtime directory, beside its pid, heartbeat and flags — never on task.yaml, because writing it onto a task would make the loop a second writer over the queue. AGENTS.md and reconcile.sh's header were both updated to say 'writes no record' rather than 'writes nothing', since the runtime directory is now explicitly named.
  7. thurbox-cli session send is used, not message send. POLICY.md forbids WORKERS the mailbox because it interrupts whoever is talking to the lead; this is the case that rule leaves open, and it only ever goes to a lead that has said it is at rest.
  8. notify_lead.py loads scripts/lib/queue.py through importlib under the alias fleet_queue — the same pattern scripts/lib/fleet_status.py already uses — so 'what is the lead session called' has ONE parser (manifest_session) rather than a second copy that agrees until someone renames the session. The alias avoids shadowing the stdlib 'queue' module.
  9. FLEET_LEAD_SESSION exists because the lead's name normally comes from the RENDERED extension.toml, which belongs to the operator's own checkout and is not something the gate may write; the selftest needs an override.
  10. It runs on collect's clock and gets no clock of its own: what makes a task ready is a blocker clearing, and what clears a blocker is a landing collect just recorded.
  11. It can never fail the pass — every branch of the notifier exits 0 and notify_lead() returns 0 regardless. A message is not worth losing the collect the loop just did.
  12. Log notes are deduplicated against the last one, so an absent or busy lead costs the log one line rather than one per pass.

Also asked for by the brief: FLEET.md must tell the lead that landing a task is not the end of the work but what unblocks the next, and that queue.sh plan is checked before going quiet — one or two sentences, not an essay. That is the new paragraph after step 8 of '## The loop'. The brief noted voice-doom/01 recently rewrote FLEET.md's '## How you report', so this branch was rebased on main first and the sentence placed where the current structure wants it.

Method: the repo's own test-first rule was followed — the eighth promise and its assertions were added to scripts/reconcile-selftest.sh and confirmed to fail for the right reason (no plan call, no sends) before scripts/lib/notify_lead.py existed. Verified with ./scripts/check.sh (all checks green, exit 0) and the lead-resolution path was confirmed read-only against the live control plane at /home/magicletur/fleet (manifest names the session, session list resolves it to a live id and state) without ever sending to the operator's real Mission Control session.

Documentation touched deliberately and should not be flagged as scope creep: AGENTS.md (runtime dir contents, the four->five verb claim, a new load-bearing bullet), FLEET.md (the two sentences the brief asked for, plus a note in '## What you are not' so an unprompted line in the lead's terminal is explained rather than read as a bug), scripts/check.sh comments, and the fleet-queue, fleet-onboarding and update-fleet skills, each of which made a claim this change makes stale.

What Changed

  • Adds scripts/lib/notify_lead.py, loading scripts/lib/queue.py via importlib (alias fleet_queue, matching fleet_status.py's pattern) to resolve the lead session's name and send it one line via thurbox-cli session send when the ready set grows on a transition — never into a lead that isn't idle/done, and never dropping a wake that couldn't be delivered (only records "told" on a successful send).
  • scripts/reconcile.sh adds a notify_lead() step that runs after collect (on its clock, not a new one), calling queue.sh plan --json and piping it to notify_lead.py --state-dir "$RT"; every branch exits 0 so a failed notification never fails the pass. Adds FLEET_LEAD_SESSION env override and expands the header/usage docs (including the notify cadence entry) to describe the new behavior.
  • scripts/reconcile-selftest.sh widens its asserted reconciler-verb set from four to five (watch, collect, shepherd, refuel, plan) and adds a test asserting notify_lead fires once per ready-set transition, not on every pass; scripts/check.sh updates its reconciler check comment/message to match.
  • Updates AGENTS.md and FLEET.md to describe the orchestration/reconcile/notified.json runtime file, the five-verb reconciler contract, and that the loop now wakes the lead (a read-only notification, not a dispatch decision); also touches the fleet-onboarding, fleet-queue, and update-fleet skill docs and FLEET.md's ## What you are not section to keep their claims about the reconciler consistent with the new behavior.

Risk Assessment

✅ Low: The change adds a notify-only capability (reconcile.sh -> notify_lead.py) that strictly reads the queue's plan and types into the lead's session; it never dispatches, never writes a queue record, and every claimed behavior (transition-only firing, pruning on re-entry, AT_REST allowlist excluding blocked/working, retry-until-delivered semantics, dedup of log notes, never failing the pass) is both implemented correctly (traced through concrete state sequences) and covered by real behavioral tests in reconcile-selftest.sh test 8, which drives the actual tick loop against stubbed queue.sh/thurbox-cli rather than asserting on source text. Documentation (AGENTS.md, FLEET.md, three skill files, check.sh) was updated consistently with the code and with each other, and shellcheck/py_compile are clean.</risk_rationale>

Testing

Baseline ./scripts/check.sh already passed; on top of that I ran the focused scripts/reconcile-selftest.sh suite, which exercises the notify-on-ready behavior end-to-end through a stubbed queue and stubbed thurbox-cli (no real thurbox/network needed) — all 49 assertions pass at the target commit, and I additionally verified the new promise-8 assertions fail for the correct reason at the pre-fix base commit, giving genuine before/after regression evidence. No issues found; worktree left clean.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • ./scripts/check.sh
  • ./scripts/reconcile-selftest.sh (full run at target commit 9929dac — all 49 assertions pass, including the new promise-8 block: transition-only wake, one-line message naming ready tasks and the dispatch command, silence on repeated passes, growing ready set as fresh transition, a working lead not interrupted and the wake held rather than dropped, a held wake landing once the lead returns to idle, no-lead-session producing no send/no error, and the five-verb (watch/collect/shepherd/refuel/plan) — never dispatch — assertion)
  • ./scripts/reconcile-selftest.sh run against base commit bd8aca623cfa8c64bf9a077dbc3cee35632df72b in an isolated temp worktree with the new test script copied over, to confirm the new assertions fail for the right reason before the fix (no plan verb called, no wake delivered) and pass after it
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

A task whose blocker clears becomes ready and nothing dispatches it. The
reconciler may not — it reconciles and does not decide — and the lead, which
may, is an interactive session that acts when spoken to. On 2026-09-10 a
blocker cleared at 01:37 and the task went out at 08:05, because the operator
typed "Status".

So the pass after `collect` reads `plan`, and when the ready set has grown it
types one line into the lead's terminal: which tasks are ready, and the command
that sends them. Notifying is not deciding — nothing moves, and the choice is
still the lead's.

Three rules keep it from being worse than the silence it replaces, and
`scripts/lib/notify_lead.py` owns all three: it fires on the transition and not
on the pass, it refuses to type into a lead that is not at rest (the wake waits
rather than being dropped), and it is silent when no lead session is running.
What has been told is held in the reconciler's own runtime directory, so the
loop still writes no record.

`plan` makes five verbs where the selftest asserted four. The assertion is
widened deliberately and argues in place why: it lists what the loop may WANT,
and `plan` is a read that moves nothing, while `dispatch` and its neighbours
change what runs and never may join it.

Claude-Session: https://claude.ai/code/session_01Jff2KWdWmVMhCFAdZS6iRP
@LeTuR
LeTuR merged commit 23fbdb7 into main Sep 10, 2026
10 checks passed
@LeTuR
LeTuR deleted the feat/notify-on-ready branch September 10, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant