Skip to content

feat(webui): M4-1 acp provider registration + P18 code block overflow fix - #158

Merged
fengzhi09 merged 60 commits into
mainfrom
dev-lhl
Oct 3, 2026
Merged

fengzhi09 merged 60 commits into
mainfrom
dev-lhl

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

内容(#157 合入后 dev-lhl 的新增)

  • M4-1(5c07d6c9→拣选 43f452ad,产品净 +140):acp 传输注册为引擎能力 registry 首个 provider(13 partial/full + 7 none 逐键如实);servedBy 双宿表达(turn-diff/plugins 由 v2 host 服务,只允许 none 条目+启动期校验);resolveCapabilityHostProvider() 为 M4-3 预备。兼容逐字节(缺省应答 diff 全等、16 个 resolve*Provider 双向钉住)。计划修正三处(registry 已有雏形/目录为 server/engine//unimplemented 是审计槽位)
  • P18(913e2b95→238c1d56,红线2):代码块超高溢出叠印正文修复——根因=滚动规则挂在非 flex item 的 <code> 上从未生效;修法=<pre> 建 flex 上下文+min-height:0(布局语义,上限未动);CDP 实测叠印 +635px→不叠印

门禁

各自 verify 18/18;acp 3331/0 与 3250/0;runtime 失败集 ⊆ 基线核(独立基线工作树对照);10+6 变异全红;六份/两份双语文档同批;推送 ref+tree 复查×2

ARCHITECTURE.md:492 counted five files under server/engine/ while the
directory has shipped six since #143. The missing one is
providers/local-runtime-v2.capabilities.js, the declaration-only module
whose sole import is ../capabilities.js — the split that keeps the v2
host's ~4.7 s TypeScript dependency tree off the boot path. The
local-runtime-v2.js row credited itself with the declaration it only
re-exports, so that credit moves to the file that actually defines it.

The zh-CN mirror takes the same edit in the same commit (equal weight);
check-docs-alignment.mjs resolves the new bare-path citation against the
merged tree.
…ine-abstraction M2)

Baseline: feat/engine-capabilities (M1, PR #143), NOT main — the
server/lib/engine -> server/engine path fix has not landed yet.

- test/lib/engine/capability-snapshot.test.js boots ONE real catalogue
  host on an isolated tmp data dir (MINIMAX_DATA_DIR + every
  MCODE_WEBUI_* path pinned before the provider import) and audits
  every full/partial key of both providers against the reflected
  surfaces: full requires every tracked method (REQUIRED_METHODS,
  derived from the live prototype chains — 91 adapter / 94 CliService
  methods — not copied from the design matrix); partial requires the
  present half to exist, method-named missing items to be genuinely
  absent (under-declaration goes red), and kebab-case sub-capability
  names to have no covering method; none is not method-checked.
- Mutation tests pin the checker itself: flipped level / deleted
  method / grown sub-capability each go red (also verified by hand:
  three file mutations red at exit 1, restored byte-identical).
- Registry-driven static guard: every registered provider declares
  exactly ENGINE_CAPABILITY_KEYS — typo keys cannot pass silently, and
  M4 providers are swept without editing the test.
- Docs: ARCHITECTURE.md/.zh-CN.md M2 section, webui.md/.zh-CN.md
  migration-state entry; tmp prefix registered in the leak gate.
… path

The M1 path move took server/lib/engine to server/engine. This file was
written against the old one and rebase carried the code forward without
carrying the import, so the suite failed on MODULE_NOT_FOUND and said
nothing about the capabilities it was meant to check.
…ther

Three leaks, all from state that lived outside the component that
should have owned it.

page.tsx read localStorage during render, so the pre-rendered HTML and
the first client frame could not agree — a skeleton screen was hiding it,
which is exactly the kind of cover that disappears the moment someone
edits the shell. The first frame now uses defaults and one mount effect
restores; the three write-back mirrors are gated so a default never
overwrites a stored value. Scroll position is read by the same sessionKey
effect Chat already had, which reads the same key.

The draft store was a module-level bucket, so a draft, a failure banner
and a model chip all followed you across sessions — type into one
conversation, switch, and your words are in the other. The store is
keyed by session now. Isolation is not discarding: switching back finds
the draft still there. Clearing was the alternative and it destroys an
unread banner every time you return to a conversation.

#126 left the accepted-but-unconfirmed banner without anything to
consume it when a turn ended. unconfirmedPatchOnTurnEnd clears it on the
falling edge of running, and only there — the three-value decision about
when to show it is untouched.
…the engine facade

Migration step M3, batch B0 (engine-abstraction). 13 endpoints across
routes/plugins.js and routes/turn-diff.js reached the catalogue host by
importing lib/acp-client.js#getCatalogueHost directly. They now call
getEngineCatalogueHost() from the facade.

- engine/host.js: the lazy bridge. Its only import is a dynamic
  `await import("../lib/acp-client.js")` inside the function body, so
  engine/index.js gains a function and not a module load. That boundary
  is the whole point: app.js reaches engine/index.js through
  routes/engine-capabilities.js, and a static import of acp-client there
  would put the ACP client tree on every server start — the regression
  M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once
  declaration and construction were split). The value is forwarded
  verbatim, `null` included, so "host did not boot" stays
  RUNTIME_UNAVAILABLE and never a second host.
- engine/index.js re-exports the getter; the two routes import it from
  there and no longer name acp-client.js.
- No endpoint behaviour changes: same wire shapes, statuses, codes, same
  `deps.getCliService` / `deps.getDiffApplication` seams, same one
  process-wide host. Measured on the module graph: routes/plugins.js
  drops from 13 product files + @mavis/shared to 8 files and zero bare
  packages; engine/index.js's whole closure is 6 files and 0 bare
  specifiers. Server start and the facade's own load are unchanged
  (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot
  stays in the same 200-300ms band) because lib/state-bus.js already
  pulls acp-client into app.js's boot graph — closing that edge belongs
  to the catalogue read/write batches (M3-B1+), not here.

Tests: test/lib/engine/host-facade.test.js pins the contract against the
real module graph rather than against source text — a resolve hook
(module.registerHooks) in a fresh process reports, per parent, which
specifiers each entry resolved. It asserts neither route has a direct
edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js
pulls no host module and no @mavis/* or @minimax/* package, that
engine/host.js is in that closure, and the source-shape tripwires
(dynamic import only, facade re-export). Mutation-checked: making the
facade import statically turns 4 tests red, making plugins.js import
directly turns 4 more red. The existing plugins/turn-diff suites pass
unchanged under both transports (158 tests x acp and x runtime).

Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains
engine/host.js on top of the six files #143 + the doc batch settled, the
"one host" rule now names the facade, and the boot-path discipline is
stated where the file list lives. docs/webui.md + .zh-CN.md are
untouched: no user-visible change. Source inventory regenerated for the
two new files (rebase conflict in it was resolved by taking the upstream
copy and regenerating, never by hand).
…ites immune to the gate's isolation env

The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and
MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those
exports take away from them:

- config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the
  gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR
  fixture and every DB-backed case resolved null against a scratch dir that
  holds no runtime-state.sqlite. The suite now exports the name that wins.
- config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB ||
  join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports
  MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach
  still cleared join(DATA_DIR, "sessions.json") — so each run read the
  previous run's records and the mid-run switch resolved an id whose
  workspace belonged to a since-removed tmp dir. Both chat-route suites now
  pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears.

Test-only: no server/ code, no helper under test/helpers/_setup.js, and no
assertion weakened or skipped. Verified with the three variables set, with
MCODE_WEBUI_SESSIONS_DB additionally set, and bare.
…the engine facade

Migration step M3, batch B0 (engine-abstraction). 13 endpoints across
routes/plugins.js and routes/turn-diff.js reached the catalogue host by
importing lib/acp-client.js#getCatalogueHost directly. They now call
getEngineCatalogueHost() from the facade.

- engine/host.js: the lazy bridge. Its only import is a dynamic
  `await import("../lib/acp-client.js")` inside the function body, so
  engine/index.js gains a function and not a module load. That boundary
  is the whole point: app.js reaches engine/index.js through
  routes/engine-capabilities.js, and a static import of acp-client there
  would put the ACP client tree on every server start — the regression
  M1 paid for once (209ms -> 2700ms; facade load 4685ms -> 5ms once
  declaration and construction were split). The value is forwarded
  verbatim, `null` included, so "host did not boot" stays
  RUNTIME_UNAVAILABLE and never a second host.
- engine/index.js re-exports the getter; the two routes import it from
  there and no longer name acp-client.js.
- No endpoint behaviour changes: same wire shapes, statuses, codes, same
  `deps.getCliService` / `deps.getDiffApplication` seams, same one
  process-wide host. Measured on the module graph: routes/plugins.js
  drops from 13 product files + @mavis/shared to 8 files and zero bare
  packages; engine/index.js's whole closure is 6 files and 0 bare
  specifiers. Server start and the facade's own load are unchanged
  (facade ~1.2ms -> ~3ms, i.e. one more 45-line zero-import file; boot
  stays in the same 200-300ms band) because lib/state-bus.js already
  pulls acp-client into app.js's boot graph — closing that edge belongs
  to the catalogue read/write batches (M3-B1+), not here.

Tests: test/lib/engine/host-facade.test.js pins the contract against the
real module graph rather than against source text — a resolve hook
(module.registerHooks) in a fresh process reports, per parent, which
specifiers each entry resolved. It asserts neither route has a direct
edge to acp-client/runtime-host/acp.mjs, that loading engine/index.js
pulls no host module and no @mavis/* or @minimax/* package, that
engine/host.js is in that closure, and the source-shape tripwires
(dynamic import only, facade re-export). Mutation-checked: making the
facade import statically turns 4 tests red, making plugins.js import
directly turns 4 more red. The existing plugins/turn-diff suites pass
unchanged under both transports (158 tests x acp and x runtime).

Docs: ARCHITECTURE.md + .zh-CN.md — the engine/ file table gains
engine/host.js on top of the six files #143 + the doc batch settled, the
"one host" rule now names the facade, and the boot-path discipline is
stated where the file list lives. docs/webui.md + .zh-CN.md are
untouched: no user-visible change. Source inventory regenerated for the
two new files (rebase conflict in it was resolved by taking the upstream
copy and regenerating, never by hand).
…ites immune to the gate's isolation env

The webui gate runs with MCODE_WEBUI_DATA_DIR, MCODE_WEBUI_SETTINGS_PATH and
MINIMAX_DATA_DIR exported at a scratch directory. Two suites read paths those
exports take away from them:

- config.js#resolveDataDir reads MINIMAX_DATA_DIR ?? MAVIS_DATA_DIR, so the
  gate's MINIMAX_DATA_DIR outranked mavis-usage.check.mjs's own MAVIS_DATA_DIR
  fixture and every DB-backed case resolved null against a scratch dir that
  holds no runtime-state.sqlite. The suite now exports the name that wins.
- config.js resolves SESSIONS_DB as MCODE_WEBUI_SESSIONS_DB ||
  join(WEBUI_DATA_DIR, "sessions.json"). A caller that exports
  MCODE_WEBUI_SESSIONS_DB redirects the store, while the suite's beforeEach
  still cleared join(DATA_DIR, "sessions.json") — so each run read the
  previous run's records and the mid-run switch resolved an id whose
  workspace belonged to a since-removed tmp dir. Both chat-route suites now
  pin MCODE_WEBUI_SESSIONS_DB to the same path their cleanup clears.

Test-only: no server/ code, no helper under test/helpers/_setup.js, and no
assertion weakened or skipped. Verified with the three variables set, with
MCODE_WEBUI_SESSIONS_DB additionally set, and bare.
…ransport (M3-B1)

The directory-read family — #9 acp-sessions, #10 acp-session-title, #72
protocol/list-sessions, #74 state, #75 health — reached the engine through
whatever MCODE_WEBUI_TRANSPORT happened to be, so "does the engine support
this" had no answer anywhere except the absence of a crash. server/engine/
session-reads.js gives it one: each endpoint declares the capability and the
provider method it needs, the facade checks the registered provider's
declaration first, and a provider that does not offer the read answers 501
through invokeHandler instead of an empty list.

Nothing on the wire moves. The facade forwards to the same acp-client
exports the routes already called, so the 30s cache, the cwd normalisation,
the 30s-stale sidebar push semantics and the catalogue-sessions projection
are the same code; handleHealth becomes async because the version now
resolves through the facade, which is why app-hono's legacy-parity helper
learned to await it. /api/state's snapshot field list is untouched —
snapshotViewFields and mcodeSessionsSnapshotFields are the first-frame render
contract and this batch adds and removes nothing.

Each read also reports where its bytes came from — catalogue, acp, or
acp-fallback when the runtime transport asked for a host that never booted.
That is metadata, not wire, and it is the difference between a sidebar that
degraded and one that pretends.

Two things this batch found rather than assumed: the catalogue host exposes
no version accessor, so /api/health keeps answering from the ACP initialize
mirror and says so rather than inventing a method; and protocol.js#72's old
test drove a mock key nothing read, so "the cwd filter works" had never
actually been proven.
…ade (M3-B2)

Routes #8 GET /api/session-tree and #11 GET /api/sessions/:id/export
through the engine facade instead of the transport, keeping every
response shape, status code and reason string unchanged.

The two families are separate files because their gate policies are
opposite. The tree is entirely engine data, so a provider that cannot
list sessions genuinely has no tree: assertSessionTreeCapability throws
and invokeHandler answers 501. Export's primary source is sessions.json
and the engine only contributes a best-effort transcript enrichment the
endpoint has always promised never to block on, so
checkSessionExportCapability reports and never throws — gating it hard
would delete working functionality in response to a declaration about a
capability the endpoint does not depend on. The tree route re-throws the
capability error, matched with the class's own instanceof helper rather
than a `.name` compare: `name` is a writable instance property, so a
stray `err.name = "…"` would silently turn that 501 back into the 200
soft-fail the gate exists to prevent. A test pins both halves — the real
class propagates, an impostor carrying the right `.name` does not.

Verified by exporting the real tree (303 rows, 32 projects, 299 nodes)
before and after and diffing every node's id/title/parent/depth: 3289
field comparisons, zero differences. A synthetic fixture covers what the
live data does not contain (orphans, cycles, four-level nesting, exotic
titles): 165 comparisons, zero differences. Two pre-existing shapes are
pinned because a "cleanup" would silently break them — child nodes carry
no `children` key (all 66 of them), and the response has no
parent_session_id key at all.
…acade (M3-B4)

#20 /api/account, #57 /api/models and #73 /api/protocol/capabilities now reach
the engine through three new engine/ modules instead of naming lib/mcode-rpc.js,
lib/models.js, lib/providers-config.js, lib/engine-catalogue.js and
lib/acp-client.js themselves.

Three modules because the three gate policies are all different: the account
read gates HARD on authCredentials.getAccountStatus (the same provider method
B3's #15/#16 read, so a provider that drops it takes both down together), the
model catalogue gates SOFT (its primary sources are files webui owns, so a hard
gate would delete a working picker), and #73 declares nothing at all because it
IS the declaration endpoint.

The model projection moved whole — three sources, the per-provider dedupe, both
builtin-tree annotations and the three derived figures are now named pure
functions pinned on their inputs, and #57 is verified by a full snapshot whose
oracle was captured from the pre-refactor implementation. Its read stays
synchronous so handleGetModels keeps its contract, which is also why
engine/model-reads.js is not re-exported from engine/index.js: its four sources
reach @mavis/shared and js-yaml, and the boot-path guard is right to refuse
that under the shared facade.

#73 is the one response body in the migration that changes: it gains an
`engine` key carrying the engine-capabilities view, with `providerFor` saying
whether the declaration came from the active transport's provider or from the
default one standing in. Every pre-existing key keeps its name, position and
value, and the ACP wire table is not replaced by the 14 matrix keys.
…ilities view (user-approved contract change)

`GET /api/protocol/capabilities` used to answer from two hand-maintained
places: `MCODE_ACP_CAPABILITIES`, a flat `{method: boolean}` table of the
ACP JSON-RPC surface, and the `initialize` agentInfo mirror. The engine's
DECLARED capability surface already existed — the 14-key per-provider
object that `GET /api/engine-capabilities` serves — so webui was carrying
two parallel answers to "what can this engine do", able to disagree, with
no test able to notice. This makes `capabilities` the declared object and
drops the wire table from the endpoint.

This is a reviewed, user-authorised endpoint contract change, not a refactor
side effect, and it is stated as such in the module header, in
`docs/API.md` and in both ARCHITECTURE twins. The twelve old accessors are
asserted GONE, so a consumer reading `capabilities.set_mode` gets
undefined and fails loudly rather than receiving a truthy object field.
No runtime consumer exists: nothing in `webapp/` reads this endpoint, and
`engine/capability-reads.js` no longer imports `lib/mcode-rpc.js` at all
(pinned by a static tripwire, because an unused import is behaviourally
inert and no behavioural test could see it).

The `engine` key the previous commit added is REMOVED rather than kept:
with `capabilities` already the declaration, an `engine` block would carry
the same 14 keys a second time in one response. What survives from that
shape is the provenance — `capabilitiesProvider` / `capabilitiesProviderFor`,
the honest bit that says whether the declaration came from the active
transport's provider or from the default one standing in — plus
`capabilitiesUnavailable` for the derived degradation roll-up. A test
counts the declaration's occurrences in the serialised body and requires
exactly one, so a second carrier is a red bar.

`MCODE_ACP_CAPABILITIES` is kept and stays pinned by
`test/lib/mcode-rpc.check.mjs`: it is still a true statement about the
ENGINE's ACP surface and `docs/CAPABILITIES.md` cites it as one. It has no
webui consumer left, recorded as debt in the module header rather than
deleted as a side effect.

`docs/webui.md` and `docs/webui.zh-CN.md` gain a diff here for the first
time in this migration: they carried the old response shape in their
endpoint tables, and an authorised contract change has to be documented
where the contract is written.
…ger has

Found by the dual-axis code review (Standards + Spec) before B4 merges. No
logic changes; both were documentation lying about the code next to it.

`engine/index.js` still described #73 as an additive change — "the response
body gains a key (`engine`, the engine-capabilities view) … additive rather
than a replacement". The second B4 commit made it a REPLACEMENT and deleted
the `engine` key, so the facade's own export table was the one place still
telling a reader the opposite of what the endpoint does. It now states the
replacement, why the `engine` key was removed rather than kept, and what
survived from it (the provenance keys and the derived roll-up).

`docs/ARCHITECTURE.md` and its zh-CN twin called `engine/account-reads.js`'s
read **synchronous**. It is `async` — `readEngineAccount` awaits a
`Promise.all` of dynamic imports — and the boot-path note the row pointed at
describes model-reads, not this module. The same two documents already
listed account-reads correctly under the `await import()` rule a few
paragraphs down, so the file contradicted itself in two languages at once.
Both rows now say asynchronous and point at the ordinary rule.

Also drops a dead `assertEngineCapability` import from
`engine/model-reads.js`: the soft gate inspects the declaration inline, so
the throwing helper was never called and its presence read as if the soft
path could still throw. Replaced by a comment saying why it is absent, so
the next reader does not "fix" it back in. And the one comment with Chinese
embedded mid-sentence (仓库 review 要求注释用英文) is now English; the header
parentheticals naming each family (账户读 / 模型目录读 / 能力声明读) stay, as
do the quoted product strings — `本地用户` is the real zh-CN value of
`userMenu.localUser` and `shell.tsx` cites it the same way.
M3 batch B5: #7 DELETE /api/sessions/:id, #4 POST /api/sessions/rename and
#6 POST /api/sessions/cleanup-orphans stop driving the store, the caches
and the engine's own local_runtime_* tables from the route. They ask
engine/session-writes.js instead, so the load -> resolve -> authorize ->
intent-audit -> mutate ordering — and the resurrection guard inside it —
becomes named, testable code rather than a two-line helper a route could
call out of order.

#7 and #6 gate HARD on sessionCrud.deleteSession, because the rows they
destroy are the engine's own; #4 declares no capability at all, because a
rename writes webui's own store and touches no engine surface. The policy
is decided by who owns the rows the write destroys, which is a different
question from the read families' and does not have the same answer twice
in a row here.

The facade exposes a plan/commit pair rather than one deleteSession(),
so the write-ahead audit still lands between "know what the user asked to
delete" and "delete it". Response bodies are built in the facade once,
which is what lets the #6 and #7 dryRun shapes be pinned byte-for-byte by
unit tests. No status code, response body or error code changes.

The 32-table delete SQL stays in lib/mcode-session-delete.js and is
reached by dynamic import; acp-client.js and four test files bind to that
specifier, so collecting it is a later batch's job. Recorded as KNOWN
DEBT, along with rename writing a webui-side label only, and delete not
detecting an in-flight session.
…rfaces

The authorize-button fix (08599472) surfaced five more primary surfaces
pairing text-text_default_inverted_static with bg_interaction_primary_default;
dark mode inverts that background to pure white while the token stays
near-white, so the label composites to white-on-white. Swap all of them to
text-text_label_primary_default and add a source-scan guardrail that keeps
the pairing out of primary surfaces while pinning the sanctioned status-badge
exception (toolbar).
M3-B6: #3 POST /api/sessions/switch now asks the engine facade instead of
reaching into lib/acp-client.js, lib/transcript.js, lib/mavis-usage.js,
lib/models.js and lib/config.js from the route.

The new engine/session-switch.js owns the four load-bearing facts the
~290-line handler had accumulated: the mvs-sid-first resolution order
(single base-session identity), the backfill decision and its read, the
workspace containment gate (which runs before any cs mutation, so a
refused switch leaves the client untouched), and the response body.

The gate is SOFT — it reports and never throws — because the switch's
primary data is webui's own record and both engine touches have a
defined degradation. Gating hard would remove a working endpoint over a
title and a transcript, and would do it on the default acp transport
first.

The route keeps what is its own: the "id required" 400, the status
mapping, the fail-closed audit and the SSE push — the audit has to land
after the switch has already mutated cs, and the push must not fire when
it fails.

Behaviour is unchanged and pinned: the four red lines (transcript
backfill, cumulative detection, workspace containment, single base
session identity) each get named tests with their negative half, and the
success body's key ORDER is compared as a string. Six mutations of the
facade were run to prove the tests are load-bearing.

KNOWN DEBT 1 in the new module records what this batch did NOT retire:
the 3-candidate transcript probe. The default acp transport has no
engine surface to replace it with, getMessages paginates where the probe
caps lines, their orderings differ, and export's enrichment is still
byte-pinned to the same candidates. What IS retired is the coupling —
the route no longer names lib/transcript.js, and the probe list is an
implementation detail behind one seam.
main's #148 squash is a subset of dev-lhl content (every main blob is an
older revision of a file dev-lhl evolved, verified blob-by-blob against
e595c7c). Resolving toward dev-lhl loses nothing and advances the merge
base past the squash, unblocking PR #149.
gitleaks' generic-api-key rule flags the deliberate sk-secret-should-
never-leak fixture that model-reads.test.js uses as a leak-prevention
tripwire (asserting the facade never serializes provider keys). The
value is fake and the assertion exists to catch real leaks; allowlist
the exact pairing instead of weakening the fixture.
The full-history scan flags two synthetic-credential fixtures: the
model-reads leak tripwire (fake provider keys asserting the facade
never serializes them) and the fs-credential-guard canaries (fake
id_rsa/pem bodies asserting the 403 guard). Pin their fingerprints in
.gitleaksignore; the .gitleaks.toml path allowlist for the same files
stays as a coarse first line.
The match-targeted entry missed the byok fixture key (the generic rule's
match string differs from the tripwire value the entry was written for).
Scope both entries to the two fixture files themselves — every finding
in them is synthetic by construction — and keep .gitleaksignore as the
precise fingerprint layer.
The batch plan transcribed the abort bound as 5s; the migrated file ran
2000ms. The product call (2026-10-03) takes the plan's value: the longer
grace gives a stubborn child more time to finalize at the cost of
'already stopped' staying a lie for three extra seconds. The pinning
test moves with it and KNOWN DEBT 1 records the resolution.
main's #149 squash is the dev-lhl tree at 3f5b8d2; everything since is
gitleaks fixture allowlists plus B7. Resolving toward dev-lhl advances
the merge base past the squash and keeps PR #150 to its real content.
fengzhi09 and others added 27 commits October 3, 2026 13:01
main's #151 squash is the dev-lhl tree at 4d904c3; everything since is
B8a/B8b. Resolving toward dev-lhl advances the merge base past the
squash and keeps PR #152 to its real content.
main's #152 squash is the dev-lhl tree before B9; B9 stacks on top.
Resolving toward dev-lhl advances the merge base past the squash.
main's #153 squash is the dev-lhl tree before B9 landed; resolving
toward dev-lhl advances the merge base past the squash.
main's #153 squash is the dev-lhl tree before B9; resolving toward
dev-lhl advances the merge base past the squash (SPEC rule 8).
main's #154 squash adds the streaming-send architecture section
(docs-only); resolving toward dev-lhl advances the merge base past the
squash (SPEC rule 8).
main's #154 squash brings the streaming-send architecture docs (lost
from dev in a push-order rollback); resolving toward dev restores them
and advances the merge base (SPEC rule 8).
…startup

The V2 agent cutover takes a dataDir lease before `mcode acp` can serve a
prompt, and the lease is a bare directory: mkdir acquires, rmdir releases, a
live holder heartbeats the directory mtime every `stale / 2`. A process killed
between the two leaves the directory behind, and an abandoned lease is then
indistinguishable from a held one except by that mtime.

The window was 30 minutes while the retry budget was 120 attempts at this
backoff shape — about 55 seconds. A waiter could not outlast the window, so
every engine launch during it spent the whole budget and then died with
`agent_name_conflict_migration_failed:lock`. One killed process therefore made
`mcode acp` unstartable for half an hour, and each blocked launch produced no
answer, no engine process and no session — the reported send regression.

The stale window drops to 2 minutes. That does not weaken the safety property:
proper-lockfile derives the heartbeat from the stale window, so "two missed
heartbeats before the lease is called abandoned" is unchanged, and the
migration re-inspects under the lease, so the worst a wrongly-considered stale
lease costs is one extra inspection rather than a double rewrite. The retry
budget rises to ~195s so a waiter survives one expiry and acquires instead of
dying at the moment the lease becomes reapable.

Verified against the live data directory: an orphaned lease took the engine
from 55450ms/exit=1 to 2030ms/exit=0, and an isolated webui instance returned
a real model reply with zero `acp exited` events.
main's #155 squash carries the send fix and B10 forward; resolving
toward dev-lhl advances the merge base past the squash (SPEC rule 8).
main's #155 squash carries the B8a/B8b content; resolving toward
dev-lhl advances the merge base past the squash (SPEC rule 8). The
send fix and B10 land on dev-lhl after this and ride the next PR.
…rtion

The macOS verify red was a TEST defect, not a product one. The product
never resolved a provider path beyond what its resolver returned:
`sources.cwd` is `join(process.cwd(), "models.json")`, and
`process.cwd()` is `getcwd(2)`, which returns a fully-resolved path on
every POSIX platform. The assertion built its expectation from the
literal string the test had chdir'd into, so the two agreed only when
the temp path had no symlink component — true on Linux CI, false on
macOS, where `/var` is a symlink to `private/var`.

Normalising the EXPECTED side is the fix, and the behaviour is now
pinned rather than assumed:

  - the assertion states the contract (`process.cwd()` + the file
    name) instead of re-deriving it;
  - a named regression test drives a real symlinked cwd and asserts
    webui applies no second resolution of its own;
  - the same symlink machinery is applied to the WRITE path, where the
    question is a security one: the store is 0600 via tmp+rename and
    carries every plaintext apiKey, so a write that resolved its path
    differently from the read would put the keys in a file the
    catalogue never reads;
  - a source tripwire fails if a `realpath` (or equivalent) is ever
    added to the provider path resolution, so the symptom is not
    "fixed" in product code next time.

Both the symlink behaviour and the tripwire were verified to bite: a
`realpathSync` injected into `loadProvidersConfig` turns the symlink
test, the sources test and the tripwire red.

The whole B11 suite was re-run with TMPDIR pointed at a symlinked
directory, which reproduces the macOS `/var` condition on Linux: 149
tests pass. The pre-fix assertion fails under exactly that condition
and the post-fix one passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main's #157 squash carries B11 (providers facade) and P16 (message
acknowledgement); resolving toward dev-lhl advances the merge base
past the squash (SPEC rule 8).
@fengzhi09
fengzhi09 merged commit de094d8 into main Oct 3, 2026
8 checks passed
fengzhi09 added a commit that referenced this pull request Oct 3, 2026
main's #158 squash carries M4-1 (acp provider registration) and P18
(code block overflow); resolving toward dev-lhl advances the merge
base past the squash (SPEC rule 8).
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