From 2b0be57dbd69e179e43542aee3ceb9a7c3546ee6 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Tue, 20 Oct 2026 14:13:26 +0800 Subject: [PATCH 1/2] chore(runtime): adopt ZCode 3.16.2 / runtime 0.17.0 - sync zcode-runtime.lock.json to the 3.16.2 linux-x64 deb - re-anchor session model recovery, login defaults, prompt model and SQLite recovery to 0.17.0 - route --model overrides through CliDefaultSelection without persisting a session change - install SQLite write recovery on the single async open path and include softDeleteSession - update tests and docs for the 3.16.2 runtime --- LICENSES/README.md | 2 +- README.md | 17 ++-- docs/CONFIGURATION.md | 12 +-- docs/CONFIGURATION.zh-CN.md | 10 +- docs/DEVELOPMENT.md | 9 +- docs/SQLITE_CONCURRENCY.md | 38 ++++---- docs/THIRD_PARTY_CONTENT.md | 4 +- package.json | 2 +- scripts/runtime-shared-oauth-patches.ts | 2 +- scripts/runtime-sqlite-patches.ts | 69 +++++++++----- scripts/sync-runtime.ts | 27 ++++-- src/runtime-sqlite-recovery.ts | 5 +- test/node/sqlite-session-store.test.cjs | 8 +- test/prompt-model.test.ts | 39 ++++++-- test/runtime-sqlite-patches.test.ts | 19 ++-- test/sync-runtime.test.ts | 116 ++++++++++++++++++------ zcode-runtime.lock.json | 6 +- 17 files changed, 260 insertions(+), 125 deletions(-) diff --git a/LICENSES/README.md b/LICENSES/README.md index bc7572fc..5c572969 100644 --- a/LICENSES/README.md +++ b/LICENSES/README.md @@ -23,7 +23,7 @@ their upstream originals byte for byte. revision. It is copied in its original language. The public source revision declares version 3.14.0. It is distinct from the -Desktop 3.14.1 artifact pinned by this project's runtime lock. These notices +Desktop 3.16.2 artifact pinned by this project's runtime lock. These notices preserve upstream attribution; they are not a claim that this CLI was built from that exact commit. The third-party inventory covers multiple upstream distributions and may list components absent from our npm package. diff --git a/README.md b/README.md index 65edb39b..897af29c 100644 --- a/README.md +++ b/README.md @@ -328,13 +328,16 @@ is forwarded to the runtime unchanged. Workflow creation and saved-workflow startup require the native workflow mode to be enabled explicitly: start the CLI with `--workflow-mode alwaysOn` or -`--workflow-mode onDemand` (the runtime default is `disabled`). During a -workflow approval, `Change concurrency` and, when supported, `Change default -model` apply only to that workflow. From `/workflows`, `Adjust concurrency` -changes a live run in place without replaying or restarting it. `Save workflow` -writes the run's actual script to `.zcode/workflows`; `/workflows saved` starts -a new run from that definition and validates its arguments through the native -runtime. +`--workflow-mode onDemand` (the runtime default is `disabled`). Tool search is +opt-in per launch: `--toolsearch-mode auto|native|local|off` (default `off`). +`auto` uses the provider's native tool-search protocol when available and falls +back to local discovery, `native` requires an Anthropic Messages provider, and +`local` requires tool-call support. During a workflow approval, `Change +concurrency` and, when supported, `Change default model` apply only to that +workflow. From `/workflows`, `Adjust concurrency` changes a live run in place +without replaying or restarting it. `Save workflow` writes the run's actual +script to `.zcode/workflows`; `/workflows saved` starts a new run from that +definition and validates its arguments through the native runtime. `/queue` manages local next-turn drafts and pauses automatic sending while its picker is open. Inputs already accepted by the runtime cannot be edited here. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d48db2b7..17b5e194 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -165,7 +165,7 @@ custom MCP servers. | `oauth` | HTTP / SSE | Optional OAuth configuration; see below | | `enabled` | All | Set to `false` to disable a server while keeping its configuration | | `timeoutMs` | All | Optional positive number in milliseconds; use this field instead of `timeout` or `startup_timeout_sec` | -| `protocolVersion` | All | Optional `"auto"`, `"legacy"` or `"2026-07-28"` in runtime 0.16.9; normally leave unset for automatic negotiation | +| `protocolVersion` | All | Optional `"auto"`, `"legacy"` or `"2026-07-28"` in runtime 0.17.0; normally leave unset for automatic negotiation | For OAuth, `oauth.type` accepts `"authorization_code"` or `"client_credentials"`. Both accept `clientId`, `clientSecret`, `clientName` and `scope`; client credentials @@ -200,10 +200,10 @@ Plugin controls remain under `plugins`, hook declarations under These paths distinguish the packaged CLI from the upstream application. The source references below are pinned to upstream [`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e). -This package's runtime lock selects the separate Desktop 3.15.3 artifact; the +This package's runtime lock selects the separate Desktop 3.16.2 artifact; the public source snapshot is not an assertion of that artifact's build commit. -The configuration boundaries were rechecked on 2026-10-16 against -[`aac4755` / `v3.15.1`](https://github.com/zai-org/ZCode/tree/aac4755666d09fdcd70272fcf063c077a639015f). +The configuration boundaries were rechecked on 2026-10-20 against +[`0a1fc56` / `0.17.0`](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411). The pinned links below retain the original audit references. | Client / source | User MCP file | JSON path | @@ -452,8 +452,8 @@ inspects live runs, changes concurrency in place, and saves the current script; `/workflows saved` lists project and global definitions and starts a new run from a definition rather than replaying old progress. -In runtime 3.15.1, risk confirmations are single-use: the picker offers only -**Allow once** and **Deny**, never a project or session-wide grant. Guarded is a +Risk confirmations are single-use: the picker offers only **Allow once** and +**Deny**, never a project or session-wide grant. Guarded is a rule-based confirmation policy, not a sandbox or a guarantee that every potentially destructive command will be recognized. Planning restrictions still take precedence when Plan is enabled. diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index 2c05dd8e..f83d2d32 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -144,7 +144,7 @@ Runtime 每次启动都会读取此文件,修改 MCP 设置后重启 ZCode 即 | `oauth` | HTTP / SSE | 可选,OAuth 配置,见下文 | | `enabled` | 全部 | 设为 `false` 可禁用服务器并保留配置 | | `timeoutMs` | 全部 | 可选,正数,单位为毫秒;超时应使用此字段,`timeout` 和 `startup_timeout_sec` 不生效 | -| `protocolVersion` | 全部 | Runtime 0.16.9 支持可选值 `"auto"`、`"legacy"`、`"2026-07-28"`;通常省略,使用自动协商 | +| `protocolVersion` | 全部 | Runtime 0.17.0 支持可选值 `"auto"`、`"legacy"`、`"2026-07-28"`;通常省略,使用自动协商 | `oauth.type` 支持 `"authorization_code"` 和 `"client_credentials"`。 两者都支持 `clientId`、`clientSecret`、`clientName`、`scope`;client credentials 模式 @@ -173,10 +173,10 @@ MCP 服务器按名称合并。当前 runtime 中,**同名服务器的用户 下面区分本发行包与上游应用。源码引用固定在上游 [`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e)。 -本项目 runtime lock 选择的是独立发布的 Desktop 3.15.3 产物,不能据此认定该产物 +本项目 runtime lock 选择的是独立发布的 Desktop 3.16.2 产物,不能据此认定该产物 由这个公开源码提交构建。 -2026-10-16 已针对 -[`aac4755` / `v3.15.1`](https://github.com/zai-org/ZCode/tree/aac4755666d09fdcd70272fcf063c077a639015f) +2026-10-20 已针对 +[`0a1fc56` / `0.17.0`](https://github.com/zai-org/ZCode/tree/0a1fc56d279fd1001f78387bd81aec1c26767411) 复核配置边界;下方固定链接保留原始审计出处。 | 客户端/配置源 | 用户级 MCP 文件 | JSON 路径 | @@ -383,7 +383,7 @@ CLI 提供四种权限模式:`build`(变更前确认)、`edit`(自动编 `/workflows` 可查看运行中的工作流、原地调整并发或保存当前脚本;`/workflows saved` 列出项目级和全局已保存定义,并从定义启动一个新的 run,而不是回放旧进度。 -runtime 3.15.1 的风险确认只允许本次授权:选择器仅提供 **Allow once** 和 **Deny**, +风险确认只允许本次授权:选择器仅提供 **Allow once** 和 **Deny**, 不会提供项目或会话级永久放行。自主模式是基于规则的确认策略,不是沙箱, 也不保证识别所有潜在破坏性指令。Plan 开启时,计划模式的执行限制仍然优先。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 70ecc271..8997e1ea 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -88,8 +88,9 @@ focus, search, selection, images and overlays. `bun run verify:tui-perf` runs the complete quality and performance gate, including those smoke checks. The three runtime patches `http-no-content`, `bigmodel-oauth` and `cli-help-contract` also remain in the -patch plan: Desktop 3.15.3 already supplies their behavior, while their -verification and legacy branches still support older local Desktop artifacts. +patch plan as required verification points: Desktop 3.16.2 already supplies +their behavior, so they report `already_present` and never rewrite the runtime. +Synchronization still fails if a future upstream build drops the behavior. ## TUI startup compatibility @@ -285,8 +286,8 @@ value; an environment override remains authoritative. layers explicitly: ```text -zcode-app-cli 3.3.6-4 -zcode-runtime 0.15.2 +zcode-app-cli 3.16.2-37 +zcode-runtime 0.17.0 ``` ## OAuth login override diff --git a/docs/SQLITE_CONCURRENCY.md b/docs/SQLITE_CONCURRENCY.md index 96b17320..6497b618 100644 --- a/docs/SQLITE_CONCURRENCY.md +++ b/docs/SQLITE_CONCURRENCY.md @@ -39,18 +39,21 @@ SQLite and bounded backoff, not by the process-local queue. The operation allowlist and maintenance SQL were reviewed against upstream `zai-org/ZCode` commit `29628c9acdb81b703bbd4080c207a0e7ce5e276e`. -On 2026-10-16 the store implementation was rechecked against -`aac4755666d09fdcd70272fcf063c077a639015f` (public 3.15.1); the -`sqlite-session-store.ts` facade is unchanged between those revisions. These -source references do not identify the separate Desktop artifact's build commit. -The required -`sqlite-write-recovery` patch validates both post-migration hooks and the native -cleanup transaction. Missing, ambiguous or partially patched anchors stop -synchronization. The implementation is in `src/runtime-sqlite-recovery.ts`, -with scoped native waits and cleanup admission in its two SQLite helpers; -`vendor/cli-config.cjs` is their existing distribution boundary. - -Required regression coverage includes both store-open paths, recovered and +On 2026-10-20 the store implementation was rechecked against +`0a1fc56d279fd1001f78387bd81aec1c26767411` (public 0.17.0 / Desktop 3.16.2). +That revision replaces the synchronous constructor and the separate +`openStartup()` migration with one `SqliteSessionStore.open()` path, adds +`softDeleteSession`, and runs checksum-validated migrations with a backup +directory. These source references do not identify the separate Desktop +artifact's build commit. +The required `sqlite-write-recovery` patch validates the single post-migration +hook and the native cleanup transaction. Missing, ambiguous or partially +patched anchors stop synchronization. The implementation is in +`src/runtime-sqlite-recovery.ts`, with scoped native waits and cleanup admission +in its two SQLite helpers; `vendor/cli-config.cjs` is their existing +distribution boundary. + +Required regression coverage includes the single store-open path, recovered and exhausted locks, responsive timers during backoff, same-process connections, input-promotion rollback, ordered updates, close during recovery, preserved non-BUSY errors, and sustained writes from 10–15 independent processes. Cleanup @@ -64,9 +67,12 @@ The CLI's shared session database uses SQLite WAL. Readers can overlap a writer, but different CLI processes still serialize writes to this file. The original #163 mitigation sets a 10-second connection timeout after initialization. This remains the default outside the scoped retryable calls described above. -Both the synchronous constructor and asynchronous `openStartup()` path apply this -setting **after** migrations finish. Startup migration lock budgets, short busy -waits, backoff, rollback, and failure cleanup are unchanged. +Desktop 3.16.2 opens the store once through `SqliteSessionStore.open()`; its +migration helper restores a 5-second `busy_timeout` in `finally`, so the patch +re-applies 10 seconds on the same connection before the store is returned. +Startup migration lock budgets, short busy waits, backoff, rollback, and failure +cleanup are unchanged. The runtime creates a migration backup under the CLI data +root and validates `schema_migration` checksums before applying SQL. This is a bounded contention mitigation, not unlimited concurrency or a retry of an entire agent turn. A lock held beyond the budget can still fail. The change @@ -121,7 +127,7 @@ is not used as a substitute for Node's SQLite driver. ## Follow-up boundary -The current lock selects Desktop 3.15.3 / runtime 0.16.9. Rebuild with +The current lock selects Desktop 3.16.2 / runtime 0.17.0. Rebuild with `sync:locked` before release validation; `sync:local` records a local App source and does not establish the locked installer's provenance. The regressions do not establish that every production workload is free of contention. Keep #162 diff --git a/docs/THIRD_PARTY_CONTENT.md b/docs/THIRD_PARTY_CONTENT.md index 0e22ddfc..9855f27c 100644 --- a/docs/THIRD_PARTY_CONTENT.md +++ b/docs/THIRD_PARTY_CONTENT.md @@ -18,7 +18,7 @@ compatibility patches applied. The [upstream notices shipped here](../LICENSES/README.md) come from a separately pinned public source revision. That revision declares version 3.14.0; the current -runtime lock selects Desktop 3.14.1. The notices are a conservative upstream +runtime lock selects Desktop 3.16.2. The notices are a conservative upstream inventory, not proof that the extracted binary was built from that exact commit or that every listed component is included in this npm package. Review artifact contents and applicable terms when updating the runtime. @@ -41,7 +41,7 @@ patches; the patch report is in `vendor/extraction.json`. ## Plugin declarations -These are the declarations observed in the Desktop 3.14.1 packages, not a +These are the declarations observed in the Desktop 3.16.2 packages, not a replacement for nested dependency or asset licenses: | Package directory | Manifest declaration | diff --git a/package.json b/package.json index 8416513a..5a9e7922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-app-cli", - "version": "3.15.3-37", + "version": "3.16.2-37", "description": "Unofficial terminal client for the ZCode agent runtime", "keywords": [ "agent", diff --git a/scripts/runtime-shared-oauth-patches.ts b/scripts/runtime-shared-oauth-patches.ts index 21019c4e..504696e2 100644 --- a/scripts/runtime-shared-oauth-patches.ts +++ b/scripts/runtime-shared-oauth-patches.ts @@ -60,7 +60,7 @@ export function patchRuntimeSharedOAuth(runtime: string): string { const resolver = namedFunction(runtime, "createCodingPlanApiKeyResolver"); const resolverSource = runtime.slice(resolver.start, resolver.end); - // 3.15.3 sends bare business tokens to metadata endpoints and Bearer tokens + // 3.16.2 sends bare business tokens to metadata endpoints and Bearer tokens // to project issuance. Both forms must participate in native 401 recovery. replace(resolver, /new Headers\(([A-Za-z_$][\w$]*)\.headers\)\.get\("Authorization"\)===`Bearer \$\{([A-Za-z_$][\w$]*)\.business\.token\}`/gu, match => `(new Headers(${match[1]}.headers).get("Authorization")===${match[2]}.business.token||${match[0]})`, "business credential rejection"); diff --git a/scripts/runtime-sqlite-patches.ts b/scripts/runtime-sqlite-patches.ts index fcbce967..c9f673e0 100644 --- a/scripts/runtime-sqlite-patches.ts +++ b/scripts/runtime-sqlite-patches.ts @@ -4,6 +4,7 @@ const pragma = `pragma busy_timeout = ${sqliteBusyTimeoutMs}`; const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; const identifier = "[A-Za-z_$][\\w$]*"; const boundedIdentifier = "[A-Za-z_$][\\w$]{0,80}"; +const storeVariable = "$zStore"; function escape(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); @@ -13,36 +14,58 @@ function count(source: string, pattern: RegExp): number { return [...source.matchAll(pattern)].length; } -/** Both open paths must retain their steady-state timeout after migration. */ +/** + * Desktop 3.16.2 / runtime 0.17.0 has one async store-open path: + * + * let migration = await runSqliteSessionMigrationsAsync(db, dbPath, options); + * return new SqliteSessionStore(db, dbPath, options, migration); + * + * The migration helper restores its 25 ms native wait to 5 s in `finally`. + * Keep a 10 s steady-state wait on the same connection before the store is + * handed to callers. + */ +const openAnchor = new RegExp( + `let (${identifier})=await (${identifier})\\((${identifier}),(${identifier}),(${identifier}\\?\\?\\{lockWaitTimeoutMs:${identifier}\\})\\);return new (${identifier})\\(([^)]*)\\)`, + "u" +); +const openedAnchor = new RegExp( + `let (${identifier})=await (${identifier})\\((${identifier}),(${identifier}),(${identifier}\\?\\?\\{lockWaitTimeoutMs:${identifier}\\})\\);(${identifier})\\.exec\\("${escape(pragma)}"\\);`, + "u" +); +const storeRecovery = new RegExp( + `let \\${storeVariable}=new (${identifier})\\(([^)]*)\\);${escape(helper)}\\.installSqliteWriteRecovery\\(\\${storeVariable}\\);return \\${storeVariable}`, + "u" +); + export function hasRuntimeSqliteBusyTimeout(runtime: string): boolean { - const recovery = escape(`,${helper}.installSqliteWriteRecovery(`); - const sync = new RegExp(`try\\{${identifier}!==${identifier}&&\\(${identifier}\\(this\\.db,this\\.dbPath,${identifier}\\),this\\.db\\.exec\\("${pragma}"\\)(?:${recovery}this\\))?\\)\\}catch`, "gu"); - const startup = new RegExp(`try\\{return await ${identifier}\\((${identifier})\\.db,\\1\\.dbPath,${identifier}\\),\\1\\.db\\.exec\\("${pragma}"\\)(?:${recovery}\\1\\))?,\\1\\}catch`, "gu"); - return count(runtime, sync) === 1 && count(runtime, startup) === 1; + const opened = openedAnchor.exec(runtime); + if (!opened || opened[6] !== opened[3]) return false; + const after = runtime.slice(opened.index + opened[0].length, opened.index + opened[0].length + 400); + return /^(?:return new [A-Za-z_$][\w$]*\([^)]*\)|let \$zStore=new [A-Za-z_$][\w$]*\([^)]*\);)/u.test(after) + && runtime.split(`.exec("${pragma}")`).length === 2; } export function patchRuntimeSqliteBusyTimeout(runtime: string): string { if (hasRuntimeSqliteBusyTimeout(runtime)) return runtime; - const sync = /try\{([A-Za-z_$][\w$]*)!==([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\(this\.db,this\.dbPath,([A-Za-z_$][\w$]*)\)\}catch/gu; - const startup = /try\{return await ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\.db,\2\.dbPath,([A-Za-z_$][\w$]*)\),\2\}catch/gu; - if (count(runtime, sync) !== 1 || count(runtime, startup) !== 1) { - throw new Error("ZCode runtime is incompatible with the SQLite busy-timeout patch (store migration anchors missing or ambiguous)."); + const anchor = openAnchor.exec(runtime); + if (!anchor || count(runtime, new RegExp(openAnchor.source, "gu")) !== 1) { + throw new Error("ZCode runtime is incompatible with the SQLite busy-timeout patch (store open anchor missing or ambiguous)."); } - const patched = runtime.replace(sync, (_match, mode, deferred, migrate, timeout) => - `try{${mode}!==${deferred}&&(${migrate}(this.db,this.dbPath,${timeout}),this.db.exec("${pragma}"))}catch` - ).replace(startup, (_match, migrate, store, options) => - `try{return await ${migrate}(${store}.db,${store}.dbPath,${options}),${store}.db.exec("${pragma}"),${store}}catch` + const [, migration, migrate, db, dbPath, options, store, args] = anchor; + const patched = runtime.replace( + anchor[0], + `let ${migration}=await ${migrate}(${db},${dbPath},${options});${db}.exec("${pragma}");return new ${store}(${args})` ); if (!hasRuntimeSqliteBusyTimeout(patched)) throw new Error("SQLite busy-timeout patch failed postcondition verification."); return patched; } export function hasRuntimeSqliteWriteRecovery(runtime: string): boolean { - const sync = `${helper}.installSqliteWriteRecovery(this)`; - const startup = new RegExp(`${escape(`.db.exec("${pragma}"),${helper}.installSqliteWriteRecovery(`)}(${identifier})\\),\\1\\}catch`, "gu"); - return hasRuntimeSqliteBusyTimeout(runtime) - && runtime.split(sync).length === 2 - && count(runtime, startup) === 1 + if (!hasRuntimeSqliteBusyTimeout(runtime)) return false; + const opened = openedAnchor.exec(runtime); + if (!opened) return false; + const after = runtime.slice(opened.index + opened[0].length, opened.index + opened[0].length + 600); + return count(after, new RegExp(storeRecovery.source, "gu")) === 1 && runtime.split(`${helper}.pruneSqliteUsage(`).length === 2; } @@ -79,9 +102,13 @@ export function patchRuntimeSqliteWriteRecovery(runtime: string): string { throw new Error("SQLite recovery patch is partial or ambiguous."); } if (!hasRuntimeSqliteBusyTimeout(runtime)) throw new Error("SQLite recovery requires the post-migration timeout patch."); - const timeout = new RegExp(`(${identifier})\\.db\\.exec\\("${pragma}"\\)`, "gu"); - if (count(runtime, timeout) !== 2) throw new Error("SQLite recovery open boundaries are ambiguous."); - let patched = runtime.replace(timeout, (call, store) => `${call},${helper}.installSqliteWriteRecovery(${store})`); + const opened = openedAnchor.exec(runtime); + if (!opened) throw new Error("SQLite recovery store-open anchor is missing."); + const after = runtime.slice(opened.index + opened[0].length); + const storeReturn = /^return new ([A-Za-z_$][\w$]*)\(([^)]*)\)/u.exec(after); + if (!storeReturn) throw new Error("SQLite recovery store construction is missing or ambiguous."); + const patchedOpen = `${opened[0]}let ${storeVariable}=new ${storeReturn[1]}(${storeReturn[2]});${helper}.installSqliteWriteRecovery(${storeVariable});return ${storeVariable}`; + let patched = runtime.slice(0, opened.index) + patchedOpen + after.slice(storeReturn[0].length); patched = patchUsageCleanup(patched); if (!hasRuntimeSqliteWriteRecovery(patched)) throw new Error("SQLite recovery patch failed postcondition verification."); return patched; diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index ff2c148f..f57dd847 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -1059,7 +1059,9 @@ export function patchRuntimeSessionModelRecovery(runtime: string): string { const marker = "$zRestoredSessionModel"; if (runtime.includes(marker) && runtime.includes('"readSessionModelState"')) return runtime; const facade = /getModelOption:([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)=>[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)\.providerRegistry,\2\),"getModelOption"\)/u.exec(runtime); - const restore = /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\.validateSelection\(\2\);return (?:[^;]*?,)?([A-Za-z_$][\w$]*)\(\)\.setSessionModelSelection\(\1\?\.ok\?\2:void 0\),\2\},"restorePersistedModelSelection"/u.exec(runtime); + // 0.17.0 restores the persisted selection and mirrors it into both the + // runtime and the CLI default used by cli-default prompt admission. + const restore = /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\.validateSelection\(\2\);return (?:[^;]*?,)?([A-Za-z_$][\w$]*)\(\)\.setSessionModelSelection\(\1\?\.ok\?\2:void 0\),[A-Za-z_$][\w$]*\?\.set\([^)]*\),\2\},"restorePersistedModelSelection"/u.exec(runtime); const boundary = /([A-Za-z_$][\w$]*)\(async ([A-Za-z_$][\w$]*)=>\{await ([A-Za-z_$][\w$]*)\.prepareUserExecutionBoundary\(\2\)\},"preparePromptBoundary"\)/u.exec(runtime); if (!facade || !restore || !boundary) throw new Error("ZCode runtime is incompatible with session model recovery (restore/facade anchors missing)."); const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; @@ -1067,7 +1069,7 @@ export function patchRuntimeSessionModelRecovery(runtime: string): string { return runtime .replace(facade[0], `${facade[0]},readSessionModelState:${facade[1]}(async()=>await ${helper}.readSessionModelState({registry:${context}.providerRegistry,sessionStore:${context}.sessionStore,sessionId:${context}.sessionId}),"readSessionModelState")`) .replace(restore[0], restore[0].replace(";return ", `;${getRuntime}.${marker}={selection:${restore[2]},registry:${restore[3]}};return `)) - .replace(boundary[0], boundary[0].replace('},"preparePromptBoundary")', `;${helper}.assertSessionModelReady({registry:${inputContext}.runtime.${marker}?.registry,sessionId:${inputContext}.sessionId,currentSelection:${inputOptions}?.intent?.modelSelection??${inputContext}.runtime.getSessionModelSelection(),restored:${inputContext}.runtime.${marker}})},"preparePromptBoundary")`)); + .replace(boundary[0], boundary[0].replace('},"preparePromptBoundary")', `;${helper}.assertSessionModelReady({registry:${inputContext}.runtime.${marker}?.registry,sessionId:${inputContext}.sessionId,currentSelection:${inputOptions}?.intent?.modelSelection??${inputContext}.cliDefaultSelection?.get?.()??${inputContext}.runtime.getSessionModelSelection(),restored:${inputContext}.runtime.${marker}})},"preparePromptBoundary")`)); } export function patchRuntimeTuiExecutionState(runtime: string): string { @@ -1389,12 +1391,19 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string { } export function patchRuntimePromptModel(runtime: string): string { - if (runtime.includes("ZCODE_CLI_PROMPT_MODEL")) return runtime; + const marker = "$zCliDefaultPromptModel"; + if (runtime.includes(marker)) return runtime; const anchor = /if\([A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.traceId,[A-Za-z_$][\w$]*\.memoryBench/u.exec(runtime); if (!anchor) throw new Error("ZCode runtime is incompatible with the prompt model patch."); - return runtime.slice(0, anchor.index) - + `if(process.env.ZCODE_CLI_PROMPT_MODEL)await ${anchor[1]}.setModel(process.env.ZCODE_CLI_PROMPT_MODEL,{transient:true});` - + runtime.slice(anchor.index); + // cli-default prompt admission reads CliDefaultSelection, not the runtime + // selection. A transient --model override must update that default without + // persisting a session model change. + const switchAnchor = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&!([A-Za-z_$][\w$]*)\?\.transient\?/u.exec(runtime); + if (!switchAnchor) throw new Error("ZCode runtime is incompatible with the prompt model patch (cli-default selection anchor missing)."); + const insertion = `let ${marker}=process.env.ZCODE_CLI_PROMPT_MODEL;if(${marker})await ${anchor[1]}.setModel(${marker},{transient:true,cliDefault:true});`; + let patched = runtime.slice(0, anchor.index) + insertion + runtime.slice(anchor.index); + patched = patched.replace(switchAnchor[0], `${switchAnchor[1]}.cliDefaultSelection&&(!${switchAnchor[2]}?.transient||${switchAnchor[2]}?.cliDefault)?`); + return patched; } const cliSettingsOverride = "process.env.ZCODE_CLI_SETTINGS_FILE?.trim()"; @@ -1676,7 +1685,9 @@ export function patchRuntimeLoginModelDefaults(runtime: string): string { const input = prefix[1]; let patched = runtime.includes("let $zConfiguredDefault=") ? runtime : runtime.replace(prefix[0], `${prefix[0]}if(${input}.configuredDefault&&!${input}.configuredDefault.options?.reasoningLevel){let $zConfiguredDefault=${complete}(${input}.registry,${input}.configuredDefault);if($zConfiguredDefault)${input}={...${input},configuredDefault:{...${input}.configuredDefault,options:{...${input}.configuredDefault.options,...$zConfiguredDefault.options}}}}`); if (!patched.includes("let $zSelectedModel=")) { - const setter = /return ([A-Za-z_$][\w$]*)\.runtime\.setSessionModelSelection\(([A-Za-z_$][\w$]*)\),[A-Za-z_$][\w$]*\?\.transient/u.exec(patched); + // 0.17.0 routes non-transient switches through CliDefaultSelection. Keep + // the reasoning-level completion ahead of that ternary. + const setter = /return ([A-Za-z_$][\w$]*)\.cliDefaultSelection&&[^{};]*?\1\.cliDefaultSelection\.set\(([A-Za-z_$][\w$]*)\):\1\.runtime\.setSessionModelSelection\(\2\),[A-Za-z_$][\w$]*\?\.transient/u.exec(patched); if (!setter) throw new Error("ZCode runtime is incompatible with login model defaults (registry model switch anchor missing)."); const [, context, selection] = setter; patched = patched.replace(setter[0], `if(!${selection}.options?.reasoningLevel){let $zSelectedModel=${complete}(${context}.providerRegistry.getView(),${selection});if($zSelectedModel)${selection}={...${selection},options:{...${selection}.options,...$zSelectedModel.options}}}${setter[0]}`); @@ -1840,7 +1851,7 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ id: "prompt-model", requirement: "required", apply: patchRuntimePromptModel, - verify: (runtime) => runtime.includes("ZCODE_CLI_PROMPT_MODEL") + verify: (runtime) => runtime.includes("$zCliDefaultPromptModel") && runtime.includes("cliDefault:true") }, { id: "bigmodel-oauth", diff --git a/src/runtime-sqlite-recovery.ts b/src/runtime-sqlite-recovery.ts index 5dae4657..709a5abd 100644 --- a/src/runtime-sqlite-recovery.ts +++ b/src/runtime-sqlite-recovery.ts @@ -21,9 +21,10 @@ export const sqliteWriteRecoveryPolicy = { // Audited against ZCode 29628c9: native transactions roll back before throwing; // nontransactional methods upsert/delete stable IDs and preserve input/message sequences. -// Rechecked 2026-10-16 against aac4755: the session-store facade is unchanged. +// Rechecked 2026-10-20 against 0a1fc56 / 0.17.0: the facade adds softDeleteSession, +// which is an idempotent soft delete by session ID. export const sqliteReplayableWrites = [ - "createSession", "updateSession", "createForkedSessionWithMetadata", "commitForkBundle", + "createSession", "updateSession", "softDeleteSession", "createForkedSessionWithMetadata", "commitForkBundle", "commitSharedContextImportBundle", "transitionSharedContextImport", "saveMessage", "removeMessage", "savePart", "removePart", "saveSessionEntry", "saveSessionInput", "updateSessionInputs", "promoteSessionInput", "markSessionInputPromoted", "settleSessionInput", diff --git a/test/node/sqlite-session-store.test.cjs b/test/node/sqlite-session-store.test.cjs index 40bf53ab..51852bcf 100644 --- a/test/node/sqlite-session-store.test.cjs +++ b/test/node/sqlite-session-store.test.cjs @@ -7,11 +7,11 @@ assert.equal(process.versions.bun, undefined, "Run with npm run test:node / node const Store = loadSessionStore(); const BoundedStore = loadSessionStore({ recoveryOptions: { writeBudgetMs: 250 } }); -test("real runtime keeps the write timeout after sync open, async startup and reopen", { timeout: 15_000 }, async t => { +test("real runtime keeps the write timeout after open and reopen", { timeout: 15_000 }, async t => { await withDirectory(async (_directory, dbPath) => { - const sync = new Store({ dbPath, startupLockTimeoutMs: 750 }); - try { assert.equal(sync.db.prepare("PRAGMA busy_timeout").get().timeout, 10_000); } - finally { sync.close(); } + const opened = await Store.open({ dbPath, startupLockTimeoutMs: 750 }); + try { assert.equal(opened.db.prepare("PRAGMA busy_timeout").get().timeout, 10_000); } + finally { opened.close(); } for (let attempt = 0; attempt < 2; attempt++) { const store = await Store.openStartup({ dbPath }); try { diff --git a/test/prompt-model.test.ts b/test/prompt-model.test.ts index 1c818d49..e78d67d1 100644 --- a/test/prompt-model.test.ts +++ b/test/prompt-model.test.ts @@ -17,25 +17,50 @@ test("extracts a per-prompt model while preserving prompt text and resume argume expect(() => extractPromptModel(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/); }); -test("switches the restored app transiently before submitting a prompt and propagates invalid models", async () => { - const source = 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}'; +test("switches the cli-default selection before submitting a prompt and propagates invalid models", async () => { + const source = [ + 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}', + 'function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' + ].join(""); const patched = patchRuntimePromptModel(source); expect(patchRuntimePromptModel(patched)).toBe(patched); expect(() => patchRuntimePromptModel("unsupported")).toThrow(/incompatible/); - const load = (env: Record) => new Function("process", `${patched};return run`)({ env }); + expect(() => patchRuntimePromptModel('async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}')) + .toThrow(/cli-default selection anchor missing/); + const load = (env: Record) => new Function("process", `${patched};return {run,setModel}`)({ env }); const events: unknown[] = []; const app = { traceId: "fixture", setModel: async (...args: unknown[]) => { events.push(args); }, submitPrompt: async () => { events.push("prompt"); return "ok"; } }; - expect(await load({ ZCODE_CLI_PROMPT_MODEL: model })(app, {})).toBe("ok"); - expect(events).toEqual([[model, { transient: true }], "prompt"]); + expect(await load({ ZCODE_CLI_PROMPT_MODEL: model }).run(app, {})).toBe("ok"); + expect(events).toEqual([[model, { transient: true, cliDefault: true }], "prompt"]); events.length = 0; - await load({})(app, {}); + await load({}).run(app, {}); expect(events).toEqual(["prompt"]); events.length = 0; - await expect(load({ ZCODE_CLI_PROMPT_MODEL: model })({ ...app, + await expect(load({ ZCODE_CLI_PROMPT_MODEL: model }).run({ ...app, setModel: async () => { throw new Error("Unknown model"); } }, {})).rejects.toThrow("Unknown model"); expect(events).toEqual([]); + + // A prompt override updates CliDefaultSelection without persisting a session switch. + let cliSelection: unknown; + let runtimeSelection: unknown; + const context = { + cliDefaultSelection: { set: (value: unknown) => { cliSelection = value; } }, + runtime: { setSessionModelSelection: (value: unknown) => { runtimeSelection = value; } } + }; + const selection = { providerId: "p", modelId: "m" }; + const loaded = load({}); + expect(loaded.setModel(context, { transient: true, cliDefault: true }, selection)).toBe(selection); + expect(cliSelection).toBe(selection); + expect(runtimeSelection).toBeUndefined(); + loaded.setModel(context, { transient: true }, selection); + expect(runtimeSelection).toBe(selection); + loaded.setModel(context, undefined, { providerId: "q", modelId: "n" }); + expect(cliSelection).toEqual({ providerId: "q", modelId: "n" }); + runtimeSelection = undefined; + loaded.setModel({ runtime: context.runtime }, { transient: true, cliDefault: true }, selection); + expect(runtimeSelection).toBe(selection); }); diff --git a/test/runtime-sqlite-patches.test.ts b/test/runtime-sqlite-patches.test.ts index 80d46fce..5fce7428 100644 --- a/test/runtime-sqlite-patches.test.ts +++ b/test/runtime-sqlite-patches.test.ts @@ -8,9 +8,9 @@ import { function fixture(): string { return [ - 'const deferred=Symbol(),retention=30*86400000;class Store{constructor(t={},n){this.dbPath="test.sqlite";let r=5000;this.db=t.db;', - 'try{n!==deferred&&migrateSync(this.db,this.dbPath,r)}catch(e){this.db.close();throw e}}', - 'static async openStartup(t={},n={}){let o=new Store(t,deferred);try{return await migrateAsync(o.db,o.dbPath,n),o}catch(e){try{o.close()}catch{}throw e}}', + 'const retention=30*86400000;class Store{static async open(t={},n={}){let dbPath="test.sqlite",timeout=5000;const db=t.db;', + 'try{let migration=await migrateAsync(db,dbPath,n??{lockWaitTimeoutMs:timeout});return new Store(db,dbPath,t,migration)}catch(e){try{db.close()}catch{}throw e}}', + 'constructor(db,dbPath,t,migration){this.db=db;this.dbPath=dbPath}', 'close(){this.db.close()}}', 'async function prune(db,options={}){let cutoff=options.beforeTime??Date.now()-retention;db.exec("begin immediate");try{', 'db.prepare("delete from model_usage where started_at < ?").run(cutoff),', @@ -21,7 +21,7 @@ function fixture(): string { } describe("SQLite recovery bundle boundaries", () => { - test("installs after both migrations and preserves the native cleanup cutoff and transaction", async () => { + test("installs after the single migration open and preserves the native cleanup cutoff and transaction", async () => { const source = patchRuntimeSqliteBusyTimeout(fixture()); const patched = patchRuntimeSqliteWriteRecovery(source); expect(hasRuntimeSqliteBusyTimeout(patched)).toBe(true); @@ -42,14 +42,11 @@ describe("SQLite recovery bundle boundaries", () => { run(); } }; - const run = new Function("require", "__dirname", "migrateSync", "migrateAsync", "label", `${patched};return {Store,prune};`)( + const run = new Function("require", "__dirname", "migrateAsync", "label", `${patched};return {Store,prune};`)( (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } : helper, - "/fixture", () => events.push("sync"), async () => events.push("async"), () => {} + "/fixture", async () => { events.push("async"); }, () => {} ); - new run.Store({ db }); - expect(events).toEqual(["sync", "pragma busy_timeout = 10000", "install"]); - events.length = 0; - await run.Store.openStartup({ db }); + await run.Store.open({ db }); expect(events).toEqual(["async", "pragma busy_timeout = 10000", "install"]); events.length = 0; await run.prune(db, { beforeTime: 42 }); @@ -69,7 +66,7 @@ describe("SQLite recovery bundle boundaries", () => { expect(() => patchRuntimeSqliteWriteRecovery(source.replace('"pruneUsage"', '"renamed"'))).toThrow("symbol"); expect(() => patchRuntimeSqliteWriteRecovery(source.replace("delete from turn_usage", "delete from different_table"))).toThrow("SQL changed"); const patched = patchRuntimeSqliteWriteRecovery(source); - const partial = patched.replace(".installSqliteWriteRecovery(this)", ".other(this)"); + const partial = patched.replace(".installSqliteWriteRecovery($zStore)", ".other($zStore)"); expect(hasRuntimeSqliteWriteRecovery(partial)).toBe(false); expect(() => patchRuntimeSqliteWriteRecovery(partial)).toThrow("partial"); }); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index bd3358db..ff8b97e5 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -18,6 +18,7 @@ import { hasRuntimeHttpNoContentGuard, hasRuntimeNetworkRetryGuard, hasRuntimeSqliteBusyTimeout, + hasRuntimeSqliteWriteRecovery, hasRuntimeStreamEofFinishGuard, installRuntimeProviderConfig, parseArgs, @@ -36,6 +37,7 @@ import { patchRuntimeOAuthHttpErrors, patchRuntimeSessionModelRecovery, patchRuntimeSqliteBusyTimeout, + patchRuntimeSqliteWriteRecovery, patchRuntimeStreamEofFinishGuard, patchRuntimeTerminalToolProjection, patchRuntimeTuiBridge as patchExtractedRuntimeTuiBridge, @@ -156,15 +158,18 @@ describe("runtime synchronization", () => { }); test.each(["", "saved&&ok&&!ok.ok&&Boolean(ok.code),"])("adds session recovery at native restore and execution boundaries with diagnostics %j", async (diagnostic) => { - const runtime = 'async function app(options){let read=()=>runtime,restore=label(async()=>{let ok=saved&®istry.validateSelection(saved);return ' - + diagnostic + 'read().setSessionModelSelection(ok?.ok?saved:void 0),saved},"restorePersistedModelSelection"),prepare=label(async args=>{await options.prepareUserExecutionBoundary(args)},"preparePromptBoundary");return{restore,prepare,getModelOption:label(model=>lookup(context.providerRegistry,model),"getModelOption")}}'; + const runtime = 'async function app(options){let cliDefault={set(){}},read=()=>runtime,restore=label(async()=>{let ok=saved&®istry.validateSelection(saved);return ' + + diagnostic + 'read().setSessionModelSelection(ok?.ok?saved:void 0),cliDefault?.set(ok?.ok?saved:void 0),saved},"restorePersistedModelSelection"),prepare=label(async args=>{await options.prepareUserExecutionBoundary(args)},"preparePromptBoundary");return{restore,prepare,getModelOption:label(model=>lookup(context.providerRegistry,model),"getModelOption")}}'; const patched = patchRuntimeSessionModelRecovery(runtime); expect(patched).toContain('readSessionModelState:label(async()=>await'); expect(patched).toContain('read().$zRestoredSessionModel={selection:saved,registry:registry}'); + expect(patched).toContain('cliDefaultSelection?.get?.()'); expect(patched).toContain('assertSessionModelReady({registry:options.runtime.$zRestoredSessionModel?.registry'); expect(patchRuntimeSessionModelRecovery(patched)).toBe(patched); expect(() => patchRuntimeSessionModelRecovery(runtime.replace('"restorePersistedModelSelection"', '"renamed"'))) .toThrow("restore/facade anchors missing"); + expect(() => patchRuntimeSessionModelRecovery(runtime.replace(",cliDefault?.set(ok?.ok?saved:void 0)", ""))) + .toThrow("restore/facade anchors missing"); const saved = { providerId: "old", modelId: "model" }; const registry = { validateSelection: (selection: { providerId: string }) => selection.providerId === "valid" ? { ok: true } : { ok: false, code: "provider-not-found" }, @@ -180,6 +185,13 @@ describe("runtime synchronization", () => { const facade = await create({ runtime: core, sessionId: "test", prepareUserExecutionBoundary: async () => { await facade.restore(); } }); await expect(facade.prepare({})).rejects.toThrow("the provider is unavailable"); await expect(facade.prepare({ intent: { modelSelection: { providerId: "valid", modelId: "model" } } })).resolves.toBeUndefined(); + // cli-default admission reads CliDefaultSelection; a replacement chosen there must clear the saved-model block. + const recovered = await create({ + runtime: core, sessionId: "test", + cliDefaultSelection: { get: () => ({ providerId: "valid", modelId: "model" }) }, + prepareUserExecutionBoundary: async () => { await recovered.restore(); } + }); + await expect(recovered.prepare({})).resolves.toBeUndefined(); }); test("projects native planEnabled separately from permission mode", async () => { @@ -263,7 +275,7 @@ describe("runtime synchronization", () => { 'async function persist(e){let p=await resolveProvider(e.providerId,e.env),id=p.providerId,model=p.modelId,unused;await store.saveConfiguredDefault({providerId:id,modelId:model});return saved}', 'function initial(e){return e.configuredDefault?.options?.reasoningLevel?e.configuredDefault:{providerId:"wrong",modelId:"fallback"}}', 'function complete(registry,selection){return {...selection,options:{reasoningLevel:"high"}}}', - 'function switchModel(e,t){let selected=t;return e.runtime.setSessionModelSelection(selected),t?.transient||void 0,selected}', + 'function switchModel(e,t){let selected=t;return e.cliDefaultSelection&&(!t?.transient||t?.cliDefault)?e.cliDefaultSelection.set(selected):e.runtime.setSessionModelSelection(selected),t?.transient||void 0,selected}', 'label(initial,"resolveInitialModelSelection");label(complete,"completeNewModelSelection");', 'label(catalog,"readStandaloneCodingPlanCatalog");label(resolveProvider,"resolveStandaloneCodingPlanProvider");label(persist,"persistStandaloneCodingPlanConnection");' ].join(""); @@ -282,8 +294,17 @@ describe("runtime synchronization", () => { const context = { runtime: { setSessionModelSelection: () => {} }, providerRegistry: { getView: () => ({}) } }; expect(switchModel(context, { providerId: "custom", modelId: "chosen" })).toMatchObject({ options: { reasoningLevel: "high" } }); expect(switchModel(context, { providerId: "custom", modelId: "chosen", options: { reasoningLevel: "low" } })).toMatchObject({ options: { reasoningLevel: "low" } }); + let cliSelection: unknown; + const cliContext = { + runtime: { setSessionModelSelection: () => { throw new Error("session write"); } }, + cliDefaultSelection: { set: (value: unknown) => { cliSelection = value; } }, + providerRegistry: { getView: () => ({}) } + }; + expect(switchModel(cliContext, { providerId: "custom", modelId: "chosen" })).toMatchObject({ options: { reasoningLevel: "high" } }); + expect(cliSelection).toMatchObject({ options: { reasoningLevel: "high" } }); await expect(persist({ providerId: "bigmodel", env })).rejects.toThrow("ambiguous provider"); expect(() => patchRuntimeLoginModelDefaults(source.replace("modelId:model}", "modelId:hardcoded}"))).toThrow("anchors missing"); + expect(() => patchRuntimeLoginModelDefaults(source.replace("e.cliDefaultSelection&&", "false&&"))).toThrow(/model switch anchor missing/); }); test("pins the exact remote runtime used by release workflows", async () => { @@ -482,12 +503,13 @@ describe("runtime synchronization", () => { expect(fixture.toWebResponse(fixture.readIncomingResponse({ statusCode: 200 })).body).not.toBeNull(); }); - test("sets the SQLite write timeout after both migration paths without changing startup budgets", async () => { + test("sets the SQLite write timeout after the single async migration open", async () => { const runtime = [ - 'const deferred=Symbol();class Store{constructor(t={},n){this.dbPath=t.dbPath??"fixture.sqlite";let r=t.startupLockTimeoutMs??5e3;', - 'this.db=new sqlite.DatabaseSync(this.dbPath,{timeout:r});', - 'try{n!==deferred&&migrateSync(this.db,this.dbPath,r)}catch(e){this.db.close();throw e}}', - 'static async openStartup(t={},n={}){let o=new Store(t,deferred);try{return await migrateAsync(o.db,o.dbPath,n),o}catch(e){try{o.close()}catch{}throw e}}', + 'class Store{', + 'static async open(t={},n={}){let o=t.dbPath??"fixture.sqlite",i=t.startupLockTimeoutMs??5e3;', + 'let a=new sqlite.DatabaseSync(o,{timeout:i});', + 'try{let c=await migrateAsync(a,o,n??{lockWaitTimeoutMs:i});return new Store(a,o,t,c)}catch(e){try{a.close()}catch{}throw e}}', + 'constructor(a,o,t,c){this.db=a;this.dbPath=o;this.migrationResult=c}', 'close(){this.db.close()}}' ].join(""); const patched = patchRuntimeSqliteBusyTimeout(runtime); @@ -505,41 +527,83 @@ describe("runtime synchronization", () => { this.close = () => { statements.push("close"); }; } }; - const Store = new Function("sqlite", "migrateSync", "migrateAsync", `${patched};return Store;`)( + const Store = new Function("sqlite", "migrateAsync", `${patched};return Store;`)( sqlite, - (_db: Database, _path: string, timeout: number) => { - statements.push(`sync migration budget: ${timeout}`); - if (migrationFailure) throw new Error("migration failed"); - }, async (db: Database) => { db.exec("pragma busy_timeout = 25"); try { if (migrationFailure) throw new Error("migration failed"); } finally { db.exec("pragma busy_timeout = 5000"); } } ); - new Store({ startupLockTimeoutMs: 2500 }); + await Store.open({ startupLockTimeoutMs: 2500 }); expect(opened).toEqual([{ options: { timeout: 2500 }, path: "fixture.sqlite" }]); - expect(statements).toEqual(["sync migration budget: 2500", `pragma busy_timeout = ${sqliteBusyTimeoutMs}`]); - statements.length = 0; - await Store.openStartup(); expect(statements).toEqual(["pragma busy_timeout = 25", "pragma busy_timeout = 5000", `pragma busy_timeout = ${sqliteBusyTimeoutMs}`]); // Failed migration paths must close the connection, not hand it to callers. migrationFailure = true; statements.length = 0; - await expect(Store.openStartup()).rejects.toThrow("migration failed"); + await expect(Store.open()).rejects.toThrow("migration failed"); expect(statements).toEqual(["pragma busy_timeout = 25", "pragma busy_timeout = 5000", "close"]); - statements.length = 0; - expect(() => new Store()).toThrow("migration failed"); - expect(statements).toEqual(["sync migration budget: 5000", "close"]); expect(patchRuntimeSqliteBusyTimeout(patched)).toBe(patched); - expect(() => patchRuntimeSqliteBusyTimeout("incompatible runtime")).toThrow(/migration anchors/); + expect(() => patchRuntimeSqliteBusyTimeout("incompatible runtime")).toThrow(/store open anchor/); expect(() => patchRuntimeSqliteBusyTimeout(runtime.replace("await migrateAsync", "await changed"))).not.toThrow(); - expect(() => patchRuntimeSqliteBusyTimeout(runtime.replace("o.dbPath,n", "o.dbPath"))).toThrow(/migration anchors/); + expect(() => patchRuntimeSqliteBusyTimeout(runtime.replace("n??{lockWaitTimeoutMs:i}", "n"))).toThrow(/store open anchor/); expect(() => patchRuntimeSqliteBusyTimeout(runtime + runtime)).toThrow(/ambiguous/); - const partial = patched.replace(`,o.db.exec("pragma busy_timeout = ${sqliteBusyTimeoutMs}")`, ""); - expect(hasRuntimeSqliteBusyTimeout(partial)).toBe(false); - expect(() => patchRuntimeSqliteBusyTimeout(partial)).toThrow(/migration anchors/); + }); + + test("installs write recovery and usage admission on the single store open", async () => { + const runtime = [ + 'function label(v){return v}', + 'class Store{', + 'static async open(t={},n={}){let o=t.dbPath??"fixture.sqlite",i=t.startupLockTimeoutMs??5e3;', + 'let a=new sqlite.DatabaseSync(o,{timeout:i});', + 'try{let c=await migrateAsync(a,o,n??{lockWaitTimeoutMs:i});return new Store(a,o,t,c)}catch(e){try{a.close()}catch{}throw e}}', + 'constructor(a,o,t,c){this.db=a;this.dbPath=o;this.migrationResult=c}', + 'close(){this.db.close()}}', + 'async function pruneUsage(e,t={}){let n=t.beforeTime??Date.now()-1000;e.exec("begin immediate");try{e.prepare("delete from model_usage where started_at < ?").run(n),e.prepare("delete from turn_usage where started_at < ?").run(n),e.prepare("delete from tool_usage where started_at < ?").run(n),e.exec("commit")}catch(o){throw e.exec("rollback"),o}}', + 'label(pruneUsage,"pruneUsage");' + ].join(""); + const patched = patchRuntimeSqliteWriteRecovery(patchRuntimeSqliteBusyTimeout(runtime)); + expect(hasRuntimeSqliteWriteRecovery(patched)).toBe(true); + expect(patched).toContain("installSqliteWriteRecovery($zStore)"); + expect(patched).toContain(".pruneSqliteUsage("); + expect(patchRuntimeSqliteWriteRecovery(patched)).toBe(patched); + expect(() => patchRuntimeSqliteWriteRecovery("incompatible runtime")).toThrow(/post-migration timeout/); + const calls: string[] = []; + const helper = { + installSqliteWriteRecovery: (store: unknown) => { + calls.push("install"); + (store as { installed?: boolean }).installed = true; + }, + pruneSqliteUsage: (_db: unknown, _options: unknown, cutoff: number, prune: () => void) => { + calls.push(`prune:${cutoff}`); + prune(); + } + }; + const require = (id: string) => id === "node:path" + ? { join: (...parts: string[]) => parts.join("/") } + : helper; + let pruned = 0; + type Database = { + exec: (sql: string) => void; + prepare: (sql: string) => { run: (value?: unknown) => void }; + close: () => void; + }; + const sqlite = { + DatabaseSync: function DatabaseSync(this: Database, _path: string, _options: unknown) { + this.exec = (sql: string) => { if (sql.startsWith("delete from")) pruned += 1; }; + this.prepare = () => ({ run: () => { pruned += 1; } }); + this.close = () => {}; + } + }; + const fixture = new Function("sqlite", "migrateAsync", "require", "__dirname", `${patched};return {Store,pruneUsage};`)( + sqlite, async () => undefined, require, "/fixture" + ); + const store = await fixture.Store.open({ dbPath: "fixture.sqlite" }); + expect(calls).toContain("install"); + await fixture.pruneUsage(store.db); + expect(calls.some(call => call.startsWith("prune:"))).toBe(true); + expect(pruned).toBe(3); }); test.each(["legacy", "budgeted"])("classifies transport failures without retrying after output (%s)", budget => { diff --git a/zcode-runtime.lock.json b/zcode-runtime.lock.json index 6cc7dd88..c490525f 100644 --- a/zcode-runtime.lock.json +++ b/zcode-runtime.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "appVersion": "3.15.3", + "appVersion": "3.16.2", "platform": "linux", "arch": "x64", - "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.15.3/linux-x64/ZCode-3.15.3-linux-x64.deb", - "sha512": "9RigUj20tjuaskzBt2sgWLtIuUaIET67x5joShRIgwLlbFcO/oWt1eemrlLn5ZskXUMzcUHtUTCKU8+mHF7kQQ==" + "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.16.2/linux-x64/ZCode-3.16.2-linux-x64.deb", + "sha512": "gWA8sQKm/3R885GyUjacpo6hkIfoJ5WwetJMKzac+nXfFmMhHSmeVI2OzR2lOOc/h+O8EhylqecWpm4yWcpBSA==" } From 1053f222af1ea451fca16e5bb23052627d0f4e7b Mon Sep 17 00:00:00 2001 From: Kingsword Date: Tue, 20 Oct 2026 16:20:39 +0800 Subject: [PATCH 2/2] fix(runtime): preserve prompt model overrides after cold resume --- scripts/sync-runtime.ts | 13 ++++-- test/prompt-model.test.ts | 51 +++++++++++++++++++-- test/runtime/session-model-recovery.test.ts | 42 +++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index f57dd847..7491565e 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -1390,19 +1390,23 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string { return patched; } +const restoredPromptModelSwitch = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&\(([A-Za-z_$][\w$]*)\?\.cliDefault&&await \1\.prepareResume\(\),!\2\?\.transient\|\|\2\?\.cliDefault\)\?/u; + export function patchRuntimePromptModel(runtime: string): string { const marker = "$zCliDefaultPromptModel"; - if (runtime.includes(marker)) return runtime; + if (runtime.includes(marker) && restoredPromptModelSwitch.test(runtime)) return runtime; const anchor = /if\([A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.traceId,[A-Za-z_$][\w$]*\.memoryBench/u.exec(runtime); if (!anchor) throw new Error("ZCode runtime is incompatible with the prompt model patch."); // cli-default prompt admission reads CliDefaultSelection, not the runtime // selection. A transient --model override must update that default without // persisting a session model change. - const switchAnchor = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&!([A-Za-z_$][\w$]*)\?\.transient\?/u.exec(runtime); + const switchAnchor = /([A-Za-z_$][\w$]*)\.cliDefaultSelection&&(?:!([A-Za-z_$][\w$]*)\?\.transient|\(!([A-Za-z_$][\w$]*)\?\.transient\|\|\3\?\.cliDefault\))\?/u.exec(runtime); if (!switchAnchor) throw new Error("ZCode runtime is incompatible with the prompt model patch (cli-default selection anchor missing)."); const insertion = `let ${marker}=process.env.ZCODE_CLI_PROMPT_MODEL;if(${marker})await ${anchor[1]}.setModel(${marker},{transient:true,cliDefault:true});`; - let patched = runtime.slice(0, anchor.index) + insertion + runtime.slice(anchor.index); - patched = patched.replace(switchAnchor[0], `${switchAnchor[1]}.cliDefaultSelection&&(!${switchAnchor[2]}?.transient||${switchAnchor[2]}?.cliDefault)?`); + let patched = runtime.includes(marker) ? runtime : runtime.slice(0, anchor.index) + insertion + runtime.slice(anchor.index); + const context = switchAnchor[1]; + const options = switchAnchor[2] ?? switchAnchor[3]; + patched = patched.replace(switchAnchor[0], `${context}.cliDefaultSelection&&(${options}?.cliDefault&&await ${context}.prepareResume(),!${options}?.transient||${options}?.cliDefault)?`); return patched; } @@ -1852,6 +1856,7 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ requirement: "required", apply: patchRuntimePromptModel, verify: (runtime) => runtime.includes("$zCliDefaultPromptModel") && runtime.includes("cliDefault:true") + && restoredPromptModelSwitch.test(runtime) }, { id: "bigmodel-oauth", diff --git a/test/prompt-model.test.ts b/test/prompt-model.test.ts index e78d67d1..cf4cffa6 100644 --- a/test/prompt-model.test.ts +++ b/test/prompt-model.test.ts @@ -20,10 +20,11 @@ test("extracts a per-prompt model while preserving prompt text and resume argume test("switches the cli-default selection before submitting a prompt and propagates invalid models", async () => { const source = [ 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}', - 'function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' + 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' ].join(""); const patched = patchRuntimePromptModel(source); expect(patchRuntimePromptModel(patched)).toBe(patched); + expect(patchRuntimePromptModel(patched.replace("f?.cliDefault&&await e.prepareResume(),", ""))).toBe(patched); expect(() => patchRuntimePromptModel("unsupported")).toThrow(/incompatible/); expect(() => patchRuntimePromptModel('async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}')) .toThrow(/cli-default selection anchor missing/); @@ -47,20 +48,60 @@ test("switches the cli-default selection before submitting a prompt and propagat // A prompt override updates CliDefaultSelection without persisting a session switch. let cliSelection: unknown; let runtimeSelection: unknown; + let resumes = 0; const context = { + prepareResume: async () => { resumes++; }, cliDefaultSelection: { set: (value: unknown) => { cliSelection = value; } }, runtime: { setSessionModelSelection: (value: unknown) => { runtimeSelection = value; } } }; const selection = { providerId: "p", modelId: "m" }; const loaded = load({}); - expect(loaded.setModel(context, { transient: true, cliDefault: true }, selection)).toBe(selection); + expect(await loaded.setModel(context, { transient: true, cliDefault: true }, selection)).toBe(selection); expect(cliSelection).toBe(selection); expect(runtimeSelection).toBeUndefined(); - loaded.setModel(context, { transient: true }, selection); + expect(resumes).toBe(1); + await loaded.setModel(context, { transient: true }, selection); expect(runtimeSelection).toBe(selection); - loaded.setModel(context, undefined, { providerId: "q", modelId: "n" }); + await loaded.setModel(context, undefined, { providerId: "q", modelId: "n" }); expect(cliSelection).toEqual({ providerId: "q", modelId: "n" }); runtimeSelection = undefined; - loaded.setModel({ runtime: context.runtime }, { transient: true, cliDefault: true }, selection); + await loaded.setModel({ runtime: context.runtime }, { transient: true, cliDefault: true }, selection); expect(runtimeSelection).toBe(selection); + expect(resumes).toBe(1); +}); + +test.each([true, false])("applies a prompt override after lazy resume restores the shared CLI selection (saved valid: %p)", async valid => { + const source = [ + 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}', + 'async function setModel(e,f,x){return e.cliDefaultSelection&&!f?.transient?e.cliDefaultSelection.set(x):e.runtime.setSessionModelSelection(x),f?.transient||void 0,x}' + ].join(""); + const loaded = new Function("process", `${patchRuntimePromptModel(source)};return {run,setModel}`)({ + env: { ZCODE_CLI_PROMPT_MODEL: model } + }); + const events: unknown[] = []; + const saved = { providerId: "saved", modelId: "model" }; + let selection: unknown; + let resumed = false; + const context = { + cliDefaultSelection: { set: (value: unknown) => { selection = value; } }, + runtime: { setSessionModelSelection: (value: unknown) => { selection = value; } }, + async prepareResume() { + if (resumed) return; + events.push("resume"); + context.cliDefaultSelection.set(valid ? saved : undefined); + resumed = true; + } + }; + const app = { + traceId: "fixture", + setModel: (value: unknown, options: unknown) => loaded.setModel(context, options, value), + async submitPrompt() { + await context.prepareResume(); + events.push(selection); + if (!selection) throw new Error("Saved model is unavailable"); + return selection; + } + }; + expect(await loaded.run(app, {})).toBe(model); + expect(events).toEqual(["resume", model]); }); diff --git a/test/runtime/session-model-recovery.test.ts b/test/runtime/session-model-recovery.test.ts index d8a29a88..75f55904 100644 --- a/test/runtime/session-model-recovery.test.ts +++ b/test/runtime/session-model-recovery.test.ts @@ -103,6 +103,48 @@ test.each(["legacy-provider", "model-casing", "missing-model"] as SessionModelCa }, 25_000 ); +test.each(["valid", "legacy-provider", "model-casing", "missing-model", "missing-reasoning"] as SessionModelCase[])( + "headless cold resume uses the explicit model instead of the saved selection (%s)", async kind => { + await using fixture = await createSessionModelFixture(kind); + const providers = await readFile(fixture.providerPath, "utf8"); + const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId, "--model", "zai/glm-5.3-flash", "--prompt", "Use the explicit override."], { + cwd: fixture.directory, env: fixture.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect({ code, stderr }).toEqual({ code: 0, stderr: "" }); + expect(stdout).toContain("SESSION_MODEL_REPLY"); + expect(fixture.requests).toEqual(["glm-5.3-flash"]); + expect(await readFile(fixture.providerPath, "utf8")).toBe(providers); + } finally { + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + clearTimeout(timeout); + } + }, 30_000 +); + +test("headless cold resume rejects an unavailable explicit model without falling back", async () => { + await using fixture = await createSessionModelFixture("valid"); + const before = fixture.readSelection(); + const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId, "--model", "zai/unavailable", "--prompt", "Do not fall back."], { + cwd: fixture.directory, env: fixture.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code).toBe(1); + expect(`${stdout}\n${stderr}`).toContain("zai/unavailable"); + expect(fixture.requests).toEqual([]); + expect(fixture.readSelection()).toEqual(before); + } finally { + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + clearTimeout(timeout); + } +}, 30_000); + test.skipIf(process.platform === "win32").each([false, true])("resume preserves valid selections and handles an empty catalog (empty: %p)", async empty => { await using fixture = await createSessionModelFixture("valid"); const before = fixture.readSelection();