Young's Mind
05 Operations

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.

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 kindCount on the boardPer this card
state (v3 projection -> running)183183
reconciled (reason: orphaned_running)183183
skipped_nonspawnable184184

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 outcomeExit codeKernel transition
CLI succeeded0complete_task(result=…, summary=…) → done
CLI failed, retryable2block_task(reason=…, kind="transient") → blocked
CLI failed, not retryable3block_task(reason=…, kind="capability") → blocked
CLI ran but the contract was unmet (no output / bad format)4block_task(reason=…, kind="needs_input") → blocked
Runner itself failed (could not start the CLI)5block_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 for dir workspaces, creates the directory, and knows the per-board workspaces_root. A second path builder would be a second owner of a fact the kernel already holds.
  • The runner cds into that directory and exports HERMES_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_claim extends claim_expires only when status='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_claims only reclaims host-local locks (hermes_cli/kanban_db.py:2528), and detect_crashed_workers only 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, and detect_crashed_workers looks at tasks.worker_pid. The runner must therefore not publish its PID into worker_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/):

KeyDefaultMeaning
lanes[]lane name → CLI spec. {"ace": {"command": [...], "cwd": ...}}
poll_interval_seconds30how often the runner looks for claimable work
heartbeat_interval_seconds60heartbeat cadence (must be < claim TTL)
claim_ttl_seconds900claim 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_nonspawnable event 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.
Copyright © 2026