Skip to content

refactor(webui): land the in-process runtime host skeleton (runtime-first S2) - #76

Closed
fengzhi09 wants to merge 2 commits into
mainfrom
feat/webui-runtime-host
Closed

fengzhi09 wants to merge 2 commits into
mainfrom
feat/webui-runtime-host

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

First code slice of the runtime-first migration: stop routing the webui backend through packages/tui/src/acp/agent.ts and let it talk to the runtime directly.

This slice changes no behaviour. No route reads the new switch yet; the point is to make the in-process host exist and be testable, with every future capability migration (S3–S6) able to run behind a flag and fall back.

What lands

  • server/lib/runtime-host.js — two hosts with deliberately different lifetimes: a long-lived catalogue host for read-only lookups, and a per-turn turn host that is disposable. Both wrap TuiRuntimeAdapter over a CliService created in-process by createLocalRuntimeHostV2. The module deliberately does not import config.js; configuration arrives as explicit options so nothing pollutes process.env at module-init time.
  • MCODE_WEBUI_TRANSPORT — defaults to acp, which is exactly today's behaviour. An unrecognised value warns and falls back to acp; an unrecognised value never refuses to start.
  • CJS interoperability — the blocker S1's acceptance found. The webui server bundle had no require shim, and the adapter's dependency tree contains proper-lockfile (CJS), so the bundle built but crashed at module load with Dynamic require of "path" is not supported. Fixed with the CLI's own createRequire banner.

Why the disposable turn host matters

Moving the engine in-process removes a process boundary. Today an engine crash costs one subprocess; in-process it could take the webui server with it. The turn host is the only hedge: every call inside a turn is wrapped so exceptions become that turn's failure state, a turn releases its host when it ends, and close() drains with a bound rather than blocking graceful shutdown.

Cancellation does not use subprocess.kill — there is no process to kill. abortSession returning success means "delivered", not "stopped", so the path is abort → wait for the stream to settle (≤5s) → discard the host.

Acceptance

Independent (GLM-5.3-Flash, not the author): PASS-WITH-CONCERNS. Both acceptance findings were fixed in 17ce2f1.

  • The CJS fix holds. Without the banner the probe exits 1 with Dynamic require of "node:os"; with it, exit 0. The crash moved from path to node:os only because the import graph reaches a different CJS module first — same shim, same root cause. better-sqlite3 requires through the banner at boot, verified against a real host that writes SQLite.
  • Default is byte-identical to today. /api/models is byte-for-byte identical to the baseline; /api/state and /api/settings differ only in per-instance token and port. git diff touches zero files under routes/, and the production server/ has no reference to the new module.
  • Zero mcode subprocesses, asserted with set-level deepEqual at three points — not "happened to be zero".
  • Both mutations reproduced: removing the turn's inner try/catch turns exactly S2-RH-03 red; changing close() to a bare await turns exactly S2-RH-02 red.

Follow-ups taken in 17ce2f1

  • The acceptance found the docs described MCODE_WEBUI_TRANSPORT=exec as current behaviour when no route consumes that value — it is a no-op in this slice, and exec is still reachable only through MCODE_USE_ACP=0. Corrected in both languages, the same way runtime was already labelled.
  • S2-RH-04 contained assert.ok(abortSeen || true) and never passed a signal, so the 5-second bound was never actually exercised. Replaced with strict assertions, and the stub now hangs forever on purpose so the bounded race fires every run (elapsed ≈ 5400ms).
  • Writing that test surfaced a real bug: a caller omitting signal passed undefined straight through. The turn host now owns an AbortController and uses the caller's signal when given, its own otherwise; safeAbortSession delivers on both paths.
  • Removed engineModelKeyFromId from mcode-acp.js — grep confirms zero callers and no export; a stub from an earlier ticket.

Known blocker for S3 (recorded, not fixed here)

The banner anchors import.meta.url to dist/webui/server.js, one level deeper than the CLI's dist/cli.js. resolveAgentAssetsDir's candidate chain therefore misses in the real dist layout: with assets at dist/assets/agents boot still throws Local Runtime V2 built-in Agent assets are missing; only dist/webui/assets/agents boots. Harmless in S2 (nothing is wired), but S3 will crash on a real dist deploy. Tracked in the S3 ticket.

Checks

pnpm typecheck · webui:typecheck · webui full suite 1952 pass / 0 fail (after rebuilding webapp/out) · check:source 4715 · check:tsconfig 132 · test:release-tools 69/70.

🤖 Generated with Claude Code

S2 Dev added 2 commits September 28, 2026 23:21
…t skeleton

S2 ships the foundation that lets later slices (S3+) drop the
`mcode acp` child-process boundary:

* New `packages/webui/server/lib/runtime-host.js` exports
  `createCatalogueHost` (long-lived, replaces the ACP singleton for
  read-only catalogue traffic) and `createTurnHost` (per-turn wrapper
  around `adapter.sendMessage`/`adapter.abortSession` with the R1
  exception boundary and the R2 bounded abort). No route consumes them
  yet — S3-S6 wire catalogue/turn traffic incrementally, S7 flips the
  default. The default behaviour is byte-identical to `main`.

* New `MCODE_WEBUI_TRANSPORT` config knob (default `acp`, the
  legacy `MCODE_USE_ACP=0` escape hatch still wins). Documented in
  both `docs/webui.md` and `docs/webui.zh-CN.md` — full table of
  accepted values, interaction with `MCODE_USE_ACP`, and the S3+
  rollout stages. `server/lib/config.js` validates the value and
  warns on unknown values rather than refusing to boot.

* `scripts/build.mjs` adds a `createRequire` banner to the webui
  server bundle. The webui bundle's dependency tree pulls in
  proper-lockfile (CJS), which requires `path` at module load. Without
  a banner the synthetic `__require` shim throws
  'Dynamic require of "path" is not supported' on first import —
  the S1 acceptance-call hard blocker, finally fixed here. The CLI
  bundle has had an equivalent banner since 0.5.4; the webui bundle
  mirrors it via a new
  `createWebuiBundleModuleLocationConfig` helper.

* `packages/webui/test/server/runtime-host.test.js` pins:
  - boot → createSession → listSessions → close in an isolated tmp
    dataDir with zero `mcode` child processes (R1 acceptance target),
  - `close()` is bounded even when `apiHost.close()` hangs (R8),
  - sendMessage iterator throws become stream error frames, never
    propagated (R1 turn-host boundary — mutation-1 turns red when
    dropped),
  - `abortSession` returns within 5s without any subprocess kill
    (R2 — mutation-2 turns red when the bounded race is removed).

Acceptance:
* `pnpm typecheck`, `pnpm --filter @mavis/webui webapp:typecheck`:
  both 0.
* `pnpm --filter @mavis/webui test`: 1952/1954 pass (2 baseline skips
  unrelated to this change). My four S2-RH tests all green.
* `pnpm check:source`: 4715 reviewed files after inventory regen.
* `pnpm check:tsconfig`: 132 package exports match.
* `pnpm test:release-tools`: 69/70 pass (1 baseline skip).

Dist-shape runtime assertion (no-banner probe → exits 1 with the
expected symptom; banner probe → exits 0 and `createCatalogueHost` +
`createTurnHost` resolve as functions on the bundled module): see
archive/evidence/s2-runtime-host/R1-dev.
…y + dead code)

Acceptance of d49d919 (S2 R1) flagged three follow-ups. R2 closes them
all without expanding scope.

* docs/webui.md and docs/webui.zh-CN.md: rewrite the MCODE_WEBUI_TRANSPORT
  Turn-condition table so the exec entry reads 'no-op in S2 — same as
  default acp; exec today is reachable only via MCODE_USE_ACP=0', the
  same shape the runtime entry already had. Both languages. The Env-var
  table and the priority rule got the matching correction.

* S2-RH-04 had two real defects that masked each other:
  (a) `assert.ok(abortSeen || true, ...)` was a tautology — verified
      the assertion was checked with an explicit strict-mode test that
      throws when abortSeen is false (mutator #1).
  (b) The 5s upper-bound branch was never activated because the stub
      settled the stream on abort. The new stub hangs forever after
      the first yield — the worst case the bounded drain exists to
      protect against. abortSession must time out at 5s, not return
      quickly. Mutator #3 (drop the 5s race) turns S2-RH-04 red.

* Implementing the fixed test surfaced a real bug: safeSendMessage's
  signal param was undefined when the caller didn't supply one, so
  adapter.sendMessage was called without a signal. createTurnHost now
  owns a per-turn AbortController and safeSendMessage forwards
  turnController.signal when the caller doesn't supply one. abortSession
  also fires turnController.abort() to deliver the signal side of
  the cancellation. Documented inline.

* packages/webui/server/lib/mcode-acp.js: drop engineModelKeyFromId.
  It was defined, mentioned in a doc comment, and NEVER exported or
  called anywhere in the tree (verified by grep). Pure baseline dead
  code — S2 didn't introduce it; the acceptance pass flagged it as
  optional cleanup. Deleting reduces cognitive load without
  behaviour change.

Acceptance:
* pnpm typecheck, pnpm --filter @mavis/webui webapp:typecheck: both 0.
* pnpm --filter @mavis/webui test: 1952/1954 pass (2 baseline skips).
  S2-RH-04 reports ~5400ms every run, proving the bounded-drain
  branch fires on every invocation.
* pnpm check:source: 4715 files.
* pnpm check:tsconfig: 132 package exports match.
* pnpm test:release-tools: 69/70 pass (1 baseline skip).

Mutation verification (3 mutators; all turn the expected test red):
  mutation-1 (drop turn sendMessage inner try/catch) -> S2-RH-03 red.
  mutation-2 (drop catalogue close() bounded race) -> S2-RH-02 red.
  mutation-3 (drop abortSession bounded-drain race) -> S2-RH-04 red.

Evidence: archive/evidence/s2-runtime-host/R2-dev.
@fengzhi09

Copy link
Copy Markdown
Collaborator Author

Superseded by #78. This branch's base (b7ff2a1) predates #73/#74/#75, so it conflicts on mcode-acp.js. #78 is the same work as an integration merge with the conflict resolved by hand.

@fengzhi09 fengzhi09 closed this Sep 28, 2026
@fengzhi09
fengzhi09 deleted the feat/webui-runtime-host branch September 28, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant