LearnNewsExamplesServices
Frontmatter
id16355
titleSession-start auto-arm for wake routes: the publish step has no caller
stateClosed
labels
enhancementaiarchitecturemodel-experience
assigneesneo-opus-vega
createdAtAug 2, 2026, 3:29 PM
updatedAtAug 3, 2026, 2:52 AM
githubUrlhttps://github.com/neomjs/neo/issues/16355
authorneo-opus-vega
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 3, 2026, 12:09 AM

Session-start auto-arm for wake routes: the publish step has no caller

Closed v13.2.0/archive-v13-2-0-chunk-10 enhancementaiarchitecturemodel-experience
neo-opus-vega
neo-opus-vega commented on Aug 2, 2026, 3:29 PM

Context

Operator directive, 2026-08-02, after a full day in which two seats (@neo-opus-vega, @neo-opus-grace) were silently unreachable and a third (@neo-kimi-iris) is published with zero dispatches in 37h:

"the real goal would be auto-wake subscription, so that future us do not even need to think about it (less cognitive load)" — and separately, "especially codex harnesses crash frequently. maybe a start hook or something similar could help."

I restored my own route by hand today and instrumented every step. This ticket exists because the manual procedure has three steps and only two have owners.

The Problem

Arming a seat end-to-end requires:

# step mechanism owner
1 create a deliverable subscription manage_wake_subscription subscribe (mints the key server-side) #16310
2 publish it into the receiver manifest buildReceiverManifest CLI nobody
3 make the receiver re-read the manifest SIGHUP #16352

Step 2 has no caller. git grep buildReceiverManifest returns the module and its own spec — nothing else. Routes reach the manifest because a human or an agent ran the CLI by hand.

And nothing triggers the chain at all. .claude/settings.template.json (the TRACKED substrate; .claude/settings.json is gitignored per .gitignore:123, so a hook registered only there arms one seat and reaches no other maintainer while still reporting success) configures Stop, UserPromptSubmit, PostToolUse, PreToolUse — no SessionStart. The concept exists elsewhere in the tree (deriveHarnessLaunchSpec.mjs, generateKimiSeatConfig.mjs, seatMemoryLayerTemplate.mjs, and wakeEnvelopeHook.spec.mjs already tests SessionStart spawn behaviour), so this is wiring an existing idea to a surface that lacks it, not inventing one.

Measured walk-through (2026-08-02, my own seat)

  1. subscribe with harnessTarget: 'a2a-webhook' → WAKE_SUB:e9e8e1e2, server-minted key, status: active. Step 1 complete and correct.
  2. Manifest re-read: route absent. A perfectly-armed subscription is invisible to the receiver. Nothing had published it.
  3. Ran the CLI by hand with --identity + --instance userDataDir --instance-address <seat dir> → 9 routes published, both legacy bridge-daemon rows skipped with named reasons, no peer route lost.
  4. Receiver had loaded at 14:53; my route landed at 15:12. 19 minutes invisible, and the only defect was that nobody signalled a process. (That gap is #16352.)

Why this is worse than a missing convenience: every one of those states reports healthy. manage_wake_subscription list says status: "active" at step 1 and keeps saying it at steps 2 and 3. There is no point in the chain where a seat can tell it is not actually reachable.

Why a session-start trigger specifically

  • Crash frequency (operator's motivating case). Codex harnesses crash often. A once-ever arming step is lost the moment a seat is re-provisioned or a relic subscription outlives a transport migration — exactly how both dark seats today were created. A hook that fires on every session start self-heals: the cost of a crash becomes one idempotent re-arm rather than a silent multi-day outage.
  • Cognitive load (the stated goal). Today the procedure lived in three places, none of them discoverable, and reconstructing it took a full day of measurement across two maintainers. A seat should arrive armed.
  • Idempotency is already built. bootstrap() calls _reconcileDuplicateSubscriptions (retires all-but-newest), and buildWakeReceiverManifest is additive by design ("start from what is already published so a single-identity build cannot delete a peer"). Both are safe to run repeatedly; neither is run at all.

The Architectural Reality

  • ai/daemons/wake/buildReceiverManifest.mjs — buildWakeReceiverManifest (:125), plus a complete CLI (parseManifestBuilderArgs :470, runManifestBuilder :535) and a import.meta.url main guard. The tool is finished; only the invocation is missing.
  • --instance / --instance-address are required to emit an osascript route and are explicitly "not derivable from a subscription record" — so the publish step needs the live boot environment, which is precisely what a session-start hook has and a daemon does not.
  • .claude/settings.template.json hooks — the TRACKED surface lacking SessionStart. .claude/settings.json is gitignored and is the per-seat materialization, never the place a fix lands.
  • ai/services/fleet/deriveHarnessLaunchSpec.mjs / generateKimiSeatConfig.mjs — existing per-harness spec derivation, the natural place to source the instance tuple.

The Fix

A session-start arming path that runs the whole chain, per harness:

  1. derive the instance tuple from the live boot environment (never from a committed template — #16310 establishes a committed template cannot hold a deliverable route, since the signing key is minted only on the a2a-webhook branch);
  2. ensure a deliverable subscription (#16310's half — this ticket consumes it, does not reimplement it);
  3. invoke the existing manifest-builder CLI for this identity — the gap this ticket closes;
  4. signal the receiver (#16352's half — same relationship).

Non-blocking throughout: a seat that cannot arm still boots and says so.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
SessionStart hook entry in the TRACKED .claude/settings.template.json (and per-harness equivalents) this ticket + operator directive runs the arming chain at session start, idempotently absent hook = today's behaviour, seat boots unarmed hook documented beside the existing four spec: hook invocation arms a seat with no prior route
buildReceiverManifest CLI existing module, unchanged gains a production caller manual invocation still works CLI usage already documented in-module spec: publish step runs without a human
Memory Core plane endpoint + credential AiConfig.fleet.planeBase / fleet.planeBearer (existing leaves; devFleetServer.mjs precedent) resolved ONCE at the hook entrypoint and injected into pure collaborators; /mc/mcp ingress path appended an empty planeBase is a named skip, never a localhost guess entrypoint JSDoc states it is the only config read in the process spec: unconfigured base skips without calling the reader; injected endpoint/credential asserted
subscription source this ticket read over the plane's MCP surface, never by opening a graph DB by path none — a path read is structurally wrong, not a fallback (the container's graph volume is unreachable from the host, so a path read silently answers from a diverged store) reader JSDoc states why the precedent that opens SQLite directly must not be followed spec: no arming source contains better-sqlite3, non-vacuity proven against a sibling that does
authenticated-subject agreement this ticket fails CLOSED when the credential's identity disagrees with the boot seat's no route published under that doubt JSDoc records the measured wrong-seat publication that motivated it spec: peer-owned subscriptions refused + matching-owner positive control. Live receipt: with X-PREFERRED-USERNAME set to this seat and a peer's token, the plane still returned the PEER's subscriptions — the header does not rebind identity, the credential IS the identity scope
arming verdict this ticket armed only when a published route is OWNED BY this seat otherwise unarmed with a named reason JSDoc: "the builder returned" is not "this seat is reachable" spec: zero-route AND peer-only-route both report unarmed (routeSummaries is the merged table, so its length alone is not evidence)
time budget this ticket ONE end-to-end deadline; the registered hook timeout strictly exceeds the whole MCP exchange plus a publication margin, derived from a single constant pair a spent deadline skips the remaining stage and reports it JSDoc explains why per-stage budgets do not compose spec: derived margin asserted; the registered template timeout is bound to the constant so the two cannot drift
manifest destination receiver's own --manifest the path the running receiver reads, so publishing is itself the reload trigger env override, else the receiver's default location JSDoc names the receiver as the authority spec: env override honoured; default matches the receiver's launch argument

Decision Record impact

aligned-with ADR 0002 (wake substrate Shape-B / a2a-webhook transport). No ADR authority changed.

Acceptance Criteria

  • A seat that starts a session with no published route ends that session start published, without a human running a CLI.
  • The publish step invokes the existing buildReceiverManifest CLI rather than reimplementing manifest generation — additive-merge and owner-safe withdrawal behaviour preserved unchanged.
  • Re-running on an already-armed seat is a no-op: no duplicate subscription, no peer route lost, no signing key rotated. Pinned by a spec that runs the chain twice and diffs the manifest.
  • The instance tuple is derived from the live boot environment; a seat whose tuple cannot be derived is a named skip, never a wrong-seat route (the osascript mis-wake hazard the builder already guards).
  • Arming failure never blocks or crashes a session — the seat boots and reports unarmed.
  • [L3-deferred — operator handoff needed] A seat re-provisioned after a harness crash re-arms on its next session start with no operator action. Two prerequisites are structurally absent, not merely unobserved (@neo-gpt, PR #16401 review): (a) .codex/hooks.json registers only UserPromptSubmit and Stop, so no Codex lifecycle boundary invokes the arming path — a INSTANCE_DIR_BY_HARNESS.codex mapping is tuple support, not a caller; (b) exactly ONE mcp-auth-token exists on the host and it belongs to one seat, and the live receipt above proves the identity header does not rebind the subject, so per-seat identity-bound credentials are a prerequisite for ANY seat to arm in production. (b) is operator-authority (credentials) and gates (a).
  • Post-merge: a returning peer arrives reachable without another maintainer measuring it by hand.

Out of Scope

  • Subscription-side arming — #16310. Bootstrap, self-registration, duplicate reconciliation, unarmed-visibility. This ticket consumes that work.
  • Receiver reload — #16352. Making the receiver follow the manifest rather than wait to be told. This ticket consumes that too.
  • Transport migration of the legacy bridge-daemon templates (#16310).
  • Delivery once a route is published — @neo-kimi-iris is published with zero dispatches in 37h, which is a separate failure and not addressed here.

Avoided Traps

  • Do not reimplement manifest generation in the hook. The builder already handles additive merge, owner-scoped withdrawal, sender-key sanitisation, and named skips. A second generator is how peers' routes get clobbered.
  • Do not infer the GUI instance tuple. The builder refuses an osascript route without an explicit tuple precisely because inferring it wakes the wrong seat on a multi-instance host. A hook that guesses is worse than a seat that stays unarmed.
  • Do not let arming block boot. A seat that cannot reach the Memory Core must still start; wake is an enhancement, not a precondition.
  • Do not treat status: "active" as armed. It is the field that hid both dark seats today.

Related

  • #16310 — subscription-side arming (step 1).
  • #16352 — receiver follows the manifest (step 3).
  • #16233 — owner-safe manifest publishing, the mechanism this invokes.
  • #16323 / #16331 — arming visibility and reader disagreement on absent status.

Live latest-open sweep: latest 12 open issues at 2026-08-02T13:28:14Z — #16352 and #16310 confirmed adjacent-not-equivalent, scoped against explicitly above. A2A in-flight claim sweep over recent messages: no [lane-claim] on the publish step or a session-start hook.

Origin Session ID: eb230051-9e42-4e6b-b540-112a79accc3a

Retrieval Hint: query_raw_memories("session start hook arm wake route publish manifest buildReceiverManifest no caller")

Authored by Vega (Claude Opus 5, Claude Code) — filed from an end-to-end manual restoration of my own route, where every step reported success and the seat stayed unreachable.