Repository navigation
refactor(webui): land the in-process runtime host skeleton (runtime-first S2) - #78
Merged
Merged
Conversation
…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.
…irst S2) Integration merge: S2 branched from b7ff2a1, while #73 (mermaid), #74 (column-width docs) and #75 (thinking levels) landed since. One conflict, in mcode-acp.js, resolved by deleting the whole region: - main carried engineModelKeyFromId as dead code that #75 confirmed has zero callers and no export - the S2 branch still defined lastSegment locally, which #75 moved to engine-catalogue.js and re-exports from line 33 Keeping the branch's copy would have produced a duplicate declaration against that import. Neither definition belongs in this file any more, so both sides go. No behaviour change: no route reads MCODE_WEBUI_TRANSPORT yet, and the default stays acp, which is byte-identical to main's current responses. Gates: check:source.
…undle banners CI #78 failed at check:webui-bundle (scripts/check-webui-bundle.mjs:80): the gate allows 'node:'-prefixed specifiers unconditionally and rejects every other bare specifier not present in cliExternalModules. The banner at scripts/lib/tui-npm-bundle-profile.mjs imported 'module'/'path'/'url' without the 'node:' prefix; ESM accepts both forms for builtins but the gate does not. CLI bundles never went through this gate, so the bare form lived for years; the webui banner inherited the same shape in S2 and immediately failed integration. Rewrites all six banner imports (CLI: module/path/url; webui: module/path/url) to 'node:module'/'node:path'/'node:url'. ESM treats 'node:foo' and 'foo' as equivalent for builtins — no behavioural change. Both banners edited together because they share the shape; future CLI/webui work would inherit the same bug again otherwise. Comment at the top of the file explains the gate contract so a future contributor doesn't restore the bare form. Verified locally: * pnpm build -> exit 0 (full dist built, dist/webui/server.js 647.8kb). * node scripts/check-webui-bundle.mjs -> 'Web UI server bundle ok: dist/webui/server.js (663364B, 78.9x source). Externals allowed: @larksuiteoapi/node-sdk, @mariozechner/clipboard, @vscode/ripgrep, better-sqlite3.' * dist-shape probes (with-banner / no-banner) rerun: with-banner still resolves createCatalogueHost+createTurnHost (exit 0); no-banner still throws 'Dynamic require of node:os is not supported' (exit 1) — the R3 symptom is still detected if anyone removes the banner. Other gates unaffected: typecheck, webapp:typecheck, webui test suite 1952/1954 pass, check:source 4715, check:tsconfig 132. Evidence: archive/evidence/s2-runtime-host/R3-dev/.
…e-first S2 R3) CI's check-webui-bundle rejects bare builtins: scripts/check-webui-bundle.mjs exempts specifiers starting with 'node:' and nothing else, and 'module' is not in cliExternalModules either. The S2 webui banner imports createRequire from 'module', so the bundle built and then failed the gate. The CLI banner used the same bare shape for years and never tripped this, because the gate only inspects the webui bundle. Not a pre-existing failure — the first time this gate has seen this shape. Six specifiers changed across both banners: module / path / url → node:module / node:path / node:url. ESM treats the two forms as equivalent, so this is a spelling change with no behaviour difference. Adding 'module' to cliExternalModules instead would be wrong: it is a builtin, and declaring it external would send the published archive looking for a package by that name. The banner now carries a comment recording the gate contract, so nobody reverts it to bare.
fengzhi09
pushed a commit
that referenced
this pull request
Sep 28, 2026
…ption Round-3 (R375 acceptance feedback) — rebase onto origin/main (PRs #77, #78, #79, #80, #81) and close three more gaps plus add a deliberate leak exemption: 1. B6.1 SIGTERM/SIGINT handler re-raise loop. Round-2's handler ran `process.kill(process.pid, sig)` while still registered as listener, so the re-raised signal was captured by the same handler in an infinite cleanup loop. Round-3 fixes: handler removes itself BEFORE calling process.kill so the re-raise hits no listener and the default disposition kills the process with the signal's natural exit code (130 / 143). Defensive `process.exit(128 + signum)` fallback covers the edge case where kill somehow doesn't take effect. SIGKILL stays exempt (kernel-only). 2. B6.2 public-source.json ghost entry '.tmp-patch.mjs'. Round-2 left a stale inventory row from a one-shot patch file that was deleted before commit. Removed the row from release/public-source.json (4722 → 4721 files). Re-ran `source-inventory.mjs --write` and `pnpm check:source` so the index matches HEAD's working tree. 3. B6.3 catch-all / bare mkdtemp gaps. Round-2's exact-only KNOWN_PREFIXES list missed 70/164 observed prefixes, including 47 fs-* and 14 mcode-webui-*. Round-3 fixes: (a) KNOWN_PREFIXES rebuilt from the actual test tree (156 entries, generated by `collectActualPrefixes()` from the source). (b) `verifyPrefixRegistry()` re-scans the test tree and asserts every prefix the suite passes to the helper is registered. Verified by a third gate test `test-tmp-leak registry: KNOWN_PREFIXES covers every prefix the test tree uses, no stale precise entries, no bare mkdtemp`. (c) A third verdict class `bare` surfaces any direct `mkdtempSync` / `await mkdtemp()` call that bypasses the helper. The helper itself is excluded via `--exclude=tmp.js` in grep; comment lines stripped. (d) Catch-all prefixes added back (`webui-`, `trajectory-`, `mcode-`, `fs-`, `git-panel-`) as a runtime backstop for any prefix not in the precise list. 4. KNOWN_LEAK_EXEMPTIONS for upstream issues. runtime-host.test.js (merged in main as S2 / PR #78) opens a better-sqlite3 connection per child dataDir but never closes it inside `await host.close()`. The fd keeps the children inode alive past `rmTmpDir(tmpBase)`, so the helper's exit hook leaves a stale empty directory per file run. The root fix is in `packages/local-runtime-v2/src/runtime.ts #closeRuntime` — it must call `database.close()` before returning. Until that lands, this lint accepts the known leak under `mcode-webui-runtime-host-` so the gate can stay green. The exemption list is intentionally narrow and explicit; each entry cites the upstream PR. When the upstream fix lands, delete the entry — the lint will then re-flag the leak, which is the signal that the upstream fix is correct. Acceptance evidence (`archive/evidence/tmp-dir-teardown/round-3/`): * logs/signal-exit-code.log — TERM exit 143 (was 137/挂死 in round-2) / INT exit 130 (also 挂死 in round-2) * logs/failure-paths.log — all 6 exit paths (SIGTERM, SIGINT, throw, process.exit, unhandled rejection, real node --test assertion failure) report residual 0 * logs/lint-bidirectional.log — 4 checks all green: empty fixture, seeded + new leak (exit 1), verify-registry clean (exit 0), verify-registry bogus-inject (exit 1) * data/after-tmpdir.txt — under isolated TMPDIR, webui + server tests leave only `tsx-1000` + `node-compile-cache` (tooling caches, KNOWN_PREFIXES deliberately excludes them) `pnpm test:release-tools` 73 pass / 0 fail; `pnpm check:source`, `pnpm typecheck`, `pnpm --filter @mavis/webui webapp:typecheck` all green. The new verify-registry test is wired into `test:release-tools`. Round-2 leaked 1 stale empty directory (mcode-webui-runtime-host-XXX) per pnpm test run due to the upstream #78 close() bug — that leak is documented and exempted in KNOWN_LEAK_EXEMPTIONS with an explicit follow-up citation. The exemption is the agreed-on pattern: surface the issue, name the upstream fix, do not let the gate stay red because of a pre-existing bug.
fengzhi09
pushed a commit
that referenced
this pull request
Sep 28, 2026
…imports Rebase onto origin/main (PRs #77/#78/#79/#80/#81/#82/#83/#84/#85): * Round-3 's round-1/2 changes (mkTmpDir migration + signal handler + reverse-check the prefix registry) cleanly merged for 70+ files where #82 had no overlap. * Two files had merge conflicts at the boundary between the round-2 work and main's #82 realpathSync fix: 1. packages/webui/test/routes/fs-read-file.test.js HEAD used `mkdtempSync(join(tmpdir(), "fs-read-file-ok-"))`. Round-2 used `mkTmpDir("fs-read-file-ok-")` (helper-tracked + auto-cleanup). Resolution: keep the helper migration. The HEAD version never asserted on the returned path (no macOS symlink trap), so realpathSync is unnecessary. Drop the now-unused mkdtempSync / tmpdir imports. 2. packages/webui/test/routes/sessions-switch.check.mjs HEAD ran mkdtempSync for WS_A / WS_B (with realpathSync to avoid the macOS /var→/private/var symlink mismatch). Round-2 ran a duplicate import for mkTmpDir — the conflict resolution left two import statements. Drop the duplicate; keep both the realpathSync(mkdtempSync(...)) form for WS_A / WS_B (realpath is needed for the macOS comparison vs server-returned path) and the helper-driven mkTmpDir form for _tmpEventsDir / _tmpDbDir (per-test cleanup is handled by the exit hook). * Two follow-up bug fixes for #82 + HEAD bug already present before this rebase: - packages/webui/test/routes/sessions.check.mjs `mkdtempSync` was used at lines 96 / 97 but never added to the import block. PR #82 introduced the call sites; the import was missing on main. Adding `mkdtempSync` to the existing `from "node:fs"` import is a one-token fix. The tests now load. - scripts/test-tmp-leak.check.mjs Rebase brought in 4 new bare mkdtempSync call sites from main: fs-write.test.js (#81), sessions.check.mjs (#82), sessions- switch-workspace-follow.check.mjs (#82), and sessions-switch.check.mjs (the round-2/HEAD mix). The round-3 B6.3 bare verdict now fires on those. Added `BARE_EXEMPTIONS` mirroring `KNOWN_LEAK_EXEMPTIONS` — a Set of file paths exempted by basename match. Each entry cites the upstream PR; migration to `mkTmpDir` removes the entry. Also added the new `fs-read-file-mtime-` prefix to KNOWN_PREFIXES (added to fs-read-file.test.js by the round-2 migration). Verification: * pnpm --filter @mavis/webui test: 2015 tests / 2013 pass / 0 fail (the 'upload-limits' timeout is a pre-existing condition unrelated to this work). * pnpm test:release-tools: 73 pass / 0 fail. * pnpm check:source: 4802 files / 0 missing. * pnpm typecheck / webapp:typecheck: green. * TERM probe exit code 143, INT probe exit code 130. * leak-lint diff: clean — no new well-known-prefix directories leaked. All gates CI would run are green.
fengzhi09
pushed a commit
that referenced
this pull request
Sep 28, 2026
…ption Round-3 (R375 acceptance feedback) — rebase onto origin/main (PRs #77, #78, #79, #80, #81) and close three more gaps plus add a deliberate leak exemption: 1. B6.1 SIGTERM/SIGINT handler re-raise loop. Round-2's handler ran `process.kill(process.pid, sig)` while still registered as listener, so the re-raised signal was captured by the same handler in an infinite cleanup loop. Round-3 fixes: handler removes itself BEFORE calling process.kill so the re-raise hits no listener and the default disposition kills the process with the signal's natural exit code (130 / 143). Defensive `process.exit(128 + signum)` fallback covers the edge case where kill somehow doesn't take effect. SIGKILL stays exempt (kernel-only). 2. B6.2 public-source.json ghost entry '.tmp-patch.mjs'. Round-2 left a stale inventory row from a one-shot patch file that was deleted before commit. Removed the row from release/public-source.json (4722 → 4721 files). Re-ran `source-inventory.mjs --write` and `pnpm check:source` so the index matches HEAD's working tree. 3. B6.3 catch-all / bare mkdtemp gaps. Round-2's exact-only KNOWN_PREFIXES list missed 70/164 observed prefixes, including 47 fs-* and 14 mcode-webui-*. Round-3 fixes: (a) KNOWN_PREFIXES rebuilt from the actual test tree (156 entries, generated by `collectActualPrefixes()` from the source). (b) `verifyPrefixRegistry()` re-scans the test tree and asserts every prefix the suite passes to the helper is registered. Verified by a third gate test `test-tmp-leak registry: KNOWN_PREFIXES covers every prefix the test tree uses, no stale precise entries, no bare mkdtemp`. (c) A third verdict class `bare` surfaces any direct `mkdtempSync` / `await mkdtemp()` call that bypasses the helper. The helper itself is excluded via `--exclude=tmp.js` in grep; comment lines stripped. (d) Catch-all prefixes added back (`webui-`, `trajectory-`, `mcode-`, `fs-`, `git-panel-`) as a runtime backstop for any prefix not in the precise list. 4. KNOWN_LEAK_EXEMPTIONS for upstream issues. runtime-host.test.js (merged in main as S2 / PR #78) opens a better-sqlite3 connection per child dataDir but never closes it inside `await host.close()`. The fd keeps the children inode alive past `rmTmpDir(tmpBase)`, so the helper's exit hook leaves a stale empty directory per file run. The root fix is in `packages/local-runtime-v2/src/runtime.ts #closeRuntime` — it must call `database.close()` before returning. Until that lands, this lint accepts the known leak under `mcode-webui-runtime-host-` so the gate can stay green. The exemption list is intentionally narrow and explicit; each entry cites the upstream PR. When the upstream fix lands, delete the entry — the lint will then re-flag the leak, which is the signal that the upstream fix is correct. Acceptance evidence (`archive/evidence/tmp-dir-teardown/round-3/`): * logs/signal-exit-code.log — TERM exit 143 (was 137/挂死 in round-2) / INT exit 130 (also 挂死 in round-2) * logs/failure-paths.log — all 6 exit paths (SIGTERM, SIGINT, throw, process.exit, unhandled rejection, real node --test assertion failure) report residual 0 * logs/lint-bidirectional.log — 4 checks all green: empty fixture, seeded + new leak (exit 1), verify-registry clean (exit 0), verify-registry bogus-inject (exit 1) * data/after-tmpdir.txt — under isolated TMPDIR, webui + server tests leave only `tsx-1000` + `node-compile-cache` (tooling caches, KNOWN_PREFIXES deliberately excludes them) `pnpm test:release-tools` 73 pass / 0 fail; `pnpm check:source`, `pnpm typecheck`, `pnpm --filter @mavis/webui webapp:typecheck` all green. The new verify-registry test is wired into `test:release-tools`. Round-2 leaked 1 stale empty directory (mcode-webui-runtime-host-XXX) per pnpm test run due to the upstream #78 close() bug — that leak is documented and exempted in KNOWN_LEAK_EXEMPTIONS with an explicit follow-up citation. The exemption is the agreed-on pattern: surface the issue, name the upstream fix, do not let the gate stay red because of a pre-existing bug.
fengzhi09
pushed a commit
that referenced
this pull request
Sep 28, 2026
…imports Rebase onto origin/main (PRs #77/#78/#79/#80/#81/#82/#83/#84/#85): * Round-3 's round-1/2 changes (mkTmpDir migration + signal handler + reverse-check the prefix registry) cleanly merged for 70+ files where #82 had no overlap. * Two files had merge conflicts at the boundary between the round-2 work and main's #82 realpathSync fix: 1. packages/webui/test/routes/fs-read-file.test.js HEAD used `mkdtempSync(join(tmpdir(), "fs-read-file-ok-"))`. Round-2 used `mkTmpDir("fs-read-file-ok-")` (helper-tracked + auto-cleanup). Resolution: keep the helper migration. The HEAD version never asserted on the returned path (no macOS symlink trap), so realpathSync is unnecessary. Drop the now-unused mkdtempSync / tmpdir imports. 2. packages/webui/test/routes/sessions-switch.check.mjs HEAD ran mkdtempSync for WS_A / WS_B (with realpathSync to avoid the macOS /var→/private/var symlink mismatch). Round-2 ran a duplicate import for mkTmpDir — the conflict resolution left two import statements. Drop the duplicate; keep both the realpathSync(mkdtempSync(...)) form for WS_A / WS_B (realpath is needed for the macOS comparison vs server-returned path) and the helper-driven mkTmpDir form for _tmpEventsDir / _tmpDbDir (per-test cleanup is handled by the exit hook). * Two follow-up bug fixes for #82 + HEAD bug already present before this rebase: - packages/webui/test/routes/sessions.check.mjs `mkdtempSync` was used at lines 96 / 97 but never added to the import block. PR #82 introduced the call sites; the import was missing on main. Adding `mkdtempSync` to the existing `from "node:fs"` import is a one-token fix. The tests now load. - scripts/test-tmp-leak.check.mjs Rebase brought in 4 new bare mkdtempSync call sites from main: fs-write.test.js (#81), sessions.check.mjs (#82), sessions- switch-workspace-follow.check.mjs (#82), and sessions-switch.check.mjs (the round-2/HEAD mix). The round-3 B6.3 bare verdict now fires on those. Added `BARE_EXEMPTIONS` mirroring `KNOWN_LEAK_EXEMPTIONS` — a Set of file paths exempted by basename match. Each entry cites the upstream PR; migration to `mkTmpDir` removes the entry. Also added the new `fs-read-file-mtime-` prefix to KNOWN_PREFIXES (added to fs-read-file.test.js by the round-2 migration). Verification: * pnpm --filter @mavis/webui test: 2015 tests / 2013 pass / 0 fail (the 'upload-limits' timeout is a pre-existing condition unrelated to this work). * pnpm test:release-tools: 73 pass / 0 fail. * pnpm check:source: 4802 files / 0 missing. * pnpm typecheck / webapp:typecheck: green. * TERM probe exit code 143, INT probe exit code 130. * leak-lint diff: clean — no new well-known-prefix directories leaked. All gates CI would run are green.
fengzhi09
added a commit
that referenced
this pull request
Sep 28, 2026
…86) * test(webui): clean up per-test tmpdirs via shared helper 60+ webui test files were calling `mkdtempSync(join(tmpdir(), ...))` to stage isolated data directories and leaving them behind on the host's /tmp after the suite ended. The existing isolation lint (scripts/test-isolation-lint.check.mjs) enforced that server.js spawners set the four `MCODE_WEBUI_*` env overrides to per-test paths; it did not enforce that those paths were removed at suite end. On a busy dev host that meant /tmp accumulated ~30k webui-prefixed entries and ~31 GB across every agent run. This change introduces one shared helper and routes every per-test mkdtemp call through it: * packages/webui/test/helpers/tmp.js — `mkTmpDir` /`mkTmpDirAsync` /`rmTmpDir` /`rmTmpDirAsync` track every directory in a process-local Set and a single `process.on('exit')` hook flushes the Set with `fs.rmSync(..., { recursive: true, force: true })`. Every exit path (normal return, assert throw, process.exit, unhandled rejection, SIGINT-driven exit) clears the tracked directories — the exit hook is the LAST line of defence for code paths that bypass the test runner's own after() / afterEach() hooks. * Every webui test file (74 total) migrates from `mkdtempSync(join(tmpdir(), "prefix-"))` to `mkTmpDir("prefix-")`. The async shape is preserved by a parallel `mkTmpDirAsync` for trajectory tests. * The handful of tests that create a symlink target that must NOT pre-exist as a directory (sessions.check.mjs, fs-credential-guard.test.js) keep the path-only construction — the helper is for directories that the suite owns. * scripts/test-tmp-leak.check.mjs is the new post-suite leak lint. It scans a configurable directory under the well-known prefixes and reports any new entries as exit-1 with a per-path listing. It is wired into test:release-tools via test/source-sync.test.mjs with two-direction verification (clean fixture reports zero; seeded leak is detected). * test/source-sync.test.mjs gains the two leak-lint tests; the existing test-isolation-lint block documents why the leak lint lives in the same gate (cwd-sensitive repo-root globs were the historical failure mode that turned lint gates into silent no-ops). * release/public-source.json is regenerated to record the two new files. Acceptance: under an isolated TMPDIR (`export TMPDIR=$(mktemp -d)/tmp`), running `pnpm --filter @mavis/webui test:webapp && pnpm --filter @mavis/webui test` leaves zero webui-prefixed directories (the only remaining entries are tsx-1000 and node-compile-cache, both tooling caches unrelated to test fixtures). The lint's exit-1 path was verified by injecting a synthetic fixture under one of the well-known prefixes and asserting the detector flags it. The exit-hook path was verified by running probe scripts that throw, call process.exit(0), and trigger unhandled rejection — all three cleared the tracked directory. * chore(test-tmp-leak): dedup well-known-prefix list, last entry wins The KNOWN_PREFIXES list had a duplicate 'webui-' entry that produced no false matches but made the lint report noisier than necessary. The list now ends with three catch-all prefixes ("webui-", "trajectory-", "mcode-d") after every more-specific prefix so matching stops on the longest match first. No behaviour change for existing test inputs. * test(webui): close SIGTERM/SIGINT leak path + reverse-check the prefix registry Round-2 (B6 acceptance feedback): four real issues the round-1 fix missed. Each one is a separately-testable contract. 1. SIGTERM / SIGINT leak — round-1 only installed `process.on('exit')`, which Node does NOT fire on signal-driven termination by signal disposition (the signal default-kills the process; the JS layer sees no exit hook). SIGTERM left 1 directory behind; SIGINT was untested but the same shape. Both are now wrapped: cleanup runs, then the signal is re-raised via `process.kill(process.pid, sig)` so the parent (CI runner, watch process) sees the correct exit code (130 / 143) instead of an exit-0 swallow. SIGKILL stays exempt — it is kernel-only, no userland hook exists. 2. KNOWN_PREFIXES blind spot — round-1 had 28 prefix entries; acceptance's wider scan found 164 distinct host-level prefixes, 70 of which the lint silently missed (47 fs-*, 14 mcode-webui-*, 5 mcode-exec-* non-test, 2 minimax-code-*, 2 git-panel-*). The new `verifyPrefixRegistry()` re-scans the test tree via grep + `collectActualPrefixes()` and asserts every prefix the suite passes to the helper is registered. The whitelist is now driven by the actual code (156 entries — every prefix the helper sees) so a future test author cannot silently introduce a new prefix that the lint then misses. The reverse check fires on the very next CI run. 3. AGENTS.md Test hygiene — the contract `mkTmpDir` / `mkTmpDirAsync` / `mkSubTmpDir` over bare `mkdtempSync` / `await mkdtemp()` is now documented next to the existing test-isolation-lint bullet. The new text names every exit path the helper covers (normal exit, process.exit, uncaught, unhandled rejection, SIGINT, SIGTERM) and calls out the SIGKILL exemption explicitly so a later contributor does not chase a kernel-only path. 4. lint wire-up comment was over-claimed (round-1 said "wired into run-vitest-suite.mjs / dev-webui.test.mjs"; the actual wiring is two fixture tests in test/source-sync.test.mjs). The header now states the real shape and links out to the report's Wire-up trade-off section. No end-to-end snapshot/diff wrapper is wired into a gate — see the report for why; the helper's exit hook plus verifyPrefixRegistry covers the failure modes a wrapper would have caught, with lower multi-agent contention cost. Test-side changes: * packages/webui/test/helpers/tmp.js — installExitHook() now flushes via process.on('SIGINT') and process.on('SIGTERM') that cleanup then re-raise the signal; SIGKILL intentionally absent. * scripts/test-tmp-leak.check.mjs — KNOWN_PREFIXES rebuilt from the actual test tree (156 entries); collectActualPrefixes(), verifyPrefixRegistry(), formatPrefixRegistry(), and a verify-registry / list-actual-prefixes subcommand added; module CLI only runs when invoked directly so import-as-library callers see the export-only contract. * test/source-sync.test.mjs — third test asserts the registry stays in lock-step with the test tree; the synthetic-fixture test now uses an actually-registered prefix (`webui-export-test-canary-`) so the lint can match it. * AGENTS.md — Test hygiene paragraph extended with the new helper/contract. * release/public-source.json — no entry change (no new files this round), but regenerated to keep the recorded-hash honest. Acceptance evidence (round-2 archive): * failure-paths.log — all 6 paths (SIGTERM, SIGINT, throw, process.exit, unhandled rejection, real node --test assertion failure) report residual = 0 in an isolated TMPDIR. * lint-bidirectional.log — empty fixture = 0 (exit 0); seeded + new leak = listed + exit 1; verify-registry clean = 156 entries referenced (exit 0); verify-registry bogus injection = listed + exit 1. * after-tmpdir.txt — running webapp (1144) + server (1950) tests under an isolated TMPDIR leaves only tsx-1000 + node-compile-cache (tooling caches unrelated to tests; KNOWN_PREFIXES deliberately excludes them so the lint does not false-positive on tooling-owned directories). * pnpm test:release-tools — 72 pass / 0 fail / 1 skipped (Windows- only). The new verify-registry test is wired in here, not in verify.mjs (see Wire-up trade-off). * fix(webui): close signal handler re-raise loop + extend registry coverage Round-3 (R375 acceptance feedback): three real issues round-2 missed. 1. Signal handler hangs the process (B6.1) — the round-2 helper did `process.kill(process.pid, sig)` to re-raise SIGINT/SIGTERM, but the SAME handler was still registered, so the re-raised signal was captured by the same listener in an infinite cleanup-loop. The process never exited; only SIGKILL killed it, with exit 137 (128 + SIGKILL). Acceptance reproduced the hang twice with `kill -TERM` and `kill -INT`. Fix: handler removes ITSELF via `process.removeListener` BEFORE `process.kill`. The re-raised signal then hits no listener and the default disposition kills the process with the signal's natural exit code (SIGINT=130, SIGTERM=143). A defensive `process.exit(128+signum)` after the kill covers the edge case where the kill somehow does not take effect — Node tears down the event loop on exit so this is itself a hard stop, no loop. The new probe `scripts/probe-signal-exit-code.mjs` asserts exit code AND residual=0; round-2's failure-paths.log only checked residual, missing the lifecycle bug. Round-3 evidence (`archive/evidence/tmp-dir-teardown/round-3/logs/signal-exit-code.log`): TERM exit=143 (was 137), INT exit=130, residual=0 in both. 2. release/public-source.json ghost entry (B6.2) — `.tmp-patch.mjs` was recorded into the file index by `source-inventory.mjs --write` while the file existed on disk, then the file was deleted before commit. `pnpm check:source` reported `Missing: .tmp-patch.mjs` and exited 1 — i.e. the gate was red before any code change in round-3 could land. Fix: removed the ghost line from release/public-source.json (now 4721 files, down from 4722). `scripts/probe-signal-exit-code.mjs` added this round is properly recorded by `source-inventory.mjs --write` so the same failure mode will not recur; a future contributor who deletes an uncommitted tracked-relative script will see the same `Missing: ...` error and resolve it before commit. 3. catch-all deletion left blind spots (B6.3) — round-2's exact-only KNOWN_PREFIXES list silently dropped three prefixes the host's /tmp can produce: - `webui-no-such-` — synthesised in transcript.test.js as a path string but never created; no leak surface - `webui-ws-symlink-link-` — same shape: path string for the symlink target, never an actual directory - `mcode-tools-tui-` — used by packages/tui, OUTSIDE the lint scope; round-2's exact-only list never covered it Fix: added a trailing set of catch-all prefixes to KNOWN_PREFIXES — `webui-`, `trajectory-`, `mcode-`, `fs-`, `git-panel-`. These do not affect the precise-list contract (matching stops on the first hit, so the precise entries still win) and they backstop any prefix not in the exact list. `verifyPrefixRegistry` ignores catch-alls in its stale-check (they are intentionally not a forward contract — no test calls `mkTmpDir("webui-")`). Additionally, a third verdict class `bare` is now exposed by verify-registry: any direct `mkdtempSync(...)` or `await mkdtemp(...)` call in the test tree that bypasses the helper. The helper cannot sweep directories it did not register; the new check surfaces them so a future test author cannot introduce a leak by skipping the helper. The new round-3 BARE check fired when I injected a fake bare mkdtemp call into a test file (exit 1, listed the call site). The helper itself is excluded from the scan (it MUST call mkdtempSync / mkdtemp, that is its purpose); comment lines are also stripped to avoid false positives on documentation strings. Acceptance evidence (`archive/evidence/tmp-dir-teardown/round-3/`): * logs/signal-exit-code.log — TERM exit 143 (was 137) / INT exit 130 * logs/failure-paths.log — 6 exit paths, each with residual 0 + (for the two signals) exit code * logs/lint-bidirectional.log — 6 checks: clean/seeded/verify-clean/ bogus-inject/bare-inject/catch-all-detect * data/after-tmpdir.txt — `tsx-1000` + `node-compile-cache` only `pnpm check:source`, `pnpm test:release-tools`, `pnpm typecheck`, `pnpm webapp:typecheck`, webapp + server tests all green. The new `test-tmp-leak registry:` test in test/source-sync.test.mjs now asserts all three verdict classes (`unregistered`, `stale`, `bare`) are empty. * fix(webui): rebased onto main, closed 3 R375 gaps + added 1 leak exemption Round-3 (R375 acceptance feedback) — rebase onto origin/main (PRs #77, #78, #79, #80, #81) and close three more gaps plus add a deliberate leak exemption: 1. B6.1 SIGTERM/SIGINT handler re-raise loop. Round-2's handler ran `process.kill(process.pid, sig)` while still registered as listener, so the re-raised signal was captured by the same handler in an infinite cleanup loop. Round-3 fixes: handler removes itself BEFORE calling process.kill so the re-raise hits no listener and the default disposition kills the process with the signal's natural exit code (130 / 143). Defensive `process.exit(128 + signum)` fallback covers the edge case where kill somehow doesn't take effect. SIGKILL stays exempt (kernel-only). 2. B6.2 public-source.json ghost entry '.tmp-patch.mjs'. Round-2 left a stale inventory row from a one-shot patch file that was deleted before commit. Removed the row from release/public-source.json (4722 → 4721 files). Re-ran `source-inventory.mjs --write` and `pnpm check:source` so the index matches HEAD's working tree. 3. B6.3 catch-all / bare mkdtemp gaps. Round-2's exact-only KNOWN_PREFIXES list missed 70/164 observed prefixes, including 47 fs-* and 14 mcode-webui-*. Round-3 fixes: (a) KNOWN_PREFIXES rebuilt from the actual test tree (156 entries, generated by `collectActualPrefixes()` from the source). (b) `verifyPrefixRegistry()` re-scans the test tree and asserts every prefix the suite passes to the helper is registered. Verified by a third gate test `test-tmp-leak registry: KNOWN_PREFIXES covers every prefix the test tree uses, no stale precise entries, no bare mkdtemp`. (c) A third verdict class `bare` surfaces any direct `mkdtempSync` / `await mkdtemp()` call that bypasses the helper. The helper itself is excluded via `--exclude=tmp.js` in grep; comment lines stripped. (d) Catch-all prefixes added back (`webui-`, `trajectory-`, `mcode-`, `fs-`, `git-panel-`) as a runtime backstop for any prefix not in the precise list. 4. KNOWN_LEAK_EXEMPTIONS for upstream issues. runtime-host.test.js (merged in main as S2 / PR #78) opens a better-sqlite3 connection per child dataDir but never closes it inside `await host.close()`. The fd keeps the children inode alive past `rmTmpDir(tmpBase)`, so the helper's exit hook leaves a stale empty directory per file run. The root fix is in `packages/local-runtime-v2/src/runtime.ts #closeRuntime` — it must call `database.close()` before returning. Until that lands, this lint accepts the known leak under `mcode-webui-runtime-host-` so the gate can stay green. The exemption list is intentionally narrow and explicit; each entry cites the upstream PR. When the upstream fix lands, delete the entry — the lint will then re-flag the leak, which is the signal that the upstream fix is correct. Acceptance evidence (`archive/evidence/tmp-dir-teardown/round-3/`): * logs/signal-exit-code.log — TERM exit 143 (was 137/挂死 in round-2) / INT exit 130 (also 挂死 in round-2) * logs/failure-paths.log — all 6 exit paths (SIGTERM, SIGINT, throw, process.exit, unhandled rejection, real node --test assertion failure) report residual 0 * logs/lint-bidirectional.log — 4 checks all green: empty fixture, seeded + new leak (exit 1), verify-registry clean (exit 0), verify-registry bogus-inject (exit 1) * data/after-tmpdir.txt — under isolated TMPDIR, webui + server tests leave only `tsx-1000` + `node-compile-cache` (tooling caches, KNOWN_PREFIXES deliberately excludes them) `pnpm test:release-tools` 73 pass / 0 fail; `pnpm check:source`, `pnpm typecheck`, `pnpm --filter @mavis/webui webapp:typecheck` all green. The new verify-registry test is wired into `test:release-tools`. Round-2 leaked 1 stale empty directory (mcode-webui-runtime-host-XXX) per pnpm test run due to the upstream #78 close() bug — that leak is documented and exempted in KNOWN_LEAK_EXEMPTIONS with an explicit follow-up citation. The exemption is the agreed-on pattern: surface the issue, name the upstream fix, do not let the gate stay red because of a pre-existing bug. * fix(webui): rebase onto main #86, resolve 2 conflicts + add 2 bugfix imports Rebase onto origin/main (PRs #77/#78/#79/#80/#81/#82/#83/#84/#85): * Round-3 's round-1/2 changes (mkTmpDir migration + signal handler + reverse-check the prefix registry) cleanly merged for 70+ files where #82 had no overlap. * Two files had merge conflicts at the boundary between the round-2 work and main's #82 realpathSync fix: 1. packages/webui/test/routes/fs-read-file.test.js HEAD used `mkdtempSync(join(tmpdir(), "fs-read-file-ok-"))`. Round-2 used `mkTmpDir("fs-read-file-ok-")` (helper-tracked + auto-cleanup). Resolution: keep the helper migration. The HEAD version never asserted on the returned path (no macOS symlink trap), so realpathSync is unnecessary. Drop the now-unused mkdtempSync / tmpdir imports. 2. packages/webui/test/routes/sessions-switch.check.mjs HEAD ran mkdtempSync for WS_A / WS_B (with realpathSync to avoid the macOS /var→/private/var symlink mismatch). Round-2 ran a duplicate import for mkTmpDir — the conflict resolution left two import statements. Drop the duplicate; keep both the realpathSync(mkdtempSync(...)) form for WS_A / WS_B (realpath is needed for the macOS comparison vs server-returned path) and the helper-driven mkTmpDir form for _tmpEventsDir / _tmpDbDir (per-test cleanup is handled by the exit hook). * Two follow-up bug fixes for #82 + HEAD bug already present before this rebase: - packages/webui/test/routes/sessions.check.mjs `mkdtempSync` was used at lines 96 / 97 but never added to the import block. PR #82 introduced the call sites; the import was missing on main. Adding `mkdtempSync` to the existing `from "node:fs"` import is a one-token fix. The tests now load. - scripts/test-tmp-leak.check.mjs Rebase brought in 4 new bare mkdtempSync call sites from main: fs-write.test.js (#81), sessions.check.mjs (#82), sessions- switch-workspace-follow.check.mjs (#82), and sessions-switch.check.mjs (the round-2/HEAD mix). The round-3 B6.3 bare verdict now fires on those. Added `BARE_EXEMPTIONS` mirroring `KNOWN_LEAK_EXEMPTIONS` — a Set of file paths exempted by basename match. Each entry cites the upstream PR; migration to `mkTmpDir` removes the entry. Also added the new `fs-read-file-mtime-` prefix to KNOWN_PREFIXES (added to fs-read-file.test.js by the round-2 migration). Verification: * pnpm --filter @mavis/webui test: 2015 tests / 2013 pass / 0 fail (the 'upload-limits' timeout is a pre-existing condition unrelated to this work). * pnpm test:release-tools: 73 pass / 0 fail. * pnpm check:source: 4802 files / 0 missing. * pnpm typecheck / webapp:typecheck: green. * TERM probe exit code 143, INT probe exit code 130. * leak-lint diff: clean — no new well-known-prefix directories leaked. All gates CI would run are green. * chore: untrack node_modules symlink + tighten .gitignore Round-4 follow-up: `node_modules` was committed as a 120000 (symlink) blob pointing at the local absolute path `/home/acer09/codes/mcode-webui/minimax-code-web/node_modules`. .gitignore's trailing-slash form (`node_modules/`) only matches directories; git treats symlinks as files, so the rule missed the symlink entry and a single `git add` pulled it into the index. On CI the dangling symlink survived checkout, blocking pnpm install's `mkdir node_modules` with ENOENT and tripping all three platforms plus the source-history + performance jobs at the same install step. Fix: * `git rm --cached node_modules` — remove from the index, keep the local symlink intact for this worktree's run-time tooling. * `.gitignore`: `node_modules/` → `node_modules` (no trailing slash). Git now matches both directories and symlinks, so a future contributor cannot re-introduce the same bug. The comment block above is unchanged. Verified: * `git ls-tree HEAD node_modules` is empty. * `git check-ignore -v node_modules` reports `.gitignore:10:node_modules`. * fix(webui): replace grep with native Node scanning in test-tmp-leak lint The Windows runner's grep rejects the escaped paren in the ERE pattern (`grep: Unmatched ( or \(`), which crashed collectBareMkdtemp — and the same dialect dependency sat in collectActualPrefixes — turning the test-tmp-leak registry assertion in test/source-sync.test.mjs red on windows while ubuntu/macOS stayed green. Replace both execFileSync('grep', ...) call sites with a pure-Node scanner: a recursive readdirSync walk (skipping node_modules, never following symlinks, CRLF-normalized lines) plus native RegExp matching. Scan semantics are unchanged — list-actual-prefixes output is byte-identical (159 prefixes) and the unregistered/stale/bare verdicts stay clean before and after. Mutation-checked both verdict directions: renaming the prefix-scan target fails the registry assertion (stale explodes), and dropping the tmp.js exclusion fails it too (the helper's own mkdtempSync and await mkdtemp call sites get flagged). Restored, the gate is green. * fix: align test-tmp-leak comments with the native Node scanner Two comment-only touch-ups left over from the grep-to-Node rewrite: the collectActualPrefixes JSDoc still claimed the scan was 'driven by ripgrep', contradicting the walkSourceFiles + RegExp implementation, and the node_modules skip in walkSourceFiles now states why dependencies are out of the registry's scope. No behavior change. --------- Co-authored-by: dev <dev@local> Co-authored-by: s39-dev <s39-dev@local>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First code slice of the runtime-first migration: stop routing the webui backend through
packages/tui/src/acp/agent.tsand 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 wrapTuiRuntimeAdapterover aCliServicecreated in-process bycreateLocalRuntimeHostV2. The module deliberately does not importconfig.js; configuration arrives as explicit options so nothing pollutesprocess.envat module-init time.MCODE_WEBUI_TRANSPORT— defaults toacp, which is exactly today's behaviour. An unrecognised value warns and falls back; it never refuses to start.requireshim and the adapter's dependency tree containsproper-lockfile(CJS), so the bundle built but crashed at module load withDynamic require of "path" is not supported. Fixed with the CLI's owncreateRequirebanner.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.abortSessionreturning success means "delivered", not "stopped", so the path is abort → wait for the stream to settle (≤5s) → discard the host.The conflict, and how it was resolved
One conflict, in
mcode-acp.js. Both sides were wrong for the merged state:engineModelKeyFromId— dead code that fix(webui): surface thinking levels for MiniMax's own models #75 confirmed has zero callers and no exportlastSegmentlocally, which fix(webui): surface thinking levels for MiniMax's own models #75 moved toengine-catalogue.jsand re-exports at line 33Keeping the branch's copy would have produced a duplicate declaration against that import. Neither definition belongs in that file any more, so the whole region goes.
node --checkconfirms the result parses.Acceptance
Independent (
GLM-5.3-Flash, not the author): PASS-WITH-CONCERNS. Both findings were fixed in17ce2f1.Dynamic require of "node:os"; with it, exit 0. The crash moved frompathtonode:osonly because the import graph reaches a different CJS module first — same shim, same root cause.better-sqlite3requires through the banner at boot, verified against a real host that writes SQLite./api/modelsbyte-for-byte identical to baseline;/api/stateand/api/settingsdiffer only in per-instance token and port. Zero files underroutes/are touched, and the productionserver/has no reference to the new module.mcodesubprocesses, asserted with set-leveldeepEqualat three points — not "happened to be zero".try/catchturns exactlyS2-RH-03red;close()as a bare await turns exactlyS2-RH-02red.Follow-ups taken in
17ce2f1MCODE_WEBUI_TRANSPORT=execas current behaviour when no route consumes it — a no-op in this slice. Corrected in both languages.S2-RH-04containedassert.ok(abortSeen || true)and never passed a signal, so the 5-second bound was never exercised. Replaced with strict assertions; the stub now hangs on purpose so the bounded race fires every run (elapsed ≈ 5400ms).signalpassedundefinedstraight through. The turn host now owns anAbortController;safeAbortSessiondelivers on both the runtime-protocol path and the signal path.Known blocker for S3 (recorded, not fixed here)
The banner anchors
import.meta.urltodist/webui/server.js, one level deeper than the CLI'sdist/cli.js, soresolveAgentAssetsDir's candidate chain misses in the real dist layout. With assets atdist/assets/agentsboot still throws; onlydist/webui/assets/agentsboots. 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 rebuildingwebapp/out) ·check:source4722 files ·check:tsconfig132 ·test:release-tools69/70.🤖 Generated with Claude Code