Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ab59020
docs(webui): the engine layer has six files, not five (webui-parity 107)
fengzhi09 Oct 1, 2026
1dc559a
test(webui): M2 capability-declaration snapshot vs the real host (eng…
fengzhi09 Oct 1, 2026
8c085fa
test(webui): point the capability snapshot at the engine layer's real…
fengzhi09 Oct 1, 2026
aa5ab47
fix(webui): stop the shell from carrying one session's state into ano…
fengzhi09 Oct 1, 2026
af01e0e
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
f1842ba
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
726d286
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
a4fad96
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
e7c0ce9
feat(webui): the five read endpoints ask the engine facade, not the t…
fengzhi09 Oct 1, 2026
e053ae7
feat(webui): the session-tree and export endpoints ask the engine fac…
fengzhi09 Oct 1, 2026
4fb8267
feat(webui): the usage endpoints ask the engine facade, and the deriv…
fengzhi09 Oct 1, 2026
88b9a48
fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, …
fengzhi09 Oct 1, 2026
6bc24bd
feat(webui): the account, model and capability reads ask the engine f…
fengzhi09 Oct 2, 2026
eb2a429
feat(webui): #73 swaps the ACP wire table for the 14-key engine-capab…
fengzhi09 Oct 2, 2026
2baf051
fix(webui): stop two B4 comments describing behaviour the code no lon…
fengzhi09 Oct 2, 2026
edf2b1e
feat(webui): move the session write family behind the engine facade
fengzhi09 Oct 2, 2026
1506cc2
fix(webui): drop whitespace text nodes in markdown tables and dedupe …
fengzhi09 Oct 2, 2026
8cca235
fix(webui): sweep the non-flipping inverted text token off primary su…
fengzhi09 Oct 2, 2026
eecd8c0
feat(webui): move session switch behind the engine facade
fengzhi09 Oct 2, 2026
0cfd51f
Merge main into dev-lhl
fengzhi09 Oct 2, 2026
e4cf052
chore: allowlist the leak-tripwire fixture in model-reads tests
fengzhi09 Oct 2, 2026
3f5b8d2
test(webui): pin session-writes cleanup-orphans test to isolated paths
fengzhi09 Oct 2, 2026
e7df93d
chore: ignore gitleaks fingerprints of deliberate test fixtures
fengzhi09 Oct 2, 2026
62814ff
chore: make the gitleaks fixture allowlists path-only
fengzhi09 Oct 2, 2026
3074010
feat(webui): move interrupt and load endpoints behind the engine facade
fengzhi09 Oct 3, 2026
063a43a
fix(webui): take the plan's 5s abort force-kill bound by product call
fengzhi09 Oct 3, 2026
90cf85e
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a9af820
docs(webui): add session-switch, interrupt and session-load to the ar…
fengzhi09 Oct 3, 2026
4d904c3
docs(webui): add the missing zh-CN section for the B5 write family
fengzhi09 Oct 3, 2026
fdc3ff2
fix(webui): make webui-only session delete return promptly instead of…
fengzhi09 Oct 3, 2026
dab453d
fix(webui): retire lossy streaming mirrors when the engine transcript…
fengzhi09 Oct 3, 2026
7138b5b
feat(webui): add the streaming-send capability gate and pure stream b…
fengzhi09 Oct 3, 2026
a2223f4
feat(webui): run send on the runtime transport behind the engine facade
fengzhi09 Oct 3, 2026
8b51fdd
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
964c0cf
feat(webui): answer set-mode and set-config-option with structured 50…
fengzhi09 Oct 3, 2026
bdde1eb
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a112e45
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
245a101
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
d9e181d
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
679d0fe
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
a078ee6
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
591ccff
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
b529543
fix(local-runtime): make an abandoned migration lease recoverable at …
fengzhi09 Oct 3, 2026
a8e56dc
feat(webui): move model and permission writes behind the engine facade
fengzhi09 Oct 3, 2026
48199c5
Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14…
fengzhi09 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ Two consequences of that table are deliberate rather than incidental:

**What the user sees.** The permission-mode selector and the model selector are hidden, not disabled and not accompanied by an error message (`webapp/lib/engine-capabilities.ts`, wired in `webapp/components/composer.tsx`). A toast would report a failure for something the user was never able to do, offer nothing to act on, and reappear on every click. The rule is fail-open: the controls are shown until the declaration positively says the engine cannot do it, so a failed or slow `/api/engine-capabilities` request never removes a working control.

### M3-B10: the model and permission writes move behind the facade (no behaviour change)

`POST /api/set-model` (#58) and `POST /api/permissions` (#59) are the second half of the model endpoint family; B4 moved its read, this batch moves the write. The reasoning leaves the route and lands in `packages/webui/server/engine/model-writes.js`, where it is named, exported and tested on its inputs.

**Nothing a client can observe changed.** Every status, response field, field order, warning string and engine push — including which push happens first and what it is allowed to say when it fails — is the one these two endpoints produced before. The boundary is:

| Concern | Home after B10 |
| --- | --- |
| webui id → engine wire value | `resolveEngineModelConfigValue` |
| variant channel vs effort channel, and the push order each implies | `planModelSelectionPush` |
| the `set_config_option` calls | `pushEngineModelSelection` / `pushEnginePermissionMode` |
| permission mode → label / engine value | `resolvePermissionSelection` |
| the rule for when the local `configOptions` snapshot may claim the engine's new effort | `applyThinkingEffortMirror` (the write stays in the route — `cs` is webui's own state) |
| body parsing, the 400s, the `cs.model` / `cs.permissions` writes, `pushStateFor`, the response bodies | `packages/webui/server/routes/model.js` |

Two forms the picker deals with are deliberately different and stay that way. What the **engine** receives is the wire form — `m:<provider>:<model>:u`, or `m:<provider>:<model>:v:<variant>` for a switchable builtin, plus a bare level for `thinkingEffort` and an engine vocabulary word for `permissionMode`. What **webui** records is the user-facing form — `cs.model.name` in `<providerKey>/<engineModelKey>`, `cs.model.thinking`, `cs.permissions` as a label. The map between them is what the suite pins, field by field, over one row per (engine option shape × request shape) in `packages/webui/test/lib/engine/model-writes.test.js`.

**The variant channel (ticket 36) is unchanged and now covered by name.** A switchable builtin (`thinking_config.mode: switchable`, e.g. MiniMax-M3) has no engine effort vocabulary — the engine rejects every `thinkingEffort` value for it — and advertises it only as the wire pair `v:thinking` / `v:none-thinking`. Such a pick is therefore **one** `model` push carrying both the model and the on/off level, with no second push at all. Every other model keeps the two-push contract: `model` first, then `thinkingEffort`, because the engine rejects an effort set when no model is selected. A cleared level on the variant channel means the engine's **default** variant, not "off" — the normaliser only knows `on` and `off`.

**The 4-second SSE race window is unchanged**, and now has both halves pinned. A pick stamps the fields the request actually carried, all with one timestamp, so `applyConfigOptionUpdate`'s ownership-aware mirror (`server/lib/mcode-acp.js`, ticket 08) defers the engine's wire-form echo for 4 seconds instead of letting it overwrite the chip a few milliseconds after the optimistic write. A field the request did *not* carry is not stamped, so a later cross-client change to that field still mirrors immediately.

**`contextWindow` is still recorded and never pushed.** The engine's ACP surface has no channel for it, so the pick is a webui-side preference the picker reflects immediately.

**These two endpoints are not gated, and that is an open decision rather than an oversight.** #59 writes `permissionMode` only, so gating it on `authCredentials.setPermissionMode` would be behaviourally inert today and safe against the shipped UI (the permission selector is already hidden under exactly that declaration) — it is one `assertEngineCapability` call. #58 also writes `thinkingEffort`, which is a *generic* config id: gating it the same way would make the thinking-effort control answer 501 for the same reason #68 does for an unrecognised id. Both branches are costed in the KNOWN DEBT section of `model-writes.js` — bridge `thinkingEffort` as a third bridged id, or accept the 501 and extend the frontend's degradation to a third control. Until that is decided, #58 keeps its pre-B10 behaviour.

**The bridge is no longer an unverified exemption.** `selectModel` and `setPermissionMode` — the two sub-items `MODE_WRITE_BRIDGED_CONFIG_IDS` names — are now in the snapshot audit's `REQUIRED_METHODS`, so a real booted host is checked for both of them on the adapter *and* the CliService surface, and a declaration that stops listing one goes red. Neither surface carries a `setThinkingEffort` / `selectThinkingEffort`, which is the fact the gating decision above turns on.

### Migration state and constraints

- **M1 done in this batch**: host construction (`createCatalogueHost`) moved verbatim into `server/engine/providers/local-runtime-v2.js`; `runtime-host.js` re-exports it, so every existing importer is untouched. No existing route's behaviour changed; `GET /api/engine-capabilities` is a new, additive endpoint.
Expand Down
27 changes: 27 additions & 0 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ GET /api/engine-capabilities[?provider=<id>]

**用户看到什么。** 权限模式选择器与模型选择器被**隐藏**,不是禁用,也不配任何错误提示(`webapp/lib/engine-capabilities.ts`,接线在 `webapp/components/composer.tsx`)。toast 会为一件用户从来就做不到的事报一次失败、无从处理、而且每点一次就再报一次。这条规则是 fail-open 的:控件会一直显示,直到声明明确说引擎做不到——因此一次失败或超时的 `/api/engine-capabilities` 请求绝不会拿掉一个本来能用的控件。

### M3-B10:模型与权限写搬进引擎门面(行为零变更)

`POST /api/set-model`(#58)与 `POST /api/permissions`(#59)是模型端点族的另一半:B4 搬了读,本批搬写。推理逻辑离开路由,落进 `packages/webui/server/engine/model-writes.js`,在那里被命名、导出,并按入参测试。

**客户端能观察到的一切都没变。** 每个状态码、每个应答字段、字段顺序、警告文案与引擎推送——包括哪一次推送先发生、失败时它被允许说什么——都与本批之前逐字节一致。边界如下:

| 关注点 | B10 之后的归属 |
| --- | --- |
| webui id → 引擎 wire 值 | `resolveEngineModelConfigValue` |
| variant 通道 vs 强度通道,以及各自蕴含的推送顺序 | `planModelSelectionPush` |
| `set_config_option` 调用 | `pushEngineModelSelection` / `pushEnginePermissionMode` |
| 权限模式 → 标签 / 引擎值 | `resolvePermissionSelection` |
| 本地 `configOptions` 快照何时可以宣称引擎的新强度 | `applyThinkingEffortMirror`(写仍留在路由——`cs` 是 webui 自己的状态) |
| 请求体解析、400、`cs.model` / `cs.permissions` 写入、`pushStateFor`、应答体 | `packages/webui/server/routes/model.js` |

选择器面对的两种形态本就不同,并且刻意保持不同。**引擎**收到的是 wire 形态——`m:<provider>:<model>:u`,可切换内置模型则是 `m:<provider>:<model>:v:<variant>`,另加 `thinkingEffort` 的裸档位与 `permissionMode` 的引擎词汇。**webui** 记录的是面向用户的形态——`<providerKey>/<engineModelKey>` 的 `cs.model.name`、`cs.model.thinking`、作为标签的 `cs.permissions`。两者之间的映射由测试逐字段钉住:`packages/webui/test/lib/engine/model-writes.test.js` 按(引擎选项形态 × 请求形态)每种组合一行。

**variant 通道(ticket 36)语义不变,并且现在被具名覆盖。** 可切换内置模型(`thinking_config.mode: switchable`,如 MiniMax-M3)没有引擎强度词汇——引擎会拒绝它的一切 `thinkingEffort` 取值——只以 `v:thinking` / `v:none-thinking` 这一对 wire 形态公布。因此这样的选择是**一次** `model` 推送,同时带上模型与开/关档位,压根没有第二次推送。其余模型保持双推送契约:先 `model` 后 `thinkingEffort`,因为引擎在未选模型时会拒绝设置强度。在 variant 通道上「清空档位」意味着引擎的**默认** variant,而不是「off」——归一化器只认 `on` 与 `off`。

**4 秒 SSE 竞态窗口不变**,且两个半边都被钉住。一次选择会为请求**实际携带**的字段打戳,全部共用同一个时间戳,于是 `applyConfigOptionUpdate` 的归属感知镜像(`server/lib/mcode-acp.js`,ticket 08)会把引擎的 wire 形态回声推迟 4 秒,而不是让它在乐观写入后几毫秒就覆盖芯片。请求**未**携带的字段不会被打戳,因此该字段后续的跨端变化仍会立即镜像。

**`contextWindow` 依旧只记录、不推送。** 引擎 ACP 面没有它的通道,因此这项选择是 webui 侧的偏好,选择器立刻就能反映。

**这两个端点没有挂门,而这是一个待人拍板的开口,不是疏漏。** #59 只写 `permissionMode`,所以把它挂到 `authCredentials.setPermissionMode` 上,今天在行为上是空转的,而且对已发布 UI 安全(权限选择器本来就按同一条声明被隐藏)——那只是一次 `assertEngineCapability` 调用。#58 还会写 `thinkingEffort`,而它是**通用** config id:照样挂门会让思考强度控件因为与 #68 遇到无法识别的 id 时完全相同的原因开始答 501。两个分支的成本都写在 `model-writes.js` 的 KNOWN DEBT 段——把 `thinkingEffort` 桥接成第三个 id,还是接受 501 并把前端降级扩到第三个控件。在拍板之前,#58 保持 B10 之前的行为。

**桥接不再是未经核实的豁免。** `selectModel` 与 `setPermissionMode`——`MODE_WRITE_BRIDGED_CONFIG_IDS` 点名的两个子项——现已进入快照审计的 `REQUIRED_METHODS`,因此真实启动的 host 会在 adapter **与** CliService 两个面上被检查这两个方法,而停止列出其中之一的声明会变红。两个面都没有 `setThinkingEffort` / `selectThinkingEffort`,这正是上面那个挂门决策所依据的事实。

### 迁移状态与边界

- **本批只做迁移第一步 M1**:host 构造(`createCatalogueHost`)原样移入 `engine/providers/local-runtime-v2.js`,`runtime-host.js` 转发导出,既有引用方零改动;没有任何现有路由行为变化,`GET /api/engine-capabilities` 是纯新增端点。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,37 @@ import {

const MANIFEST_FILE = 'agent-name-conflicts.json';
const EMPTY_REFERENCE_COUNTS = EMPTY_AGENT_NAME_CONFLICT_REFERENCE_COUNTS;
// Synchronous backup/rewrite needs a long-lived dataDir lease.
export const AGENT_NAME_CONFLICT_MIGRATION_LOCK_STALE_MS = 30 * 60_000;
const AGENT_NAME_CONFLICT_MIGRATION_LOCK_RETRIES = {
retries: 120,
// Synchronous backup/rewrite needs a dataDir lease, so the lease outlives the
// critical section rather than the other way round.
//
// The window is 2 minutes, and it was 30. The reason is not "2 minutes is
// enough to rewrite a database" — a live holder refreshes the lease every
// `stale / 2` (proper-lockfile derives the heartbeat from the stale window), so
// the ratio that actually matters — "two missed heartbeats before the lease is
// called abandoned" — is unchanged. What changed is the cost of a lease that
// is abandoned for real.
//
// This lease gates PROCESS STARTUP: `mcode acp` takes it during the V2 cutover
// and cannot serve a prompt without it. The lease is a bare directory, so a
// process killed between `mkdir` and `release` leaves it behind, and the only
// evidence of a live holder is the directory's mtime. A 30-minute window
// therefore made one killed process unstartable for half an hour — and the
// retry budget (55s) was far too short to outlast it, so every launch during
// that window burned 55s of backoff and then died with
// `agent_name_conflict_migration_failed:lock`. That is the outage: repeated
// sends producing no reply, no engine process, and no session.
//
// The migration itself is idempotent and re-inspects under the lease
// (`ensureAgentNameConflictMigrationLocked`), so the worst a wrongly-considered
// stale lease can cost is one extra inspection — not a double rewrite.
export const AGENT_NAME_CONFLICT_MIGRATION_LOCK_STALE_MS = 2 * 60_000;
// The wait must be able to OUTLAST the stale window, or a waiter gives up at
// the moment the abandoned lease becomes reapable. At this backoff shape the
// 120 retries summed to ~55s against a 30-minute window — a waiter could never
// win, only die. 400 retries sum to ~195s, which rides out one full stale
// expiry and then acquires.
export const AGENT_NAME_CONFLICT_MIGRATION_LOCK_RETRIES = {
retries: 400,
factor: 1.2,
minTimeout: 25,
maxTimeout: 500,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, rm, utimes } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import lockfile from 'proper-lockfile';
import {
AGENT_NAME_CONFLICT_MIGRATION_LOCK_RETRIES,
AGENT_NAME_CONFLICT_MIGRATION_LOCK_STALE_MS,
withAgentNameConflictMigrationLock,
} from '../../src/persistence/migration/agent-name-conflict-migration.js';

// The lease that gates `mcode acp` startup. `proper-lockfile` represents it as
// a bare directory (`<dataDir>.lock`): mkdir acquires, rmdir releases, and 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
// indistinguishable from a live one except by that mtime.
//
// These tests pin the two properties the outage depended on. An abandoned lease
// must become reapable on a timescale a process launch can wait out, AND a
// lease that really is held must still be respected. Both halves matter: a fix
// that only shortened the window would trade a startup outage for two
// processes inside one critical section.

const cleanups: Array<() => Promise<void | void>> = [];

afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!();
});

async function makeDataDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'agent-name-conflict-lock-'));
cleanups.push(async () => {
await rm(dir, { recursive: true, force: true });
});
cleanups.push(async () => {
await rm(`${dir}.lock`, { recursive: true, force: true });
});
return dir;
}

/** Create the lease directory and age its mtime to `ageMs` in the past. */
async function plantAgedLease(dataDir: string, ageMs: number): Promise<void> {
const leaseDir = `${dataDir}.lock`;
await mkdir(leaseDir, { recursive: true });
const when = new Date(Date.now() - ageMs);
await utimes(leaseDir, when, when);
}

/**
* An abandoned lease of a fixed, real-world age.
*
* The age is ABSOLUTE and deliberately not derived from the stale constant.
* A fixture aged by `STALE + margin` is reaped instantly under any value of
* the constant, so it passes whatever the constant is — it cannot fail, and a
* test that cannot fail is decoration. Five minutes is the shape of the outage
* this pins: a lease orphaned 15+ minutes earlier by a killed process, still
* blocking every engine launch.
*/
const ABANDONED_LEASE_AGE_MS = 5 * 60_000;

describe('agent name conflict migration lease', () => {
it('takes over an abandoned lease promptly instead of burning the whole retry budget', async () => {
const dataDir = await makeDataDir();
await plantAgedLease(dataDir, ABANDONED_LEASE_AGE_MS);

const startedAt = Date.now();
await expect(withAgentNameConflictMigrationLock(dataDir, () => 'reaped')).resolves.toBe(
'reaped',
);

// Fast, not merely eventually correct. Before the fix a 30-minute stale
// window made this wait out the whole ~55s retry budget and then throw,
// because the budget could not outlast the window — which is what killed
// every engine launch.
expect(Date.now() - startedAt).toBeLessThan(10_000);
});

it('still leaves a lease that is genuinely held alone', async () => {
const dataDir = await makeDataDir();
// A real holder, taken with the same library the migration uses, so the
// lease directory is a genuine one and its mtime is a live heartbeat —
// which is the ONLY thing that separates a held lease from an abandoned
// one. This is the half that guards against over-correcting: if the stale
// window were shortened to the point of reaping a beating lease, the
// takeover would trade a startup outage for two processes inside one
// critical section.
const release = await lockfile.lock(dataDir, {
stale: AGENT_NAME_CONFLICT_MIGRATION_LOCK_STALE_MS,
});

let entered = false;
const acquisition = withAgentNameConflictMigrationLock(dataDir, () => {
entered = true;
});
// Long enough to prove it is waiting rather than barging in.
await new Promise((resolve) => setTimeout(resolve, 3_000));
expect(entered).toBe(false);

// Once the holder releases, the waiter proceeds on its own.
await release();
await acquisition;
expect(entered).toBe(true);
});

it('keeps the retry budget long enough to outlast the stale window', async () => {
// The invariant behind both tests: a waiter must be able to survive one
// abandoned-lease expiry and then acquire, rather than dying at the moment
// the lease becomes reapable. With 120 retries at this backoff shape the
// wait was ~55s against a 30-minute window, so it could only ever lose.
const { retries, factor, minTimeout, maxTimeout } =
AGENT_NAME_CONFLICT_MIGRATION_LOCK_RETRIES;
let total = 0;
let delay = minTimeout;
for (let attempt = 0; attempt < retries; attempt += 1) {
total += delay;
delay = Math.min(delay * factor, maxTimeout);
}

expect(total).toBeGreaterThan(AGENT_NAME_CONFLICT_MIGRATION_LOCK_STALE_MS);
});
});
Loading
Loading