Skip to content

design agent dispatch

sinrimin edited this page Oct 20, 2026 · 1 revision

Design: assistant client-agent dispatch (#67)

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.

1. Semantics

  • 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_dispatch returns a handle immediately; agent_dispatch_poll fetches the outcome (waitMs blocks 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 with running and 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 as resume {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.

2. The three pieces

2.1 ChatService: in-process open + event tap

  • 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 resolved cwd (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 in onStream (tap order === room order by construction), and closeInternal (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.

2.2 AssistantDispatchService (realtime/assistant-dispatch.ts)

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.

2.3 Tools (group agent-dispatch, DEFAULT OFF)

  • agent_dispatch — in WRITE_TOOLS: every dispatch crosses the M3 approval card (the model gets blocked with a readable reason on deny/ timeout). Highest blast radius in the platform = opt-in per config (absent from DEFAULT_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).

3. Verification

  • Integration (assistant-dispatch.test.ts, 7): the full happy path (prompt on ready, buffered turn, done with text/stopReason/usage/ nativeSessionId, channel closed dispatch, terminal record answerable); the resume arm passthrough; waitMs returning running for an unfinished turn then done; pendingPermission surfacing (loop-caught against the 400 ms test watchdog); channel death reporting closed with the reason; the honest gates (offline machine → MACHINE_OFFLINE, stranger → DISPATCH_NOT_FOUND); the tool surface (group builds both tools, agent_dispatch write-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 calls agent_dispatch_poll with 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) returned done with text: "echo: bench dispatch task", the continuation handle, and usage — then the final assistant message rendered. The tool-call streaming dialect (openai-chat delta.tool_calls) worked through pi-ai untouched.
  • ui-snap 52/52; server 378, web 109, typecheck clean.

4. Follow-ups recorded (not built)

  • 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_request surfaced as the assistant's OWN M3 card (chain the answers through chat:permission.respond) — today the model is told via pendingPermission and 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.

5. #68 — user-operation backflow, completion notes, stop choice, card link

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.

Bench lessons (#68 round)

  • The adapter spawn env is ONLY cmd.env + spawnEnv — it does NOT inherit process.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 as spawn ENOENT (misleading: it names the BINARY). Create the dir.
  • hnx daemon flags do not override an existing ~/.hnx/config.json identity — for a scratch-home bench, write the config file directly.
  • pgrep patterns match the driving shell's own -c command line — kill by exact pid from ps, never a bare pattern loop.

6. #69 — dispatch discipline in the default prompt + turn-start brief

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 via resume {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.

7. #70 — adapter spawn env base + the session-cwd auto-create contract

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:

  1. The adapter subprocess lost the daemon environment. The channel and prewarm spawns built env = cmd.env + spawnEnv ONLY; 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 node shim; without PATH, env falls 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 no env key at all → inherit process.env → worked; that's why older rig chats ran fine.) All spawn sites now use { ...process.env, ...cmd.env, ...opts.spawnEnv }.
  2. A missing session cwd masqueraded as the same ENOENT. directory validation 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.

Clone this wiki locally