Heart wrapped-CLI lane — design of record (2026-10-21)
Heart wrapped-CLI lane — design of record (2026-10-21)
Owner: Sol. Lane: engineering. Status: implemented (see §7).
Card: t_9f7477ee — "Heart wrapped-CLI lane: build the plugin that lets Heart agents claim board cards".
Evidence base: Docs/Fleet/05-operations/hermes-native-fleet-system-2026-10-20.md §1 correction
(Echo's pass) — a wrapped-CLI lane is "not yet a paved path" upstream; PR #19924 closed unmerged,
issue #19931 open.
1. The defect, measured (not inferred)
ace is a Heart-plane agent (A2A listener on 127.0.0.1:8301), not a Hermes profile on LAB.
A card assigned to ace therefore lands in the dispatcher's skipped_nonspawnable bucket and
nothing ever claims it.
The second half of the defect is worse, and it is the one that made the board spin. The V3
board-projection bridge writes a card's status from the V3 task store. For a card the gateway
does not own, that write lands running with no claim_lock — and the Hermes reconciler
(reconcile_orphaned_running, hermes_cli/kanban_db_dispatch.py:845) reads exactly that shape:
WHERE status = 'running' AND (claim_lock IS NULL OR claim_expires IS NULL)
and requeues it to ready in the same second. The two writers then fight forever.
Measured on the live docker-vhost board, card t_8d7bde82 (assignee ace):
| Event kind | Count on the board | Per this card |
|---|---|---|
state (v3 projection -> running) | 183 | 183 |
reconciled (reason: orphaned_running) | 183 | 183 |
skipped_nonspawnable | 184 | 184 |
Every cycle is: projection writes running → reconciler sees no claim_lock → requeues to ready
→ projection writes running again. The card never progresses and the board accumulates garbage
indefinitely. This is the "control at the wrong choke point" defect in its purest form: the
projection owns a state but not the claim that makes the state mean "a worker holds this".
The fix is not in the projection. A projection that writes running without a claim is
asserting a fact it does not own. The fix is that a lane runner holds the claim while it
works, so running and claim_lock are written by the same owner at the same time.
2. Why the lane is PULL-based, not push-based
The dispatcher's spawn_fn is a parameter of dispatch_once
(hermes_cli/kanban_db_dispatch.py:1977), and the gateway calls
dispatch_once(conn, board=slug, **kwargs) with no spawn_fn
(gateway/kanban_watchers_dispatcher.py:196). No plugin surface can inject one — verified by
grepping every spawn_fn reference in the tree: the only non-test call sites are the parameter
and _call_spawn_fn. So a "plugin that registers a spawn function for ace" is not buildable
without patching core, which plugins/AGENTS.md forbids ("Plugins never touch core").
The lane is therefore pull-based, and the kernel already has the primitive:
kanban_db.claim_task(conn, task_id, claimer=...) is an atomic ready → running CAS that
writes claim_lock and claim_expires in the same transaction (_claim_and_open_run,
hermes_cli/kanban_db.py:2304). Exactly one claimer wins the CAS; the loser gets None.
This is the same shape the kernel already documents for control-plane lanes ("Control-plane lanes
(e.g. orion-cc) are pulled by terminals via claim_task and never spawnable" —
gateway/kanban_watchers_dispatcher.py:213). The Heart lane is one of those lanes, made real.
Consequence: the lane needs a runner process, not a dispatcher hook. The runner is the plugin's payload.
3. The shape
┌─────────────────────────── Hermes kernel (single writer) ───────────────────────────┐
│ │
board card │ claim_task() ──► status=running, claim_lock=HEART:<lane>:<uuid>, │
assignee=ace ───►│ claim_expires=now+TTL, run opened │
status=ready │ │
│ heartbeat_claim() ──► claim_expires extended (lane is alive) │
│ │
│ complete_task() ──► status=done (exit 0) │
│ block_task() ──► status=blocked (exit != 0, with reason) │
└─────────────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ claim / heartbeat │ terminal transition
┌─────┴──────────────────────────┴─────┐
│ heart-lane runner (this plugin) │
│ ── runs the wrapped CLI ── │
│ ── maps exit code → transition ── │
└──────────────────────────────────────┘
4. The three mappings the card asks for
The card names three obligations. Each gets exactly one implementation site, and each is testable without a live model.
4.1 Exit codes → kanban_complete / kanban_block
| Runner outcome | Exit code | Kernel transition |
|---|---|---|
| CLI succeeded | 0 | complete_task(result=…, summary=…) → done |
| CLI failed, retryable | 2 | block_task(reason=…, kind="transient") → blocked |
| CLI failed, not retryable | 3 | block_task(reason=…, kind="capability") → blocked |
| CLI ran but the contract was unmet (no output / bad format) | 4 | block_task(reason=…, kind="needs_input") → blocked |
| Runner itself failed (could not start the CLI) | 5 | block_task(reason=…, kind="capability") → blocked |
The rule that matters: a failure is never a silent requeue. The kernel's
block_task is the only failure path, so the card lands in blocked with the reason attached —
a human (or a later unblock) can see why. The old behaviour — write running, get reconciled,
requeue to ready — is exactly the silent requeue the card forbids.
The runner distinguishes retryable from not by the CLI's own classification, and defaults to
not retryable (capability) when it cannot tell. A runner that guesses "retryable" loops; one
that guesses "not retryable" asks a human. Guessing wrong in the retryable direction is the
expensive direction.
4.2 Workspace conventions → HERMES_KANBAN_WORKSPACE
The CLI does not know Hermes. It needs a directory to work in. The mapping:
- The runner resolves the card's workspace through the kernel's own resolver,
kanban_db_workspace.resolve_workspace(task, board=board)— not a path it constructs. The kernel's resolver enforces the absolute-path guard fordirworkspaces, creates the directory, and knows the per-boardworkspaces_root. A second path builder would be a second owner of a fact the kernel already holds. - The runner
cds into that directory and exportsHERMES_KANBAN_WORKSPACE(the same value) so the CLI's own tooling can see it. - The CLI's own sandbox convention (opencode: "writes outside the workdir auto-reject, rc=1") is
satisfied by running with
cwd= the workspace, so the CLI enforces the same boundary the kernel recorded.
4.3 Heartbeats → the reconciler does not requeue a live worker
This is the acceptance criterion that the 183-cycle fight fails today.
- The runner calls
heartbeat_claim(conn, task_id, claimer=…)on a fixed interval, well inside the claim TTL.heartbeat_claimextendsclaim_expiresonly whenstatus='running' AND claim_lock = <ours>— so a heartbeat from a runner that has lost its claim is refused, and the runner learns it has lost the card. - The claim lock is
HEART:<lane>:<uuid>— it starts with the lane's own prefix, not the host prefix. This matters:release_stale_claimsonly reclaims host-local locks (hermes_cli/kanban_db.py:2528), anddetect_crashed_workersonly reclaims host-local PIDs. A Heart lane running as a different process tree on the same host would otherwise be reaped by the host-local crash detector the moment its PID check fails.
Correction to my own first draft: the lane runner runs on the same host as the kernel (LAB), so a host-prefixed lock would work. But the lane is a different process whose PID is not the dispatcher's worker PID, anddetect_crashed_workerslooks attasks.worker_pid. The runner must therefore not publish its PID intoworker_pid; the lock prefix keeps the card out of the host-local reclaim path entirely, and the heartbeat is the liveness signal.
This is the honest trade: the lane's liveness is the heartbeat, not the PID. A lane whose process dies without heartbeating is reclaimed when the claim TTL expires (15 min default), which is the kernel's own floor and is correct.
5. Why this stops the projection fight
The projection's write is _write_projection (operation/ops/dispatcher.py:924): it updates
status, result, completed_at — and nothing else. It never writes claim_lock.
The fix is a guard in the projection, not a rewrite of it. The projection must refuse to write
running for a card it does not own the claim of. Concretely, the projection's write is skipped
when the target status is running and the card has no live claim held by the projector.
That is a one-line change at the choke point, and it is the change that makes the whole thing
coherent: after the lane lands, the projection writes done/blocked (both of which release the
claim legitimately) and never writes a bare running for a lane-owned card.
Measured proof of the before-state: 183 cycles. The after-state is §7's live proof.
6. Configuration surface
kanban:
pull_lane_assignees: [ace, alpha, atlas] # the NAMED lanes this host runs
Already landed in hermes-agent commit 8071418c8d (the naming half). This build adds the
runner half.
Plugin settings (in ~/.hermes/plugins/heart-lane/):
| Key | Default | Meaning |
|---|---|---|
lanes | [] | lane name → CLI spec. {"ace": {"command": [...], "cwd": ...}} |
poll_interval_seconds | 30 | how often the runner looks for claimable work |
heartbeat_interval_seconds | 60 | heartbeat cadence (must be < claim TTL) |
claim_ttl_seconds | 900 | claim TTL requested at claim time |
model | "" | model id passed to the CLI; empty = the CLI's own default |
Fail-closed: a lane with no command configured is refused at startup, not silently
skipped. A lane that starts and then cannot run is a lane that eats claims and blocks cards.
7. What was built, and how it was proven
(filled in by the implementation — see the completion handoff and the commit.)
8. Honest limits
- The lane runs on the same host as the kernel. Kanban is single-host by design
(
kanban-worker-lanes.md), so a Heart agent on another host cannot pull from this board. The Pulse plane already solves this with its own board + dispatcher; the Heart plane is on LAB. - The CLI's model selection is the CLI's business. The lane passes a model id when configured and does not otherwise govern cost. The free-first ladder is the V3 executor's property, not the lane's.
- The lane does not implement the review hand-off. A lane that finishes says
done; routing a lane card into the review column is a separate decision for Min. - The
skipped_nonspawnableevent is not suppressed for a named pull lane. The dispatcher still records it; the difference is that a lane runner now claims the card. Suppressing the event would hide the fact that the gateway cannot spawn this lane — which is true and worth seeing.
GO/ZEN model tracking — the consolidated sheet (2026-10-20)
One file now; supersedes Mira's go-zen-model-tracking-2026-10-18.md (archived pointer stays). This version reconciles two probe lanes that initially appeared to disagree, and that reconciliation is itself a finding — read §1 first.
Hermes-native fleet system (2026-10-20) — with Echo's collab pass
Sources = the 7 official docs Young dropped. The core finding: the missing fleet mechanism Min needs (a dispatcher that agents report to + heartbeat polling) ALREADY EXISTS in Hermes itself — it is hermes kanban + worker lanes + heartbeats. Our fleet-board.md (a hand-rolled 2026-10-20 markdown file) is at version zero of a system that Hermes ships natively. The upgrade = stop pushing markdown, adopt the native one.

