design adapter prewarm - sinrimin/harness-nexus GitHub Wiki

Adapter pre-warm pool (Issues #3–#4)

Issue-driven era. Anchors: issue #3 (dsh investigation: open decomposition, CPU profile of the 1.3s dsh --profile acp boot β€” a module-resolution I/O storm inherent to dsh; NODE_COMPILE_CACHE measured and rejected) and issue #4 (opencode: open 2.5s = ~1.6s binary boot + ~0.93s per-process lazy first session/new; idle RSS ~316MB β€” the heaviest of the set). Daily-work orientation lives in the repo's AGENTS.md feature index.

Context

Issues #2's per-spawn costs were gone, but opening a chat channel still paid the adapter process boot on every click, because the daemon spawns a fresh adapter per channel:

target open (steady) spawn+initialize note
deepseek ~1.5s ~1.30s the bulk
claude-code ~1.2s ~0.4s (wrapper) its CLI child boots at session/load regardless
codex ~2.3s ~2.1s wrapper shape
opencode (#4) ~2.5s ~1.6s native binary boot; slowest of all

The structural fix is the reference portal's resident model: boot the adapter BEFORE the click.

Design

Pre-warm to "initialized adapter, no session" when the user is about to need it; consume it on chat:session.open:

Per-target switch, MACHINE-scoped

  • machines.chat_prewarm JSON column (migration 16), written through the ordinary PATCH /api/machines/:id (chatPrewarm arm, owner-or-admin + 404-hiding β€” the same gates as rename/remote-chat). Redesigned same-day from the first instance-global cut: the idle processes live on the machine, so its owner decides.
  • Absent = DEFAULT_CHAT_PREWARM_SETTINGS: deepseek ON; claude-code/codex/opencode OFF (opencode #4: biggest absolute saving after codex, but ~316MB idle argues for opt-in).
  • The map is strict replace-semantics (all four keys; a legacy 3-key payload 400s). The web normalizes {...DEFAULT, ...machine.chatPrewarm} before sending, which heals pre-#4 rows on first toggle.
  • The toggle lives in the machine page's 代理 tab β€” one Switch per Agent runtime card (PrewarmToggle in agents-tab.tsx; only the pi card renders none). PREWARM_ADAPTER_TARGETS (shared, re-exported by the SDK alongside DEFAULT_CHAT_PREWARM_SETTINGS) enumerates the pool targets.

Trigger chain

  • AgentSession mount β†’ chat:adapter.prewarm {agentInstanceId} (browserβ†’server, owner-gated like open, ack {accepted} β€” false when off/unsupported/offline) β†’ chat:adapter.prewarm {target} over /ctl β†’ the daemon pool.
  • chat:session.open also stamps the current switch onto chat:session.start as an optional prewarm: true so the daemon re-arms AFTER a channel took one.

The pool (daemon/prewarm.ts, generic keyed cache)

  • At most ONE prewarmed adapter per target, spawned to a completed initialize (the payload carries the FULL AcpAgentConnection.start result β€” caps and agent identity were already paid).
  • consume() on chat:session.start skips spawn+initialize and goes straight to session establishment. Pending (mid-boot) entries are NOT consumed β€” the channel spawns fresh, the prewarm stays for the next click.
  • Idle TTL HN_PREWARM_TTL_MS (default 120s; 0 disables prewarm) kills ready entries; teardown on /ctl loss (teardownAll) and the W11 boot sweep/audit own the rest.

dsh tap inheritance

  • The prewarm arms the W7.1 event tap AT PREWARM TIME (own listener + --patch + env), so a consumed prewarm keeps tap-grade streaming; a failed handshake closes the listener and the channel degrades to the transcript tail exactly like a tap-less spawn. The tap is dsh-ONLY (armTap no-ops elsewhere) β€” an opencode adoption is a plain native-ACP connection.

Ledger bookkeeping

  • Prewarms ledger under prewarm-<target> pseudo ids (the WIRE_ID regex allows no colons β€” hence the hyphen); adoption DELETES the pseudo file and re-ledgers under the channel's id with the same pgid; TTL/teardown kills deliberately never unlink (audit-only, per W11 A). deleteAdapterLedgerEntry exists for exactly this rename.

Rig numbers (click β†’ history+ready)

target cold prewarmed
deepseek 1.5s 59–206ms (fully warm pool vs settled boot)
codex 2.3s 199ms
claude-code 1.2s 666ms
opencode (#4) 2.5s 846–887ms

Switches off β†’ baseline unchanged (1.48s dsh). The remaining ~0.9s on opencode is its per-process lazy first session/new β€” deliberately NOT warmed: a throwaway warm-up session would persist a junk row in opencode's session store (ACP has no session delete). Daemon 0.24.0-i4.

Test-side

  • ChatHandlersHandle.prewarmReady(target) exposes pool readiness for deterministic waits; cli/test/prewarm.test.ts covers pool semantics (dedupe, consume-once, pending-miss, TTL, dead-replace, late-resolve kill, teardownAll).
  • The server suite covers the strict-map PATCH semantics (incl. the legacy 3-key 400) and each target's independent switch; smoke covers the machine-level PATCH flow end to end.
⚠️ **GitHub.com Fallback** ⚠️