Repository navigation
design agent dispatch
Issue #67 (P2 of the dispatch design discussed on #66; #66's viewer grace and control-verb gate are its substrate). Shipped 2026-10-19. The platform assistant can now DRIVE a client agent on one of the owner's machines: async dispatch → poll → native-session continuation.
-
Async-first — the sub-agent invocation reference: a SYNC dispatch would
block the whole assistant turn behind the per-user busy gate for minutes
(the user could only stare at an in-progress tool card).
agent_dispatchreturns a handle immediately;agent_dispatch_pollfetches the outcome (waitMsblocks up to 60 s for a TERMINAL transition — running→running blips do not wake the wait; the E2E showed why: a wake-on-any-change poll returned early withrunningand forced extra rounds). -
Session continuation is the difference from one-shot sub-agents — the
driven agent's session is its OWN (W7): every poll result carries
nativeSessionId+cwd, and a follow-up dispatch passes them back asresume {sessionId, cwd}(the existing chat resume arm). Cross-turn state lives in the ASSISTANT TRANSCRIPT (tool results persist there); the server keeps only a bounded in-flight record map (64, FIFO). - The transcript-as-memory consequence: an evicted record is not a loss — the handle in an earlier tool result still names the native session.
-
open(..., openerSocketId: string | null, ...): a NULL opener opens a dispatch channel — nothing is joined to the channel room; the in-process consumer observes via the tap. The open result now carries the resolvedcwd(the continuation handle needs it). -
tap(sessionId, cb)— per-channel observers receiving{kind:'ready', nativeSessionId?}/{kind:'event', event}/{kind:'closed', reason, failed, error?}. Emission sites:onReady(after the session fields land), the shared relay point inonStream(tap order === room order by construction), andcloseInternal(exactly-once, then the tap set is dropped). -
Dispatch channels are viewer-coupling-EXEMPT (
LiveSession.dispatch): the ready-time no-viewer close and the #66 viewer reap skip them. The in-process consumer is their watcher; their lifecycle is owned by the dispatch (machine budgets, daemon liveness, the dispatch's own close) — an assistant turn may legitimately outlive its viewer. They still appear in the owner's tab bar (watchable, never watch-required) and still count against the per-machine budgets.
dispatch(ownerId, {agentInstanceId, prompt, directory?, resume?}) →
opens (all ChatService gates apply verbatim: #36 owner-only, remote-chat,
online, capability, budget-eviction) → on the ready tap sends the prompt
through the ORDINARY door (onMessageSend — the #66 presence gate and the
queue semantics included) → buffers the turn from the tap → on
turn_result settles done and CLOSES the channel (reason dispatch,
budget back; the idle event may never arrive on some adapters). Terminal
closed/failed report the reason honestly (evicted, daemon loss, prompt
rejected). poll(ownerId, channelId, waitMs) waits as above; terminal
records stay answerable; foreign/unknown ids answer DISPATCH_NOT_FOUND
(existence-hiding). pendingPermission reports an unanswered driven-agent
permission card — answerable TODAY in the web channel tab (the watchdog
backstops); bridging it into the assistant's own M3 card is a follow-up.
-
agent_dispatch— in WRITE_TOOLS: every dispatch crosses the M3 approval card (the model getsblocked with a readable reason on deny/ timeout). Highest blast radius in the platform = opt-in per config (absent fromDEFAULT_ASSISTANT_TOOL_GROUPS) AND per call. -
agent_dispatch_poll— ungated read.
The realtime plugin constructs ONE coordinator (the assistant tool seam and
the RealtimeService.assistantDispatch export share the records).
-
Integration (
assistant-dispatch.test.ts, 7): the full happy path (prompt on ready, buffered turn, done with text/stopReason/usage/ nativeSessionId, channel closeddispatch, terminal record answerable); the resume arm passthrough; waitMs returningrunningfor an unfinished turn thendone;pendingPermissionsurfacing (loop-caught against the 400 ms test watchdog); channel death reportingclosedwith the reason; the honest gates (offline machine →MACHINE_OFFLINE, stranger →DISPATCH_NOT_FOUND); the tool surface (group builds both tools,agent_dispatchwrite-gated, poll not). -
Bench E2E (real server + stub ACP agent + a stateful fake OpenAI
gateway that SCRIPTS the model: round 1 calls
agent_dispatch, round 2 callsagent_dispatch_pollwith the channelId parsed from the tool result, round 3 answers): the M3 card surfaced the dispatch (Allow once), the driven agent ran on the machine, and a SINGLE poll(waitMs 20000) returneddonewithtext: "echo: bench dispatch task", the continuation handle, and usage — then the final assistant message rendered. The tool-call streaming dialect (openai-chatdelta.tool_calls) worked through pi-ai untouched. - ui-snap 52/52; server 378, web 109, typecheck clean.
- Sync dispatch sugar (
wait: true= dispatch + poll∞, cancellable via the assistant turn abort chain; bundles the #68 notices into its return — a user op mid-wait should surface in the sync result, not only the poll). - Permission-card bridge: driven-agent
permission_requestsurfaced as the assistant's OWN M3 card (chain the answers throughchat:permission.respond) — today the model is told viapendingPermissionand the user answers in the channel tab. -
sessions:read(P3): daemon-side filtered transcript reads (shape/grep/head-tail/offset) for inspecting long driven sessions. - Scheduling/cron dispatch — the job queue is the substrate; needs an unattended permission stance first.
- Mobile live-stream presentation for the driven session (modal vs drawer — undecided; the card link is same-form on phone today).
- Sub-agent (Task) cards were never individually verified on the bench.
- Cost/concurrency: driven-agent tokens stay OUTSIDE the assistant's
dailyTokenBudget(v1 stance, documented); pi tool parallelism unverified (parallel dispatches would need a mutex or an explicit budget-as-guard). - Bench lesson: tsx watch hot-reloads WIPE memory-driver state mid-E2E (JWT, machines, daemon registration all evaporate) — freeze file edits during a bench run, or rebuild the stack after any reload.
The driven channel being an ordinary channel cuts both ways: the user can also OPERATE it from the tab bar. Those operations now flow back so the MODEL learns the user's intent (a user stop is not an adapter failure to retry) and the user's interjections keep the channel alive.
Marking point. Every /app chat verb handler marks the op AFTER a
successful ack (assistantDispatch.noteUserOp): turn.cancel,
message.send, queue.cancel, config.set, permission.respond,
elicitation.respond, session.close, and channels.closeAll (marks all
live records of the caller). Ordinary channels (no dispatch record, or a
foreign owner) are a cheap no-op. The coordinator's own prompt send is
in-process (chat.onMessageSend directly), never through the socket —
it can never be mis-marked.
Two delivery arms, one persistence. A marked op appends a notice to the
dispatch record (drained ON READ into the next poll result's notices[] —
each op reaches the model exactly once through the poll) AND persists a
note row in the owning assistant session (assistantSessionId rides the
dispatch record from buildAssistantTools's new session context). The row
is relayed live to the session room as a raw hnx/note event; the web fold
renders it as a neutral info system row (live and replay alike — the replay
emits the same raw event from rowsToHistoryItems).
modelVisible notes. Note rows are display-only by default (rebinds,
error annotations). #68 adds modelVisible: true for backflow notes:
rowsToPiMessages folds them into the NEXT user turn as
[platform note] … text (a mid-turn note lands between a toolCall and its
toolResult in seq order — a standalone user-role message there would break
provider alternation), with trailing notes merging into a trailing user
turn. One turn of latency is also the earliest a running turn could see it.
Completion note. finalize records whether a poll was BLOCKED in
waitMs when the outcome landed (captured before wake() clears the
waiters). No waiter and nobody consumed the result → a completion note
(done/failed summary + "poll has the full result") + poll notice. A waiting
poll consumes the outcome directly — no note, no duplication. The note
never starts a turn (the busy gate stays the only door).
Interjection keeps the channel alive. The driven channel's own
turn_result used to settle + close the dispatch, which DROPPED any
message the user parked in the #10 send queue mid-turn. The settle gate
(ChatService.hasQueuedPrompt) now defers: a parked interjection flushes as
the NEXT turn on the same native session, its deltas append to the record
text (turn boundary = one \n, stopReason resets on the new turn's first
delta), and the next turn_result/idle re-runs the gate with an empty
queue. status stays running while an interjection rides.
Stop choice. The assistant session's stop button, when the busy turn
has live dispatches (the per-session assistant:dispatches snapshot —
pushed on open/sync and every lifecycle change via the coordinator's
late-bound sink), opens a three-way ConfirmDialog (kit secondaryAction):
stop ONLY the client agents (each rides the ordinary chat:turn.cancel →
marked user-stopped; the assistant turn keeps running and observes it via
the backflow), or stop BOTH (clients FIRST — a completing client would
otherwise wake the aborted turn — then assistant:turn.cancel). Plain stop
stays the behavior when no dispatch is live. The tab-strip × on OTHER pages
still stops only the assistant turn (documented behavior, not a choice UI).
Card link. agent_dispatch/agent_dispatch_poll render a dedicated
card with a 「实时会话 ↗」 link (ToolCardShell's new headerTrailing
slot — outside the expand button, interactive content may not nest). The
handle comes from poll args or the dispatch result JSON; the link exists
exactly while the channel is in the chat:channels snapshot (agent id from
the channel view) — the driven channel closes itself at the dispatch's end.
Seq allocation fix (found on the bench). The turn's rows persist at
SETTLE as a batch with seqs allocated from the PRE-turn transcript; a
mid-turn note (allocated max(seq)+1 at its own time) collided — the
memory driver silently overwrote the note, sqlite's PK would throw. Every
transcript write now allocates under a per-session promise chain
(AssistantRealtimeService.transact) — mid-turn notes and settle batches
are strictly ordered and both survive (regression test in
assistant-lifecycle).
Verification. Unit: 5 new dispatch-backflow tests + 3 history-notes
tests + the seq regression + 2 web fold tests (387 server / 111 web green,
typecheck clean, ui-snap 52/52). Bench (agent-browser, stub ACP agent +
stateful fake gateway): M3 card → dispatch → live links on both cards →
followed the link to the driven channel page → stop → choice dialog →
"stop client agents only" → the note rendered LIVE in the assistant stream
mid-turn, the parked poll woke with stopReason: cancelled + the
user-stopped notice, the model's final acknowledgment rendered, and the
note row survived in the persisted transcript (seq between the user row
and the turn rows) and replayed after navigation.
- The adapter spawn env is ONLY
cmd.env + spawnEnv— it does NOT inheritprocess.env:HN_ACP_COMMAND_*needs the ABSOLUTE node path AND any fixture knobs (e.g.FIXTURE_DELAY_PROMPT_MS) must be set by a wrapper script, not the daemon's env. - The spawn cwd is the agent instance's
directory— a detected instance pointing at a nonexistent dir fails asspawn ENOENT(misleading: it names the BINARY). Create the dir. -
hnx daemonflags do not override an existing~/.hnx/config.jsonidentity — for a scratch-home bench, write the config file directly. - pgrep patterns match the driving shell's own
-ccommand line — kill by exact pid fromps, never a bare pattern loop.
The #52 default prompt predates dispatch entirely; the model had no instruction that a driven session is the USER'S own continuable session, and no way to know which of its earlier handles are still live.
-
Static discipline lives in
DEFAULT_ASSISTANT_SYSTEM_PROMPT(now a shared constant —packages/shared/src/assistant-prompt.ts; the server re-exports it, the SDK re-exports it for the web): async-first (poll before reporting/building),notices[]carry user intent, re-check before acting on an earlier turn's session (continue viaresume {sessionId, cwd}; never assume the last dispatch's output is the session's current state), one instruction at a time per driven session. - Ground truth at turn start: when any dispatch record of THIS assistant session is still live, the runner appends a brief to the system prompt (channelId + agent + status + mid-turn + pending permission card) — appended to overrides too, after the M2 addendum. The model's transcript holds the handles; the brief tells it they are STILL RUNNING before it acts. Empty when nothing is live (no noise).
- The dialog shows the baseline: the create/edit dialog's prompt override field gains a collapsible viewer of the platform default (same shared constant via the SDK) — an override starts from an informed baseline. The #52 comment promised this; it never landed until now.
Verified: 13 dispatch tests (briefFor included), shared prompt anchor tests, zh UI on the bench dialog (expand → the discipline text renders), ui-snap 52/52.
Bug report against a REAL machine (detected claude-code, dispatch to a new
project directory): channel opened, agent never spawned —
spawn-failed: ACP adapter '<pinned-bin>' failed: spawn … ENOENT, reading
as "adapter not installed". Two stacked causes, both fixed:
-
The adapter subprocess lost the daemon environment. The channel and
prewarm spawns built
env = cmd.env + spawnEnvONLY; since W14.1 gave claude-code a target env, that meant{CLAUDE_CODE_ENABLE_TODO_TOOLS:'1'}and NOTHING else — no PATH. The pinned bin is a#!/usr/bin/env nodeshim; without PATH,envfalls back to/bin:/usr/bin, and machines with node elsewhere (e.g./usr/local/bin/node) fail with an ENOENT that NAMES THE SPAWNED FILE. (Before W14.1 an empty baseEnv meant noenvkey at all → inheritprocess.env→ worked; that's why older rig chats ran fine.) All spawn sites now use{ ...process.env, ...cmd.env, ...opts.spawnEnv }. -
A missing session cwd masqueraded as the same ENOENT.
directoryvalidation is lexical server-side (correct — the machine's FS is not the server's), so a brand-new project dir reached the daemon, whose spawn with a nonexistent cwd throws the same misleading error.
The cwd contract (createDirectory): chat:session.open/.start
carry an optional boolean (additive — old daemons strip it). Daemon: set →
mkdir -p the cwd before spawning; unset + missing → honest
chat:session.failed naming the directory and the remedy. The assistant
dispatch ALWAYS sets it (the tool promises a NEW session directory).
Picker UI: an 「自动创建目录」 checkbox under the selected-directory
input — a MANUAL edit of the path auto-arms it (tree picks and focus never
do; editing back to the tree value does not either), unchecked + a missing
path shows the daemon's honest refusal. Verified on the bench in all four
combinations; env base proven by the fixture's FIXTURE_AGENT_NAME knob.