From 4bb60b10692f98c1869da8cdc3e62c23e08c7968 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:42 +0800 Subject: [PATCH 01/43] feat(dev-infra): make gate plans inspectable and replayable --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 6 + .../2026-07-27-replayable-gate-plans.md | 43 + .../2026-07-27-replayable-gate-plans.zh.md | 43 + .github/workflows/ci.yml | 39 +- package.json | 1 + scripts/gate-log-helper.mjs | 220 ++++ scripts/publint-all.spec.ts | 6 + scripts/run-gates.spec.ts | 523 +++++++++ scripts/run-gates.ts | 1011 +++++++++++++++-- 9 files changed, 1758 insertions(+), 134 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md create mode 100644 scripts/gate-log-helper.mjs create mode 100644 scripts/run-gates.spec.ts diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml new file mode 100644 index 0000000000..3966caf9cf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9 +2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md new file mode 100644 index 0000000000..604fb87ebe --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -0,0 +1,43 @@ +# Agent Note: Validated, self-describing, replayable gate plans + +Status: implemented + +English | [中文](2026-07-27-replayable-gate-plans.zh.md) + +## Problem + +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. + +The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. + +## Decision + +[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. + +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake in inherited values; values under secret-like names are redacted. + +`--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. + +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. + +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. + +## Verification + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. + +## Alternatives considered + +- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. +- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. +- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. +- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. +- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. + +## Consequences + +The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. + +Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. + +Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md new file mode 100644 index 0000000000..e24d77ff62 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 经过验证、自描述、可回放的门禁计划 + +Status: implemented + +[English](2026-07-27-replayable-gate-plans.md) | 中文 + +## 问题 + +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 + +Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 + +## 决策 + +[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 + +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 + +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 + +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 + +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 + +## 验证 + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 + +## 曾考虑的替代方案 + +- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 +- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 +- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 +- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 +- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 + +## 后果 + +调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 + +后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 + +保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aecff76a7d..3639333861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,44 +240,7 @@ jobs: exit "$sandbox_status" - name: Run compatibility, snapshot, and artifact gates - run: | - pnpm run check:ci:lint & - lint_pid=$! - pnpm run check:node-compat & - compat_pid=$! - DSH_EXAMPLE_MODE=lib pnpm run test:snapshot & - snapshot_pid=$! - pnpm run publint & - publint_pid=$! - pnpm run verify-node-next-types & - node_next_pid=$! - pnpm run verify-built-package-invariants & - built_invariants_pid=$! - DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \ - examples/headless-agent/tests/keyless-smoke.e2e.ts \ - examples/tui-agent/tests/tui-keyless-smoke.e2e.ts \ - packages/examples/cli-demo/tests/built-bin.e2e.ts \ - packages/examples/acp-demo/tests/built-bin.e2e.ts \ - packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts \ - packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \ - packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts & - built_bin_pid=$! - - final_status=0 - capture_status() { - local child_status=0 - wait "$1" || child_status=$? - if (( final_status == 0 && child_status != 0 )); then - final_status=$child_status - fi - } - for child_pid in \ - "$lint_pid" "$compat_pid" "$snapshot_pid" \ - "$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid" - do - capture_status "$child_pid" - done - exit "$final_status" + run: pnpm run check:ci:consumers node-compat: diff --git a/package.json b/package.json index d74a780925..a004a99d85 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", + "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs new file mode 100644 index 0000000000..363b538f70 --- /dev/null +++ b/scripts/gate-log-helper.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** Pin the repository and each log-path component before creating or operating on private logs. */ + +import { constants } from 'node:fs' +import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' +import { isAbsolute, sep } from 'node:path' + +const MAX_REQUEST_BYTES = 8 * 1024 * 1024 +const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ + +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} + +async function readRequest() { + const chunks = [] + let bytes = 0 + for await (const chunk of process.stdin) { + bytes += chunk.length + if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function assertInteger(value, label, minimum) { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`${label} must be an integer of at least ${minimum}`) + } +} + +function assertLogName(name) { + if (typeof name !== 'string' || !LOG_NAME.test(name)) { + throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) + } +} + +function assertRequest(request) { + if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') + switch (request.operation) { + case 'write': + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + return + case 'prune': + assertInteger(request.retain, 'retain', 0) + return + case 'clean': + return + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +function assertIdentity(value, label) { + if ( + typeof value !== 'object' + || value === null + || typeof value.dev !== 'string' + || typeof value.ino !== 'string' + ) { + throw new Error(`missing expected ${label} identity`) + } +} + +function identityOf(metadata) { + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +function sameIdentity(metadata, expected) { + return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino +} + +async function assertPinnedRepository(repository) { + if ( + typeof repository !== 'object' + || repository === null + || typeof repository.root !== 'string' + || !isAbsolute(repository.root) + || typeof repository.relative !== 'string' + || repository.relative === '' + || repository.relative === '..' + || repository.relative.startsWith(`..${sep}`) + || isAbsolute(repository.relative) + ) { + throw new Error('invalid repository-relative gate-log path') + } + assertIdentity(repository.identity, 'repository') + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { + throw new Error('gate-log repository identity changed before the helper started') + } + const rootMetadata = await lstat(repository.root, { bigint: true }) + if ( + !rootMetadata.isDirectory() + || rootMetadata.isSymbolicLink() + || !sameIdentity(rootMetadata, repository.identity) + ) { + throw new Error('gate-log repository root is not a real directory') + } +} + +async function enterLogDirectory(relativePath, create) { + const traversed = [] + for (const component of relativePath.split(sep)) { + if (component === '' || component === '.' || component === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + } + traversed.push(component) + let componentMetadata + try { + componentMetadata = await lstat(component, { bigint: true }) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + if (!create) return undefined + try { + await mkdir(component, { mode: 0o700 }) + } catch (mkdirError) { + if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + } + componentMetadata = await lstat(component, { bigint: true }) + } + const shown = traversed.join('/') + if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { + throw new Error(`gate-log path component is not a real directory: ${shown}`) + } + const expected = identityOf(componentMetadata) + process.chdir(component) + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { + throw new Error(`gate-log path component identity changed before pinning: ${shown}`) + } + } + await chmod('.', 0o700) + return identityOf(await stat('.', { bigint: true })) +} + +async function removeOldLogs(retain, newest) { + assertInteger(retain, 'retain', 0) + const entries = await readdir('.', { withFileTypes: true }) + const logs = [] + for (const entry of entries) { + if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue + let metadata + try { + metadata = await lstat(entry.name, { bigint: true }) + } catch (error) { + if (errorCode(error) === 'ENOENT') continue + throw error + } + if (!metadata.isFile() || metadata.isSymbolicLink()) continue + logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) + } + logs.sort((left, right) => { + if (left.name === newest) return 1 + if (right.name === newest) return -1 + if (left.mtimeNs < right.mtimeNs) return -1 + if (left.mtimeNs > right.mtimeNs) return 1 + return left.name.localeCompare(right.name) + }) + const removed = [] + for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { + try { + await unlink(entry.name) + removed.push(entry.name) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + } + return removed +} + +async function writeLog(request) { + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + const handle = await open( + request.filename, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ) + try { + await handle.writeFile(request.content, 'utf8') + await handle.chmod(0o600) + } finally { + await handle.close() + } + const removed = await removeOldLogs(request.retention, request.filename) + return { filename: request.filename, removed } +} + +async function main() { + const request = await readRequest() + assertRequest(request) + await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + if (directory === undefined) return { removed: [] } + switch (request.operation) { + case 'write': { + const result = await writeLog(request) + return { ...result, directory } + } + case 'prune': + return { directory, removed: await removeOldLogs(request.retain) } + case 'clean': + return { directory, removed: await removeOldLogs(0) } + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +try { + process.stdout.write(`${JSON.stringify(await main())}\n`) +} catch (error) { + process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts index 22dd80d6b0..9e6d4ef26e 100644 --- a/scripts/publint-all.spec.ts +++ b/scripts/publint-all.spec.ts @@ -58,4 +58,10 @@ describe('publint package runner', () => { expect(result.status).toBe(1) expect(result.stdout).toContain('unpublished.js') }) + + it('rejects a public export whose built file is missing', () => { + const result = run(fixture('./lib/missing.js')) + expect(result.status).toBe(1) + expect(result.stdout).toContain('missing.js') + }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts new file mode 100644 index 0000000000..49c1aab675 --- /dev/null +++ b/scripts/run-gates.spec.ts @@ -0,0 +1,523 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cleanGateFailureLogs, + executeGatePlan, + failureLogUnavailableReason, + formatGateFailureLog, + formatGatePlanJson, + formatGatePlanList, + formatGateResultReason, + formatOnlyNotice, + gateDependencyClosure, + gatePlanForMode, + listedGatePlan, + limitGateFailureLog, + parseCliRequest, + pruneGateLogs, + replayCommand, + resolveGateEnvironment, + resolvePlanConcurrency, + runGate, + validateGatePlan, + writeGateFailureLog, + type Gate, + type GatePlan, + type GateResult, +} from './run-gates.ts' + +const temporaryRoots: string[] = [] +const repositoryRoot = join(import.meta.dirname, '..') + +afterEach(() => { + vi.unstubAllEnvs() + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function gate(id: string, options: Partial = {}): Gate { + return { + id, + label: id, + displayCommand: `run ${id}`, + command: process.execPath, + args: ['-e', ''], + ...options, + } +} + +function plan(gates: Gate[]): GatePlan { + return { mode: 'check-all', script: 'check:all', gates } +} + +function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { + return { + gate: subject, + status, + durationMs: 10, + stdout: '', + stderr: '', + output: [], + exitCode: status === 'passed' ? 0 : 1, + signalCode: null, + } +} + +function temporaryRoot(prefix = 'dsh-gate-logs-'): string { + const root = mkdtempSync(join(tmpdir(), prefix)) + temporaryRoots.push(root) + return root +} + +function withPnpmEntrypoint(action: () => T): T { + const previous = process.env.npm_execpath + process.env.npm_execpath = '/private/pnpm.cjs' + try { + return action() + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath') + else process.env.npm_execpath = previous + } +} + +describe('gate plan validation', () => { + it.each([ + 'ci-primary', + 'ci-static', + 'ci-lint', + 'ci-coverage', + 'ci-snapshot', + 'ci-artifacts', + 'ci-consumers', + 'ci-windows-blocking', + 'ci-windows-complete', + 'ci-windows-observational', + 'node-compat', + 'check-all', + 'doc-sync', + ] as const)('constructs a valid non-empty %s plan', (mode) => { + const subject = withPnpmEntrypoint(() => gatePlanForMode(mode)) + expect(() => { + validateGatePlan(subject) + }).not.toThrow() + }) + + it.each([ + ['empty', plan([]), /plan has no gates/], + ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], + ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], + ])('rejects %s before starting a child', async (_label, invalid, message) => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an invalid plan worker bound', () => { + expect(() => { + validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 }) + }).toThrow( + 'maxWorkers must be a positive integer', + ) + }) + + it('rejects an executor request above the plan worker ceiling before starting a child', async () => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow( + 'exceeds the check-all plan ceiling 1', + ) + expect(execute).not.toHaveBeenCalled() + }) + + it('selects a target with its transitive dependencies in canonical plan order', () => { + const subject = plan([ + gate('prepare'), + gate('build', { needs: ['prepare'] }), + gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }), + gate('unrelated'), + ]) + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot']) + expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) + +describe('gate plan inspection and replay', () => { + it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ + kind: 'run', mode: 'check-all', list: true, json: true, + }) + expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ + kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', + }) + expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) + expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') + expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') + }) + + it('renders deterministic human and stable JSON fields without inherited environment values', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subject = plan([ + gate('prepare'), + gate('subject', { + needs: ['prepare'], + allowFailure: true, + env: { + Z_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + }, + }), + ]) + + const json = formatGatePlanJson(subject) + expect(formatGatePlanJson(subject)).toBe(json) + expect(json).not.toContain('ambient-secret') + expect(json).not.toContain('scheduler-secret') + expect(JSON.parse(json)).toEqual({ + version: 1, + mode: 'check-all', + script: 'check:all', + scope: 'complete', + maxWorkers: null, + gates: [ + { id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true }, + { + id: 'subject', + label: 'subject', + command: 'run subject', + needs: ['prepare'], + env: { + ACCESS_TOKEN: { operation: 'set', value: '' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + Z_MODE: { operation: 'set', value: 'lib' }, + }, + blocking: false, + }, + ], + }) + expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject') + expect(formatGatePlanList(subject)).toContain('needs: prepare') + expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)') + }) + + it('emits one clean JSON object through the documented silent package-script entry', () => { + const result = spawnSync('pnpm', [ + '--silent', + 'run', + 'check:ci:consumers', + '--', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + timeout: 10_000, + }) + if (result.error !== undefined) throw result.error + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + version: 1, + mode: 'ci-consumers', + script: 'check:ci:consumers', + scope: 'complete', + maxWorkers: 7, + }) + }) + + it('renders a cross-platform scheduler replay and labels focused evidence', () => { + const subject = plan([gate('snapshot')]) + expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') + expect(formatOnlyNotice(subject, 'snapshot')).toBe( + 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', + ) + }) + + it('resolves append, set, and unset operations only when spawning', () => { + const resolved = resolveGateEnvironment(gate('subject', { + env: { + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + MODE: { operation: 'set', value: 'lib' }, + REMOVE_ME: { operation: 'unset' }, + }, + }), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' }) + expect(resolved).toEqual({ + NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', + MODE: 'lib', + INHERITED: 'kept', + }) + }) + + it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => { + const subjectGate = gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + }) + const result = await runGate(subjectGate) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') + expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') + }) +}) + +describe('gate failure logs', () => { + it('records attributable scheduler metadata without inherited secrets', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subjectGate = gate('snapshot', { + env: { + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + }, + }) + const subject = plan([subjectGate]) + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: 'failure details\n' }], + stderr: 'failure details\n', + } + const log = formatGateFailureLog(subject, failure) + expect(log).toContain('replay: pnpm run check:all -- --only snapshot') + expect(log).toContain('DSH_EXAMPLE_MODE') + expect(log).toContain('') + expect(log).toContain('[stderr]\nfailure details') + expect(log).not.toContain('ambient-secret') + expect(log).not.toContain('scheduler-secret') + }) + + it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + const failure = resultFor(subjectGate, 'failed') + + const first = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }) + const second = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', + }) + const third = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + }) + + expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) + expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') + expect(statSync(directory).mode & 0o777).toBe(0o700) + expect(statSync(third).mode & 0o777).toBe(0o600) + expect(() => statSync(first)).toThrow() + await expect(writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + })).rejects.toThrow('EEXIST') + await cleanGateFailureLogs(directory, repositoryRoot) + expect(readdirSync(directory)).toEqual([]) + }) + + it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('build:web') + const path = await writeGateFailureLog( + plan([subjectGate]), + resultFor(subjectGate, 'failed'), + { + directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }, + ) + expect(path.slice(directory.length + 1)).toContain('-build-web-') + expect(path.slice(directory.length + 1)).not.toContain(':') + }) + + it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 256, + unique: 'bounded', + now: new Date('2026-07-27T00:00:00Z'), + platform: 'linux', + }) + const content = readFileSync(path, 'utf8') + + expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) + expect(content).toContain('[run-gates log truncated: original-bytes=') + expect(content).toContain('max-bytes=256') + expect(content).toContain('last detail') + expect(content).not.toContain('\uFFFD') + expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) + }) + + it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 400_000, + unique: 'control-heavy', + platform: 'linux', + }) + + expect(statSync(path).size).toBeLessThanOrEqual(400_000) + expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') + }) + + it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { + const auditRoot = temporaryRoot('dsh-gate-symlink-') + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(repositoryRoot) + mkdirSync(join(external, 'gates'), { recursive: true }) + const victim = join(external, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') + const subjectGate = gate('subject') + const message = 'gate-log path component is a symbolic link: .cache' + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', + })).rejects.toThrow(message) + await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) + await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) + expect(existsSync(victim)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + mkdirSync(directory, { recursive: true }) + mkdirSync(external) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') + if (victim !== undefined) { + mkdirSync(join(external, 'gates')) + writeFileSync(victim, 'keep\n') + } + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + symlinkSync(external, cache, 'dir') + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(existsSync(join(external, 'gates'))).toBe(false) + } else { + expect(readFileSync(victim, 'utf8')).toBe('keep\n') + expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it('uses a console-only fallback on Windows before creating a retention directory', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') + expect(failureLogUnavailableReason('linux')).toBeUndefined() + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, platform: 'win32', + })).rejects.toThrow('retained failure logs are disabled on Windows') + expect(existsSync(directory)).toBe(false) + }) +}) + +describe('Node 24 consumer plan', () => { + it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => { + const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers')) + validateGatePlan(subject) + expect(subject.maxWorkers).toBe(7) + expect(listedGatePlan(subject).maxWorkers).toBe(7) + expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({ + workers: 7, + source: 'ci-consumers plan default 7', + }) + expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({ + workers: 4, + source: '$DSH_GATE_CONCURRENCY', + }) + expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({ + workers: 7, + source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7', + }) + expect(subject.gates.map(item => item.id)).toEqual([ + 'lint-and-duplication', + 'node-compat', + 'snapshot', + 'publint', + 'node-next-types', + 'built-package-invariants', + 'built-bin-smoke', + ]) + expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + } + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([ + 'snapshot', + 'publint', + 'built-package-invariants', + ]) + expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..268c2ffd0f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,44 +1,69 @@ /** - * Run local and CI quality gates with bounded in-process scheduling. + * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * - * The gate vocabulary stays in package.json; this runner only decides which - * independent commands can overlap and which commands wait for built artifacts. + * Package scripts own public aggregate names; this runner owns their validated + * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { resolve } from 'node:path' +import { isAbsolute, relative, resolve, sep } from 'node:path' import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' + +const MODES = [ + 'ci-primary', + 'ci-static', + 'ci-lint', + 'ci-coverage', + 'ci-snapshot', + 'ci-artifacts', + 'ci-consumers', + 'ci-windows-blocking', + 'ci-windows-complete', + 'ci-windows-observational', + 'node-compat', + 'check-all', + 'doc-sync', +] as const + +/** A named aggregate exposed by the gate runner. */ +export type Mode = typeof MODES[number] -type Mode = - | 'ci-primary' - | 'ci-static' - | 'ci-lint' - | 'ci-coverage' - | 'ci-snapshot' - | 'ci-artifacts' - | 'ci-windows-blocking' - | 'ci-windows-complete' - | 'ci-windows-observational' - | 'node-compat' - | 'pre-push' - | 'check-all' - | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' -interface Gate { +/** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ +export type GateEnvironmentOverride = + | { operation: 'set'; value: string } + | { operation: 'unset' } + | { operation: 'append'; value: string; separator?: string } + +/** A command and its dependency metadata inside one gate plan. */ +export interface Gate { id: string label: string displayCommand: string command: string args: string[] needs?: string[] - env?: Record + env?: Record input?: string verify?: (result: GateResult) => Promise allowFailure?: boolean } -interface GateResult { +/** A complete executable aggregate and the package script that owns its diagnostics. */ +export interface GatePlan { + mode: Mode + script: string + gates: Gate[] + maxWorkers?: number +} + +/** The observed outcome of one gate process. */ +export interface GateResult { gate: Gate status: GateStatus durationMs: number @@ -46,7 +71,10 @@ interface GateResult { stderr: string output: GateOutputChunk[] exitCode: number | null + signalCode: NodeJS.Signals | null error?: string + logPath?: string + logError?: string } interface GateOutputChunk { @@ -59,71 +87,199 @@ interface RunningGate { promise: Promise } -interface ConcurrencyDefault { +/** The effective worker count and the facts that selected it. */ +export interface ResolvedConcurrency { workers: number source: string } +interface RunRequest { + kind: 'run' + mode: Mode + list: boolean + json: boolean + only?: string +} + +interface CleanLogsRequest { + kind: 'clean-logs' +} + +type CliRequest = RunRequest | CleanLogsRequest + +interface ListedEnvironmentOverride { + operation: GateEnvironmentOverride['operation'] + value?: string + separator?: string +} + +interface ListedGate { + id: string + label: string + command: string + needs: string[] + env: Record + blocking: boolean +} + +interface ListedPlan { + version: 1 + mode: Mode + script: string + scope: 'complete' + maxWorkers: number | null + gates: ListedGate[] +} + +interface GateLogDirectoryIdentity { + dev: string + ino: string +} + +type GateLogHelperRequest = + | { operation: 'write'; filename: string; content: string; retention: number } + | { operation: 'prune'; retain: number } + | { operation: 'clean' } + +interface GateLogHelperResult { + directory?: GateLogDirectoryIdentity + filename?: string + removed: string[] +} + +type GateExecutor = (gate: Gate) => Promise +type ResultObserver = (result: GateResult) => Promise | void + const root = resolve(import.meta.dirname, '..') -const mode = parseMode(process.argv[2]) -const gates = gatesForMode(mode) -const concurrencyDefault = defaultConcurrency(mode, gates.length) -const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY -const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) -const verbose = process.env.DSH_GATE_VERBOSE === '1' -const startedAt = performance.now() +const gateLogRoot = resolve(root, '.cache/gates') +const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') +const GATE_LOG_RETENTION = 20 +const GATE_LOG_MAX_BYTES = 1_048_576 +const MIN_GATE_LOG_MAX_BYTES = 128 +const MODE_SCRIPTS: Record = { + 'ci-primary': 'check:ci', + 'ci-static': 'check:ci:static', + 'ci-lint': 'check:ci:lint', + 'ci-coverage': 'check:ci:coverage', + 'ci-snapshot': 'check:ci:snapshot', + 'ci-artifacts': 'check:ci:artifacts', + 'ci-consumers': 'check:ci:consumers', + 'ci-windows-blocking': 'check:ci:windows-blocking', + 'ci-windows-complete': 'check:ci:windows-complete', + 'ci-windows-observational': 'check:ci:windows-observational', + 'node-compat': 'check:node-compat', + 'check-all': 'check:all', + 'doc-sync': 'doc-sync', +} -const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' - ? concurrencyDefault.source - : '$DSH_GATE_CONCURRENCY' -console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) +if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) -const results = await runGates(gates, maxConcurrency) -printSummary(results, performance.now() - startedAt) +async function main(args: string[]): Promise { + const request = parseCliRequest(args) + if (request.kind === 'clean-logs') { + await cleanGateFailureLogs() + console.log('run-gates: cleared retained logs in .cache/gates/.') + return 0 + } -if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { - process.exit(1) + const completePlan = gatePlanForMode(request.mode) + validateGatePlan(completePlan) + if (request.list) { + console.log(request.json ? formatGatePlanJson(completePlan) : formatGatePlanList(completePlan)) + return 0 + } + + const plan = request.only === undefined + ? completePlan + : { ...completePlan, gates: gateDependencyClosure(completePlan, request.only) } + validateGatePlan(plan) + if (request.only !== undefined) console.log(formatOnlyNotice(completePlan, request.only)) + + const concurrency = resolvePlanConcurrency(plan, process.env.DSH_GATE_CONCURRENCY) + const maxConcurrency = concurrency.workers + const concurrencySource = concurrency.source + const startedAt = performance.now() + console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) + + const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { + await attachFailureLog(completePlan, result) + printResult(completePlan, result) + }) + printSummary(completePlan, results, performance.now() - startedAt) + return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped')) + ? 1 + : 0 +} + +function isMainModule(): boolean { + const entry = process.argv[1] + return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +} + +/** + * Parse one runner invocation without constructing or starting its plan. + * @param args - command-line arguments after the script entrypoint. + * @returns the validated run or cleanup request. + */ +export function parseCliRequest(args: readonly string[]): CliRequest { + if (args[0] === '--clean-logs') { + if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') + return { kind: 'clean-logs' } + } + + const mode = parseMode(args[0]) + let list = false + let json = false + let only: string | undefined + const firstOption = args[1] === '--' ? 2 : 1 + for (let index = firstOption; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--list') { + if (list) throw new Error('run-gates: --list may be specified only once.') + list = true + } else if (arg === '--json') { + if (json) throw new Error('run-gates: --json may be specified only once.') + json = true + } else if (arg === '--only') { + if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') + const id = args[index + 1] + if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') + only = id + index += 1 + } else { + throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) + } + } + if (json && !list) throw new Error('run-gates: --json requires --list.') + if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') + return { kind: 'run', mode, list, json, ...only === undefined ? {} : { only } } } function parseMode(raw: string | undefined): Mode { - switch (raw) { - case 'ci-primary': - case 'ci-static': - case 'ci-lint': - case 'ci-coverage': - case 'ci-snapshot': - case 'ci-artifacts': - case 'ci-windows-blocking': - case 'ci-windows-complete': - case 'ci-windows-observational': - case 'node-compat': - case 'pre-push': - case 'check-all': - case 'doc-sync': - return raw - default: - throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`, - ) - } + if (MODES.includes(raw as Mode)) return raw as Mode + throw new Error(`run-gates: expected mode ${MODES.join(' | ')}, got ${JSON.stringify(raw)}.`) } -function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { - const available = availableParallelism() +function defaultConcurrency(plan: GatePlan, available: number): ResolvedConcurrency { + if (plan.maxWorkers !== undefined) { + return { + workers: Math.min(plan.gates.length, plan.maxWorkers), + source: `${plan.mode} plan default ${plan.maxWorkers}`, + } + } // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. - const localCap = selectedMode === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync' + const localCap = plan.mode === 'check-all' || plan.mode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { - workers: Math.min(total, modeLimit), + workers: Math.min(plan.gates.length, modeLimit), source: localCap - ? `${available} available CPU(s), ${selectedMode} cap 4` + ? `${available} available CPU(s), ${plan.mode} cap 4` : `${available} available CPU(s)`, } } -function concurrencyFromEnv(name: string, fallback: number): number { - const raw = process.env[name] +function concurrencyFromValue(name: string, raw: string | undefined, fallback: number): number { if (raw === undefined || raw === '') return fallback const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1) { @@ -132,6 +288,33 @@ function concurrencyFromEnv(name: string, fallback: number): number { return parsed } +/** + * Resolve a plan's default, optional environment request, and hard worker ceiling. + * @param plan - validated complete or diagnostic plan. + * @param override - optional `DSH_GATE_CONCURRENCY` value. + * @param available - host CPU availability for modes without a plan-owned default. + * @returns the effective worker count and its inspectable source. + */ +export function resolvePlanConcurrency( + plan: GatePlan, + override: string | undefined, + available = availableParallelism(), +): ResolvedConcurrency { + validateGatePlan(plan) + const defaultValue = defaultConcurrency(plan, available) + const requested = concurrencyFromValue('DSH_GATE_CONCURRENCY', override, defaultValue.workers) + const workers = Math.min(requested, plan.maxWorkers ?? requested) + const requestedSource = override === undefined || override === '' + ? defaultValue.source + : '$DSH_GATE_CONCURRENCY' + return { + workers, + source: workers === requested + ? requestedSource + : `${requestedSource}, ${plan.mode} plan cap ${String(plan.maxWorkers)}`, + } +} + function pnpmScript(id: string, script: string, options: Partial = {}): Gate { return { id, @@ -161,8 +344,18 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -function nodeOptions(...options: string[]): string { - return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') +/** + * Construct the complete plan for a named aggregate without executing it. + * @param selected - aggregate mode to construct. + * @returns the aggregate's package-script identity and gate graph. + */ +export function gatePlanForMode(selected: Mode): GatePlan { + return { + mode: selected, + script: MODE_SCRIPTS[selected], + gates: gatesForMode(selected), + ...selected === 'ci-consumers' ? { maxWorkers: 7 } : {}, + } } function gatesForMode(selected: Mode): Gate[] { @@ -182,6 +375,8 @@ function gatesForMode(selected: Mode): Gate[] { return [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() + case 'ci-consumers': + return ciConsumerGates() case 'ci-windows-blocking': return ciWindowsBlockingGates() case 'ci-windows-complete': @@ -190,7 +385,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -204,7 +398,7 @@ function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] @@ -273,7 +467,7 @@ function ciStaticGates(): Gate[] { pnpmScript('build', 'build'), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -294,6 +488,23 @@ function ciArtifactGates(): Gate[] { ] } +function ciConsumerGates(): Gate[] { + const publicArtifacts = ['publint'] + const restoredBuild = ['built-package-invariants'] + return [ + pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + snapshotGate(restoredBuild), + pnpmScript('publint', 'publint'), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: restoredBuild, + }), + builtPackageInvariantsGate(publicArtifacts), + builtBinSmokeGate(restoredBuild), + ] +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), @@ -343,17 +554,17 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { 'content', ], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } if (concurrencyArgs.length > 0) { return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } @@ -381,11 +592,11 @@ function coverageGate(): Gate { // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, // plugins via real exports); repository-script snapshots execute their real source entry path. -// CI and check-all already build before either class runs, so the suite waits on `build`. -function snapshotGate(): Gate { +// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. +function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { - env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + needs, }) } @@ -430,7 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] - docTypecheckEnv?: Record + docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} @@ -468,7 +679,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(): Gate { +function builtBinSmokeGate(needs: string[] = ['build']): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -486,12 +697,582 @@ function builtBinSmokeGate(): Gate { 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', ], { label: 'built-bin smoke', - needs: ['build'], - env: { DSH_EXAMPLE_MODE: 'lib' }, + needs, + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, }) } -async function runGates(allGates: Gate[], maxActive: number): Promise { +/** + * Reject a plan whose graph cannot be executed unambiguously. + * @param plan - complete or diagnostic plan to validate. + */ +export function validateGatePlan(plan: GatePlan): void { + const errors: string[] = [] + if (plan.gates.length === 0) errors.push('plan has no gates') + if (plan.maxWorkers !== undefined && (!Number.isSafeInteger(plan.maxWorkers) || plan.maxWorkers < 1)) { + errors.push(`maxWorkers must be a positive integer, got ${JSON.stringify(plan.maxWorkers)}`) + } + + const counts = new Map() + for (const gate of plan.gates) { + counts.set(gate.id, (counts.get(gate.id) ?? 0) + 1) + if (!/^[a-z0-9][a-z0-9:-]*$/.test(gate.id)) { + errors.push(`gate id ${JSON.stringify(gate.id)} must contain only lowercase letters, digits, colons, and hyphens`) + } + } + for (const [id, count] of counts) { + if (count > 1) errors.push(`duplicate gate id ${JSON.stringify(id)}`) + } + + const ids = new Set(counts.keys()) + for (const gate of plan.gates) { + for (const dependency of gate.needs ?? []) { + if (!ids.has(dependency)) { + errors.push(`gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}`) + } + } + } + + const cycle = findDependencyCycle(plan.gates) + if (cycle !== undefined) errors.push(`dependency cycle: ${cycle.join(' -> ')}`) + if (errors.length > 0) { + throw new Error(`run-gates: invalid ${plan.mode} plan:\n${errors.map(error => ` - ${error}`).join('\n')}`) + } +} + +function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { + const byId = new Map(gates.map(gate => [gate.id, gate])) + const complete = new Set() + const active = new Map() + const path: string[] = [] + + const visit = (id: string): string[] | undefined => { + if (complete.has(id)) return undefined + const cycleStart = active.get(id) + if (cycleStart !== undefined) return [...path.slice(cycleStart), id] + const gate = byId.get(id) + if (gate === undefined) return undefined + + active.set(id, path.length) + path.push(id) + for (const dependency of gate.needs ?? []) { + const cycle = visit(dependency) + if (cycle !== undefined) return cycle + } + path.pop() + active.delete(id) + complete.add(id) + return undefined + } + + for (const gate of gates) { + const cycle = visit(gate.id) + if (cycle !== undefined) return cycle + } + return undefined +} + +/** + * Return one target and all of its transitive dependencies in canonical plan order. + * @param plan - validated complete owning plan. + * @param targetId - gate selected for diagnostic execution. + * @returns the target's dependency closure in owning-plan order. + */ +export function gateDependencyClosure(plan: GatePlan, targetId: string): Gate[] { + validateGatePlan(plan) + const byId = new Map(plan.gates.map(gate => [gate.id, gate])) + if (!byId.has(targetId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(targetId)}.`) + } + + const selected = new Set() + const include = (id: string): void => { + if (selected.has(id)) return + const gate = byId.get(id) + if (gate === undefined) throw new Error(`run-gates: missing validated dependency ${JSON.stringify(id)}.`) + for (const dependency of gate.needs ?? []) include(dependency) + selected.add(id) + } + include(targetId) + return plan.gates.filter(gate => selected.has(gate.id)) +} + +/** + * Produce the stable machine-readable view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the versioned environment-redacted plan view. + */ +export function listedGatePlan(plan: GatePlan): ListedPlan { + validateGatePlan(plan) + return { + version: 1, + mode: plan.mode, + script: plan.script, + scope: 'complete', + maxWorkers: plan.maxWorkers ?? null, + gates: plan.gates.map(listedGate), + } +} + +function listedGate(gate: Gate): ListedGate { + return { + id: gate.id, + label: gate.label, + command: gate.displayCommand, + needs: [...gate.needs ?? []], + env: listedEnvironment(gate.env), + blocking: gate.allowFailure !== true, + } +} + +function listedEnvironment( + environment: Readonly> | undefined, +): Record { + if (environment === undefined) return {} + return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { + const value = 'value' in override + ? { value: sensitiveEnvironmentName(name) ? '' : override.value } + : {} + const separator = override.operation === 'append' && override.separator !== undefined + ? { separator: override.separator } + : {} + return [name, { operation: override.operation, ...value, ...separator }] + })) +} + +function sensitiveEnvironmentName(name: string): boolean { + return /(key|secret|token|password|credential)/i.test(name) +} + +/** + * Render the deterministic human-readable view used by `--list`. + * @param plan - complete plan to inspect. + * @returns the formatted plan. + */ +export function formatGatePlanList(plan: GatePlan): string { + const listed = listedGatePlan(plan) + const lines = [ + `run-gates: complete ${listed.mode} plan (pnpm run ${listed.script})`, + `max workers: ${listed.maxWorkers === null ? '(host and gate count)' : listed.maxWorkers}`, + ] + for (const gate of listed.gates) { + lines.push(`- ${gate.id} [${gate.blocking ? 'blocking' : 'non-blocking'}] ${gate.label}`) + lines.push(` command: ${gate.command}`) + lines.push(` needs: ${gate.needs.length === 0 ? '(none)' : gate.needs.join(', ')}`) + lines.push(` env: ${Object.keys(gate.env).length === 0 ? '(none)' : JSON.stringify(gate.env)}`) + } + return lines.join('\n') +} + +/** + * Render the stable JSON view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the formatted JSON object. + */ +export function formatGatePlanJson(plan: GatePlan): string { + return JSON.stringify(listedGatePlan(plan), null, 2) +} + +/** + * Render the package-script command that restores a gate's scheduler context. + * @param plan - complete owning plan. + * @param gateId - gate to replay with its dependencies. + * @returns a shell-independent pnpm command. + */ +export function replayCommand(plan: GatePlan, gateId: string): string { + validateGatePlan(plan) + if (!plan.gates.some(gate => gate.id === gateId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) + } + return `pnpm run ${plan.script} -- --only ${gateId}` +} + +/** + * Explain that a focused run is diagnostic rather than the complete aggregate. + * @param plan - complete owning plan. + * @param gateId - selected diagnostic gate. + * @returns the partial-evidence notice. + */ +export function formatOnlyNotice(plan: GatePlan, gateId: string): string { + return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` +} + +/** + * Resolve only scheduler-declared environment operations against the spawn environment. + * @param gate - gate whose operations to apply. + * @param inherited - environment inherited by the runner. + * @returns the child environment without mutating the inherited object. + */ +export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const resolved = { ...inherited } + for (const [name, override] of Object.entries(gate.env ?? {})) { + if (override.operation === 'unset') { + Reflect.deleteProperty(resolved, name) + } else if (override.operation === 'set') { + resolved[name] = override.value + } else { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current}${override.separator ?? ' '}${override.value}` + } + } + return resolved +} + +/** + * Run a validated plan; invalid input rejects before the injected executor can start a child. + * @param plan - complete or diagnostic plan to execute. + * @param maxActive - maximum concurrent child count. + * @param execute - child-process executor. + * @param observe - serialized result observer. + * @returns results in canonical plan order. + */ +export async function executeGatePlan( + plan: GatePlan, + maxActive: number, + execute: GateExecutor, + observe: ResultObserver = () => {}, +): Promise { + validateGatePlan(plan) + if (!Number.isSafeInteger(maxActive) || maxActive < 1) { + throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) + } + if (plan.maxWorkers !== undefined && maxActive > plan.maxWorkers) { + throw new Error(`run-gates: max concurrency ${maxActive} exceeds the ${plan.mode} plan ceiling ${plan.maxWorkers}.`) + } + return runGates(plan.gates, maxActive, execute, observe) +} + +/** + * Format one private failure log without consulting or enumerating the inherited environment. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @returns attributable metadata and interleaved output. + */ +export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { + const gate = listedGate(result.gate) + const lines = [ + 'run-gates failure log', + `mode: ${plan.mode}`, + `gate: ${gate.id}`, + `status: ${result.status}`, + `blocking: ${gate.blocking}`, + `command: ${gate.command}`, + `replay: ${replayCommand(plan, gate.id)}`, + `scheduler environment: ${JSON.stringify(gate.env)}`, + `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, + `signal: ${result.signalCode ?? 'none'}`, + ] + if (result.error !== undefined) lines.push(`error: ${result.error}`) + lines.push('', 'interleaved output:') + for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) + return `${lines.join('\n')}\n` +} + +/** + * Explain why retained logs are unavailable on a platform. + * @param platform - host platform to evaluate. + * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. + */ +export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { + return platform === 'win32' + ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' + : undefined +} + +/** + * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. + * @param content - complete formatted failure log. + * @param maxBytes - maximum encoded byte length. + * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. + */ +export function limitGateFailureLog(content: string, maxBytes: number): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { + throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) + } + const originalBytes = Buffer.byteLength(content) + if (originalBytes <= maxBytes) return content + + const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` + const available = maxBytes - Buffer.byteLength(marker) + if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') + const prefixBytes = Math.ceil(available / 2) + const suffixBytes = available - prefixBytes + return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` +} + +function utf8Prefix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let end = maxBytes + while (end > 0) { + const byte = encoded[end] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + end -= 1 + } + return encoded.subarray(0, end).toString('utf8') +} + +function utf8Suffix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let start = encoded.length - maxBytes + while (start < encoded.length) { + const byte = encoded[start] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + start += 1 + } + return encoded.subarray(start).toString('utf8') +} + +/** + * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @param options - injectable storage, bound, clock, identity, and platform seams. + * @returns the absolute log path. + */ +export async function writeGateFailureLog( + plan: GatePlan, + result: GateResult, + options: { + directory?: string + repositoryRoot?: string + retention?: number + maxBytes?: number + unique?: string + now?: Date + platform?: NodeJS.Platform + beforeHelper?: () => Promise | void + } = {}, +): Promise { + const directory = options.directory ?? gateLogRoot + const repositoryRoot = options.repositoryRoot ?? root + const retention = options.retention ?? GATE_LOG_RETENTION + const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES + const unique = options.unique ?? randomUUID() + const now = options.now ?? new Date() + const unavailable = failureLogUnavailableReason(options.platform) + if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) + if (!Number.isSafeInteger(retention) || retention < 1) { + throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') + const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') + if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') + const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') + const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` + const helperResult = await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + { + operation: 'write', + filename, + content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), + retention, + }, + options.beforeHelper, + ) + if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') + return resolve(directory, filename) +} + +async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { + const relativeTarget = relative(repositoryRoot, target) + if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { + throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) + } + + const rootMetadata = await lstat(repositoryRoot) + if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { + throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) + } + let current = repositoryRoot + for (const component of relativeTarget.split(sep)) { + current = resolve(current, component) + let metadata + try { + metadata = await lstat(current) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return + throw error + } + const shown = relative(repositoryRoot, current).split(sep).join('/') + if (metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) + } + if (!metadata.isDirectory()) { + throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) + } + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +async function readDirectoryIdentity(directory: string): Promise { + let metadata + try { + metadata = await lstat(directory, { bigint: true }) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return undefined + throw error + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) + } + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +async function runGateLogHelper( + directory: string, + repositoryRoot: string, + repositoryIdentity: GateLogDirectoryIdentity, + request: GateLogHelperRequest, + beforeHelper: (() => Promise | void) | undefined, +): Promise { + await beforeHelper?.() + const payload = JSON.stringify({ + ...request, + repository: { + root: repositoryRoot, + relative: relative(repositoryRoot, directory), + identity: repositoryIdentity, + }, + }) + const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { + const child = spawn(process.execPath, [gateLogHelper], { + cwd: repositoryRoot, + env: {}, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (status) => { + resolveResult({ status, stdout, stderr }) + }) + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') reject(error) + }) + child.stdin.end(payload) + }) + if (result.status !== 0) { + throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) + } + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) + } + if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') + await assertRepoLocalLogPath(repositoryRoot, directory) + const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if ( + currentRepositoryIdentity === undefined + || currentRepositoryIdentity.dev !== repositoryIdentity.dev + || currentRepositoryIdentity.ino !== repositoryIdentity.ino + ) { + throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') + } + if (parsed.directory !== undefined) { + const currentDirectoryIdentity = await readDirectoryIdentity(directory) + if ( + currentDirectoryIdentity === undefined + || currentDirectoryIdentity.dev !== parsed.directory.dev + || currentDirectoryIdentity.ino !== parsed.directory.ino + ) { + throw new Error('run-gates: gate-log directory identity changed while the helper was running.') + } + } else if (request.operation === 'write') { + throw new Error('run-gates: gate-log helper did not return the created directory identity.') + } + return parsed +} + +function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { + if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false + if (!value.removed.every(entry => typeof entry === 'string')) return false + if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false + return !('directory' in value) + || value.directory === undefined + || isGateLogDirectoryIdentity(value.directory) +} + +function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { + return typeof value === 'object' + && value !== null + && 'dev' in value + && typeof value.dev === 'string' + && 'ino' in value + && typeof value.ino === 'string' +} + +/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ +export async function cleanGateFailureLogs( + directory = gateLogRoot, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) +} + +/** + * Remove older scheduler log files until at most `retain` remain. + * @param directory - private log directory. + * @param retain - number of newest log files to preserve. + * @param repositoryRoot - repository boundary containing the log directory. + * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. + */ +export async function pruneGateLogs( + directory: string, + retain: number, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + if (!Number.isSafeInteger(retain) || retain < 0) { + throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) +} + +async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { + if (result.status !== 'failed') return + try { + const path = await writeGateFailureLog(plan, result) + result.logPath = relative(root, path).split(sep).join('/') + } catch (error: unknown) { + result.logError = error instanceof Error ? error.message : String(error) + } +} + +async function runGates( + allGates: Gate[], + maxActive: number, + execute: GateExecutor, + observe: ResultObserver, +): Promise { const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -502,7 +1283,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') - running.push({ gate: ready, promise: runGate(ready) }) + running.push({ gate: ready, promise: execute(ready) }) console.log(`run-gates: start ${ready.label}`) madeProgress = true } @@ -519,11 +1300,12 @@ async function runGates(allGates: Gate[], maxActive: number): Promise): boolea return (gate.needs ?? []).every(id => states.get(id) === 'passed') } -async function runGate(gate: Gate): Promise { +/** + * Execute one gate through the real shell-free child-process boundary. + * @param gate - command and scheduler environment to execute. + * @returns the complete process and verification outcome. + */ +export async function runGate(gate: Gate): Promise { const started = performance.now() let stdout = '' let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined - const exitCode = await new Promise((resolveExit) => { + const outcome = await new Promise<{ + exitCode: number | null + signalCode: NodeJS.Signals | null + }>((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, - env: { ...process.env, ...gate.env }, + env: resolveGateEnvironment(gate, process.env), stdio: ['pipe', 'pipe', 'pipe'], }) child.stdout.setEncoding('utf8') @@ -573,18 +1363,21 @@ async function runGate(gate: Gate): Promise { }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` - resolveExit(null) + resolveExit({ exitCode: null, signalCode: null }) + }) + child.on('close', (exitCode, signalCode) => { + resolveExit({ exitCode, signalCode }) }) - child.on('close', resolveExit) if (gate.input !== undefined) child.stdin.end(gate.input) else child.stdin.end() }) + const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed' + let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode }) + await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode, signalCode }) } catch (verifyError: unknown) { status = 'failed' error = verifyError instanceof Error ? verifyError.message : String(verifyError) @@ -599,12 +1392,27 @@ async function runGate(gate: Gate): Promise { stderr, output, exitCode, + signalCode, } if (error !== undefined) result.error = error return result } -function printResult(result: GateResult): void { +/** + * Format every independently observed failure fact for the aggregate summary. + * @param result - unsuccessful gate result. + * @returns error, exit, and signal facts without allowing one to hide another. + */ +export function formatGateResultReason(result: GateResult): string { + const facts: string[] = [] + if (result.error !== undefined) facts.push(result.error) + if (result.exitCode !== null) facts.push(`exit ${result.exitCode}`) + if (result.signalCode !== null) facts.push(`signal ${result.signalCode}`) + return facts.length === 0 ? 'no exit code or signal' : facts.join(', ') +} + +function printResult(plan: GatePlan, result: GateResult): void { + const verbose = process.env.DSH_GATE_VERBOSE === '1' const seconds = (result.durationMs / 1000).toFixed(2) if (result.status === 'passed' && !verbose) { console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`) @@ -614,12 +1422,22 @@ function printResult(result: GateResult): void { const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)` const writeHeading = result.status === 'passed' ? console.log : console.error writeHeading(`\n== ${heading} ==`) - if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`) + if (result.status !== 'passed') { + const environment = listedGate(result.gate).env + console.error(`command: ${result.gate.displayCommand}`) + if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) { + console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) + console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') + } + if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) + } printOutput(result.output) if (result.error !== undefined) console.error(result.error) } -function printSummary(results: GateResult[], durationMs: number): void { +function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { const passed = results.filter(result => result.status === 'passed').length const failed = results.filter(result => result.status === 'failed').length const skipped = results.filter(result => result.status === 'skipped').length @@ -632,10 +1450,11 @@ function printSummary(results: GateResult[], durationMs: number): void { console.error('run-gates: unsuccessful gates:') for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) - const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) + const reason = formatGateResultReason(result) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) - console.error(` ${result.gate.displayCommand}`) + console.error(` replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } From 2e210c369bff058f335417c7224e8fdfce815f58 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:58 +0800 Subject: [PATCH 02/43] fix(dev-infra): pin replay log path identities --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 4 +- .../2026-07-27-replayable-gate-plans.zh.md | 4 +- scripts/gate-log-helper.mjs | 57 +++++++--- scripts/run-gates.spec.ts | 107 ++++++++++++++++++ scripts/run-gates.ts | 88 ++++++++++---- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 3966caf9cf..4b12a533b2 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9 -2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 +2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 +2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 604fb87ebe..0b052352f5 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -18,13 +18,13 @@ Every mode supports deterministic `--list` output and a versioned stable `--list `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index e24d77ff62..0c9f20d723 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -18,13 +18,13 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 `check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs index 363b538f70..0c1e4fa3af 100644 --- a/scripts/gate-log-helper.mjs +++ b/scripts/gate-log-helper.mjs @@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) { throw new Error('invalid repository-relative gate-log path') } assertIdentity(repository.identity, 'repository') + const names = repository.relative.split(sep) + if (!Array.isArray(repository.components) || repository.components.length !== names.length) { + throw new Error('invalid gate-log path-component plan') + } + for (let index = 0; index < names.length; index += 1) { + const component = repository.components[index] + if ( + typeof component !== 'object' + || component === null + || component.name !== names[index] + || !('identity' in component) + ) { + throw new Error('invalid gate-log path-component plan') + } + if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) + } const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { throw new Error('gate-log repository identity changed before the helper started') @@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) { ) { throw new Error('gate-log repository root is not a real directory') } + return repository.components } -async function enterLogDirectory(relativePath, create) { +async function enterLogDirectory(components, create) { const traversed = [] - for (const component of relativePath.split(sep)) { - if (component === '' || component === '.' || component === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + for (const component of components) { + if (component.name === '' || component.name === '.' || component.name === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) } - traversed.push(component) + traversed.push(component.name) let componentMetadata + let created = false try { - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) } catch (error) { if (errorCode(error) !== 'ENOENT') throw error + if (component.identity !== null) { + throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) + } if (!create) return undefined try { - await mkdir(component, { mode: 0o700 }) + await mkdir(component.name, { mode: 0o700 }) } catch (mkdirError) { - if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + if (errorCode(mkdirError) === 'EEXIST') { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + throw mkdirError } - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) + created = true + } + if (component.identity === null && !created) { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { + throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) } const shown = traversed.join('/') if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { throw new Error(`gate-log path component is not a real directory: ${shown}`) } - const expected = identityOf(componentMetadata) - process.chdir(component) + const expected = component.identity ?? identityOf(componentMetadata) + process.chdir(component.name) const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { throw new Error(`gate-log path component identity changed before pinning: ${shown}`) @@ -195,8 +226,8 @@ async function writeLog(request) { async function main() { const request = await readRequest() assertRequest(request) - await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + const components = await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(components, request.operation === 'write') if (directory === undefined) return { removed: [] } switch (request.operation) { case 'write': { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 49c1aab675..46d41a5df8 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -25,6 +25,7 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, + isMainModule, listedGatePlan, limitGateFailureLog, parseCliRequest, @@ -240,6 +241,15 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { + const temporary = temporaryRoot('dsh-run-gates-entry-') + const entry = join(temporary, 'run-gates.ts') + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + + expect(isMainModule(entry)).toBe(true) + expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) + }) + it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -465,6 +475,103 @@ describe('gate failure logs', () => { } }) + it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + const externalCache = join(external, 'cache') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') + if (victim !== undefined) writeFileSync(victim, 'keep\n') + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + renameSync(externalCache, cache) + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(readdirSync(join(cache, 'gates'))).toEqual([]) + } else { + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { + const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalCache = join(auditRoot, 'external-cache') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + mkdirSync(repositoryRoot) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + const victim = join(externalCache, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + const subjectGate = gate('subject') + + const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: 'missing-swap', + platform: 'linux', + beforeHelper: () => { + renameSync(externalCache, cache) + }, + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) + }) + + it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { + const auditRoot = temporaryRoot('dsh-gate-root-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalRoot = join(auditRoot, 'external-repository') + const displacedRoot = join(auditRoot, 'repository-pinned') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') + + const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { + renameSync(repositoryRoot, displacedRoot) + renameSync(externalRoot, repositoryRoot) + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') + }) + it('uses a console-only fallback on Windows before creating a retention directory', async () => { const repositoryRoot = temporaryRoot() const directory = join(repositoryRoot, '.cache/gates') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 268c2ffd0f..08a524556e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -7,6 +7,7 @@ */ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { realpathSync } from 'node:fs' import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' @@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity { ino: string } +interface GateLogPathComponent { + name: string + identity: GateLogDirectoryIdentity | null +} + +interface GateLogPathPlan { + repositoryIdentity: GateLogDirectoryIdentity + pathComponents: GateLogPathComponent[] +} + type GateLogHelperRequest = | { operation: 'write'; filename: string; content: string; retention: number } | { operation: 'prune'; retain: number } @@ -211,9 +222,19 @@ async function main(args: string[]): Promise { : 0 } -function isMainModule(): boolean { - const entry = process.argv[1] - return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +/** + * Decide whether this module is the process entry, including through a symlinked path. + * @param entry - process entry path to compare with this module. + * @returns Whether the entry resolves to this module. + */ +export function isMainModule(entry: string | undefined = process.argv[1]): boolean { + if (entry === undefined) return false + if (import.meta.url === pathToFileURL(resolve(entry)).href) return true + try { + return import.meta.url === pathToFileURL(realpathSync(entry)).href + } catch { + return false + } } /** @@ -1058,9 +1079,7 @@ export async function writeGateFailureLog( if (!Number.isSafeInteger(retention) || retention < 1) { throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') @@ -1070,6 +1089,7 @@ export async function writeGateFailureLog( directory, repositoryRoot, repositoryIdentity, + pathComponents, { operation: 'write', filename, @@ -1082,24 +1102,37 @@ export async function writeGateFailureLog( return resolve(directory, filename) } -async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { +async function inspectRepoLocalLogPath( + repositoryRoot: string, + target: string, +): Promise { const relativeTarget = relative(repositoryRoot, target) if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) } - const rootMetadata = await lstat(repositoryRoot) + const rootMetadata = await lstat(repositoryRoot, { bigint: true }) if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) } + const components: GateLogPathComponent[] = [] let current = repositoryRoot + let missing = false for (const component of relativeTarget.split(sep)) { current = resolve(current, component) + if (missing) { + components.push({ name: component, identity: null }) + continue + } let metadata try { - metadata = await lstat(current) + metadata = await lstat(current, { bigint: true }) } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return + if (hasErrorCode(error, 'ENOENT')) { + missing = true + components.push({ name: component, identity: null }) + continue + } throw error } const shown = relative(repositoryRoot, current).split(sep).join('/') @@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P if (!metadata.isDirectory()) { throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) } + components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) + } + return { + repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, + pathComponents: components, } } @@ -1134,6 +1172,7 @@ async function runGateLogHelper( directory: string, repositoryRoot: string, repositoryIdentity: GateLogDirectoryIdentity, + pathComponents: GateLogPathComponent[], request: GateLogHelperRequest, beforeHelper: (() => Promise | void) | undefined, ): Promise { @@ -1144,6 +1183,7 @@ async function runGateLogHelper( root: repositoryRoot, relative: relative(repositoryRoot, directory), identity: repositoryIdentity, + components: pathComponents, }, }) const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { @@ -1181,7 +1221,7 @@ async function runGateLogHelper( throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) } if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await assertRepoLocalLogPath(repositoryRoot, directory) + await inspectRepoLocalLogPath(repositoryRoot, directory) const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) if ( currentRepositoryIdentity === undefined @@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs( repositoryRoot = root, beforeHelper?: () => Promise | void, ): Promise { - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'clean' }, + beforeHelper, + ) } /** @@ -1251,10 +1296,15 @@ export async function pruneGateLogs( if (!Number.isSafeInteger(retain) || retain < 0) { throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'prune', retain }, + beforeHelper, + ) } async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { From 0fabbd72ad8f4bbb5e453642b8052daa774e6000 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:05:21 +0800 Subject: [PATCH 03/43] test(dev-infra): share gate log swap setup --- scripts/run-gates.spec.ts | 70 +++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 46d41a5df8..13a37d0070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,30 @@ function temporaryRoot(prefix = 'dsh-gate-logs-'): string { return root } +function invokeGateLogOperation( + operation: 'write' | 'prune' | 'clean', + subjectGate: Gate, + directory: string, + root: string, + beforeHelper: () => void, +): Promise { + switch (operation) { + case 'write': + return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot: root, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper, + }) + case 'prune': + return pruneGateLogs(directory, 0, root, beforeHelper) + case 'clean': + return cleanGateFailureLogs(directory, root, beforeHelper) + } +} + function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -426,7 +450,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) @@ -448,21 +471,13 @@ describe('gate failure logs', () => { symlinkSync(external, cache, 'dir') } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { @@ -477,7 +492,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) @@ -497,21 +511,13 @@ describe('gate failure logs', () => { renameSync(externalCache, cache) } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { From 3f27434f388d100d88333f1acc6c0cf3a4eaa2d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:00 +0800 Subject: [PATCH 04/43] refactor(dev-infra): drop retained gate logs --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 28 +- .../2026-07-27-replayable-gate-plans.zh.md | 28 +- scripts/gate-log-helper.mjs | 251 ---------- scripts/run-gates.spec.ts | 349 ++------------ scripts/run-gates.ts | 429 +----------------- 6 files changed, 70 insertions(+), 1019 deletions(-) delete mode 100644 scripts/gate-log-helper.mjs diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 4b12a533b2..98e78e752a 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 -2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 +2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b +2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 0b052352f5..a312481a4d 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -6,33 +6,37 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) ## Problem -Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. ## Decision [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn, so inspection never enumerates or bakes in inherited values; values under secret-like names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered -- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. -- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. -- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. -- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. -- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. +**Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. + +**Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. + +**Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. + +**Persist child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record. + +**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates. ## Consequences @@ -40,4 +44,4 @@ The scheduler owns a small CLI and a versioned JSON schema that must evolve deli Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. -Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. +Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 0c9f20d723..c213068f54 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -6,33 +6,37 @@ Status: implemented ## 问题 -仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 ## 决策 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 -- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 -- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 -- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 -- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 -- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 +**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 + +**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 + +**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 + +**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。 + +**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。 ## 后果 @@ -40,4 +44,4 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 -保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 +缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs deleted file mode 100644 index 0c1e4fa3af..0000000000 --- a/scripts/gate-log-helper.mjs +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env node -/** Pin the repository and each log-path component before creating or operating on private logs. */ - -import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' -import { isAbsolute, sep } from 'node:path' - -const MAX_REQUEST_BYTES = 8 * 1024 * 1024 -const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ - -function errorCode(error) { - return typeof error === 'object' && error !== null && 'code' in error - ? error.code - : undefined -} - -async function readRequest() { - const chunks = [] - let bytes = 0 - for await (const chunk of process.stdin) { - bytes += chunk.length - if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') - chunks.push(chunk) - } - return JSON.parse(Buffer.concat(chunks).toString('utf8')) -} - -function assertInteger(value, label, minimum) { - if (!Number.isSafeInteger(value) || value < minimum) { - throw new Error(`${label} must be an integer of at least ${minimum}`) - } -} - -function assertLogName(name) { - if (typeof name !== 'string' || !LOG_NAME.test(name)) { - throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) - } -} - -function assertRequest(request) { - if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') - switch (request.operation) { - case 'write': - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - return - case 'prune': - assertInteger(request.retain, 'retain', 0) - return - case 'clean': - return - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -function assertIdentity(value, label) { - if ( - typeof value !== 'object' - || value === null - || typeof value.dev !== 'string' - || typeof value.ino !== 'string' - ) { - throw new Error(`missing expected ${label} identity`) - } -} - -function identityOf(metadata) { - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -function sameIdentity(metadata, expected) { - return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino -} - -async function assertPinnedRepository(repository) { - if ( - typeof repository !== 'object' - || repository === null - || typeof repository.root !== 'string' - || !isAbsolute(repository.root) - || typeof repository.relative !== 'string' - || repository.relative === '' - || repository.relative === '..' - || repository.relative.startsWith(`..${sep}`) - || isAbsolute(repository.relative) - ) { - throw new Error('invalid repository-relative gate-log path') - } - assertIdentity(repository.identity, 'repository') - const names = repository.relative.split(sep) - if (!Array.isArray(repository.components) || repository.components.length !== names.length) { - throw new Error('invalid gate-log path-component plan') - } - for (let index = 0; index < names.length; index += 1) { - const component = repository.components[index] - if ( - typeof component !== 'object' - || component === null - || component.name !== names[index] - || !('identity' in component) - ) { - throw new Error('invalid gate-log path-component plan') - } - if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) - } - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { - throw new Error('gate-log repository identity changed before the helper started') - } - const rootMetadata = await lstat(repository.root, { bigint: true }) - if ( - !rootMetadata.isDirectory() - || rootMetadata.isSymbolicLink() - || !sameIdentity(rootMetadata, repository.identity) - ) { - throw new Error('gate-log repository root is not a real directory') - } - return repository.components -} - -async function enterLogDirectory(components, create) { - const traversed = [] - for (const component of components) { - if (component.name === '' || component.name === '.' || component.name === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) - } - traversed.push(component.name) - let componentMetadata - let created = false - try { - componentMetadata = await lstat(component.name, { bigint: true }) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - if (component.identity !== null) { - throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) - } - if (!create) return undefined - try { - await mkdir(component.name, { mode: 0o700 }) - } catch (mkdirError) { - if (errorCode(mkdirError) === 'EEXIST') { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - throw mkdirError - } - componentMetadata = await lstat(component.name, { bigint: true }) - created = true - } - if (component.identity === null && !created) { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { - throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) - } - const shown = traversed.join('/') - if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { - throw new Error(`gate-log path component is not a real directory: ${shown}`) - } - const expected = component.identity ?? identityOf(componentMetadata) - process.chdir(component.name) - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { - throw new Error(`gate-log path component identity changed before pinning: ${shown}`) - } - } - await chmod('.', 0o700) - return identityOf(await stat('.', { bigint: true })) -} - -async function removeOldLogs(retain, newest) { - assertInteger(retain, 'retain', 0) - const entries = await readdir('.', { withFileTypes: true }) - const logs = [] - for (const entry of entries) { - if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue - let metadata - try { - metadata = await lstat(entry.name, { bigint: true }) - } catch (error) { - if (errorCode(error) === 'ENOENT') continue - throw error - } - if (!metadata.isFile() || metadata.isSymbolicLink()) continue - logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) - } - logs.sort((left, right) => { - if (left.name === newest) return 1 - if (right.name === newest) return -1 - if (left.mtimeNs < right.mtimeNs) return -1 - if (left.mtimeNs > right.mtimeNs) return 1 - return left.name.localeCompare(right.name) - }) - const removed = [] - for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { - try { - await unlink(entry.name) - removed.push(entry.name) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - } - } - return removed -} - -async function writeLog(request) { - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - const handle = await open( - request.filename, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, - ) - try { - await handle.writeFile(request.content, 'utf8') - await handle.chmod(0o600) - } finally { - await handle.close() - } - const removed = await removeOldLogs(request.retention, request.filename) - return { filename: request.filename, removed } -} - -async function main() { - const request = await readRequest() - assertRequest(request) - const components = await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(components, request.operation === 'write') - if (directory === undefined) return { removed: [] } - switch (request.operation) { - case 'write': { - const result = await writeLog(request) - return { ...result, directory } - } - case 'prune': - return { directory, removed: await removeOldLogs(request.retain) } - case 'clean': - return { directory, removed: await removeOldLogs(0) } - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -try { - process.stdout.write(`${JSON.stringify(await main())}\n`) -} catch (error) { - process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 -} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 13a37d0070..1a86ccd064 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,24 +1,14 @@ import { - existsSync, - mkdirSync, mkdtempSync, - readFileSync, - readdirSync, - renameSync, rmSync, - statSync, symlinkSync, - writeFileSync, } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { - cleanGateFailureLogs, executeGatePlan, - failureLogUnavailableReason, - formatGateFailureLog, formatGatePlanJson, formatGatePlanList, formatGateResultReason, @@ -27,15 +17,12 @@ import { gatePlanForMode, isMainModule, listedGatePlan, - limitGateFailureLog, parseCliRequest, - pruneGateLogs, replayCommand, resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, - writeGateFailureLog, type Gate, type GatePlan, type GateResult, @@ -77,36 +64,12 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-gate-logs-'): string { +function temporaryRoot(prefix = 'dsh-run-gates-'): string { const root = mkdtempSync(join(tmpdir(), prefix)) temporaryRoots.push(root) return root } -function invokeGateLogOperation( - operation: 'write' | 'prune' | 'clean', - subjectGate: Gate, - directory: string, - root: string, - beforeHelper: () => void, -): Promise { - switch (operation) { - case 'write': - return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot: root, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper, - }) - case 'prune': - return pruneGateLogs(directory, 0, root, beforeHelper) - case 'clean': - return cleanGateFailureLogs(directory, root, beforeHelper) - } -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -167,6 +130,31 @@ describe('gate plan validation', () => { expect(execute).not.toHaveBeenCalled() }) + it('reports a settled failure before an unrelated gate finishes', async () => { + const first = gate('first') + const second = gate('second') + const settle = new Map void>() + const observed: string[] = [] + const execution = executeGatePlan( + plan([first, second]), + 2, + subject => new Promise(resolve => settle.set(subject.id, resolve)), + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + const settleFirst = settle.get(first.id) + const settleSecond = settle.get(second.id) + if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start') + settleFirst(resultFor(first, 'failed')) + await vi.waitFor(() => { + expect(observed).toEqual(['first:failed']) + }) + settleSecond(resultFor(second)) + + await expect(execution).resolves.toHaveLength(2) + expect(observed).toEqual(['first:failed', 'second:passed']) + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -182,14 +170,13 @@ describe('gate plan validation', () => { }) describe('gate plan inspection and replay', () => { - it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + it('parses package-script separators, list JSON, and focused runs', () => { expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ kind: 'run', mode: 'check-all', list: true, json: true, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', }) - expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') }) @@ -307,288 +294,6 @@ describe('gate plan inspection and replay', () => { expect(result.exitCode).toBeNull() expect(result.signalCode).toBe('SIGTERM') expect(formatGateResultReason(result)).toBe('signal SIGTERM') - expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') - }) -}) - -describe('gate failure logs', () => { - it('records attributable scheduler metadata without inherited secrets', () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') - const subjectGate = gate('snapshot', { - env: { - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, - }, - }) - const subject = plan([subjectGate]) - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: 'failure details\n' }], - stderr: 'failure details\n', - } - const log = formatGateFailureLog(subject, failure) - expect(log).toContain('replay: pnpm run check:all -- --only snapshot') - expect(log).toContain('DSH_EXAMPLE_MODE') - expect(log).toContain('') - expect(log).toContain('[stderr]\nfailure details') - expect(log).not.toContain('ambient-secret') - expect(log).not.toContain('scheduler-secret') - }) - - it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const subject = plan([subjectGate]) - const failure = resultFor(subjectGate, 'failed') - - const first = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }) - const second = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', - }) - const third = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - }) - - expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) - expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') - expect(statSync(directory).mode & 0o777).toBe(0o700) - expect(statSync(third).mode & 0o777).toBe(0o600) - expect(() => statSync(first)).toThrow() - await expect(writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - })).rejects.toThrow('EEXIST') - await cleanGateFailureLogs(directory, repositoryRoot) - expect(readdirSync(directory)).toEqual([]) - }) - - it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('build:web') - const path = await writeGateFailureLog( - plan([subjectGate]), - resultFor(subjectGate, 'failed'), - { - directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }, - ) - expect(path.slice(directory.length + 1)).toContain('-build-web-') - expect(path.slice(directory.length + 1)).not.toContain(':') - }) - - it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 256, - unique: 'bounded', - now: new Date('2026-07-27T00:00:00Z'), - platform: 'linux', - }) - const content = readFileSync(path, 'utf8') - - expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) - expect(content).toContain('[run-gates log truncated: original-bytes=') - expect(content).toContain('max-bytes=256') - expect(content).toContain('last detail') - expect(content).not.toContain('\uFFFD') - expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) - }) - - it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 400_000, - unique: 'control-heavy', - platform: 'linux', - }) - - expect(statSync(path).size).toBeLessThanOrEqual(400_000) - expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') - }) - - it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { - const auditRoot = temporaryRoot('dsh-gate-symlink-') - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(repositoryRoot) - mkdirSync(join(external, 'gates'), { recursive: true }) - const victim = join(external, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') - const subjectGate = gate('subject') - const message = 'gate-log path component is a symbolic link: .cache' - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', - })).rejects.toThrow(message) - await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) - await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) - expect(existsSync(victim)).toBe(true) - }) - - it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - mkdirSync(directory, { recursive: true }) - mkdirSync(external) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') - if (victim !== undefined) { - mkdirSync(join(external, 'gates')) - writeFileSync(victim, 'keep\n') - } - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - symlinkSync(external, cache, 'dir') - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(existsSync(join(external, 'gates'))).toBe(false) - } else { - expect(readFileSync(victim, 'utf8')).toBe('keep\n') - expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - const externalCache = join(external, 'cache') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') - if (victim !== undefined) writeFileSync(victim, 'keep\n') - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - renameSync(externalCache, cache) - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(readdirSync(join(cache, 'gates'))).toEqual([]) - } else { - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { - const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalCache = join(auditRoot, 'external-cache') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - mkdirSync(repositoryRoot) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - const victim = join(externalCache, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - const subjectGate = gate('subject') - - const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: 'missing-swap', - platform: 'linux', - beforeHelper: () => { - renameSync(externalCache, cache) - }, - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) - }) - - it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { - const auditRoot = temporaryRoot('dsh-gate-root-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalRoot = join(auditRoot, 'external-repository') - const displacedRoot = join(auditRoot, 'repository-pinned') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') - - const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { - renameSync(repositoryRoot, displacedRoot) - renameSync(externalRoot, repositoryRoot) - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') - }) - - it('uses a console-only fallback on Windows before creating a retention directory', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') - expect(failureLogUnavailableReason('linux')).toBeUndefined() - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, platform: 'win32', - })).rejects.toThrow('retained failure logs are disabled on Windows') - expect(existsSync(directory)).toBe(false) }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 08a524556e..1f6d18c2e0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -2,15 +2,13 @@ * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * * Package scripts own public aggregate names; this runner owns their validated - * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * dependency graphs, scheduler environment, and replay diagnostics. * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { realpathSync } from 'node:fs' -import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' @@ -74,8 +72,6 @@ export interface GateResult { exitCode: number | null signalCode: NodeJS.Signals | null error?: string - logPath?: string - logError?: string } interface GateOutputChunk { @@ -102,12 +98,6 @@ interface RunRequest { only?: string } -interface CleanLogsRequest { - kind: 'clean-logs' -} - -type CliRequest = RunRequest | CleanLogsRequest - interface ListedEnvironmentOverride { operation: GateEnvironmentOverride['operation'] value?: string @@ -132,41 +122,10 @@ interface ListedPlan { gates: ListedGate[] } -interface GateLogDirectoryIdentity { - dev: string - ino: string -} - -interface GateLogPathComponent { - name: string - identity: GateLogDirectoryIdentity | null -} - -interface GateLogPathPlan { - repositoryIdentity: GateLogDirectoryIdentity - pathComponents: GateLogPathComponent[] -} - -type GateLogHelperRequest = - | { operation: 'write'; filename: string; content: string; retention: number } - | { operation: 'prune'; retain: number } - | { operation: 'clean' } - -interface GateLogHelperResult { - directory?: GateLogDirectoryIdentity - filename?: string - removed: string[] -} - type GateExecutor = (gate: Gate) => Promise -type ResultObserver = (result: GateResult) => Promise | void +type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const gateLogRoot = resolve(root, '.cache/gates') -const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') -const GATE_LOG_RETENTION = 20 -const GATE_LOG_MAX_BYTES = 1_048_576 -const MIN_GATE_LOG_MAX_BYTES = 128 const MODE_SCRIPTS: Record = { 'ci-primary': 'check:ci', 'ci-static': 'check:ci:static', @@ -187,12 +146,6 @@ if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) async function main(args: string[]): Promise { const request = parseCliRequest(args) - if (request.kind === 'clean-logs') { - await cleanGateFailureLogs() - console.log('run-gates: cleared retained logs in .cache/gates/.') - return 0 - } - const completePlan = gatePlanForMode(request.mode) validateGatePlan(completePlan) if (request.list) { @@ -212,8 +165,7 @@ async function main(args: string[]): Promise { const startedAt = performance.now() console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) - const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { - await attachFailureLog(completePlan, result) + const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => { printResult(completePlan, result) }) printSummary(completePlan, results, performance.now() - startedAt) @@ -240,14 +192,9 @@ export function isMainModule(entry: string | undefined = process.argv[1]): boole /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. - * @returns the validated run or cleanup request. + * @returns the validated run request. */ -export function parseCliRequest(args: readonly string[]): CliRequest { - if (args[0] === '--clean-logs') { - if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') - return { kind: 'clean-logs' } - } - +export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) let list = false let json = false @@ -946,7 +893,7 @@ export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv) * @param plan - complete or diagnostic plan to execute. * @param maxActive - maximum concurrent child count. * @param execute - child-process executor. - * @param observe - serialized result observer. + * @param observe - result observer invoked when each gate settles. * @returns results in canonical plan order. */ export async function executeGatePlan( @@ -965,358 +912,6 @@ export async function executeGatePlan( return runGates(plan.gates, maxActive, execute, observe) } -/** - * Format one private failure log without consulting or enumerating the inherited environment. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @returns attributable metadata and interleaved output. - */ -export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { - const gate = listedGate(result.gate) - const lines = [ - 'run-gates failure log', - `mode: ${plan.mode}`, - `gate: ${gate.id}`, - `status: ${result.status}`, - `blocking: ${gate.blocking}`, - `command: ${gate.command}`, - `replay: ${replayCommand(plan, gate.id)}`, - `scheduler environment: ${JSON.stringify(gate.env)}`, - `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, - `signal: ${result.signalCode ?? 'none'}`, - ] - if (result.error !== undefined) lines.push(`error: ${result.error}`) - lines.push('', 'interleaved output:') - for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) - return `${lines.join('\n')}\n` -} - -/** - * Explain why retained logs are unavailable on a platform. - * @param platform - host platform to evaluate. - * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. - */ -export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { - return platform === 'win32' - ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' - : undefined -} - -/** - * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. - * @param content - complete formatted failure log. - * @param maxBytes - maximum encoded byte length. - * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. - */ -export function limitGateFailureLog(content: string, maxBytes: number): string { - if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { - throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) - } - const originalBytes = Buffer.byteLength(content) - if (originalBytes <= maxBytes) return content - - const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` - const available = maxBytes - Buffer.byteLength(marker) - if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') - const prefixBytes = Math.ceil(available / 2) - const suffixBytes = available - prefixBytes - return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` -} - -function utf8Prefix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let end = maxBytes - while (end > 0) { - const byte = encoded[end] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - end -= 1 - } - return encoded.subarray(0, end).toString('utf8') -} - -function utf8Suffix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let start = encoded.length - maxBytes - while (start < encoded.length) { - const byte = encoded[start] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - start += 1 - } - return encoded.subarray(start).toString('utf8') -} - -/** - * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @param options - injectable storage, bound, clock, identity, and platform seams. - * @returns the absolute log path. - */ -export async function writeGateFailureLog( - plan: GatePlan, - result: GateResult, - options: { - directory?: string - repositoryRoot?: string - retention?: number - maxBytes?: number - unique?: string - now?: Date - platform?: NodeJS.Platform - beforeHelper?: () => Promise | void - } = {}, -): Promise { - const directory = options.directory ?? gateLogRoot - const repositoryRoot = options.repositoryRoot ?? root - const retention = options.retention ?? GATE_LOG_RETENTION - const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES - const unique = options.unique ?? randomUUID() - const now = options.now ?? new Date() - const unavailable = failureLogUnavailableReason(options.platform) - if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) - if (!Number.isSafeInteger(retention) || retention < 1) { - throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') - const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') - if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') - const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') - const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` - const helperResult = await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { - operation: 'write', - filename, - content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), - retention, - }, - options.beforeHelper, - ) - if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') - return resolve(directory, filename) -} - -async function inspectRepoLocalLogPath( - repositoryRoot: string, - target: string, -): Promise { - const relativeTarget = relative(repositoryRoot, target) - if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { - throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) - } - - const rootMetadata = await lstat(repositoryRoot, { bigint: true }) - if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { - throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) - } - const components: GateLogPathComponent[] = [] - let current = repositoryRoot - let missing = false - for (const component of relativeTarget.split(sep)) { - current = resolve(current, component) - if (missing) { - components.push({ name: component, identity: null }) - continue - } - let metadata - try { - metadata = await lstat(current, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) { - missing = true - components.push({ name: component, identity: null }) - continue - } - throw error - } - const shown = relative(repositoryRoot, current).split(sep).join('/') - if (metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) - } - if (!metadata.isDirectory()) { - throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) - } - components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) - } - return { - repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, - pathComponents: components, - } -} - -function hasErrorCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} - -async function readDirectoryIdentity(directory: string): Promise { - let metadata - try { - metadata = await lstat(directory, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return undefined - throw error - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) - } - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -async function runGateLogHelper( - directory: string, - repositoryRoot: string, - repositoryIdentity: GateLogDirectoryIdentity, - pathComponents: GateLogPathComponent[], - request: GateLogHelperRequest, - beforeHelper: (() => Promise | void) | undefined, -): Promise { - await beforeHelper?.() - const payload = JSON.stringify({ - ...request, - repository: { - root: repositoryRoot, - relative: relative(repositoryRoot, directory), - identity: repositoryIdentity, - components: pathComponents, - }, - }) - const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { - const child = spawn(process.execPath, [gateLogHelper], { - cwd: repositoryRoot, - env: {}, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - }) - child.stderr.on('data', (chunk: string) => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (status) => { - resolveResult({ status, stdout, stderr }) - }) - child.stdin.on('error', (error: NodeJS.ErrnoException) => { - if (error.code !== 'EPIPE') reject(error) - }) - child.stdin.end(payload) - }) - if (result.status !== 0) { - throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) - } - let parsed: unknown - try { - parsed = JSON.parse(result.stdout) - } catch { - throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) - } - if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await inspectRepoLocalLogPath(repositoryRoot, directory) - const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if ( - currentRepositoryIdentity === undefined - || currentRepositoryIdentity.dev !== repositoryIdentity.dev - || currentRepositoryIdentity.ino !== repositoryIdentity.ino - ) { - throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') - } - if (parsed.directory !== undefined) { - const currentDirectoryIdentity = await readDirectoryIdentity(directory) - if ( - currentDirectoryIdentity === undefined - || currentDirectoryIdentity.dev !== parsed.directory.dev - || currentDirectoryIdentity.ino !== parsed.directory.ino - ) { - throw new Error('run-gates: gate-log directory identity changed while the helper was running.') - } - } else if (request.operation === 'write') { - throw new Error('run-gates: gate-log helper did not return the created directory identity.') - } - return parsed -} - -function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { - if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false - if (!value.removed.every(entry => typeof entry === 'string')) return false - if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false - return !('directory' in value) - || value.directory === undefined - || isGateLogDirectoryIdentity(value.directory) -} - -function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { - return typeof value === 'object' - && value !== null - && 'dev' in value - && typeof value.dev === 'string' - && 'ino' in value - && typeof value.ino === 'string' -} - -/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ -export async function cleanGateFailureLogs( - directory = gateLogRoot, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'clean' }, - beforeHelper, - ) -} - -/** - * Remove older scheduler log files until at most `retain` remain. - * @param directory - private log directory. - * @param retain - number of newest log files to preserve. - * @param repositoryRoot - repository boundary containing the log directory. - * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. - */ -export async function pruneGateLogs( - directory: string, - retain: number, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - if (!Number.isSafeInteger(retain) || retain < 0) { - throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'prune', retain }, - beforeHelper, - ) -} - -async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { - if (result.status !== 'failed') return - try { - const path = await writeGateFailureLog(plan, result) - result.logPath = relative(root, path).split(sep).join('/') - } catch (error: unknown) { - result.logError = error instanceof Error ? error.message : String(error) - } -} - async function runGates( allGates: Gate[], maxActive: number, @@ -1355,7 +950,7 @@ async function runGates( } states.set(gate.id, 'skipped') results.set(gate.id, result) - await observe(result) + observe(result) } break } @@ -1365,7 +960,7 @@ async function runGates( running.splice(running.indexOf(settled.item), 1) states.set(settled.item.gate.id, settled.result.status) results.set(settled.item.gate.id, settled.result) - await observe(settled.result) + observe(settled.result) } } @@ -1477,11 +1072,6 @@ function printResult(plan: GatePlan, result: GateResult): void { console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) { - console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) - console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') - } - if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) } printOutput(result.output) if (result.error !== undefined) console.error(result.error) @@ -1504,7 +1094,6 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } From 3e6fbccffaa55120189b61b3b2ae800585d79104 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:27 +0800 Subject: [PATCH 05/43] refactor(dev-infra): trim gate plan surfaces --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 2 +- .../2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 37 +------ scripts/run-gates.ts | 96 ++++++------------- 5 files changed, 37 insertions(+), 104 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 98e78e752a..aa9b6d0cbc 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b -2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c +2026-07-27-replayable-gate-plans.md: 8a42ae3c89a3f75bb248f78583f6ef825af3241e +2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index a312481a4d..8a42ae3c89 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -14,7 +14,7 @@ Operators also need the scheduler-owned environment and dependency context for a [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn, so inspection never enumerates or bakes in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection therefore never enumerates or bakes in inherited values; values under secret-like names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index c213068f54..572ef1ce13 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -14,7 +14,7 @@ Status: implemented [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查结果因此不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 1a86ccd064..d112bccd89 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,10 +1,4 @@ -import { - mkdtempSync, - rmSync, - symlinkSync, -} from 'node:fs' import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -15,7 +9,6 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, - isMainModule, listedGatePlan, parseCliRequest, replayCommand, @@ -28,13 +21,9 @@ import { type GateResult, } from './run-gates.ts' -const temporaryRoots: string[] = [] const repositoryRoot = join(import.meta.dirname, '..') -afterEach(() => { - vi.unstubAllEnvs() - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) +afterEach(() => vi.unstubAllEnvs()) function gate(id: string, options: Partial = {}): Gate { return { @@ -64,12 +53,6 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-run-gates-'): string { - const root = mkdtempSync(join(tmpdir(), prefix)) - temporaryRoots.push(root) - return root -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -172,10 +155,10 @@ describe('gate plan validation', () => { describe('gate plan inspection and replay', () => { it('parses package-script separators, list JSON, and focused runs', () => { expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ - kind: 'run', mode: 'check-all', list: true, json: true, + mode: 'check-all', list: true, json: true, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ - kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', + mode: 'check-all', list: false, json: false, only: 'snapshot', }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') @@ -252,15 +235,6 @@ describe('gate plan inspection and replay', () => { }) }) - it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { - const temporary = temporaryRoot('dsh-run-gates-entry-') - const entry = join(temporary, 'run-gates.ts') - symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) - - expect(isMainModule(entry)).toBe(true) - expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) - }) - it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -269,14 +243,13 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append, set, and unset operations only when spawning', () => { + it('resolves append and set operations only when spawning', () => { const resolved = resolveGateEnvironment(gate('subject', { env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, - REMOVE_ME: { operation: 'unset' }, }, - }), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' }) + }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) expect(resolved).toEqual({ NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', MODE: 'lib', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f6d18c2e0..9a8367ec79 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -6,38 +6,38 @@ * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { realpathSync } from 'node:fs' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' -const MODES = [ - 'ci-primary', - 'ci-static', - 'ci-lint', - 'ci-coverage', - 'ci-snapshot', - 'ci-artifacts', - 'ci-consumers', - 'ci-windows-blocking', - 'ci-windows-complete', - 'ci-windows-observational', - 'node-compat', - 'check-all', - 'doc-sync', -] as const +const MODE_SCRIPTS = { + 'ci-primary': 'check:ci', + 'ci-static': 'check:ci:static', + 'ci-lint': 'check:ci:lint', + 'ci-coverage': 'check:ci:coverage', + 'ci-snapshot': 'check:ci:snapshot', + 'ci-artifacts': 'check:ci:artifacts', + 'ci-consumers': 'check:ci:consumers', + 'ci-windows-blocking': 'check:ci:windows-blocking', + 'ci-windows-complete': 'check:ci:windows-complete', + 'ci-windows-observational': 'check:ci:windows-observational', + 'node-compat': 'check:node-compat', + 'check-all': 'check:all', + 'doc-sync': 'doc-sync', +} as const /** A named aggregate exposed by the gate runner. */ -export type Mode = typeof MODES[number] +export type Mode = keyof typeof MODE_SCRIPTS + +const MODES = Object.keys(MODE_SCRIPTS) as Mode[] type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = | { operation: 'set'; value: string } - | { operation: 'unset' } - | { operation: 'append'; value: string; separator?: string } + | { operation: 'append'; value: string } /** A command and its dependency metadata inside one gate plan. */ export interface Gate { @@ -91,18 +91,13 @@ export interface ResolvedConcurrency { } interface RunRequest { - kind: 'run' mode: Mode list: boolean json: boolean only?: string } -interface ListedEnvironmentOverride { - operation: GateEnvironmentOverride['operation'] - value?: string - separator?: string -} +type ListedEnvironmentOverride = GateEnvironmentOverride interface ListedGate { id: string @@ -126,24 +121,11 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const MODE_SCRIPTS: Record = { - 'ci-primary': 'check:ci', - 'ci-static': 'check:ci:static', - 'ci-lint': 'check:ci:lint', - 'ci-coverage': 'check:ci:coverage', - 'ci-snapshot': 'check:ci:snapshot', - 'ci-artifacts': 'check:ci:artifacts', - 'ci-consumers': 'check:ci:consumers', - 'ci-windows-blocking': 'check:ci:windows-blocking', - 'ci-windows-complete': 'check:ci:windows-complete', - 'ci-windows-observational': 'check:ci:windows-observational', - 'node-compat': 'check:node-compat', - 'check-all': 'check:all', - 'doc-sync': 'doc-sync', +const entry = process.argv[1] +if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { + process.exitCode = await main(process.argv.slice(2)) } -if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) - async function main(args: string[]): Promise { const request = parseCliRequest(args) const completePlan = gatePlanForMode(request.mode) @@ -174,21 +156,6 @@ async function main(args: string[]): Promise { : 0 } -/** - * Decide whether this module is the process entry, including through a symlinked path. - * @param entry - process entry path to compare with this module. - * @returns Whether the entry resolves to this module. - */ -export function isMainModule(entry: string | undefined = process.argv[1]): boolean { - if (entry === undefined) return false - if (import.meta.url === pathToFileURL(resolve(entry)).href) return true - try { - return import.meta.url === pathToFileURL(realpathSync(entry)).href - } catch { - return false - } -} - /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. @@ -220,7 +187,7 @@ export function parseCliRequest(args: readonly string[]): RunRequest { } if (json && !list) throw new Error('run-gates: --json requires --list.') if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') - return { kind: 'run', mode, list, json, ...only === undefined ? {} : { only } } + return { mode, list, json, ...only === undefined ? {} : { only } } } function parseMode(raw: string | undefined): Mode { @@ -798,13 +765,8 @@ function listedEnvironment( ): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { - const value = 'value' in override - ? { value: sensitiveEnvironmentName(name) ? '' : override.value } - : {} - const separator = override.operation === 'append' && override.separator !== undefined - ? { separator: override.separator } - : {} - return [name, { operation: override.operation, ...value, ...separator }] + const value = sensitiveEnvironmentName(name) ? '' : override.value + return [name, { operation: override.operation, value }] })) } @@ -874,15 +836,13 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'unset') { - Reflect.deleteProperty(resolved, name) - } else if (override.operation === 'set') { + if (override.operation === 'set') { resolved[name] = override.value } else { const current = resolved[name] resolved[name] = current === undefined || current === '' ? override.value - : `${current}${override.separator ?? ' '}${override.value}` + : `${current} ${override.value}` } } return resolved From 55d6ab52207e3f8092047f46608c95f487636001 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:16 +0800 Subject: [PATCH 06/43] fix(dev-infra): harden gate runner execution --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 6 +- .../2026-07-27-replayable-gate-plans.zh.md | 8 +-- scripts/run-gates.spec.ts | 70 ++++++++++++++++--- scripts/run-gates.ts | 47 +++++++++---- 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index aa9b6d0cbc..f61cb168d2 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 8a42ae3c89a3f75bb248f78583f6ef825af3241e -2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee +2026-07-27-replayable-gate-plans.md: fc4f883d74d765d173a6d1419c2e75f44bd1966f +2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 8a42ae3c89..fc4f883d74 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -8,13 +8,13 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing later commands to consume restored artifacts before publint and built-package invariant checks established their public and runtime-closure contracts. ## Decision [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection therefore never enumerates or bakes in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Gate-level spawn overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection serializes those operations without resolving them against inherited values; values under secret-like declared names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. @@ -24,7 +24,7 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, direct and symlinked entries emit one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 572ef1ce13..d6c6eb9649 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -8,15 +8,15 @@ Status: implemented 仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许后续命令在 publint 和已构建包(package)不变式检查确立恢复后产物的公开契约与运行时闭包契约之前,就消费这些产物。 ## 决策 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查结果因此不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。门禁级 spawn 覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查会序列化这些操作,而不会结合继承值进行解析;声明的名称若疑似机密,其值会被脱敏。 -`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 @@ -24,7 +24,7 @@ Status: implemented ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、直接入口和符号链接入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d112bccd89..142a221070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -12,7 +14,6 @@ import { listedGatePlan, parseCliRequest, replayCommand, - resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, @@ -89,6 +90,7 @@ describe('gate plan validation', () => { it.each([ ['empty', plan([]), /plan has no gates/], ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unsafe ids', plan([gate('unsafe id')]), /gate id "unsafe id" must contain only lowercase letters/], ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], ])('rejects %s before starting a child', async (_label, invalid, message) => { @@ -138,6 +140,27 @@ describe('gate plan validation', () => { expect(observed).toEqual(['first:failed', 'second:passed']) }) + it('propagates dependency skips in causal order', async () => { + const leaf = gate('leaf', { needs: ['middle'] }) + const middle = gate('middle', { needs: ['root'] }) + const rootGate = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) + const observed: string[] = [] + + const results = await executeGatePlan( + plan([leaf, middle, rootGate]), + 1, + execute, + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + expect(execute).toHaveBeenCalledOnce() + expect(execute).toHaveBeenCalledWith(rootGate) + expect(observed).toEqual(['root:failed', 'middle:skipped', 'leaf:skipped']) + expect(results.find(result => result.gate === middle)?.error).toBe('dependency failed or skipped: root') + expect(results.find(result => result.gate === leaf)?.error).toBe('dependency failed or skipped: middle') + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -235,6 +258,32 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('executes when the script entry path is a symlink', () => { + const temporary = mkdtempSync(join(tmpdir(), 'dsh-run-gates-entry-')) + const entry = join(temporary, 'run-gates.ts') + try { + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + entry, + 'ci-consumers', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: process.env.npm_execpath ?? '/private/pnpm.cjs' }, + timeout: 10_000, + }) + + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ mode: 'ci-consumers', maxWorkers: 7 }) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } + }) + it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -243,17 +292,22 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append and set operations only when spawning', () => { - const resolved = resolveGateEnvironment(gate('subject', { + it('applies append and set operations through the child spawn environment', async () => { + vi.stubEnv('NODE_OPTIONS', '--trace-warnings') + vi.stubEnv('INHERITED', 'kept') + const result = await runGate(gate('subject', { + args: ['-e', 'process.stdout.write(JSON.stringify({ nodeOptions: process.env.NODE_OPTIONS, mode: process.env.MODE, inherited: process.env.INHERITED }))'], env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, }, - }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) - expect(resolved).toEqual({ - NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', - MODE: 'lib', - INHERITED: 'kept', + })) + + expect(result.status).toBe('passed') + expect(JSON.parse(result.stdout)).toEqual({ + nodeOptions: '--trace-warnings --max-old-space-size=8192', + mode: 'lib', + inherited: 'kept', }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9a8367ec79..2b376d6882 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,7 +9,6 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -import { pathToFileURL } from 'node:url' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -121,8 +120,7 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const entry = process.argv[1] -if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { +if (import.meta.main) { process.exitCode = await main(process.argv.slice(2)) } @@ -833,21 +831,31 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { * @param inherited - environment inherited by the runner. * @returns the child environment without mutating the inherited object. */ -export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'set') { - resolved[name] = override.value - } else { - const current = resolved[name] - resolved[name] = current === undefined || current === '' - ? override.value - : `${current} ${override.value}` + switch (override.operation) { + case 'set': + resolved[name] = override.value + break + case 'append': { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current} ${override.value}` + break + } + default: + assertNever(override) } } return resolved } +function assertNever(value: never): never { + throw new Error(`run-gates: unreachable value ${JSON.stringify(value)}.`) +} + /** * Run a validated plan; invalid input rejects before the injected executor can start a child. * @param plan - complete or diagnostic plan to execute. @@ -894,9 +902,17 @@ async function runGates( } if (running.length === 0) { - const pending = allGates.filter(gate => states.get(gate.id) === 'pending') - for (const gate of pending) { - const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed') + let pending = allGates.filter(gate => states.get(gate.id) === 'pending') + while (pending.length > 0) { + const gate = pending.find(item => (item.needs ?? []).some((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + })) + if (gate === undefined) throw new Error('run-gates: validated plan stalled without a failed dependency.') + const failedDeps = (gate.needs ?? []).filter((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + }) const result: GateResult = { gate, status: 'skipped', @@ -911,6 +927,7 @@ async function runGates( states.set(gate.id, 'skipped') results.set(gate.id, result) observe(result) + pending = pending.filter(item => item !== gate) } break } @@ -1031,10 +1048,10 @@ function printResult(plan: GatePlan, result: GateResult): void { const environment = listedGate(result.gate).env console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`outcome: ${formatGateResultReason(result)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) } printOutput(result.output) - if (result.error !== undefined) console.error(result.error) } function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { From 6b19be28430902558bcfce5f46adfaafbba6e31f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:11 +0800 Subject: [PATCH 07/43] refactor(dev-infra): use Node gate argv parsing --- scripts/run-gates.ts | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2b376d6882..fbcc3232ef 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -96,14 +97,12 @@ interface RunRequest { only?: string } -type ListedEnvironmentOverride = GateEnvironmentOverride - interface ListedGate { id: string label: string command: string needs: string[] - env: Record + env: Record blocking: boolean } @@ -161,28 +160,17 @@ async function main(args: string[]): Promise { */ export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) - let list = false - let json = false - let only: string | undefined - const firstOption = args[1] === '--' ? 2 : 1 - for (let index = firstOption; index < args.length; index += 1) { - const arg = args[index] - if (arg === '--list') { - if (list) throw new Error('run-gates: --list may be specified only once.') - list = true - } else if (arg === '--json') { - if (json) throw new Error('run-gates: --json may be specified only once.') - json = true - } else if (arg === '--only') { - if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') - const id = args[index + 1] - if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') - only = id - index += 1 - } else { - throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) - } - } + const optionArgs = args[1] === '--' ? args.slice(2) : args.slice(1) + const { values: { list, json, only } } = parseArgs({ + args: optionArgs, + options: { + list: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + only: { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }) if (json && !list) throw new Error('run-gates: --json requires --list.') if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') return { mode, list, json, ...only === undefined ? {} : { only } } From 4338b2725cc2ec7b18c10e8ab29af7a361ea10d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:52 +0800 Subject: [PATCH 08/43] fix(dev-infra): distinguish gate results from states --- scripts/run-gates.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fbcc3232ef..8feaf576e5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -32,7 +32,8 @@ export type Mode = keyof typeof MODE_SCRIPTS const MODES = Object.keys(MODE_SCRIPTS) as Mode[] -type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' +type GateResultStatus = 'passed' | 'failed' | 'skipped' +type GateState = 'pending' | 'running' | GateResultStatus /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = @@ -64,7 +65,7 @@ export interface GatePlan { /** The observed outcome of one gate process. */ export interface GateResult { gate: Gate - status: GateStatus + status: GateResultStatus durationMs: number stdout: string stderr: string @@ -874,7 +875,7 @@ async function runGates( execute: GateExecutor, observe: ResultObserver, ): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) + const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -936,7 +937,7 @@ async function runGates( }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function dependenciesPassed(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') } @@ -983,7 +984,7 @@ export async function runGate(gate: Gate): Promise { }) const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' + let status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { From 32645aaa294e7a3e8386ca4f3c6ec6cd612d7cea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:09 +0800 Subject: [PATCH 09/43] fix(dev-infra): repair gate listing type --- scripts/run-gates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8feaf576e5..bc0b3d9476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -749,7 +749,7 @@ function listedGate(gate: Gate): ListedGate { function listedEnvironment( environment: Readonly> | undefined, -): Record { +): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { const value = sensitiveEnvironmentName(name) ? '' : override.value From e71aa49a26925c6df2a34b6e0a203d6c9d438c60 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:19:46 +0800 Subject: [PATCH 10/43] test(dev-infra): exercise replay diagnostics --- scripts/run-gates.spec.ts | 27 +++++++++++++++++++-------- scripts/run-gates.ts | 4 ++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 142a221070..898a637e6a 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -8,12 +8,10 @@ import { formatGatePlanJson, formatGatePlanList, formatGateResultReason, - formatOnlyNotice, gateDependencyClosure, gatePlanForMode, listedGatePlan, parseCliRequest, - replayCommand, resolvePlanConcurrency, runGate, validateGatePlan, @@ -284,12 +282,25 @@ describe('gate plan inspection and replay', () => { } }) - it('renders a cross-platform scheduler replay and labels focused evidence', () => { - const subject = plan([gate('snapshot')]) - expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') - expect(formatOnlyNotice(subject, 'snapshot')).toBe( - 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', - ) + it('prints focused-run context and replay through a real failure block', () => { + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + join(repositoryRoot, 'scripts/run-gates.ts'), + 'ci-lint', + '--only', + 'duplication', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: join(repositoryRoot, 'scripts/missing-pnpm-entrypoint.cjs') }, + timeout: 10_000, + }) + + expect(result.status).toBe(1) + expect(result.stdout).toContain('partial diagnostic evidence; the complete owning mode is pnpm run check:ci:lint') + expect(result.stderr).toContain('outcome: exit 1') + expect(result.stderr).toContain('replay: pnpm run check:ci:lint -- --only duplication') }) it('applies append and set operations through the child spawn environment', async () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index bc0b3d9476..b5137b836b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -796,7 +796,7 @@ export function formatGatePlanJson(plan: GatePlan): string { * @param gateId - gate to replay with its dependencies. * @returns a shell-independent pnpm command. */ -export function replayCommand(plan: GatePlan, gateId: string): string { +function replayCommand(plan: GatePlan, gateId: string): string { validateGatePlan(plan) if (!plan.gates.some(gate => gate.id === gateId)) { throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) @@ -810,7 +810,7 @@ export function replayCommand(plan: GatePlan, gateId: string): string { * @param gateId - selected diagnostic gate. * @returns the partial-evidence notice. */ -export function formatOnlyNotice(plan: GatePlan, gateId: string): string { +function formatOnlyNotice(plan: GatePlan, gateId: string): string { return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` } From 5b79a43d4cf4d9b219a2d35b8d0017ba594f0af1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:36:05 +0800 Subject: [PATCH 11/43] fix(dev-infra): serialize lint after invariant staging --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../implemented/process/2026-07-27-replayable-gate-plans.md | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 1 + scripts/run-gates.ts | 5 ++++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index f61cb168d2..dc28813ed1 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: fc4f883d74d765d173a6d1419c2e75f44bd1966f -2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef +2026-07-27-replayable-gate-plans.md: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 +2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index fc4f883d74..2b912024ce 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -20,7 +20,7 @@ Every mode supports deterministic `--list` output and a versioned stable `--list The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`. Source compatibility smokes may overlap the validation stages; lint and duplication wait for built-package invariant validation so ESLint cannot traverse its transient staged package views, then may overlap downstream consumers. ## Verification @@ -42,6 +42,6 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. -Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. +Later artifact consumers and lint start only after publint and built-package invariant validation, so ESLint cannot traverse the verifier's transient staged views and those downstream gates may overlap one another. Source compatibility smokes still overlap both validation stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index d6c6eb9649..0d356825c9 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -20,7 +20,7 @@ Status: implemented 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段。源码兼容性冒烟测试可以与验证阶段并行;lint 和 duplication 会等待已构建包不变式验证,以免 ESLint 遍历验证过程中临时暂存的包视图,之后可以与下游消费方并行。 ## 验证 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 898a637e6a..344f678374 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -364,6 +364,7 @@ describe('Node 24 consumer plan', () => { ]) expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.gates.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b5137b836b..a517a19127 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -414,7 +414,10 @@ function ciConsumerGates(): Gate[] { const publicArtifacts = ['publint'] const restoredBuild = ['built-package-invariants'] return [ - pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('lint-and-duplication', 'check:ci:lint', { + label: 'lint and duplication', + needs: restoredBuild, + }), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), snapshotGate(restoredBuild), pnpmScript('publint', 'publint'), From 02f9b55699423f11a5730f5a3ffa39f7f5f6f62e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:42 +0800 Subject: [PATCH 12/43] docs(dev-infra): synchronize gate plan consequences --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index dc28813ed1..771b3270bd 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 -2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 +2026-07-27-replayable-gate-plans.md: 2b912024ce337063cf677532e85d64f4fd8ab4a7 +2026-07-27-replayable-gate-plans.zh.md: 26bf4632ea84292ed48ce0cf8405d06e5bfa4aa9 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 0d356825c9..26bf4632ea 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -42,6 +42,6 @@ Status: implemented 调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 -后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 +后续产物消费方和 lint 只在 publint 和已构建包不变式验证通过后才启动,因此 ESLint 不会遍历验证器临时暂存的视图,而这些下游门禁可以彼此并行。源码兼容性冒烟测试仍可与这两个验证阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 From 1f79b780454e06b30b04cfac31b74c9887654f39 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 04:25:18 +0800 Subject: [PATCH 13/43] fix(web-ui): menu placement and scrolling, tool-row and settings polish Menus: keep 12px viewport clearance with internal scroll, pin workspace create actions in a footer, and pre-render portal lists hidden so the first painted frame is already at its final position (no open jump). Tool rows: 14px icons, secondary titles, no hover fill, and a hover chevron preview on in-place expandable rows. Settings: 800x600 layer-2 panel over a blurred mask, hover states, and wrapping selector cubes; ModelSelect surface tokens now match the Menu primitive. --- .../locale/src/client/LanguageRow.module.css | 4 + .../src/client/chat/AssistantMarkdown.tsx | 2 +- .../src/client/chat/GenericToolCard.tsx | 18 +- .../src/client/chat/ToolRow.module.css | 25 ++- .../src/client/chat/ToolRow.tsx | 21 +- .../src/client/contract/slots.ts | 2 + .../skeleton/ConversationRoot.module.css | 13 +- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/HeroShell.module.css | 11 +- .../client/toolviews/bash-sample.module.css | 6 +- .../src/client/toolviews/bash-sample.tsx | 2 +- .../src/client/toolviews/todo-row.module.css | 6 +- .../src/client/ModelSelect.module.css | 19 +- .../ui-primitives/src/Button.module.css | 10 +- .../client/ui-primitives/src/Menu.module.css | 33 +++- packages/client/ui-primitives/src/Menu.tsx | 182 +++++++++++------- .../client/ui-primitives/src/Modal.module.css | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 2 +- .../client/ui-primitives/tests/atoms.spec.tsx | 43 ++++- .../ui-primitives/tests/tooltip.spec.tsx | 9 +- .../src/client/GeneralSection.module.css | 20 +- .../src/client/SettingsRoot.module.css | 14 +- .../src/client/AppearanceRow.module.css | 10 +- .../ui-theme/src/styles/design-platform.css | 9 +- .../src/client/WorkspaceBrowser.module.css | 1 + .../src/client/WorkspaceBrowser.tsx | 9 +- .../src/client/WorkspacePicker.tsx | 42 +++- .../tests/workspace-browser.spec.tsx | 20 +- .../tests/workspace-picker.spec.tsx | 2 + 29 files changed, 367 insertions(+), 175 deletions(-) diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index f17a67d279..57d975d9b7 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -42,6 +42,10 @@ cursor: pointer; } +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .chevron { flex: none; } diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 0e91afcc07..fc487843df 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( } + icon={} title="Think" summary={firstLine(text)} body={text} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5cb2126f34..24b3b36a22 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -13,16 +13,16 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t import { ToolRow } from './ToolRow.tsx' import { IconSparkle16 } from './IconSparkle16.tsx' -/** Variant leading icons (figma table). */ +/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { - think: , - search: , - read: , - bash: , - write: , - edit: , - code: , - others: , + think: , + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , } export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 5d44a9260e..6f99bb2696 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -13,15 +13,12 @@ min-width: 0; } +/* Clickable rows keep only the cursor affordance — no hover fill. */ .row[data-clickable] { cursor: pointer; border-radius: 6px; } -.row[data-clickable]:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .leading { flex: none; width: 16px; @@ -65,11 +62,29 @@ button.leading { color: var(--dsw-alias-label-secondary); } +/* Hover preview on expandable rows: the idle tool icon yields to a down + chevron before the row is opened. */ +.iconIdle { + display: inline-flex; +} + +.chevronHover { + display: none; +} + +.row:hover .iconIdle { + display: none; +} + +.row:hover .chevronHover { + display: inline-flex; +} + .title { flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a81c084b8e..a406d05cc3 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -1,8 +1,10 @@ // ToolRow: the single-line tool summary row (figma component set 122:9479) — -// 16px leading slot (state dot / tool icon, chevron when expanded) + title + +// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title + // separator dot + FILL-truncated summary. Expanded body is indented gray text; // no inline output (full results live in the details panel). Expand state is // component-local view state; row click hands the selection off to the owner. +// TODO(ux): converge every chat-tab tool row on in-place expansion for its +// expandable content, retiring the details-panel handoff where feasible. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' @@ -66,6 +68,19 @@ export function ToolRow({ event.preventDefault() toggleExpand() } + // Expandable rows preview the toggle on hover: the tool icon yields to a + // down chevron (CSS swap on .row:hover); state dots still take precedence. + const collapsedIcon = expandable + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : leadingFor(state, collapsedIcon) return (
- {open ? : leadingFor(state, icon)} + {leading} ) : ( - {open ? : leadingFor(state, icon)} + {leading} )} {title} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..c7c53aaafe 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -300,6 +300,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject + /** Currently active workspace (renders a trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index be68ea1394..0bfd772f60 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -87,7 +87,7 @@ padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ .tab { position: relative; padding: 0 0 11px; @@ -95,7 +95,7 @@ background: transparent; font-size: 13px; line-height: 16px; - font-weight: 510; + font-weight: 500; color: var(--dsw-alias-label-tertiary); cursor: pointer; } @@ -140,10 +140,19 @@ block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { align-self: center; + /* figma 75:8208: 12 between hero chrome / workspace row / card. */ + gap: 12px; width: min(776px, calc(100% - 48px)); z-index: 1; } +.heroWorkspaceRow { + display: flex; + align-items: center; + min-width: 0; + padding-left: 8px; +} + .root[data-phase='hero'] { justify-content: center; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index d6e7492836..2c97e5ce0e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -48,7 +48,7 @@ export function ConversationRoot({ session === undefined || inputState === undefined ? undefined : { session, input: inputState } const heroWorkspaceRow = ( - <> +
{ setPickerOpen(false) setPendingWorkspaceId(workspaceId) @@ -72,7 +73,7 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} - +
) const inputBar = sessionId === undefined diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3bba50c67c..6bc6af5fea 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -8,8 +8,7 @@ justify-content: center; height: 100%; min-width: 0; - padding: 24px; - margin-bottom: -70px; + padding: 0 24px; } /* Cap matches InputBar card width (800). Glow may paint past the sides. */ @@ -24,17 +23,15 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block - keeps 36px below the headline before the flex gap. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; - padding-bottom: 36px; font-size: 26px; line-height: 32px; - font-weight: 600; + font-weight: 500; color: var(--dsw-alias-label-primary); } @@ -88,7 +85,7 @@ display: inline-flex; align-items: center; gap: 4px; - max-width: fit-content; + max-width: min(100%, 360px); min-height: 28px; padding: 0 8px; border: none; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..84224c9979 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -9,10 +9,6 @@ border-radius: 6px; } -.root:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .leading { flex: none; width: 16px; @@ -39,7 +35,7 @@ flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..dc4dd6b367 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -15,7 +15,7 @@ function leadingFor(state: ToolRowState) { case 'running': return case 'error': return case 'stopped': return - default: return + default: return } } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..829c2886e8 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -11,10 +11,6 @@ font-size: 13px; } -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .badge { flex: none; color: var(--dsw-alias-state-business-primary); @@ -22,7 +18,7 @@ .title { flex: none; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index cdf7f4be2e..d17a777edd 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -72,9 +72,11 @@ max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + /* Surface tokens match the Menu primitive card (ui-primitives + * Menu.module.css) so every dropdown reads as the same material. */ + border: 1px solid var(--dsw-alias-border-inverted); border-radius: 12px; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); box-shadow: var(--dsw-shadow-lv3); color: var(--dsw-alias-label-primary); } @@ -132,7 +134,7 @@ top: 0; z-index: 1; padding: 5px 8px 3px; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; @@ -156,11 +158,16 @@ } .option:hover:not(:disabled), -.option:focus-visible, -.selected { +.option:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +/* Selection marker is the trailing check, not a fill — matches the Menu + * primitive's selected treatment. */ +.selected { + background: transparent; +} + .option:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; @@ -201,7 +208,7 @@ display: grid; place-items: center; flex: 0 0 18px; - color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-label-primary); } /* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 3f415b5d15..b9384dd522 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -18,7 +18,7 @@ .button:disabled { cursor: not-allowed; - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; } .md { @@ -44,10 +44,6 @@ background: var(--dsw-alias-button-primary-hover); } -.primary:disabled { - background: var(--dsw-alias-button-primary-dimmed); -} - .ghost:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } @@ -66,10 +62,6 @@ background: var(--dsw-alias-interactive-bg-hover); } -.outline:disabled { - border-color: var(--dsw-alias-border-l1); -} - .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6825731275..8e16b252d3 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -26,6 +26,7 @@ left: 0; z-index: 100; min-width: 218px; + max-width: 360px; } /* Portal mode: fixed in the viewport, coordinates supplied inline from the @@ -50,6 +51,36 @@ right: 0; } +/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges + * (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside + * .viewport, so a pinned .footer stays visible. Menus with submenu rows skip + * this class — the overflow clip would crop the side card, so they rely on + * staying short. */ +.scrollable { + max-height: calc(100vh - 24px); +} + +.viewport { + display: flex; + flex-direction: column; + min-height: 0; +} + +.scrollable .viewport { + overflow-y: auto; +} + +/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on + * the menu surface) mirrors the .separator spacing. */ +.footer { + flex: none; + display: flex; + flex-direction: column; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + .itemWrap { position: relative; } @@ -78,7 +109,7 @@ } .item:disabled { - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; cursor: not-allowed; } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 6ed7f6c0c2..1a6409f42b 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -5,6 +5,8 @@ // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. // Entries also cover non-interactive `label` headings and `danger` rows. +// Lists keep 12px clearance to the viewport's top/bottom edges and scroll +// internally past that; submenu-bearing menus are exempt (see .scrollable). import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { return 'type' in entry && entry.type === 'label' } +/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * the trigger (render-prop anchors, effect-positioned proxies — measuring the * wrapper there races the host's layout effects). Called on open and on every * scroll/resize; return null to skip placement for that frame. + * @param props.footer - rows pinned below the scrolling items area, separated + * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] - selectedId?: string + footer?: readonly MenuEntry[] + selectedId?: string | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' - side?: 'bottom' | 'top' + side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null @@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align r = rootRef.current?.getBoundingClientRect() ?? null } if (r === null) return - setFixedPos({ - ...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }), - ...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }), - }) + const MARGIN = 12 + const vw = window.innerWidth + const vh = window.innerHeight + const listEl = listRef.current + const lw = listEl?.offsetWidth ?? 0 + const lh = listEl?.offsetHeight ?? 0 + + let x: number + let y: number + if (side === 'right') { + x = r.right + 4 + y = r.top + } else if (align === 'start') { + x = r.left + y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + } else { + x = r.right - lw + y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + } + + if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN) + + setFixedPos({ left: x, top: y }) } + // First run measures the hidden pre-render (same commit as `open`), so + // end/top alignment and clamping use real dimensions before anything + // paints — no visible jump from a zero-size first guess. place() window.addEventListener('scroll', place, true) window.addEventListener('resize', place) @@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) - const list = open && (!portal || fixedPos !== null) && ( + // The submenu card is absolutely positioned outside the list box; the + // scroll clip would crop it, so only submenu-free menus get the height cap. + const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) + + const renderEntry = (entry: MenuEntry) => { + if (isSeparator(entry)) { + return
+ } + if (isLabel(entry)) { + return
{entry.text}
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + } + + // Portal lists render hidden until placed: the placement effect measures + // this pre-render in the same commit, so the first painted frame is + // already at the final position (with getAnchorRect returning null the + // list simply stays hidden). + const list = open && (
{ onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without @@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} > - {items.map((entry) => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} -
- ) - })} +
+ {items.map(renderEntry)} +
+ {footer !== undefined && footer.length > 0 && ( +
+ {footer.map(renderEntry)} +
+ )}
) diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 02c075803e..0c6d68e5fa 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -54,7 +54,7 @@ margin: 0; font-size: 16px; line-height: 24px; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 2c48854055..30e0c5fe0e 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { {cloneElement(children, { ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 9e298d057a..7724afa493 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -271,14 +271,47 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('portal mode positions from the opposite edges for align=end / side=top', () => { + it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => { render( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.right).not.toBe('') - expect(menu.style.bottom).not.toBe('') - expect(menu.style.left).toBe('') - expect(menu.style.top).toBe('') + expect(menu.style.left).not.toBe('') + expect(menu.style.top).not.toBe('') + expect(menu.style.right).toBe('') + expect(menu.style.bottom).toBe('') + }) + + it('renders footer rows in a pinned section below the items; they still select', () => { + const onSelect = vi.fn() + render( + trigger} + items={items} + footer={[{ id: 'new', label: 'Create new' }]} + onSelect={onSelect} + onClose={() => {}} + />) + const footerItem = screen.getByRole('menuitem', { name: 'Create new' }) + expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull() + expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull() + fireEvent.click(footerItem) + expect(onSelect).toHaveBeenCalledWith('new') + }) + + it('caps the list height for internal scrolling unless a submenu row is present', () => { + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + expect(screen.getByRole('menu').className).toMatch(/scrollable/) + rerender( + trigger} + items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) }) diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 6741921c66..591a5eb67a 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,23 +81,20 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) - it('keeps the bubble while either hover or focus is still active', () => { + it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => { render( , ) const anchor = screen.getByText('anchor') - // Focused AND hovered: leaving with the mouse must not drop the bubble. + // Focused AND hovered: leaving with the mouse drops the bubble at once. fireEvent.focus(anchor) fireEvent.mouseEnter(anchor) fireEvent.mouseLeave(anchor) - expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.blur(anchor) expect(screen.queryByRole('tooltip')).toBeNull() - // Symmetric: blurring while still hovered keeps it, mouseleave ends it. + // Re-entering shows it again; blurring while still hovered keeps it. fireEvent.mouseEnter(anchor) - fireEvent.focus(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.mouseLeave(anchor) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index 5e053a49c5..aced3b2962 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -72,6 +72,10 @@ cursor: pointer; } +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .selector:disabled { cursor: default; } @@ -80,18 +84,21 @@ flex: none; } -/* Tool Call mode cubes share an 8px gap. */ +/* Tool Call mode cubes share an 8px gap and wrap to one per row when the + panel is too narrow. */ .cubeRow { display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } -/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = - * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the + * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, + * vertical = inner pad 8). */ .modeCube { box-sizing: border-box; - width: 418px; + flex: 1 1 276px; display: flex; flex-direction: column; justify-content: center; @@ -101,6 +108,11 @@ border-radius: 16px; background: transparent; text-align: left; + cursor: pointer; +} + +.modeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); } /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e2b2c878df..5f7ca9ec23 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -44,7 +44,8 @@ white-space: nowrap; } -/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the + Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */ .overlay { position: fixed; inset: 0; @@ -58,21 +59,22 @@ position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); } -/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow - (figma effects match --dsw-shadow-lv3 exactly). */ +/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ .panel { position: relative; z-index: 1; display: flex; - width: 1080px; - height: 700px; + width: 800px; + height: 600px; max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-shadow-lv3); } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css index 9619caa823..6c5354b0ac 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.module.css +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -20,13 +20,15 @@ display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } /* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ + * icon-over-label column, gap 4); flexed down from the figma width so all + * three sit on one row in the 800 panel, wrapping when narrower. */ .themeCube { box-sizing: border-box; - width: 276px; + flex: 1 1 180px; display: flex; flex-direction: column; align-items: center; @@ -43,6 +45,10 @@ cursor: pointer; } +.themeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index e00ec415b7..3e8710822e 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -1,3 +1,6 @@ +/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as + font-weight: 500 in this UI — non-variable webfonts snap intermediate + weights unpredictably across platforms. */ body { --dsw-static-amber-100: rgb(254, 245, 231); --dsw-static-amber-400: rgb(247, 173, 49); @@ -20,7 +23,7 @@ body { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -95,7 +98,7 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -302,7 +305,7 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); - --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index c03d511c92..7dcb4f9b9d 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -129,6 +129,7 @@ reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ .rail .sectionHeader { + gap: 0; padding-left: 0; margin-bottom: 12px; } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..dfca686a6e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -265,6 +265,7 @@ export function WorkspaceBrowser({ // states; the menu anchors on this button). const [wsPickerOpen, setWsPickerOpen] = useState(false) const wsPlusRef = useRef(null) + const composingRef = useRef(false) // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. @@ -358,7 +359,6 @@ export function WorkspaceBrowser({ className={css.iconButton} aria-label="Create workspace" onClick={() => { - if (!wide) expandSidebar() setWsPickerOpen(v => !v) }} > @@ -372,6 +372,8 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + createOnly + side="right" onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) @@ -459,9 +461,12 @@ export function WorkspaceBrowser({ aria-label="Workspace name" autoFocus disabled={renaming} + onFocus={(e) => { e.target.select() }} onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !composingRef.current) { e.preventDefault() confirmRename() } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..f33e7dc526 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,7 +5,7 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps { onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ onClose: () => void + /** Only show create actions (open folder / create new), hide existing workspaces. */ + createOnly?: boolean + /** Menu opening direction relative to the anchor. */ + side?: 'bottom' | 'top' | 'right' + /** Currently active workspace (trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined } /** @@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({ pickDirectory, onPick, onClose, + createOnly = false, + side = 'bottom', + selectedId, }: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items @@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) + const composingRef = useRef(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - const items: MenuEntry[] = [ - ...workspaces.map(workspace => ({ + const createEntries: MenuEntry[] = [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + ] + // With workspaces listed, the create actions pin below the scroll region + // (divider + always visible); otherwise they ARE the menu. + const pinCreate = !createOnly && workspaces.length > 0 + const items: MenuEntry[] = pinCreate + ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, icon: , disabled: pickingFolder, - })), - ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, - ] + })) + : createEntries const closeModal = (): void => { if (creating) return @@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({ } if (id === CREATE_NEW) { onClose() - setWorkspaceName('workspace') + setWorkspaceName('') setModalError(null) setModalKind('create') return @@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({ open={open} anchor={null} items={items} + {...pinCreate ? { footer: createEntries } : {}} + selectedId={selectedId} onSelect={handleSelect} onClose={onClose} + side={side} portal getAnchorRect={getAnchorRect} /> @@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({ { setWorkspaceName(event.target.value); setModalError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() confirmCreate() } @@ -225,6 +245,7 @@ export function WorkspacePicker({ open, anchorRef, useWorkspaces, + selectedId, onPick, onClose, createWorkspace, @@ -237,6 +258,7 @@ export function WorkspacePicker({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + selectedId={selectedId} onPick={onPick} onClose={onClose} /> diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..e8405c6bcd 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + it('rail create-workspace toggles the create-only picker in place, without expanding', () => { const expandSidebar = vi.fn() - const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(expandSidebar).toHaveBeenCalledTimes(1) - rerender(b, { wide: true }) - // The picker menu is open (anchored on the +); picking starts a session. - fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) - expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) - expect(screen.queryByRole('menu')).toBeNull() - // Wide toggle: open and close without expand requests. - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(screen.getByRole('menu')).toBeTruthy() + expect(expandSidebar).not.toHaveBeenCalled() + // createOnly: existing workspaces are not listed, only the create actions. + expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + // Toggle: open and close in place. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(screen.queryByRole('menu')).toBeNull() - expect(expandSidebar).toHaveBeenCalledTimes(1) // Escape closes the picker through its own onClose. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..081ff0c044 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -205,6 +205,8 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) chooseItem('Create a new workspace') + // The name field starts empty (no prefill); a name is required to submit. + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') From ae76ed28740b6493bc6431b349aeecbcf36cd290 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 04:56:14 +0800 Subject: [PATCH 14/43] fix(web-ui): workspace chip placeholder, logo new-session shortcut, hero foot padding - The hero workspace chip is a selector: no-live-selection states (cold start, workspace deleted from the sidebar after the list is ready) now render a "Choose workspace" placeholder (closed-folder icon) instead of resurrecting the deleted folder name via the session cwd; the cwd-derived name still bridges the initial list load. Stale pending picks clear when their workspace leaves a ready list. - The expanded sidebar wordmark starts a new session (visuals unchanged, pointer cursor only); the collapsed rail logo keeps its expand toggle. - The centered hero composer stack gains a 32px foot for visual balance. --- .../skeleton/ConversationRoot.module.css | 2 ++ .../src/client/skeleton/ConversationRoot.tsx | 33 ++++++++++++++----- .../src/client/skeleton/EmptyHero.tsx | 24 +++++++------- .../src/client/SidebarRoot.module.css | 8 ++++- .../ui-sidebar/src/client/SidebarRoot.tsx | 11 +++++-- .../ui-sidebar/tests/sidebar-root.spec.tsx | 9 +++-- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0bfd772f60..11f3502325 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -142,6 +142,8 @@ align-self: center; /* figma 75:8208: 12 between hero chrome / workspace row / card. */ gap: 12px; + /* Foot inside the centered box floats the stack a bit above true center. */ + padding-bottom: 32px; width: min(776px, calc(100% - 48px)); z-index: 1; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 2c97e5ce0e..382a42cb40 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -36,27 +36,42 @@ export function ConversationRoot({ workspace => workspace.workspaceId === pendingWorkspaceId, ) + // Clear the pending pick once the session lands in it, or when the picked + // workspace disappears from a ready list (deleted from the sidebar). useEffect(() => { - if (pendingWorkspaceId !== undefined - && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + if (pendingWorkspaceId === undefined) return + if (sessionWorkspace?.workspaceId === pendingWorkspaceId + || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) { setPendingWorkspaceId(undefined) } - }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } + // Flow optimization — worth a close PR review for code/boundary issues. + // The chip is a selector; label resolution walks the flow top-down: + // 1. a just-picked workspace (pending) → its title; + // 2. cold start, no session yet → placeholder ("Choose workspace"); + // 3. the blank session's workspace is in the list → its title; + // 4. list still loading → cwd folder name bridges so the title does not + // flash on refresh (empty cwd → placeholder); + // 5. list ready but no owning workspace (deleted from the sidebar) → + // placeholder, never the deleted folder's name via cwd. + const chipTitle = pendingWorkspace?.title + ?? (sessionId === undefined + ? undefined + : sessionWorkspace?.title + ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' + ? undefined + : workspaceLabel(cwd))) + const heroWorkspaceRow = (
{ setPickerOpen(open => !open) }} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c6122ecbcc..ca64fd7f44 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -7,20 +7,18 @@ import { useId } from 'react' import type { ReactNode, RefObject } from 'react' import { - FishLogo, IconChevronDownOutline14, IconFolderOpen16, + FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import css from './HeroShell.module.css' /** - * Basename label for the workspace chip / menu rows (the shared derivation); - * empty → the design's "New Workspace" placeholder copy; separator-only - * paths echo the raw cwd. - * @param cwd - workspace directory path ('' for none). + * Basename label for the workspace chip (the shared derivation); + * separator-only paths echo the raw cwd. + * @param cwd - workspace directory path (non-empty). * @returns chip label. */ export function workspaceLabel(cwd: string): string { - if (cwd === '') return 'New Workspace' const base = workspaceTitleOf(cwd) return base !== '' ? base : cwd } @@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string { /** * The workspace chip (folder + label + chevron), always interactive: before * the first message the workspace stays switchable — picking another one - * moves the New Session flow to that workspace's blank session. - * @param props.label - chip label (see {@link workspaceLabel}). + * moves the New Session flow to that workspace's blank session. Without a + * label the chip renders its placeholder state: closed folder + the + * "Choose workspace" call to action. + * @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder. * @param props.menuOpen - menu expansion echo. * @param props.onClick - menu toggle. * @returns the chip button element. */ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { buttonRef?: RefObject - label: string + label?: string | undefined menuOpen?: boolean onClick?: () => void }) { @@ -50,8 +50,10 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { aria-expanded={menuOpen} onClick={onClick} > - - {label} + {label === undefined + ? + : } + {label ?? 'Choose workspace'} ) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fe73435df8..ebb47467af 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -79,13 +79,19 @@ /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma-flows ruling: main-screen instance is black; blue is brand - emphasis only). */ + emphasis only). A button only in behavior (New Session shortcut): the + pointer cursor is the sole affordance — no hover chrome on the mark. */ .brand { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; } .iconButton { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f5f810cbbe..30d705c780 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -61,10 +61,17 @@ export function SidebarRoot({ style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} >
+ {/* Expanded, the wordmark doubles as a New Session shortcut; the + collapsed rail's logo is the expand toggle below instead. */} {wide && ( - + )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 458ed0bda4..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w } describe('SidebarRoot shell', () => { - it('routes New Session and the column toggle', () => { + it('routes New Session (capsule + wordmark) and the column toggle', () => { const b = mountShell() - fireEvent.click(screen.getByRole('button', { name: 'New session' })) - expect(b.startSession).toHaveBeenCalledWith() + // Expanded, both the wordmark and the capsule start a session. + const starters = screen.getAllByRole('button', { name: 'New session' }) + expect(starters).toHaveLength(2) + for (const button of starters) fireEvent.click(button) + expect(b.startSession).toHaveBeenCalledTimes(2) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() }) From 484e0f70cc63592e0829490fc96be0c4682d5bc9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 07:59:08 +0800 Subject: [PATCH 15/43] fix(web-ui): pixel loading language, tool-row sweep, composer polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StateDot ongoing: gradient spin ring replaced by an 8-cell pixel chase (2px matrix cells, stepped trail, no tweening) - Chat: streaming pulse block replaced by a turn-level 4-pixel chase at the flow tail — rides the whole running turn (first-token wait, tools, streaming) instead of flickering with partial presence - Tool rows (ToolRow/BashRow): running no longer swaps the icon for a dot; an animated mask band sweeps the row content, gliding off on exit via mask-position transition - Composer: send/stop unified on the blue fill (bigger stop glyph, static white arrow), textarea box-sizing overflow fix, settling phase hides the composer while replay decides hero vs docked, workspace-placeholder fallback disables the bar - Hero: glow moved behind (z-index) with lower opacity; tool-row hover icon crossfade at 100ms --- .../client/chat/AssistantMarkdown.module.css | 12 ---- .../src/client/chat/AssistantMarkdown.tsx | 7 +-- .../src/client/chat/ChatView.module.css | 25 ++++++++ .../src/client/chat/ChatView.tsx | 36 +++++++++++ .../src/client/chat/ToolRow.module.css | 41 ++++++++++-- .../src/client/chat/ToolRow.tsx | 6 +- .../skeleton/ConversationRoot.module.css | 23 +++++++ .../src/client/skeleton/ConversationRoot.tsx | 16 +++-- .../src/client/skeleton/DisabledInputBar.tsx | 2 +- .../src/client/skeleton/EmptyHero.tsx | 62 +++++++++++-------- .../src/client/skeleton/HeroShell.module.css | 19 ++---- .../src/client/skeleton/InputBar.module.css | 19 +++--- .../src/client/skeleton/InputBar.tsx | 6 +- .../client/toolviews/bash-sample.module.css | 16 +++++ .../src/client/toolviews/bash-sample.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-tool-row.spec.tsx | 6 +- .../tests/coverage-tails.spec.tsx | 2 +- .../ui-primitives/src/StateDot.module.css | 30 ++++----- .../client/ui-primitives/src/StateDot.tsx | 34 ++++++---- .../ui-primitives/tests/state-dot.spec.tsx | 15 ++--- 21 files changed, 257 insertions(+), 124 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index b4570af921..9177d721c3 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -9,18 +9,6 @@ color: var(--dsw-alias-label-primary); } -.pulse { - display: inline-block; - width: 8px; - height: 14px; - background: var(--dsw-alias-state-business-primary); - animation: pulse 1s infinite ease-in-out; -} - -@keyframes pulse { - 50% { opacity: 0.2; } -} - /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index fc487843df..71b7a8a142 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -2,8 +2,8 @@ // reasoning as the figma Think summary row (expand = indented gray text), // other-block JSON fallback. Tool-call heads are NOT rendered here: the chat // view groups them into tool rows through its keyed toolview slot (figma -// step-summary flow). Shared by finalized nodes and the streaming partial -// (pulse marker). +// step-summary flow). Shared by finalized nodes and the streaming partial; +// the turn-level loading dots live in the chat view's tail, not here. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean - /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ + /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ interrupted?: boolean | undefined } @@ -51,7 +51,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea default: return } })} - {streaming && } {interrupted && 已停止}
) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 6d75f9a519..f4ebce83db 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -51,6 +51,31 @@ border-left: 1px solid var(--dsw-alias-border-l2); } +/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to + right with a stepped trail — flat keyframe holds, no tweening. Phase + offsets come from per-rect animation-delay (index * -250ms) set inline + by the component. */ +.turnDots { + align-self: flex-start; + flex: none; + /* Same pin as StateDot: ongoing blue has no alias token (business-primary + is the 500 step, not this 450). */ + color: var(--dsw-static-deepseek-450); +} + +.turnDotCell { + fill: currentColor; + opacity: 0.15; + animation: dsh-turn-dots-chase 1s infinite; +} + +@keyframes dsh-turn-dots-chase { + 0%, 24.9% { opacity: 1; } + 25%, 49.9% { opacity: 0.6; } + 50%, 74.9% { opacity: 0.35; } + 75%, 100% { opacity: 0.15; } +} + .hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f61c6da6ef..737f1f042f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -149,6 +149,38 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, ) }) +/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot + * 2px cell, same blue) chasing left to right with a stepped trail — flat + * keyframe holds, no tweening, no rotation. Phase offsets come from + * per-rect animation-delay. */ +const LOADER_CELLS = [0, 5, 10, 15] as const + +function TurnDots() { + return ( + + ) +} + /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ function StreamingTail({ useSession, onGrow }: { @@ -169,6 +201,7 @@ function StreamingTail({ useSession, onGrow }: { */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) const pending = useSession(s => s.pending) @@ -314,6 +347,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
)} {pending.map(item => )} + {/* Turn-level loading signal: rides the whole running turn (first-token + wait, tool execution, streaming) so it never flickers per step. */} + {running && }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 6f99bb2696..d4ae79cd71 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -6,11 +6,34 @@ flex-direction: column; } +/* Row sweep mask (running signal): the row content itself is the medium — + an animated mask band modulates the alpha of everything in the row + (glyphs, icon, dot), so the light reads as passing THROUGH the content, + not behind it. The mask is ALWAYS mounted with the band parked off-screen + (-50% = past the right edge; tiled copies sit outside the view too), so + at rest it is a no-op. Running only adds the position animation. On exit + the animation drops and `transition: mask-position` carries the band from + its animated position onward off-screen right (before-change style + includes animation effects per css-transitions-1) — the sweep finishes + instead of snapping. */ .row { display: flex; align-items: center; height: 24px; min-width: 0; + mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); + mask-size: 200% 100%; + mask-position: -50% 0; + transition: mask-position 1.6s ease-out; +} + +.root[data-state='running'] .row { + animation: dsh-tool-row-sweep 2.2s linear infinite; +} + +@keyframes dsh-tool-row-sweep { + from { mask-position: 150% 0; } + to { mask-position: -50% 0; } } /* Clickable rows keep only the cursor affordance — no hover fill. */ @@ -20,6 +43,7 @@ } .leading { + position: relative; /* .chevronHover overlay anchor */ flex: none; width: 16px; height: 16px; @@ -62,22 +86,29 @@ button.leading { color: var(--dsw-alias-label-secondary); } -/* Hover preview on expandable rows: the idle tool icon yields to a down - chevron before the row is opened. */ +/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) + into a down chevron before the row is opened. The chevron overlays the + icon cell absolutely so both can stay mounted for the opacity transition. */ .iconIdle { display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; } .chevronHover { - display: none; + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; } .row:hover .iconIdle { - display: none; + opacity: 0; } .row:hover .chevronHover { - display: inline-flex; + opacity: 1; } .title { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a406d05cc3..5c5d059292 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -30,11 +30,11 @@ export interface ToolRowProps { onOpenDetails?: (() => void) | undefined } -/** Leading-slot state substitution: the tool icon yields to the state semantic - * (running = blue ring, error = red, interrupted = amber halo; ok = icon). */ +/** Leading-slot state substitution: the tool icon yields to the terminal state + * semantic (error = red, interrupted = amber halo). Running keeps the icon — + * the row sweep (CSS on data-state) carries the in-flight signal. */ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { switch (state) { - case 'running': return case 'error': return case 'stopped': return default: return icon diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 11f3502325..6c18b8c1a5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -139,6 +139,7 @@ NOT absolute+transform: a transform would make this box the containing block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { + position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208: 12 between hero chrome / workspace row / card. */ gap: 12px; @@ -148,6 +149,22 @@ z-index: 1; } +/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the + card's resting center sits ~92px above the stack bottom (32 foot pad + + half of the ~120px two-row card); width tracks the card (glow asset 1051 + vs design card 776) so blur scales in userSpace with it. z-index -1 keeps + it behind the in-flow hero content inside this stacking context. */ +.heroGlow { + position: absolute; + left: 50%; + bottom: 92px; + z-index: -1; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, 50%); + pointer-events: none; +} + .heroWorkspaceRow { display: flex; align-items: center; @@ -158,3 +175,9 @@ .root[data-phase='hero'] { justify-content: center; } + +/* Settling (session replaying, hero/docked unknown): keep the composer + mounted but invisible so no wrong layout flashes before the phase lands. */ +.root[data-phase='settling'] .composerStack { + visibility: hidden; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 382a42cb40..b110860685 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' -import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' import css from './ConversationRoot.module.css' @@ -46,7 +46,11 @@ export function ConversationRoot({ } }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) - const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) + // While a session is still replaying (loading + blank) the hero/docked + // choice is unknowable — render the composer hidden instead of flashing + // the centered hero and snapping to the docked bar (or vice versa). + const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading' + const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open') const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -91,7 +95,10 @@ export function ConversationRoot({
) - const inputBar = sessionId === undefined + // The placeholder chip ("Choose workspace") and the inert input travel + // together: a blank session whose workspace vanished (deleted from the + // sidebar) reverts to the same disabled bar as the initial no-session state. + const inputBar = sessionId === undefined || (hero && chipTitle === undefined) ? : renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -103,6 +110,7 @@ export function ConversationRoot({ const composerBar = (
+ {hero && } {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} @@ -112,7 +120,7 @@ export function ConversationRoot({ ) return ( -
+
{/* Mounted for every real session, hero included: ConversationSession renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx index 118baf5ac9..bfaa0b8d02 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx @@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index ca64fd7f44..fe3ee28d32 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -59,6 +59,40 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { ) } +/** + * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero + * owner (ConversationRoot), not HeroShell, so it can center on the input + * card; the owner's className supplies all positioning. + * @param props.className - positioning class from the owner. + * @returns the blurred-ellipse svg element. + */ +export function HeroGlow({ className }: { className?: string }) { + // Stable filter id so multiple hero mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` + return ( + + ) +} + /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ export interface HeroShellProps { /** Overlay content after the stack (modals). */ @@ -66,13 +100,12 @@ export interface HeroShellProps { } /** - * Render the hero chrome (headline + glow; no composer, no workspace row). + * Render the hero chrome (headline only; no glow, no composer, no workspace + * row — the glow is the owner's {@link HeroGlow}). * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ export function HeroShell({ children }: HeroShellProps) { - // Stable filter id so multiple hero mounts do not collide in the DOM. - const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return (
@@ -82,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) { Let's start building
- {/* figma 313:14109: soft ellipse behind workspace + composer; width - tracks the card (glow asset 1051 vs design card 776) so blur - scales in userSpace with it. */} - {/* The resident composer (rendered by ConversationRoot at its stable tree position; the workspace row rides its accessory hole) is CSS-positioned into this gap during the hero phase — see diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 6bc6af5fea..71b90bfdfa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -41,8 +41,9 @@ color: var(--dsw-alias-state-business-primary); } -/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is - centered on this block so it stays under the picker + InputBar together. */ +/* Workspace row sits 12px above the input card (figma y80 → y112). The blue + glow lives with the owner (ConversationRoot .heroGlow) so it can center on + the input card. */ .body { position: relative; display: flex; @@ -52,19 +53,7 @@ overflow: visible; } -/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ -.glow { - position: absolute; - left: 50%; - top: 50%; - z-index: 0; - width: calc(100% * 1051 / 776); - aspect-ratio: 1051 / 468; - transform: translate(-50%, -50%); - pointer-events: none; -} - -.body > :not(.glow) { +.body > * { position: relative; z-index: 1; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 7b2711da39..0cb3920f6e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -171,6 +171,10 @@ .input, .mirror, .backdrop { + /* Textareas default to content-box (unlike buttons/inputs): without this the + width:100% textarea gains its padding OUTSIDE the card and text runs past + the right padding — and wraps 28px later than the mirror/backdrop layers. */ + box-sizing: border-box; /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; @@ -306,11 +310,14 @@ border: none; border-radius: 999px; background: var(--dsw-alias-button-info-fill); - color: var(--dsw-alias-label-primary-foreground); + /* Static white, not the foreground token: the arrow stays white on the blue + fill in both themes (design 34:10465). */ + color: #fff; cursor: pointer; + transition: background-color 100ms ease; } -.primary:hover { +.primary:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover); } @@ -319,14 +326,6 @@ cursor: default; } -/* Stop state: same slot, dimmed brand fill — the running-state send-key - replacement is a design gap filled by us (figma gives no stop form). */ -.stopping, -.stopping:hover { - background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-label-primary); -} - .retry { margin-left: 8px; padding: 1px 8px; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..f72103ffc0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -374,7 +374,7 @@ export function InputBar({ {machineBusy && } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 84224c9979..81123532d5 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,5 +1,8 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ +/* Row sweep mask — same masked in-flight signal and exit-glide contract as + ToolRow (mask always mounted, band parked off-screen; running only adds + the animation; the transition finishes the sweep on exit). */ .root { display: flex; align-items: center; @@ -7,6 +10,19 @@ min-width: 0; cursor: pointer; border-radius: 6px; + mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); + mask-size: 200% 100%; + mask-position: -50% 0; + transition: mask-position 1.6s ease-out; +} + +.root[data-state='running'] { + animation: dsh-bash-row-sweep 2.2s linear infinite; +} + +@keyframes dsh-bash-row-sweep { + from { mask-position: 150% 0; } + to { mask-position: -50% 0; } } .leading { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index dc4dd6b367..fbb1104eca 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -12,9 +12,9 @@ import css from './bash-sample.module.css' function leadingFor(state: ToolRowState) { switch (state) { - case 'running': return case 'error': return case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. default: return } } diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..798f5c52bc 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -251,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => { const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) const view = mountApp(b.slots) // The nested row derives 'running' from the RunningToolCall shape — the - // same StateDot ring a native in-flight row wears. + // same data-state chrome (row sweep) a native in-flight row wears. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') expect(nested).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 13a74548b4..4425e98cb3 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -125,12 +125,12 @@ describe('ToolRow', () => { expect(view.getByText('List files')).toBeTruthy() }) - it('running and error states replace the icon with a StateDot', () => { + it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => { const runningView = render() - expect(runningView.queryByTestId('tool-icon')).toBeNull() + expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() - expect(errorView.queryByTestId('tool-icon')).toBeNull() + expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull() }) it('non-expandable rows render a passive leading slot', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 136901bd6d..86bb99246d 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -76,7 +76,7 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => { + it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index e5a8ebdb50..e9442f46bb 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -1,7 +1,7 @@ /* Ongoing blue has no alias token (state-business-primary is the 500 step, * not this 450) — component-level var pinned to the static scale instead. */ .dot, -.ring { +.matrix { --dsh-state-ongoing: var(--dsw-static-deepseek-450); } @@ -42,24 +42,24 @@ color: var(--dsw-alias-state-error-primary); } -.ring { +/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe + * holds, no tweening — the retro feel), peaking when the chase hits it and + * decaying over the next three cells. Phase offsets come from per-rect + * animation-delay (index * -125ms) set inline by the component. */ +.matrix { flex: none; color: var(--dsh-state-ongoing); - animation: dsh-state-dot-spin 1s linear infinite; } -.stopFrom { - stop-color: currentColor; - stop-opacity: 1; +.cell { + fill: currentColor; + opacity: 0.15; + animation: dsh-state-dot-chase 1s infinite; } -.stopTo { - stop-color: currentColor; - stop-opacity: 0; -} - -@keyframes dsh-state-dot-spin { - to { - transform: rotate(360deg); - } +@keyframes dsh-state-dot-chase { + 0%, 12.4% { opacity: 1; } + 12.5%, 24.9% { opacity: 0.6; } + 25%, 37.4% { opacity: 0.35; } + 37.5%, 100% { opacity: 0.15; } } diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index c4117673ba..643f9283f4 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,15 +1,19 @@ // StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182). // done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid -// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a -// linear gradient, spinning. Colors resolve through --dsw-* tokens only. +// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light +// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only. -import { useId } from 'react' import clsx from 'clsx' import css from './StateDot.module.css' /** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' +/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ +const MATRIX_CELLS: readonly (readonly [number, number])[] = [ + [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4], +] + /** * Render a state dot. * @param props.state - which of the four states to show. @@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: { size?: number className?: string }) { - const gradientId = useId() if (state === 'ongoing') { return ( ) } diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index a3759174ff..86e41c7787 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -14,16 +14,17 @@ describe('StateDot', () => { expect(dot.getAttribute('aria-hidden')).toBe('true') }) - it('solid states are spans; ongoing is an svg gradient ring', () => { + it('solid states are spans; ongoing is an svg pixel matrix', () => { const { container, rerender } = render() expect(container.firstElementChild?.tagName).toBe('SPAN') rerender() - const ring = container.firstElementChild as SVGSVGElement - expect(ring.tagName).toBe('svg') - const circle = ring.querySelector('circle') - expect(circle?.getAttribute('stroke-width')).toBe('1') - expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/) - expect(ring.querySelector('linearGradient')).not.toBeNull() + const matrix = container.firstElementChild as SVGSVGElement + expect(matrix.tagName).toBe('svg') + const cells = matrix.querySelectorAll('rect') + expect(cells).toHaveLength(8) + // Chase phase: every cell carries its own negative animation delay. + const delays = [...cells].map(cell => (cell).style.animationDelay) + expect(new Set(delays).size).toBe(8) }) it('sizes via the size prop in both shapes', () => { From 16b54aa4c4012897144e851acf47776f5fa9d2e6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:05:58 +0800 Subject: [PATCH 16/43] fix(web-ui): cwd-relative path summaries, sweep glare rework, uniform 16px chat rhythm Tool row summaries strip the session workspace root; the running sweep becomes a glare-band overlay (deepsuite ShimmerText pattern); assistant nodes that render nothing no longer split tool-row groups; block and tool-row spacing collapse to one 16px rhythm. --- .../src/client/chat/ChatView.module.css | 11 ++- .../src/client/chat/ChatView.tsx | 75 +++++++++++-------- .../src/client/chat/GenericToolCard.tsx | 4 +- .../src/client/chat/ToolRow.module.css | 41 +++++----- .../src/client/chat/chat-flow.ts | 11 +++ .../src/client/contract/slots.ts | 2 + .../src/client/contract/tool-call-model.ts | 13 +++- .../src/client/skeleton/EmptyHero.tsx | 2 +- .../src/client/skeleton/TodoPanel.module.css | 8 +- .../client/toolviews/bash-sample.module.css | 31 +++++--- .../tests/chat-tool-row.spec.tsx | 10 +++ .../ui-conversation/tests/chat-view.spec.tsx | 15 ++++ 12 files changed, 152 insertions(+), 71 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index f4ebce83db..fae96e5f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,5 +1,6 @@ -/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); - tool rows inside a group gap 10. Input padding cap rides the skeleton. */ +/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool + runs) via the column gap and between consecutive tool rows via the group + gap. Input padding cap rides the skeleton. */ .root { position: relative; @@ -30,7 +31,7 @@ .toolGroup { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; } .callRow { @@ -58,6 +59,10 @@ .turnDots { align-self: flex-start; flex: none; + display: flex; + align-items: center; + /* One message line box: the dots center inside the text line height. */ + height: 26px; /* Same pin as StateDot: ongoing blue has no alias token (business-primary is the 500 step, not this 450). */ color: var(--dsw-static-deepseek-450); diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 737f1f042f..deb7f09f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: { renderSlot: RenderToolRow node: CodeSubCall onOpenDetails: OpenDetails selected: boolean + cwd: string | undefined }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, + callId: node.callId, toolName, block: node, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + }), [node, toolName, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * GenericToolCard at this render site. A `run_code` call additionally * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { +const CallRow = memo(function CallRow({ + renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd, +}: { renderSlot: RenderToolRow callId: string toolName: string @@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, + callId, toolName, block, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + }), [callId, toolName, block, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq node={node} onOpenDetails={onOpenDetails} selected={node.callId === selectedCallId} + cwd={cwd} /> ))}
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq ) }) -/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails @@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ codeDispatches: ReadonlyMap + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { return (
@@ -143,6 +151,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
@@ -157,27 +166,29 @@ const LOADER_CELLS = [0, 5, 10, 15] as const function TurnDots() { return ( - + /* The wrapper is a 26px line box (message line height) so the loader + occupies one text line and centers the dots inside it. */ + ) } @@ -199,8 +210,10 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + // Workspace root off the session list row: path summaries display relative to it. + const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) @@ -301,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl onOpenDetails={openDetails} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} + cwd={cwd} /> ) } @@ -342,6 +356,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 24b3b36a22..e5b5fb541b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -25,8 +25,8 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { - const model = toolRowModel(toolName, block) +export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block, cwd) return ( b.kind === 'tool-call' + || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). @@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem const items: ChatFlowItem[] = [] let group: ToolResultNode[] | null = null for (const node of nodes) { + if (rendersNothing(node)) continue if (node.kind === 'tool-result') { if (group === null) { group = [node] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c7c53aaafe..a40eb3cd7f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,6 +143,8 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ block: ToolCallBlock + /** Session workspace root; path summaries display relative to it. */ + cwd?: string | undefined /** Open the details panel for this call (session-level facility, supplied by the view). */ openDetails: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..c7db85b0aa 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record = { others: [], } +/** Strip the workspace root from workspace-rooted absolute paths (display only). */ +function relativizeToCwd(text: string, cwd: string | undefined): string { + if (cwd === undefined || cwd === '') return text + const root = cwd.replace(/[/\\]+$/, '') + if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) + return text +} + function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { const parsed = parseArgs(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) @@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * Derive the full row model from a frozen call slice. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index fe3ee28d32..7fbed0ee25 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -66,7 +66,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { * @param props.className - positioning class from the owner. * @returns the blurred-ellipse svg element. */ -export function HeroGlow({ className }: { className?: string }) { +export function HeroGlow({ className }: { className?: string | undefined }) { // Stable filter id so multiple hero mounts do not collide in the DOM. const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return ( diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 8086cbfea7..c6b50a2c77 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -92,15 +92,15 @@ color: var(--dsw-alias-state-success-primary); } +.glyphPending { + color: var(--dsw-alias-label-caption); +} + .glyphProgress { color: var(--dsw-alias-state-business-primary); animation: todo-progress-spin 1s linear infinite; } -.glyphPending { - color: var(--dsw-alias-label-caption); -} - @keyframes todo-progress-spin { to { transform: rotate(360deg); diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 81123532d5..a79faf30f1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,28 +1,37 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -/* Row sweep mask — same masked in-flight signal and exit-glide contract as - ToolRow (mask always mounted, band parked off-screen; running only adds - the animation; the transition finishes the sweep on exit). */ .root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); - mask-size: 200% 100%; - mask-position: -50% 0; - transition: mask-position 1.6s ease-out; } -.root[data-state='running'] { - animation: dsh-bash-row-sweep 2.2s linear infinite; +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-bash-row-sweep 2.6s ease-out infinite; + pointer-events: none; } @keyframes dsh-bash-row-sweep { - from { mask-position: 150% 0; } - to { mask-position: -50% 0; } + 0% { left: -300px; } + 90%, 100% { left: 100%; } } .leading { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 4425e98cb3..63f2da6586 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -64,6 +64,16 @@ describe('tool-call-model', () => { expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1') }) + it('displays workspace-rooted paths relative to the session cwd', () => { + const cwd = '/Users/u/ws/' + expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md') + // Paths outside the workspace (and non-path summaries) stay verbatim. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts') + expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20389c9e23..f9028bd386 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -131,6 +131,21 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) + + it('skips render-nothing assistant nodes so tool runs stay one group', () => { + // A tool-call-only step message (and blank text/reasoning) renders nothing: + // it must not split the run into two groups with an empty line between. + const headsOnly: AssistantMessageNode = { + kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, + blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], + } + const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) + expect(flowKeys(items)).toBe('g3') + expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b']) + // Interrupted and visible-content nodes still render (已停止 marker / prose). + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') + }) }) describe('ChatView', () => { From c4df0628db2445ce590d9d4544bd0f40414df5b5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:29 +0800 Subject: [PATCH 17/43] docs(notes): record reactive provide projection and lexicon subscription decisions --- ...5-web-client-session-scope-and-provide-channel.i18n.yaml | 4 ++-- ...26-07-25-web-client-session-scope-and-provide-channel.md | 2 +- ...07-25-web-client-session-scope-and-provide-channel.zh.md | 2 +- .../2026-07-25-web-command-surfaces-and-assembly.i18n.yaml | 6 +++--- .../2026-07-25-web-command-surfaces-and-assembly.md | 4 ++-- .../2026-07-25-web-command-surfaces-and-assembly.zh.md | 4 ++-- ...026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml | 6 +++--- .../2026-07-25-web-input-machine-and-slash-pipeline.md | 4 ++-- .../2026-07-25-web-input-machine-and-slash-pipeline.zh.md | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f3a525fe70..4783b79a6a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 +2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 09afe6d9e8..4496e3786e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess Slot scope is the closed set `root | session-maybe | session`: - `root` receives only the global standard kit, with no session identity or provisioning. -- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. +- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 4d45d74c2e..768dada95a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件 slot scope 是闭集 `root | session-maybe | session`: - `root` 只拿全局标准件,不接收 session 身份或供数。 -- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 +- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 - `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..29f56666b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c +2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..4c4a400aba 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) -- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). -- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). +- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). +- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..c0acd1ecc5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -28,8 +28,8 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 -- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 +- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 0249baff80..121879629c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index acbd132a5f..8cf3be7b3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived: - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. -- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface. +- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. - `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan. - Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). -- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once. +- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 158650a41b..b148889355 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -81,10 +81,10 @@ occurrence 表与 chip 三投影: skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生: - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 -- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。 +- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。 - `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。 - 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 -- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。 +- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### per-session 供数贡献与键盘私面 From a0b618abb96ae619858bbb2f852fc924bc4fda44 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:50 +0800 Subject: [PATCH 18/43] fix(client): publish current session provide bundle as one reactive projection A provider roster change under a stable current id rematerialized every scope's bundle but nothing notified React: SessionProvider resolved the bundle from a current-id subscription only, so mounted entries kept the obsolete hook/prop schema until an unrelated re-render. The sessions service now owns an atomic currentProvide observable fed by both current writes and roster changes; the renderer host exposes it as sessions.provide, replacing the current/provideInfo/maybeProvideInfo trio, and both providers subscribe to it. --- .../runtime/src/client/sessions/service.ts | 49 ++++++++++++++--- packages/client/runtime/src/client/slots.ts | 11 +--- .../runtime/tests/sessions-service.spec.ts | 54 +++++++++++++++++++ .../runtime/tests/slots-service.spec.ts | 17 ++---- .../tests/apply-inject.spec.tsx | 3 +- .../ui-conversation/tests/chat-apply.spec.tsx | 3 +- .../tests/chat-code-subcalls.spec.tsx | 10 ++-- .../tests/chat-toolview-slot.spec.tsx | 46 ++++++++-------- .../tests/selection-survival.spec.ts | 8 ++- packages/client/ui-slots/src/renderer.ts | 16 +++--- .../client/web-react/src/session-provider.tsx | 20 +++---- .../tests/scoped-slots-real-core.spec.tsx | 5 +- .../web-react/tests/scoped-slots.spec.tsx | 20 ++++--- .../web-react/tests/session-provider.spec.tsx | 50 ++++++++++++++--- .../tests/stale-authorization.spec.tsx | 5 +- 15 files changed, 222 insertions(+), 95 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..03eca7724f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -151,6 +151,13 @@ export class SessionsService { readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ private readonly manager: SessionManager + /** + * Atomic current-session provide projection: selection changes and + * provider-roster changes publish through this one source (the renderer + * host's `sessions.provide` feed), so a roster change under a stable + * current id republishes the bundle instead of stranding mounted entries. + */ + readonly currentProvide: HostObservable /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -167,6 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ private maybeInfo: SessionMaybeProvideInfo + /** Latest published {@link SessionsService.currentProvide} bundle (identity comparison dedupes republish). */ + private currentProvideSnapshot: SessionMaybeProvideInfo + /** currentProvide subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ + private readonly currentProvideListeners = new Set<() => void>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -198,7 +209,11 @@ export class SessionsService { // dedicated code path. Safe to run synchronously inside the store notify: // the follower writes no list state — session.open()'s synchronous prefix // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) + // The current-provide projection follows the same current writes. + this.list.subscribe(() => { + this.followCurrent() + this.projectCurrentProvide() + }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). this.providers.push({ @@ -206,6 +221,14 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) this.maybeInfo = this.materializeMaybeProvideInfo() + this.currentProvideSnapshot = this.maybeInfo + this.currentProvide = { + getSnapshot: () => this.currentProvideSnapshot, + subscribe: (fn) => { + this.currentProvideListeners.add(fn) + return () => { this.currentProvideListeners.delete(fn) } + }, + } rootCtx.reflect.provide('sessions', this, undefined) } @@ -238,6 +261,20 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } + this.projectCurrentProvide() + } + + /** + * Publish the current selection's provide bundle when it changed. Bundles + * are identity-stable per (scope, roster) materialization, so an identity + * compare is exact; synchronous notify — both call sites (list.subscribe, + * provide()) already sit behind their own batching or registration edges. + */ + private projectCurrentProvide(): void { + const next = this.maybeProvideInfo(this.list.getSnapshot().current) + if (next === this.currentProvideSnapshot) return + this.currentProvideSnapshot = next + for (const fn of [...this.currentProvideListeners]) fn() } /** Build the static no-session kit and reject duplicate declared names. */ @@ -404,11 +441,11 @@ export class SessionsService { } /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). + * Resolve one session's render-layer standard-props bundle (ctx never + * enters the render layer; the renderer subscribes to + * {@link SessionsService.currentProvide}). Pure resolution — render-safe: + * no staging, no window side effects (StrictMode double-invokes and + * concurrent discarded passes must stay free). * @param id - session id. * @returns the provide info, or undefined for a session neither listed nor already scoped. */ diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 74af31502a..ed10826b9d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -246,13 +246,6 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } - // Identity-stable view: current rides the list snapshot (arbitrated), but - // the provider consumes it as its own observable; one cached object keeps - // the renderer's per-source hook cache stable. - const current = { - getSnapshot: () => sessions.list.getSnapshot().current as string | undefined, - subscribe: (fn: () => void) => sessions.list.subscribe(fn), - } this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -263,9 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - current, - provideInfo: id => sessions.provideInfo(id), - maybeProvideInfo: id => sessions.maybeProvideInfo(id), + provide: sessions.currentProvide, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..2f90c1c301 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -195,6 +195,60 @@ describe('cell (render-layer session kit)', () => { expect(b.svc.provideInfo('ghost')).toBeUndefined() }) + it('currentProvide follows selection: absent projection ↔ definite bundle, notified on each move', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const absent = b.svc.currentProvide.getSnapshot() + expect(absent.sessionId).toBeUndefined() + expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) + const notified = vi.fn() + b.svc.currentProvide.subscribe(notified) + b.svc.open(sid('s1')) + expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s1')) + expect(notified).toHaveBeenCalledTimes(1) + b.svc.open(sid('s2')) + expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s2')) + expect(notified).toHaveBeenCalledTimes(2) + b.svc.clear() + await Promise.resolve() // clearSelection projects through the manager notifier + expect(b.svc.currentProvide.getSnapshot().sessionId).toBeUndefined() + }) + + it('a provider roster change under a stable current id republishes the bundle', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + const before = b.svc.currentProvide.getSnapshot() + const notified = vi.fn() + b.svc.currentProvide.subscribe(notified) + const source = { getSnapshot: () => 'live', subscribe: () => () => {} } + const dispose = b.svc.provide({ + hooks: ['extra'], + props: ['marker'], + resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), + }) + const added = b.svc.currentProvide.getSnapshot() + expect(added).not.toBe(before) + expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) + expect(added.hooks['extra']).toBe(source) + expect(notified).toHaveBeenCalledTimes(1) + dispose() + const removed = b.svc.currentProvide.getSnapshot() + expect(removed).not.toBe(added) + expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) + expect(notified).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed currentProvide listener stops receiving notifications', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const notified = vi.fn() + const off = b.svc.currentProvide.subscribe(notified) + off() + b.svc.open(sid('s1')) + expect(notified).not.toHaveBeenCalled() + }) + it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 97bcb50f0a..b7d6f31093 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -97,18 +97,13 @@ function fakeWorkspaces() { return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } } -/** Minimal sessions face for the host seam (list observable + provide bundle). */ +/** Minimal sessions face for the host seam (list observable + current provide projection). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } + const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - provideInfo: (id: string) => (id === 'known' - ? { - sessionId: id, - hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, - props: {}, - } - : undefined), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } @@ -232,13 +227,11 @@ describe('host face', () => { expect(host.entriesOf('t.host')).toHaveLength(0) }) - it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => { + it('exposes the session list and the atomic current provide projection', async () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.current.getSnapshot()).toBeUndefined() - expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' }) - expect(host.sessions.provideInfo('ghost')).toBeUndefined() + expect(host.sessions.provide.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 504d0ec8c6..37b824c906 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -87,12 +87,13 @@ async function bench() { } } const providers: TestProvider[] = [] + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 36a25c5b34..818a6bf620 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -32,12 +32,13 @@ async function bench() { current: undefined, phase: 'ready', }) + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..d7f523b028 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } + // Identity-stable currentProvide snapshot (uSES getSnapshot contract), + // materialized on first render after the provide contributions landed. + let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { list, binding: (id: SessionId) => (id === SID @@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - maybeProvideInfo: (id: string | undefined) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : { hooks: provided.hooks, props: provided.props }), + currentProvide: { + getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, + subscribe: () => () => {}, + }, create: vi.fn(), open: vi.fn(), } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..117a7108c9 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) { subscribe: (fn: () => void) => session.subscribe(fn), }, }) + const provideInfo = (id: string) => { + if (id !== SID) return undefined + if (info === undefined) { + const hooks: Record = { session } + const props: Record = {} + for (const provider of providers) { + const c = provider(bindingOf(SID)) + Object.assign(hooks, c.hooks ?? {}) + Object.assign(props, c.props ?? {}) + } + info = { sessionId: SID, hooks, props } + } + return info + } ctx.provide('sessions', { list, binding: bindingOf, scope: () => actxFake, - provideInfo: (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - for (const provider of providers) { - const c = provider(bindingOf(SID)) - Object.assign(hooks, c.hooks ?? {}) - Object.assign(props, c.props ?? {}) - } - info = { sessionId: SID, hooks, props } - } - return info - }, - maybeProvideInfo(id: string | undefined) { - // `this` inside an object-literal method is any under strict lint; the - // fake resolves through its own provideInfo above. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, - @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ - return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } + provideInfo, + currentProvide: { + getSnapshot: () => provideInfo(SID), + subscribe: () => () => {}, }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, scopeOf: () => SID, @@ -254,7 +255,10 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index ec50a3f317..19988eeab9 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts' const sid = (s: string): SessionId => s as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + interface Bench { slots: SlotsService chat: ReturnType @@ -23,7 +26,10 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, }) ctx.provide('workspaces', { diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 5b7de0d6f1..09143ca84e 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -105,18 +105,14 @@ export interface SlotRendererHost { sessions: { /** Session list source backing the useSessions standard hook. */ list: HostObservable - /** Current-session source used by SessionProvider. */ - current: HostObservable - /** Resolve a definite session bundle, or undefined when the id is unknown. */ - provideInfo(id: string): SessionProvideInfo | undefined /** - * Resolve the current-session-optional standard props bundle. The result - * always carries the static provider roster, even when `id` is absent or - * cannot resolve to a live session. - * @param id - current session id, when selected. - * @returns the optional provide info. + * Atomic current-session provide projection used by SessionProvider: + * selection changes and provider-roster changes publish through this one + * source, so a stable current id cannot strand mounted entries on an + * obsolete hook/prop schema. Carries the static roster with sessionId + * undefined while no current session resolves. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo + provide: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 79cb763a3e..61212e57a3 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,9 +90,9 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) + const info = observableHook(host.sessions.provide)(s => s) return ( - + {children} ) @@ -107,17 +107,17 @@ export interface SessionProviderProps { } /** - * Framework-wired session area: subscribes to the host's current-session - * source, resolves the session cell, and remounts the body under - * `key={sessionId}` so a session switch rebuilds the session subtree. This - * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the - * branded type at the component boundary. + * Framework-wired session area: subscribes to the host's current provide + * source and remounts the body under `key={sessionId}` so a session switch + * rebuilds the session subtree. This dependency-inverted layer uses plain + * string ids; `PropsRuntime` applies the branded type at the component + * boundary. */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) - const info = id === undefined ? undefined : host.sessions.provideInfo(id) - if (id === undefined || info === undefined) return <>{empty?.() ?? null} + const info = observableHook(host.sessions.provide)(s => s) + const id = info.sessionId + if (id === undefined) return <>{empty?.() ?? null} return ( {children(id)} diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 381170527a..09d38c187f 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'> /** Passthrough host over the real core (store/session seats unused here). */ function hostOver(core: SlotCore): SlotRendererHost { + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } return { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), @@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 51b12d2385..dba09810f6 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, type ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionProvideInfo, @@ -85,7 +86,9 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const bump = (key: string) => { @@ -123,10 +126,7 @@ function makeHost() { }, sessions: { list, - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: {}, props: {} }, + provide, }, workspaces: { list: workspaces }, } @@ -134,7 +134,14 @@ function makeHost() { host, list, workspaces, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) @@ -161,6 +168,7 @@ function makeHost() { props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, } diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 2e055bcee2..1dacdcec53 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' -import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, type SessionProvideInfo, type SlotRendererHost, @@ -26,12 +26,14 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.current/cell, but it must + * Minimal host: SessionProvider only reads sessions.provide, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) { - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const sessionEntries: StoredEntry[] = [] const rootEntry: StoredEntry = { @@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: { session: undefined }, props: {} }, + provide, }, workspaces: { list: observable({ items: [] }) }, } return { host, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, addSession: (id: string) => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. const info: SessionProvideInfo = { @@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, + /** Swap one session's bundle in place (roster-change stand-in); republish when current. */ + replaceSession: (info: SessionProvideInfo) => { + infos.set(info.sessionId, info) + if (currentId === info.sessionId) provide.set(info) + }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } } @@ -149,6 +161,28 @@ describe('SessionProvider', () => { expect(seen.at(-1)!['sessionId']).toBe('s2') }) + it('republishes a mounted session entry when its provide bundle changes under the same id', () => { + const seen: unknown[] = [] + const h = makeHost({ + root: renderSlot => {() => renderSlot('k.session', {})}, + }) + const original = h.addSession('s1') + h.registerSession({ + component: (props: { feature?: string }) => { + seen.push(props.feature) + return null + }, + options: {}, + }) + render(<>{createSlotRenderer().renderRoot(h.host, {})}) + act(() => { h.current.set('s1') }) + expect(seen.at(-1)).toBeUndefined() + // A provider-roster change rematerializes the bundle; the provide source + // must carry it to already-mounted entries without a selection change. + act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) }) + expect(seen.at(-1)).toBe('now-live') + }) + it('fails loud when mounted outside the renderer tree (no host channel)', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render( diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index 6c3e5d194b..f0fa07fd44 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -21,6 +21,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() @@ -39,9 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, From eae712409b27e93f5379c2d7813c82b5f2998ba9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:08 +0800 Subject: [PATCH 19/43] fix(ui-settings): subscribe to the section ledger through useSyncExternalStore The manual useState+useEffect subscription could miss a registration landing between render and effect commit; uSES closes that window and keeps the same version-dedupe behavior. --- .../client/ui-settings/src/client/SettingsRoot.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 0d12135311..22946e799a 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -7,7 +7,7 @@ * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state. */ -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps } from './contract/slots.ts' @@ -99,12 +99,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) { // The ledger tick keeps the nav rows fresh: registrants re-register with // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. - // State = ledger version: same-version notifications dedupe to no render. - const [, setSectionsRev] = useState(() => sectionsVersion()) - useEffect( - () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), - [subscribeSections, sectionsVersion], - ) + // uSES over the ledger version: same-version notifications dedupe to no + // render, and a registration landing between render and effect + // subscription cannot be missed. + useSyncExternalStore(subscribeSections, sectionsVersion) const rows = sections() return ( From d3d01cb49cd07674507a12a71c4865c948f716e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:24 +0800 Subject: [PATCH 20/43] fix(ui-slash): make the reference lexicon reactive end to end The decoration scan read a mutable lexicon() aggregation during render with no subscription, so a catalog settling or a child spawning after prewarm left drafted tokens undecorated until an unrelated re-render. The controller now publishes the aggregation as a snapshot store fed by a new optional SlashSource.subscribeLexicon hook (ui-skill notifies on settle/invalidate, ui-subagent forwards the session-list feed), the composer keyboard face exposes it as an observable, and InputBar subscribes through uSES. Sources registered after scope birth now warm and join live controllers via a service broadcast. --- .../src/client/input/contract.ts | 6 +- .../src/client/input/facade.ts | 11 +-- .../src/client/skeleton/InputBar.tsx | 7 +- .../ui-conversation/tests/input-bar.spec.tsx | 6 +- packages/client/ui-skill/src/client/index.ts | 22 +++++- .../ui-skill/tests/browser-plugin.spec.ts | 27 +++++++ .../client/ui-slash/src/client/controller.ts | 57 ++++++++++++--- .../client/ui-slash/src/client/service.ts | 4 +- packages/client/ui-slash/src/types.ts | 10 +++ .../client/ui-slash/tests/service.spec.ts | 71 ++++++++++++++++++- .../client/ui-subagent/src/client/index.ts | 4 ++ .../ui-subagent/tests/browser-plugin.spec.ts | 37 ++++++++-- 12 files changed, 232 insertions(+), 30 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..c229abf18c 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -5,7 +5,7 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -99,8 +99,8 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void - /** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */ - lexicon(): ReadonlyMap<'/' | '@', readonly string[]> + /** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */ + readonly lexicon: ObservableSnapshot> } /** One queued-message row projected from the session/queued frames (T9 supplies the store). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..d6b2fde80a 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput { } /** - * Hot plain-text reference lexicons for the decoration scan (decision 21). - * @returns the controller's per-trigger aggregation; empty Map without a pipeline. + * Hot plain-text reference lexicon source for the decoration scan + * (decision 21): delegates to the controller's aggregated store. Stable + * identity per shell; without a pipeline the snapshot is the empty Map and + * subscribers never fire. */ - lexicon(): ReadonlyMap<'/' | '@', readonly string[]> { - return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON + readonly lexicon: ObservableSnapshot> = { + getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON, + subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}), } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..aa224d1ec1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -36,6 +36,11 @@ export function InputBar({ (fn: () => void) => noticeStore.subscribe(fn), () => noticeStore.getSnapshot(), ) + const lexiconStore = keyboard.lexicon + const lexicon = useSyncExternalStore( + (fn: () => void) => lexiconStore.subscribe(fn), + () => lexiconStore.getSnapshot(), + ) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) @@ -244,7 +249,7 @@ export function InputBar({ // claim token highlights through behind the textarea glyphs; each U+FFFC // placeholder renders as a chip (the textarea's own glyph is invisible, the // backdrop chip supplies the visual); the claim hint is ghost text. - const deco = deriveDecorations(input, keyboard.lexicon()) + const deco = deriveDecorations(input, lexicon) const backdrop: ReactNode[] = [] { // Segment boundaries: the token range end, every chip offset, and every diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c50a35110a..ab62af80bb 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,7 +56,11 @@ function bench(over?: BenchOptions) { // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined - ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable } + ? { + slash: (() => ({ + lexicon: { getSnapshot: () => lex, subscribe: () => () => {} }, + })) as unknown as NonNullable, + } : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 677843c844..7226f45163 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -44,6 +44,12 @@ export function apply(ctx: ClientContext): void { // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() + // Per-session lexicon invalidation listeners (subscribeLexicon consumers). + const lexiconListeners = new Map void>>() + + const notifyLexicon = (sessionId: SessionId): void => { + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener() + } const fetchCatalog = (sessionId: SessionId): Promise => { const existing = fetches.get(sessionId) @@ -58,7 +64,10 @@ export function apply(ctx: ClientContext): void { fetches.set(sessionId, entry) promise.then( // Settled snapshot backs the synchronous lexicon reads. - (skills) => { entry.settled = skills }, + (skills) => { + entry.settled = skills + notifyLexicon(sessionId) + }, // A failed fetch must not poison the key: the next consumer retries. () => { if (fetches.get(sessionId) === entry) fetches.delete(sessionId) @@ -72,6 +81,7 @@ export function apply(ctx: ClientContext): void { if (entry === undefined) return fetches.delete(key) entry.abort.abort() + notifyLexicon(key) } const clearAll = (): void => { @@ -97,6 +107,16 @@ export function apply(ctx: ClientContext): void { lexicon(session) { return fetches.get(session.sessionId)?.settled?.map(skill => skill.name) }, + subscribeLexicon(session, listener) { + const key = session.sessionId + const listeners = lexiconListeners.get(key) ?? new Set() + listeners.add(listener) + lexiconListeners.set(key, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0) lexiconListeners.delete(key) + } + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 11f53e142c..0d8f2c57cd 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -208,6 +208,33 @@ describe('lexicon', () => { // Another session's key is independent — cold until its own fetch. expect(source.lexicon!(proj('s2'))).toBeUndefined() }) + + it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => { + const { list } = countingList() + const { ctx, source } = await bench(list) + const s1 = vi.fn() + const s2 = vi.fn() + source.subscribeLexicon!(proj('s1'), s1) + source.subscribeLexicon!(proj('s2'), s2) + await source.candidates(proj('s1'), req('')) + expect(s1).toHaveBeenCalledTimes(1) + expect(s2).not.toHaveBeenCalled() + // Reset invalidates every cached session: each key notifies its own listeners. + await source.candidates(proj('s2'), req('')) + ctx.emit('connection/reset') + expect(s1).toHaveBeenCalledTimes(2) + expect(s2).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed lexicon listener stops receiving notifications', async () => { + const { list } = countingList() + const { source } = await bench(list) + const listener = vi.fn() + const off = source.subscribeLexicon!(proj('s1'), listener) + off() + await source.candidates(proj('s1'), req('')) + expect(listener).not.toHaveBeenCalled() + }) }) describe('pick and codec', () => { diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..9e95dbdd41 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,18 +40,35 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(MENU_CLOSED) + /** + * Aggregated hot reference lexicon, grouped by trigger (decision 21): + * sources implementing the lexicon hook are polled with the session + * projection; undefined answers (roll not hot yet) are skipped; multiple + * sources on one trigger concatenate in registration order. A snapshot + * store because rolls change asynchronously (catalog settles, children + * spawn/exit) — render-side consumers subscribe instead of re-reading a + * mutable answer. + */ + readonly lexicon: SnapshotStore> = + createSnapshotStore>(new Map()) /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ private hit: TriggerHit | null = null private fetch: AbortController | null = null private disposed = false + /** Per-source lexicon unsubscribers (sources without the hook never enter). */ + private readonly lexiconOffs = new Map void>() constructor(private readonly deps: SlashControllerDeps) { // Scope-birth prewarm: sessions are always agent-backed, so the one-time // roster warm here replaces the projection-transition watch — there are // no capability steps to react to. const projection = this.project() - for (const src of deps.roster.all()) src.warm?.(projection) + for (const src of deps.roster.all()) { + src.warm?.(projection) + this.watchLexicon(src, projection) + } + this.refreshLexicon() } /** @@ -220,6 +237,23 @@ export class SlashController { if (state.open && state.hit !== null && state.hit.trigger === source.trigger) { this.reduce({ type: 'source-failed', generation: state.generation, source: source.name }) } + this.lexiconOffs.get(source)?.() + this.lexiconOffs.delete(source) + this.refreshLexicon() + } + + /** + * Admit a source registered after this controller's birth (root registry + * change notification): warm it and fold its roll into the live lexicon — + * the constructor-time prewarm covers only the roster present at scope + * birth. + * @param source - the newly registered source. + */ + sourceAdded(source: SlashSource): void { + const projection = this.project() + source.warm?.(projection) + this.watchLexicon(source, projection) + this.refreshLexicon() } /** Scope teardown: close and abort (the service deletes the map entry). */ @@ -228,6 +262,8 @@ export class SlashController { this.stopFetch() this.reduce({ type: 'close' }) this.hit = null + for (const off of this.lexiconOffs.values()) off() + this.lexiconOffs.clear() } /** The session projection handed to sources (agent-backed identity; constant per scope). */ @@ -248,15 +284,8 @@ export class SlashController { return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } - /** - * Aggregate the sources' plain-text reference lexicons (decision 21), - * grouped by trigger: sources implementing the hook are polled with the - * session projection (onSpace's poll pattern); undefined answers (roll not - * hot yet) are skipped; multiple sources on one trigger concatenate in - * registration order. - * @returns trigger → decorated-name roll for the decoration scan. - */ - lexicon(): ReadonlyMap { + /** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */ + private refreshLexicon(): void { const projection = this.project() const rolls = new Map() for (const src of this.deps.roster.all()) { @@ -266,7 +295,13 @@ export class SlashController { const prev = rolls.get(src.trigger) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) } - return rolls + this.lexicon.set(rolls) + } + + /** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */ + private watchLexicon(source: SlashSource, projection: ClientSessionContext): void { + if (source.lexicon === undefined || source.subscribeLexicon === undefined) return + this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() })) } /** Launch the candidate fetch for one hit generation, superseding the previous one. */ diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 094f325393..0ca3b91c2a 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract { } /** - * Register one trigger source. + * Register one trigger source. Live session controllers are notified so a + * source arriving after scope birth still warms and joins the lexicon. * @param src - the source; (trigger, name) must be unique — duplicates throw. * @returns the disposer (callers wrap registration in ctx.effect). Disposal * while a controller shows the source's menu group drops that group. @@ -49,6 +50,7 @@ export class SlashService extends Service implements SlashServiceContract { throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) } live.sources.push(src) + for (const controller of live.controllers.values()) controller.sourceAdded(src) return () => { const at = live.sources.indexOf(src) if (at < 0) return diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..4b9bd64408 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -165,6 +165,16 @@ export interface SlashSource { * (the render path must stay synchronous and side-effect free). */ lexicon?(session: ClientSessionContext): readonly string[] | undefined + /** + * Subscribe to changes of this source's {@link SlashSource.lexicon} answer + * for one session (backing data settled, invalidated, or refreshed). The + * controller re-polls lexicon on each notification; a source whose roll + * never changes after warm omits the hook. + * @param session - stable session projection. + * @param listener - invalidation callback. + * @returns unsubscribe. + */ + subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void /** Reference codec; required for sources producing insert outcomes. */ readonly codec?: ReferenceCodec } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 379d7bbe8a..099e2bb4f5 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -126,6 +126,18 @@ describe('registerSource', () => { slash.registerSource(deferredSource('/', 'beta').source) }) + it('a source registered after controller birth warms in every live controller', async () => { + const { slash, mint } = await serviceBench() + const ca = slash.sessionOf(mint('a').actx) + const cb = slash.sessionOf(mint('b').actx) + const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] }) + slash.registerSource(late.source) + expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') }) + expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') }) + expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + it('HMR shape: dispose of the registering fiber removes the source', async () => { const { root, slash, mint } = await serviceBench() const controller = slash.sessionOf(mint('a').actx) @@ -513,7 +525,7 @@ describe('lexicon', () => { skill, lexSource('@', 'subagent', ['worker-1']), ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect([...rolls.keys()]).toEqual(['/', '@']) expect(rolls.get('/')).toEqual(['commit-helper', 'review']) expect(rolls.get('@')).toEqual(['worker-1']) @@ -522,7 +534,7 @@ describe('lexicon', () => { it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => { const { controller } = controllerBench([lexSource('/', 'skill', undefined)]) - expect(controller.lexicon().size).toBe(0) + expect(controller.lexicon.getSnapshot().size).toBe(0) }) it('two sources on one trigger concatenate in registration order', () => { @@ -531,10 +543,63 @@ describe('lexicon', () => { lexSource('/', 'prompt', ['c']), lexSource('@', 'subagent', undefined), // not hot: '@' stays absent ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect(rolls.get('/')).toEqual(['b', 'a', 'c']) expect(rolls.has('@')).toBe(false) }) + + it('a source lexicon notification republishes the aggregated store', () => { + let roll: readonly string[] | undefined = undefined + let notify: (() => void) | undefined + const source: SlashSource = { + trigger: '/', + name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session, listener) => { + notify = listener + return () => { notify = undefined } + }, + } + const { controller } = controllerBench([source]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const seen: number[] = [] + controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) }) + roll = ['commit-helper'] + notify?.() + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper']) + expect(seen).toEqual([1]) + controller.dispose() + expect(notify).toBeUndefined() + }) + + it('a source registered after scope birth is warmed and folded into the live lexicon', () => { + const { controller, sources } = controllerBench([]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const warm = vi.fn() + const late: SlashSource = { + trigger: '/', + name: 'late', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + warm, + lexicon: () => ['fresh'], + } + sources.push(late) + controller.sourceAdded(late) + expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') }) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + + it('a removed source leaves the aggregated lexicon', () => { + const src = lexSource('/', 'skill', ['gone']) + const { controller, sources } = controllerBench([src]) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone']) + sources.splice(sources.indexOf(src), 1) + controller.sourceRemoved(src) + expect(controller.lexicon.getSnapshot().size).toBe(0) + }) }) describe('arbitrate', () => { diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 3ad1543c68..4170f8e339 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void { // The list snapshot is always warm — the full running-children roster. return childLabels(session, '') }, + subscribeLexicon(_session, listener) { + // The roll derives from the list snapshot, so its change feed IS the list's. + return sessions.list.subscribe(listener) + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index fc74470406..d138295276 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } + const subs = new Set<() => void>() + return { + list: { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, + }, + notify: () => { for (const fn of [...subs]) fn() }, + listenerCount: () => subs.size, + } } -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { +/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ +async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() let captured: SlashSource | undefined + const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) + ctx.provide('sessions', face) await ctx.plugin({ inject: [...inject], apply }).await() - return captured! + return { source: captured!, face } +} + +/** Source-only bench for the behavior-contract suites. */ +async function bench(sessions: SessionSummary[]): Promise { + return (await fullBench(sessions)).source } const FAMILY: SessionSummary[] = [ @@ -113,6 +127,19 @@ describe('lexicon', () => { expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) expect(source.lexicon!(proj('childless'))).toEqual([]) }) + + it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => { + const { source, face } = await fullBench(FAMILY) + let notified = 0 + const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 }) + expect(face.listenerCount()).toBe(1) + face.notify() + expect(notified).toBe(1) + off() + expect(face.listenerCount()).toBe(0) + face.notify() + expect(notified).toBe(1) + }) }) describe('pick and codec', () => { From bce9910b47c54e33c5f9bdd74810a22c01fdd461 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:47 +0800 Subject: [PATCH 21/43] docs: regenerate cordis catalog line anchors --- docs/cordis-catalog/events.md | 8 ++++---- docs/event-producer-consumer.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 86a857cd17..ca0e9b82fc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-consume-token` — bail @@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-reference` — bail @@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-text` — bail @@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p 'slash/input-insert-text'(request: InsertTextRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts) ## `subagent/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 18ca96e1b3..3a72d5c0f9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | From 71529aa7d22d258095bd189f7c0437e821217d7f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:03:56 +0800 Subject: [PATCH 22/43] refactor(client): rename the host provide source to provideInfo --- packages/client/runtime/src/client/slots.ts | 2 +- packages/client/runtime/tests/slots-service.spec.ts | 2 +- packages/client/ui-slots/src/renderer.ts | 2 +- packages/client/web-react/src/session-provider.tsx | 4 ++-- .../client/web-react/tests/scoped-slots-real-core.spec.tsx | 2 +- packages/client/web-react/tests/scoped-slots.spec.tsx | 2 +- packages/client/web-react/tests/session-provider.spec.tsx | 4 ++-- packages/client/web-react/tests/stale-authorization.spec.tsx | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index ed10826b9d..413668b2f8 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -256,7 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - provide: sessions.currentProvide, + provideInfo: sessions.currentProvide, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index b7d6f31093..e9d6d3e12b 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -231,7 +231,7 @@ describe('host face', () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.provide.getSnapshot()).toMatchObject({ sessionId: undefined }) + expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 09143ca84e..4a437887d1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -112,7 +112,7 @@ export interface SlotRendererHost { * obsolete hook/prop schema. Carries the static roster with sessionId * undefined while no current session resolves. */ - provide: HostObservable + provideInfo: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 61212e57a3..a9679e460c 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,7 +90,7 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const info = observableHook(host.sessions.provide)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) return ( {children} @@ -115,7 +115,7 @@ export interface SessionProviderProps { */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const info = observableHook(host.sessions.provide)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) const id = info.sessionId if (id === undefined) return <>{empty?.() ?? null} return ( diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 09d38c187f..6649018b0b 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -36,7 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index dba09810f6..36b99a6ef8 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -126,7 +126,7 @@ function makeHost() { }, sessions: { list, - provide, + provideInfo: provide, }, workspaces: { list: workspaces }, } diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 1dacdcec53..c1f1f648a2 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -26,7 +26,7 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.provide, but it must + * Minimal host: SessionProvider only reads sessions.provideInfo, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ @@ -51,7 +51,7 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - provide, + provideInfo: provide, }, workspaces: { list: observable({ items: [] }) }, } diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index f0fa07fd44..df3bc51d2b 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -40,7 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, From b7f3cd3d789e531e2ee72659f928c7ffdb38aaf1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:55 +0800 Subject: [PATCH 23/43] refactor(client): rename currentProvide to currentProvideInfo --- .../runtime/src/client/sessions/service.ts | 28 +++++++++---------- packages/client/runtime/src/client/slots.ts | 2 +- .../runtime/tests/sessions-service.spec.ts | 24 ++++++++-------- .../runtime/tests/slots-service.spec.ts | 2 +- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 4 +-- .../tests/chat-toolview-slot.spec.tsx | 4 +-- .../tests/selection-survival.spec.ts | 2 +- 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 03eca7724f..b5ce1905eb 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -157,7 +157,7 @@ export class SessionsService { * host's `sessions.provide` feed), so a roster change under a stable * current id republishes the bundle instead of stranding mounted entries. */ - readonly currentProvide: HostObservable + readonly currentProvideInfo: HostObservable /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -174,10 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ private maybeInfo: SessionMaybeProvideInfo - /** Latest published {@link SessionsService.currentProvide} bundle (identity comparison dedupes republish). */ - private currentProvideSnapshot: SessionMaybeProvideInfo - /** currentProvide subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ - private readonly currentProvideListeners = new Set<() => void>() + /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */ + private currentProvideInfoSnapshot: SessionMaybeProvideInfo + /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ + private readonly currentProvideInfoListeners = new Set<() => void>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -221,12 +221,12 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) this.maybeInfo = this.materializeMaybeProvideInfo() - this.currentProvideSnapshot = this.maybeInfo - this.currentProvide = { - getSnapshot: () => this.currentProvideSnapshot, + this.currentProvideInfoSnapshot = this.maybeInfo + this.currentProvideInfo = { + getSnapshot: () => this.currentProvideInfoSnapshot, subscribe: (fn) => { - this.currentProvideListeners.add(fn) - return () => { this.currentProvideListeners.delete(fn) } + this.currentProvideInfoListeners.add(fn) + return () => { this.currentProvideInfoListeners.delete(fn) } }, } rootCtx.reflect.provide('sessions', this, undefined) @@ -272,9 +272,9 @@ export class SessionsService { */ private projectCurrentProvide(): void { const next = this.maybeProvideInfo(this.list.getSnapshot().current) - if (next === this.currentProvideSnapshot) return - this.currentProvideSnapshot = next - for (const fn of [...this.currentProvideListeners]) fn() + if (next === this.currentProvideInfoSnapshot) return + this.currentProvideInfoSnapshot = next + for (const fn of [...this.currentProvideInfoListeners]) fn() } /** Build the static no-session kit and reject duplicate declared names. */ @@ -443,7 +443,7 @@ export class SessionsService { /** * Resolve one session's render-layer standard-props bundle (ctx never * enters the render layer; the renderer subscribes to - * {@link SessionsService.currentProvide}). Pure resolution — render-safe: + * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: * no staging, no window side effects (StrictMode double-invokes and * concurrent discarded passes must stay free). * @param id - session id. diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 413668b2f8..d18377e052 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -256,7 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - provideInfo: sessions.currentProvide, + provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 2f90c1c301..b97dac3d78 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -195,55 +195,55 @@ describe('cell (render-layer session kit)', () => { expect(b.svc.provideInfo('ghost')).toBeUndefined() }) - it('currentProvide follows selection: absent projection ↔ definite bundle, notified on each move', async () => { + it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) - const absent = b.svc.currentProvide.getSnapshot() + const absent = b.svc.currentProvideInfo.getSnapshot() expect(absent.sessionId).toBeUndefined() expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) const notified = vi.fn() - b.svc.currentProvide.subscribe(notified) + b.svc.currentProvideInfo.subscribe(notified) b.svc.open(sid('s1')) - expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1')) expect(notified).toHaveBeenCalledTimes(1) b.svc.open(sid('s2')) - expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s2')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2')) expect(notified).toHaveBeenCalledTimes(2) b.svc.clear() await Promise.resolve() // clearSelection projects through the manager notifier - expect(b.svc.currentProvide.getSnapshot().sessionId).toBeUndefined() + expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined() }) it('a provider roster change under a stable current id republishes the bundle', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) - const before = b.svc.currentProvide.getSnapshot() + const before = b.svc.currentProvideInfo.getSnapshot() const notified = vi.fn() - b.svc.currentProvide.subscribe(notified) + b.svc.currentProvideInfo.subscribe(notified) const source = { getSnapshot: () => 'live', subscribe: () => () => {} } const dispose = b.svc.provide({ hooks: ['extra'], props: ['marker'], resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), }) - const added = b.svc.currentProvide.getSnapshot() + const added = b.svc.currentProvideInfo.getSnapshot() expect(added).not.toBe(before) expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) expect(added.hooks['extra']).toBe(source) expect(notified).toHaveBeenCalledTimes(1) dispose() - const removed = b.svc.currentProvide.getSnapshot() + const removed = b.svc.currentProvideInfo.getSnapshot() expect(removed).not.toBe(added) expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) expect(notified).toHaveBeenCalledTimes(2) }) - it('an unsubscribed currentProvide listener stops receiving notifications', async () => { + it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) const notified = vi.fn() - const off = b.svc.currentProvide.subscribe(notified) + const off = b.svc.currentProvideInfo.subscribe(notified) off() b.svc.open(sid('s1')) expect(notified).not.toHaveBeenCalled() diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index e9d6d3e12b..07e03b6e9d 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -103,7 +103,7 @@ function fakeSessions() { const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 37b824c906..885cdd2523 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -93,7 +93,7 @@ async function bench() { binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 818a6bf620..dab04e94c7 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -38,7 +38,7 @@ async function bench() { binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index d7f523b028..5c40f282db 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,7 +87,7 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } - // Identity-stable currentProvide snapshot (uSES getSnapshot contract), + // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), // materialized on first render after the provide contributions landed. let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { @@ -106,7 +106,7 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - currentProvide: { + currentProvideInfo: { getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, subscribe: () => () => {}, }, diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 117a7108c9..ec713d4bb1 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -111,7 +111,7 @@ async function bench(nodes: ToolResultNode[]) { binding: bindingOf, scope: () => actxFake, provideInfo, - currentProvide: { + currentProvideInfo: { getSnapshot: () => provideInfo(SID), subscribe: () => () => {}, }, @@ -255,7 +255,7 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - currentProvide: { + currentProvideInfo: { getSnapshot: () => ABSENT_INFO, subscribe: () => () => {}, }, diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index 19988eeab9..533d5ce730 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -26,7 +26,7 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - currentProvide: { + currentProvideInfo: { getSnapshot: () => ABSENT_INFO, subscribe: () => () => {}, }, From b5168bbf865b827050dae04e1c6fa049eda9c8bd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:08:24 +0800 Subject: [PATCH 24/43] feat(ui-slots): bind inject hooks compartments into use selector hooks Registrant-private reactive facts previously reached components as raw observables that each component subscribed by hand (InputBar notices/ lexicon via uSES, SettingsRoot via a version/subscribe/getter triple). The inject face now carries a reserved hooks compartment of bare sources; the renderer binds each into a use selector hook through the same machinery as the provide channel, so components consume useNotices/useLexicon/useSections and never see a subscription primitive. InputBar and SettingsRoot are the first two consumers. --- ...2-slot-type-chain-implementation.i18n.yaml | 6 +-- ...26-07-22-slot-type-chain-implementation.md | 8 ++-- ...07-22-slot-type-chain-implementation.zh.md | 8 ++-- packages/client/AGENTS.md | 4 +- .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/contract/slots.ts | 17 ++++++--- .../src/client/input/contract.ts | 6 +-- .../src/client/skeleton/InputBar.tsx | 19 +++------- .../ui-conversation/tests/input-bar.spec.tsx | 2 + .../tests/input-matrix.spec.tsx | 2 + .../tests/input-scenarios.spec.tsx | 2 + .../ui-conversation/tests/skeleton.spec.tsx | 2 + .../ui-settings/src/client/SettingsRoot.tsx | 14 +++---- .../ui-settings/src/client/contract/slots.ts | 30 +++++++++------ .../client/ui-settings/src/client/index.ts | 38 +++++++++++++------ .../client/ui-settings/tests/apply.spec.ts | 13 ++++--- .../ui-settings/tests/settings-root.spec.tsx | 19 ++++++---- packages/client/ui-slots/src/index.ts | 36 ++++++++++++++++-- .../client/web-react/src/scoped-slots.tsx | 25 ++++++++++-- .../web-react/tests/scoped-slots.spec.tsx | 19 ++++++++++ 20 files changed, 185 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index eacbd89847..1604914ac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 -2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 +2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 617524475f..e88361701f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | -| business | `I` | inject return type | plain data + callbacks (hooks banned) | +| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use` selector hooks (`InjectFace`) | `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API. @@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one ### inject: the registrant's business face, on its own ctx -An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. +An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. ### Data-boundary discipline -Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. +Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. ### Tree context and the renderer seam @@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a | Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks | | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything | | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn | -| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine | +| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery | | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation | | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact | | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 52edea30ac..90473861c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -45,7 +45,7 @@ ctx.slots.register({ | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | -| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use` 选择器 hook 到达(`InjectFace`) | 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 @@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会 ### inject:注册方的业务面,立足自己的 ctx -inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 +inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 ### 数据界线纪律 -hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 +hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 ### 树上语境与渲染器安装缝 @@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 | 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | -| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 | +| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 | | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3728641560..0fd9e71f01 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -11,10 +11,10 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. -4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) +4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). -7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. ## Export discipline (client plugin packages) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 789f86aeb6..62801ca0b8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -135,13 +135,15 @@ export function apply(ctx: Context): void { 'conversation.input.model': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId): ComposerBarInjected => { + const shell = inputHub.shell(sessionId) return { - keyboard: inputHub.keyboard(sessionId), + keyboard: shell, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, }, InputBar) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..7a99323826 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,11 +1,11 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -220,6 +220,13 @@ export interface ComposerBarInjected { keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ stop: () => void + /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */ + hooks: { + /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ + notices: ObservableSnapshot + /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ + lexicon: ObservableSnapshot> + } } /** @@ -231,11 +238,11 @@ export interface InputControlOwnerProps { locked: boolean } -/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */ +/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> - & ComposerBarInjected + & InjectFace /** * Composer chain currency: what ConversationRoot dispatches at its diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index c229abf18c..4454662cd6 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -5,7 +5,7 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -77,8 +77,6 @@ export interface InputNotice { * satisfies it structurally. */ export interface ComposerKeyboard { - /** Latest surfaced notice store (null after none). */ - readonly notices: SnapshotStore /** Live machine state for event-handler reads (render reads go through useInput). */ readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ @@ -99,8 +97,6 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void - /** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */ - readonly lexicon: ObservableSnapshot> } /** One queued-message row projected from the session/queued frames (T9 supplies the store). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa224d1ec1..a844c18a72 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,11 +1,12 @@ /** The default composer body: the 'conversation.composer.bar' slot entry * (decision 20). Machine state arrives through the standard provide channel * (useInput + inputActions); the keyboard/DOM command face and stop arrive - * through this entry's own inject; layout-phase inputs (variant, placeholder, + * through this entry's own inject, whose hooks compartment binds + * useNotices/useLexicon; layout-phase inputs (variant, placeholder, * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,20 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ ] export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, + useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) - const noticeStore = keyboard.notices - const notice = useSyncExternalStore( - (fn: () => void) => noticeStore.subscribe(fn), - () => noticeStore.getSnapshot(), - ) - const lexiconStore = keyboard.lexicon - const lexicon = useSyncExternalStore( - (fn: () => void) => lexiconStore.subscribe(fn), - () => lexiconStore.getSnapshot(), - ) + const notice = useNotices(s => s) + const lexicon = useLexicon(s => s) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index ab62af80bb..4999125c62 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -91,6 +91,8 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), stop, renderSlot, variant: over?.variant ?? 'composer', diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 284ef6c76a..f21f124b78 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 414f3c15b4..826405f2be 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b777c85ac3..27c635a1f0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -118,6 +118,8 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + useNotices={bindSnapshotSelector(wiring.notices)} + useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 22946e799a..c3480e1d18 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -7,10 +7,10 @@ * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state. */ -import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SettingsRootComponentProps } from './contract/slots.ts' +import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ @@ -20,7 +20,7 @@ function navIcon(id: string) { } type PanelProps = { - rows: ReturnType + rows: readonly SettingsSectionRow[] renderSlot: SettingsRootComponentProps['renderSlot'] onClose: () => void } @@ -92,18 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { * @returns the settings shell element tree. */ export function SettingsRoot(props: SettingsRootComponentProps) { - const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props + const { wide, useSections, renderSlot } = props const [open, setOpen] = useState(false) const close = useCallback(() => { setOpen(false) }, []) // The ledger tick keeps the nav rows fresh: registrants re-register with // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. - // uSES over the ledger version: same-version notifications dedupe to no - // render, and a registration landing between render and effect - // subscription cannot be missed. - useSyncExternalStore(subscribeSections, sectionsVersion) - const rows = sections() + const rows = useSections(s => s) return ( <> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 1a263108bc..c20a041858 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -7,7 +7,7 @@ * setting never means editing the shell; copy that belongs to no single * feature (chrome, the General section) is owned by ui-settings-general. */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) // into every program that sees this contract. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps { children?: never } +/** One nav row projected from a settings.section registration's options. */ +export interface SettingsSectionRow { + id: string + order: number + label: string +} + /** * Registrant-private injected share of the settings shell (assembled in - * apply): ledger projections only — the shell reads no locale state. + * apply): the ledger's nav-row projection as a hooks-compartment source — + * the shell reads no locale state and subscribes through the bound hook. */ export type SettingsRootInjected = { - /** Read the settings.section ledger version (nav invalidation). */ - sectionsVersion: () => number - /** Subscribe to settings.section ledger changes. */ - subscribeSections: (listener: () => void) => () => void - /** Project the settings.section ledger into nav rows (id/order/label). */ - sections: () => readonly { id: string; order: number; label: string }[] + hooks: { + /** settings.section ledger projected into ordered nav rows. */ + sections: HostObservable + } } /** * Full component props of the settings shell root: the sidebar owner share - * (wide/rail state) plus the declared render shares and the injected face. - * No store is registered — modal open state and active section id are - * component-local viewing state. + * (wide/rail state) plus the declared render shares and the injected face + * (hooks compartment bound to useSections). No store is registered — modal + * open state and active section id are component-local viewing state. */ export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'> - & SettingsRootInjected + & InjectFace diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 7cb3dfd6d4..f858be9c37 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -10,12 +10,12 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsRootInjected } from './contract/slots.ts' +import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' export type { SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsSectionOwnerProps, SettingsTriggerOwnerProps, + SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps, } from './contract/slots.ts' /** @@ -32,17 +32,31 @@ export const inject = ['slots'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + // Ledger → nav-row projection as an observable source (uSES contract: + // getSnapshot returns the cached rows until the ledger version moves). + let rowsVersion = -1 + let rows: readonly SettingsSectionRow[] = [] const injected = (): SettingsRootInjected => ({ - sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), - sections: () => ctx.slots.entries('settings.section') - .map(e => ({ - /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ - id: e.options.id ?? '', - order: e.options.order ?? 0, - label: e.options.label ?? '', - })) - .sort((a, b) => a.order - b.order), + hooks: { + sections: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.section') + if (version !== rowsVersion) { + rowsVersion = version + rows = ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) + .sort((a, b) => a.order - b.order) + } + return rows + }, + subscribe: listener => ctx.slots.subscribe('settings.section', listener), + }, + }, }) ctx.effect(() => { const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index d7fdfbd546..caec65f3f5 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -60,22 +60,25 @@ describe('ui-settings apply', () => { const b = await bench() declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots) + const { sections } = injectedOf(b.slots).hooks // The shell ships no sections of its own — registrants fill the ledger. - expect(injected.sections()).toEqual([]) + expect(sections.getSnapshot()).toEqual([]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) // No order and no label: both projection defaults apply. b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) - expect(injected.sections()).toEqual([ + const rows = sections.getSnapshot() + expect(rows).toEqual([ { id: 'a', order: 0, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) - expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + // Snapshot identity is stable until the ledger moves (uSES contract). + expect(sections.getSnapshot()).toBe(rows) const listener = vi.fn() - const off = injected.subscribeSections(listener) + const off = sections.subscribe(listener) b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() + expect(sections.getSnapshot()).not.toBe(rows) off() }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 9584d500e5..dd340dc2ea 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' @@ -22,9 +23,9 @@ function mount({ { id: 'models', order: 10, label: 'Models' }, ], }: { wide?: boolean; rows?: Row[] } = {}) { - // Mutable row store standing in for the ledger; bump() plays a change. + // Mutable row source standing in for the bound useSections hook; bump() + // plays a ledger change through the same observable contract. let current = rows - let version = 0 const listeners = new Set<() => void>() const renderSlot = vi.fn( ((key: string, _owner: unknown, opts?: { only?: string }) => { @@ -38,19 +39,21 @@ function mount({ useSessions: unusedHook, useWorkspaces: unusedHook, wide, - sectionsVersion: () => version, - subscribeSections: (listener) => { - listeners.add(listener) - return () => { listeners.delete(listener) } + useSections: (select) => { + const [, force] = useState(0) + useEffect(() => { + const listener = () => { force(n => n + 1) } + listeners.add(listener) + return () => { listeners.delete(listener) } + }, []) + return select(current) }, - sections: () => current, renderSlot, } const view = render() const bump = (next: Row[]) => { act(() => { current = next - version += 1 for (const fn of [...listeners]) fn() }) } diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 1f30f7027b..7b980571ef 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -14,6 +14,7 @@ * consumer merges keys in and the intersection is what keeps them string-typed. * The rule fires on the empty-map view, not on real redundancy. */ import type { ReactNode } from 'react' +import type { HostObservable } from './renderer.ts' import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts' export * from './store.ts' @@ -214,11 +215,40 @@ export type PropsRenderSlots = { */ export type SlotComponent

= (props: P) => ReactNode +/** + * Registrant hooks compartment: bare observable sources (getSnapshot + + * subscribe pairs) supplied under the reserved `hooks` key of an inject + * face. The registrant-private twin of the `sessions.provide` hooks + * compartment: the renderer binds each source into a `use` selector + * hook, so the sources never reach the component and plugin-private reactive + * facts ride the same subscription machinery as the standard kit instead of + * hand-rolled component subscriptions. + */ +export type HooksSources = Record> + +/** + * Selector-hook share synthesized from a hooks compartment: each source + * `name` becomes a `use` selector hook over its snapshot type. + */ +export type PropsHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + SnapshotSelectorHook ? T : never> +} + +/** + * The component-side view of an inject face: the reserved `hooks` + * compartment (when declared) arrives as bound `use` selector hooks; + * every other member passes through verbatim. + */ +export type InjectFace = + I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I + /** * The four-share component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + - * the registrant's injected business face. Each share derives from its single - * source of truth; components reference this composition, never re-type it. + * the registrant's injected business face (its hooks compartment bound, see + * {@link InjectFace}). Each share derives from its single source of truth; + * components reference this composition, never re-type it. */ export type ComposedProps< K extends keyof SlotMap & string, @@ -226,7 +256,7 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3ef01d7390..5cea31cd6c 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,8 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo, - type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, + type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined const args: unknown[] = [] if (info !== undefined) args.push(info.sessionId) if (actions !== undefined) args.push(actions) - return (inject as (...args: unknown[]) => InjectedProps)(...args) + return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args)) +} + +/** + * Bind an inject face's reserved `hooks` compartment (bare observable + * sources, see HooksSources) into `use` selector hooks — the + * registrant-private twin of the provide-bundle binding in standardKit. + * Runs once per cached inject result; hook identity rides observableHook's + * per-source cache. + */ +function bindInjectHooks(face: InjectedProps): InjectedProps { + const sources = face['hooks'] + if (sources === undefined) return face + const { hooks: _hooks, ...rest } = face + const bound: InjectedProps = rest + for (const [name, source] of Object.entries(sources as Record>)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + bound[hookName] = observableHook(source) + } + return bound } function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 36b99a6ef8..971a6ad060 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -748,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', () expect(inject).toHaveBeenCalledWith() }) + it('binds the inject hooks compartment into use selector hooks (sources never reach the component)', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + const badge = observable('cold') + const seen: Record[] = [] + h.add('k.single', { + component: (props: { useBadge?: (sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => { + seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) }) + return null + }, + inject: () => ({ plain: 'kept', hooks: { badge } }), + }) + mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {})) + // The raw compartment is consumed by the binding; the plain member passes through. + expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' }) + act(() => { badge.set('hot') }) + expect(seen.at(-1)!['read']).toBe('hot') + }) + it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) From 0a625b1144bd5affebda2dcd486d5b73f1fca4cf Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:18:18 +0800 Subject: [PATCH 25/43] fix(web-ui): header title 14/20, drop turns counter, global grayscale antialiasing --- .../src/client/skeleton/ConversationRoot.module.css | 11 ++--------- .../src/client/skeleton/ConversationSession.tsx | 8 -------- packages/client/web/src/base.css | 4 ++++ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 6c18b8c1a5..de974da86c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -54,8 +54,8 @@ border: none; border-radius: 12px; background: transparent; - font-size: 13px; - line-height: 16px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; @@ -72,13 +72,6 @@ cursor: default; } -.meta { - margin-left: 4px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { display: flex; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 45d98c1693..0c1addcba7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -31,7 +31,6 @@ export function ConversationSession({ const activeId = useStore(s => s.view) ?? 'chat' const active = tabs.find(view => view.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const turns = useSession(s => countTurns(s)) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) @@ -69,7 +68,6 @@ export function ConversationSession({ ) })} {ancestry.length === 0 && {sessionId}} - · {turns} turns

{tabs.length > 1 && ( @@ -95,9 +93,3 @@ export function ConversationSession({ ) } - -function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number { - let count = 0 - for (const node of snapshot.nodes) if (node.kind === 'user') count += 1 - return count -} diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index b8449634eb..03593fa829 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -15,6 +15,10 @@ body, body { font-family: var(--dsw-font-family); + /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the + Firefox macOS equivalent; other engines ignore both lines. */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); } From 8eb1a6f66b941f4025115a4727ce3c1875afab20 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:25:32 +0800 Subject: [PATCH 26/43] fix(web-ui): narrow flow item before reading group results in chat-view spec --- packages/client/ui-conversation/tests/chat-view.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f9028bd386..1754397eba 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -141,7 +141,8 @@ describe('chat-flow derivation', () => { } const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) expect(flowKeys(items)).toBe('g3') - expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b']) + const group = items[0]! + expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) // Interrupted and visible-content nodes still render (已停止 marker / prose). expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') From 3134f2d4301b120ad20bc7581f25a74d923f3ee6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:36:29 +0800 Subject: [PATCH 27/43] doc: agent note for the web conversation polish sweep --- ...28-web-conversation-polish-sweep.i18n.yaml | 6 +++ ...026-07-28-web-conversation-polish-sweep.md | 37 +++++++++++++++++++ ...-07-28-web-conversation-polish-sweep.zh.md | 37 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..3df46dedf2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md +2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a +2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md new file mode 100644 index 0000000000..cae52217d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md) + +## Problem + +A design review of the web GUI's conversation surfaces found a batch of presentation defects: portal menus painted one frame at the wrong position before repositioning (visible open jump), the chat column split one tool run into several groups whenever a step message carried only tool-call heads, tool row summaries printed workspace-absolute paths that consumed most of the row, the running-row sweep was implemented as an alpha mask that dimmed the whole row, the hero workspace chip resurrected a deleted workspace's folder name from the session cwd, and the header showed a turns counter nobody asked for next to a 13px title. + +## Decision + +The sweep lands as presentation-layer changes only; nothing enters the session log. + +- **Portal menus pre-render hidden and measure before paint.** The menu list mounts with `visibility: hidden` at (0,0), measures in `useLayoutEffect`, and becomes visible already at its final position. Menus keep 12px viewport clearance with internal scroll; workspace create actions pin in a non-scrolling footer. +- **The chat flow skips assistant nodes that render nothing.** A finalized assistant node whose blocks are only tool-call heads and blank text/reasoning is dropped from the flow derivation, so consecutive tool results merge into one group. Interrupted nodes always render (they carry the 已停止 marker). +- **Tool row summaries relativize workspace-rooted paths.** The session cwd threads through the toolview slot contract (`ToolRowOwnerProps.cwd`) and `toolRowModel` strips it from summaries that start with it; paths outside the workspace stay verbatim. Display-only — args and the log are untouched. +- **The running sweep is a glare-band overlay.** A fixed-width `::after` gradient band animates across the row (the deepsuite ShimmerText pattern), replacing the previous `mask-image` approach, in both ToolRow and the Bash toolview. +- **The hero workspace chip is a selector, not an echo.** With no live selection (cold start, or the workspace was deleted after the list settled) it shows a "Choose workspace" placeholder; the cwd-derived name only bridges the initial list load, and stale pending picks clear when their workspace leaves a ready list. +- **One 16px vertical rhythm.** The chat column gap and in-group tool-row gap are both 16px, replacing the 10px in-group gap plus a negative cross-group margin. +- **Header title reads 14/20 with no turns counter**; StateDot ongoing and the turn tail use a stepped pixel-chase loading language; `body` gets grayscale antialiasing (`-webkit-font-smoothing` and the Firefox macOS equivalent). + +## Alternatives considered + +- **Position menus synchronously from anchor rects before mount.** Rejected: the list's own size is unknown until it lays out, so clamping to the viewport still needs a post-layout measure; measuring a hidden mounted node is the pattern React and Floating UI document. +- **Filter empty assistant messages host-side.** Rejected: the node is real model output that Trajectory and replay must keep; only the chat presentation should skip it, and the web layer is pure presentation by contract. +- **Relativize paths in each tool's presenter.** Rejected: the redundancy is shared by every path-summarizing tool; one display-only pass in `toolRowModel` covers them all and non-chat consumers keep absolute paths. +- **Keep the mask-based sweep.** Rejected: the mask dims the entire row content including state dots, and its exit transition fought the hover icon crossfade; an overlay band composites above the content without touching its alpha. +- **Keep showing the deleted workspace's name in the chip.** Rejected: the chip is the selector for the *next* session; echoing a cwd whose workspace the user just deleted misrepresents the current pick. + +## Consequences + +Chat renders fewer flow items than the snapshot has nodes: anyone counting rendered blocks against nodes must account for skipped render-nothing assistants (the chat-view spec pins this). The path relativization is a prefix check against the session cwd, so a workspace rename mid-session shows absolute paths until the summary re-derives — accepted as display-only staleness. The uniform 16px rhythm retires the tighter 10px tool-run look; a future denser layout would reintroduce a second constant deliberately. The menu pre-render adds one hidden layout pass per open, negligible at menu sizes. + +## Testing + +`chat-view.spec.tsx` pins the render-nothing grouping (including the interrupted exception); `chat-tool-row.spec.tsx` pins cwd relativization inside/outside the workspace and with an empty cwd; `atoms.spec.tsx` and `workspace-picker.spec.tsx` cover the menu and chip states; the full ui-conversation, ui-primitives, and ui-workspace suites pass. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md new file mode 100644 index 0000000000..d19a5f7593 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +[English](2026-07-28-web-conversation-polish-sweep.md) | 中文 + +## Problem + +一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。 + +## Decision + +本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。 + +- **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。 +- **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。 +- **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。 +- **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。 +- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。 +- **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。 +- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。 + +## Alternatives considered + +- **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。 +- **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。 +- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。 +- **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。 +- **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。 + +## Consequences + +聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。 + +## Testing + +`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。 From 2b74db670efbbbe7e84b763e263ca4f3b6a52c4e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:06:08 +0800 Subject: [PATCH 28/43] refactor(client): rename the provide reprojection to updateCurrentProvideInfo and privatize the id resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provideInfo(id)/maybeProvideInfo(id) lost their last external caller when the renderer host switched to the currentProvideInfo observable; both become private (tests assert through the public projection). The reprojection method's name now says what it does — re-derive and publish on change — and matches the field family it maintains. --- packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 ++++++------- .../runtime/tests/sessions-service.spec.ts | 32 +++++++++++-------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..16c1124ec8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b5ce1905eb..759c320bde 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -212,7 +212,7 @@ export class SessionsService { // The current-provide projection follows the same current writes. this.list.subscribe(() => { this.followCurrent() - this.projectCurrentProvide() + this.updateCurrentProvideInfo() }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). @@ -261,16 +261,17 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } - this.projectCurrentProvide() + this.updateCurrentProvideInfo() } /** - * Publish the current selection's provide bundle when it changed. Bundles - * are identity-stable per (scope, roster) materialization, so an identity - * compare is exact; synchronous notify — both call sites (list.subscribe, - * provide()) already sit behind their own batching or registration edges. + * Re-derive the current selection's provide bundle and publish it when it + * changed. Bundles are identity-stable per (scope, roster) + * materialization, so an identity compare is exact; synchronous notify — + * both call sites (list.subscribe, provide()) already sit behind their own + * batching or registration edges. */ - private projectCurrentProvide(): void { + private updateCurrentProvideInfo(): void { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next @@ -446,20 +447,16 @@ export class SessionsService { * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: * no staging, no window side effects (StrictMode double-invokes and * concurrent discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. */ - provideInfo(id: string): SessionProvideInfo | undefined { + private provideInfo(id: string): SessionProvideInfo | undefined { return this.resolve(id as SessionId)?.provideInfo } /** * Resolve the current-session-optional standard kit. Unknown or absent ids * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index b97dac3d78..45539d3b99 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -79,7 +79,8 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) + b.svc.open(sid('s1')) + expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session']) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -183,16 +184,17 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) describe('cell (render-layer session kit)', () => { - it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { + it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - const info = b.svc.provideInfo('s1') - expect(info).toBeDefined() - expect(info?.sessionId).toBe('s1') + b.svc.open(sid('s1')) + const info = b.svc.currentProvideInfo.getSnapshot() + expect(info.sessionId).toBe('s1') // The bundle carries bare observables; hook binding happens in React. - expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.provideInfo('s1')).toBe(info) - expect(b.svc.provideInfo('ghost')).toBeUndefined() + expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + // Re-staging the same id republishes nothing: identity holds. + b.svc.open(sid('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info) }) it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { @@ -204,10 +206,14 @@ describe('cell (render-layer session kit)', () => { const notified = vi.fn() b.svc.currentProvideInfo.subscribe(notified) b.svc.open(sid('s1')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1')) + const s1Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s1Bundle.sessionId).toBe('s1') + expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) expect(notified).toHaveBeenCalledTimes(1) b.svc.open(sid('s2')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2')) + const s2Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s2Bundle.sessionId).toBe('s2') + expect(s2Bundle).not.toBe(s1Bundle) expect(notified).toHaveBeenCalledTimes(2) b.svc.clear() await Promise.resolve() // clearSelection projects through the manager notifier @@ -249,12 +255,11 @@ describe('cell (render-layer session kit)', () => { expect(notified).not.toHaveBeenCalled() }) - it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { + it('binding() is pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.provideInfo('s2') // resolution only — must NOT move the stage - b.svc.binding(sid('s2')) + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() }) @@ -265,7 +270,6 @@ describe('cell (render-layer session kit)', () => { const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) From f331f248d88762ead77e42dae721877f123506f8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:16:14 +0800 Subject: [PATCH 29/43] fix: static --- packages/client/runtime/README.i18n.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3fa9c934f3..4629e67d59 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 +README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 From d833be412afa0f091d9f206140fc095847681198 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:37 +0800 Subject: [PATCH 30/43] fix(client): contain notification-callback failures and document the source lifecycle Review follow-ups: the three new notify loops (currentProvideInfo subscribers, ui-skill lexicon listeners, late-registration controller setup) now contain per-callback failures so one faulty consumer cannot starve the rest, abort the list projection pass, or poison the source roster with no disposer; controller lexicon polling drops a throwing source with a console record like the candidate path. The ui-slash README (both languages) now states the late-registration warm and the subscribeLexicon contract, and the scenario suite drives a typed /name token gaining its decoration when the roll settles with no further input. --- .../runtime/src/client/sessions/service.ts | 11 ++++++- .../tests/input-scenarios.spec.tsx | 29 +++++++++++++++++++ packages/client/ui-skill/src/client/index.ts | 11 ++++++- packages/client/ui-slash/README.i18n.yaml | 6 ++-- packages/client/ui-slash/README.md | 2 +- packages/client/ui-slash/README.zh.md | 2 +- .../client/ui-slash/src/client/controller.ts | 11 ++++++- .../client/ui-slash/src/client/service.ts | 11 ++++++- 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 759c320bde..9754c87345 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -275,7 +275,16 @@ export class SessionsService { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next - for (const fn of [...this.currentProvideInfoListeners]) fn() + for (const fn of [...this.currentProvideInfoListeners]) { + try { + fn() + } catch (error) { + // Contain subscriber failures: this notify runs inside the list + // notification, where a throwing render-side subscriber would starve + // later listeners and abort the projection pass that scheduled it. + console.error('sessions.currentProvideInfo subscriber failed:', error) + } + } } /** Build the static no-session kit and reject duplicate declared names. */ diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 826405f2be..807650169b 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -236,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => { }) }) +describe('scenario: reference decoration lights up when the lexicon settles', () => { + it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => { + let roll: readonly string[] | undefined + let notify: (() => void) | undefined + const b = await scopedBench((slash) => { + slash.registerSource({ + trigger: '/', name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => { + notify = listener + return () => { notify = undefined } + }, + } as never) + }) + // Typed before the catalog settled: a plain token, no decoration. + b.type('/deploy now') + expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull() + // The catalog settles (ui-skill's settle path fires the same notification). + act(() => { + roll = ['deploy'] + notify?.() + }) + const mark = b.view.container.querySelector('[data-decoration="text-ref"]') + expect(mark?.textContent).toBe('/deploy') + }) +}) + describe('scenario I: unknown /xyz + enter', () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => { const b = await bench() diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7226f45163..d34c6ba96d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -48,7 +48,16 @@ export function apply(ctx: ClientContext): void { const lexiconListeners = new Map void>>() const notifyLexicon = (sessionId: SessionId): void => { - for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener() + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) { + try { + listener() + } catch (error) { + // Contain listener failures: settlement notifies from an ignored + // promise chain (a throw would surface as an unhandled rejection) + // and one faulty consumer must not starve the others. + console.error('[ui-skill] lexicon listener failed:', error) + } + } } const fetchCatalog = (sessionId: SessionId): Promise => { diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..1053e205b0 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d +README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..4e363c2682 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..76d39673cb 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 9e95dbdd41..ab0b26da54 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -290,7 +290,16 @@ export class SlashController { const rolls = new Map() for (const src of this.deps.roster.all()) { if (src.lexicon === undefined) continue - const names = src.lexicon(projection) + let names: readonly string[] | undefined + try { + names = src.lexicon(projection) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); the refresh runs inside + // notification callbacks, where a throw would starve other consumers. + console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error) + continue + } if (names === undefined) continue const prev = rolls.get(src.trigger) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 0ca3b91c2a..c47d44c3d4 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -50,7 +50,16 @@ export class SlashService extends Service implements SlashServiceContract { throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) } live.sources.push(src) - for (const controller of live.controllers.values()) controller.sourceAdded(src) + for (const controller of live.controllers.values()) { + try { + controller.sourceAdded(src) + } catch (error) { + // Contain faulty source callbacks (warm/subscribeLexicon): the + // registration must stand with a usable disposer and the remaining + // controllers must still be notified. + console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error) + } + } return () => { const at = live.sources.indexOf(src) if (at < 0) return From 2665e55e5d3437ce5013fe1e49a6698bd63c6eb3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:36:16 +0800 Subject: [PATCH 31/43] docs(runtime): align the zh README resolution sentence with the privatized resolvers --- packages/client/runtime/README.i18n.yaml | 2 +- packages/client/runtime/README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 4629e67d59..32d4fdf417 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc -README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 +README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cbbf6eded4..a3d2a2dfdd 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 已知限制与暂缓事项 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 -- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 +- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 From cee0666a4d3e7859cb8d6e11508194bbdea27cc3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 14:41:51 +0800 Subject: [PATCH 32/43] refactor(session): remove synthetic log-only turns --- ...6-06-15-turn-enclosure-invariant.i18n.yaml | 4 +- .../2026-06-15-turn-enclosure-invariant.md | 1 + .../2026-06-15-turn-enclosure-invariant.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + ...xt-injection-from-turn-execution.i18n.yaml | 4 +- ...e-context-injection-from-turn-execution.md | 4 +- ...ontext-injection-from-turn-execution.zh.md | 4 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 6 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- ...026-06-30-session-store-fork-api.i18n.yaml | 6 +- .../2026-06-30-session-store-fork-api.md | 6 +- .../2026-06-30-session-store-fork-api.zh.md | 6 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...-07-21-log-backed-session-titles.i18n.yaml | 6 +- .../2026-07-21-log-backed-session-titles.md | 12 +- ...2026-07-21-log-backed-session-titles.zh.md | 12 +- ...-remove-synthetic-log-only-turns.i18n.yaml | 6 + ...6-07-28-remove-synthetic-log-only-turns.md | 43 ++++ ...7-28-remove-synthetic-log-only-turns.zh.md | 43 ++++ ...06-20-truncate-interrupted-turns.i18n.yaml | 6 +- .../2026-06-20-truncate-interrupted-turns.md | 2 +- ...026-06-20-truncate-interrupted-turns.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 35 +-- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/core.zh.md | 4 +- .../persistence.i18n.yaml | 6 +- docs/core-data-structures/persistence.md | 2 +- docs/core-data-structures/persistence.zh.md | 2 +- .../session-title.i18n.yaml | 6 +- docs/core-data-structures/session-title.md | 2 +- docs/core-data-structures/session-title.zh.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 28 +-- docs/core-data-structures/session.zh.md | 28 +-- docs/persistence-catalog.md | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/session/README.i18n.yaml | 6 +- packages/core/session/README.md | 9 +- packages/core/session/README.zh.md | 9 +- packages/core/session/src/index.ts | 110 ++------- packages/core/session/src/invariant.ts | 13 +- packages/core/session/src/types.ts | 14 -- packages/core/session/tests/fork.spec.ts | 23 +- packages/core/session/tests/invariant.spec.ts | 10 +- .../core/session/tests/out-of-band.spec.ts | 226 ------------------ packages/core/tools/src/code-mode.ts | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 6 +- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/README.zh.md | 2 +- .../sandbox/sandbox-policy/README.i18n.yaml | 6 +- packages/sandbox/sandbox-policy/README.md | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 16 +- .../session-title-llm/README.i18n.yaml | 6 +- .../session-title/session-title-llm/README.md | 2 +- .../session-title-llm/README.zh.md | 2 +- .../session-title-llm/src/index.ts | 9 +- .../session-title/README.i18n.yaml | 6 +- .../session-title/session-title/README.md | 4 +- .../session-title/session-title/README.zh.md | 4 +- .../session-title/session-title/src/index.ts | 80 ++----- .../session-title/src/invariant.ts | 5 +- .../session-title/tests/persistence.spec.ts | 7 +- .../tests/service-contracts.spec.ts | 207 ++-------------- .../subagent-inprocess/README.i18n.yaml | 6 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 2 +- .../telemetry/session-telemetry/README.zh.md | 2 +- packages/ui/jsonrpc/README.i18n.yaml | 6 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/README.zh.md | 2 +- scripts/gen-cordis-catalog.ts | 1 - scripts/type-equiv.manifest.json | 5 - 83 files changed, 355 insertions(+), 819 deletions(-) rename .agents/notes/{implemented => archived}/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml (63%) rename .agents/notes/{implemented => archived}/architecture/2026-06-15-turn-enclosure-invariant.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-06-15-turn-enclosure-invariant.zh.md (99%) create mode 100644 .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md create mode 100644 .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md delete mode 100644 packages/core/session/tests/out-of-band.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml similarity index 63% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml index 46cb7aaa20..9f712421c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-turn-enclosure-invariant.md: 6e2abd1716f8efc08c06d5ff8faec38282f2a17f -2026-06-15-turn-enclosure-invariant.zh.md: 0921c2574dc171e887664d8a2ea840a4e81f1531 +2026-06-15-turn-enclosure-invariant.md: f38f5b2600b57ad8f5fc4c8975579e281b5f3a49 +2026-06-15-turn-enclosure-invariant.zh.md: b74ddf3f6b65a7990329a8a903aafe2e4ca41c90 diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md index 6e2abd1716..f38f5b2600 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.md @@ -1,6 +1,7 @@ # Agent Note: Every session event is enclosed in a turn Status: implemented +Archived: 2026-07-28 English | [中文](2026-06-15-turn-enclosure-invariant.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md rename to .agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md index 0921c2574d..b74ddf3f6b 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md +++ b/.agents/notes/archived/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -1,6 +1,7 @@ # Agent Note: 每个会话事件都封闭在一个轮次内 Status: implemented +Archived: 2026-07-28 [English](2026-06-15-turn-enclosure-invariant.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 78c51b882f..e46d7c34cd 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -7,6 +7,9 @@ "architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml": "sha256:37230a2f5b9dbe160b4f36b6906065637846261cbef380c3e2b777cf0d8e9013", "architecture/2026-06-11-tool-schemas-in-prompt-assembly.md": "sha256:6f7b7f15f53f857ccb8477b3fdf65bf2de3a6f96acd93049c03ce4aff1629538", "architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md": "sha256:fd5ddfc53f8a1c4afa86599c858c2ad27858166e676754ee6e2ce0881f55cef7", + "architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml": "sha256:7eb471a53b7bef104c57e9343b80d672763f062ecb086b01b318b65b488d3c02", + "architecture/2026-06-15-turn-enclosure-invariant.md": "sha256:afefa3a268c84f26cf5461e08933245352a9e63cff688d3c398c8064a4ac6e85", + "architecture/2026-06-15-turn-enclosure-invariant.zh.md": "sha256:c54fdac980abc922cdc252a8fef59e4bdd7567316c7fbb6f7dbc035e470d95fa", "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4", "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce", "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff", diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml index bd19eb7b18..eabd18d6a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md -2026-07-24-separate-context-injection-from-turn-execution.md: b74cd6bdc48e795e57d780ab31a907ffe94dd518 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: f2421d2fc7b8c1329dd1349a6fb088407ac5fc75 +2026-07-24-separate-context-injection-from-turn-execution.md: d44ef5afc8c376790192998bcf3069ceb651ae82 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: ba561b587effb278a67844ce4d876da9cd940e94 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md index b74cd6bdc4..d44ef5afc8 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -32,7 +32,7 @@ Outside that window, `inject()` appends its `user/message` immediately. It does If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. -The session invariant permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. +The session invariant permits `user/message` between turns while continuing to require turn enclosure for core execution events, steering, assistant output, and tools. Merge-extensible event relations belong to their declaring plugin rather than a core default. Persistence, recovery, resume, fork, and compaction treat valid between-turn events as committed session history rather than an interrupted or discardable turn tail. ## Extension and caller semantics @@ -42,7 +42,7 @@ Caller-driven injection and hook-produced additional context deliberately have d Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. -This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. +This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md) and the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md). The later [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) applies the same execution-only meaning to plugin-owned records. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md index f2421d2fc7..ba561b587e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -32,7 +32,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: 如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 -会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork 和压缩会把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 +会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求核心执行事件、steering、助手输出和工具事件均受轮次边界约束。可合并扩展事件的关系由声明它们的插件拥有,而不是采用核心默认规则。持久化、恢复、resume、fork 和压缩会把合法的轮次间事件当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 ## 扩展点与调用方语义 @@ -42,7 +42,7 @@ agent API 曾用三种相互重叠的方式表示面向模型的补充输入: 跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 -本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 +本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则。后续的[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)将同样的「轮次仅表示执行」语义应用于插件所属记录。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index de9949114b..260ea57905 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-30-hook-protocol-lib.md: 19a69119befde99417b736edf38923ec6ac5fa7c -2026-06-30-hook-protocol-lib.zh.md: f4950eea2b02e86ed7109f8ebdaab29dd77428d8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c +2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 19a69119be..33ec23dd4f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -19,7 +19,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo - **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. -- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. +- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and owner-defined execution relation stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. **Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index f4950eea2b..8e8c89a4ec 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -19,7 +19,7 @@ Status: implemented - **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 +- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有方定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 **方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index b535ef6081..437bd200dc 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-30-session-store-fork-api.md: 13d411e915b2e34b15a12e632f1a5e047f4aeedc -2026-06-30-session-store-fork-api.zh.md: bcf15eb581af3993ed2d71a2b7dc604faa6e4433 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +2026-06-30-session-store-fork-api.md: 5342deba8ca879026d32ee1420cb3c0fdf67c500 +2026-06-30-session-store-fork-api.zh.md: 51a3e0ce50aff10a9812c91d24dc6e78a56c43ba diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index 13d411e915..5342deba8c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -8,7 +8,7 @@ English | [中文](2026-06-30-session-store-fork-api.zh.md) The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified. -The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and turn-enclosed. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it. +The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and end outside an active turn. Forking inside execution would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates execution and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. Standalone context and plugin-owned log-only events are stable forkable history after a closed turn. The existing [subagent seam](2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it. ## Decision @@ -24,9 +24,9 @@ class SessionStore extends Service { } ``` -`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. +`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation checks that the requested boundary exists and that the selected prefix's latest turn boundary is not an unmatched `turn/start`. The selected prefix may therefore end at `turn/end` or at a later standalone event, then is deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. -An empty prefix is forkable; any non-empty boundary must be a safe existing sequence at `turn/end`, regardless of reason. Typed errors distinguish missing sources, stale objects, duplicate child ids, and invalid boundaries. Broader log validation and crash repair remain with their existing owners. +An empty prefix is forkable; any non-empty boundary must be a safe existing sequence outside an open turn. Typed errors distinguish missing sources, stale objects, duplicate child ids, invalid boundaries, and prefixes ending during execution. Broader log validation and crash repair remain with their existing owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index bcf15eb581..51a3e0ce50 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -8,7 +8,7 @@ Status: implemented 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 +语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须连续,并在活跃轮次之外结束。如果在执行过程中 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了执行与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。已关闭轮次之后的独立上下文和插件所属纯日志事件是稳定且可 fork 的历史。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 ## 决策 @@ -24,9 +24,9 @@ class SessionStore extends Service { } ``` -`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验仅检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 +`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验会检查请求的边界存在,并确认所选前缀最近的轮次边界不是未匹配的 `turn/start`。因此,所选前缀可以结束于 `turn/end` 或更晚的独立事件,随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 -空前缀可以被 fork;任何非空边界都必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 +空前缀可以被 fork;任何非空边界都必须是位于开放轮次之外且安全、已存在的序号。类型化的错误区分源缺失、对象陈旧、子 id 重复、边界无效和前缀结束于执行过程中等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index c00293eb69..952027b6e3 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: b9c410e4498a565a7414085fddfb74856592da66 -2026-07-06-sandbox.zh.md: 876870a12f1205b4f2cc2c8c13b5e7ec815a1569 +2026-07-06-sandbox.md: c3c61ed4539bcbca359f84f3dcc020e0bd41ae79 +2026-07-06-sandbox.zh.md: 39bf92aa20c697d2ad5f4c0926caeff0a0b60a1d diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index b9c410e449..c3c61ed453 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -202,5 +202,5 @@ In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied. - The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. -- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys. +- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the per-session modes fold over, and the explicit durability boundary the anchoring design obeys. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 876870a12f..39bf92aa20 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -202,5 +202,5 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md)——接口/实现/消费方拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 - `dsh-bash` 的 request/spec 拆分([bash 词汇目录](../../../../docs/core-data-structures/bash.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 - [批准 seam Agent Note](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 -- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[轮次封闭不变式](../architecture/2026-06-15-turn-enclosure-invariant.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的提交边界。 +- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 - [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 2dee4d6261..b09dcbf17d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-log-backed-session-titles.md: 4cf238a278a4eec7b894a0fcfb0f2ac464325bb0 -2026-07-21-log-backed-session-titles.zh.md: 933cc14eb245581c128cb18055df726174f953ec +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881 +2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 4cf238a278..1bd58e35ec 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -8,7 +8,7 @@ English | [中文](2026-07-21-log-backed-session-titles.zh.md) A session needs a short human-facing title before an editor, terminal, or query consumer can present it usefully. The cheapest implementation can derive one from the first prompt, while higher-quality implementations may call a model over the first prompt or the whole conversation. Those strategies have different latency, cost, routing, and retry behavior, but every consumer needs one durable source of truth. -Session identity metadata is immutable, the event log is the replay and fork boundary, and every event must remain turn-enclosed. A model-generated title often finishes after the main turn closes, so writing it synchronously would delay the agent response while writing it as mutable metadata would bypass ordinary persistence, replay, and lineage semantics. Concurrent prompts, provider HMR, cancellation, and ignored abort signals also make an unfenced background result capable of overwriting a newer title. +Session identity metadata is immutable, and the event log is the replay and fork boundary. A model-generated title often finishes after the main turn closes, so writing it synchronously would delay the agent response while writing it as mutable metadata would bypass ordinary persistence, replay, and lineage semantics. Concurrent prompts, provider HMR, cancellation, and ignored abort signals also make an unfenced background result capable of overwriting a newer title. ## Decision @@ -18,13 +18,13 @@ The [`session-title` capability family](../../../../packages/session-title/READM Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. The dispatched envelope is deep-frozen to preserve exact agreement with that record but carries no process-local agent-loop request identity, so loop-only reconstruction checks do not compare it with the main conversation header. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`. -The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the source-free `session-title` zero-step trigger and opt both title event types into this seam. No message caused that trigger, so consumers of the merge-extensible `TurnTriggerMap` discriminate `kind` before reading variant fields; goal-round admission, for example, ignores every non-`message` trigger. +The title service appends `session/title` directly after checking its current revision and exact live session; the bundled model helper likewise appends its literal `session/title-llm-request` record before dispatch. Both records may sit between turns without inventing an execution boundary. Persistence observes them eagerly and drains through ordinary checkpoints and lifecycle teardown; title publication does not force a per-event flush. No generic marker, cast, or settlement queue sits between the event owner and `Session.append()`. This is the domain-specific application of the [standalone log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). ### Input and asynchronous timing Only text blocks from human-source `user/message` events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq. -Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event and zero-step turn. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement. +Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended `request/header` starts pending work directly; when the header is unchanged, the marked loop-built `llm/stream` request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion appends a standalone event without changing turn state. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation does not roll back an already accepted fallback event, and `refresh()` rechecks the signal before returning success. Concurrent refreshes reserve their session-local revision before provider work, so a newer call aborts and supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement. The first-message provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; `refresh()` is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work. @@ -34,13 +34,13 @@ The first-message provider schedules once when a fresh session first creates its Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. The dispatched `GenerateOptions` carries `purpose: 'session-title'`; the DeepSeek adapter maps that purpose to thinking-disabled and omits reasoning effort so the bounded output is visible title text, while the main conversation keeps its configured thinking mode. The input limit measures the final user prompt, including wrappers, seq fields, and JSON escaping, before the request is logged or dispatched. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use. The fused deadline is checked while consuming each stream chunk and after completion, so a successful result returned after timeout cannot be accepted even when an interceptor or adapter ignores abort. -Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before durable acceptance. +Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance. ### Forks and consumers A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. -`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. +`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome. ## Alternatives considered @@ -57,6 +57,6 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th - Titles survive JSONL and SQLite persistence, replay, and fork inheritance without a separate mutable record. - Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach. - A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session. -- Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. +- Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. - Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 933cc14eb2..37ec95efbc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -8,7 +8,7 @@ Status: implemented 会话需要一个面向用户的简短标题,编辑器、终端或查询消费方才能有效呈现它。成本最低的实现可以从第一条提示词派生标题,质量更高的实现则可以让模型处理第一条提示词或整个对话。这些策略在延迟、成本、路由和重试行为上各有不同,但所有消费方都需要一个持久的真源。 -会话身份元数据不可变,事件日志是回放和 fork 的边界,而且每个事件都必须包围在轮次内。模型生成的标题往往在主轮次结束后才完成,因此同步写入会延迟 agent(智能体)响应,而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR(热模块替换)、取消以及被忽略的中止信号,还可能让未受版本校验约束的后台结果覆盖更新的标题。 +会话身份元数据不可变,事件日志是回放和 fork 的边界。模型生成的标题往往在主轮次结束后才完成,因此同步写入会延迟 agent(智能体)响应,而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR(热模块替换)、取消以及被忽略的中止信号,还可能让未受版本校验约束的后台结果覆盖更新的标题。 ## 决策 @@ -18,13 +18,13 @@ Status: implemented 每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。发送的请求信封经过深度冻结,以确保其与该记录精确一致,但它有意不携带进程本地的 agent loop(智能体循环)请求身份,因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。 -核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供不带消息来源的 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。该触发器并非由消息引起,因此可合并扩展的 `TurnTriggerMap` 的消费方在读取变体字段前,会先根据 `kind` 判别类型;例如,目标轮次准入会忽略所有非 `message` 触发器。 +标题服务会在检查当前修订和确切的实时会话后,直接追加 `session/title`;随附模型辅助函数同样会在发起调用前追加其字面量 `session/title-llm-request` 记录。两类记录都可以位于轮次之间,而无需虚构执行边界。持久化会尽快观察它们,并通过常规检查点和生命周期 teardown 排空;标题发布不会强制逐事件 flush。事件所有方与 `Session.append()` 之间不存在通用标记、类型断言或结算队列。这是[独立纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)在特定领域中的应用。 ### 输入与异步时序 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件和一个零步骤轮次。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 +仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果会追加一个独立事件,而不改变轮次状态。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方取消不会回滚已接受的回退事件,`refresh()` 会在返回成功前重新检查信号。并发刷新会在提供方工作之前预留会话本地修订号,因此较新的调用会在任何调用有机会造成提供方完成顺序倒置之前中止并取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 @@ -34,13 +34,13 @@ Status: implemented 模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。发出的 `GenerateOptions` 携带 `purpose: 'session-title'`;DeepSeek 适配器将该用途映射为禁用思考且省略推理强度设置的请求,使受限输出成为可见的标题文本,而主对话仍沿用已配置的思考模式。输入字节数按最终形成的用户提示词计算,其中包括包装文本、seq 字段和 JSON 转义;系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间,因此即使拦截器或适配器忽略中止信号,超时后返回的成功结果也不会被接受。 -自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在持久接受前对其进行规范化并施加字节限制。 +自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。 ### Fork 与消费方 与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 -`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 +`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。 ## 考虑过的替代方案 @@ -57,6 +57,6 @@ Status: implemented - 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。 - Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷启动列表项会在会话附加后改用标题。 - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 -- 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 +- 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 - 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml new file mode 100644 index 0000000000..7014e8b5d2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +2026-07-28-remove-synthetic-log-only-turns.md: 41ada81ef040cb7826777cd53911c3cb8bbee41e +2026-07-28-remove-synthetic-log-only-turns.zh.md: 82d24cefc01b94ba584dfa7b1a3549eea36eedc9 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md new file mode 100644 index 0000000000..41ada81ef0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -0,0 +1,43 @@ +# Agent Note: Remove synthetic turns for log-only events + +Status: implemented + +English | [中文](2026-07-28-remove-synthetic-log-only-turns.zh.md) + +## Problem + +The session store exposed `appendOutOfBand()` so a plugin could publish a late log-only event while no agent turn was running. The method wrapped that event in `turn/start` and `turn/end`, then flushed it. This preserved the old rule that every durable event had to live inside a turn, but it made one identifier mean both a model-loop execution and a persistence-only update. + +That rule was introduced when persistence recovery treated the last `turn/end` as the only committed boundary. The persistence scanners now preserve every valid contiguous event, and crash repair reacts only to an actually open turn. Idle context already uses the same capability by appending `user/message` between turns. Retaining synthetic turns for title updates therefore inflated turn counts, produced execution outcomes for work that never ran the model, and let a late metadata write consume the next turn number. + +The generic seam also duplicated domain policy. Its marker map said which plugin events were eligible, while the title capability already owned cancellation, liveness, and stale-result rules. Replacing it with another generic or title-specific append wrapper would preserve the same type indirection for two literal event types. + +## Decision + +`SessionStore.appendOutOfBand()`, `OutOfBandSessionEventMap`, and `OutOfBandSessionEventType` do not exist. A plugin that owns a log-only event appends it through `Session`; when the operation promises durability, it explicitly awaits `ctx.sessions.flush(session)`. No turn is opened solely to obtain that checkpoint. + +Core session invariants continue to enforce core-owned execution relations: turn and step numbering, enclosure of steering, assistant, tool, todo, and request-header events, and same-step tool call/result pairing. Core permits merge-extensible events between turns because only their declaring plugin knows whether they are execution-scoped or standalone. Plugin invariant companions remain responsible for their own event relations. + +The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence observes both through the eager `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. + +A session fork may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and context records in a default fork while still rejecting a prefix cut through active execution. + +The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. + +## Alternatives considered + +**Keep synthetic zero-step turns.** This preserves a uniform-looking log and reuses `turn/end` as a flush point, but it reports executions that never happened, perturbs turn numbering, and makes every turn consumer filter persistence-only records. Durability already has the independent `session/flush` boundary. + +**Keep a generic core durable-append helper without synthetic turns.** A method that performs `append()` plus `flush()` is small, but its eligibility marker and concurrency promises would still centralize plugin policy in the session store. The event owners already have literal typed append sites, and a caller that truly needs a durability barrier can await the existing `session/flush` operation at that boundary. + +**Store titles as mutable session metadata.** This avoids between-turn events but creates a second mutation, replay, persistence, and fork protocol beside the append-only log. Titles remain replayable latest-wins events instead. + +**Require every plugin event to declare standalone eligibility to core.** This keeps a central allowlist but makes absence mean an execution relation that core cannot verify. Merge-extensible unions already assign semantic ownership to the declaring plugin; its invariant companion is the correct enforcement point. + +## Verification + +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. Generated API and type-equivalence catalogs contain no removed symbol. + +## Consequences + +Turn counts and outcomes again describe model-loop executions only. Standalone events consume session seqs, start eager persistence like every other append, and require owners to request an explicit durability barrier only when their operation promises one. Generic plugin mistakes no longer fail under a core default enclosure rule, so each plugin that needs an execution relation must state and test that relation itself. The title capability keeps revision ordering and lifecycle persistence with less core state, no duplicate type seam, and no turn-number collision. diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md new file mode 100644 index 0000000000..82d24cefc0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 移除纯日志事件的合成轮次 + +Status: implemented + +[English](2026-07-28-remove-synthetic-log-only-turns.md) | 中文 + +## 问题 + +会话存储曾暴露 `appendOutOfBand()`,让插件可以在没有 agent(智能体)轮次运行时发布延迟到达的纯日志事件。该方法会用 `turn/start` 和 `turn/end` 包住事件,再将其刷写。这保留了「每个持久事件都必须位于轮次内」的旧规则,却让同一个标识符既表示模型循环执行,又表示仅持久化更新。 + +引入该规则时,持久化恢复曾将最后一个 `turn/end` 视为唯一的已提交边界。如今,持久化扫描器会保留每个合法且连续的事件,崩溃修复也只处理确实处于开放状态的轮次。空闲上下文早已采用同一机制,在轮次之间追加 `user/message`。因此,为标题更新保留合成轮次会夸大轮次计数、为从未运行模型的工作产生执行结果,还会让延迟到达的元数据写入占用下一个轮次编号。 + +通用 seam 还重复了领域策略。它的标记映射说明哪些插件事件符合条件,而标题功能本就拥有取消、活跃性和陈旧结果处理规则。改用另一个通用或标题专属追加包装层,仍会为两个字面量事件类型保留同一层类型间接性。 + +## 决策 + +`SessionStore.appendOutOfBand()`、`OutOfBandSessionEventMap` 和 `OutOfBandSessionEventType` 均不再存在。拥有纯日志事件的插件通过 `Session` 追加该事件;当操作承诺持久性时,插件会显式等待 `ctx.sessions.flush(session)`。系统不会仅为获得该检查点而打开轮次。 + +核心会话不变量继续强制核心所属的执行关系:轮次与步骤编号、steering、助手、工具、待办和请求头事件的封闭,以及同一步骤内的工具调用/结果配对。核心允许可合并扩展事件位于轮次之间,因为只有声明它们的插件知道这些事件受执行作用域约束,还是可以独立存在。插件的不变量配套组件仍负责其自身的事件关系。 + +标题服务会在完成既有的服务状态、修订、取消和实时会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过尽快处理的 `session/event` 路径观察两者,并在常规检查点与生命周期 teardown 时排空;二者都不会仅因为位于轮次之间就强制 flush。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。 + +会话 fork 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认 fork 会保留独立标题和上下文记录,同时仍拒绝在活跃执行过程中截断前缀。 + +历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)确立了当前语义:一个轮次表示一次模型循环执行。 + +## 曾考虑的替代方案 + +**保留合成的零步骤轮次。** 这可以让日志在形式上保持统一,并复用 `turn/end` 作为刷写点,但会报告从未发生的执行、扰动轮次编号,还会迫使每个轮次消费方过滤仅持久化记录。持久性已有独立的 `session/flush` 边界。 + +**保留不使用合成轮次的通用核心持久追加辅助函数。** 执行 `append()` 再执行 `flush()` 的方法本身很小,但其准入标记和并发承诺仍会将插件策略集中到会话存储中。事件所有方已经拥有直接、类型化的字面量追加点;真正需要持久性屏障的调用方可以在该边界等待既有的 `session/flush` 操作。 + +**将标题存储为可变会话元数据。** 这可以避免轮次间事件,却会在仅追加日志之外建立第二套变更、回放、持久化和 fork 协议。标题仍采用可回放、后写覆盖的事件。 + +**要求每个插件事件向核心声明可独立存在的资格。** 这可以保留中心准入列表,但会让缺少声明意味着一种核心无法验证的执行关系。可合并扩展联合类型已经将语义所有权赋予声明事件的插件;该插件的不变量配套组件才是正确的强制位置。 + +## 验证 + +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。生成的 API 和类型等价性目录不含任何已移除符号。 + +## 后果 + +轮次计数和结果重新只描述模型循环执行。独立事件会占用会话 seq,像其他追加一样启动尽快持久化,并且仅当操作承诺持久性时,才要求事件所有方请求显式持久性屏障。通用插件错误不再因核心默认的封闭规则而失败,因此每个需要执行关系的插件都必须自行声明并测试该关系。标题功能保留修订排序和生命周期持久化,同时减少了核心状态,不再重复类型 seam,并消除了轮次编号冲突。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 98845d7e03..704d2e8227 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-20-truncate-interrupted-turns.md: af18618ad4c41af125e37c51b9fd971dd8eae64e -2026-06-20-truncate-interrupted-turns.zh.md: a20d0169f7735aa7a9437c10c958580c00704171 +# pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +2026-06-20-truncate-interrupted-turns.md: 3c7acf8d673568f851edd52635a28f73d9bf5f6f +2026-06-20-truncate-interrupted-turns.zh.md: 36a3d3f3cbd736c8f9a70fea453a314c0495d6f2 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index af18618ad4..3c7acf8d67 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -31,6 +31,6 @@ A crash can lose real work from the final turn: assistant text, tool calls, and ## Related -This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and [turn enclosure](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. +This is a direct simplification of [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and the historical [universal turn-enclosure rule](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md). It also removes much of the motivation for durable step boundary events, making [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) smaller. diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index a20d0169f7..36a3d3f3cb 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -31,6 +31,6 @@ Status: rejected — 单个轮次可以包含大量真实工作,包括多个 ## 相关 -本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使[移除持久化步骤边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 +本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与历史上的[通用轮次封闭规则](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使[移除持久化步骤边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 6b10543048..e56a3f91bb 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 6a9bfbbeb399bc1d4fb5fa4b5f2cb639547f5a93 -architecture.zh.md: 6f7c59cf8ec24cb5d8527e82aa04ca773535e54c +architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897 +architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574 diff --git a/docs/architecture.md b/docs/architecture.md index 6a9bfbbeb3..054985ac5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` adds plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +Log-only events may sit between turns. Owners append through `Session`, flushing only for durability. `session/title` relies on eager persistence and lifecycle drains. Latest title wins with provenance; fallback and provider work never delays responses. Such records are fork boundaries, so forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6f7c59cf8e..84876faf2a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -145,7 +145,7 @@ idle inject: 持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +纯日志事件可以位于轮次之间。事件所有方通过 `Session` 追加,仅为持久性而刷写。`session/title` 依赖尽快持久化与生命周期排空。最新标题按后写覆盖并携带来源信息;回退与提供方工作绝不会延迟响应。这类记录可作为 fork 边界,因此 fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b89a49529c..dcb4fa69e7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1149,7 +1149,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:67`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 75464938b0..19d7d7765d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1320,28 +1320,6 @@ announce(session: Session): void */ async flush(session: Session): Promise -/** - * Append one plugin-declared log-only event without borrowing the agent - * loop's lifecycle. An open turn receives the event directly and remains - * responsible for its ordinary checkpoint. A closed log receives one - * zero-step turn around the event, followed by an awaited flush. - * - * Once the synthetic `turn/start` commits, this method always attempts its - * matching `turn/end` and flush, including when the target append fails. - * Detachment requested by an event or flush listener is deferred until that - * sequence settles, so publication cannot switch from a live scoped session - * to an unobserved bare `Session` halfway through the update. - * - * @param session - exact live session that owns the target log. - * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner. - * @param data - typed JSON payload for the target event. - * @param trigger - plugin-owned turn trigger used only when the log is closed. - * @returns the accepted target event with its assigned sequence and timestamp. - * @throws when the session is detached, another out-of-band append is active, - * event acceptance fails, the synthetic turn cannot close, or flushing fails. - */ -async appendOutOfBand( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise> - /** * Look up a live session. * @param id - the session id to look up. @@ -1356,9 +1334,10 @@ get(id: SessionId): Session | undefined list(): Session[] /** - * Create a live child session from a turn-enclosed prefix of a live source. + * Create a live child session from a stable prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must end at `turn/end`. + * current last event. The selected slice may end with a between-turn event + * but must not end inside an open turn. * * @param source - Live source session object or id. * @param boundary - Inclusive source event seq to fork through; omitted means @@ -1371,9 +1350,9 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) +Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:613`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1391,7 +1370,7 @@ get(session: Session): SessionTitleSnapshot | undefined * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. * @param session - exact live session to refresh. - * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection. + * @param signal - optional caller cancellation. * @returns latest accepted title, or `undefined` when no eligible text exists. */ async refresh(session: Session, signal?: AbortSignal): Promise @@ -1407,7 +1386,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:283`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:232`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 63d3cda07b..f0d078c123 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 357712bd197ac2e0661e6bc61a638aa8a4738356 -core.zh.md: dcb7210d37377da99ac2cd68b1ce18fa6e90e0b8 +core.md: 647c7f273183e0890ef191d98ab009ad129db572 +core.zh.md: 8b0f439f09a6e6609dbe69c3056aa74d553a0943 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 357712bd19..647c7f2731 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -22,7 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | -| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | +| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | @@ -401,7 +401,7 @@ type SessionEvent = { }[T] ``` -The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index dcb7210d37..8b0f439f09 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -22,7 +22,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | -| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | @@ -407,7 +407,7 @@ type SessionEvent = { }[T] ``` -十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ebb8bfb6f3..ea83035f92 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e -persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd +# pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md +persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de +persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4ec967873e..5a660e17d6 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -12,7 +12,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## Crash recovery preserves an interrupted turn -A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the interrupted execution balanced without changing any standalone events before or after it. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it with the stored header only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3030ff2fe9..b5477cfc82 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -12,7 +12,7 @@ ## 崩溃恢复保留被中断的轮次 -后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 修复仅适用于冷会话。对于活跃 id,`SessionPersistence.load(id)` 会对内存日志拍摄快照,等待该快照完成持久化,并且只在日志平衡时连同已存储的 header 返回;若活跃轮次仍未闭合,则拒绝操作,而不是添加合成的中断边界。由协调器管理的冷加载会在后端读取和修复写入期间占用该 id,因此并发发布同 id 的活跃会话会被拒绝并回滚。HMR 也会接管活跃前缀,而不会关闭其中正在进行的轮次。 diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index f31d9016ac..64b3d8f342 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -session-title.md: 6575bda5fdecf2be15ed7c3288efb0efa759ac8a -session-title.zh.md: 66ec567ac6cef32acef9c50ae3e67155097a76b6 +# pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md +session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5 +session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 6575bda5fd..33efc911c0 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -114,7 +114,7 @@ interface SessionTitleProviderRequest { ``` ```ts type-equiv -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index 66ec567ac6..a4b95a726d 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/docs/core-data-structures/session-title.zh.md @@ -114,7 +114,7 @@ interface SessionTitleProviderRequest { ``` ```ts type-equiv -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 539beff405..56c9e9d415 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 058236cb628f0e517fe18b0e4276dba46bd6b0b0 -session.zh.md: 2d8022c7892828a30094728a1449d16dddd2fd89 +session.md: e952e6f869d03589ae1645a1becc8d8dadecd84b +session.zh.md: e38b925cba9366160af7002d390a7aa3d11bf175 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 058236cb62..e952e6f869 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -107,20 +107,6 @@ interface SessionEventMap { `UserMessageData` is the durable `content` and `source` base shared by ordinary prompts, injected context, and steering. Live inbox events extend the same shape with an `AgentMessageId`; the loop adds only driver-owned routing state while an item remains pending. -### `OutOfBandSessionEventMap` — narrow late-append opt-in - -`SessionEventMap` membership alone does not authorize an event outside the agent loop's ordinary lifecycle. An event owner declaration-merges the same key into this empty marker map before `ctx.sessions.appendOutOfBand()` accepts it; the derived type additionally excludes every surface event. An accepted update joins an open turn or receives a balanced, flushed zero-step turn. - -```ts type-equiv -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -interface OutOfBandSessionEventMap {} -``` - ### `TodoItem` — one todo-list entry The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity. See the [todo_write Agent Note](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md). @@ -465,9 +451,9 @@ Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and `ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). +- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the selected prefix to end outside an open turn, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). -An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. +An explicit `boundary` lets callers fork from any stable between-turn position, including a previous `turn/end` or a later standalone log-only event, even if the source has newer events or an open current turn. The API rejects a prefix that ends inside an open turn instead of clipping silently. Broader execution-relation sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. ## What started a turn: `TurnTriggerMap` @@ -526,18 +512,20 @@ interface TurnEndReasonMap { `max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. -## The turn-enclosure invariant +## Execution enclosure and standalone events -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +A turn encloses one model-loop execution, not the whole session log. Idle injected `user/message` events and plugin-owned log-only events may appear between `turn/end` and the next `turn/start`; they consume event seqs without incrementing turn numbers. Persistence eagerly records every contiguous accepted event, while crash repair closes only a genuinely open trailing turn. A producer that needs a durability barrier explicitly awaits `ctx.sessions.flush(session)`. + +The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). ## Plugin-contributed log-only events -A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). +A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 2d8022c789..e38b925cba 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -107,20 +107,6 @@ interface SessionEventMap { `UserMessageData` 是普通提示词、注入上下文与 steering(中途引导)共享的持久 `content` + `source` 基础形状。实时收件箱事件在同一形状上扩展一个 `AgentMessageId`;条目待处理期间,loop 只额外附加驱动器自有的路由状态。 -### `OutOfBandSessionEventMap`:受限的带外追加显式准入 - -仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop(智能体循环)的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 - -```ts type-equiv -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -interface OutOfBandSessionEventMap {} -``` - ### `TodoItem`:一条待办项 这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 @@ -467,9 +453,9 @@ declare class Session { `ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: -- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求 boundary 事件必须是 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 -显式 `boundary` 允许调用者从之前完成的轮次 fork,即使源会话有更新的事件或正在进行的轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默截断。更广泛的轮次封闭性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 +显式 `boundary` 允许调用者从任意稳定的轮次间位置 fork,包括之前的 `turn/end` 或更晚的独立纯日志事件,即使源会话有更新的事件或正在进行的轮次。API 拒绝结束于开放轮次内的前缀,而不是静默截断。更广泛的执行关系健全性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 ## 轮次的触发原因:`TurnTriggerMap` @@ -530,18 +516,20 @@ interface TurnEndReasonMap { `max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 -## 轮次封闭不变式 +## 执行封闭与独立事件 -每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `user/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 +一个轮次包围一次模型循环执行,而不是整个会话日志。空闲注入的 `user/message` 事件和插件所属的纯日志事件可以出现在 `turn/end` 与下一个 `turn/start` 之间;它们占用事件 seq,但不递增轮次编号。持久化会尽快记录每个连续且已接受的事件,而崩溃修复只关闭确实仍处于开放状态的尾部轮次。需要持久性屏障的生产方会显式等待 `ctx.sessions.flush(session)`。 + +可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 ## 插件贡献的仅日志事件 -插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 +插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 -持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型、破坏核心执行嵌套,或违反事件所有方声明的关系,都会构成磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f4d7357572..8b51586912 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ## Events @@ -373,7 +373,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:88`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -384,7 +384,7 @@ Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/ses Types: [SessionTitleLlmRequestEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages/session-title/session-title-llm/src/index.ts) +Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages/session-title/session-title-llm/src/index.ts) ### `steering/*` @@ -462,7 +462,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter * model context; persistence and UIs get every call. Appended inside the * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so the turn-enclosure invariant holds by + * before returning), so its execution-enclosure relation holds by * construction. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 084652ea26..47f8a1a54f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -634,10 +634,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async flush(session: Session): Promise', jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', }, - { - signature: 'async appendOutOfBand( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise>', - jsDoc: '/**\n * Append one plugin-declared log-only event without borrowing the agent\n * loop\'s lifecycle. An open turn receives the event directly and remains\n * responsible for its ordinary checkpoint. A closed log receives one\n * zero-step turn around the event, followed by an awaited flush.\n *\n * Once the synthetic `turn/start` commits, this method always attempts its\n * matching `turn/end` and flush, including when the target append fails.\n * Detachment requested by an event or flush listener is deferred until that\n * sequence settles, so publication cannot switch from a live scoped session\n * to an unobserved bare `Session` halfway through the update.\n *\n * @param session - exact live session that owns the target log.\n * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.\n * @param data - typed JSON payload for the target event.\n * @param trigger - plugin-owned turn trigger used only when the log is closed.\n * @returns the accepted target event with its assigned sequence and timestamp.\n * @throws when the session is detached, another out-of-band append is active,\n * event acceptance fails, the synthetic turn cannot close, or flushing fails.\n */', - }, { signature: 'get(id: SessionId): Session | undefined', jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */', @@ -648,7 +644,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', - jsDoc: '/**\n * Create a live child session from a turn-enclosed prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. A non-empty selected slice must end at `turn/end`.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */', + jsDoc: '/**\n * Create a live child session from a stable prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. The selected slice may end with a between-turn event\n * but must not end inside an open turn.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */', }, ], }, @@ -662,7 +658,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async refresh(session: Session, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */', + jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */', }, { signature: 'register(provider: SessionTitleProvider): () => Promise', @@ -1805,14 +1801,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, - { - name: 'OutOfBandSessionEventMap', - declaration: 'export interface OutOfBandSessionEventMap {\n}', - }, - { - name: 'OutOfBandSessionEventType', - declaration: 'export type OutOfBandSessionEventType = Exclude, SurfaceEventType>;', - }, { name: 'PreparedLlmCall', declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index b52609f52f..cc97d6d28a 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 95b67fc5977a73d0b7fbf8d37d27eccfcd981338 -README.zh.md: 47c97256fb14a21adef2a10589d0d7fa22ab2646 +# pnpm run verify-translation-pairing --write packages/core/session/README.md +README.md: f54dc3048fb9ee765a420f387dce9a729c8a85ef +README.zh.md: a3737a12ebe5c86d77ceb6776359a84fb7f0d84f diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 95b67fc597..f54dc3048f 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -14,9 +14,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. -- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles. -- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome. -- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. +- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -74,7 +73,7 @@ A `user/message` renders its `content` verbatim as a user-role message whether i The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. -Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded. +Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn. Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. @@ -142,6 +141,6 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi ## Known Limitations and Deferred Work - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. -- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). +- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). - **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 47c97256fb..a3737a12eb 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -14,9 +14,8 @@ - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 -- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。 -- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。 -- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求边界为 `turn/end`,再创建带谱系元数据的实时子会话。 +- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 +- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -74,7 +73,7 @@ 生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。 -`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。`OutOfBandSessionEventMap` 是独立、默认为空的标记映射:事件所有方必须在其中合并相同键,`appendOutOfBand()` 才接受该仅日志类型;surface 和生命周期类型仍被排除。 +`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。 此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。 @@ -142,6 +141,6 @@ ## 已知限制与暂缓工作 - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 -- **`fork()` 仅在实时会话已关闭轮次的边界处切分**:边界必须是 `turn/end` 事件,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 +- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 - **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0156eb9a13..5619724525 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' +import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' @@ -30,8 +30,8 @@ export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' /** - * Find the latest closed message-triggered turn, excluding injection and - * plugin-owned zero-step turns. + * Find the latest closed message-triggered turn, ignoring other triggers and + * between-turn events. * @param events - session events, or an owned suffix, to inspect. * @returns the latest matching turn end, or `undefined`. */ @@ -257,7 +257,6 @@ interface SessionEntry { announced: boolean announcing: boolean appending: boolean - outOfBand: boolean detachRequested: boolean detach(): void } @@ -446,7 +445,7 @@ export class Session { } finally { if (entry !== undefined) { entry.appending = false - if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach() + if (entry.detachRequested && !entry.announcing) entry.detach() } } } @@ -587,8 +586,8 @@ export type SessionForkSource = Session | SessionId * live store (`SESSION_NOT_FOUND`) or names a session object that is not the * store's live instance (`SESSION_NOT_LIVE`); the requested child id is * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous - * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a - * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`). + * existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an + * open turn (`OPEN_TURN`). */ export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' @@ -729,7 +728,6 @@ export class SessionStore extends Service { announced: false, announcing: false, appending: false, - outOfBand: false, detachRequested: false, detach: () => { this.detachEntered(entry) }, } @@ -742,7 +740,7 @@ export class SessionStore extends Service { // A lifecycle listener may own the advanced detach capability. Keep the // entry and its publication hooks live until synchronous creation or append // publication unwinds, then publish the paired disposal edge. - if (entry.announcing || entry.appending || entry.outOfBand) { + if (entry.announcing || entry.appending) { entry.detachRequested = true return } @@ -796,7 +794,7 @@ export class SessionStore extends Service { } } finally { entry.announcing = false - if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach() + if (entry.detachRequested && !entry.appending) entry.detach() } } @@ -840,87 +838,6 @@ export class SessionStore extends Service { if (failure !== undefined) throw failure.reason } - /** - * Append one plugin-declared log-only event without borrowing the agent - * loop's lifecycle. An open turn receives the event directly and remains - * responsible for its ordinary checkpoint. A closed log receives one - * zero-step turn around the event, followed by an awaited flush. - * - * Once the synthetic `turn/start` commits, this method always attempts its - * matching `turn/end` and flush, including when the target append fails. - * Detachment requested by an event or flush listener is deferred until that - * sequence settles, so publication cannot switch from a live scoped session - * to an unobserved bare `Session` halfway through the update. - * - * @param session - exact live session that owns the target log. - * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner. - * @param data - typed JSON payload for the target event. - * @param trigger - plugin-owned turn trigger used only when the log is closed. - * @returns the accepted target event with its assigned sequence and timestamp. - * @throws when the session is detached, another out-of-band append is active, - * event acceptance fails, the synthetic turn cannot close, or flushing fails. - */ - async appendOutOfBand( - session: Session, - type: T, - data: SessionEventMap[T], - trigger: TurnTrigger, - ): Promise> { - const entry = this.liveEntryFor(session) - if (entry.outOfBand) { - throw new Error(`session "${session.id}" already has an out-of-band append in progress`) - } - entry.outOfBand = true - // `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but - // TypeScript does not reduce Session.append's conditional rest parameter - // through a generic intersection. Preserve that proven two-argument call - // shape without widening the public Session.append overload. - const appendLogOnly = session.append.bind(session) as unknown as ( - eventType: K, - eventData: SessionEventMap[K], - ) => SessionEvent - try { - const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end') - if (lastBoundary?.type === 'turn/start') { - return appendLogOnly(type, data) - } - - const lastStart = session.events.findLast(event => event.type === 'turn/start') - const turn = (lastStart?.data.turn ?? 0) + 1 - let accepted: SessionEvent | undefined - let failure: unknown - let opened = false - try { - session.append('turn/start', { turn, trigger }) - opened = true - accepted = appendLogOnly(type, data) - } catch (error: unknown) { - failure = error - } finally { - if (opened) { - // The only target types admitted by OutOfBandSessionEventMap are - // log-only plugin events, so the synthetic turn remains open here. - session.append('turn/end', { turn, reason: { kind: 'completed' } }) - try { - await this.flush(session) - } catch (error: unknown) { - if (failure === undefined) failure = error - } - } - } - if (failure !== undefined) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly - throw failure - } - /* v8 ignore next -- accepted is assigned unless an append failure was captured above. */ - if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event') - return accepted - } finally { - entry.outOfBand = false - if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach() - } - } - /** Return the exact live entry; detached/prepared objects reject. */ private liveEntryFor(session: Session): SessionEntry { const entry = attachments.get(session) @@ -948,9 +865,10 @@ export class SessionStore extends Service { } /** - * Create a live child session from a turn-enclosed prefix of a live source. + * Create a live child session from a stable prefix of a live source. * `boundary` is an inclusive source event seq; omitted means the source's - * current last event. A non-empty selected slice must end at `turn/end`. + * current last event. The selected slice may end with a between-turn event + * but must not end inside an open turn. * * @param source - Live source session object or id. * @param boundary - Inclusive source event seq to fork through; omitted means @@ -1007,9 +925,11 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - if (boundaryEvent.type !== 'turn/end') { + const lastTurnBoundary = events.slice(0, boundary + 1) + .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') + if (lastTurnBoundary?.type === 'turn/start') { throw new SessionForkError( - `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`, + `fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`, 'OPEN_TURN', ) } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 0c08c1697f..84e6feb292 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -66,8 +66,8 @@ function validateEvent( let nextStep = trace.nextStep let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - // Model input may be appended between turns without running the model. - // Merge-extensible package events remain turn-enclosed by default. + // Context and plugin-owned log-only events may be appended between model + // executions. Core execution events retain their explicit turn relations. switch (event.type) { case 'turn/start': { if (trace.openTurn !== null) { @@ -143,12 +143,17 @@ function validateEvent( } case 'user/message': break - default: { + case 'steering/message': + case 'todo/write': + case 'request/header': { if (trace.openTurn === null) { - fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`) + fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`) } break } + default: + // Merge-extensible event relations belong to their owning plugin. + break } return { scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 3f8c30e5d5..8a96a0f67a 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -257,23 +257,9 @@ export interface SessionEventMap { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } } -/** - * Marker map for plugin-owned log-only events accepted by - * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key - * it adds to {@link SessionEventMap}; surface and lifecycle events stay - * ineligible unless their owner explicitly opts them into this narrow seam. - */ -export interface OutOfBandSessionEventMap {} - /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ export type SessionEventType = keyof SessionEventMap -/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */ -export type OutOfBandSessionEventType = Exclude< - Extract, - SurfaceEventType -> - /** * The subset of {@link SessionEventType} values whose events produce LLM * messages and are eligible to appear on the ordered surface. Only these diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 921232d724..f996faa313 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -4,6 +4,12 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'test/log-only': { value: string } + } +} + async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -83,6 +89,21 @@ describe('SessionStore.fork', () => { }) }) + it('includes stable log-only events appended after a closed turn', async () => { + const { ctx, sessions } = await setup() + const source = ctx.sessions.create(SessionId('log-only-parent')) + appendClosedTurn(source, 1, 'hello') + source.append('test/log-only', { value: 'after execution' }) + + const child = sessions.fork(source, undefined, SessionId('log-only-child')) + + expect(child.events).toEqual(source.events) + expect(child.events.at(-1)).toMatchObject({ + type: 'test/log-only', + data: { value: 'after execution' }, + }) + }) + it('forks from an earlier turn boundary even when the source currently has an open tail', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) @@ -217,7 +238,7 @@ describe('SessionStore.fork', () => { const boundary = build(source) expect(() => sessions.fork(source, boundary)) - .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN')) + .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN')) } }) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index d6fb2950e9..b421c8f141 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -102,7 +102,7 @@ describe('session-log invariants', () => { } as never) }).toThrow(/seq must strictly increase/) }) - it('enforces turn numbering and encloses events other than idle context', async () => { + it('enforces turn numbering and core execution enclosure', async () => { const first = await setup() const open = first.ctx.sessions.create() open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -127,9 +127,13 @@ describe('session-log invariants', () => { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' }, }, { surfaceOp: 'append' })).toThrow(/outside any open turn/) - // Merge-extensible session events use the same default enclosure branch. + // The owning plugin decides whether a merge-extensible event is log-only. const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown - expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/) + expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow() + expect(() => outside.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).not.toThrow() }) it('enforces open-step identity and numbering', async () => { diff --git a/packages/core/session/tests/out-of-band.spec.ts b/packages/core/session/tests/out-of-band.spec.ts deleted file mode 100644 index 0265b1e8d6..0000000000 --- a/packages/core/session/tests/out-of-band.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/log-only': { value: string } - } - - interface OutOfBandSessionEventMap { - 'test/log-only': true - } - -} - -const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const - -describe('SessionStore.appendOutOfBand', () => { - it('joins an open turn without adding a boundary or flushing it', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('open')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - session.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - - const event = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'inside' }, - updateTrigger, - ) - - expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } }) - expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only']) - expect(flushes).toBe(0) - }) - - it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('closed')) - const flushedTypes: string[][] = [] - ctx.on('session/flush', (flushed) => { - flushedTypes.push(flushed.events.map(event => event.type)) - }) - - const first = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'first' }, - updateTrigger, - ) - const second = await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'second' }, - updateTrigger, - ) - - expect(first.seq).toBe(1) - expect(second.seq).toBe(4) - expect(session.events).toMatchObject([ - { type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } }, - { type: 'test/log-only', seq: 1, data: { value: 'first' } }, - { type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - { type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } }, - { type: 'test/log-only', seq: 4, data: { value: 'second' } }, - { type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } }, - ]) - expect(flushedTypes).toEqual([ - ['turn/start', 'test/log-only', 'turn/end'], - ['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'], - ]) - }) - - it('closes and flushes a zero-step turn when the target event is rejected', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('rejected')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 1n } as never, - updateTrigger, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events).toMatchObject([ - { type: 'turn/start', data: { turn: 1 } }, - { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }, - ]) - expect(flushes).toBe(1) - }) - - it('does not flush when the synthetic turn cannot open', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('start-failure')) - let flushes = 0 - ctx.on('session/flush', () => { flushes += 1 }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'unreachable' }, - { ...updateTrigger, invalid: 1n } as never, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events).toEqual([]) - expect(flushes).toBe(0) - }) - - it('preserves a target rejection when the balancing flush also rejects', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('target-and-flush-failure')) - ctx.on('session/flush', () => { throw new Error('disk failed') }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 1n } as never, - updateTrigger, - )).rejects.toThrow(/non-JSON-serializable/) - - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'turn/end', - ]) - }) - - it('keeps the session attached through publication and its flush', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.prepare(SessionId('dispose')) - const detach = ctx.sessions.enter(session) - ctx.sessions.announce(session) - let liveDuringFlush = false - ctx.on('session/event', (_observed, event) => { - if (event.type === 'turn/start') detach() - }) - ctx.on('session/flush', () => { - liveDuringFlush = ctx.sessions.get(session.id) === session - }) - - await ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'last' }, - updateTrigger, - ) - - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'test/log-only', - 'turn/end', - ]) - expect(liveDuringFlush).toBe(true) - expect(ctx.sessions.get(session.id)).toBeUndefined() - }) - - it('rejects detached sessions before opening a turn', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.prepare(SessionId('detached')) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'nope' }, - updateTrigger, - )).rejects.toThrow('session "detached" is not live in this store') - expect(session.events).toEqual([]) - }) - - it('leaves a balanced log when the durability checkpoint rejects', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('flush-failure')) - ctx.on('session/flush', () => { throw new Error('disk failed') }) - - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'accepted' }, - updateTrigger, - )).rejects.toThrow('disk failed') - expect(session.events.map(event => event.type)).toEqual([ - 'turn/start', - 'test/log-only', - 'turn/end', - ]) - }) - - it('rejects overlapping updates while the first append is still settling', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('overlap')) - let release!: () => void - const checkpoint = new Promise((resolve) => { - release = resolve - }) - ctx.on('session/flush', () => checkpoint) - - const first = ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'first' }, - updateTrigger, - ) - await expect(ctx.sessions.appendOutOfBand( - session, - 'test/log-only', - { value: 'overlap' }, - updateTrigger, - )).rejects.toThrow(/out-of-band append in progress/) - release() - await expect(first).resolves.toMatchObject({ data: { value: 'first' } }) - }) -}) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 5f1ef46891..244934de3f 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -43,7 +43,7 @@ declare module '@deepseek-ai/dsh-session' { * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter * model context; persistence and UIs get every call. Appended inside the * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so the turn-enclosure invariant holds by + * before returning), so its execution-enclosure relation holds by * construction. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 3fa0f9eecd..f74f1bb3a8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 144616248d4e811b112f4c556502a960e681950b -README.zh.md: 036434ea6f10156a565734ac4da40f447e38ebb8 +# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md +README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397 +README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 144616248d..10cfcdcbf8 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -29,7 +29,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note. +Hook provenance records must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) satisfy that owner-defined relation by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note. ## Model Experience diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 036434ea6f..f6fd30c968 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,由 `appendHookResult` 拥有决策规则)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。 -与每个事件一样,它们必须位于开启轮次内。轮次中点(`PreToolUse`/`PostToolUse`/`Stop`)按构造位于 loop 的开启轮次中。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 +Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有方定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index a098b0ba0d..c324986a78 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e01db9f618fcc8ad139c7b7aaa3b942150df3194 -README.zh.md: 2341850cefadff1bba8d0f738320028e00ea9ae5 +# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md +README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd +README.zh.md: abf2d9fb8830fdcaf7f1357b393b434de4a5ad8d diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index e01db9f618..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -21,7 +21,7 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. -The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. +The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. ## The per-session store diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 2341850cef..abf2d9fb88 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -21,7 +21,7 @@ - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 -可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与轮次封闭规则。 +可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件拥有周围的存储与核心执行封闭规则。 ## 逐会话 store diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 5018e19dbf..03e8b15da0 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -253,10 +253,10 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE const headerLine = parsedHeader // Parse and decode every complete line first so the last valid `turn/end` - // determines whether an earlier hole is committed corruption or an - // uncommitted tail. One line yields one event, or a whole run for a packed - // chunk row; a row-tagged line that fails row validation is a hole, exactly - // like unparsable JSON. + // determines whether an earlier hole interrupts an otherwise closed + // execution or belongs to a tolerable final suffix. One line yields one + // event, or a whole run for a packed chunk row; a row-tagged line that fails + // row validation is a hole, exactly like unparsable JSON. interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number } const parsed: Parsed[] = eventEntries.map((entry) => { try { @@ -266,9 +266,11 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE } }) - // The last index (into eventEntries) that ends in a valid `turn/end` — the - // last fully-committed boundary (the loop flushes only at turn/end). A packed - // row never stores a turn/end, so only single-event lines can match. + // The last index (into eventEntries) that ends in a valid `turn/end`. A hole + // before this boundary cannot be a torn final suffix because later execution + // already closed. Standalone events after it remain part of the preserved + // contiguous prefix. A packed row never stores a turn/end, so only + // single-event lines can match. let lastTurnEnd = -1 for (let i = parsed.length - 1; i >= 0; i--) { const p = parsed[i] diff --git a/packages/session-title/session-title-llm/README.i18n.yaml b/packages/session-title/session-title-llm/README.i18n.yaml index 5911c38ba7..2343a6cb3a 100644 --- a/packages/session-title/session-title-llm/README.i18n.yaml +++ b/packages/session-title/session-title-llm/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 5d004b634e6242a8a558182e6c953e4c43b25e1f -README.zh.md: 36567d950e612561d8af804eca64ac6f9f3cd788 +# pnpm run verify-translation-pairing --write packages/session-title/session-title-llm/README.md +README.md: 342687b5aa0cd35a70abf8cc66b3fe80342bce0b +README.zh.md: 2bb48b52ae6298c5c895ef99ff54cf5f1d7a6d8c diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md index 5d004b634e..342687b5aa 100644 --- a/packages/session-title/session-title-llm/README.md +++ b/packages/session-title/session-title-llm/README.md @@ -10,7 +10,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis `provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure. -After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. +After route and input validation, the helper appends a log-only `session/title-llm-request` event directly through `Session` before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. Persistence observes the record eagerly; the append does not need a title-specific marker, cast, settlement queue, or flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. ## Configuration diff --git a/packages/session-title/session-title-llm/README.zh.md b/packages/session-title/session-title-llm/README.zh.md index 36567d950e..2bb48b52ae 100644 --- a/packages/session-title/session-title-llm/README.zh.md +++ b/packages/session-title/session-title-llm/README.zh.md @@ -10,7 +10,7 @@ `provider` 和 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助模块会使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由;因此,在任何路由出现前显式刷新时必须提供覆盖项。辅助模块在记录或分发前,以 `maxInputBytes` 测量最终 JSON 封装的用户提示词,包括 seq 字段、包装层与 JSON 转义,而不是将其截断。消费流期间和流完成后都会重新检查超时与调用方取消,因此即使 interceptor 或适配器忽略 abort,也不能接受迟到的成功结果。格式错误或空输出、工具调用和非 stop 结束原因同样会 reject;会话标题服务决定该 reject 属于自动警告还是显式调用方失败。 -路由与输入验证完成后,辅助模块会在模型分发前追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。追加操作共享标题能力的逐会话结算队列,因此取代当前请求的新请求不会与更早回退、请求记录或已接受标题的 flush 冲突。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking,使少量输出预算全部用于可见标题文本;其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 +路由与输入验证完成后,辅助模块会在模型分发前直接通过 `Session` 追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。持久化会尽快观察该记录;追加不需要标题专属标记、类型断言、结算队列或 flush。分发的 envelope 会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。Interceptor 会与记录保持一致,而循环专用重建观察者不会把它与对话 header 比较。DeepSeek 适配器会将该 purpose 映射为关闭 thinking,使少量输出预算全部用于可见标题文本;其他适配器负责自身 purpose 专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。 ## 配置 diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index 673b50311a..51ac30bdf1 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - appendSessionTitleOutOfBand, normalizeSessionTitle, SessionTitleProviderId, } from '@deepseek-ai/dsh-session-title' @@ -43,10 +42,6 @@ declare module '@deepseek-ai/dsh-session' { /** Log-only pre-dispatch record of one session-title model request. */ 'session/title-llm-request': SessionTitleLlmRequestEventData } - - interface OutOfBandSessionEventMap { - 'session/title-llm-request': true - } } /** Capability-owned timeout reason code for auxiliary title requests. */ @@ -264,14 +259,14 @@ export async function generateSessionTitleWithLlm( purpose: 'session-title', signal: callDeadline.signal, }) - await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', { + request.session.append('session/title-llm-request', { titleProvider, messageSeqs: selectedMessages.map(message => message.seq), route, system, messages, maxTokens: config.maxOutputTokens, - }, callDeadline.signal) + }) callDeadline.signal.throwIfAborted() const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(options)) { diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml index 29748c59a5..1e430b8974 100644 --- a/packages/session-title/session-title/README.i18n.yaml +++ b/packages/session-title/session-title/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 2be6f37ce37f525dd567772144e925886ad51b42 -README.zh.md: 7f55b7bba912bf4611f5a058a0390d67b8860b24 +# pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md +README.md: 1939d00f7e78834ec19e2d6b4590cf12af297a30 +README.zh.md: d373c212a193a82567686a634bad79726185e832 diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index 2be6f37ce3..1939d00f7e 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -9,10 +9,10 @@ Only text blocks from human `user/message` events are eligible. The first eligib ## Service: `SessionTitleService` (ctx key: `sessionTitle`) - `get(session)` folds the latest accepted title from a live or replayed log. -- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability. +- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. -Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. +Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. diff --git a/packages/session-title/session-title/README.zh.md b/packages/session-title/session-title/README.zh.md index 7f55b7bba9..d373c212a1 100644 --- a/packages/session-title/session-title/README.zh.md +++ b/packages/session-title/session-title/README.zh.md @@ -9,10 +9,10 @@ ## 服务:`SessionTitleService`(ctx 键:`sessionTitle`) - `get(session)` 从活跃或回放日志折叠最新已接受标题。 -- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已经进入持久化流程的回退追加。 +- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已接受的回退事件。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;资源释放会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 -自动工作绝不会延迟主 agent 响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会加入开放轮次,或使用已经 flush 的零步骤 `session-title` 轮次,并通过 `ctx.sessions.appendOutOfBand()` 追加。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在等待回退持久化前预留顺序;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方记录使用 `appendSessionTitleOutOfBand()`,共享逐会话结算队列,因此替换请求记录会等待更早标题写入,但无需串行等待被取代的模型调用本身。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 +自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会尽快观察该事件,并在常规生命周期检查点排空;标题发布本身不会强制 flush。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方各自追加自己的字面量事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新 revision。 diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 628cc2b551..4708dcafe7 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -9,10 +9,8 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { - OutOfBandSessionEventType, Session, SessionEvent, - SessionEventMap, } from '@deepseek-ai/dsh-session' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' @@ -82,11 +80,6 @@ declare module 'cordis' { } declare module '@deepseek-ai/dsh-session' { - interface TurnTriggerMap { - /** Zero-step turn opened only to durably append a late title update. */ - 'session-title': { kind: 'session-title' } - } - interface SessionEventMap { /** * Latest-wins session title snapshot. Log-only: it never enters the model @@ -94,50 +87,6 @@ declare module '@deepseek-ai/dsh-session' { */ 'session/title': SessionTitleEventData } - - interface OutOfBandSessionEventMap { - 'session/title': true - } -} - -/** Per-session settlement tails for title-capability out-of-band writes. */ -const SESSION_TITLE_WRITE_TAILS = new WeakMap>() - -/** Convert either write outcome into a fulfilled queue tail. */ -function settleSessionTitleWrite(): void {} - -/** - * Serialize one title-capability out-of-band event with its session peers. - * Cancellation is checked when the write reaches the head of the queue; once - * the core append starts, its durability contract runs to completion. - * @param ctx - context exposing the live session store. - * @param session - exact live session that owns the title-capability event. - * @param type - plugin-declared log-only title event type. - * @param data - typed JSON payload for the event. - * @param signal - service or provider lifetime checked before publication starts. - * @returns the durably accepted event. - */ -export async function appendSessionTitleOutOfBand( - ctx: Context, - session: Session, - type: T, - data: SessionEventMap[T], - signal: AbortSignal, -): Promise> { - const predecessor = SESSION_TITLE_WRITE_TAILS.get(session) - const run = Promise.resolve(predecessor).then(() => { - signal.throwIfAborted() - return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' }) - }) - const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite) - SESSION_TITLE_WRITE_TAILS.set(session, tail) - try { - return await run - } finally { - if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) { - SESSION_TITLE_WRITE_TAILS.delete(session) - } - } } /** One eligible human text message exposed to title providers. */ @@ -163,7 +112,7 @@ export interface SessionTitleProviderRequest { readonly signal: AbortSignal } -/** Provider output before service-owned normalization and durable acceptance. */ +/** Provider output before service-owned normalization and log acceptance. */ export interface SessionTitleProviderResult { /** Proposed title text. */ readonly title: string @@ -360,7 +309,7 @@ export class SessionTitleService extends Service { * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. * @param session - exact live session to refresh. - * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection. + * @param signal - optional caller cancellation. * @returns latest accepted title, or `undefined` when no eligible text exists. */ async refresh(session: Session, signal?: AbortSignal): Promise { @@ -509,7 +458,7 @@ export class SessionTitleService extends Service { return this.track(run, work.registration) } - /** Execute and durably accept one current provider revision. */ + /** Execute and accept one current provider revision. */ private async runProvider( session: Session, work: ActiveProviderWork, @@ -528,7 +477,7 @@ export class SessionTitleService extends Service { }) this.assertCurrent(session, work) const accepted = this.validateResult(result, messages) - await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { + session.append('session/title', { title: accepted.title, messageSeqs: [...accepted.messageSeqs], source: { @@ -536,7 +485,7 @@ export class SessionTitleService extends Service { provider: work.registration.provider.id, ...accepted.model === undefined ? {} : { model: accepted.model }, }, - }, work.signal) + }) return this.get(session) } finally { const state = this.work.get(session) @@ -711,11 +660,20 @@ export class SessionTitleService extends Service { if (title.length === 0) return undefined const state = this.stateFor(session) if (state.fallback !== undefined) return state.fallback - const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { - title, - messageSeqs: [first.seq], - source: { kind: 'fallback' }, - }, this.lifetime.signal).then(() => this.get(session)) + const fallback = Promise.resolve().then(() => { + this.assertServiceActive() + if (this.ctx.sessions.get(session.id) !== session) { + throw new Error(`session "${session.id}" is not live in this store`) + } + const accepted = this.get(session) + if (accepted !== undefined) return accepted + session.append('session/title', { + title, + messageSeqs: [first.seq], + source: { kind: 'fallback' }, + }) + return this.get(session) + }) state.fallback = fallback try { return await fallback diff --git a/packages/session-title/session-title/src/invariant.ts b/packages/session-title/session-title/src/invariant.ts index ac6a513391..9bae01a72f 100644 --- a/packages/session-title/session-title/src/invariant.ts +++ b/packages/session-title/session-title/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'session-title-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the service validates provider revisions before their single durable - * append, and its remaining provider lifecycle state is process-local and covered by package tests. + * No runtime invariant: the service validates provider revisions before their + * title append, and its remaining lifecycle state is process-local and covered + * by package tests. */ const install: InvariantInstaller = () => {} diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts index 9981b0f87c..949b73273a 100644 --- a/packages/session-title/session-title/tests/persistence.spec.ts +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -30,9 +30,8 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType setTimeout(resolve, 0)) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.parallel('session/flush', session) + await ctx.sessionTitle.refresh(session) } async function expectPersistedTitle(ctx: Context, id: ReturnType): Promise { @@ -41,13 +40,13 @@ async function expectPersistedTitle(ctx: Context, id: ReturnType event.type)).toEqual([ 'turn/start', 'user/message', - 'session/title', 'turn/end', + 'session/title', ]) } diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 1e5f0c8b26..42819ef542 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -2,7 +2,6 @@ import { Context, type Fiber } from 'cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { - appendSessionTitleOutOfBand, SessionTitleProviderId, type Config, type SessionTitleProvider, @@ -10,16 +9,6 @@ import SessionTitleService, { type SessionTitleProviderResult, } from '@deepseek-ai/dsh-session-title' -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/title-provider-request': { revision: number } - } - - interface OutOfBandSessionEventMap { - 'test/title-provider-request': true - } -} - const CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, @@ -173,38 +162,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(disposeSignal?.aborted).toBe(true) }) - it('rejects fallback refresh cancellation that arrives during durability flush', async () => { - const ctx = await setup() - const seed = new Session(SessionId('fallback-cancel-seed')) - seed.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - const source = appendPrompt(seed, 'Persist this fallback despite caller cancellation') - seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('fallback-cancel'), { seed: seed.events }) - const flushStarted = deferred() - const releaseFlush = deferred() - ctx.on('session/flush', async (subject) => { - if (subject !== session) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - const controller = new AbortController() - - const refresh = ctx.sessionTitle.refresh(session, controller.signal) - await flushStarted.promise - controller.abort(new Error('cancelled while fallback flushed')) - releaseFlush.resolve(undefined) - - await expect(refresh).rejects.toThrow('cancelled while fallback flushed') - expect(ctx.sessionTitle.get(session)).toMatchObject({ - messageSeqs: [source.seq], - source: { kind: 'fallback' }, - }) - }) - - it('shares one durable fallback across concurrent refreshes', async () => { + it('shares one fallback across concurrent refreshes', async () => { const ctx = await setup() const seed = new Session(SessionId('fallback-concurrency-seed')) seed.append('turn/start', { @@ -214,10 +172,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events }) - let flushes = 0 - ctx.on('session/flush', (subject) => { - if (subject === session) flushes += 1 - }) const results = await Promise.all([ ctx.sessionTitle.refresh(session), @@ -226,136 +180,46 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(results[0]).toEqual(results[1]) expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) - expect(session.events.filter(event => event.type === 'turn/start' - && event.data.trigger.kind === 'session-title')).toHaveLength(1) + expect(session.events.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'turn/end', + 'session/title', + ]) expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq]) - expect(flushes).toBe(1) }) - it('reserves overlapping refresh order before fallback durability settles', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionTitleService, CONFIG) - const seed = new Session(SessionId('refresh-order-seed')) - seed.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - const source = appendPrompt(seed, 'Keep the newest explicit refresh') - seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('refresh-order'), { seed: seed.events }) - const flushStarted = deferred() - const releaseFlush = deferred() - let flushCount = 0 - ctx.on('session/flush', async (subject) => { - if (subject !== session || ++flushCount !== 1) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - const result = deferred() + it('lets the newest overlapping explicit refresh win', async () => { + const ctx = await setup() + const session = startSession(ctx, 'refresh-order') + const source = appendPrompt(session, 'Keep the newest explicit refresh') + await settle() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const requests: SessionTitleProviderRequest[] = [] + const results: Array>> = [] ctx.sessionTitle.register({ id: SessionTitleProviderId('refresh-order'), automatic: 'first-message', generate(request) { requests.push(request) + const result = deferred() + results.push(result) return result.promise }, }) const older = ctx.sessionTitle.refresh(session) - const olderOutcome = older.then( - () => undefined, - (error: unknown) => error, - ) - await flushStarted.promise + await settle() const newer = ctx.sessionTitle.refresh(session) await settle() - expect(requests).toHaveLength(1) - expect(requests[0]?.signal.aborted).toBe(false) - releaseFlush.resolve(undefined) - await settle() - expect(requests).toHaveLength(1) - expect(requests[0]?.signal.aborted).toBe(false) - result.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] }) + expect(requests).toHaveLength(2) + expect(requests[0]?.signal.aborted).toBe(true) + expect(requests[1]?.signal.aborted).toBe(false) + results[0]?.resolve({ title: 'Obsolete title', messageSeqs: [source.seq] }) + await expect(older).rejects.toThrow(/superseded/) + results[1]?.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] }) await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' }) - const olderError = await olderOutcome - expect(olderError).toBeInstanceOf(Error) - if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject') - expect(olderError.message).toMatch(/superseded/) - }) - - it('serializes a newer provider write after the superseded write', async () => { - const ctx = await setup() - const session = startSession(ctx, 'refresh-provider-write-order') - const source = appendPrompt(session, 'Serialize explicit provider writes') - await settle() - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const flushStarted = deferred() - const releaseFlush = deferred() - let flushCount = 0 - ctx.on('session/flush', async (subject) => { - if (subject !== session || ++flushCount !== 1) return - flushStarted.resolve(undefined) - await releaseFlush.promise - }) - let generation = 0 - ctx.sessionTitle.register({ - id: SessionTitleProviderId('refresh-provider-write-order'), - automatic: 'first-message', - async generate(request) { - generation += 1 - const revision = generation - await appendSessionTitleOutOfBand(ctx, request.session, 'test/title-provider-request', { - revision, - }, request.signal) - return { - title: `Generated title ${revision}`, - messageSeqs: [source.seq], - } - }, - }) - - const older = ctx.sessionTitle.refresh(session) - const olderOutcome = older.then( - () => undefined, - (error: unknown) => error, - ) - await flushStarted.promise - const middle = ctx.sessionTitle.refresh(session) - const middleOutcome = middle.then( - value => value, - (error: unknown) => error, - ) - await settle() - - expect(generation).toBe(2) - expect(session.events.filter(event => event.type === 'test/title-provider-request')) - .toHaveLength(1) - const newer = ctx.sessionTitle.refresh(session) - const newerOutcome = newer.then( - value => value, - (error: unknown) => error, - ) - await settle() - expect(generation).toBe(3) - expect(session.events.filter(event => event.type === 'test/title-provider-request')) - .toHaveLength(1) - - releaseFlush.resolve(undefined) - const newerResult = await newerOutcome - expect(newerResult).toMatchObject({ title: 'Generated title 3' }) - const olderError = await olderOutcome - expect(olderError).toBeInstanceOf(Error) - if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject') - expect(olderError.message).toMatch(/superseded/) - const middleError = await middleOutcome - expect(middleError).toBeInstanceOf(Error) - if (!(middleError instanceof Error)) throw new Error('expected middle refresh to reject') - expect(middleError.message).toMatch(/superseded/) - expect(session.events.filter(event => event.type === 'test/title-provider-request').map(event => event.data.revision)) - .toEqual([1, 3]) }) it('cancels a queued fallback when the session-title service unloads', async () => { @@ -430,31 +294,6 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' })) }) - it('suppresses a queued fallback failure after service unload begins', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionTitleService, CONFIG) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const session = startSession(ctx, 'service-unload-flush') - appendPrompt(session, 'Fallback whose flush outlives the service') - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const flushStarted = deferred() - const releaseFlush = deferred() - ctx.on('session/flush', async (subject) => { - if (subject !== session) return - flushStarted.resolve(undefined) - await releaseFlush.promise - throw new Error('flush failed during service unload') - }) - - await flushStarted.promise - const disposal = fiber.dispose() - releaseFlush.resolve(undefined) - await disposal - - expect(warn).not.toHaveBeenCalled() - }) - it('warns when a detached session prevents queued fallback publication', async () => { const ctx = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 555a9956ac..82c3f1a57a 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 74a1b504270ec18921156c490c9898a1b0ec1d0e -README.zh.md: 2c358f7ed48cfbbefacf00ce307253d1bdb0c3c8 +# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md +README.md: 220a3f44d0eb14dfed3241c9561800f86a90aecf +README.zh.md: cd03f01cff19c42b8dc91a906ebb89779c494a24 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 74a1b50427..220a3f44d0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. +5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 2c358f7ed4..cd03f01cff 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 +5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 7699174575..ae4c852011 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md -README.md: 4905b4492b3c7266c2d18c32302782d1185d80b5 -README.zh.md: dd70f6274878baa95c0c224c7fd9aff861a8ca32 +README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2 +README.zh.md: fdf35d423b0a650f4e6280e500436fe8bcf5a48c diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 4905b4492b..272c9abe78 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index dd70f62748..fdf35d423b 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -10,7 +10,7 @@ ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运行错误记录)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 ## 脱敏 waterfall diff --git a/packages/ui/jsonrpc/README.i18n.yaml b/packages/ui/jsonrpc/README.i18n.yaml index b91eb86bde..28bb4357db 100644 --- a/packages/ui/jsonrpc/README.i18n.yaml +++ b/packages/ui/jsonrpc/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 96dae46c9c6ecce6643bb408a5e57c2db2275a83 -README.zh.md: 2c257b13a15ad3c6c1bf0c4dd44a04e308e8f0b0 +# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md +README.md: 48eb6106fe4b015f114b264a312249a92128266f +README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303 diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 96dae46c9c..48eb6106fe 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/README.zh.md b/packages/ui/jsonrpc/README.zh.md index 2c257b13a1..8ec2d57a20 100644 --- a/packages/ui/jsonrpc/README.zh.md +++ b/packages/ui/jsonrpc/README.zh.md @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续注入或插件持有的零步骤轮次仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 86ef6d8477..b10ad9dfac 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -128,7 +128,6 @@ export const LINK_MAP: Record = { ScopeKey: 'scope.md', Scoped: 'scope.md', EpochHeader: 'session.md', - OutOfBandSessionEventType: 'session.md', Session: 'session.md', SessionEventMap: 'session.md', TurnEndReason: 'session.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b6545cf07a..b607995003 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -328,11 +328,6 @@ "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/session.md", - "symbol": "OutOfBandSessionEventMap", - "source": "packages/core/session/src/types.ts" - }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", From 059ba4e0d1827b15f979e7b42c98e0d34b2c8820 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:48:46 +0800 Subject: [PATCH 33/43] docs(client): add the reactive-read and contract-currency discipline --- packages/client/AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0fd9e71f01..11be39a93d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -16,6 +16,17 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +## Reactive read and contract-currency discipline + +The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules: + +1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not. +2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`. +3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription. +4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead). +5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render). +6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template). + ## Export discipline (client plugin packages) The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): From eeaf4c72208ba68c1789c536063823c2c4e7af47 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:49:59 +0800 Subject: [PATCH 34/43] fix(ci): stabilize readiness timing boundaries --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../2026-07-16-persistent-pty-sessions.md | 6 ++--- .../2026-07-16-persistent-pty-sessions.zh.md | 6 ++--- .../tests/transport-recovery.spec.ts | 6 +++-- packages/pty/pty-local/README.i18n.yaml | 6 ++--- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/README.zh.md | 2 +- packages/pty/pty-local/src/session.ts | 19 ++++++++++++++- packages/pty/pty-local/tests/local.spec.ts | 15 +++++++----- packages/pty/pty-local/tests/session.spec.ts | 23 +++++++++++++++++++ packages/ui/tui/tests/tui.spec.ts | 6 +++-- 11 files changed, 71 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index fd047fa625..be4fc8f19f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 55ab262b89526911e16a3fec5dd624fc42fb2222 -2026-07-16-persistent-pty-sessions.zh.md: 525d199298bc071c36444d689368317968186c9f +2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba +2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 55ab262b89..691ccd4341 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on ` The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -155,7 +155,7 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification -- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. +- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. @@ -168,7 +168,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model. -**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert the observable behavior — the next send runs — rather than the attribution. +**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert child-produced output from the next send, using a token absent from echoed input, rather than the attribution. **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 525d199298..86d57f79ee 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -74,7 +74,7 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -155,7 +155,7 @@ plugins: ## 验证 -- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 +- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 @@ -168,7 +168,7 @@ plugins: **Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。 -**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试断言可观察行为——下一次 send 能正常执行——而不是断言归因路径。 +**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试使用不会出现在输入回显中的 token,断言下一次 send 中由子进程产生的输出,而不是断言归因路径。 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fcaa9c8d91..9c61f84848 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -204,7 +204,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { apiKey: 'mock-key', successText: 'recovered after timeout', }) - context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 }) + // This crosses the real HTTP idle timer, so leave scheduler slack between + // the stalled attempt and the mock server's immediate successful response. + context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 }) const agent = context.agentLoop.create(SessionId('wire-stall'), { provider: 'deepseek', model: 'mock-model', @@ -216,7 +218,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') - }) + }, 10_000) it('stops after the configured transport retry budget is exhausted', async () => { const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], { diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 279b7ab950..04e34c3f3c 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: abc7d9bc81b6ec43bed6277de7f42ddf5728c7a8 -README.zh.md: 7a99a303ac3b6c83e2acd0f8154e9b313ef673d1 +# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md +README.md: 6f243a6edf3ab8bc228cfda3f6b3b774dabadcbb +README.zh.md: c417828a443d227b5bfcdc1c788cf4ab6751b2ee diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index abc7d9bc81..6f243a6edf 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,7 +8,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7a99a303ac..c417828a44 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 +Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。系统确认每个保留的进程身份都已消失;在 Linux 上,非执行中的僵尸进程也视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。 diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index f6e6e7a20d..a11201bc6c 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -79,14 +79,18 @@ class LocalSendOperation implements PtySendOperation { private readonly output: BoundedTextBuffer private readonly promise: PromiseWithResolvers private finished = false + private initialForegroundLeftWait: boolean constructor( maxBytes: number, readonly startedAt: number, + private readonly initialForegroundPgid: number | undefined, + initialForegroundWasWaiting: boolean, private readonly onCancel: () => void, ) { this.output = new BoundedTextBuffer(maxBytes) this.promise = Promise.withResolvers() + this.initialForegroundLeftWait = !initialForegroundWasWaiting } get done(): Promise { @@ -119,6 +123,14 @@ class LocalSendOperation implements PtySendOperation { return this.output.consume() } + acceptsStdinWait(pgid: number, waiting: boolean): boolean { + // The same group may still expose the wait that existed before terminal.write. + // It becomes post-write evidence only after polling observes it leave that wait. + if (pgid !== this.initialForegroundPgid) return waiting + if (!waiting) this.initialForegroundLeftWait = true + return waiting && this.initialForegroundLeftWait + } + cancel(): boolean { if (this.finished) return false this.onCancel() @@ -200,9 +212,14 @@ export class LocalPtySession implements PtyBackendSession { if (this.active !== undefined) throw new Error('PTY session already has an active send') if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + const initialForegroundPgid = this.inspector.foregroundPgid(this.pid) + const initialForegroundWasWaiting = initialForegroundPgid !== undefined + && this.inspector.isStdinWaiting(initialForegroundPgid) const operation = new LocalSendOperation( this.config.maxReadBytes, Date.now(), + initialForegroundPgid, + initialForegroundWasWaiting, () => { this.interrupt(operation) }, ) this.active = operation @@ -323,7 +340,7 @@ export class LocalPtySession implements PtyBackendSession { const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) { const pgid = this.inspector.foregroundPgid(this.pid) - if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) { + if (pgid !== undefined && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid))) { this.settleActive('stdin_read') return } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57dc11c31..3e5a12173e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -170,12 +170,15 @@ describe('pty-local real shell', () => { controller.abort() const result = await foreground.done expectReadyForNextSend(result.waitReason) - const after = await ctx.pty.startSend(agent, created.sessionId, { - text: 'echo AFTER_SIGINT', + const afterReady = 'AFTER_SIGINT' + const afterCommand = 'printf "AFTER_%s\\n" SIGINT' + expect(afterCommand).not.toContain(afterReady) + const after = ctx.pty.startSend(agent, created.sessionId, { + text: afterCommand, submit: true, - }).done - expect(after.viewport).toContain('AFTER_SIGINT') - expectReadyForNextSend(after.waitReason) + }) + await waitForOutput(after, afterReady, 15_000) + expectReadyForNextSend((await after.done).waitReason) await ctx.pty.kill(agent, created.sessionId) - }, 20_000) + }, 35_000) }) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index e4dbacaa2a..92ac828154 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -115,12 +115,35 @@ describe('LocalPtySession readiness and output', () => { inspector.waiting = true const operation = session.startSend({ text: 'python3', submit: true }) expect(terminal.writes).toEqual(['python3', '\r']) + inspector.pgid = 789 terminal.emitData('Python\r\n>>> ') await vi.advanceTimersByTimeAsync(20) expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } }) expect(operation.cancel()).toBe(false) }) + it('does not reuse a pre-write stdin wait as post-write readiness', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config()) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'echo ready', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(20) + expect(settled).toBe(false) + + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(false) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index f9d2bc0206..c226b7eab3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3040,12 +3040,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() From 095e3944ae51aff390dfd62a6139e55f0c4bc656 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:52:04 +0800 Subject: [PATCH 35/43] docs(client): state the reactive-read rules positively --- packages/client/AGENTS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 11be39a93d..a7d80b3232 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -18,14 +18,14 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- ## Reactive read and contract-currency discipline -The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules: +How live data reaches render code, and what may cross a business boundary: -1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not. -2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`. -3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription. -4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead). -5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render). -6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template). +1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. +2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. +3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration. +4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively). +5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). +6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages) From 80ce377c829ead2bfc36a8ddccf819445eb670cc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 14:56:17 +0800 Subject: [PATCH 36/43] test(session-title): cover direct fallback races --- .../tests/service-contracts.spec.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 42819ef542..f3a4a2ab3c 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -189,6 +189,22 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq]) }) + it('reuses a title accepted before the queued fallback commits', async () => { + const ctx = await setup() + const session = startSession(ctx, 'fallback-already-accepted') + const source = appendPrompt(session, 'Reuse the title that wins the fallback race') + + const refresh = ctx.sessionTitle.refresh(session) + session.append('session/title', { + title: 'Already accepted', + messageSeqs: [source.seq], + source: { kind: 'fallback' }, + }) + + await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) + expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + }) + it('lets the newest overlapping explicit refresh win', async () => { const ctx = await setup() const session = startSession(ctx, 'refresh-order') @@ -254,6 +270,21 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(inactiveError.message).toBe('session-title service disposed') }) + it('suppresses a queued fallback failure after service unload begins', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionTitleService, CONFIG) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = startSession(ctx, 'service-unload-started-fallback') + appendPrompt(session, 'Start fallback before unloading the service') + + await Promise.resolve() + await fiber.dispose() + + expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(warn).not.toHaveBeenCalled() + }) + it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) From a39ffb095a5110215e398bba0d46691062d874eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 15:18:48 +0800 Subject: [PATCH 37/43] fix(session): preserve plugin turn invariants --- ...-remove-synthetic-log-only-turns.i18n.yaml | 4 +- ...6-07-28-remove-synthetic-log-only-turns.md | 2 +- ...7-28-remove-synthetic-log-only-turns.zh.md | 2 +- .../session-title.cordis.snapshot.yml | 53 ++++++++++++++ examples/acp-agent/session-title.cordis.yml | 19 +++++ examples/acp-agent/tests/acp.snapshot.ts | 8 +++ .../session-title-after-turn/input.json | 8 +++ .../replay.override.json | 15 ++++ .../session-title-after-turn/session.jsonl | 16 +++++ .../stdout.expected.jsonl | 4 ++ packages/compact/compact/src/invariant.ts | 52 ++++++++++---- .../compact/compact/tests/invariant.spec.ts | 71 +++++++++++++++++-- packages/core/tools/src/invariant.ts | 37 +++++++++- packages/core/tools/tests/invariant.spec.ts | 48 +++++++++++++ packages/hooks/hook-protocol/src/invariant.ts | 44 ++++++++---- .../hook-protocol/tests/invariant.spec.ts | 38 +++++++++- packages/plan/plan-mode/src/invariant.ts | 30 ++++++-- .../plan/plan-mode/tests/invariant.spec.ts | 58 +++++++++++++-- packages/support/acp-snapshot/src/harness.ts | 35 ++++++++- .../acp-snapshot/tests/harness.spec.ts | 54 +++++++++++++- packages/ui/user-approval/src/invariant.ts | 42 +++++++---- .../ui/user-approval/tests/invariant.spec.ts | 34 +++++++++ 22 files changed, 609 insertions(+), 65 deletions(-) create mode 100644 examples/acp-agent/session-title.cordis.snapshot.yml create mode 100644 examples/acp-agent/session-title.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/session-title-after-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index 7014e8b5d2..b76dc482c3 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: 41ada81ef040cb7826777cd53911c3cb8bbee41e -2026-07-28-remove-synthetic-log-only-turns.zh.md: 82d24cefc01b94ba584dfa7b1a3549eea36eedc9 +2026-07-28-remove-synthetic-log-only-turns.md: af4da00f4fe1d7aebff845cd55053bb5b807c979 +2026-07-28-remove-synthetic-log-only-turns.zh.md: bc9fc0e1637be79d75e18b678d1a7ca8b0543085 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index 41ada81ef0..af4da00f4f 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2 ## Verification -Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. Generated API and type-equivalence catalogs contain no removed symbol. +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, compaction, plan-mode, Code Mode dispatch, and approval invariant companions replay existing logs and reject the same execution-scoped events before commit when no turn is open. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 82d24cefc0..9d72781d6b 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 验证 -核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。生成的 API 和类型等价性目录不含任何已移除符号。 +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、压缩(compaction)、plan-mode、Code Mode 分发和审批的不变量配套组件会回放既有日志,并在没有开放轮次时,于提交前拒绝相同的执行作用域事件。会话标题服务测试会在并发刷新、会话脱离拒绝和最新修订接受场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 ## 后果 diff --git a/examples/acp-agent/session-title.cordis.snapshot.yml b/examples/acp-agent/session-title.cordis.snapshot.yml new file mode 100644 index 0000000000..2226fdf8a7 --- /dev/null +++ b/examples/acp-agent/session-title.cordis.snapshot.yml @@ -0,0 +1,53 @@ +# Keyless session-title composition. Main-agent chunks derive from session.jsonl; +# the auxiliary route consumes replay.override.json with pacing so its accepted +# title commits only after the main turn has closed. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay-main + name: '@deepseek-ai/dsh-llm-replay' + config: + overrideFile: ./.missing-main-replay-override.json + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: llm-replay-title + name: '@deepseek-ai/dsh-llm-replay' + config: + paceMs: 10 + providers: + - id: title-replay + name: Title replay + models: + - id: title-model + - id: session-title-provider + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 32 + timeoutMs: 5000 + provider: title-replay + model: title-model diff --git a/examples/acp-agent/session-title.cordis.yml b/examples/acp-agent/session-title.cordis.yml new file mode 100644 index 0000000000..09c9e2b919 --- /dev/null +++ b/examples/acp-agent/session-title.cordis.yml @@ -0,0 +1,19 @@ +# Session-title snapshot composition: the optional first-message provider uses +# the ordinary DeepSeek route while the ACP app and every other capability stay +# identical to the base example. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: session-title-provider + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 32 + timeoutMs: 5000 + provider: deepseek + model: deepseek-v4-flash diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 5d8f4669bb..9987679607 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -39,6 +39,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) +const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -75,6 +76,13 @@ const SCENARIOS: Scenario[] = [ // text-turn is the pinned-header scenario: the minimal single text turn. // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'session-title-after-turn', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: SESSION_TITLE_CONFIG, + }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, // Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary // app composition. The contract below pins decoded equality and all three diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json b/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json new file mode 100644 index 0000000000..468835606d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly TITLE_DONE. Do not use tools." }, + { "op": "waitForTitleAfterTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json b/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json new file mode 100644 index 0000000000..65b5cea7be --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/replay.override.json @@ -0,0 +1,15 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Late" }, + { "type": "text-delta", "index": 0, "text": " durable" }, + { "type": "text-delta", "index": 0, "text": " session" }, + { "type": "text-delta", "index": 0, "text": " title" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Late durable session title" } }, + { "type": "usage", "usage": { "inputTokens": 12, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl new file mode 100644 index 0000000000..028f41d511 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -0,0 +1,16 @@ +{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"/tmp/session-title-after-turn","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785222848199,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785222848199,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"session/title-llm-request","seq":5,"time":1785222848201,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[1],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"role":"user","content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":1,\"text\":\"Reply with exactly TITLE_DONE. Do not use tools.\"}]"}]}],"maxTokens":32}} +{"type":"assistant/chunk","seq":6,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":7,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"TITLE_DONE"}}} +{"type":"assistant/chunk","seq":8,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TITLE_DONE"}}}} +{"type":"assistant/chunk","seq":9,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":10,"time":1785222848208,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":11,"time":1785222848208,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"TITLE_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1785222848209,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1785222848209,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":14,"time":1785222848209,"data":{"title":"Late durable session title","messageSeqs":[1],"source":{"kind":"provider","provider":"session-title-first-message-llm","model":{"provider":"title-replay","model":"title-model"}}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..651af9e5ce --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TITLE_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index da5d5eba5a..7dd06722e8 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -17,6 +17,11 @@ interface CompactionTrace { summarized: boolean } +interface SessionTrace { + openTurn: number | null + compaction: CompactionTrace | undefined +} + type CompactionTransition = | { kind: 'start'; turn: number } | { kind: 'summary'; turn: number } @@ -24,16 +29,27 @@ type CompactionTransition = /** Validate one compaction event without advancing committed trace state. */ function validateCompactionEvent( - open: CompactionTrace | undefined, + trace: SessionTrace, event: SessionEvent, fail: InvariantFailure, ): CompactionTransition | undefined { + if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { + return undefined + } + if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`) + const open = trace.compaction if (event.type === 'compact/start') { if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`) + if (event.data.turn !== trace.openTurn) { + fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } return { kind: 'start', turn: event.data.turn } } if (event.type === 'compact/summary') { if (open === undefined) fail('compact/summary has no matching compact/start') + if (open.turn !== trace.openTurn) { + fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`) + } if (open.summarized) fail('compact/summary repeated within one compaction') const seqs = event.data.shadowedSeqs if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty') @@ -45,11 +61,13 @@ function validateCompactionEvent( } return { kind: 'summary', turn: open.turn } } - if (event.type !== 'compact/end') return undefined if (open === undefined) fail('compact/end has no matching compact/start') if (event.data.turn !== open.turn) { fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`) } + if (event.data.turn !== trace.openTurn) { + fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } if (event.data.error === undefined && !open.summarized) { fail('successful compact/end requires one compact/summary') } @@ -69,29 +87,39 @@ function applyCompactionTransition( // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): void => { - let open: CompactionTrace | undefined + const seed = (session: Session): SessionTrace => { + const trace: SessionTrace = { openTurn: null, compaction: undefined } + traces.set(session, trace) for (const event of session.events) { - const transition = validateCompactionEvent(open, event, fail) - if (transition !== undefined) open = applyCompactionTransition(transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateCompactionEvent(trace, event, fail) + if (transition !== undefined) trace.compaction = applyCompactionTransition(transition) } - if (open !== undefined) traces.set(session, open) + return trace } - const traceFor = (session: Session): CompactionTrace | undefined => traces.get(session) + const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every compaction event */ if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation') staged.delete(event) - const next = applyCompactionTransition(candidate.transition) - if (next === undefined) traces.delete(session) - else traces.set(session, next) + trace.compaction = applyCompactionTransition(candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 2f4d10ff71..f5fc5c87c7 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -22,15 +22,21 @@ const summary = (overrides: Record = {}) => ({ ...overrides, }) +function startTurn(session: ReturnType, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('compaction invariants', () => { it('accepts successful and failed compaction lifecycles', async () => { const ctx = await setup() const success = ctx.sessions.create() + startTurn(success) success.append('compact/start', { turn: 1 }) success.append('compact/summary', summary()) success.append('compact/end', { turn: 1 }) const failed = ctx.sessions.create() + startTurn(failed, 2) failed.append('compact/start', { turn: 2 }) failed.append('compact/end', { turn: 2, error: 'provider failed' }) }) @@ -40,13 +46,68 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('compact/start', { turn: 3 }) + session.append('compact/start', { turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) - expect(() => session.append('compact/end', { turn: 3, error: 'resume failed' })).not.toThrow() + expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) + it('adopts a bare session and ignores unrelated committed events', async () => { + const ctx = await setup() + const session = new Session(SessionId('bare-compaction-session')) + expect(() => { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) + ctx.emit('session/event', session, { + type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, + }) + ctx.emit('session/event', session, { + type: 'compact/start', seq: 2, time: 2, data: { turn: 1 }, + }) + }).not.toThrow() + }) + + it('rejects compaction outside or for a different open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/) + startTurn(session) + expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) + }) + + it('rejects an unenclosed compaction event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('compact/start', { turn: 1 }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + + it('rejects an open compaction that crosses into another turn', async () => { + const ctx = await setup() + const summarySession = ctx.sessions.create() + startTurn(summarySession) + summarySession.append('compact/start', { turn: 1 }) + summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + startTurn(summarySession, 2) + expect(() => summarySession.append('compact/summary', summary())) + .toThrow(/belongs to turn 1 but open turn is 2/) + + const endSession = ctx.sessions.create() + startTurn(endSession) + endSession.append('compact/start', { turn: 1 }) + endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + startTurn(endSession, 2) + expect(() => endSession.append('compact/end', { turn: 1, error: 'late' })) + .toThrow(/names turn 1 but open turn is 2/) + }) + it.each([ ['summary without start', (session: ReturnType) => { session.append('compact/summary', summary()) @@ -85,6 +146,8 @@ describe('compaction invariants', () => { }, /requires one compact\/summary/], ])('rejects %s', async (_name, action, message) => { const ctx = await setup() - expect(() => { action(ctx.sessions.create()) }).toThrow(message) + const session = ctx.sessions.create() + startTurn(session) + expect(() => { action(session) }).toThrow(message) }) }) diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 2f5f27a281..78666f5890 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,6 +1,7 @@ /** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ToolExecution, ToolExecutionResult } from './index.ts' @@ -28,10 +29,40 @@ function validateResult( } } -/** Install monotonic pipeline and final-snapshot checks. */ -const install: InvariantInstaller = (ctx, fail) => { +/** Install monotonic pipeline, final-snapshot, and code-dispatch enclosure checks. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const stages = new WeakMap() + const openTurns = new WeakMap() + const seed = (session: Session): number | null => { + let openTurn: number | null = null + for (const event of session.events) { + if (event.type === 'turn/start') openTurn = event.data.turn + else if (event.type === 'turn/end') openTurn = null + else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') + && openTurn === null) { + fail(`${event.type} appended outside any open turn`) + } + } + openTurns.set(session, openTurn) + return openTurn + } + const openTurnFor = (session: Session): number | null => openTurns.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') openTurns.set(session, event.data.turn) + else if (event.type === 'turn/end') openTurns.set(session, null) + }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName === 'session/event') { + const [session, event] = args as [Session, SessionEvent] + if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') + && openTurnFor(session) === null) { + fail(`${event.type} appended outside any open turn`) + } + return + } if (eventName === 'tools/pre-execute') { const exec = args[0] as ToolExecution if (stages.has(exec)) fail('tools/pre-execute repeated for one execution') @@ -58,7 +89,7 @@ const install: InvariantInstaller = (ctx, fail) => { validateResult(exec, result, fail) stages.delete(exec) }, { global: true }) -} +}, { inject: ['sessions'] }) /** * Register the tools invariant companion. diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 3752dc7321..f703934a5d 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -10,6 +11,7 @@ const testToolSignal = new AbortController().signal async function setup(): Promise { const ctx = new Context() + await ctx.plugin(SessionStore) await ctx.plugin(InvariantService) await ctx.plugin(ToolsInvariant) return ctx @@ -85,4 +87,50 @@ describe('tool-pipeline invariants', () => { const anonymous = Object.freeze(execution({ name: '' })) expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/) }) + + it('requires code-dispatch records to be turn-enclosed', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const data = { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + } + expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + + it('replays enclosed code-dispatch records on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/code-dispatch', { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + isError: false, + content: [{ type: 'text', text: 'ok' }], + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined() + }) + + it('rejects an unenclosed code-dispatch record on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('tool/code-dispatch-start', { + parentCallId: CallId('parent'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) }) diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 6972d109da..9bc5e6a1e1 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -17,6 +17,11 @@ interface HookTransition { delta: 1 | -1 } +interface HookTrace { + openTurn: number | null + pending: Map +} + /** Correlation key shared by an invoked/result pair. */ function hookKey(data: { turn: number; point: string; handlerId: string }): string { return `${data.turn}\0${data.point}\0${data.handlerId}` @@ -24,10 +29,15 @@ function hookKey(data: { turn: number; point: string; handlerId: string }): stri /** Validate one hook event against committed pending invocations. */ function validateHookEvent( - pending: ReadonlyMap, + trace: HookTrace, event: SessionEvent, fail: InvariantFailure, ): HookTransition | undefined { + if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return undefined + if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`) + if (event.data.turn !== trace.openTurn) { + fail(`${event.type} names turn ${event.data.turn} but open turn is ${trace.openTurn}`) + } if (event.type === 'hook/invoked') { if (event.data.point.length === 0 || event.data.handlerId.length === 0) { fail('hook/invoked point and handlerId must be non-empty') @@ -38,9 +48,8 @@ function validateHookEvent( } return { key: hookKey(event.data), delta: 1 } } - if (event.type !== 'hook/result') return undefined const key = hookKey(event.data) - if ((pending.get(key) ?? 0) === 0) { + if ((trace.pending.get(key) ?? 0) === 0) { fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`) } if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) { @@ -60,28 +69,39 @@ function applyHookTransition(pending: Map, transition: HookTrans // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap>() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): Map => { - const pending = new Map() - traces.set(session, pending) + const seed = (session: Session): HookTrace => { + const trace: HookTrace = { openTurn: null, pending: new Map() } + traces.set(session, trace) for (const event of session.events) { - const transition = validateHookEvent(pending, event, fail) - if (transition !== undefined) applyHookTransition(pending, transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateHookEvent(trace, event, fail) + if (transition !== undefined) applyHookTransition(trace.pending, transition) } - return pending + return trace } - const traceFor = (session: Session): Map => traces.get(session) ?? seed(session) + const traceFor = (session: Session): HookTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every hook provenance event */ if (candidate === undefined || candidate.session !== session) return fail('hook event published without pre-commit validation') staged.delete(event) - applyHookTransition(traceFor(session), candidate.transition) + applyHookTransition(trace.pending, candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index dc7b1d38bb..5092758e74 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -29,12 +29,18 @@ const result = (overrides: Record = {}) => ({ ...overrides, }) +function startTurn(session: Session, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('hook-protocol invariants', () => { it('pairs serial and repeated handler invocations', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) session.append('hook/invoked', invoked()) session.append('hook/invoked', invoked()) + session.append('step/start', { turn: 1, step: 1 }) session.append('hook/result', result()) session.append('hook/result', result()) }) @@ -56,26 +62,52 @@ describe('hook-protocol invariants', () => { const session = new Session(SessionId('bare-hook-session')) expect(() => { ctx.emit('session/event', session, { - type: 'hook/invoked', seq: 0, time: 0, data: invoked(), + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) ctx.emit('session/event', session, { - type: 'hook/result', seq: 1, time: 1, data: result(), + type: 'hook/invoked', seq: 1, time: 1, data: invoked(), + }) + ctx.emit('session/event', session, { + type: 'hook/result', seq: 2, time: 2, data: result(), }) }).not.toThrow() }) + it('rejects hook events outside or for a different open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('hook/invoked', invoked())).toThrow(/outside any open turn/) + startTurn(session) + expect(() => session.append('hook/invoked', invoked({ turn: 2 }))).toThrow(/but open turn is 1/) + }) + + it('rejects an unenclosed hook event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('hook/invoked', invoked()) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + it.each([ [invoked({ point: '' }), /point and handlerId must be non-empty/], [invoked({ handlerId: '' }), /point and handlerId must be non-empty/], [invoked({ dialect: 'other' }), /unknown dialect/], ])('rejects malformed hook invocation %#', async (data, message) => { const ctx = await setup() - expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message) + const session = ctx.sessions.create() + startTurn(session) + expect(() => session.append('hook/invoked', data as never)).toThrow(message) }) it('rejects unmatched and malformed results', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/) session.append('hook/invoked', invoked()) expect(() => session.append('hook/result', result({ durationMs: -1 }))) diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index f501d2fe44..7c377daac6 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -11,9 +11,10 @@ export const name = 'plan-mode-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one `plan/mode` payload before it reaches the durable log. */ -function validateEvent(event: SessionEvent, fail: InvariantFailure): void { +/** Validate one `plan/mode` event before it reaches the durable log. */ +function validateEvent(openTurn: number | null, event: SessionEvent, fail: InvariantFailure): void { if (event.type !== 'plan/mode') return + if (openTurn === null) fail('plan/mode appended outside any open turn') const active = (event.data as { active?: unknown }).active if (typeof active !== 'boolean') { fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`) @@ -23,13 +24,30 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(event, fail) + const traces = new WeakMap() + const seed = (session: Session): number | null => { + let openTurn: number | null = null + traces.set(session, openTurn) + for (const event of session.events) { + if (event.type === 'turn/start') openTurn = event.data.turn + else if (event.type === 'turn/end') openTurn = null + validateEvent(openTurn, event, fail) + traces.set(session, openTurn) + } + return openTurn } + const traceFor = (session: Session): number | null => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') traces.set(session, event.data.turn) + else if (event.type === 'turn/end') traces.set(session, null) + }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const event = (args as [Session, SessionEvent])[1] - validateEvent(event, fail) + const [session, event] = args as [Session, SessionEvent] + validateEvent(traceFor(session), event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index e5bb13687e..569fbdd09e 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -16,24 +16,46 @@ function event(active: unknown): SessionEvent { return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent } +function emitTurnStart(ctx: Context, session: Session): void { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) +} + describe('plan-mode stream invariants', () => { it('accepts either boolean state', async () => { const ctx = await setup() - expect(() => { ctx.emit('session/event', {} as Session, event(true)) }).not.toThrow() - expect(() => { ctx.emit('session/event', {} as Session, event(false)) }).not.toThrow() + const session = new Session(SessionId('plan-state')) + emitTurnStart(ctx, session) + expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow() + expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow() + ctx.emit('session/event', session, { + type: 'turn/end', seq: 3, time: 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }) }) it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => { const ctx = await setup() - expect(() => { ctx.emit('session/event', {} as Session, event(active)) }) + const session = new Session(SessionId(`invalid-${String(active)}`)) + emitTurnStart(ctx, session) + expect(() => { ctx.emit('session/event', session, event(active)) }) .toThrow(/expected a boolean/) }) + it('rejects plan state outside any open turn', async () => { + const ctx = await setup() + expect(() => ctx.sessions.create().append('plan/mode', { active: true })) + .toThrow(/outside any open turn/) + }) + it('ignores unrelated dispatches and session events', async () => { const ctx = await setup() + const session = new Session(SessionId('unrelated')) expect(() => { ctx.emit('tools/change') - ctx.emit('session/event', {} as Session, { + ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) }).not.toThrow() @@ -42,9 +64,33 @@ describe('plan-mode stream invariants', () => { it('rejects invalid existing state on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean }) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('plan/mode', { active: 'plan' as unknown as boolean }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.plugin(InvariantService, { enabled: true }) await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/) }) + + it('replays enclosed existing plan state through its closing boundary', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('plan/mode', { active: true }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined() + }) + + it('rejects unenclosed existing plan state on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create().append('plan/mode', { active: true }) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) }) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index ff98e620d3..d11a4f1b9c 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -50,6 +50,7 @@ const WAIT_POLL_INTERVAL_MS = 10 * `waitForTurnStart` waits for an open durable turn, optionally at or beyond a * specified turn number. `waitForTurnEnd` holds the subprocess open until the * selected session's latest complete raw-JSONL turn boundary is `turn/end`. + * `waitForTitleAfterTurnEnd` additionally waits for a later durable title. * A standalone `cancel` may also wait for a cwd-relative readiness marker. * All wait timeouts default to 10s. */ @@ -67,6 +68,7 @@ export type InputStep = } | { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number } | { op: 'waitForTurnEnd'; timeoutMs?: number } + | { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number } | { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } } /** A scenario's `input.json`: an ordered list of input steps. */ @@ -280,6 +282,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise (id) => { sessionId = id }, (id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn), (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), + (id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs), ) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — @@ -353,6 +356,7 @@ async function runStep( setSessionId: (id: string) => void, waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise, waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, + waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise, ): Promise { switch (step.op) { case 'initialize': @@ -430,6 +434,12 @@ async function runStep( await waitForTurnEnd(sessionId, step.timeoutMs) return } + case 'waitForTitleAfterTurnEnd': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession') + await waitForTitleAfterTurnEnd(sessionId, step.timeoutMs) + return + } case 'waitForTurnStart': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnStart before newSession') @@ -497,6 +507,20 @@ async function waitForPersistedTurnEnd( }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } +/** Wait until a complete provider or fallback title record follows the latest closed turn. */ +async function waitForPersistedTitleAfterTurnEnd( + root: string, + sessionId: string, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, +): Promise { + await vi.waitFor(async () => { + const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) + if (log === undefined || !latestTitleFollowsTurnEnd(log.content)) { + throw new Error(`snapshot-harness: session "${sessionId}" did not persist session/title after turn/end within ${timeoutMs}ms`) + } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) +} + /** Wait for a cwd-relative marker proving an external action reached readiness. */ async function waitForWorkspaceFile( cwd: string, @@ -518,6 +542,13 @@ function latestTurnIsClosed(content: string): boolean { > complete.lastIndexOf('\n{"type":"turn/start",') } +/** Return whether the last complete title record occurs after the last complete turn end. */ +function latestTitleFollowsTurnEnd(content: string): boolean { + const complete = content.slice(0, content.lastIndexOf('\n') + 1) + const turnEnd = complete.lastIndexOf('\n{"type":"turn/end",') + return turnEnd >= 0 && complete.lastIndexOf('\n{"type":"session/title",') > turnEnd +} + /** Return the latest open turn number, validating the persisted boundary record. */ function latestOpenTurn(content: string): number | undefined { const complete = content.slice(0, content.lastIndexOf('\n') + 1) @@ -571,8 +602,8 @@ async function harvestSessionLogs(root: string): Promise { // so session..jsonl maps to the same child on record and replay — replay // re-sorts childFiles by the same key, so the two stay consistent. logs.sort((a, b) => { - const ap = a.parentSession === undefined ? 0 : 1 - const bp = b.parentSession === undefined ? 0 : 1 + const ap = Number(a.parentSession !== undefined) + const bp = Number(b.parentSession !== undefined) return ap - bp || a.createdAt - b.createdAt || a.id.localeCompare(b.id) }) return logs diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 4d60afb148..b908330554 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -536,7 +536,7 @@ describe('runScenario', () => { waitForText: 'thinking about it', }], }, - { agent: AGENT, mode: 'replay', fixtureFile }, + { agent: AGENT, mode: 'replay', fixtureFile, configPath: AGENT.configPath }, ) expect(result.rawStdout).toContain('thinking about it') }) @@ -560,6 +560,32 @@ describe('runScenario', () => { expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"') }) + it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'session/title', seq: 2, time: 3, data: { title: 'Late title' } }, + ], + }], + }) + const result = await runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTitleAfterTurnEnd' }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"session\/title"/) + }) + it('waitForTurnStart can require a later durable turn before continuing', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', @@ -692,6 +718,31 @@ describe('runScenario', () => { )).rejects.toThrow(/did not persist turn\/end within 20ms/) }) + it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + persistLogsOnCancel: true, + logs: [{ + file: 'project/main/session.jsonl', + lines: [ + { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, + { type: 'session/title', seq: 1, time: 1, data: { title: 'Early title' } }, + { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, + ], + }], + }) + await expect(runScenario( + { + steps: [ + ...boot, + { op: 'promptAndCancel', text: 'hang' }, + { op: 'waitForTitleAfterTurnEnd', timeoutMs: 20 }, + ], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + )).rejects.toThrow(/did not persist session\/title after turn\/end within 20ms/) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( @@ -797,6 +848,7 @@ describe('runScenario', () => { [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/], [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/], + [{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/], [{ op: 'cancel' }, /cancel before newSession/], ] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => { const { fixtureFile } = await scenario({}) diff --git a/packages/ui/user-approval/src/invariant.ts b/packages/ui/user-approval/src/invariant.ts index 5643e412c5..6eca2571ff 100644 --- a/packages/ui/user-approval/src/invariant.ts +++ b/packages/ui/user-approval/src/invariant.ts @@ -18,19 +18,26 @@ type ApprovalTransition = | { kind: 'asked'; id: ApprovalRequestId } | { kind: 'decided'; id: ApprovalRequestId } +interface ApprovalTrace { + openTurn: number | null + pending: Set +} + /** Validate one approval event against committed unmatched questions. */ function validateApprovalEvent( - pending: ReadonlySet, + trace: ApprovalTrace, event: SessionEvent, fail: InvariantFailure, ): ApprovalTransition | undefined { if (event.type === 'approval/asked') { + if (trace.openTurn === null) fail('approval/asked appended outside any open turn') if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty') - if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) + if (trace.pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`) return { kind: 'asked', id: event.data.id } } if (event.type === 'approval/decided') { - if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) + if (trace.openTurn === null) fail('approval/decided appended outside any open turn') + if (!trace.pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`) if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) { fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`) } @@ -52,28 +59,39 @@ function applyApprovalTransition(pending: Set, transition: Ap // Event owners keep precommit staging local so their vocabularies never move into a central helper. /* jscpd:ignore-start */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const traces = new WeakMap>() + const traces = new WeakMap() const staged = new WeakMap() - const seed = (session: Session): Set => { - const pending = new Set() - traces.set(session, pending) + const seed = (session: Session): ApprovalTrace => { + const trace: ApprovalTrace = { openTurn: null, pending: new Set() } + traces.set(session, trace) for (const event of session.events) { - const transition = validateApprovalEvent(pending, event, fail) - if (transition !== undefined) applyApprovalTransition(pending, transition) + if (event.type === 'turn/start') trace.openTurn = event.data.turn + else if (event.type === 'turn/end') trace.openTurn = null + const transition = validateApprovalEvent(trace, event, fail) + if (transition !== undefined) applyApprovalTransition(trace.pending, transition) } - return pending + return trace } - const traceFor = (session: Session): Set => traces.get(session) ?? seed(session) + const traceFor = (session: Session): ApprovalTrace => traces.get(session) ?? seed(session) for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + const trace = traceFor(session) + if (event.type === 'turn/start') { + trace.openTurn = event.data.turn + return + } + if (event.type === 'turn/end') { + trace.openTurn = null + return + } if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return const candidate = staged.get(event) /* v8 ignore next -- internal/dispatch stages every package-owned pair event */ if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation') staged.delete(event) - applyApprovalTransition(traceFor(session), candidate.transition) + applyApprovalTransition(trace.pending, candidate.transition) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/ui/user-approval/tests/invariant.spec.ts b/packages/ui/user-approval/tests/invariant.spec.ts index ea9d4472e5..924272a360 100644 --- a/packages/ui/user-approval/tests/invariant.spec.ts +++ b/packages/ui/user-approval/tests/invariant.spec.ts @@ -13,10 +13,15 @@ async function setup(): Promise { return ctx } +function startTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) +} + describe('approval invariants', () => { it('accepts paired audit events and closed policy values', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) const id = ApprovalRequestId('ask-1') session.append('approval/asked', { id, toolName: 'bash' }) session.append('approval/decided', { id, outcome: 'allowed-once' }) @@ -47,14 +52,43 @@ describe('approval invariants', () => { type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const }, } as const expect(() => { + ctx.emit('session/event', session, { + type: 'turn/start', seq: 0, time: 0, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }) ctx.emit('session/event', session, asked) ctx.emit('session/event', session, decided) }).not.toThrow() }) + it('rejects audit events outside any open turn', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('approval/asked', { + id: ApprovalRequestId('ask-1'), toolName: 'bash', + })).toThrow(/outside any open turn/) + expect(() => session.append('approval/decided', { + id: ApprovalRequestId('ask-1'), outcome: 'rejected', + })).toThrow(/outside any open turn/) + }) + + it('rejects an unenclosed audit event when replaying an existing session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create() + startTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('approval/asked', { + id: ApprovalRequestId('ask-replay'), toolName: 'bash', + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(ApprovalInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) + }) + it('rejects malformed and unpaired audit events', async () => { const ctx = await setup() const session = ctx.sessions.create() + startTurn(session) const id = ApprovalRequestId('ask-1') expect(() => session.append('approval/asked', { id, toolName: '' })) .toThrow(/toolName must be non-empty/) From 16958cdbe75ddf7514c002307e6264fde8871983 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:22:05 +0800 Subject: [PATCH 38/43] fix(pty): observe readiness before exact probe threshold --- packages/pty/pty-local/src/session.ts | 16 ++++++++----- packages/pty/pty-local/tests/session.spec.ts | 25 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index a11201bc6c..46f3be2ca8 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -125,7 +125,8 @@ class LocalSendOperation implements PtySendOperation { acceptsStdinWait(pgid: number, waiting: boolean): boolean { // The same group may still expose the wait that existed before terminal.write. - // It becomes post-write evidence only after polling observes it leave that wait. + // Observe every poll so a departure before the exact-settlement threshold + // still makes a later return to that wait post-write evidence. if (pgid !== this.initialForegroundPgid) return waiting if (!waiting) this.initialForegroundLeftWait = true return waiting && this.initialForegroundLeftWait @@ -338,12 +339,15 @@ export class LocalPtySession implements PtyBackendSession { } const elapsed = Date.now() - operation.startedAt const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 - if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) { + let acceptsStdinWait = false + if (startupHasOutput) { const pgid = this.inspector.foregroundPgid(this.pid) - if (pgid !== undefined && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid))) { - this.settleActive('stdin_read') - return - } + acceptsStdinWait = pgid !== undefined + && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid)) + } + if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) { + this.settleActive('stdin_read') + return } // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 92ac828154..53ce61eebc 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -144,6 +144,31 @@ describe('LocalPtySession readiness and output', () => { expect((await operation.done).waitReason).toBe('stdin_read') }) + it('tracks a pre-write wait exit before exact probing begins', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config({ + exactProbeAfterMs: 50, + idleSilenceMs: 100, + timeoutMs: 200, + })) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'fast command', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(30) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(true) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() From 833a58784f932a0e2d7ccf7f4a088af489a7e7d7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 15:22:16 +0800 Subject: [PATCH 39/43] docs(session): refresh invariant metadata --- .../2026-07-28-remove-synthetic-log-only-turns.i18n.yaml | 2 +- docs/event-producer-consumer.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index b76dc482c3..9031dc8eb0 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md 2026-07-28-remove-synthetic-log-only-turns.md: af4da00f4fe1d7aebff845cd55053bb5b807c979 -2026-07-28-remove-synthetic-log-only-turns.zh.md: bc9fc0e1637be79d75e18b678d1a7ca8b0543085 +2026-07-28-remove-synthetic-log-only-turns.zh.md: 9d72781d6b7cf396a830790d108f4ff25adc816a diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3a72d5c0f9..e9e5bb6e6a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:56`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | From 0b1a8b3fee1ed367cf83925c7fd425e4d0a2469d Mon Sep 17 00:00:00 2001 From: imccyu Date: Tue, 28 Jul 2026 15:25:38 +0800 Subject: [PATCH 40/43] fix: ci --- apps/web/tests/workspace-flow.snapshot.ts | 7 +++++-- vitest.config.ts | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..b33df1b157 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -165,7 +165,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen sidebar: visibleText(tree), }).toMatchInlineSnapshot(` { - "chip": "New Workspace", + "chip": "Choose workspace", "composerDisabled": true, "headline": "Let's start building", "sendDisabled": true, @@ -232,7 +232,10 @@ it('New Session reuses the Workspace blank session and converts the single visib // New Session resolves through the recent Workspace and reuses its blank // session in place: no locked interlude, no second entity. - fireEvent.click(screen.getByRole('button', { name: 'New session' })) + const newSessionButton = screen.getAllByRole('button', { name: 'New session' }) + .find(button => visibleText(button) === 'New Session') + if (newSessionButton === undefined) throw new Error('New Session button missing') + fireEvent.click(newSessionButton) const composer = await findHeroComposer() await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) diff --git a/vitest.config.ts b/vitest.config.ts index d19b1aa0f8..0141b11f96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -103,6 +103,9 @@ export default defineConfig({ // yet. TODO(gui): cover and remove as the client test lane matures. 'packages/client/ui-trajectory/src/*', 'packages/client/ui-question/src/client/QuestionComposer.tsx', + 'packages/client/ui-primitives/src/Menu.tsx', + 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', + 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*', From f85736e81409c8b6a2c79d4fd19d5190d8ddf617 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:26:00 +0800 Subject: [PATCH 41/43] ci: run sandbox matrix on master only --- ...-21-serial-cross-platform-ci-reference.i18n.yaml | 4 ++-- ...2026-07-21-serial-cross-platform-ci-reference.md | 9 ++++++++- ...6-07-21-serial-cross-platform-ci-reference.zh.md | 9 ++++++++- .github/workflows/sandbox.yml | 13 +++++++------ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 51073f506e..333d19da04 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3 -2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 +2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 0f324a5c235a2b495186caf0d6cc0490cc5876e3 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 9ecd7906d2..71b364f72a 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -12,6 +12,8 @@ Encoding the one-minute non-Windows target and three-minute Windows target as jo Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system? +Real-kernel sandbox proofs require specific hosted operating systems and architectures but do not provide a pull-request merge verdict. Repeating that four-job matrix for every pull request consumes Linux, arm64 Linux, and macOS capacity without satisfying branch protection or contributing to the required aggregate in another workflow. + ## Decision [CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. @@ -22,7 +24,9 @@ Platform ownership remains explicit inside that complete aggregate. `pty-local` The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. +The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge. + +Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. @@ -31,6 +35,7 @@ The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, a - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. - **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. +- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. - **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. @@ -40,6 +45,8 @@ The workflow contains duplicated setup steps and a master reference run can take The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. +A sandbox regression visible only to a real host kernel or the packed Landlock install can merge before the master run reports it. That post-merge detection window is accepted in exchange for removing four non-blocking jobs from every pull request; the default branch retains the complete signal. + The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it. Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index ed927012c5..0f324a5c23 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -12,6 +12,8 @@ Status: implemented 评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样? +真实内核沙箱验证需要特定的托管操作系统和架构,但不会产生拉取请求的合并裁决。在每个拉取请求上重复运行这个包含四个作业的矩阵会消耗 Linux、arm64 Linux 和 macOS 容量,却既不能满足分支保护,也无法参与另一工作流中的必需聚合结果。 + ## 决策 [CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 @@ -22,7 +24,9 @@ Status: implemented macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 +独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。 + +master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 @@ -31,6 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 +- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 @@ -40,6 +45,8 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 +仅在真实宿主内核或打包后的 Landlock 安装中可见的沙箱回归,可能在 master 上的运行报告前已经合并。我们接受这个合并后检测窗口,以换取从每个拉取请求中移除四个非阻塞作业;默认分支仍保留完整信号。 + 明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 0d14914bfd..36f58cc75b 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -1,15 +1,16 @@ -# Sandbox CI: the keyless real-kernel confinement proofs. A separate workflow -# from ci.yml because the axis is different — these jobs fan out over -# OS×runner (kernel capabilities), not node versions. The Landlock launcher -# arrives from the registry with `pnpm install` (the npm package family +# Sandbox CI: the keyless real-kernel confinement proofs. This master-only +# reference stays outside the pull-request verdict; rationale lives in +# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. +# A separate workflow from ci.yml because the axis is different — these jobs +# fan out over OS×runner (kernel capabilities), not node versions. The Landlock +# launcher arrives from the registry with `pnpm install` (the npm package family # `node-addon-landlock-run`, built and released from its own repository), so # these legs exercise the true consumer path — nothing is compiled here. name: Sandbox on: push: - branches: [main, master] - pull_request: + branches: [master] concurrency: group: ${{ github.workflow }}-${{ github.ref }} From cbbd888cab03fde636f8319d7393a59773b35c74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:33:20 +0800 Subject: [PATCH 42/43] simplify gate graph validation --- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 6 +- .../2026-07-06-parallel-pre-push-gates.md | 12 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 12 +- ...2026-07-27-replayable-gate-plans.i18n.yaml | 6 - .../2026-07-27-replayable-gate-plans.md | 47 -- .../2026-07-27-replayable-gate-plans.zh.md | 47 -- scripts/run-gates.spec.ts | 343 ++---------- scripts/run-gates.ts | 521 ++++-------------- 8 files changed, 189 insertions(+), 805 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index cfa2cafeb6..9821b8fea4 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-06-parallel-pre-push-gates.md: 0c3311b259a2fcf00deb4eed491c301a0c330186 -2026-07-06-parallel-pre-push-gates.zh.md: 6949237d2e025034162f66950033d3ad6ecf11ea +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +2026-07-06-parallel-pre-push-gates.md: d86642b7feb82908ec792db0c6a3da403cfc79fd +2026-07-06-parallel-pre-push-gates.zh.md: 0425cf1a01b56604a07be366dd46d3920c5fb487 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 0c3311b259..d86642b7fe 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -12,12 +12,18 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. + +The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that ESLint must not traverse; source compatibility checks can overlap the validation chain. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md)). +## Verification + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer inventory and dependency edges, and exercises signal termination through a real child process. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. + ## Alternatives considered - **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup. @@ -28,6 +34,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie ## Consequences -Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. The cost is a custom scheduler with an explicit mode inventory. +Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. Invalid graphs fail before partial execution. The cost is a custom scheduler with an explicit mode inventory. + +The consumer validation chain delays restored-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. `publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 6949237d2e..0425cf1a01 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -12,12 +12,18 @@ Status: implemented ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,遵守产物依赖,缓冲可归因的输出,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 + +Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 ESLint 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 各门禁的包脚本仍是临时本地运行所用的词汇。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 在调度器中拥有其成员列表([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 +## 验证 + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方清单和依赖边,并通过真实子进程验证信号终止。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 + ## 曾考虑的替代方案 - **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。 @@ -28,6 +34,8 @@ Status: implemented ## 后果 -由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。代价是维护一个具有显式模式清单的定制调度器。 +由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 + +这条验证链会让已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 `publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml deleted file mode 100644 index 771b3270bd..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 2b912024ce337063cf677532e85d64f4fd8ab4a7 -2026-07-27-replayable-gate-plans.zh.md: 26bf4632ea84292ed48ce0cf8405d06e5bfa4aa9 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md deleted file mode 100644 index 2b912024ce..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: Validated, self-describing, replayable gate plans - -Status: implemented - -English | [中文](2026-07-27-replayable-gate-plans.zh.md) - -## Problem - -Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. - -Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing later commands to consume restored artifacts before publint and built-package invariant checks established their public and runtime-closure contracts. - -## Decision - -[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. - -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Gate-level spawn overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection serializes those operations without resolving them against inherited values; values under secret-like declared names are redacted. - -`--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. - -The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. - -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`. Source compatibility smokes may overlap the validation stages; lint and duplication wait for built-package invariant validation so ESLint cannot traverse its transient staged package views, then may overlap downstream consumers. - -## Verification - -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, direct and symlinked entries emit one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. - -## Alternatives considered - -**Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. - -**Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. - -**Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. - -**Persist child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record. - -**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates. - -## Consequences - -The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. - -Later artifact consumers and lint start only after publint and built-package invariant validation, so ESLint cannot traverse the verifier's transient staged views and those downstream gates may overlap one another. Source compatibility smokes still overlap both validation stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. - -Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md deleted file mode 100644 index 26bf4632ea..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 经过验证、自描述、可回放的门禁计划 - -Status: implemented - -[English](2026-07-27-replayable-gate-plans.md) | 中文 - -## 问题 - -仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 - -故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许后续命令在 publint 和已构建包(package)不变式检查确立恢复后产物的公开契约与运行时闭包契约之前,就消费这些产物。 - -## 决策 - -[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 - -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。门禁级 spawn 覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查会序列化这些操作,而不会结合继承值进行解析;声明的名称若疑似机密,其值会被脱敏。 - -`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 - -调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 - -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段。源码兼容性冒烟测试可以与验证阶段并行;lint 和 duplication 会等待已构建包不变式验证,以免 ESLint 遍历验证过程中临时暂存的包视图,之后可以与下游消费方并行。 - -## 验证 - -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、直接入口和符号链接入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 - -## 曾考虑的替代方案 - -**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 - -**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 - -**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 - -**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。 - -**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。 - -## 后果 - -调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 - -后续产物消费方和 lint 只在 publint 和已构建包不变式验证通过后才启动,因此 ESLint 不会遍历验证器临时暂存的视图,而这些下游门禁可以彼此并行。源码兼容性冒烟测试仍可与这两个验证阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 - -缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 344f678374..38b7d96a0c 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,29 +1,14 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - executeGatePlan, - formatGatePlanJson, - formatGatePlanList, + defaultConcurrency, formatGateResultReason, - gateDependencyClosure, - gatePlanForMode, - listedGatePlan, - parseCliRequest, - resolvePlanConcurrency, + gatesForMode, runGate, - validateGatePlan, + runGates, type Gate, - type GatePlan, type GateResult, } from './run-gates.ts' -const repositoryRoot = join(import.meta.dirname, '..') - -afterEach(() => vi.unstubAllEnvs()) - function gate(id: string, options: Partial = {}): Gate { return { id, @@ -35,17 +20,11 @@ function gate(id: string, options: Partial = {}): Gate { } } -function plan(gates: Gate[]): GatePlan { - return { mode: 'check-all', script: 'check:all', gates } -} - function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { return { gate: subject, status, durationMs: 10, - stdout: '', - stderr: '', output: [], exitCode: status === 'passed' ? 0 : 1, signalCode: null, @@ -63,7 +42,7 @@ function withPnpmEntrypoint(action: () => T): T { } } -describe('gate plan validation', () => { +describe('gate graph validation', () => { it.each([ 'ci-primary', 'ci-static', @@ -78,282 +57,54 @@ describe('gate plan validation', () => { 'node-compat', 'check-all', 'doc-sync', - ] as const)('constructs a valid non-empty %s plan', (mode) => { - const subject = withPnpmEntrypoint(() => gatePlanForMode(mode)) - expect(() => { - validateGatePlan(subject) - }).not.toThrow() + ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => { + const subject = withPnpmEntrypoint(() => gatesForMode(mode)) + const execute = vi.fn(async (item: Gate) => resultFor(item)) + + await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) it.each([ - ['empty', plan([]), /plan has no gates/], - ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], - ['unsafe ids', plan([gate('unsafe id')]), /gate id "unsafe id" must contain only lowercase letters/], - ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], - ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], - ])('rejects %s before starting a child', async (_label, invalid, message) => { + ['empty', [], /gate graph has no gates/], + ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], + ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/], + ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], + ] as const)('rejects %s before starting a child', async (_label, invalid, message) => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) - await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message) + + await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message) expect(execute).not.toHaveBeenCalled() }) - it('rejects an invalid plan worker bound', () => { - expect(() => { - validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 }) - }).toThrow( - 'maxWorkers must be a positive integer', - ) - }) - - it('rejects an executor request above the plan worker ceiling before starting a child', async () => { + it('rejects an invalid worker count before starting a child', async () => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) - await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow( - 'exceeds the check-all plan ceiling 1', - ) + + await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer') expect(execute).not.toHaveBeenCalled() }) - it('reports a settled failure before an unrelated gate finishes', async () => { - const first = gate('first') - const second = gate('second') - const settle = new Map void>() - const observed: string[] = [] - const execution = executeGatePlan( - plan([first, second]), - 2, - subject => new Promise(resolve => settle.set(subject.id, resolve)), - result => observed.push(`${result.gate.id}:${result.status}`), - ) - - const settleFirst = settle.get(first.id) - const settleSecond = settle.get(second.id) - if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start') - settleFirst(resultFor(first, 'failed')) - await vi.waitFor(() => { - expect(observed).toEqual(['first:failed']) - }) - settleSecond(resultFor(second)) - - await expect(execution).resolves.toHaveLength(2) - expect(observed).toEqual(['first:failed', 'second:passed']) - }) - - it('propagates dependency skips in causal order', async () => { - const leaf = gate('leaf', { needs: ['middle'] }) - const middle = gate('middle', { needs: ['root'] }) - const rootGate = gate('root') + it('skips dependents after their prerequisite fails', async () => { + const dependent = gate('dependent', { needs: ['root'] }) + const root = gate('root') const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) - const observed: string[] = [] - const results = await executeGatePlan( - plan([leaf, middle, rootGate]), - 1, - execute, - result => observed.push(`${result.gate.id}:${result.status}`), - ) + const results = await runGates([dependent, root], 1, execute) expect(execute).toHaveBeenCalledOnce() - expect(execute).toHaveBeenCalledWith(rootGate) - expect(observed).toEqual(['root:failed', 'middle:skipped', 'leaf:skipped']) - expect(results.find(result => result.gate === middle)?.error).toBe('dependency failed or skipped: root') - expect(results.find(result => result.gate === leaf)?.error).toBe('dependency failed or skipped: middle') - }) - - it('selects a target with its transitive dependencies in canonical plan order', () => { - const subject = plan([ - gate('prepare'), - gate('build', { needs: ['prepare'] }), - gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }), - gate('unrelated'), - ]) - expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot']) - expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({ - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - }) + expect(execute).toHaveBeenCalledWith(root) + expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' }) }) }) -describe('gate plan inspection and replay', () => { - it('parses package-script separators, list JSON, and focused runs', () => { - expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ - mode: 'check-all', list: true, json: true, - }) - expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ - mode: 'check-all', list: false, json: false, only: 'snapshot', - }) - expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') - expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') - }) +describe('Node 24 consumer graph', () => { + it('owns the seven-command pool and orders restored-artifact consumers', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) - it('renders deterministic human and stable JSON fields without inherited environment values', () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') - const subject = plan([ - gate('prepare'), - gate('subject', { - needs: ['prepare'], - allowFailure: true, - env: { - Z_MODE: { operation: 'set', value: 'lib' }, - ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - }, - }), - ]) - - const json = formatGatePlanJson(subject) - expect(formatGatePlanJson(subject)).toBe(json) - expect(json).not.toContain('ambient-secret') - expect(json).not.toContain('scheduler-secret') - expect(JSON.parse(json)).toEqual({ - version: 1, - mode: 'check-all', - script: 'check:all', - scope: 'complete', - maxWorkers: null, - gates: [ - { id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true }, - { - id: 'subject', - label: 'subject', - command: 'run subject', - needs: ['prepare'], - env: { - ACCESS_TOKEN: { operation: 'set', value: '' }, - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - Z_MODE: { operation: 'set', value: 'lib' }, - }, - blocking: false, - }, - ], - }) - expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject') - expect(formatGatePlanList(subject)).toContain('needs: prepare') - expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)') - }) - - it('emits one clean JSON object through the documented silent package-script entry', () => { - const result = spawnSync('pnpm', [ - '--silent', - 'run', - 'check:ci:consumers', - '--', - '--list', - '--json', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - shell: process.platform === 'win32', - timeout: 10_000, - }) - if (result.error !== undefined) throw result.error - expect(result.status, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ - version: 1, - mode: 'ci-consumers', - script: 'check:ci:consumers', - scope: 'complete', - maxWorkers: 7, - }) - }) - - it.skipIf(process.platform === 'win32')('executes when the script entry path is a symlink', () => { - const temporary = mkdtempSync(join(tmpdir(), 'dsh-run-gates-entry-')) - const entry = join(temporary, 'run-gates.ts') - try { - symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) - const result = spawnSync(process.execPath, [ - '--import', - 'tsx', - entry, - 'ci-consumers', - '--list', - '--json', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - env: { ...process.env, npm_execpath: process.env.npm_execpath ?? '/private/pnpm.cjs' }, - timeout: 10_000, - }) - - expect(result.status, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ mode: 'ci-consumers', maxWorkers: 7 }) - } finally { - rmSync(temporary, { recursive: true, force: true }) - } - }) - - it('prints focused-run context and replay through a real failure block', () => { - const result = spawnSync(process.execPath, [ - '--import', - 'tsx', - join(repositoryRoot, 'scripts/run-gates.ts'), - 'ci-lint', - '--only', - 'duplication', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - env: { ...process.env, npm_execpath: join(repositoryRoot, 'scripts/missing-pnpm-entrypoint.cjs') }, - timeout: 10_000, - }) - - expect(result.status).toBe(1) - expect(result.stdout).toContain('partial diagnostic evidence; the complete owning mode is pnpm run check:ci:lint') - expect(result.stderr).toContain('outcome: exit 1') - expect(result.stderr).toContain('replay: pnpm run check:ci:lint -- --only duplication') - }) - - it('applies append and set operations through the child spawn environment', async () => { - vi.stubEnv('NODE_OPTIONS', '--trace-warnings') - vi.stubEnv('INHERITED', 'kept') - const result = await runGate(gate('subject', { - args: ['-e', 'process.stdout.write(JSON.stringify({ nodeOptions: process.env.NODE_OPTIONS, mode: process.env.MODE, inherited: process.env.INHERITED }))'], - env: { - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - MODE: { operation: 'set', value: 'lib' }, - }, - })) - - expect(result.status).toBe('passed') - expect(JSON.parse(result.stdout)).toEqual({ - nodeOptions: '--trace-warnings --max-old-space-size=8192', - mode: 'lib', - inherited: 'kept', - }) - }) - - it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => { - const subjectGate = gate('terminated', { - args: ['-e', "process.kill(process.pid, 'SIGTERM')"], - }) - const result = await runGate(subjectGate) - - expect(result.status).toBe('failed') - expect(result.exitCode).toBeNull() - expect(result.signalCode).toBe('SIGTERM') - expect(formatGateResultReason(result)).toBe('signal SIGTERM') - }) -}) - -describe('Node 24 consumer plan', () => { - it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => { - const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers')) - validateGatePlan(subject) - expect(subject.maxWorkers).toBe(7) - expect(listedGatePlan(subject).maxWorkers).toBe(7) - expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({ + expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ workers: 7, - source: 'ci-consumers plan default 7', + source: 'ci-consumers gate count', }) - expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({ - workers: 4, - source: '$DSH_GATE_CONCURRENCY', - }) - expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({ - workers: 7, - source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7', - }) - expect(subject.gates.map(item => item.id)).toEqual([ + expect(subject.map(item => item.id)).toEqual([ 'lint-and-duplication', 'node-compat', 'snapshot', @@ -362,19 +113,25 @@ describe('Node 24 consumer plan', () => { 'built-package-invariants', 'built-bin-smoke', ]) - expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() - expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) - expect(subject.gates.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) + expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { - expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } - expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([ - 'snapshot', - 'publint', - 'built-package-invariants', - ]) - expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({ - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - }) + expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + }) +}) + +describe('gate process outcomes', () => { + it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { + const result = await runGate(gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + })) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a517a19127..aa2378b24f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,46 +1,35 @@ /** - * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. + * Run local and CI quality gates with bounded in-process scheduling. * * Package scripts own public aggregate names; this runner owns their validated - * dependency graphs, scheduler environment, and replay diagnostics. - * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md + * dependency graphs, scheduler environment, and process diagnostics. + * @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md */ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -import { parseArgs } from 'node:util' - -const MODE_SCRIPTS = { - 'ci-primary': 'check:ci', - 'ci-static': 'check:ci:static', - 'ci-lint': 'check:ci:lint', - 'ci-coverage': 'check:ci:coverage', - 'ci-snapshot': 'check:ci:snapshot', - 'ci-artifacts': 'check:ci:artifacts', - 'ci-consumers': 'check:ci:consumers', - 'ci-windows-blocking': 'check:ci:windows-blocking', - 'ci-windows-complete': 'check:ci:windows-complete', - 'ci-windows-observational': 'check:ci:windows-observational', - 'node-compat': 'check:node-compat', - 'check-all': 'check:all', - 'doc-sync': 'doc-sync', -} as const /** A named aggregate exposed by the gate runner. */ -export type Mode = keyof typeof MODE_SCRIPTS - -const MODES = Object.keys(MODE_SCRIPTS) as Mode[] +export type Mode = + | 'ci-primary' + | 'ci-static' + | 'ci-lint' + | 'ci-coverage' + | 'ci-snapshot' + | 'ci-artifacts' + | 'ci-consumers' + | 'ci-windows-blocking' + | 'ci-windows-complete' + | 'ci-windows-observational' + | 'node-compat' + | 'check-all' + | 'doc-sync' type GateResultStatus = 'passed' | 'failed' | 'skipped' type GateState = 'pending' | 'running' | GateResultStatus -/** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ -export type GateEnvironmentOverride = - | { operation: 'set'; value: string } - | { operation: 'append'; value: string } - -/** A command and its dependency metadata inside one gate plan. */ +/** A command and its dependency metadata inside one aggregate. */ export interface Gate { id: string label: string @@ -48,27 +37,15 @@ export interface Gate { command: string args: string[] needs?: string[] - env?: Record - input?: string - verify?: (result: GateResult) => Promise + env?: Record allowFailure?: boolean } -/** A complete executable aggregate and the package script that owns its diagnostics. */ -export interface GatePlan { - mode: Mode - script: string - gates: Gate[] - maxWorkers?: number -} - /** The observed outcome of one gate process. */ export interface GateResult { gate: Gate status: GateResultStatus durationMs: number - stdout: string - stderr: string output: GateOutputChunk[] exitCode: number | null signalCode: NodeJS.Signals | null @@ -85,37 +62,11 @@ interface RunningGate { promise: Promise } -/** The effective worker count and the facts that selected it. */ -export interface ResolvedConcurrency { +interface ConcurrencyDefault { workers: number source: string } -interface RunRequest { - mode: Mode - list: boolean - json: boolean - only?: string -} - -interface ListedGate { - id: string - label: string - command: string - needs: string[] - env: Record - blocking: boolean -} - -interface ListedPlan { - version: 1 - mode: Mode - script: string - scope: 'complete' - maxWorkers: number | null - gates: ListedGate[] -} - type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void @@ -125,83 +76,74 @@ if (import.meta.main) { } async function main(args: string[]): Promise { - const request = parseCliRequest(args) - const completePlan = gatePlanForMode(request.mode) - validateGatePlan(completePlan) - if (request.list) { - console.log(request.json ? formatGatePlanJson(completePlan) : formatGatePlanList(completePlan)) - return 0 - } - - const plan = request.only === undefined - ? completePlan - : { ...completePlan, gates: gateDependencyClosure(completePlan, request.only) } - validateGatePlan(plan) - if (request.only !== undefined) console.log(formatOnlyNotice(completePlan, request.only)) - - const concurrency = resolvePlanConcurrency(plan, process.env.DSH_GATE_CONCURRENCY) - const maxConcurrency = concurrency.workers - const concurrencySource = concurrency.source + const mode = parseMode(args[0]) + const gates = gatesForMode(mode) + const concurrencyDefault = defaultConcurrency(mode, gates.length) + const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY + const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) + const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' + ? concurrencyDefault.source + : '$DSH_GATE_CONCURRENCY' const startedAt = performance.now() - console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) + console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) - const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => { - printResult(completePlan, result) - }) - printSummary(completePlan, results, performance.now() - startedAt) + const results = await runGates(gates, maxConcurrency, runGate, printResult) + printSummary(results, performance.now() - startedAt) return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped')) ? 1 : 0 } -/** - * Parse one runner invocation without constructing or starting its plan. - * @param args - command-line arguments after the script entrypoint. - * @returns the validated run request. - */ -export function parseCliRequest(args: readonly string[]): RunRequest { - const mode = parseMode(args[0]) - const optionArgs = args[1] === '--' ? args.slice(2) : args.slice(1) - const { values: { list, json, only } } = parseArgs({ - args: optionArgs, - options: { - list: { type: 'boolean', default: false }, - json: { type: 'boolean', default: false }, - only: { type: 'string' }, - }, - strict: true, - allowPositionals: false, - }) - if (json && !list) throw new Error('run-gates: --json requires --list.') - if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') - return { mode, list, json, ...only === undefined ? {} : { only } } -} - function parseMode(raw: string | undefined): Mode { - if (MODES.includes(raw as Mode)) return raw as Mode - throw new Error(`run-gates: expected mode ${MODES.join(' | ')}, got ${JSON.stringify(raw)}.`) + switch (raw) { + case 'ci-primary': + case 'ci-static': + case 'ci-lint': + case 'ci-coverage': + case 'ci-snapshot': + case 'ci-artifacts': + case 'ci-consumers': + case 'ci-windows-blocking': + case 'ci-windows-complete': + case 'ci-windows-observational': + case 'node-compat': + case 'check-all': + case 'doc-sync': + return raw + default: + throw new Error( + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, + ) + } } -function defaultConcurrency(plan: GatePlan, available: number): ResolvedConcurrency { - if (plan.maxWorkers !== undefined) { - return { - workers: Math.min(plan.gates.length, plan.maxWorkers), - source: `${plan.mode} plan default ${plan.maxWorkers}`, - } - } +/** + * Resolve the default worker count for one aggregate. + * @param selectedMode - aggregate whose resource posture applies. + * @param total - number of gates in the aggregate. + * @param available - host CPU availability for ordinary modes. + * @returns the default worker count and its diagnostic source. + */ +export function defaultConcurrency( + selectedMode: Mode, + total: number, + available = availableParallelism(), +): ConcurrencyDefault { + if (selectedMode === 'ci-consumers') return { workers: total, source: 'ci-consumers gate count' } // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. - const localCap = plan.mode === 'check-all' || plan.mode === 'doc-sync' + const localCap = selectedMode === 'check-all' || selectedMode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { - workers: Math.min(plan.gates.length, modeLimit), + workers: Math.min(total, modeLimit), source: localCap - ? `${available} available CPU(s), ${plan.mode} cap 4` + ? `${available} available CPU(s), ${selectedMode} cap 4` : `${available} available CPU(s)`, } } -function concurrencyFromValue(name: string, raw: string | undefined, fallback: number): number { +function concurrencyFromEnv(name: string, fallback: number): number { + const raw = process.env[name] if (raw === undefined || raw === '') return fallback const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1) { @@ -210,33 +152,6 @@ function concurrencyFromValue(name: string, raw: string | undefined, fallback: n return parsed } -/** - * Resolve a plan's default, optional environment request, and hard worker ceiling. - * @param plan - validated complete or diagnostic plan. - * @param override - optional `DSH_GATE_CONCURRENCY` value. - * @param available - host CPU availability for modes without a plan-owned default. - * @returns the effective worker count and its inspectable source. - */ -export function resolvePlanConcurrency( - plan: GatePlan, - override: string | undefined, - available = availableParallelism(), -): ResolvedConcurrency { - validateGatePlan(plan) - const defaultValue = defaultConcurrency(plan, available) - const requested = concurrencyFromValue('DSH_GATE_CONCURRENCY', override, defaultValue.workers) - const workers = Math.min(requested, plan.maxWorkers ?? requested) - const requestedSource = override === undefined || override === '' - ? defaultValue.source - : '$DSH_GATE_CONCURRENCY' - return { - workers, - source: workers === requested - ? requestedSource - : `${requestedSource}, ${plan.mode} plan cap ${String(plan.maxWorkers)}`, - } -} - function pnpmScript(id: string, script: string, options: Partial = {}): Gate { return { id, @@ -266,21 +181,16 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -/** - * Construct the complete plan for a named aggregate without executing it. - * @param selected - aggregate mode to construct. - * @returns the aggregate's package-script identity and gate graph. - */ -export function gatePlanForMode(selected: Mode): GatePlan { - return { - mode: selected, - script: MODE_SCRIPTS[selected], - gates: gatesForMode(selected), - ...selected === 'ci-consumers' ? { maxWorkers: 7 } : {}, - } +function nodeOptions(...options: string[]): string { + return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') } -function gatesForMode(selected: Mode): Gate[] { +/** + * Construct the complete gate list for a named aggregate. + * @param selected - aggregate mode to construct. + * @returns the aggregate's gate graph. + */ +export function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() @@ -320,7 +230,7 @@ function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] @@ -389,7 +299,7 @@ function ciStaticGates(): Gate[] { pnpmScript('build', 'build'), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -479,17 +389,17 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { 'content', ], { label: 'lint', - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } if (concurrencyArgs.length > 0) { return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { label: 'lint', - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } @@ -520,7 +430,7 @@ function coverageGate(): Gate { // Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { - env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + env: { DSH_EXAMPLE_MODE: 'lib' }, needs, }) } @@ -566,7 +476,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] - docTypecheckEnv?: Record + docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} @@ -623,46 +533,32 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { ], { label: 'built-bin smoke', needs, - env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + env: { DSH_EXAMPLE_MODE: 'lib' }, }) } /** - * Reject a plan whose graph cannot be executed unambiguously. - * @param plan - complete or diagnostic plan to validate. + * Reject a gate list whose graph cannot be executed unambiguously. + * @param gates - complete aggregate to validate. */ -export function validateGatePlan(plan: GatePlan): void { - const errors: string[] = [] - if (plan.gates.length === 0) errors.push('plan has no gates') - if (plan.maxWorkers !== undefined && (!Number.isSafeInteger(plan.maxWorkers) || plan.maxWorkers < 1)) { - errors.push(`maxWorkers must be a positive integer, got ${JSON.stringify(plan.maxWorkers)}`) - } +function validateGateGraph(gates: readonly Gate[]): void { + if (gates.length === 0) throw new Error('run-gates: gate graph has no gates.') - const counts = new Map() - for (const gate of plan.gates) { - counts.set(gate.id, (counts.get(gate.id) ?? 0) + 1) - if (!/^[a-z0-9][a-z0-9:-]*$/.test(gate.id)) { - errors.push(`gate id ${JSON.stringify(gate.id)} must contain only lowercase letters, digits, colons, and hyphens`) - } + const ids = new Set() + for (const gate of gates) { + if (ids.has(gate.id)) throw new Error(`run-gates: duplicate gate id ${JSON.stringify(gate.id)}.`) + ids.add(gate.id) } - for (const [id, count] of counts) { - if (count > 1) errors.push(`duplicate gate id ${JSON.stringify(id)}`) - } - - const ids = new Set(counts.keys()) - for (const gate of plan.gates) { + for (const gate of gates) { for (const dependency of gate.needs ?? []) { if (!ids.has(dependency)) { - errors.push(`gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}`) + throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`) } } } - const cycle = findDependencyCycle(plan.gates) - if (cycle !== undefined) errors.push(`dependency cycle: ${cycle.join(' -> ')}`) - if (errors.length > 0) { - throw new Error(`run-gates: invalid ${plan.mode} plan:\n${errors.map(error => ` - ${error}`).join('\n')}`) - } + const cycle = findDependencyCycle(gates) + if (cycle !== undefined) throw new Error(`run-gates: dependency cycle: ${cycle.join(' -> ')}.`) } function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { @@ -698,194 +594,31 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { } /** - * Return one target and all of its transitive dependencies in canonical plan order. - * @param plan - validated complete owning plan. - * @param targetId - gate selected for diagnostic execution. - * @returns the target's dependency closure in owning-plan order. - */ -export function gateDependencyClosure(plan: GatePlan, targetId: string): Gate[] { - validateGatePlan(plan) - const byId = new Map(plan.gates.map(gate => [gate.id, gate])) - if (!byId.has(targetId)) { - throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(targetId)}.`) - } - - const selected = new Set() - const include = (id: string): void => { - if (selected.has(id)) return - const gate = byId.get(id) - if (gate === undefined) throw new Error(`run-gates: missing validated dependency ${JSON.stringify(id)}.`) - for (const dependency of gate.needs ?? []) include(dependency) - selected.add(id) - } - include(targetId) - return plan.gates.filter(gate => selected.has(gate.id)) -} - -/** - * Produce the stable machine-readable view used by `--list --json`. - * @param plan - complete plan to inspect. - * @returns the versioned environment-redacted plan view. - */ -export function listedGatePlan(plan: GatePlan): ListedPlan { - validateGatePlan(plan) - return { - version: 1, - mode: plan.mode, - script: plan.script, - scope: 'complete', - maxWorkers: plan.maxWorkers ?? null, - gates: plan.gates.map(listedGate), - } -} - -function listedGate(gate: Gate): ListedGate { - return { - id: gate.id, - label: gate.label, - command: gate.displayCommand, - needs: [...gate.needs ?? []], - env: listedEnvironment(gate.env), - blocking: gate.allowFailure !== true, - } -} - -function listedEnvironment( - environment: Readonly> | undefined, -): Record { - if (environment === undefined) return {} - return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { - const value = sensitiveEnvironmentName(name) ? '' : override.value - return [name, { operation: override.operation, value }] - })) -} - -function sensitiveEnvironmentName(name: string): boolean { - return /(key|secret|token|password|credential)/i.test(name) -} - -/** - * Render the deterministic human-readable view used by `--list`. - * @param plan - complete plan to inspect. - * @returns the formatted plan. - */ -export function formatGatePlanList(plan: GatePlan): string { - const listed = listedGatePlan(plan) - const lines = [ - `run-gates: complete ${listed.mode} plan (pnpm run ${listed.script})`, - `max workers: ${listed.maxWorkers === null ? '(host and gate count)' : listed.maxWorkers}`, - ] - for (const gate of listed.gates) { - lines.push(`- ${gate.id} [${gate.blocking ? 'blocking' : 'non-blocking'}] ${gate.label}`) - lines.push(` command: ${gate.command}`) - lines.push(` needs: ${gate.needs.length === 0 ? '(none)' : gate.needs.join(', ')}`) - lines.push(` env: ${Object.keys(gate.env).length === 0 ? '(none)' : JSON.stringify(gate.env)}`) - } - return lines.join('\n') -} - -/** - * Render the stable JSON view used by `--list --json`. - * @param plan - complete plan to inspect. - * @returns the formatted JSON object. - */ -export function formatGatePlanJson(plan: GatePlan): string { - return JSON.stringify(listedGatePlan(plan), null, 2) -} - -/** - * Render the package-script command that restores a gate's scheduler context. - * @param plan - complete owning plan. - * @param gateId - gate to replay with its dependencies. - * @returns a shell-independent pnpm command. - */ -function replayCommand(plan: GatePlan, gateId: string): string { - validateGatePlan(plan) - if (!plan.gates.some(gate => gate.id === gateId)) { - throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) - } - return `pnpm run ${plan.script} -- --only ${gateId}` -} - -/** - * Explain that a focused run is diagnostic rather than the complete aggregate. - * @param plan - complete owning plan. - * @param gateId - selected diagnostic gate. - * @returns the partial-evidence notice. - */ -function formatOnlyNotice(plan: GatePlan, gateId: string): string { - return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` -} - -/** - * Resolve only scheduler-declared environment operations against the spawn environment. - * @param gate - gate whose operations to apply. - * @param inherited - environment inherited by the runner. - * @returns the child environment without mutating the inherited object. - */ -function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const resolved = { ...inherited } - for (const [name, override] of Object.entries(gate.env ?? {})) { - switch (override.operation) { - case 'set': - resolved[name] = override.value - break - case 'append': { - const current = resolved[name] - resolved[name] = current === undefined || current === '' - ? override.value - : `${current} ${override.value}` - break - } - default: - assertNever(override) - } - } - return resolved -} - -function assertNever(value: never): never { - throw new Error(`run-gates: unreachable value ${JSON.stringify(value)}.`) -} - -/** - * Run a validated plan; invalid input rejects before the injected executor can start a child. - * @param plan - complete or diagnostic plan to execute. + * Validate and run one aggregate before the injected executor can start a child. + * @param gates - complete aggregate to execute. * @param maxActive - maximum concurrent child count. * @param execute - child-process executor. * @param observe - result observer invoked when each gate settles. - * @returns results in canonical plan order. + * @returns results in aggregate order. */ -export async function executeGatePlan( - plan: GatePlan, +export async function runGates( + gates: Gate[], maxActive: number, execute: GateExecutor, observe: ResultObserver = () => {}, ): Promise { - validateGatePlan(plan) + validateGateGraph(gates) if (!Number.isSafeInteger(maxActive) || maxActive < 1) { throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) } - if (plan.maxWorkers !== undefined && maxActive > plan.maxWorkers) { - throw new Error(`run-gates: max concurrency ${maxActive} exceeds the ${plan.mode} plan ceiling ${plan.maxWorkers}.`) - } - return runGates(plan.gates, maxActive, execute, observe) -} - -async function runGates( - allGates: Gate[], - maxActive: number, - execute: GateExecutor, - observe: ResultObserver, -): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) + const states = new Map(gates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] for (;;) { let madeProgress = false while (running.length < maxActive) { - const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) + const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') running.push({ gate: ready, promise: execute(ready) }) @@ -894,13 +627,13 @@ async function runGates( } if (running.length === 0) { - let pending = allGates.filter(gate => states.get(gate.id) === 'pending') + let pending = gates.filter(gate => states.get(gate.id) === 'pending') while (pending.length > 0) { const gate = pending.find(item => (item.needs ?? []).some((id) => { const state = states.get(id) return state === 'failed' || state === 'skipped' })) - if (gate === undefined) throw new Error('run-gates: validated plan stalled without a failed dependency.') + if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.') const failedDeps = (gate.needs ?? []).filter((id) => { const state = states.get(id) return state === 'failed' || state === 'skipped' @@ -909,8 +642,6 @@ async function runGates( gate, status: 'skipped', durationMs: 0, - stdout: '', - stderr: '', output: [], exitCode: null, signalCode: null, @@ -933,7 +664,7 @@ async function runGates( } } - return allGates.map((gate) => { + return gates.map((gate) => { const result = results.get(gate.id) if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`) return result @@ -947,12 +678,10 @@ function dependenciesPassed(gate: Gate, states: Map): boolean /** * Execute one gate through the real shell-free child-process boundary. * @param gate - command and scheduler environment to execute. - * @returns the complete process and verification outcome. + * @returns the complete process outcome. */ export async function runGate(gate: Gate): Promise { const started = performance.now() - let stdout = '' - let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined @@ -962,17 +691,15 @@ export async function runGate(gate: Gate): Promise { }>((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, - env: resolveGateEnvironment(gate, process.env), + env: { ...process.env, ...gate.env }, stdio: ['pipe', 'pipe', 'pipe'], }) child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { - stdout += chunk output.push({ stream: 'stdout', text: chunk }) }) child.stderr.on('data', (chunk: string) => { - stderr += chunk output.push({ stream: 'stderr', text: chunk }) }) child.on('error', (error) => { @@ -982,33 +709,20 @@ export async function runGate(gate: Gate): Promise { child.on('close', (exitCode, signalCode) => { resolveExit({ exitCode, signalCode }) }) - if (gate.input !== undefined) child.stdin.end(gate.input) - else child.stdin.end() + child.stdin.end() }) const { exitCode, signalCode } = outcome - let status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' - let error = spawnError - if (status === 'passed' && gate.verify !== undefined) { - try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode, signalCode }) - } catch (verifyError: unknown) { - status = 'failed' - error = verifyError instanceof Error ? verifyError.message : String(verifyError) - } - } - + const status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' const result: GateResult = { gate, status, durationMs: performance.now() - started, - stdout, - stderr, output, exitCode, signalCode, } - if (error !== undefined) result.error = error + if (spawnError !== undefined) result.error = spawnError return result } @@ -1025,7 +739,7 @@ export function formatGateResultReason(result: GateResult): string { return facts.length === 0 ? 'no exit code or signal' : facts.join(', ') } -function printResult(plan: GatePlan, result: GateResult): void { +function printResult(result: GateResult): void { const verbose = process.env.DSH_GATE_VERBOSE === '1' const seconds = (result.durationMs / 1000).toFixed(2) if (result.status === 'passed' && !verbose) { @@ -1037,16 +751,13 @@ function printResult(plan: GatePlan, result: GateResult): void { const writeHeading = result.status === 'passed' ? console.log : console.error writeHeading(`\n== ${heading} ==`) if (result.status !== 'passed') { - const environment = listedGate(result.gate).env console.error(`command: ${result.gate.displayCommand}`) - if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) console.error(`outcome: ${formatGateResultReason(result)}`) - console.error(`replay: ${replayCommand(plan, result.gate.id)}`) } printOutput(result.output) } -function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { +function printSummary(results: GateResult[], durationMs: number): void { const passed = results.filter(result => result.status === 'passed').length const failed = results.filter(result => result.status === 'failed').length const skipped = results.filter(result => result.status === 'skipped').length @@ -1062,7 +773,7 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number) const reason = formatGateResultReason(result) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) - console.error(` replay: ${replayCommand(plan, result.gate.id)}`) + console.error(` ${result.gate.displayCommand}`) } } From 6227b55e4aef8c13be8caa3159e3a0ddd6126624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:16:10 +0800 Subject: [PATCH 43/43] fix(ci): retry the Wine lane's pnpm install on the hoisted-linker rename race The wine blocking job flaked with ERR_PNPM_ENOENT rename '_tmp_*' -> '/node_modules/esbuild' during workspace snapshot + pnpm install (runs 30334004123 and 30340039598), and a plain rerun passed. Root cause is upstream pnpm/pnpm#12880: the hoisted linker's parallel linkers race to rename their _tmp_* staging directory onto the same nested package path, and the loser exits although an identical re-install succeeds. Only this lane uses nodeLinker: hoisted, so only this lane hits it. snapshot_and_install now retries exactly that log signature up to two times, wiping the scratch tree's node_modules first (the snapshot itself contains none, so the wipe restores the pre-install state) and logging each retry with the upstream issue. Any other install failure still fails on the first attempt, and a race that survives the final attempt still fails loud with the install log tail. --- scripts/wine-windows-gates.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 8b6dcd8a0a..7706a88a4b 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -125,8 +125,26 @@ supportedArchitectures: os: [current, win32] cpu: [current, x64] EOF - (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ - || { tail -40 "$scratch/logs/install.log" >&2; return 1; } + # The hoisted linker — used only by this lane — has an upstream rename + # race (pnpm/pnpm#12880): parallel linkers staging a nested package copy + # (observed on the tree's nested esbuild versions) rename their _tmp_* + # directory onto a path another racer already claimed, and the loser + # exits ERR_PNPM_ENOENT although an identical re-install succeeds. + # Exactly that signature earns up to two retries on a clean tree — the + # snapshot contains no node_modules, so wiping them restores the + # pre-install state; any other failure, or the race still standing after + # the final attempt, fails loud with the log tail. + local attempt + for attempt in 1 2 3; do + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + && return 0 + grep -q 'ERR_PNPM_ENOENT.*rename.*_tmp_' "$scratch/logs/install.log" || break + (( attempt < 3 )) || break + echo "wine-windows-gates: pnpm hoisted-linker rename race (pnpm/pnpm#12880) on install attempt $attempt; retrying on a clean tree" >&2 + find "$scratch/tree" -name node_modules -type d -prune -exec rm -rf {} + + done + tail -40 "$scratch/logs/install.log" >&2 + return 1 } mkdir "$scratch/tree"