diff --git a/README.md b/README.md index 446e207f0..94d4d5beb 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Shortcuts sit below the title and effort levels have their own section; DSH, Cla In `/provider`'s model list, focus a model and press `Tab` to edit its context window, max output tokens, reasoning efforts, and image input capability. The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. On a cold Codex scan, pages appear as they arrive. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. -With DSH's current JSONL backend, startup and `/new` keep initial permission events in memory until further session activity or an explicit durability flush saves the complete log. Restarting an unstored empty session starts fresh. +With DSH's current JSONL backend, startup and `/new` keep initial permission events in memory until further session activity saves the complete log; an explicit durability flush still runs, but it saves nothing for a session that holds only that initialization. Restarting an unstored empty session starts fresh. A normal exit removes sessions no human ever spoke in. Removing a workspace registration keeps its sessions accessible under a "History only" directory in the rail. History-only directories offer edit and new-session actions; rename and remove are available for registered workspaces. diff --git a/README_ZH.md b/README_ZH.md index e29c0f569..e0109c83b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -270,7 +270,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 在 `/provider` 模型列表聚焦一项并按 `Tab`,可编辑上下文窗口、最大输出 token、推理档位和图片输入能力。 会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。Codex 首次扫描时,分页到达就显示;DSH 中需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 -使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中;后续会话活动或显式持久化 flush 才会保存完整日志。尚未落盘的空会话重启时会重新创建。 +使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中,直到后续会话活动才会保存完整日志;显式持久化 flush 仍会照常执行,但不会为只有初始化的会话落盘。尚未落盘的空会话重启时会重新创建。正常退出会清理人类从未发言过的会话。 移除工作区登记后,其历史会话仍可从侧栏的「仅历史」目录进入。 「仅历史」目录只提供编辑和新建会话操作;重命名与移除仅适用于已登记工作区。 diff --git a/docs/interaction.en.md b/docs/interaction.en.md index 0b105a888..a00df1e59 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -405,6 +405,15 @@ those sessions as usual — "no registration" is not "no history". - The group lives only inside this screen and is never written back to the registry. +On a normal exit (`/exit`, `/quit`, `/q`, a double `Ctrl+C` while idle, `Ctrl+D`), the TUI +removes sessions **no human has ever spoken in** — leftovers from earlier runs included — +and, when anything was cleaned, reports the count in its exit notice. + +- Only that path sweeps: hand-offs (`/update`, kernel switch, `/restart`), crashes and + signal-driven exits leave sessions alone. +- A session with any human message (even if its turn never started) and any sub-agent are + **never removed**. + **Behaviour changes versus the old screens** (deliberately removed, and no longer covered by regressions): - Session-level right-click menu (rename/delete one session). diff --git a/docs/interaction.md b/docs/interaction.md index 8172526c3..2b8cc50d2 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -404,6 +404,11 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在有清理时于退出提示里报出数量。 + +- 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 +- 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 + **旧界面相较之下的行为变更**(有意移除,不再回归): - 会话级右键菜单(重命名/删除单个会话)。 diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index 0b105a888..a00df1e59 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -405,6 +405,15 @@ those sessions as usual — "no registration" is not "no history". - The group lives only inside this screen and is never written back to the registry. +On a normal exit (`/exit`, `/quit`, `/q`, a double `Ctrl+C` while idle, `Ctrl+D`), the TUI +removes sessions **no human has ever spoken in** — leftovers from earlier runs included — +and, when anything was cleaned, reports the count in its exit notice. + +- Only that path sweeps: hand-offs (`/update`, kernel switch, `/restart`), crashes and + signal-driven exits leave sessions alone. +- A session with any human message (even if its turn never started) and any sub-agent are + **never removed**. + **Behaviour changes versus the old screens** (deliberately removed, and no longer covered by regressions): - Session-level right-click menu (rename/delete one session). diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 8172526c3..2b8cc50d2 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -404,6 +404,11 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在有清理时于退出提示里报出数量。 + +- 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 +- 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 + **旧界面相较之下的行为变更**(有意移除,不再回归): - 会话级右键菜单(重命名/删除单个会话)。 diff --git a/package.json b/package.json index 0fce3aadd..ded01097a 100644 --- a/package.json +++ b/package.json @@ -436,7 +436,8 @@ "usehooks-ts": "^3.1.0", "wrap-ansi": "^10.0.1", "ws": "^8.21.3", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zod": "^4.4.3" }, "optionalDependencies": { "sharp": "0.35.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4338888..3d6d5464e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: yaml: specifier: ^2.9.0 version: 2.9.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@anthropic-ai/claude-agent-sdk': specifier: 0.3.287 diff --git a/scripts/run-ci-group.mjs b/scripts/run-ci-group.mjs index 730736963..edab86d2e 100644 --- a/scripts/run-ci-group.mjs +++ b/scripts/run-ci-group.mjs @@ -359,6 +359,15 @@ const GROUPS = { // 字节、不得拉回 raw mode——在途回复与鼠标事件由清理后的 re-drain // 吞掉,不再落入 shell。 ["verify-exit-mouse-residue", ['node', '--import', 'tsx/esm', 'scripts/verify-exit-mouse-residue.tsx']], +// 正常退出分支的清理接线回归(ADR-0012 决策 3/5 · AC-5/AC-6):未发言 +// 会话的 sweep 必须在回合结束后折进 finishExit 的通知(await 的 sweep +// 永远到不了那里),崩溃 / `/update` / 内核切换 / `/restart` / 启动失败 +// 分支一律不 sweep;恶意依赖只损失回合不损失关机(终端恢复序列与进程 +// 交接原位、fail-soft);③ 层吃本进程真实视图(绑定会话、注册表活 +// agent、清单血缘),有人类消息无 turn/start 的会话存活,非本进程产出 +// 的清单整份放过而不猜血缘。清退分支是 apply() 内的闭包,其接线按源码 +// 断言(与 verify-shutdown-fallback 同形),被调用的 helper 直接驱动。 + ["verify-session-cleanup-exit", ['node', '--import', 'tsx/esm', 'scripts/verify-session-cleanup-exit.tsx']], // 组件级拖拽协议回归:无修饰左键 press 捕获 drag target,首动 dragstart、 // 连续 dragmove、release/focus-out/reset 收尾 dragend;未移动仍走 click, // 无 handler 与修饰键区域保留基线文本选择;真实 SGR 管线 + 最小滑块消费者。 @@ -635,9 +644,39 @@ const GROUPS = { ["verify-session-artifact-cache", ['node', 'scripts/verify-session-artifact-cache.mjs']], // 跨进程首屏快照:不等慢枚举、来源隔离、失败保留、空库清空与迟到守卫。 ["verify-session-list-snapshot", ['node', 'scripts/verify-session-list-snapshot.mjs']], +// 会话清单投影镜像回归(issue #1342 的可见性面):TUI 必须注册与 web 宿主 +// **同名同版本**的 sessionListMetadata 投影——定义(键/stateVersion/字段 +// 类型/init)逐项对字面量钉死(宿主侧改动要让本脚本红、而不是静默重开 +// issue),legacy 拼写 schema/viewSchema/view 必须缺席;fold 矩阵含两条 +// 只看值看不到的性质(无变化事件返回同一引用、blank 不回落); +// 跨运行时读取按对方等价形状解析并驱动真实 registry 的 restore;同键双 +// 注册的版本判定(首个定义生效、行仍写入);服务缺失/无 register/版本 +// 冲突必须降级不崩;宿主包可定位时跑真实实现的漂移探针,定位不到则响亮 +// SKIP(含搜索路径与原因),绝不静默通过。 + ["verify-session-list-metadata", ['node', '--import', 'tsx/esm', 'scripts/verify-session-list-metadata.ts']], // 空会话须完整读取后才能判定:截断/损坏、帧数上限、纯图片输入与旧缓存 // 不得隐藏真实历史或进入清理名单;真实 JSONL 重开验证落盘后的可见性。 ['verify-session-emptiness', ['node', '--import', 'tsx/esm', 'scripts/verify-session-emptiness.ts']], +// 未发言会话清理回归(ADR-0012 / 三层判据):真实临时 sessions 根 + 真 TUI +// 会话索引,走的是出货实现(store.readIndex、sessionLog 的有界读与删除原语、 +// sessionHistory 的每会话记录)而不是替身——启动壳(含本次运行之前就存在的 +// 「历史」条目)被回收,人类 user/message 无 turn/start 必须保留(宿主自身 +// blank 规则会删的反例),turn/start 与受委托运行及其后代保留,本进程仍持有 +// 的会话(绑定 / 活后台)保留,缺失/悬空/损坏/预算截断的日志只跳过不删并 +// 报出是哪层放过的;对抗用例「索引说 hasPrompt:false 而日志有人类消息」; +// 恒删/恒留/抽掉日志规则的负控(正例不能被常量满足);候选上限与每日志事件 +// 预算、依赖抛错不中止本轮(fail-soft);分区恰好覆盖索引一次(不静默丢), +// 真 sweep 只删收集到的 id、索引不动、last-used/agent-view/resume 记录被遗忘。 + ["verify-unspoken-session-sweep", ['node', '--import', 'tsx/esm', 'scripts/verify-unspoken-session-sweep.tsx']], +// 会话写租约回归(CR-2 / T-FIX-18):真实 JSONL 持久化后端在临时 sessions 根上为 +// 夹具会话持有独占写租约(Windows 命名内核信号量 / POSIX flock),验证退出清扫 +// 「探不到就不删」——① 被别的写者持有的空壳保留、记为 write-leased(与挂载账本的 +// held-elsewhere 可区分)、日志仍在盘上;② 同一形态但无人持有照旧删除;③ 探针不可用 +// (平台不支持 / 非争用失败)保留;④ 恒返回 free 的探针必须让 ① 变红(锁着的会话 +// 被删)——「完全不看租约」正是修复前那个世界。另覆盖探针失败分类(无服务 / 无方法 / +// 释放失败 ⇒ unknown)、预扫描的预算与顺序、索引不可读则一无所证,以及退出接线的锚: +// 先取证 → 再本轮 → 最后组通知。 + ["verify-session-write-lease", ['node', '--import', 'tsx/esm', 'scripts/verify-session-write-lease.ts']], // /resume 会话浏览器按键流回归:子运行折叠/展开、空会话不列出、搜索、 // Esc 先清查询再退出、rename 后光标按 id 跟随目标(不是按行号)、 // confirm-delete 只认无修饰 Enter、Esc 取消。真实 Chat 渲染驱动。 diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 0718586db..f88a818cc 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -5,24 +5,152 @@ * write failure/retry, disposal, concurrent factories and saved history. * Restart/update cases spawn real replacements, parse the real Config and * create/resume through the real Agent registry; no installer is invoked. + * + * A flush is not a publication either: the host projection cache checkpoints a + * session from its `session/created` hook and from its own event throttle, and + * draining on those checkpoints re-materialized exactly the permission-only + * shell the deferral keeps out of JSONL. The checkpoint still participates, and + * the first real event still publishes the complete log from seq 0. + * + * The other side of the same shell is the SEED. The four channel actions that + * create a child from a source prefix (`/model`, `/fork`, `/rewind`, `/tree`) + * copied a source nobody had used, and the host stores a seed at publication + * (`dsh-agent-loop` `appendUnstoredSuffix` → `writer.append`) — so the copy, + * not a flush, is what puts the child's log on disk before its first real + * event. Each of them now asks whether the CUT HOLDS NO CONVERSATION — the + * slice the child actually inherits, never the session it was cut from: the + * deferral's own `isUnstoredFreshSession` (`src/dsh-adapter/fresh-agent.ts`) + * answers first for the live source, then the exit sweep's evidence rule + * (`src/dsh-adapter/unspoken-sessions.ts`'s `conversationEvidence`, asked + * through the exported `holdsNoConversation`) reads that slice. Judging the SOURCE left a hole + * a rewind reaches: the first message's boundary is the seq before its + * turn/start, and that turn opens behind the initialization `session/created` + * wrote (seq 0-2), so a whole conversation can cut down to the initialization + * alone while the source verdict says "seed it". The evidence rule is also what + * an already-stored shell satisfies and the never-used shortcut cannot see: a + * shell left by an earlier process, one web created, or one whose + * `agent-preset/selected` already started the deferral. A cut that holds no + * conversation starts an unseeded fresh session instead. Both halves are pinned + * here: the creation shapes below drive the real host, `/fork` is driven end to + * end from an on-disk shell, `/rewind` is driven end to end at both cut depths, + * and `verifySeededWiring` reads the four actions to prove the cut verdict is + * what selects the unseeded branch. + * + * A cut that holds no conversation is not an EMPTY inheritance. The session's + * policy — plan mode, sandbox mode, approval policy and the durable permission + * preset — lives in the same prefix, and the unseeded branch copies none of it, + * so the child fell back to the deployment defaults and could end up with WIDER + * permissions than the session it came from (CR-1). Each action therefore takes + * the cut's last value per policy type (`latestPolicyFacts`) and replays those + * facts into the unseeded child (`replayPolicyFacts`) AFTER the factory returns: + * the deferral is armed by then, and those four types are exactly the ones it + * holds back — any other type would start it and publish the shell. Inside + * `setup` the same append would leave `seq !== 0` and the gate would refuse to + * arm in silence (KNOWN-ISSUES B-14 ①), which is why the placement is pinned by + * behaviour and not only by the wiring text. `verifyCutPolicyReplay` drives + * that over real actions and reads the child's effective policy through the + * services that enforce it. + * + * The gate itself has one precondition, and it is pinned here rather than + * assumed: `fresh-agent.ts:72` returns SILENTLY unless the session is still at + * `seq === 0` when `createFreshAgent`'s own setup resolves. A create whose + * setup appends an event before resolving therefore skips the deferral and + * stores the session immediately, with nothing above reddening. The production + * shape appends nothing of its own — `composePreset` composes a setup that + * awaits its mount and returns undefined (`presets.ts:70-72`) — + * so `verifyGateInstalledShape` drives that shape and asserts the gate armed, + * and `--negative-controls` replays the pre-append shape that skips it. + * Throwing on that branch is deliberately NOT done: a setup that owns + * pre-publication facts is a legitimate shape and the deferral has nothing to + * say about it. + * * Run: node --import tsx/esm scripts/verify-empty-session-persistence.ts + * + * Negative controls: + * 1. In-process (re-runnable): add `--negative-controls`. It replays the + * pre-fix `guardedFlush` (start, drain the live snapshot, flush) on the + * same creation shape and asserts the shell DOES appear — the pair is what + * makes "the create-time checkpoint does not publish the permission-only + * shell" a discriminating assertion instead of a vacuous one. It also + * replays the pre-fix SEED for the same never-used sources, for an + * on-disk shell, and for a `/rewind` cut that reaches only the + * initialization (asserting that the child DOES appear and that the + * pre-fix notice DOES advertise a resume command), and reverses the four + * wiring checks four ways — `=> cutHoldsNoConversation` → `=> false`, the + * cut verdict put back on the SOURCE session, the never-used shortcut + * dropped, and the verdict dropped entirely — to prove those checks can + * fail (LESSONS L-044 / L-048). + * 2. Real revert (flush): restore `start(); await drain()` at the head of + * `guardedFlush` in src/dsh-adapter/fresh-agent.ts, then + * `node --import tsx/esm scripts/verify-empty-session-persistence.ts` + * → expect FAIL "the create-time checkpoint does not publish the + * permission-only shell" (plus the two narrowed checkpoint cases). + * 3. Real revert (seed): make one action seed unconditionally — e.g. in + * src/dsh-adapter/channel/model-switch.ts replace + * `=> cutHoldsNoConversation` with `=> false` — then the same command → + * expect FAIL "channel-model-switch: the cut verdict selects the unseeded + * branch". The creation-shape cases stay green there: they drive the + * creation, the wiring check reads the action. + * 4. Real revert (verdict narrowed): put the widening back to T-FIX-10's + * criterion in src/dsh-adapter/channel/session-fork.ts — replace the two + * lines `seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)` + * + `if (holdsNoConversation(seed)) seed = []` with + * `seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)` + * — then the same command → expect FAIL "the fork notice for a + * conversation-less source is the new-session wording", FAIL "the /fork + * action took its unseeded branch for an on-disk shell" and FAIL "a fork + * of an on-disk shell publishes no child", plus the + * `channel-session-fork` wiring FAIL. The creation-shape cases stay green + * there too. + * 5. Real revert (T-FIX-12, the cut judged by its source): in + * src/dsh-adapter/channel/session-rewind.ts restart the verdict from the + * source session — replace + * `if (holdsNoConversation(seed)) seed = []` + * with + * `if (holdsNoConversation(snapshotLiveSessionEvents(source))) seed = []` + * — then the same command → expect FAIL "a cut that holds no conversation + * takes the unseeded branch", FAIL "and leaves no log on disk", and the + * `channel-session-rewind` wiring FAIL. `--negative-controls` carries the + * same reversal textually for all four sites and replays its DECISION + * behaviourally (`PASS negative control: the source verdict seeds the + * policy-only cut into a published shell`). + * 6. Real revert (gate shape): give `verifyGateInstalledShape`'s `setup` an + * append before it resolves — e.g. `agent.session.append('session/title', + * { title: 'appended before the commit', messageSeqs: [], source: { kind: + * 'user' } })` — then the same command → expect FAIL "the production + * create shape arms the deferral gate (its setup resolves without + * appending)". `--negative-controls` replays that shape behaviourally + * (`PASS negative control: an append before the setup resolves skips the + * deferral in silence`). + * 7. Real revert (policy replay): drop it from one action — e.g. in + * src/dsh-adapter/channel/model-switch.ts replace + * `if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)` + * with `void policyFacts` — then the same command → expect FAIL "(A) the + * unseeded /model child keeps the source policy" while the "has no log" + * assertions stay GREEN: losing the policy and publishing the shell are + * separately visible failure modes. Replaying the WHOLE cut instead of the + * four policy types is the other mode — the child is published, and the + * "no log" assertion fails while the policy one passes. */ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setImmediate } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { type AgentHandle } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { LlmAdapter, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import PlanMode from '@deepseek-ai/dsh-plan-mode' +import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import Approval, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { settled, sleep } from './lib/term-test.mjs' const handoffRole = process.env.DSH_TUI_EMPTY_HANDOFF_ROLE @@ -74,10 +202,20 @@ process.env.HOME = root process.env.USERPROFILE = root process.env.DSH_HOME = join(root, 'home') process.env.DSH_TUI_LANG = 'en' -const { createFreshAgent, isUnstoredFreshSession } = await import('../src/dsh-adapter/fresh-agent.js') +const { createFreshAgent, isUnstoredFreshSession, INITIAL_POLICY_EVENTS } = await import('../src/dsh-adapter/fresh-agent.js') const { createChannel } = await import('../src/dsh-adapter/channel.js') +const { createForkSessionAction } = await import('../src/dsh-adapter/channel/session-fork.js') +const { createRewindToAction } = await import('../src/dsh-adapter/channel/session-rewind.js') +const { dshHandleOf } = await import('../src/dsh-adapter/backend/session.js') +const { extractEntries, rewindTarget } = await import('../src/dsh-adapter/sessionTree.js') const { isExitResumable } = await import('../src/dsh-adapter/plugin.js') const { concreteService } = await import('../src/dsh-adapter/host-access.js') +const { liveSessionCreateOptions } = await import('../src/dsh-adapter/compat/index.js') +const { holdsNoConversation } = await import('../src/dsh-adapter/unspoken-sessions.js') +const { foldPermissionPreset } = await import('../src/dsh-adapter/channel/mode-permission.js') +const { t } = await import('../src/i18n.js') + +const negativeControls = process.argv.includes('--negative-controls') class ScriptedAdapter extends LlmAdapter { async resolveModel(provider: string, model: string) { return { provider, id: model, name: model } } @@ -94,6 +232,36 @@ const title = (session: Session, text: string): void => { session.append('session/title', { title: text, messageSeqs: [], source: { kind: 'user' } }) } +/** + * The four enforcement-relevant policy atoms of one live session — plan mode, + * sandbox mode, approval policy and the durable permission preset — read + * through the services that ENFORCE them (`ctx.planMode`, `ctx.sandboxPolicy`, + * `ctx.approval`, and the channel's own preset fold) rather than restated here. + * CR-1 / T-FIX-17 is about exactly these four: a child created without a seed + * falls back to the deployment defaults, which may be WIDER than the session + * it came from. + */ +type PolicyReading = Record<'plan' | 'sandbox' | 'approval' | 'preset', unknown> +function policyOf(ctx: Context, agent: Agent): PolicyReading { + return { + plan: ctx.planMode.get(agent).active, + sandbox: ctx.sandboxPolicy.overrideOf(agent.session), + approval: ctx.approval.overrideOf(agent.session), + preset: foldPermissionPreset(agent.session.snapshotEvents()), + } +} + +/** The non-default policy every CR-1 fixture switches its source into. */ +const RESTRICTED_POLICY: PolicyReading = { plan: true, sandbox: 'read-only', approval: 'never', preset: 'read-only' } + +/** Switch one session into that policy, through each service's own write path. */ +function restrictPolicy(ctx: Context, agent: Agent): void { + ctx.planMode.set(agent, true) + setSandboxMode(agent.session, 'read-only') + setApprovalPolicy(agent.session, 'never') + agent.session.append('permission/preset', { preset: 'read-only' }) +} + async function verify(compression: 'zstd' | 'none'): Promise { const ctx = new Context() const handles: AgentHandle[] = [] @@ -110,13 +278,42 @@ async function verify(compression: 'zstd' | 'none'): Promise { const backend = await ctx.plugin(JsonlSessionPersistence, { root: sessionsRoot, compression }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter()) - // Reproduce the official permission service's session/created pinning. + // Reproduce the official permission service's session/created pinning, plus + // the host projection cache's create-time checkpoint: both are listeners of + // the same event, and the cache's `flushSoft('create')` reaches + // `ctx.sessions.flush(session)` from inside the creation transaction. + const createFlushes = new Set() + const writers = new Map }; flush: () => Promise }>() + let preFixId = '' ctx.on('session/created', session => { if (session.seq !== 0) return const append = session.append as (type: string, data: Record) => unknown append.call(session, 'permission/preset', { preset: 'workspace-write' }) session.append('sandbox/mode', { mode: 'workspace-write' }) session.append('approval/policy', { policy: 'ask' }) + const id = String(session.id) + if (!createFlushes.has(id)) return + const captured = writers.get(id) + if (negativeControls && id === preFixId && captured !== undefined) { + // Pre-fix `guardedFlush`: start(), drain the live snapshot, then flush. + const events = session.snapshotEvents() + void (async () => { + try { + if (events.length > 0) await captured.writer.append(events) + await captured.flush() + } catch (error) { ctx.logger.warn(`negative control: pre-fix flush failed: ${String(error)}`) } + })() + return + } + void ctx.sessions.flush(session) + }) + // Informational: a listener on a foreign fiber still observes a deferred + // session (the gate only mutes the JSONL provider's own routing), which is + // why the cache's event throttle also reaches the gate. + const foreignSeen = new Map() + ctx.on('session/event', session => { + const id = String(session.id) + foreignSeen.set(id, (foreignSeen.get(id) ?? 0) + 1) }) const seen = new Map() ctx.on('session/event', (session, event) => { @@ -195,14 +392,571 @@ async function verify(compression: 'zstd' | 'none'): Promise { channel.releaseContributions() channel = undefined - // An explicit checkpoint retains the persistence contract, even before a prompt. - const explicit = await fresh('explicit-flush') - await ctx.sessions.flush(explicit.agent.session) - assert.equal(isUnstoredFreshSession(explicit.agent.session), false, 'an explicitly saved empty session can be handed off') - await assertComplete(explicit.agent.session) - const serviceFlush = await fresh('service-flush') - await persistence.flush() - await assertComplete(serviceFlush.agent.session) + // A checkpoint is not a publication: the host projection cache checkpoints + // from `session/created` (and from its event throttle) while the session + // still holds only initialization, so draining there re-materialized the + // exact shell the deferral keeps out of JSONL. The checkpoint still + // participates — callers keep observing a durability listener — and the + // first real event still publishes the complete log from seq 0. + persistence.create = async function (header, config) { + const writer = await originalCreate.call(this, header, config) + writers.set(String(header.id), { writer, flush: writer.flush.bind(writer) }) + return writer + } + const verifyCheckpointPublication = async (): Promise => { + const createFlushId = String(options('create-flush').sessionId) + createFlushes.add(createFlushId) + const createFlushed = await fresh('create-flush') + assert.deepEqual(createFlushed.agent.session.snapshotEvents().map(event => event.type), policyTypes) + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(existsSync(artifact(createFlushed.agent.session)), false, 'the create-time checkpoint does not publish the permission-only shell') + assert.equal(isUnstoredFreshSession(createFlushed.agent.session), true, 'the create-time checkpoint does not hand the session off') + console.log(`INFO deferred create-flush session: foreign listeners observed ${String(foreignSeen.get(createFlushId) ?? 0)} events`) + title(createFlushed.agent.session, 'first real event') + await ctx.sessions.flush(createFlushed.agent.session) + assert.equal(isUnstoredFreshSession(createFlushed.agent.session), false, 'the first real event still hands the checkpointed session off') + await assertComplete(createFlushed.agent.session) + assert.deepEqual((await stored(createFlushed.agent.session)).map(event => event.seq), [0, 1, 2, 3], 'the published log is contiguous from seq 0') + + const explicit = await fresh('explicit-flush') + assert.equal(await ctx.sessions.flush(explicit.agent.session), true, 'an idle checkpoint still reaches a participating listener') + assert.equal(isUnstoredFreshSession(explicit.agent.session), true, 'an idle checkpoint does not publish the initialization') + assert.equal(existsSync(artifact(explicit.agent.session)), false) + title(explicit.agent.session, 'explicitly flushed') + await ctx.sessions.flush(explicit.agent.session) + assert.equal(isUnstoredFreshSession(explicit.agent.session), false, 'the first real event still hands the session off') + await assertComplete(explicit.agent.session) + const serviceFlush = await fresh('service-flush') + await persistence.flush() + assert.equal(existsSync(artifact(serviceFlush.agent.session)), false, 'the backend sweep does not publish an untouched session') + title(serviceFlush.agent.session, 'after the sweep') + await persistence.flush() + await assertComplete(serviceFlush.agent.session) + + // The five channel actions that reached `agents.create` directly. + // `/bg` starts an unseeded session, so it shares the gate: its creation + // shape stays unpublished while idle, and the same shape without the gate + // still publishes (this pair is what makes the case discriminative). + const backgrounded = await fresh('channel-background-action') + await sleep(250) // 固定窗:探针 — an absent artifact is already true, so polling it proves nothing; the window (250ms > the JSONL 200ms batch timer) is what makes the checkpoint's silence observable. + assert.equal(existsSync(artifact(backgrounded.agent.session)), false, 'the /bg creation shape stays unpublished while idle') + const ungated = await ctx.agents.create(options('channel-background-action-ungated')) + handles.push(ungated) + assert.ok(await settled(() => existsSync(artifact(ungated.agent.session))), 'the ungated /bg shape still publishes the shell') + await ungated.dispose() + + // The other four channel actions (`/model`, `/fork`, `/rewind`, `/tree`) + // copy a source prefix into the child, and the host appends that prefix + // through the writer BEFORE `session/created` (dsh-agent-loop + // `appendUnstoredSuffix` → writer.append → the JSONL backend materializes + // on the first batch), so a flush-side gate cannot unmake a seeded child's + // artifact: the file IS the inherited prefix. They are covered by + // `verifySeededFamily` below, which is about the SOURCE they copy from. + + // Negative control (--negative-controls): replay the pre-fix + // `guardedFlush` — start, drain the live snapshot, flush — for the same + // creation shape and assert the shell DOES materialize. + if (negativeControls) { + preFixId = String(options('negative-control').sessionId) + createFlushes.add(preFixId) + const preFix = await fresh('negative-control') + await sleep(250) // 固定窗:墙钟 — the replayed pre-fix append+flush runs detached, so the shell lands on the JSONL writer's 200ms batch deadline rather than when create() resolves. + assert.equal(existsSync(artifact(preFix.agent.session)), true, 'negative control: an unconditional drain+flush on the create checkpoint publishes the shell') + console.log('PASS negative control: the pre-fix drain-on-flush semantics publish the shell') + } + } + await verifyCheckpointPublication() + persistence.create = originalCreate + + /** + * A permission switch is session policy, not conversation. + * + * Measured on a real tree (2026-10-10): an idle fresh session that only + * switched its permission preset published an 11-event shell — `command/run` + * + `command/done` (the registry command `mode-permission.ts` drives the + * switch through) and the `agent/inbox/spliced` approval notice it leaves — + * because those types are not initialization atoms, so the deferral started + * on the first of them. The stored shell is exactly the 「未命名」 row this + * change exists to remove, so the policy plane (atoms AND the command + * envelope / inbox notice a policy switch leaves behind) must stay deferred. + * + * (a) the switch alone publishes nothing; + * (b) a HUMAN message in the same session still publishes — the gate is + * live, the classification is what changed; + * (c) `INITIAL_POLICY_EVENTS` alone cannot express (a): every envelope type + * below is outside it, which is why the old rule leaked; + * (d) `--negative-controls` creates the same envelope WITHOUT the gate: the + * events are storable, so (a) is the gate's doing and not a fixture that + * cannot be written at all. + */ + const policySwitchEnvelope = (session: Session): void => { + session.append('command/run', { commandId: 'cmd-fixture-permission-1', name: 'permission', args: ' workspace-write', source: { kind: 'user' } }) + session.append('agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [{ + content: [{ type: 'text', text: 'The approval policy changed from "never" to "ask" (changed by the user).' }], + source: { kind: 'user-approval' }, + role: 'user', + id: 'fixture-approval-notice', + }], + }) + session.append('command/done', { commandId: 'cmd-fixture-permission-1', kind: 'success', text: 'preset workspace-write' }) + session.append('agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }) + } + const verifyPolicyPlaneStaysDeferred = async (): Promise => { + for (const type of ['command/run', 'command/done', 'agent/inbox/spliced']) { + assert.equal(INITIAL_POLICY_EVENTS.has(type), false, `${type} is outside the initialization vocabulary — the old rule started on it`) + } + const switched = await fresh('policy-switch') + assert.deepEqual(switched.agent.session.snapshotEvents().map(event => event.type), policyTypes, 'a fresh session starts on its initialization') + policySwitchEnvelope(switched.agent.session) + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer, so an absent artifact is observable silence. + assert.equal(existsSync(artifact(switched.agent.session)), false, 'a permission switch alone publishes nothing') + assert.equal(isUnstoredFreshSession(switched.agent.session), true, 'a permission switch alone does not hand the session off') + + switched.agent.session.append('agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [{ + content: [{ type: 'text', text: 'a real prompt' }], + source: { kind: 'user' }, + role: 'user', + id: 'fixture-human-message', + }], + }) + assert.equal(isUnstoredFreshSession(switched.agent.session), false, 'a human message in the same session still hands it off') + await ctx.sessions.flush(switched.agent.session) + await assertComplete(switched.agent.session) + const published = await stored(switched.agent.session) + assert.equal(published.length, policyTypes.length + 5, 'the published log keeps the switch AND the prompt, from seq 0') + console.log('PASS a permission switch stays deferred and the first human message still publishes it') + + if (negativeControls) { + const ungatedSwitch = await ctx.agents.create(options('policy-switch-ungated')) + handles.push(ungatedSwitch) + policySwitchEnvelope(ungatedSwitch.agent.session) + assert.ok(await settled(() => existsSync(artifact(ungatedSwitch.agent.session))), 'negative control: the same envelope publishes without the gate') + await ungatedSwitch.dispose() + console.log('PASS negative control: the permission-switch envelope is storable — the deferral, not the fixture, keeps it off disk') + } + } + await verifyPolicyPlaneStaysDeferred() + + /** + * The deferral is armed from `createFreshAgent`'s own setup, and + * `fresh-agent.ts:72` returns SILENTLY unless the session is still at + * `seq === 0` when that setup resolves. The production shape meets that + * precondition — `composePreset` composes a setup that awaits its mount and + * returns undefined, and the composed factory appends nothing of its own — + * so a session created in that shape must come out already unstored. None + * of the cases above would redden if the gate silently stopped arming: a + * setup that appends first stores the session immediately and leaves every + * assertion there untouched. + */ + const verifyGateInstalledShape = async (): Promise => { + const shaped = await createFreshAgent(ctx, ctx.agents, { + ...options('production-shape'), + setup: async () => { + // `composePreset`'s composed setup: awaits the mount, returns no commit. + }, + }) + handles.push(shaped) + assert.equal(isUnstoredFreshSession(shaped.agent.session), true, 'the production create shape arms the deferral gate (its setup resolves without appending)') + await sleep(250) // 固定窗:探针 — an armed gate must keep the session out of the store; absence needs an observation window past the 200ms drain timer to mean anything. + assert.equal(existsSync(artifact(shaped.agent.session)), false, 'and an armed gate still keeps that session out of the store') + title(shaped.agent.session, 'production shape') + await ctx.sessions.flush(shaped.agent.session) + assert.equal(isUnstoredFreshSession(shaped.agent.session), false, 'a real event still releases the armed gate') + await assertComplete(shaped.agent.session) + + // Negative control (--negative-controls): the KNOWN BOUNDARY replayed. + // The same create with a setup that appends BEFORE it resolves leaves + // `seq !== 0` behind `fresh-agent.ts:72`; the gate is skipped in silence + // and the session is stored without ever being marked unstored. + if (negativeControls) { + const preAppend = await createFreshAgent(ctx, ctx.agents, { + ...options('production-shape-pre-append'), + setup: async (_agentCtx, agent) => { + agent.session.append('session/title', { title: 'appended before the commit', messageSeqs: [], source: { kind: 'user' } }) + }, + }) + handles.push(preAppend) + assert.equal(isUnstoredFreshSession(preAppend.agent.session), false, 'negative control: an append before the setup resolves leaves the gate uninstalled') + assert.ok(await settled(() => existsSync(artifact(preAppend.agent.session))), 'negative control: and that shape stores the session immediately') + console.log('PASS negative control: an append before the setup resolves skips the deferral in silence') + } + } + await verifyGateInstalledShape() + + /** + * The SEEDED family (`/model`, `/fork`, `/rewind`, `/tree`). Each of the + * four copies a source prefix into a child, and the host stores a seed at + * publication — the copy, not a flush, is what materializes the child. The + * cases below drive BOTH shapes on real sources: + * + * (a) a never-used source (`isUnstoredFreshSession` true) and the unseeded + * shape the action now takes: the create-time checkpoint still fires + * and the child's log must NOT appear until a real event, which then + * publishes it completely from seq 0; + * (b) a used source and the unchanged seeded shape: the whole prefix is + * copied and the child's log is complete at publication. + * + * `verifySeededWiring` pins which shape each action takes; these two halves + * together are what make the pair discriminating. + */ + const verifySeededFamily = async (): Promise => { + const unusedSource = await fresh('channel-seed-unused-source') + assert.equal(isUnstoredFreshSession(unusedSource.agent.session), true, 'an untouched fresh session is the never-used verdict the four actions key on') + const unusedSeed = unusedSource.agent.session.snapshotEvents() + assert.deepEqual(unusedSeed.map(event => event.type), policyTypes, 'a never-used source holds initialization only') + + // Boundary of this change's coverage, asserted rather than argued: the + // `/rewind` and `/tree` branches below are defense-in-depth while a + // never-used source cannot be reached by either. Chat.tsx's rewind list + // is human `user` rows only, and a tree entry comes from `extractEntries` + // — both need real content, so a never-used source offers neither. If a + // future projection starts offering one, this fails first and the two + // branches need reachable coverage of their own — `/rewind` already has + // it (`verifyCutPrefixSeeding` drives the real action end to end). + const unusedChannel = createChannel(ctx, unusedSource.agent, { handle: unusedSource, cwd: root, provider: 'scripted', model: 'scripted', activity: false }) + try { + assert.equal(unusedChannel.rows.filter(row => row.kind === 'user' && row.label === undefined).length, 0, 'a never-used source offers no /rewind candidate (Chat.tsx rewindRows)') + } finally { unusedChannel.releaseContributions() } + assert.equal(extractEntries(String(unusedSource.agent.session.id), unusedSeed).length, 0, 'a never-used source offers no /tree entry to rewind or fork from') + assert.equal(isUnstoredFreshSession(unusedSource.agent.session), true, 'mounting a channel over the source does not use it up') + + const usedSource = await fresh('channel-seed-used-source') + const usedChannel = createChannel(ctx, usedSource.agent, { handle: usedSource, cwd: root, provider: 'scripted', model: 'scripted', activity: false }) + try { + usedChannel.submit('a real prompt') + assert.ok(await settled(() => usedChannel.rows.some(row => row.text === 'saved reply') && !usedChannel.working)) + } finally { usedChannel.releaseContributions() } + await ctx.sessions.flush(usedSource.agent.session) + assert.equal(isUnstoredFreshSession(usedSource.agent.session), false, 'a source with a real event is no longer never-used') + const usedSeed = usedSource.agent.session.snapshotEvents() + assert.ok(usedSeed.some(event => event.type === 'turn/start'), 'the used source holds a turn') + assert.ok(usedSeed.some(event => event.type === 'user/message'), 'the used source holds the human prompt that started it') + + const sites: readonly { readonly name: string; readonly parentSession: SessionId | undefined }[] = [ + { name: 'channel-model-switch', parentSession: undefined }, + { name: 'channel-session-fork', parentSession: undefined }, + { name: 'channel-session-rewind', parentSession: usedSource.agent.session.id }, + { name: 'channel-session-tree-actions', parentSession: usedSource.agent.session.id }, + ] + for (const site of sites) { + // (a) The branch a never-used source takes: unseeded, and created + // through the fresh-session gate (the shape `/new` and `/bg` use). + const childId = SessionId(`${site.name}-unused`) + createFlushes.add(String(childId)) + const child = await createFreshAgent(ctx, ctx.agents, { + sessionId: childId, + meta: { cwd: root }, + agentOptions: { provider: 'scripted', model: 'scripted' }, + }) + handles.push(child) + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(existsSync(artifact(child.agent.session)), false, `${site.name}: a never-used source publishes no child`) + assert.equal(isUnstoredFreshSession(child.agent.session), true, `${site.name}: the child starts as an unstored fresh session`) + assert.deepEqual(child.agent.session.snapshotEvents().map(event => event.type), policyTypes, `${site.name}: the child starts from its own initialization`) + title(child.agent.session, 'first real event') + await ctx.sessions.flush(child.agent.session) + assert.deepEqual((await stored(child.agent.session)).map(event => event.seq), [0, 1, 2, 3], `${site.name}: the child publishes completely from seq 0`) + await assertComplete(child.agent.session) + + // (b) A source with real events keeps the unchanged branch: the prefix + // is copied, and the create-time checkpoint adds nothing to it. + const seededId = SessionId(`${site.name}-used`) + createFlushes.add(String(seededId)) + const seeded = await ctx.agents.create(liveSessionCreateOptions({ + sessionId: seededId, + seed: usedSeed, + runtimeSession: usedSource.agent.session, + inheritedCount: usedSeed.length, + cwd: root, + ...(site.parentSession === undefined ? {} : { parentSession: site.parentSession }), + agentOptions: { provider: 'scripted', model: 'scripted' }, + })) + handles.push(seeded) + await sleep(250) // 固定窗:墙钟 — the host-appended prefix reaches disk only when the JSONL 200ms batch drain fires; the read-back below compares that file. + const persisted = await stored(seeded.agent.session) + assert.deepEqual(persisted.slice(0, usedSeed.length), usedSeed, `${site.name}: a used source still copies its whole prefix`) + assert.deepEqual(persisted, seeded.agent.session.snapshotEvents(), `${site.name}: the seeded child stores exactly its own log`) + assert.deepEqual(persisted.slice(0, 3).map(event => event.type), policyTypes, `${site.name}: no create-time checkpoint adds a suffix to the seeded child`) + + // Negative control (--negative-controls): the pre-fix DECISION for the + // same never-used source — copy its initialization anyway. The host + // stores a seed at publication, so the child's log appears; this is the + // revert the wiring checks above refuse to let back in. + if (negativeControls) { + const preFix = await ctx.agents.create(liveSessionCreateOptions({ + sessionId: SessionId(`${site.name}-pre-fix-seed`), + seed: unusedSeed, + runtimeSession: unusedSource.agent.session, + inheritedCount: unusedSeed.length, + cwd: root, + agentOptions: { provider: 'scripted', model: 'scripted' }, + })) + handles.push(preFix) + assert.equal(existsSync(artifact(preFix.agent.session)), true, `negative control: seeding a never-used source publishes the ${site.name} child`) + console.log(`PASS negative control: seeding a never-used source publishes the ${site.name} child`) + } + } + } + await verifySeededFamily() + + /** + * The face T-FIX-10's verdict could not see: a shell that is ALREADY on + * disk. `isUnstoredFreshSession` answers for the deferral THIS process + * installed, so a shell left behind by an earlier process — or one whose + * `agent-preset/selected` already started the gate, or one web created — is + * not in its WeakSet. The widened verdict asks what the source's log holds + * instead, from the live snapshot in hand. + * + * The real `/fork` action is driven here, not just the creation shape it + * takes: its notice is the user-facing half of the same decision, and a + * seeded child would publish an artifact. Narrowing the verdict back to + * `isUnstoredFreshSession` therefore reddens this case on all three counts. + * The same action is then driven over a source that HAS content, which must + * keep the seeded path exactly as it was. + */ + const verifyOnDiskShellSource = async (): Promise => { + const shell = await ctx.agents.create(options('on-disk-shell')) + handles.push(shell) + assert.ok(await settled(() => existsSync(artifact(shell.agent.session))), 'the ungated shell publishes at creation') + await shell.dispose() + const resumed = await ctx.agents.resume({ resumeSessionId: options('on-disk-shell').sessionId }) + handles.push(resumed) + const shellEvents = resumed.agent.session.snapshotEvents() + // The shell's log is the shape an earlier process leaves behind: the + // initialization it was created with (plus the constructor's own + // `session/end-seed`), and nothing a person ever said. + assert.deepEqual((await stored(resumed.agent.session)).slice(0, 3).map(event => event.type), policyTypes, 'the log on disk keeps the initialization it was created with') + assert.equal(shellEvents.some(event => event.type === 'turn/start' || event.type === 'user/message'), false, 'the resumed shell carries no conversation evidence in the snapshot the verdict reads') + assert.equal(isUnstoredFreshSession(resumed.agent.session), false, 'the never-used verdict cannot see a shell that is already on disk — the face this widening adds') + + const forks: AgentHandle[] = [] + const notices: string[] = [] + const created: string[] = [] + const fork = createForkSessionAction( + ctx, + { working: false, cwd: root, provider: 'scripted', model: 'scripted', sessionTitle: 'shell' }, + { + owner: { current: () => true }, + settleCompaction: async () => {}, + notify: text => { notices.push(text) }, + source: () => resumed.agent.session, + createDetachedHandle: async create => { + const handle = await create() + forks.push(handle) + return { handle, release: async () => { await handle.dispose() } } + }, + }, + ) + const driveFork = async (): Promise => { + notices.length = 0 + forks.length = 0 + created.length = 0 + persistence.create = async function (header, config) { + created.push(String(header.id)) + return originalCreate.call(this, header, config) + } + try { + assert.equal(await fork(), true, 'the /fork action completes') + } finally { persistence.create = originalCreate } + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(created.length, 1, 'the fork creates exactly one session') + return created[0]! + } + + // (a) A shell on disk: no notice may promise a resume, and no child log + // may exist before the child's own first real event. + const childId = await driveFork() + const child = forks[0]! + assert.equal(isUnstoredFreshSession(child.agent.session), true, 'the /fork action took its unseeded branch for an on-disk shell') + assert.equal(existsSync(artifact(child.agent.session)), false, 'a fork of an on-disk shell publishes no child') + const notice = notices.at(-1) ?? '' + assert.equal(notice, t('fork-done-unstored', { id: childId }), 'the fork notice for a conversation-less source is the new-session wording') + assert.equal(notice.includes('--resume'), false, 'a fork of a conversation-less source must not print a --resume command') + assert.equal(notice.includes('DSH_TUI_RESUME_SESSION'), false, 'nor the POSIX resume form') + + // (b) The other direction at the same level: a source WITH content keeps + // the seeded branch — a widening that swallowed every source would fail + // here, and the copied prefix is still byte for byte the source log. + const usedChannel = createChannel(ctx, resumed.agent, { handle: resumed, cwd: root, provider: 'scripted', model: 'scripted', activity: false }) + try { + usedChannel.submit('a real prompt') + assert.ok(await settled(() => usedChannel.rows.some(row => row.text === 'saved reply') && !usedChannel.working)) + } finally { usedChannel.releaseContributions() } + await ctx.sessions.flush(resumed.agent.session) + const usedEvents = resumed.agent.session.snapshotEvents() + assert.ok(usedEvents.some(event => event.type === 'turn/start'), 'the source now holds a turn') + const seededId = await driveFork() + const seededChild = forks[0]! + const seededNotice = notices.at(-1) ?? '' + assert.equal(isUnstoredFreshSession(seededChild.agent.session), false, 'a source with real content still takes the seeded branch') + assert.equal(existsSync(artifact(seededChild.agent.session)), true, '…and its child still publishes the copied prefix') + assert.equal(seededNotice.includes(`--resume ${seededId}`) || seededNotice.includes(`DSH_TUI_RESUME_SESSION=${seededId}`), true, '…and the notice still says how to enter it') + assert.deepEqual((await stored(seededChild.agent.session)).slice(0, usedEvents.length), usedEvents, 'the copied prefix is byte for byte the source log') + console.log('PASS /fork on an on-disk shell: no resume command, no child log, and a used source still seeds') + + if (negativeControls) { + // The pre-fix DECISION for the same on-disk shell: copy its + // initialization into a child and advertise the resume command. Both + // assertions above must be able to see this (L-044 / L-048). + const preFixId = String(options('on-disk-shell-pre-fix').sessionId) + const preFix = await ctx.agents.create(liveSessionCreateOptions({ + sessionId: SessionId(preFixId), + seed: shellEvents, + runtimeSession: resumed.agent.session, + inheritedCount: shellEvents.length, + cwd: root, + agentOptions: { provider: 'scripted', model: 'scripted' }, + })) + handles.push(preFix) + assert.equal(isUnstoredFreshSession(preFix.agent.session), false, 'negative control: a seeded child of an on-disk shell is not an unstored fresh session') + assert.equal(existsSync(artifact(preFix.agent.session)), true, 'negative control: seeding an on-disk shell publishes its child') + assert.equal(t('fork-done', { id: preFixId, command: `dsh-tui --resume ${preFixId}` }).includes('--resume'), true, 'negative control: the pre-fix notice advertises a resume command') + console.log('PASS negative control: seeding an on-disk shell publishes its child and the pre-fix notice advertises a resume command') + } + } + await verifyOnDiskShellSource() + + /** + * The CUT the verdict now judges, driven through the real `/rewind` action. + * T-FIX-11 asked whether the SOURCE SESSION holds a conversation, and a + * rewind boundary can stop before every real event: the first message's + * boundary is the seq before its turn/start, and that turn opens behind the + * initialization `session/created` wrote (seq 0-2). A source that holds a + * whole conversation can therefore cut down to a prefix with no evidence in + * it, and the source verdict seeded that prefix — the host stores a seed at + * publication, so the child's log existed before its first real event: the + * permission-only shell, by the one road the source verdict could not see. + * + * The source conversation is written in the durable `user/message` form + * `conversationEvidence` also counts (`digest.ts:66-98`), not through this + * host's inbox. A live prompt is preceded by its own `agent/inbox/spliced`, + * and that splice carries the human message — as evidence it fills the + * first-message cut, which is why the fixture writes the durable form a + * foreign or older writer leaves. The verdict must judge that cut, not the + * session it was cut from. + * + * (a) rewind to the first message: the cut is the initialization alone, so + * the child must be unseeded, must leave no log, and no notice may + * offer a resume command for a session that has none; + * (b) rewind to the second message: the cut holds the first turn, so the + * branch is unchanged and the copied prefix is byte for byte the cut. + * + * `--negative-controls` replays the pre-fix DECISION on the same cut (seed + * it, as the source verdict did), so "no log" in (a) is a discriminating + * assertion rather than a vacuous one (L-044 / L-048). + */ + const verifyCutPrefixSeeding = async (): Promise => { + const source = await ctx.agents.create(options('rewind-cut-source')) + handles.push(source) + for (const [turn, text] of [[1, 'first prompt'], [2, 'second prompt']] as const) { + source.agent.session.append('turn/start', { turn }) + source.agent.session.append('step/start', { turn, step: 1 }) + source.agent.session.append('user/message', createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }), { surfaceOp: 'append' }) + source.agent.session.append('assistant/message', { + turn, step: 1, stream: [], + message: createAssistantMessage({ source: { provider: 'scripted', model: 'scripted' }, content: [{ type: 'text', text: 'saved reply' }] }), + }, { surfaceOp: 'append' }) + source.agent.session.append('step/end', { turn, step: 1 }) + source.agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + await ctx.sessions.flush(source.agent.session) + const notices: string[] = [] + const created: AgentSession[] = [] + const switched: string[] = [] + const channel = createChannel(ctx, source.agent, { handle: source, cwd: root, provider: 'scripted', model: 'scripted', activity: false }) + const rewindRows = () => channel.rows.filter(row => row.kind === 'user' && row.label === undefined) + const rewind = createRewindToAction( + ctx, + { working: false, cwd: root, provider: 'scripted', model: 'scripted' }, + { + owner: { current: () => true }, + binding: { + agent: source.agent, + capture: () => ({ session: source.agent.session, agent: source.agent, generation: 1 }), + isCurrent: () => true, + prepare: async (_capture, create) => await create(), + abandon: async session => { await session.dispose() }, + }, + settleCompaction: async () => {}, + notify: text => { notices.push(text) }, + adoptForkedAgent: candidate => { created.push(candidate); return String(source.agent.session.id) }, + notifySessionSwitched: (_kind, sessionId) => { switched.push(sessionId) }, + }, + ) + const drive = async (row: ReturnType[number]): Promise => { + notices.length = 0 + created.length = 0 + switched.length = 0 + assert.equal(await rewind(row), row.text, 'the /rewind action completes and hands the prompt back for editing') + assert.equal(created.length, 1, 'the rewind creates exactly one session') + assert.equal(switched.length, 1, 'the rewind commits the switch') + return created[0]! + } + /** The child's live session, through the handle its adoption kept. */ + const liveOf = (candidate: AgentSession) => dshHandleOf(candidate).agent.session + try { + const events = source.agent.session.snapshotEvents() + assert.ok(await settled(() => rewindRows().length === 2), 'the conversation offers both prompts as rewind rows') + const rows = rewindRows() + assert.equal(events[rows[0]!.seq!]!.type, 'user/message', 'a rewind row carries its own message seq') + assert.equal(isUnstoredFreshSession(source.agent.session), false, 'the rewind source is a used session — the source verdict alone would seed it') + assert.ok(events.some(event => event.type === 'turn/start' && event.seq > 0), 'the conversation opens behind the initialization (otherwise its first message could not be rewound at all)') + + // (a) The first message: the cut stops before its turn/start, so it is + // the initialization alone while the source is a whole conversation. + const firstBoundary = rewindTarget(events, rows[0]!.seq!).boundary + assert.ok(firstBoundary >= 0, 'rewinding to the first message is reachable — it is not the turn-0 refusal') + assert.deepEqual(events.slice(0, firstBoundary + 1).map(event => event.type), policyTypes, 'the cut on offer is the initialization alone') + const cutChild = await drive(rows[0]!) + handles.push(dshHandleOf(cutChild)) + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(isUnstoredFreshSession(liveOf(cutChild)), true, 'a cut that holds no conversation takes the unseeded branch') + assert.equal(existsSync(artifact(liveOf(cutChild))), false, 'and leaves no log on disk') + assert.deepEqual(liveOf(cutChild).snapshotEvents().map(event => event.type), [...policyTypes, ...policyTypes], 'the child starts from its own initialization plus the cut policy facts — the conversation is still not inherited') + assert.equal(notices.some(text => text.includes('--resume') || text.includes('DSH_TUI_RESUME_SESSION')), false, 'no notice offers to resume a session that has no log') + + // (b) The second message: the cut still holds the whole first turn, so + // the branch is the unchanged one, prefix included. + const secondBoundary = rewindTarget(events, rows[1]!.seq!).boundary + const expectedCut = events.slice(0, secondBoundary + 1) + assert.ok(expectedCut.some(event => event.type === 'turn/start'), 'the second cut holds the conversation') + const seededChild = await drive(rows[1]!) + handles.push(dshHandleOf(seededChild)) + await sleep(250) // 固定窗:墙钟 — the /rewind child's seed is host-appended before session/created and materializes on the first 200ms batch, before it can be read back. + assert.equal(isUnstoredFreshSession(liveOf(seededChild)), false, 'a cut that holds a conversation takes the seeded branch') + assert.equal(existsSync(artifact(liveOf(seededChild))), true, 'and publishes the copied prefix') + assert.deepEqual((await stored(liveOf(seededChild))).slice(0, expectedCut.length), expectedCut, 'the copied prefix is byte for byte the cut') + console.log('PASS /rewind cut prefix: a policy-only cut stays unseeded while a content cut still seeds') + + if (negativeControls) { + // The pre-fix DECISION for the same cut: the source holds a + // conversation, so the source verdict seeded the initialization-only + // prefix — and the host stores a seed at publication, so the shell + // appeared. (a) asserts exactly what this produces. + const cut = events.slice(0, firstBoundary + 1) + const preFix = await ctx.agents.create(liveSessionCreateOptions({ + sessionId: SessionId(`${compression}-rewind-cut-pre-fix-seed`), + seed: cut, + runtimeSession: source.agent.session, + inheritedCount: cut.length, + cwd: root, + agentOptions: { provider: 'scripted', model: 'scripted' }, + })) + handles.push(preFix) + assert.equal(isUnstoredFreshSession(preFix.agent.session), false, 'negative control: a seeded child of a policy-only cut is not an unstored fresh session') + assert.equal(existsSync(artifact(preFix.agent.session)), true, 'negative control: the source verdict seeds the policy-only cut into a published shell') + console.log('PASS negative control: the source verdict seeds the policy-only cut into a published shell') + } + } finally { channel.releaseContributions() } + } + await verifyCutPrefixSeeding() + // Hold the first suffix in the public writer while more events arrive, // then fail the next suffix. A checkpoint must retry that exact prefix. @@ -344,6 +1098,206 @@ async function verify(compression: 'zstd' | 'none'): Promise { return savedId! } +/** + * `/bg` is the one channel create that starts an unseeded session, so it is the + * one that can share the gate. The shape checks above drive `createFreshAgent` + * directly and would stay green if the action went back to the ungated factory, + * so pin the wiring itself. + */ +function verifyChannelWiring(): void { + const source = readFileSync(new URL('../src/dsh-adapter/channel/background-action.ts', import.meta.url), 'utf8') + assert.match(source, /import \{ createFreshAgent \} from '\.\.\/fresh-agent\.js'/, '/bg imports the fresh-session gate') + assert.match(source, /createFreshAgent\(ctx, agents, \{/, '/bg creates through the gate') + assert.equal(source.includes('agents.create('), false, '/bg no longer calls the ungated factory') + console.log('PASS /bg creation wiring') +} + +/** + * The four seeded channel actions. `verifySeededFamily` drives the creation + * SHAPES through the real host, `verifyOnDiskShellSource` drives `/fork` itself, + * `verifyCutPrefixSeeding` drives `/rewind` itself and `verifyCutPolicyReplay` + * drives the real `/model` and `/fork` over a source in a non-default policy; + * the SHAPES alone would stay green if an action went back to seeding + * unconditionally, because they drive the creation rather than the action. So + * pin the wiring: each action must cut the seed first, ask the CUT verdict — + * the deferral's `isUnstoredFreshSession` where it can answer, then the sweep's + * evidence rule over that slice — let THAT verdict select the unseeded branch, + * and replay the cut's policy facts into that branch AFTER the factory returns + * (never inside `setup`, where the deferral would silently not arm). Every + * marker is guarded (a missing or reordered marker fails instead of passing on + * an empty window — LESSONS L-048), and `--negative-controls` reverses the + * decision to prove the checks can fail (L-044): seeded unconditionally, the + * verdict put back on the SOURCE session, the never-used shortcut dropped, the + * verdict dropped, the policy replay dropped, the facts never read from the + * cut, and `/fork`'s notice reverting to an advertised resume command. + */ + +/** The one line every site must carry: the verdict asks the CUT, never the source. */ +const CUT_EVIDENCE = 'holdsNoConversation(cut)' + +/** The one line that turns a conversation-less cut's policy facts into the child's. */ +const POLICY_FACTS = 'const policyFacts = cutHoldsNoConversation ? latestPolicyFacts(cut) : []' + +const SEEDED_SITES: readonly { + readonly name: string + readonly file: string + /** The live snapshot each site judged before T-FIX-12, for the "back to the source" reversal. */ + readonly sourceEvents: string + /** The session expression the never-used shortcut reads, for the "shortcut dropped" reversal. */ + readonly subject: string + /** The site's own replay call, for the "replay dropped" reversal. */ + readonly replay: string + /** A notice line, when the site has one, for the "resume command is back" reversal. */ + readonly notice?: string + readonly markers: readonly string[] +}[] = [ + { + name: 'channel-model-switch', + file: 'model-switch.ts', + sourceEvents: 'snapshotLiveSessionEvents(source)', + subject: 'source', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source)', + `seed = neverUsed || ${CUT_EVIDENCE} ? [] : cut`, + 'const cutHoldsNoConversation = seed.length === 0', + POLICY_FACTS, + 'const create = (): Promise => cutHoldsNoConversation', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + 'const handle = await create()', + 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + 'return createDshSession(ctx, handle)', + ], + }, + { + name: 'channel-session-fork', + file: 'session-fork.ts', + sourceEvents: 'snapshotLiveSessionEvents(source)', + subject: 'source', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(detached.handle.agent.session, policyFacts)', + notice: "? t('fork-done-unstored', { id: String(childId) })", + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source)', + `seed = neverUsed || ${CUT_EVIDENCE} ? [] : cut`, + 'const cutHoldsNoConversation = seed.length === 0', + POLICY_FACTS, + 'deps.createDetachedHandle(() => cutHoldsNoConversation', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + 'if (cutHoldsNoConversation) replayPolicyFacts(detached.handle.agent.session, policyFacts)', + "? t('fork-done-unstored', { id: String(childId) })", + ], + }, + { + name: 'channel-session-rewind', + file: 'session-rewind.ts', + sourceEvents: 'snapshotLiveSessionEvents(source)', + subject: 'source', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source, boundary)', + `seed = neverUsed || ${CUT_EVIDENCE} ? [] : cut`, + 'const cutHoldsNoConversation = seed.length === 0', + POLICY_FACTS, + 'const create = (): Promise => cutHoldsNoConversation', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + 'const handle = await create()', + 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + 'return createDshSession(ctx, handle)', + ], + }, + { + name: 'channel-session-tree-actions', + file: 'session-tree-actions.ts', + sourceEvents: 'sourceEvents', + subject: 'entrySession', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + markers: [ + 'const cut = sourceEvents.filter(event => event.seq <= target.boundary)', + // The never-used shortcut still answers first, but only for the LIVE + // source (the deferral is this process's own bookkeeping): the evidence + // rule below reads the CUT for a persisted foreign source too. + 'const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession)', + `const seed = neverUsed || ${CUT_EVIDENCE} ? [] : cut`, + 'const cutHoldsNoConversation = seed.length === 0', + POLICY_FACTS, + 'const create = (): Promise => cutHoldsNoConversation', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + 'const handle = await create()', + 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', + 'return createDshSession(ctx, handle)', + ], + }, +] + +/** Every wiring violation of one site's source text, in reading order. */ +function seededWiringViolations( + site: (typeof SEEDED_SITES)[number], + source: string, +): string[] { + const violations: string[] = [] + if (!/import \{ createFreshAgent, isUnstoredFreshSession \} from '\.\.\/fresh-agent\.js'/.test(source)) { + violations.push('does not import createFreshAgent + isUnstoredFreshSession') + } + // Co-imports are allowed (and now present): all three cut helpers must come + // from the one module that owns them. + if (!/import \{[^}]*\bholdsNoConversation\b[^}]*\blatestPolicyFacts\b[^}]*\breplayPolicyFacts\b[^}]*\} from '\.\.\/unspoken-sessions\.js'/.test(source)) { + violations.push('does not ask the cut criterion and replay the cut policy through unspoken-sessions.ts') + } + let cursor = -1 + for (const marker of site.markers) { + const at = source.indexOf(marker) + if (at === -1) { violations.push(`missing: ${marker}`); continue } + if (at <= cursor) violations.push(`out of order: ${marker}`) + cursor = at + } + return violations +} + +function verifySeededWiring(): void { + for (const site of SEEDED_SITES) { + const path = new URL(`../src/dsh-adapter/channel/${site.file}`, import.meta.url) + const source = readFileSync(path, 'utf8') + assert.deepEqual(seededWiringViolations(site, source), [], `${site.name}: the cut verdict selects the unseeded branch and its policy facts reach the child`) + if (negativeControls) { + // The reversals this task forbids — seed unconditionally, put the verdict + // back on the SOURCE session, drop the never-used shortcut, drop the + // verdict, drop the policy replay (and read no facts from the cut), and + // put the resume command back in `/fork`'s notice. Every one must be + // caught (L-044 / L-048). For `/model` and `/fork` the source form + // denotes the same events as the cut (their cut IS the whole log), so + // that reversal is a text-level one there; the behavioural proof lives in + // `verifyCutPrefixSeeding`'s pre-fix replay. + const unconditional = seededWiringViolations(site, source.replaceAll('=> cutHoldsNoConversation', '=> false')) + assert.ok(unconditional.length > 0, `negative control: ${site.name} wiring catches seeding unconditionally`) + const sourceJudged = seededWiringViolations(site, source.replace(CUT_EVIDENCE, `holdsNoConversation(${site.sourceEvents})`)) + assert.ok(sourceJudged.length > 0, `negative control: ${site.name} wiring catches the cut verdict narrowed back to the source session`) + const shortcutless = seededWiringViolations(site, source.replaceAll(`isUnstoredFreshSession(${site.subject})`, 'false')) + assert.ok(shortcutless.length > 0, `negative control: ${site.name} wiring catches a dropped never-used shortcut`) + const verdictless = seededWiringViolations(site, source.replace(CUT_EVIDENCE, 'true')) + assert.ok(verdictless.length > 0, `negative control: ${site.name} wiring catches a dropped verdict`) + const replayless = seededWiringViolations(site, source.replace(site.replay, 'void policyFacts')) + assert.ok(replayless.length > 0, `negative control: ${site.name} wiring catches a dropped policy replay`) + const factless = seededWiringViolations(site, source.replace(POLICY_FACTS, 'const policyFacts: readonly unknown[] = []')) + assert.ok(factless.length > 0, `negative control: ${site.name} wiring catches policy facts that are never read from the cut`) + const notice = site.notice === undefined + ? [] + : seededWiringViolations(site, source.replace(site.notice, "t('fork-done', { id: String(childId), command })")) + if (site.notice !== undefined) { + assert.ok(notice.length > 0, `negative control: ${site.name} wiring catches a notice that advertises a resume command again`) + } + console.log(`PASS negative control: ${site.name} wiring catches "seed unconditionally", the verdict back on the source session, a dropped never-used shortcut, a dropped verdict, a dropped policy replay and cut facts that are never read${site.notice === undefined ? '' : ', and the resume notice coming back'}`) + } + console.log(`PASS ${site.name} seeded wiring`) + } +} + function verifyHandoffs(savedId: SessionId): void { const cases = [ { name: 'startup empty /restart', kind: 'restart', session: '', args: [], expected: [] }, @@ -371,9 +1325,242 @@ function verifyHandoffs(savedId: SessionId): void { } } +/** + * CR-1 / T-FIX-17: the POLICY FACTS a conversation-less cut carries. + * + * "This cut holds no conversation" is not "this cut holds nothing". The + * source's plan mode, sandbox mode, approval policy and durable permission + * preset live in the same prefix, and the unseeded branch copies none of it — + * a child that falls back to the deployment defaults can end up with WIDER + * permissions than the session it came from. That is the regression + * CodeRabbit reported against the T-FIX-10/12 decision, and the reason the + * four actions replay the cut's policy facts before the child's first prompt. + * + * Every case drives a REAL action (the channel's `/model`, the `/fork` action) + * over a source in a NON-DEFAULT policy and reads the child's effective policy + * through the services that ENFORCE it (`ctx.planMode`, `ctx.sandboxPolicy`, + * `ctx.approval`, the channel's preset fold) — never by restating their folds. + * The source reading is pinned to `RESTRICTED_POLICY` first, so "the child + * equals the source" cannot pass vacuously, and each case also asserts the + * child stays unpublished: a replay that appended anything the deferral does + * NOT ignore would start it and publish the very shell the unseeded branch + * exists to avoid (`fresh-agent.ts`'s `INITIAL_POLICY_EVENTS` is the whole + * reason appending those four types is safe — which is also why the replay + * must run AFTER the factory returns; inside `setup` a `seq !== 0` session + * makes the gate return in silence, KNOWN-ISSUES B-14 ①). + * + * The context is this case's own: the policy services contribute runtime + * context to every request (the agent loop logs that snapshot as model + * history), which would add a `user/message` to the cases above. + */ +async function verifyCutPolicyReplay(): Promise { + const policyRoot = mkdtempSync(join(tmpdir(), 'dsh-tui-policy-replay-')) + const ctx = new Context() + const handles: AgentHandle[] = [] + try { + for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { + await ctx.plugin(plugin) + } + await ctx.plugin(JsonlSessionPersistence, { root: policyRoot, compression: 'none' }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SandboxPolicy, { mode: 'workspace-write' }) + await ctx.plugin(Approval) + await ctx.plugin(PlanMode, { section: 'plan mode is active' }) + ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter()) + // The official permission service's `session/created` pinning, plus the + // host projection cache's create-time checkpoint: every session starts + // from the same three deployment defaults and is checkpointed at once. + // The checkpoint is not a publication — the child must survive it. + ctx.on('session/created', session => { + if (session.seq !== 0) return + session.append('permission/preset', { preset: 'workspace-write' }) + session.append('sandbox/mode', { mode: 'workspace-write' }) + session.append('approval/policy', { policy: 'ask' }) + void ctx.sessions.flush(session) + }) + const persistence = concreteService(ctx.sessionPersistence) + const artifact = (session: Session): string => persistence.locate(session.header).path + const fresh = async (id: string): Promise => { + const handle = await createFreshAgent(ctx, ctx.agents, { + sessionId: SessionId(`policy-${id}`), meta: { cwd: policyRoot }, + agentOptions: { provider: 'scripted', model: 'scripted' }, + }) + handles.push(handle) + return handle + } + const stored = async (session: Session): Promise => { + const reader = await persistence.open(session.id, 'read') + try { return (await reader.read()).events } finally { await reader.close() } + } + + // (A) `/model` over a never-used source: its cut is the policy alone. + const unused = await fresh('cut-model-source') + restrictPolicy(ctx, unused.agent) + assert.deepEqual(policyOf(ctx, unused.agent), RESTRICTED_POLICY, '(A) the source is in a non-default policy') + assert.equal(isUnstoredFreshSession(unused.agent.session), true, '(A) and nobody has used it — the never-used verdict still answers') + assert.equal(holdsNoConversation(unused.agent.session.snapshotEvents()), true, '(A) its cut holds no conversation: the CR-1 shape') + const modelChannel = createChannel(ctx, unused.agent, { handle: unused, cwd: policyRoot, provider: 'scripted', model: 'scripted', activity: false }) + try { + assert.equal(await modelChannel.switchModel('scripted', 'scripted'), true, '(A) the /model action completes') + const child = ctx.agents.get(SessionId(modelChannel.agentId)) + assert.ok(child !== undefined && child.session.id !== unused.agent.session.id, '(A) the switch adopted a replacement session') + assert.deepEqual(policyOf(ctx, child), RESTRICTED_POLICY, '(A) the unseeded /model child keeps the source policy') + assert.equal(isUnstoredFreshSession(child.session), true, '(A) and it is still unpublished') + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(existsSync(artifact(child.session)), false, '(A) the child has no log before its first real event') + modelChannel.submit('first real prompt') + assert.ok(await settled(() => modelChannel.rows.some(row => row.text === 'saved reply') && !modelChannel.working), '(A) the child runs its first turn') + await ctx.sessions.flush(child.session) + const published = await stored(child.session) + assert.deepEqual(published.map(event => event.seq), published.map((_, index) => index), '(A) the published log is contiguous from seq 0') + assert.deepEqual(policyOf(ctx, child), RESTRICTED_POLICY, '(A) and the published child still carries the source policy') + console.log('PASS CR-1 /model: an unseeded child keeps the source policy, and the deferral survives the replay') + } finally { modelChannel.releaseContributions() } + + // (B) `/fork` over a TITLED SHELL: not never-used, yet its cut still holds + // no conversation — the face only the cut criterion can answer (T-FIX-12's + // widening). The same action over a source that DOES hold a conversation + // must keep the seeded branch, policy included. + const shell = await fresh('cut-fork-shell') + restrictPolicy(ctx, shell.agent) + title(shell.agent.session, 'a titled shell') + await ctx.sessions.flush(shell.agent.session) + assert.equal(isUnstoredFreshSession(shell.agent.session), false, '(B) a titled shell is no longer never-used') + assert.equal(holdsNoConversation(shell.agent.session.snapshotEvents()), true, '(B) yet its cut still holds no conversation') + + const talker = await fresh('cut-fork-used') + restrictPolicy(ctx, talker.agent) + const talkerChannel = createChannel(ctx, talker.agent, { handle: talker, cwd: policyRoot, provider: 'scripted', model: 'scripted', activity: false }) + try { + talkerChannel.submit('a real prompt') + assert.ok(await settled(() => talkerChannel.rows.some(row => row.text === 'saved reply') && !talkerChannel.working), '(C) the source answers its prompt') + } finally { talkerChannel.releaseContributions() } + await ctx.sessions.flush(talker.agent.session) + assert.equal(holdsNoConversation(talker.agent.session.snapshotEvents()), false, '(C) the used source holds a conversation') + + let forkSource: Session = shell.agent.session + const forks: AgentHandle[] = [] + let releases = 0 + const fork = createForkSessionAction( + ctx, + { working: false, cwd: policyRoot, provider: 'scripted', model: 'scripted', sessionTitle: 'source' }, + { + owner: { current: () => true }, + settleCompaction: async () => {}, + notify: () => {}, + source: () => forkSource, + createDetachedHandle: async create => { + const handle = await create() + forks.push(handle) + // The child stays LIVE here on purpose: the action replays the cut's + // policy facts only after this factory returns, so a reading taken + // inside it would see the child before the replay. The real + // implementation disposes the handle right after the action, and + // `verifyOnDiskShellSource` covers that shape end to end. + handles.push(handle) + return { handle, release: async () => { releases++ } } + }, + }, + ) + const driveFork = async (source: Session): Promise => { + forkSource = source + forks.length = 0 + releases = 0 + assert.equal(await fork(), true, 'the /fork action completes') + assert.equal(forks.length, 1, 'the fork creates exactly one child') + assert.equal(releases, 1, 'and releases it') + return forks[0]! + } + + const shellChild = await driveFork(shell.agent.session) + assert.deepEqual(policyOf(ctx, shellChild.agent), RESTRICTED_POLICY, '(B) the unseeded /fork child keeps the shell policy') + assert.equal(isUnstoredFreshSession(shellChild.agent.session), true, '(B) and it is still unpublished') + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(existsSync(artifact(shellChild.agent.session)), false, '(B) and it leaves no log on disk') + console.log('PASS CR-1 /fork: an unseeded child keeps the shell policy and stays unpublished') + + const seededChild = await driveFork(talker.agent.session) + assert.deepEqual(policyOf(ctx, seededChild.agent), RESTRICTED_POLICY, '(C) a source with real content keeps its policy on the seeded branch') + assert.equal(isUnstoredFreshSession(seededChild.agent.session), false, '(C) and still takes the seeded branch — the widening did not swallow it') + assert.ok(await settled(() => existsSync(artifact(seededChild.agent.session))), '(C) which publishes the copied prefix') + assert.deepEqual( + (await stored(seededChild.agent.session)).slice(0, talker.agent.session.snapshotEvents().length), + talker.agent.session.snapshotEvents(), + '(C) the copied prefix is byte for byte the source log', + ) + console.log('PASS CR-1 /fork reverse: a source with real content is unchanged, policy included') + + // (D) `/rewind` to the first message of a hand-written conversation: the + // boundary stops before its `turn/start`, so the cut is the policy atoms + // alone. Written in the durable `user/message` form a foreign writer leaves + // — a live prompt carries its own `agent/inbox/spliced` before the turn, + // and that splice IS human evidence filling the first-message cut. + const rewound = await fresh('cut-rewind-source') + restrictPolicy(ctx, rewound.agent) + for (const [turn, text] of [[1, 'first prompt'], [2, 'second prompt']] as const) { + rewound.agent.session.append('turn/start', { turn }) + rewound.agent.session.append('step/start', { turn, step: 1 }) + rewound.agent.session.append('user/message', createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }), { surfaceOp: 'append' }) + rewound.agent.session.append('assistant/message', { + turn, step: 1, stream: [], + message: createAssistantMessage({ source: { provider: 'scripted', model: 'scripted' }, content: [{ type: 'text', text: 'saved reply' }] }), + }, { surfaceOp: 'append' }) + rewound.agent.session.append('step/end', { turn, step: 1 }) + rewound.agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + await ctx.sessions.flush(rewound.agent.session) + const rewindChannel = createChannel(ctx, rewound.agent, { handle: rewound, cwd: policyRoot, provider: 'scripted', model: 'scripted', activity: false }) + try { + const rows = rewindChannel.rows.filter(row => row.kind === 'user' && row.label === undefined) + assert.equal(rows.length, 2, '(D) the conversation offers both prompts as rewind rows') + const events = rewound.agent.session.snapshotEvents() + const boundary = rewindTarget(events, rows[0]!.seq!).boundary + assert.equal(holdsNoConversation(events.slice(0, boundary + 1)), true, '(D) the cut on offer is the policy atoms alone') + assert.equal(await rewindChannel.rewindTo(rows[0]!), 'first prompt', '(D) the /rewind action hands the prompt back for editing') + const child = ctx.agents.get(SessionId(rewindChannel.agentId)) + assert.ok(child !== undefined && child.session.id !== rewound.agent.session.id, '(D) the rewind adopted a replacement session') + assert.deepEqual(policyOf(ctx, child), RESTRICTED_POLICY, '(D) the unseeded /rewind child keeps the cut policy') + assert.equal(isUnstoredFreshSession(child.session), true, '(D) and it is still unpublished') + await sleep(250) // 固定窗:探针 — beyond JSONL's 200ms live drain timer. + assert.equal(existsSync(artifact(child.session)), false, '(D) and it leaves no log on disk') + console.log('PASS CR-1 /rewind: an unseeded child keeps the cut policy and stays unpublished') + } finally { rewindChannel.releaseContributions() } + + if (negativeControls) { + // The pre-fix DECISION for the same policy-only cut: an unseeded child + // and no replay at all. Its policy falls back to the deployment defaults + // — and it still publishes nothing, which is why the policy assertion + // above is not vacuous, and why the two failure modes ("the policy was + // lost" / "the shell was published") are separately visible. + const preFix = await fresh('cut-pre-fix') + assert.notDeepEqual(policyOf(ctx, preFix.agent), RESTRICTED_POLICY, 'negative control: without the replay an unseeded child loses the source policy') + assert.deepEqual(policyOf(ctx, preFix.agent), { plan: false, sandbox: 'workspace-write', approval: 'ask', preset: 'workspace-write' }, 'negative control: it falls back to the deployment defaults') + await sleep(250) // 固定窗:探针 — an absent artifact is already true, so the window is what makes the silence observable. + assert.equal(existsSync(artifact(preFix.agent.session)), false, 'negative control: and it publishes nothing, so only the policy assertion can see the loss') + console.log('PASS negative control: an unseeded child without the replay loses the source policy while publishing nothing') + + // The other failure mode: a replay that appends a cut event the deferral + // does NOT ignore starts it, and the child is published immediately — + // the shell the unseeded branch exists to avoid. + const started = await fresh('cut-pre-fix-started') + title(started.agent.session, 'a replayed non-policy event') + assert.equal(isUnstoredFreshSession(started.agent.session), false, 'negative control: a non-policy append starts the deferral') + assert.ok(await settled(() => existsSync(artifact(started.agent.session))), 'negative control: and that replay publishes the shell') + console.log('PASS negative control: replaying a non-policy cut event starts the deferral and publishes the shell') + } + } finally { + for (const handle of handles) await handle.dispose() + await ctx.fiber.dispose() + rmSync(policyRoot, { recursive: true, force: true }) + } +} + try { + verifyChannelWiring() + verifySeededWiring() const savedId = await verify('none') await verify('zstd') + await verifyCutPolicyReplay() verifyHandoffs(savedId) } finally { rmSync(root, { recursive: true, force: true }) diff --git a/scripts/verify-session-cleanup-exit.tsx b/scripts/verify-session-cleanup-exit.tsx new file mode 100644 index 000000000..17a8bda8a --- /dev/null +++ b/scripts/verify-session-cleanup-exit.tsx @@ -0,0 +1,1054 @@ +#!/usr/bin/env node +/** + * Clean-exit sweep regression (ADR-0012 decisions 3/5 · AC-5 / AC-6): + * + * - the normal-exit fall-through sweeps the never-spoken shells and folds the + * count into the notice `finishExit` writes. The notice lands immediately + * after the terminal cleanup, so the round has to be OVER before the call — + * an awaited sweep could never reach it (DESIGN D6); + * - no other exit path sweeps: the crash / `/update` / kernel-switch / + * `/restart` / startup-failure branches keep the notices they had; + * - a hostile dependency costs the round, never the shutdown: the terminal + * restore sequence and the process hand-off stay exactly where they were + * (D7, fail-soft); + * - layer ③ is fed with this process's real view — the bound session, the + * live agents the registry lists, the sessions a live peer holds, and the + * delegated lineage of the listing plus each candidate's own header; + * - a session whose log carries a human message but no `turn/start` survives + * (AC-6 ①), and a listing this process never made spares the whole index + * instead of guessing the lineage. + * + * ## What is driven for real, and what can only be tripwired (F-10 / L-033) + * + * Every helper the branch calls is an exported pure function, so it is DRIVEN + * here: `composeExitNotice` (§1), `readExitListing` (§3), `exitListingGap` + * (§8), `readSessionHeaderFromLog` + the delegation verdict it feeds (§9), + * `liveExitSessionIds` (§10), `sweepUnspokenOnExit` (§1/§4/§5) and — for the + * cross-site question of who counts as a person — the sweep's log layer against + * `digestSession` (§11). + * + * The branch itself is a closure inside `apply()`, so its WIRING can only be + * read off `plugin.ts`. THREE assertions carry that meaning (§7); the rest of + * what used to be nine source-text assertions is kept here as a tripwire list, + * deliberately NOT asserted, because each was measured to go red on a + * zero-behaviour edit (a reworded comment, an extra mention of a name, moving a + * declaration) — the brittleness F-10 names: + * + * - `attachSessionListMetadata(ctx)` is invoked exactly once, at the + * composition root — no line number pinned here; the order this needs is + * asserted in `scripts/verify-session-list-metadata.ts` under + * `order: the session-list mirror is registered before the boot agent is + * resolved`; + * - the crash / update / kernel-switch / restart / startup branches still + * pass their own notice texts (`crashLine,` / `hintText,` / + * `t('restart-starting'),` / `formatHandoffNotice(` / + * `dsh-tui startup failed:`); + * - the count line is the dictionary entry + * (`t('exit-cleaned-unspoken-sessions')`), not a literal — and that one is + * proven behaviourally in §1, where the zh and en notices differ. + * + * ## Negative controls (L-044): what can be re-run here, and what cannot + * + * In this file, via `--negative-controls`: five groups drive deliberately + * broken subjects (or deliberately wrong inputs) through the SAME assertion + * bodies the suite uses and require them to go RED — see `CONTROL_GROUPS` at the + * end. The suite runs them too, so a green run also proves its own + * discriminating power; `--negative-controls` runs ONLY them and prints + * `negative-controls: N/M controls went red across 5 groups` for a caller that + * wants just that reading (T-FIX-04's `--reverse-check`). + * + * CLOSURE-LEVEL controls — the two from T05's one-off experiments, plus the + * reverse-check of the §7 interval assertion. They need a real-source rewrite, + * so they cannot live in the file; run the command and expect exactly the red + * rows named here (measured on this suite), then restore with + * `git checkout HEAD -- src/dsh-adapter/plugin.ts`: + * + * (a) the branch feeds empty process facts — write `currentSessionId: () => undefined` + * and `liveSessionIds: () => new Set()` inside the normal-exit fall-through + * branch's `sweepUnspokenOnExit({…})` call in `plugin.ts`. No line number is + * pinned on purpose: §7's `wiring:` assertions locate that call structurally + * $ node --import tsx/esm scripts/verify-session-cleanup-exit.tsx + * ⇒ 1 red: `wiring: the branch feeds the sweep the bound session, …` + * (b) the branch stops sweeping — drop the `const swept = sweepUnspokenOnExit({…})` + * call in `plugin.ts`'s normal-exit fall-through branch, leaving + * `const swept = undefined as UnspokenSweepResult | undefined` so the notice + * falls back to `hint` + * $ node --import tsx/esm scripts/verify-session-cleanup-exit.tsx + * ⇒ 2 red: `wiring: the sweep is invoked once, …` (found 0) and + * `wiring: the branch feeds the sweep …` + * (c) F-12's counter-example — inject a second sweep into the CRASH branch + * (`sweepUnspokenSessions({ currentSessionId: () => channel.agentId, + * liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId), + * isSubagentOrDescendant: () => true })` before `finish: crashLine => {`) + * $ node --import tsx/esm scripts/verify-session-cleanup-exit.tsx + * ⇒ 1 red: `wiring: the sweep is invoked once, …` (found 2, printing both + * offsets). The other two wiring assertions stay GREEN — which is why + * the interval property, not the call-site count, is what closes F-12. + * + * The three sweep/notice/lineage groups are the other three of T05's five + * experiments (a hint-only `composeExitNotice`, the pre-fix lineage mapping, a + * blind / brittle sweep runner); the remaining two groups cover this script's + * own new guards (the §7 source window, §11's cross-site parity). + * + * F-17 has its own switch, because no control that lives INSIDE the suite can + * crash it and still report the crash: + * + * $ DSH_TUI_EXIT_SWEEP_CRASH=1 node --import tsx/esm scripts/verify-session-cleanup-exit.tsx + * ⇒ `FAIL: F-17 control: a deliberately red row before the crash …` and + * `verify-session-cleanup-exit: 1/2 checks passed` are printed BEFORE the + * stack trace, exit 1. A plain run can never show that: the reporter used + * to be the last statement of the file, so a mid-suite throw printed the + * stack and nothing about which checks had run. + * + * ## Isolation (B-12) + * + * `DATA_DIR` is a module-level constant (the `DATA_DIR` export in + * `src/utils/paths.ts`, evaluated at import time), so the throwaway HOME is + * installed BEFORE the first import of anything that reaches it — hence the + * dynamic imports below. Without that, the synthetic fixtures + * read the operator's REAL `~/.dsh-tui/session-mounts.json` and their verdicts + * depend on it. §12 asserts the isolation: the ledger the round consults is a + * file under the throwaway home, and it is really consumed. + * + * Run: node --import tsx/esm scripts/verify-session-cleanup-exit.tsx [--negative-controls] + * DSH_TUI_EXIT_SWEEP_CRASH=1 node --import tsx/esm scripts/verify-session-cleanup-exit.tsx (F-17) + * @module dsh-tui/scripts/verify-session-cleanup-exit + */ + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { zstdCompressSync } from 'node:zlib' +// Type-only imports are erased at load time, so they cannot pull a module (and +// its `DATA_DIR`) in before the throwaway home is installed (B-12). +import type { ExitSweepInput } from '../src/dsh-adapter/plugin.js' +import type { + UnspokenCollection, + UnspokenSessionLineage, + UnspokenSweepDeps, + UnspokenSweepResult, +} from '../src/dsh-adapter/unspoken-sessions.js' + +/** Run only the negative controls (T-FIX-04's `--reverse-check` driver). */ +const NEGATIVE_CONTROLS_ONLY = process.argv.includes('--negative-controls') +/** + * F-17's own reverse-check: crash the suite on purpose right after the first + * check and require the reporter to have printed the rows that ran (see the + * module docstring for the command and the expected reading). + */ +const CRASH_ON_PURPOSE = process.env.DSH_TUI_EXIT_SWEEP_CRASH === '1' + +// ── a throwaway home BEFORE the first import that captures DATA_DIR (B-12) ── +const operatorHome = homedir() +const root = mkdtempSync(join(tmpdir(), 'dsh-tui-exit-sweep-')) +process.env.HOME = root +process.env.USERPROFILE = root +process.env.DSH_HOME = join(root, 'dsh') +process.env.DSH_TUI_SESSION_ROOT = join(root, 'logs') + +const { DATA_DIR } = await import('../src/utils/paths.js') +const { + composeExitNotice, + exitListingGap, + finishExit, + liveExitSessionIds, + readExitListing, + sweepUnspokenOnExit, +} = await import('../src/dsh-adapter/plugin.js') +const { + collectUnspokenSessionIds, + readSessionHeaderFromLog, + sweepUnspokenSessions, +} = await import('../src/dsh-adapter/unspoken-sessions.js') +const { digestSession } = await import('../src/dsh-adapter/sessions/digest.js') +const { getLang, setLang, t } = await import('../src/i18n.js') +const { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, DISABLE_WIN32_INPUT_MODE } = await import('../src/ink/termio/csi.js') +const { DBP, DFE, DISABLE_MOUSE_TRACKING, SHOW_CURSOR } = await import('../src/ink/termio/dec.js') +const { CLEAR_ITERM2_PROGRESS } = await import('../src/ink/termio/osc.js') +const instances = (await import('../src/ink/instances.js')).default + +// ── harness ───────────────────────────────────────────────────────────────── +// F-17: one reporter, which the exit handler also calls, so a throw halfway +// through still prints WHICH checks were red (and the temp tree is still +// removed) instead of leaving a bare stack trace. + +let failures = 0 +const results: string[] = [] +let controlsReading = '' + +const record = (name: string, ok: boolean, detail = ''): void => { + results.push(`${ok ? 'PASS' : 'FAIL'}: ${name}${ok || detail === '' ? '' : ` — ${detail}`}`) + if (!ok) failures++ +} + +/** Run one assertion body (node:assert throws) as one reported check. */ +const checkBody = (name: string, body: () => void): void => { + try { + body() + record(name, true) + } catch (error) { + const message = String((error as Error).message ?? error).replaceAll('\n', ' | ').slice(0, 400) + record(name, false, message) + } +} + +/** Require one assertion body to FAIL — the discriminating-power control. */ +const expectRed = (name: string, run: () => void, why: string, marker: string): void => { + try { + run() + } catch (error) { + const message = String((error as Error).message ?? error) + if (!message.includes(marker)) { + record(name, false, `went red on the wrong assertion: ${message.split('\n')[0] ?? ''} (expected one mentioning "${marker}")`) + return + } + record(name, true) + return + } + record(name, false, `expected a failure because ${why}`) +} + +let reported = false +const report = (): void => { + if (reported) return + reported = true + try { + rmSync(root, { recursive: true, force: true }) + } catch { + // A temp directory the OS will reap is not worth failing a regression for. + } + console.log(results.join('\n')) + console.log(`verify-session-cleanup-exit: ${results.length - failures}/${results.length} checks passed`) + if (controlsReading !== '') console.log(controlsReading) + if (failures > 0) process.exitCode = 1 +} +// Both the normal end of the suite and a throw in the middle land here (F-17): +// a crash must still print which checks were red, not just a stack trace. +process.on('exit', report) + +// ── the throwaway session root ────────────────────────────────────────────── +// Two shells nobody ever spoke in, and four that must survive: a conversation +// with a `turn/start`, one whose only evidence is a human message (no turn yet +// — the shape the web blank rule would call empty), a delegated run, a live +// background run, and the session this process is bound to. Every index entry +// says `hasPrompt: false` on purpose: the log layer and the process layer are +// what the assertions below actually exercise. +const SHELLS = ['shell-a', 'shell-b'] +const SPARED = ['spoken', 'human-first', 'sub-run', 'bg', 'cur'] +const dirOf = (id: string): string => join(root, id) +const index = new Map(SHELLS.concat(SPARED).map(id => [id, { derived: { hasPrompt: false } }])) +const logs = new Map([ + ['shell-a', { events: [], complete: true }], + ['shell-b', { events: [], complete: true }], + ['spoken', { events: [{ type: 'turn/start' }], complete: true }], + ['human-first', { events: [{ type: 'user/message', data: { source: { kind: 'user' } } }], complete: true }], + ['sub-run', { events: [], complete: true }], + ['bg', { events: [], complete: true }], + ['cur', { events: [], complete: true }], +]) +const ensureDirs = (): void => { + for (const id of SHELLS.concat(SPARED)) mkdirSync(dirOf(id), { recursive: true }) +} +const removed: string[] = [] +ensureDirs() + +/** The store seams: a fake index, fake logs, a real removal inside the temp root. */ +const fixtureSeams = (overrides: Partial = {}): Partial => ({ + readIndex: () => index, + readLog: id => logs.get(id), + deleteLog: id => { + removed.push(id) + rmSync(dirOf(id), { recursive: true, force: true }) + return 'deleted' + }, + ...overrides, +}) + +/** A sweep runner shape: the exit helper as the branch calls it, plus seam overrides. */ +type SweepRun = (input: ExitSweepInput, overrides?: Partial) => UnspokenSweepResult | undefined + +/** The exit helper as the clean-exit branch calls it, with the store faked. */ +const sweepWithFixture: SweepRun = (input, overrides = {}) => + sweepUnspokenOnExit({ + ...input, + sweep: deps => sweepUnspokenSessions({ ...deps, ...fixtureSeams(overrides) }), + }) + +/** The same fixture sweep, with the process facts deliberately rewritten (controls). */ +const sweepFixtureRewriting = (rewrite: (input: ExitSweepInput) => ExitSweepInput): SweepRun => + (input, overrides = {}) => sweepWithFixture(rewrite(input), overrides) + +/** The process view the clean-exit branch passes: bound session + live + lineage. */ +const exitInput = (overrides: Partial = {}): ExitSweepInput => ({ + currentSessionId: () => 'cur', + liveSessionIds: () => new Set(['bg']), + listedSessions: () => [ + { id: 'sub-run', delegated: true, parent: 'cur' }, + { id: 'cur' }, + ], + ...overrides, +}) + +/** Rebuild the fixture and run one fresh clean-exit round. */ +const freshCleanExit = (run: SweepRun): UnspokenSweepResult | undefined => { + ensureDirs() + removed.length = 0 + return run(exitInput()) +} + +/** One zstd frame, the shape the durable log stores rows in. */ +const frame = (rows: readonly unknown[]): Buffer => + zstdCompressSync(Buffer.from(rows.map(row => `${JSON.stringify(row)}\n`).join(''))) + +/** + * Write a REAL compressed session log where the shipping locators look for it + * (`sessionsRoots()` honours `DSH_TUI_SESSION_ROOT`), so `readSessionHeaderFromLog` + * and `digestSession` read the same fixture the sweep does. The name is the + * canonical committed generation (`session.v4.jsonl.zstd`), because + * `findSessionLogFile` only accepts a `session[.vN].jsonl[.zstd]` artifact. + * @param sessionId - Session directory name. + * @param header - Extra physical-header fields (`origin`, `delegationDepth`, …). + * @param events - Rows committed after the header. + * @returns The artifact path. + */ +const writeRealLog = (sessionId: string, header: Record = {}, events: readonly unknown[] = []): string => { + const dir = join(process.env.DSH_TUI_SESSION_ROOT as string, 'workspace-a', sessionId) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'session.v4.jsonl.zstd') + writeFileSync(path, frame([ + { type: 'session', version: 4, id: sessionId, cwd: '/fixture', ...header }, + ...events, + ])) + return path +} + +/** The reason one id was spared, from a collection or a sweep result. */ +const reasonOf = (result: UnspokenCollection | UnspokenSweepResult, id: string): string | undefined => + result.skipped.find(row => row.id === id)?.reason + +// ── the plugin source, and the guards that keep a window from being empty ── +// F-07: a bare `indexOf` + `slice` silently yields an empty string when the +// anchor moves, which makes the wiring assertion built on it vacuously green. +// Every window below goes through `window()`, which refuses `-1` and inverted +// spans instead of reading nothing. + +const pluginSource = readFileSync(new URL('../src/dsh-adapter/plugin.ts', import.meta.url), 'utf8') +const at = (needle: string, from = 0): number => pluginSource.indexOf(needle, from) + +/** A guarded source window (F-07). */ +const window = (from: number, to: number, what: string): string => { + assert.ok(from !== -1, `${what}: the opening anchor is gone from plugin.ts — re-read the branch before trusting this assertion`) + assert.ok(to > from, `${what}: the window is empty or inverted (from=${from}, to=${to}); a slice of that span proves nothing`) + return pluginSource.slice(from, to) +} + +/** + * Source offsets of every INVOCATION of a name family, excluding the module's + * own `function …` definition and any mention inside a comment — so a comment + * that names the helper (or a reworded docstring) cannot redden the result + * (F-10's measured false-red class). + * @param pattern - A regex whose match starts at the name. + * @returns Absolute offsets into `pluginSource`. + */ +const invocationsMatching = (pattern: RegExp): number[] => { + const sites: number[] = [] + for (const match of pluginSource.matchAll(pattern)) { + const site = match.index ?? -1 + if (site < 0) continue + if (/\bfunction\s+$/u.test(pluginSource.slice(Math.max(0, site - 12), site))) continue + const prefix = pluginSource.slice(pluginSource.lastIndexOf('\n', site) + 1, site).trimStart() + if (prefix.startsWith('//') || prefix.startsWith('*') || prefix.startsWith('/*')) continue + sites.push(site) + } + return sites +} + +/** Every `sweepUnspoken*` invocation (import and definition excluded). */ +const sweepInvocations = (): number[] => invocationsMatching(/\bsweepUnspoken[A-Za-z]*\s*\(/gu) +/** Every `composeExitNotice(` invocation (definition and comments excluded). */ +const noticeInvocations = (): number[] => invocationsMatching(/\bcomposeExitNotice\s*\(/gu) + +/** The fall-through block of the exit funnel: from its opening anchor through the end of its own `onUserExit`. */ +const fallThroughWindow = (): { readonly from: number, readonly to: number, readonly block: string } => { + const from = at('// Judge against the live session behind the channel') + const to = at('\n },', from) + return { from, to, block: window(from, to, 'the fall-through block') } +} + +// ── assertion bodies ──────────────────────────────────────────────────────── +// Defined once and used twice: by the suite (green reading) and by the negative +// controls (which drive a broken subject through the very same body). + +type Compose = (hint: string | undefined, cleaned: number) => string | undefined + +const assertNoticeKeepsHint = (compose: Compose): void => { + const notice = compose('Resume with the command below:\n/path', 2) + assert.ok(notice?.startsWith('Resume with the command below:\n/path\n') === true, + `the resume hint must be kept and the count line must follow it on its own line (got ${String(notice)})`) + assert.equal(notice?.split('\n').length, 3, 'exactly one added line') +} +const assertNoticeCountLine = (compose: Compose): void => { + const original = getLang() + setLang('zh') + const notice = compose(undefined, 2) + setLang(original) + assert.ok(notice?.endsWith(t('exit-cleaned-unspoken-sessions', { count: 2 })) === true, + `the count line must be the dictionary entry with the count substituted (got ${String(notice)})`) +} +const assertNoticeLocalized = (compose: Compose): void => { + const original = getLang() + setLang('zh') + const zhNotice = compose(undefined, 2) + setLang('en') + const enNotice = compose(undefined, 2) + const enSingular = compose(undefined, 1) + setLang(original) + assert.ok(enNotice !== undefined && enNotice !== zhNotice && enNotice.includes('2'), + `zh and en must both report the number, localized and not hard-coded (zh=${String(zhNotice)} en=${String(enNotice)})`) + assert.ok(enSingular !== undefined && enSingular !== enNotice && enSingular.includes('1'), + `English must pick its plural form from the count (got ${String(enSingular)})`) +} +const assertNoticeQuietWhenClean = (compose: Compose): void => { + assert.equal(compose('hint', 0), 'hint', 'nothing cleaned leaves the hint byte-for-byte') + assert.equal(compose(undefined, 0), undefined, 'and nothing at all stays nothing') +} + +const assertCleanExitDeletes = (run: SweepRun): void => { + const swept = freshCleanExit(run) + assert.equal(String(swept?.deleted.join(',')), 'shell-a,shell-b', + `the round must delete the two never-spoken shells (got ${String(swept?.deleted)})`) + assert.ok(!existsSync(dirOf('shell-a')) && !existsSync(dirOf('shell-b')), 'and their directories must be gone from disk') +} +const assertDelegatedRunSurvives = (run: SweepRun): void => { + freshCleanExit(run) + assert.ok(existsSync(dirOf('sub-run')), 'a delegated run must survive (AC-6 ③): the listing names it delegated') +} +const assertHumanFirstSurvives = (run: SweepRun): void => { + freshCleanExit(run) + assert.ok(existsSync(dirOf('human-first')), 'a human message with no turn/start must survive (AC-6 ①): the log layer reads it, not the index flag') +} +const assertSpokenSurvives = (run: SweepRun): void => { + freshCleanExit(run) + assert.ok(existsSync(dirOf('spoken')), 'a conversation with a turn/start must survive') +} +const assertLiveAndBoundSurvive = (run: SweepRun): void => { + freshCleanExit(run) + assert.ok(existsSync(dirOf('bg')) && existsSync(dirOf('cur')), 'the live background run and the bound session must survive (AC-6 ④)') +} +const assertSparedPartition = (run: SweepRun): void => { + const swept = freshCleanExit(run) + assert.ok(swept !== undefined, 'a readable listing must produce a round') + for (const [id, reason] of [['sub-run', 'subagent'], ['bg', 'live-session'], ['cur', 'current-session'], ['spoken', 'turn-start']] as const) { + assert.equal(reasonOf(swept, id), reason, `${id} must be reported as spared with reason ${reason} (got ${String(reasonOf(swept, id))})`) + } +} +const assertUnknownListingSpares = (run: SweepRun): void => { + ensureDirs() + removed.length = 0 + const swept = run(exitInput({ listedSessions: () => undefined })) + assert.equal(swept, undefined, 'an unknown listing must report no round at all') + assert.equal(removed.length, 0, 'and delete nothing') + assert.ok(existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b')), 'every shell is still on disk') + assert.equal(composeExitNotice('hint', swept?.deleted.length ?? 0), 'hint', 'and the notice stays the resume hint') +} + +type ListingRead = (channel: unknown) => readonly UnspokenSessionLineage[] | undefined +const LISTED_ROWS = [ + { id: 'root-1', kind: { kind: 'root' } }, + { id: 'fork-1', kind: { kind: 'fork', parent: 'root-1' } }, + { id: 'sub-1', kind: { kind: 'subagent', parent: 'root-1', depth: 1 } }, + { id: 'sub-2', kind: { kind: 'subagent', parent: undefined, depth: 1 } }, +] as const +const lineageOf = (read: ListingRead): readonly UnspokenSessionLineage[] | undefined => + read({ cachedSessions: () => LISTED_ROWS } as never) + +const assertListingDelegatedFlag = (read: ListingRead): void => { + const rows = lineageOf(read) + assert.equal(rows?.[2]?.delegated, true, `a listed sub-agent must be flagged delegated (got ${String(rows?.[2]?.delegated)})`) + assert.equal(rows?.[2]?.parent, 'root-1', 'and carry its parent') +} +const assertListingForkKeepsParent = (read: ListingRead): void => { + const rows = lineageOf(read) + assert.equal(rows?.[1]?.delegated, false, `a fork is not itself delegated (got ${String(rows?.[1]?.delegated)})`) + assert.equal(rows?.[1]?.parent, 'root-1', 'the parent link is what the descendant closure walks') +} +const assertListingRootNoParent = (read: ListingRead): void => { + const rows = lineageOf(read) + assert.equal(rows?.[0]?.parent, undefined, 'a root records no parent') + assert.equal(rows?.[0]?.delegated, false) +} +const assertListingParentlessDelegated = (read: ListingRead): void => { + assert.equal(lineageOf(read)?.[3]?.delegated, true, 'a parentless delegated run is still delegated') +} +// Guard against lineage drift: a kind this build does not know must count as +// delegated, because over-marking only spares — under-marking deletes. +const assertListingUnknownKindDelegated = (read: ListingRead): void => { + const rows = read({ cachedSessions: () => [{ id: 'mystery', kind: { kind: 'imported' } }] } as never) + assert.equal(rows?.[0]?.delegated, true, + `a kind this build does not know must count as delegated (got ${String(rows?.[0]?.delegated)}): over-marking only spares`) +} +const assertListingUnknownSources = (read: ListingRead): void => { + assert.equal(read({ cachedSessions: () => [] } as never)?.length, 0, 'an empty listing is empty, not unknown') + assert.equal(read({} as never), undefined, 'a host without the cache reports unknown') + assert.equal(read({ cachedSessions: () => { throw new Error('cache boom') } } as never), undefined, 'a throwing cache reports unknown') +} + +/** The pre-fix mapping (control G2): "delegated" meant exactly `kind === 'subagent'`. */ +const preFixListing: ListingRead = channel => { + const rows = (channel as { + cachedSessions?(): readonly { readonly id: string, readonly kind: { readonly kind: string, readonly parent?: string | undefined } }[] | undefined + }).cachedSessions?.() + return rows?.map(row => ({ + id: row.id, + delegated: row.kind.kind === 'subagent', + parent: row.kind.kind === 'root' ? undefined : row.kind.parent, + })) +} +/** The shipped mapping with the parent link dropped (control G2). */ +const parentlessListing: ListingRead = channel => + (readExitListing as ListingRead)(channel)?.map(row => ({ id: row.id, delegated: row.delegated })) + +/** The shipped entry point with its fail-soft wrapper removed (control G3). */ +const brittleSweep: SweepRun = input => { + const listed = input.listedSessions() + if (listed === undefined) return undefined + return (input.sweep ?? sweepUnspokenSessions)({ + currentSessionId: input.currentSessionId, + liveSessionIds: input.liveSessionIds, + isSubagentOrDescendant: () => false, + }) +} + +/** Every hostile dependency, in the branch's own call shape. */ +const hostileCases = (run: SweepRun): readonly (readonly [string, () => UnspokenSweepResult | undefined])[] => [ + ['the listing throws', () => run(exitInput({ listedSessions: () => { throw new Error('listing boom') } }))], + ['the index read throws', () => run(exitInput(), { readIndex: () => { throw new Error('index boom') } })], + ['a log read throws', () => run(exitInput(), { readLog: () => { throw new Error('log boom') } })], + ['the delete primitive throws', () => run(exitInput(), { deleteLog: () => { throw new Error('delete boom') } })], + // The shipping entry point, directly: `run` is the fixture runner and would + // replace the very `sweep` seam this case is about. + ['the round itself throws', () => sweepUnspokenOnExit({ ...exitInput(), sweep: () => { throw new Error('round boom') } })], + ['the process-layer fact throws', () => run(exitInput({ currentSessionId: () => { throw new Error('bound boom') } }))], +] +const assertFailSoft = (run: SweepRun): void => { + ensureDirs() + removed.length = 0 + const outcomes: string[] = [] + let threw = false + for (const [label, hostile] of hostileCases(run)) { + try { + const swept = hostile() + outcomes.push(`${label}:${swept === undefined ? 'skipped' : swept.deleted.length}`) + } catch (error) { + threw = true + outcomes.push(`${label}:THREW ${String(error)}`) + } + } + assert.ok(!threw, `no hostile dependency may escape as a throw (${outcomes.join(' | ')})`) + assert.equal(outcomes.slice(1, 4).join(','), 'the index read throws:0,a log read throws:0,the delete primitive throws:0', + `a broken index, log or delete deletes nothing and still reports the round (${outcomes.join(' | ')})`) + assert.ok(outcomes[0] === 'the listing throws:skipped' && outcomes[4] === 'the round itself throws:skipped', + `a throwing listing or round is reported as no round at all (${outcomes.join(' | ')})`) + assert.ok(existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b')), 'a shell whose dependencies failed is still on disk') +} + +// ── §11's fixtures: who counts as a person, across the two shipped readers ── +// B-1: the same question has three implementations. Two of them — this module's +// `conversationEvidence` and `digest.ts`'s `humanPrompt` — agree that a message +// with NO source is human; the third (`channel/session-lineage.ts`) says it is +// not. Fixing the third is outside this task's write set, so what is pinned +// here is that the two sides that DECIDE deletion stay in agreement, including +// on that divergence point (control G5 drives the third rule through this very +// assertion and requires it to go red). +const SAMPLE_EVENTS: readonly (readonly [string, unknown])[] = [ + ['a user/message from the person', { type: 'user/message', data: { source: { kind: 'user' } } }], + ['a user/message from a sub-agent', { type: 'user/message', data: { source: { kind: 'subagent' } } }], + ['a user/message with NO source at all', { type: 'user/message', data: {} }], + ['a user/message with a null source', { type: 'user/message', data: { source: null } }], + ['a user/message whose source is not an object', { type: 'user/message', data: { source: 'user' } }], + ['an inbox splice carrying a human message', { type: 'agent/inbox/spliced', data: { inserted: [{ role: 'user', source: { kind: 'user' }, content: [] }] } }], + ['an inbox splice carrying only a model message', { type: 'agent/inbox/spliced', data: { inserted: [{ role: 'assistant', source: { kind: 'model' }, content: [] }] } }], + ['an assistant message', { type: 'assistant/message', data: { source: { kind: 'model' } } }], + ['a bare turn/start', { type: 'turn/start', data: { turn: 0 } }], +] + +/** Does the SWEEP's log layer call this event human evidence? (drives `conversationEvidence`.) */ +const sweepSaysHuman = (one: unknown): boolean => { + const collected = collectUnspokenSessionIds({ + readIndex: () => new Map([['sample', { derived: { hasPrompt: false } }]]), + readLog: () => ({ events: [one], complete: true }), + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, + }) + return collected.skipped.some(row => row.id === 'sample' && row.reason === 'human-message') +} + +/** Does the DIGEST call the same log a prompt? One own artifact per sample. */ +const digestSaysHuman = (one: unknown, index: number): boolean => + digestSession(writeRealLog(`parity-${index}`, {}, [one]), '/fixture').hasPrompt + +/** `channel/session-lineage.ts:48-53` verbatim: `source?.kind === 'user'` — no source is NOT human. */ +const lineageSaysHuman = (one: unknown): boolean => { + const event = one as { + readonly type?: string + readonly data?: { + readonly source?: { readonly kind?: string } + readonly inserted?: readonly { readonly role?: string, readonly source?: { readonly kind?: string } }[] + } + } + if (event.type === 'user/message') return event.data?.source?.kind === 'user' + if (event.type === 'agent/inbox/spliced') { + return (event.data?.inserted ?? []).some(entry => entry.role === 'user' && entry.source?.kind === 'user') + } + return false +} + +const assertHumanPromptParity = (): void => { + const disagreements = SAMPLE_EVENTS + .map(([label, one], index) => ({ label, sweep: sweepSaysHuman(one), digest: digestSaysHuman(one, index) })) + .filter(row => row.sweep !== row.digest) + assert.deepEqual(disagreements, [], + 'the sweep and the digest must call the same events human: they decide the same irreversible delete (B-1)') +} + +// ── 1. the clean exit sweeps, and the count reaches the notice ────────────── + +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('clean exit: exactly the sessions no human spoke in are deleted, and gone from disk', () => assertCleanExitDeletes(sweepWithFixture)) + if (CRASH_ON_PURPOSE) { + record('F-17 control: a deliberately red row before the crash', false, + 'this row must be printed even though the process throws on the next line') + throw new Error('DSH_TUI_EXIT_SWEEP_CRASH=1: on purpose — the reporter must still name the red row (F-17)') + } + checkBody('clean exit: a human message with no turn/start survives (AC-6 ①)', () => assertHumanFirstSurvives(sweepWithFixture)) + checkBody('clean exit: a conversation with a turn/start survives', () => assertSpokenSurvives(sweepWithFixture)) + checkBody('clean exit: a delegated run survives (AC-6 ③)', () => assertDelegatedRunSurvives(sweepWithFixture)) + checkBody('clean exit: the live background run and the bound session survive (AC-6 ④)', () => assertLiveAndBoundSurvive(sweepWithFixture)) + checkBody('clean exit: the spared set is reported as a full partition, not a log', () => assertSparedPartition(sweepWithFixture)) + checkBody('notice: the resume hint is kept, and the count line follows it on its own line', () => assertNoticeKeepsHint(composeExitNotice)) + checkBody('notice: the count line is the dictionary entry, with the count substituted', () => assertNoticeCountLine(composeExitNotice)) + checkBody('notice: zh and en both report the number (localized, not hard-coded)', () => assertNoticeLocalized(composeExitNotice)) + checkBody('notice: nothing cleaned leaves the notice byte-for-byte what it was', () => assertNoticeQuietWhenClean(composeExitNotice)) +} + +// ── 2. layer ③ is wired with this process's own view ─────────────────────── +if (!NEGATIVE_CONTROLS_ONLY) { + let captured: UnspokenSweepDeps | undefined + let rounds = 0 + const swept = sweepUnspokenOnExit({ + currentSessionId: () => 'cur', + liveSessionIds: () => new Set(['bg']), + listedSessions: () => [ + { id: 'sub-run', delegated: true, parent: 'root-1' }, + { id: 'fork-of-sub', parent: 'sub-run' }, + { id: 'root-1' }, + ], + sweep: deps => { + rounds += 1 + captured = deps + return { deleted: ['x'], skipped: [] } + }, + }) + checkBody('layer ③: the sweep receives the bound session as currentSessionId', () => { + assert.equal(captured?.currentSessionId(), 'cur') + }) + checkBody('layer ③: the sweep receives the live-session set', () => { + assert.equal(captured?.liveSessionIds().has('bg'), true) + }) + checkBody('layer ③: a delegated run is spared', () => { + assert.equal(captured?.isSubagentOrDescendant('sub-run'), true) + }) + checkBody('layer ③: a fork of a delegated run counts as a descendant', () => { + assert.equal(captured?.isSubagentOrDescendant('fork-of-sub'), true) + }) + checkBody('layer ③: an ordinary conversation is not delegated', () => { + assert.equal(captured?.isSubagentOrDescendant('root-1'), false) + }) + checkBody('layer ③: the round runs exactly once and its result is reported', () => { + assert.equal(rounds, 1, 'one round per exit') + assert.equal(String(swept?.deleted.join(',')), 'x', 'and the round\'s own result is what the caller sees') + }) +} + +// ── 3. the listing seam: lineage mapping, and "unknown" is not "empty" ───── +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('listing: a delegated run is flagged and carries its parent', () => assertListingDelegatedFlag(readExitListing as ListingRead)) + checkBody('listing: a fork keeps its parent and is not itself delegated', () => assertListingForkKeepsParent(readExitListing as ListingRead)) + checkBody('listing: a root records no parent', () => assertListingRootNoParent(readExitListing as ListingRead)) + checkBody('listing: a parentless delegated run is still delegated', () => assertListingParentlessDelegated(readExitListing as ListingRead)) + checkBody('listing: a kind this build does not know counts as delegated (over-marking only spares)', () => assertListingUnknownKindDelegated(readExitListing as ListingRead)) + checkBody('listing: an empty listing is empty, not unknown; no source and a throw are both unknown', () => assertListingUnknownSources(readExitListing as ListingRead)) +} + +// ── 4. no listing yet ⇒ no round: the index is spared, never guessed ─────── +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('unknown lineage: the round reports nothing and deletes nothing', () => assertUnknownListingSpares(sweepWithFixture)) +} + +// ── 5. fail-soft: a hostile dependency never blocks the shutdown ─────────── +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('fail-soft: no hostile dependency escapes as a throw, and every case is still reported', () => assertFailSoft(sweepWithFixture)) +} + +// ── 6. the terminal restore sequence is unchanged, notice and all ────────── +class CapturingStream extends Writable { + isTTY = true + columns = 80 + rows = 24 + chunks: string[] = [] + _write(chunk: unknown, _enc: BufferEncoding, cb: () => void): void { + this.chunks.push(String(chunk)) + cb() + } +} + +if (!NEGATIVE_CONTROLS_ONLY) { + const captured = new CapturingStream() as unknown as NodeJS.WriteStream + const originalStdout = process.stdout + const swapStdout = (stream: NodeJS.WriteStream): void => { + Object.defineProperty(process, 'stdout', { + value: stream, + configurable: true, + writable: true, + enumerable: true, + }) + } + swapStdout(captured) + instances.delete(captured) + let done = false + await finishExit( + { logger: { debug() {} } } as never, + { unmount() {} } as never, + false, + composeExitNotice('hint', 2), + undefined, + () => { done = true }, + ) + swapStdout(originalStdout) + const written = captured.chunks.join('') + const order = [DISABLE_MOUSE_TRACKING, DISABLE_MODIFY_OTHER_KEYS, DISABLE_KITTY_KEYBOARD, DISABLE_WIN32_INPUT_MODE, DFE, DBP, SHOW_CURSOR, CLEAR_ITERM2_PROGRESS] + let markerAt = -1 + let ordered = true + for (const marker of order) { + const found = written.indexOf(marker) + if (found <= markerAt) { ordered = false; break } + markerAt = found + } + checkBody('shutdown: the terminal restore sequence is written in the shipped order', () => { + assert.ok(ordered, `every restore marker must appear after the previous one (${JSON.stringify(written)})`) + }) + checkBody('shutdown: the sweep line is the last thing written, after the restore sequence', () => { + assert.ok(written.endsWith(`${composeExitNotice('hint', 2) ?? ''}\n`), `the notice is written last (${JSON.stringify(written.slice(-80))})`) + }) + checkBody('shutdown: the notice still precedes the dispose hand-off', () => { + assert.ok(done, 'the hand-off ran') + assert.ok(written.indexOf('hint') > written.indexOf(SHOW_CURSOR), 'after the cursor is restored') + }) +} + +// ── 7. the wiring: only the clean-exit branch sweeps (F-10 / F-12) ───────── +// Three assertions carry the meaning. Everything else that used to be a source +// text assertion lives in this file's tripwire list (see the module docstring). +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('wiring: the sweep is invoked once, only inside the normal-exit fall-through, and its count reaches that block\'s notice', () => { + const { from, to, block } = fallThroughWindow() + const sweeps = sweepInvocations() + assert.equal(sweeps.length, 1, + `exactly one sweep invocation in the module (found ${sweeps.length}: ${sweeps.map(site => `@${site} ${pluginSource.slice(site, site + 30)}`).join(' | ')})`) + const sweepAt = sweeps[0] as number + assert.ok(sweepAt > from && sweepAt < to, + `the only sweep invocation is at ${sweepAt}, OUTSIDE the fall-through block [${from}, ${to}): a crash / handoff / restart branch must never clean up (F-12)`) + const notices = noticeInvocations() + assert.equal(notices.length, 1, + `only the fall-through composes a notice (found ${notices.length}: ${notices.map(site => `@${site} ${pluginSource.slice(site, site + 30)}`).join(' | ')})`) + const noticeAt = notices[0] as number + assert.ok(noticeAt > sweepAt && noticeAt < to, + `the notice must be composed inside the block and after the round (sweep@${sweepAt} notice@${noticeAt} block@[${from}, ${to})): DESIGN D6`) + assert.match(block, /composeExitNotice\(\s*hint\s*,\s*swept\?\.deleted\.length\s*\?\?\s*0\s*\)/u, + 'the notice argument is that round\'s own count (this pattern tolerates line breaks and spacing: F-10)') + }) + checkBody('wiring: the branch feeds the sweep the bound session, the live set and the listing cache', () => { + const { block } = fallThroughWindow() + const facts: readonly (readonly [string, RegExp])[] = [ + ['currentSessionId', /currentSessionId:\s*\(\)\s*=>\s*channel\.agentId/u], + ['liveSessionIds', /liveSessionIds:\s*\(\)\s*=>\s*liveExitSessionIds\(\s*ctx\s*,\s*channel\.agentId\s*\)/u], + ['listedSessions', /listedSessions:\s*\(\)\s*=>\s*readExitListing\(\s*channel\s*\)/u], + ] + for (const [fact, pattern] of facts) { + assert.match(block, pattern, + `the ${fact} fact must be wired to the live process (an empty set / an undefined bound id widens the delete surface)`) + } + }) + checkBody('wiring: finishExit stays sweep-free, so its five other callers cannot inherit the cleanup', () => { + const body = window(at('export async function finishExit('), at('function readInkShutdownState('), 'the finishExit body') + assert.ok(!body.includes('sweepUnspoken'), 'finishExit is called from five other branches; the sweep must not ride along') + }) +} + +// ── 8. "no round" is classified, not silent (T-FIX-02's exitListingGap) ──── +if (!NEGATIVE_CONTROLS_ONLY) { + const GAP_SHAPES: readonly (readonly [string, unknown, string])[] = [ + ['a host line whose channel has no listing cache', {}, 'no source'], + ['a cache that has never listed', { cachedSessions: () => undefined }, 'no listing'], + ['a cache that throws while being read', { cachedSessions: () => { throw new Error('cache boom') } }, 'read failed'], + ['a listing that is readable after all', { cachedSessions: () => [] }, 'listed'], + ] + + checkBody('exit gap: the four worlds read differently, and a readable listing is not a gap', () => { + const seen = new Set() + for (const [label, channel, expected] of GAP_SHAPES) { + const gap = exitListingGap(channel as never) + assert.equal(gap, expected, label) + seen.add(gap) + } + assert.equal(seen.size, 4, 'a constant classifier would collapse two of these') + }) + checkBody('exit gap: "no round" and its classification agree on the same channel', () => { + ensureDirs() + removed.length = 0 + const swept = sweepUnspokenOnExit(exitInput({ listedSessions: () => undefined })) + assert.equal(swept, undefined, 'the round cannot run without a listing') + assert.equal(exitListingGap({ cachedSessions: () => undefined } as never), 'no listing', 'and the reason names the world it was in') + assert.equal(exitListingGap({} as never), 'no source', 'a host without the seam is a different world from a host with an empty one') + }) + // The debug line itself is inside the branch closure, so it can only be + // anchored structurally: the `else` of `swept !== undefined` must interpolate + // the classifier, i.e. "no round" is never reported as a generic nothing. + checkBody('exit gap: the branch reports the classification when the round did not run', () => { + const { block } = fallThroughWindow() + assert.match(block, /if\s*\(\s*swept\s*!==\s*undefined\s*\)/u, 'the branch keeps the round result as the discriminator') + assert.match(block, /ctx\.logger\.debug\([^)]*swept\.deleted\.length/u, 'a round that ran reports its own count') + assert.match(block, /exitListingGap\(\s*channel\s*\)/u, 'a round that did not run names the reason through exitListingGap (F-13)') + }) +} + +// ── 9. a candidate's own header decides delegation (T-FIX-02's seam) ─────── +if (!NEGATIVE_CONTROLS_ONLY) { + writeRealLog('header-sub', { origin: 'subagent', delegationDepth: 1 }) + writeRealLog('header-deep', { delegationDepth: 2 }) + writeRealLog('header-plain', { origin: 'root', delegationDepth: 0 }) + const headerIndex = new Map(['header-sub', 'header-deep', 'header-plain'].map(id => [id, { derived: { hasPrompt: false } }])) + const headerDeps = (): UnspokenSweepDeps => ({ + readIndex: () => headerIndex, + // The event layer proves nothing here on purpose: only the header can spare, + // and `readSessionHeader` is deliberately NOT injected — the shipping reader + // is what the exit path uses. + readLog: () => ({ events: [], complete: true }), + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, + }) + + checkBody('header reader: the first physical frame is what it reads, and only claims what is there', () => { + assert.equal(readSessionHeaderFromLog('header-sub')?.origin, 'subagent') + assert.equal(readSessionHeaderFromLog('header-deep')?.delegationDepth, 2) + assert.equal(readSessionHeaderFromLog('header-plain')?.origin, 'root') + assert.equal(readSessionHeaderFromLog('no-such-log-at-all'), undefined, 'no log is "unknown", never "root"') + }) + checkBody('header verdict: origin:subagent spares a candidate no listing ever saw', () => { + const result = collectUnspokenSessionIds(headerDeps()) + assert.equal(reasonOf(result, 'header-sub'), 'subagent', + `a delegated run created after boot must be spared (got ${String(reasonOf(result, 'header-sub'))})`) + assert.ok(!result.ids.includes('header-sub')) + }) + checkBody('header verdict: a nonzero delegationDepth is delegation too (upstream keeps it optional)', () => { + const result = collectUnspokenSessionIds(headerDeps()) + assert.equal(reasonOf(result, 'header-deep'), 'subagent') + assert.ok(!result.ids.includes('header-deep')) + }) + checkBody('header verdict: a header with no delegation mark is NOT spared by this rule', () => { + const result = collectUnspokenSessionIds(headerDeps()) + assert.ok(result.ids.includes('header-plain'), 'the header only ever ADDS protection; a root header leaves the candidate alone') + }) +} + +// ── 10. layer ③'s live set is driven for real (F-06) ─────────────────────── +if (!NEGATIVE_CONTROLS_ONLY) { + /** A ctx whose only service is `agents` — the duck-typed roster the live set reads. */ + const rosterCtx = (agents: unknown): never => ({ get: (name: string) => name === 'agents' ? agents : undefined }) as never + const idsOf = (ids: ReadonlySet): string[] => [...ids].sort() + + checkBody('live set: the bound session and every ordinary agent are kept, sub-agent runs are not', () => { + const ids = liveExitSessionIds(rosterCtx({ + list: () => [ + { id: 'bg-1', session: { header: {} } }, + { id: 'bg-2', session: { header: { origin: 'fork' } } }, + { id: 'sub-1', session: { header: { origin: 'subagent' } } }, + { id: '', session: { header: {} } }, + ], + }), 'cur') + assert.deepEqual(idsOf(ids), ['bg-1', 'bg-2', 'cur'], + 'a sub-agent run is layer ③\'s OTHER half (the header/lineage rule), never a live mount; an empty id claims nothing') + }) + checkBody('live set: a composition with no agents service degrades to exactly the bound session (B-6)', () => { + assert.deepEqual(idsOf(liveExitSessionIds(rosterCtx(undefined), 'cur')), ['cur'], + 'the documented degradation: fewer ids than possible, never a guessed one') + assert.deepEqual(idsOf(liveExitSessionIds(rosterCtx(undefined), undefined)), [], + 'and to nothing when no session is bound either') + }) + checkBody('live set: a registry that lists nothing adds nothing', () => { + assert.deepEqual(idsOf(liveExitSessionIds(rosterCtx({ list: () => [] }), 'cur')), ['cur']) + }) +} + +// ── 11. who counts as a person: the two shipped readers must agree (F-11) ── +if (!NEGATIVE_CONTROLS_ONLY) { + checkBody('human prompt: the sweep and the digest call every sample the same way (F-11 / B-1)', assertHumanPromptParity) +} + +// ── 12. the ledger is a throwaway one, and it is really consumed (B-12) ──── +if (!NEGATIVE_CONTROLS_ONLY) { + const ledgerFile = join(DATA_DIR, 'session-mounts.json') + + checkBody('isolation: the module-level DATA_DIR is the throwaway home, not the operator\'s', () => { + assert.ok(DATA_DIR.startsWith(root), + `DATA_DIR must be captured AFTER the HOME override (got ${DATA_DIR}; the operator's is ${join(operatorHome, '.dsh-tui')}): ` + + 'static imports here would make every fixture read the real ledger (B-12)') + assert.notEqual(DATA_DIR, join(operatorHome, '.dsh-tui'), 'the operator\'s ledger directory must never be the one under test') + }) + checkBody('isolation: the fixture starts with no ledger at all, and an absent one is not "no round"', () => { + assert.equal(existsSync(ledgerFile), false, 'nothing has published a mount yet, so there is no ledger to inherit') + assert.equal(String(freshCleanExit(sweepWithFixture)?.deleted.join(',')), 'shell-a,shell-b', + 'an absent ledger means "no foreign holder", so the round still runs') + }) + checkBody('isolation: a foreign holder written to the throwaway ledger is what the round obeys', () => { + ensureDirs() + removed.length = 0 + // A live pid that is not ours: the parent process of this run. + const foreignPid = process.ppid + mkdirSync(DATA_DIR, { recursive: true }) + writeFileSync(ledgerFile, JSON.stringify({ + version: 1, + owners: [{ pid: foreignPid, startedAt: 1, sessionIds: ['shell-a'] }], + })) + const stat = statSync(ledgerFile) + // The fixture sweep, so the store seams stay faked — and the process facts + // (`occupiedElsewhere`) are the ones `sweepUnspokenOnExit` itself builds, so + // this is the shipping ledger read. + const swept = sweepWithFixture(exitInput()) + assert.equal(reasonOf(swept ?? { deleted: [], skipped: [] }, 'shell-a'), 'held-elsewhere', + 'the round must read THIS ledger: a foreign live holder spares its session and reports why') + assert.ok(existsSync(dirOf('shell-a')), 'and the held shell is still on disk') + assert.equal(String(swept?.deleted.join(',')), 'shell-b', 'while the unheld shell is swept as usual') + assert.equal(statSync(ledgerFile).mtimeMs, stat.mtimeMs, 'the read is read-only: the ledger itself is untouched') + rmSync(ledgerFile, { force: true }) + }) +} + +// ── negative controls (L-044): the assertion bodies above, driven red ────── + +interface ControlGroup { + readonly label: string + readonly controls: readonly { readonly name: string, readonly run: () => void, readonly why: string, readonly marker: string }[] +} + +const CONTROL_GROUPS: readonly ControlGroup[] = [ + { + label: 'G1 composeExitNotice fed a stub (T05 ③)', + controls: [ + { + name: 'a hint-only composer must break the count-line assertion', + run: () => assertNoticeCountLine(hint => hint), + why: 'the pre-fix composer never appended a count line', + marker: 'dictionary entry', + }, + { + name: 'a hard-coded English line must break the localized-notice assertion', + run: () => assertNoticeLocalized((hint, cleaned) => cleaned <= 0 ? hint : `${hint ?? ''}\nCleaned ${cleaned} session(s)`), + why: 'a literal cannot follow the dictionary, and the zh/en notices would be identical', + marker: 'localized and not hard-coded', + }, + ], + }, + { + label: 'G2 readExitListing fed a wrong kind mapping (T05 ⑤)', + controls: [ + { + name: 'the pre-fix mapping must break the unknown-kind assertion', + run: () => assertListingUnknownKindDelegated(preFixListing), + why: 'the pre-fix rule marked only an exact `kind === "subagent"` as delegated', + marker: 'does not know must count as delegated', + }, + { + name: 'a mapping that drops the parent link must break the fork assertion', + run: () => assertListingForkKeepsParent(parentlessListing), + why: 'the descendant closure walks `parent`, so losing it silently narrows the spared set', + marker: 'parent link is what the descendant closure walks', + }, + ], + }, + { + label: 'G3 sweepUnspokenOnExit fed a blind / brittle listing (T05 ①)', + controls: [ + { + name: 'an empty listing must let a delegated run be deleted', + run: () => assertDelegatedRunSurvives(sweepFixtureRewriting(input => ({ ...input, listedSessions: () => [] }))), + why: 'with no lineage the delegated run is just another shell — the shape this suite exists for', + marker: 'delegated run must survive', + }, + { + name: 'a throwing listing must cost the round, and the partition assertion must notice', + run: () => assertCleanExitDeletes(sweepFixtureRewriting(input => ({ + ...input, + listedSessions: () => { throw new Error('listing boom') }, + }))), + why: 'an unreadable listing means no round at all, so the shipped clean-exit partition cannot hold', + marker: 'must delete the two never-spoken shells', + }, + { + name: 'a runner without the fail-soft wrapper must let a hostile dependency escape', + run: () => assertFailSoft(brittleSweep), + why: 'the shipping entry point wraps the whole round; removing that wrapper is what the fail-soft assertion catches', + marker: 'no hostile dependency may escape', + }, + ], + }, + { + label: 'G4 the guarded source window (F-07)', + controls: [ + { + name: 'an inverted window must be refused instead of read as empty', + run: () => { window(10, 5, 'a deliberately inverted window') }, + why: 'a bare `indexOf` + `slice` reads an empty string and makes the wiring assertion vacuously green', + marker: 'empty or inverted', + }, + ], + }, + { + label: 'G5 the third human-prompt rule (B-1) through the parity assertion (F-11)', + controls: [ + { + name: 'the session-lineage rule must disagree on the source-less sample', + run: () => { + for (const [label, one] of SAMPLE_EVENTS) { + assert.equal(lineageSaysHuman(one), digestSaysHuman(one, 0), label) + } + }, + why: 'a missing `source` is human to the sweep and the digest and NOT human to session-lineage (the B-1 divergence)', + marker: 'NO source', + }, + ], + }, +] + +/** Run every control and require it to go red on the assertion named by `marker`. */ +const runNegativeControls = (): void => { + let total = 0 + let red = 0 + for (const group of CONTROL_GROUPS) { + for (const control of group.controls) { + total += 1 + const before = failures + expectRed(`${group.label}: ${control.name}`, control.run, control.why, control.marker) + if (failures === before) red += 1 + } + } + controlsReading = `negative-controls: ${red}/${total} controls went red across ${CONTROL_GROUPS.length} groups` + if (red !== total) process.exitCode = 1 +} + +runNegativeControls() +report() diff --git a/scripts/verify-session-list-metadata.ts b/scripts/verify-session-list-metadata.ts new file mode 100644 index 000000000..c86f428de --- /dev/null +++ b/scripts/verify-session-list-metadata.ts @@ -0,0 +1,1193 @@ +#!/usr/bin/env node +/** + * Regression: the TUI's **mirror** of the web host's `sessionListMetadata` + * session projection — the visibility face of issue #1342. + * + * `dsh web` decides whether a session is a blank shell with + * `metadata?.blank ?? false` over a projection key that, until this change, + * only the web host registered. A TUI-created session therefore carried no such + * row at all, and every untouched session showed up in the sidebar as an + * untitled row. The TUI now registers the same key with the same version and + * the same fold, so the row is written at the forced checkpoint. + * + * What this script pins, and why each case exists: + * + * 1. **The definition handed to the registry.** Key, `stateVersion`, field + * names/types, `init` and the **identity `wire`** are a *copy* of the host's + * (`list.js:9-12`, `:59-66`), so every one of them is asserted against a + * literal here — a host-side change must break this script rather than + * silently re-open the issue. `wire` is not decoration: every wire read + * (`snapshot` / `cachedSnapshot` / `viewCheckpoint` / `restore`) skips a + * definition without it (`lib/index.js:147`, `:170`, `:249`), so a mirror + * registered first used to blank the key for the web sidebar's own read + * face — issue #1342 back through another door. The view is the identity + * (`view: state => state`, `list.js:64`) parsed with the SAME schema the + * state already passed, so it can never be the stricter of the two; the + * legacy definition-level spellings (`schema` / `viewSchema` / `view`) stay + * absent. + * 2. **The fold matrix.** Including the two properties a value-only test cannot + * see: an event that changes nothing returns the SAME reference (the host + * relies on it to skip re-publication), and `blank` never goes back to true. + * 3. **Cross-runtime reads.** The host's own schema is module-private, so each + * side's value is parsed with the other side's **equivalent shape**, and the + * `restore()` path (whose `stateSchema.parse` is NOT inside a `try`, + * `lib/index.js:297`) is driven through the real registry. + * 4. **Real registry integration.** Two registrations of one key are legal iff + * the `stateVersion` matches; the first definition wins and the row is still + * written. Both orders are exercised because "who registered first" decides + * which `apply` runs — and the **wire read** (`snapshot()`) is asserted in + * both orders as well, because that is the read face a mirror without `wire` + * silently blanks and a `checkpoint()` row cannot see. + * 5. **Degradation.** No service / a service without `register` / a conflicting + * version must all leave the process running: a startup risk here would be + * worse than the bug. + * 6. **Drift probe.** When the installed host package is locatable, the host's + * REAL `applySessionListMetadata` is folded side by side with ours, its real + * definition validates a value we wrote, and the anchor's version is + * asserted; when it is not locatable the probe prints a loud SKIP with the + * search path and the reason — never a silent pass. + * 7. **Negative controls.** Every assertion family above is re-run against a + * deliberately broken subject (no registration / one inverted fold term / a + * validator without `.parse`) and must go red, so "green" here means the + * assertions have discriminating power (LESSONS L-044). The control harness + * is itself checked, so no control can be vacuous. + * 8. **The boot-session wiring ORDER.** The second half of the fix is a + * position inside `apply()`, not a feature: the host writes one checkpoint + * row per registered key when a session is created, so a mirror attached + * after `resolveAgent` misses the boot session's own creation record and the + * sidebar shows it as an untitled shell until its next checkpoint + * (KNOWN-ISSUES A-8). `plugin.ts` is read as text and the mirror's single + * call site is required to precede both halves of the boot-agent statement; + * the pre-fix order is rebuilt **in memory** as the discriminating control — + * nothing is written to disk. + * + * Run: node --import tsx/esm scripts/verify-session-list-metadata.ts [--list] + * Env: `DSH_TUI_HOST_ANCHOR` overrides the drift-probe anchor package — used to + * exercise the SKIP path, which must be loud rather than silent (AC-11). + * @module dsh-tui/scripts/verify-session-list-metadata + */ + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { + SESSION_LIST_METADATA_KEY, + SESSION_LIST_METADATA_STATE_VERSION, + applySessionListMetadata, + attachSessionListMetadata, + initSessionListMetadata, + type SessionListMetadataState, +} from '../src/dsh-adapter/session-list-metadata.js' + +const SECTIONS = [ + '1. mirror definition handed to the registry', + '2. fold matrix', + '3. cross-runtime reads + restore path', + '4. real registry integration (two registrations)', + '5. degradation (missing seam / conflicting version)', + '6. drift probe against the installed host package', + '7. negative controls (assertions must be able to go red)', + '8. boot-session wiring order (registration precedes the agent)', +] as const + +if (process.argv.includes('--list')) { + for (const section of SECTIONS) console.log(section) + process.exit(0) +} + +// ── harness ──────────────────────────────────────────────────────────────── + +let checks = 0 +const failed: string[] = [] + +function report(label: string, error: unknown): void { + failed.push(label) + console.error(` ✗ ${label}`) + for (const line of String((error as Error).message ?? error).split('\n')) console.error(` ${line}`) +} + +/** Run one assertion group; a throw is a failed check, not a crashed script. */ +function check(label: string, run: () => void): void { + checks += 1 + try { + run() + } catch (error) { + report(label, error) + } +} + +/** The async sibling of {@link check} — awaited, so its failures really count. */ +async function checkAsync(label: string, run: () => Promise): Promise { + checks += 1 + try { + await run() + } catch (error) { + report(label, error) + } +} + +/** Require one assertion group to FAIL — the discriminating-power control. */ +function wentRed(run: () => void): boolean { + try { + run() + return false + } catch { + return true + } +} + +function expectRed(label: string, run: () => void, why: string): void { + checks += 1 + if (wentRed(run)) return + failed.push(label) + console.error(` ✗ negative control did not go red: ${label}`) + console.error(` expected a failure because ${why}`) +} + +function section(title: string): void { + console.log(`\n${title}`) +} + +const flush = (): Promise => new Promise(resolve => { setTimeout(resolve, 0) }) + +// ── fixtures ─────────────────────────────────────────────────────────────── + +/** + * One committed event, in the shape the durable log stores it. `surfaceOp` is a + * top-level marker the session codec requires on every surface-eligible event + * ("requires a surfaceOp marker"), so the fixtures carry it where the log does. + */ +function event( + type: string, + time: number, + data: Record, + surfaceOp?: 'append' | 'replace', +): SessionEvent { + return { type, time, seq: 0, data, ...(surfaceOp === undefined ? {} : { surfaceOp }) } as unknown as SessionEvent +} + +/** The same event at an explicit sequence. */ +function at(value: SessionEvent, seq: number): SessionEvent { + return { ...value, seq: seq as SessionEvent['seq'] } +} + +const TURN_START = event('turn/start', 100, { turn: 0 }) +const TURN_START_LATER = event('turn/start', 101, { turn: 1 }) +const TURN_END = event('turn/end', 102, { turn: 0, reason: 'completed' }) +const HUMAN_MESSAGE = event('user/message', 111, { + id: 'm1', + role: 'user', + source: { kind: 'user' }, + content: [{ type: 'text', text: 'hi' }], +}, 'append') +const HUMAN_MESSAGE_REPEAT = event('user/message', 111, { + id: 'm2', + role: 'user', + source: { kind: 'user' }, + content: [{ type: 'text', text: 'again' }], +}, 'append') +const SUBAGENT_MESSAGE = event('user/message', 112, { + id: 'm3', + role: 'user', + source: { kind: 'subagent' }, + content: [{ type: 'text', text: 'from a child' }], +}, 'append') +const SOURCE_LESS_MESSAGE = event('user/message', 113, { + id: 'm4', + role: 'user', + content: [{ type: 'text', text: 'no source' }], +}, 'append') +const ASSISTANT_MESSAGE = event('assistant/message', 114, { + id: 'm5', + role: 'assistant', + source: { kind: 'model', provider: 'deepseek', model: 'deepseek-chat' }, + content: [{ type: 'text', text: 'hello' }], +}, 'append') +const TOOL_RESULT = event('tool/result', 115, { id: 't1', name: 'bash', ok: true, content: [] }, 'append') + +/** The log a blank shell grows into: one turn, one human message. */ +const SPOKEN_LOG: readonly SessionEvent[] = [at(TURN_START, 0), at(HUMAN_MESSAGE, 1)] +/** What the fold must produce for {@link SPOKEN_LOG}. */ +const SPOKEN_STATE: SessionListMetadataState = { blank: false, lastPromptAt: 111 } + +type Fold = (state: SessionListMetadataState, one: SessionEvent) => SessionListMetadataState + +/** A composition root whose `inject` fires synchronously with fake services. */ +function compositionRoot( + services: Record, + debug: string[] = [], +): { inject: (deps: unknown, callback: (ctx: unknown) => unknown) => void; logger: { debug: (message: string) => void } } { + const logger = { debug: (message: string): void => { debug.push(message) } } + return { + inject: (_deps, callback) => { callback({ ...services, logger }) }, + logger, + } +} + +/** A registry that only records what it was handed. */ +function recordingRegistry(): { definitions: Record[]; services: Record } { + const definitions: Record[] = [] + return { + definitions, + services: { + sessionProjections: { + onChanged: () => () => undefined, + snapshot: () => ({ values: {} }), + register: (definition: Record) => { definitions.push(definition); return () => undefined }, + }, + }, + } +} + +interface ProjectionRow { readonly ver: number; readonly seq: number; readonly val: unknown } +interface HostRegistryLike { + register(definition: unknown): () => void + checkpoint(session: unknown): Record + /** The wire read face: skips every definition without `wire` (`lib/index.js:147`). */ + snapshot(session: unknown, keys?: readonly string[]): { readonly asOfSeq: number, readonly values: Record } + restore( + checkpoint: Record, + events: readonly SessionEvent[], + baseSeq: number, + header: unknown, + inheritedEventCount: number, + ): { readonly checkpoint: Record } +} + +/** Mount the REAL projection registry — the host service this mirror joins. */ +async function freshRoot(): Promise<{ ctx: unknown; registry: HostRegistryLike }> { + const [{ Context }, registryModule] = await Promise.all([ + import('@deepseek-ai/cordis'), + import('@deepseek-ai/dsh-session-projection'), + ]) + const ctx = new Context() + await ctx.plugin(registryModule.default) + return { ctx: ctx as unknown, registry: ctx.get('sessionProjections') as unknown as HostRegistryLike } +} + +const freshRegistry = async (): Promise => (await freshRoot()).registry + +// ── 1. the mirror definition ─────────────────────────────────────────────── + +section(SECTIONS[0]) + +const recorder = recordingRegistry() +attachSessionListMetadata(compositionRoot(recorder.services) as never) +const definition = recorder.definitions[0] as { + key: string + stateSchema: { parse: (value: unknown) => unknown } + init: (...args: readonly unknown[]) => SessionListMetadataState + apply: Fold + stateVersion: number + wire?: unknown + schema?: unknown + viewSchema?: unknown + view?: unknown +} + +check('attach registers exactly one definition', () => { + assert.equal(recorder.definitions.length, 1, 'one projection, one registration') +}) + +check('registration key is the host literal', () => { + assert.equal(definition.key, 'sessionListMetadata', 'the key is the shared identity; a typo means no row') + assert.equal(SESSION_LIST_METADATA_KEY, definition.key, 'the exported constant is what gets registered') +}) + +check('registration carries the host stateVersion', () => { + assert.equal(definition.stateVersion, 1, 'a mismatched version makes the host discard the row (lib/index.js:85-93)') + assert.equal(SESSION_LIST_METADATA_STATE_VERSION, 1, 'the exported constant is what gets registered') +}) + +check('registration carries the identity wire and no legacy spelling', () => { + const wire = definition.wire as { + viewSchema?: { parse: (value: unknown) => unknown } + view?: (state: unknown) => unknown + } | undefined + assert.notEqual( + wire, + undefined, + 'every wire read skips a definition without wire (lib/index.js:147/:170/:249), and the web sidebar reads through one', + ) + assert.equal(wire?.viewSchema, definition.stateSchema, 'the view must be parsed with the SAME schema the state already passed — a second copy could be the stricter of the two, and lib/index.js:259/:305 parse outside a try') + assert.equal(typeof wire?.view, 'function', 'wire.view must be callable') + const parsed = wire?.viewSchema?.parse({ blank: true, lastPromptAt: null, extra: 1 }) + assert.deepEqual(parsed, { blank: true, lastPromptAt: null }, 'the wire schema must accept both writers\' values like the state schema') + assert.throws(() => wire?.viewSchema?.parse({ blank: 1, lastPromptAt: null }), 'the wire schema is the same validator, not a looser one') + assert.equal(definition.schema, undefined, '`schema` is the 0.1.0-rc.6 spelling — deliberately not carried') + assert.equal(definition.viewSchema, undefined, '`viewSchema` is the 0.1.2-alpha.2 spelling — deliberately not carried') + assert.equal(definition.view, undefined, '`view` is a legacy spelling — deliberately not carried') +}) + +check('the identity view hands back the very state it was given (the host relies on Object.is)', () => { + const state: SessionListMetadataState = { blank: false, lastPromptAt: 111 } + const wire = definition.wire as { view: (value: unknown) => unknown } + assert.equal(wire.view(state), state, 'list.js:64 registers `view: state => state`; a copy would break the host\'s reference comparison') +}) + +check('stateSchema is a zod-shaped parser for {blank, lastPromptAt}', () => { + const schema = definition.stateSchema + assert.equal(typeof schema?.parse, 'function', 'the host calls def.stateSchema.parse(row.val) (lib/index.js:255/:297)') + assert.deepEqual(schema.parse({ blank: true, lastPromptAt: null }), { blank: true, lastPromptAt: null }) + assert.deepEqual(schema.parse({ blank: false, lastPromptAt: 111 }), { blank: false, lastPromptAt: 111 }) + assert.deepEqual(schema.parse({ blank: true, lastPromptAt: 1_700_000_000_000 }), { blank: true, lastPromptAt: 1_700_000_000_000 }) +}) + +check('stateSchema rejects wrong field names, types and coercion', () => { + assert.throws(() => definition.stateSchema.parse({ blank: 1, lastPromptAt: null }), 'blank must be a boolean, not 0/1') + assert.throws(() => definition.stateSchema.parse({ blank: true }), 'lastPromptAt is required (nullable, not optional)') + assert.throws(() => definition.stateSchema.parse({ blank: true, lastPromptAt: '111' }), 'no coercion of a numeric string') + assert.throws(() => definition.stateSchema.parse({ blank: true, lastPromptAtAt: 111 }), 'a misspelled field must not pass') +}) + +check('stateSchema is neither strict nor coercing on unknown keys', () => { + // ADR-0011 §3: it must accept BOTH writers' values. Strictness would reject a + // value the host itself writes once its state grows a field; coercion would + // silently change a stored value. Unknown-key tolerance is zod's default. + const parsed = definition.stateSchema.parse({ blank: true, lastPromptAt: null, extra: 1 }) + assert.deepEqual(parsed, { blank: true, lastPromptAt: null }) +}) + +check('init starts every session blank with no prompt time', () => { + assert.deepEqual(definition.init(), { blank: true, lastPromptAt: null }) + assert.notEqual(definition.init(), definition.init(), 'a fresh object per session, not one shared literal') +}) + +// ── 2. fold matrix ───────────────────────────────────────────────────────── + +section(SECTIONS[1]) + +/** + * The whole fold contract as one reusable group: the positive case runs it + * against the real fold, §7 runs the very same group against each mutant. + */ +function assertFoldSemantics(fold: Fold, label: string): void { + const init = initSessionListMetadata() + assert.deepEqual(init, { blank: true, lastPromptAt: null }, `${label}: init`) + + // A non-turn event must not even allocate: the host compares by value and + // keeps the reference, and a needless allocation re-publishes every unit. + assert.equal(fold(init, ASSISTANT_MESSAGE), init, `${label}: a non-turn event leaves the reference alone`) + assert.equal(fold(init, TOOL_RESULT), init, `${label}: a tool event leaves the reference alone`) + assert.equal(fold(init, SUBAGENT_MESSAGE), init, `${label}: a non-human message leaves the reference alone`) + assert.deepEqual(init, { blank: true, lastPromptAt: null }, `${label}: those events also left the value blank`) + + const afterTurn = fold(init, TURN_START) + assert.deepEqual(afterTurn, { blank: false, lastPromptAt: null }, `${label}: turn/start clears blank`) + assert.notEqual(afterTurn, init, `${label}: turn/start is a real change (new object)`) + assert.equal(fold(afterTurn, TURN_START_LATER), afterTurn, `${label}: a second turn/start changes nothing`) + assert.equal(fold(afterTurn, TURN_END), afterTurn, `${label}: blank never returns to true`) + + const afterHuman = fold(afterTurn, HUMAN_MESSAGE) + assert.deepEqual(afterHuman, SPOKEN_STATE, `${label}: a human message stamps lastPromptAt and keeps blank false`) + assert.equal(fold(afterHuman, HUMAN_MESSAGE_REPEAT), afterHuman, `${label}: the same timestamp is not a change`) + assert.equal(fold(afterHuman, ASSISTANT_MESSAGE), afterHuman, `${label}: an assistant message changes nothing`) +} + +check('fold matches the host state machine', () => { + assertFoldSemantics(applySessionListMetadata, 'fold') +}) + +check('fold reaches the post-turn state of a spoken log', () => { + const folded = SPOKEN_LOG.reduce( + (state, one) => applySessionListMetadata(state, one), + initSessionListMetadata(), + ) + assert.deepEqual(folded, SPOKEN_STATE) +}) + +check('a source-less user/message fails exactly like the host', () => { + // NOT a defensive branch: the host reads event.data.source.kind unguarded + // (list.js:29), and the session codec refuses to store such an event at all + // ("seed user/message ... has invalid source"), so the mirror stays verbatim + // instead of inventing a second semantic for a value the log cannot hold. + assert.throws(() => applySessionListMetadata(initSessionListMetadata(), SOURCE_LESS_MESSAGE), TypeError) +}) + +// ── 3. cross-runtime reads + restore ─────────────────────────────────────── + +section(SECTIONS[2]) + +/** Values our fold produces — what the TUI writes. */ +const OUR_VALUES: readonly SessionListMetadataState[] = [ + { blank: true, lastPromptAt: null }, + SPOKEN_STATE, + { blank: true, lastPromptAt: 1_700_000_000_000 }, +] +/** Values a web-written row carries — folded by the host's own `apply`. */ +const HOST_VALUES: readonly SessionListMetadataState[] = [ + { blank: true, lastPromptAt: null }, + { blank: false, lastPromptAt: 111 }, + { blank: false, lastPromptAt: 1_700_000_000_000 }, +] + +/** + * Assert one parser serves BOTH writers and still rejects a malformed row — the + * rejection half is what makes the "no `.parse`" control in §7 go red. + */ +function assertValidatorServesBothWriters(parse: (value: unknown) => unknown, label: string): void { + assert.equal(typeof parse, 'function', `${label}: must be callable`) + for (const value of OUR_VALUES) { + assert.deepEqual(parse(value), value, `${label}: must accept the value the TUI writes (${JSON.stringify(value)})`) + } + for (const value of HOST_VALUES) { + assert.deepEqual(parse(value), value, `${label}: must accept the value the host writes (${JSON.stringify(value)})`) + } + assert.throws(() => parse({ blank: 1, lastPromptAt: null }), `${label}: must reject a wrong-typed blank`) + assert.throws(() => parse({}), `${label}: must reject a row with no fields at all`) +} + +check('our schema reads the values the host writes', () => { + assertValidatorServesBothWriters(definition.stateSchema.parse.bind(definition.stateSchema), 'mirror schema') +}) + +const { z } = await import('zod') +/** The host schema's equivalent shape — `list.js:9-12` is module-private. */ +const hostShapedSchema = z.object({ blank: z.boolean(), lastPromptAt: z.number().nullable() }) + +check('the host-equivalent schema reads the values we write', () => { + for (const value of OUR_VALUES) { + assert.deepEqual(hostShapedSchema.parse(value), value, 'the host must be able to parse our row') + } + assert.throws(() => hostShapedSchema.parse({ blank: true }), 'and it is the same shape, not a looser one') +}) + +const restoreRegistry = await freshRegistry() +restoreRegistry.register(definition) +const { Session } = await import('@deepseek-ai/dsh-session') +const spokenSession = Session.create(SessionId('11111111-0000-4000-8000-000000000012'), [...SPOKEN_LOG]) +const header = (spokenSession as unknown as { header: unknown }).header + +check('restore() accepts a row the TUI wrote', () => { + const restored = restoreRegistry.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 1, seq: 1, val: SPOKEN_STATE } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + assert.deepEqual(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.val, SPOKEN_STATE, 'the TUI-written row survives a restart') +}) + +check('restore() accepts a row the host wrote', () => { + const restored = restoreRegistry.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 1, seq: -1, val: { blank: true, lastPromptAt: null } } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + assert.deepEqual( + restored.checkpoint[SESSION_LIST_METADATA_KEY]?.val, + SPOKEN_STATE, + 'a host-written row seeds the fold and the tail advances it', + ) +}) + +check('restore() does not throw on either writer\'s row', () => { + // The parse at lib/index.js:297 is NOT inside a try: a schema that rejects a + // stored row breaks the TUI's own session restore. Reaching the assertions + // below at all is half the check; the folded values are the other half. + for (const value of HOST_VALUES) { + const restored = restoreRegistry.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 1, seq: -1, val: value } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + assert.equal(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.ver, 1) + assert.deepEqual(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.val, SPOKEN_STATE) + } +}) + +check('restore() discards a row written by another version', () => { + const restored = restoreRegistry.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 2, seq: -1, val: { blank: true, lastPromptAt: null } } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + assert.deepEqual(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.ver, 1, 'the refreshed row is written back at our version') + assert.deepEqual(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.val, SPOKEN_STATE, 'a mismatched version refolds from init') +}) + +// ── 4. real registry integration ─────────────────────────────────────────── + +section(SECTIONS[3]) + +/** + * What the web host registers for the same key: same semantics, plus `wire`. + * + * A hand-written copy — including the host's *unguarded* `data.source` read + * (`list.js:29`) — used to exercise "the other runtime registered first" without + * depending on the host package being locatable. The drift probe asserts this + * copy folds like the real host whenever it can be located, so it cannot rot + * into a comfortable fiction. + */ +function webSideDefinition(): unknown { + return { + key: SESSION_LIST_METADATA_KEY, + stateSchema: z.object({ blank: z.boolean(), lastPromptAt: z.number().nullable() }), + init: () => ({ blank: true, lastPromptAt: null }), + apply: (state: SessionListMetadataState, one: SessionEvent) => { + const blank = state.blank && one.type !== 'turn/start' + const source = (one.data as { source: { kind: string } }).source + const lastPromptAt = one.type === 'user/message' && source.kind === 'user' ? one.time : state.lastPromptAt + return blank === state.blank && lastPromptAt === state.lastPromptAt ? state : { blank, lastPromptAt } + }, + wire: { viewSchema: z.object({ blank: z.boolean(), lastPromptAt: z.number().nullable() }), view: (state: unknown) => state }, + stateVersion: 1, + } +} + +const session = Session.create(SessionId('11111111-0000-4000-8000-000000000042'), [...SPOKEN_LOG]) + +/** Assert the row is written by whoever owns the key, with the expected value. */ +function assertRowWritten(registry: HostRegistryLike, sessionLike: unknown, where: string): void { + const rows = registry.checkpoint(sessionLike) + const row = rows[SESSION_LIST_METADATA_KEY] + assert.notEqual(row, undefined, `${where}: checkpoint() must contain the key (lib/index.js:195-207)`) + assert.equal(row?.ver, 1, `${where}: the row version is the registered stateVersion`) + assert.deepEqual(row?.val, SPOKEN_STATE, `${where}: the row value is the folded state`) +} + +/** + * Assert the WIRE read serves the key — the face the web sidebar actually reads. + * + * This is the assertion the row check cannot make: `checkpoint()` writes a row + * per registered key with or without `wire` (`lib/index.js:195-207`), while + * `snapshot()` skips every definition without one (`:147`). The shim (mirror + * registered first) therefore used to serve `{values: {}}` and the sidebar fell + * back to `?? false` — issue #1342 back through another door. + */ +function assertSnapshotServes(registry: HostRegistryLike, sessionLike: unknown, where: string): void { + const snapshot = registry.snapshot(sessionLike, [SESSION_LIST_METADATA_KEY]) + assert.notEqual( + snapshot.values[SESSION_LIST_METADATA_KEY], + undefined, + `${where}: snapshot() must contain the key (lib/index.js:147 skips a definition without wire)`, + ) + assert.deepEqual( + snapshot.values[SESSION_LIST_METADATA_KEY], + SPOKEN_STATE, + `${where}: the wire view serves the folded state`, + ) +} + +const tuiFirst = await freshRoot() +attachSessionListMetadata(tuiFirst.ctx as never) +await flush() +tuiFirst.registry.register(webSideDefinition()) + +check('TUI first, host second: the row is still written', () => { + assertRowWritten(tuiFirst.registry, session, 'tui-first') +}) + +check('TUI first, host second: the wire read serves the key too', () => { + assertSnapshotServes(tuiFirst.registry, session, 'tui-first') +}) + +const hostFirst = await freshRoot() +hostFirst.registry.register(webSideDefinition()) +attachSessionListMetadata(hostFirst.ctx as never) +await flush() + +check('host first, TUI second: no throw and the row is still written', () => { + assertRowWritten(hostFirst.registry, session, 'host-first') +}) + +check('host first, TUI second: the wire read serves the key too', () => { + assertSnapshotServes(hostFirst.registry, session, 'host-first') +}) + +check('registration order is not observable in the value', () => { + const left = tuiFirst.registry.checkpoint(session)[SESSION_LIST_METADATA_KEY] + const right = hostFirst.registry.checkpoint(session)[SESSION_LIST_METADATA_KEY] + assert.deepEqual(left, right, 'both definitions fold identically, so "who registered first" cannot matter') +}) + +check('registration order is not observable in the wire read either', () => { + const left = tuiFirst.registry.snapshot(session, [SESSION_LIST_METADATA_KEY]) + const right = hostFirst.registry.snapshot(session, [SESSION_LIST_METADATA_KEY]) + assert.deepEqual(left, right, 'a shim registered first must serve the same value as one registered second (AC-2)') +}) + +const refs = await freshRoot() +const firstDispose = refs.registry.register(definition) +const secondDispose = refs.registry.register(webSideDefinition()) + +await checkAsync('the second registration increments refs instead of replacing the definition', async () => { + firstDispose() + await flush() + assertRowWritten(refs.registry, session, 'after the first release') +}) + +await checkAsync('the key disappears only when the last registration is released', async () => { + secondDispose() + await flush() + assert.equal(refs.registry.checkpoint(session)[SESSION_LIST_METADATA_KEY], undefined, 'refs back to zero removes the key') +}) + +// ── 5. degradation ───────────────────────────────────────────────────────── + +section(SECTIONS[4]) + +/** + * A composition root for a host line with **no** projection plugin: `inject` + * records what was asked for and never fires its callback, which is what Cordis + * does while nothing provides `sessionProjections`. The recorder it holds is the + * service a registration WOULD land in, so "no definition was registered" is a + * fact about `attach` rather than about a fixture with nowhere to register — + * `withProvider()` drives the same stored callback the way a provider would and + * the definition must then appear (the control inside the check below). + * @returns The root, the request log and the recorder. + */ +function rootWithoutProjections(): { + readonly asked: unknown[] + readonly debug: string[] + readonly registry: ReturnType + readonly root: { inject: (deps: unknown, callback: (ctx: unknown) => unknown) => void } + readonly withProvider: () => void +} { + const registry = recordingRegistry() + const asked: unknown[] = [] + const debug: string[] = [] + let stored: ((ctx: unknown) => unknown) | undefined + return { + asked, + debug, + registry, + root: { + inject: (deps, callback) => { + asked.push(deps) + stored = callback + }, + }, + withProvider: () => { + stored?.({ ...registry.services, logger: { debug: (message: string) => debug.push(message) } }) + }, + } +} + +const noProvider = rootWithoutProjections() +check('no projection service at all: attach asks for it, registers nothing and stays silent', () => { + attachSessionListMetadata(noProvider.root as never) + assert.equal(noProvider.asked.length, 1, 'attach subscribes exactly once: a second subscription would double-register') + assert.ok( + (noProvider.asked[0] as readonly unknown[]).includes('sessionProjections'), + 'and it waits on the projection service by name', + ) + assert.equal(noProvider.registry.definitions.length, 0, 'no provider ever appeared, so nothing may be registered') + assert.equal(noProvider.debug.length, 0, 'and there is nothing to report: silence is this path\'s contract') + // Control: the very callback `attach` handed over, fired the way a provider + // would fire it, DOES register — so the zero above is `attach` behaving, not + // an unobservable fixture (LESSONS L-044). + noProvider.withProvider() + assert.equal(noProvider.registry.definitions.length, 1, 'a provider makes this exact shape register') +}) + +const lateDebug: string[] = [] +check('no projection service at all: a callback that fires without the service is still a no-op', () => { + // The production path never fires without a provider, but the callback keeps + // its own guard for a composition that answers late; it must not throw and it + // must not report anything (there is no key to report on). + assert.doesNotThrow(() => attachSessionListMetadata(compositionRoot({}, lateDebug) as never)) + assert.equal(lateDebug.length, 0, 'nothing is registered and nothing is said') +}) + +const noRegisterDebug: string[] = [] +check('a service without register: silent degradation', () => { + attachSessionListMetadata(compositionRoot({ + sessionProjections: { onChanged: () => () => undefined, snapshot: () => ({ values: {} }) }, + }, noRegisterDebug) as never) + assert.equal(noRegisterDebug.length, 1, 'the reason goes to the opt-in debug channel, once') + assert.match(noRegisterDebug[0] ?? '', /sessionListMetadata/u, 'the debug line names the projection it skipped') +}) + +const conflictDebug: string[] = [] +const conflicting = await freshRegistry() +conflicting.register({ + key: SESSION_LIST_METADATA_KEY, + stateSchema: z.object({ blank: z.boolean(), lastPromptAt: z.number().nullable(), owner: z.string() }), + init: () => ({ blank: true, lastPromptAt: null, owner: 'someone-else' }), + apply: (state: unknown) => state, + stateVersion: 2, +}) + +check('a conflicting stateVersion degrades instead of throwing', () => { + attachSessionListMetadata(compositionRoot({ sessionProjections: conflicting }, conflictDebug) as never) + assert.equal(conflictDebug.length, 1, 'the conflict is reported on the debug channel only') + assert.match(conflictDebug[0] ?? '', /sessionListMetadata/u, 'the debug line names the refused key') +}) + +check('a conflicting stateVersion leaves the other owner untouched', () => { + const row = conflicting.checkpoint(session)[SESSION_LIST_METADATA_KEY] + assert.equal(row?.ver, 2, 'the first definition keeps the key ("first registration wins")') + assert.deepEqual(row?.val, { blank: true, lastPromptAt: null, owner: 'someone-else' }) +}) + +await checkAsync('a real Context without the registry plugin stays quiet', async () => { + const { Context } = await import('@deepseek-ai/cordis') + const lonely = new Context() + attachSessionListMetadata(lonely) + attachSessionListMetadata(lonely) + await flush() + assert.equal(lonely.get('sessionProjections'), undefined, 'no service ever appeared, so nothing was registered') +}) + +// ── 6. drift probe ───────────────────────────────────────────────────────── + +section(SECTIONS[5]) + +/** The host line this mirror was written against (DESIGN D4, ADR-0011 §8). */ +const EXPECTED_HOST_LINE = '0.2.0-rc.2' +/** + * Where the probe looks, printed verbatim on SKIP so a miss is diagnosable. + * Overridable **only** so the SKIP path itself can be exercised (AC-11 asks for + * both environments): point it at a package that does not resolve and the probe + * must report a loud SKIP instead of passing silently. + */ +const ANCHOR = process.env.DSH_TUI_HOST_ANCHOR ?? '@deepseek-ai/dsh-web-app/package.json' +const SEARCH = `${ANCHOR} -> createRequire(anchor).resolve('@deepseek-ai/dsh-api-session-controller/package.json') -> /lib/types/list.js` + +interface HostProbe { + readonly fold: Fold + readonly anchorVersion: string + readonly controllerVersion: string + readonly listPath: string + readonly definition: Record +} + +/** Locate the installed host's own fold and definition, or explain why not. */ +async function locateHost(): Promise { + const anchor = import.meta.resolve(ANCHOR) + const require = createRequire(anchor) + const controllerPackage = require.resolve('@deepseek-ai/dsh-api-session-controller/package.json') + const listPath = join(dirname(controllerPackage), 'lib/types/list.js') + const [list, anchorJson, controllerJson] = await Promise.all([ + import(pathToFileURL(listPath).href) as Promise<{ + applySessionListMetadata: Fold + ApiSessionList: new (ctx: unknown) => unknown + }>, + Promise.resolve(JSON.parse(readFileSync(new URL(anchor), 'utf8')) as { version: string }), + Promise.resolve(JSON.parse(readFileSync(controllerPackage, 'utf8')) as { version: string }), + ]) + // The host's own definition, captured by constructing its owner with a stub + // registry: this is the object the web host hands to the SAME service. + const captured: Record[] = [] + new list.ApiSessionList({ + sessionProjections: { register: (value: Record) => { captured.push(value); return () => undefined } }, + inject: () => undefined, + }) + return { + fold: list.applySessionListMetadata, + anchorVersion: anchorJson.version, + controllerVersion: controllerJson.version, + listPath, + definition: captured[0] ?? {}, + } +} + +let host: HostProbe | undefined +try { + host = await locateHost() +} catch (error) { + console.log('\n ⚠️ SKIP drift probe: the installed host package could not be located.') + console.log(` anchor : import.meta.resolve('${ANCHOR}') from scripts/verify-session-list-metadata.ts`) + console.log(` search : ${SEARCH}`) + console.log(` reason : ${String((error as Error).message ?? error).split('\n')[0]}`) + console.log(' effect : the mirror is UNVERIFIED against the live host here; the equivalent-shape') + console.log(' checks of section 3 still ran. This is a SKIP, not a pass.') +} + +if (host !== undefined) { + const probe = host + console.log(`\n drift probe HIT: anchor ${probe.anchorVersion} -> controller ${probe.controllerVersion}`) + console.log(` fold: ${probe.listPath}`) + + check('the anchor resolves to the host line this mirror was written against', () => { + assert.equal( + probe.anchorVersion, + EXPECTED_HOST_LINE, + `the devDependency range is wide: re-read the host definition and re-check this mirror before accepting ${probe.anchorVersion}`, + ) + assert.equal(probe.controllerVersion, EXPECTED_HOST_LINE, 'the anchor and the controller must come from one host line') + }) + + check('the host definition has the mirrored key, version and schema shape', () => { + assert.equal(probe.definition.key, SESSION_LIST_METADATA_KEY) + assert.equal(probe.definition.stateVersion, SESSION_LIST_METADATA_STATE_VERSION) + const schema = probe.definition.stateSchema as { parse?: (value: unknown) => unknown } | undefined + assert.equal(typeof schema?.parse, 'function', 'the host stateSchema is a zod-shaped parser') + }) + + const PARITY_EVENTS: readonly (readonly [string, SessionEvent])[] = [ + ['turn/start (clears blank)', TURN_START], + ['turn/start again (no change)', TURN_START_LATER], + ['human user/message (stamps lastPromptAt)', HUMAN_MESSAGE], + ['human user/message, same time (no change)', HUMAN_MESSAGE_REPEAT], + ['subagent user/message (no change)', SUBAGENT_MESSAGE], + ['assistant/message (no change)', ASSISTANT_MESSAGE], + ['tool/result (no change)', TOOL_RESULT], + ['turn/end (no change)', TURN_END], + ] + const PARITY_STATES: readonly (readonly [string, SessionListMetadataState])[] = [ + ['init', initSessionListMetadata()], + ['after turn/start', { blank: false, lastPromptAt: null }], + ['after a human message', SPOKEN_STATE], + ] + + check(`drift probe: ${PARITY_EVENTS.length} events x ${PARITY_STATES.length} states fold identically to the host`, () => { + for (const [stateLabel, state] of PARITY_STATES) { + for (const [eventLabel, one] of PARITY_EVENTS) { + const ours = applySessionListMetadata(state, one) + const theirs = probe.fold(state, one) + assert.deepEqual(ours, theirs, `${stateLabel} + ${eventLabel}: the mirror must fold like the host`) + assert.equal( + ours === state, + theirs === state, + `${stateLabel} + ${eventLabel}: the same-reference optimisation must match too`, + ) + } + } + }) + + check('drift probe: the hand-written host fixture folds like the real host too', () => { + // Section 4 runs with an equivalent definition so it works without the host + // package; that copy is only trustworthy while it is checked against the + // real thing (Knowledge Duplication: one decision, two writers). + const fixture = webSideDefinition() as { apply: Fold } + for (const [stateLabel, state] of PARITY_STATES) { + for (const [eventLabel, one] of PARITY_EVENTS) { + assert.deepEqual( + fixture.apply(state, one), + probe.fold(state, one), + `${stateLabel} + ${eventLabel}: the fixture must not drift from the host`, + ) + } + } + }) + + check('drift probe: both sides refuse a source-less user/message', () => { + assert.throws(() => applySessionListMetadata(initSessionListMetadata(), SOURCE_LESS_MESSAGE), TypeError) + assert.throws(() => probe.fold(initSessionListMetadata(), SOURCE_LESS_MESSAGE), TypeError) + }) + + check('drift probe: the host schema validates a value the TUI wrote', () => { + const schema = probe.definition.stateSchema as { parse: (value: unknown) => unknown } + assertValidatorServesBothWriters(schema.parse.bind(schema), 'host schema') + }) + + await checkAsync('drift probe: the real registry restores our row while the host definition owns the key', async () => { + const root = await freshRoot() + root.registry.register(probe.definition) + attachSessionListMetadata(root.ctx as never) + await flush() + const restored = root.registry.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 1, seq: 1, val: SPOKEN_STATE } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + assert.deepEqual(restored.checkpoint[SESSION_LIST_METADATA_KEY]?.val, SPOKEN_STATE) + }) +} + +// ── 7. negative controls ─────────────────────────────────────────────────── + +section(SECTIONS[6]) + +check('the control harness itself can tell red from green', () => { + // Without this, every `expectRed` below could be vacuous — a control that can + // never report a miss proves nothing (LESSONS L-044). + assert.equal( + wentRed(() => assertFoldSemantics(applySessionListMetadata, 'meta')), + false, + 'the real fold must NOT go red, or `expectRed` would be reporting a constant', + ) + assert.equal( + wentRed(() => assertFoldSemantics(mutantFold('blank-always-true'), 'meta')), + true, + 'and a mutant MUST go red, or every control below is decoration', + ) +}) + +/** A fold with one term of the host formula replaced (see `assertFoldSemantics`). */ +function mutantFold(term: 'blank-always-true' | 'blank-always-false' | 'no-prompt-time'): Fold { + return (state, one) => { + const blank = term === 'blank-always-true' + ? true + : term === 'blank-always-false' + ? false + : state.blank && one.type !== 'turn/start' + const lastPromptAt = term === 'no-prompt-time' + ? state.lastPromptAt + : one.type === 'user/message' && (one.data as { source?: { kind?: string } }).source?.kind === 'user' + ? one.time + : state.lastPromptAt + return blank === state.blank && lastPromptAt === state.lastPromptAt ? state : { blank, lastPromptAt } + } +} + +const unregistered = await freshRegistry() + +expectRed( + 'a value-only test would not notice a missing registration', + () => assertRowWritten(unregistered, session, 'negative control'), + 'without the registration the checkpoint has no such key — the original bug', +) + +/** + * The mirror exactly as it shipped before the `wire` was added: same key, same + * version, same fold — and no wire. Registering it FIRST is the shipped layout + * the regression missed (F-02), because the host's own second registration only + * bumps `refs` and the wire-less definition keeps the key (`lib/index.js:85-93`). + */ +function mirrorWithoutWire(): Record { + const { wire, ...withoutWire } = webSideDefinition() as Record + assert.notEqual(wire, undefined, 'the fixture must actually carry a wire, or this control proves nothing') + return withoutWire +} + +const shimFirst = await freshRegistry() +shimFirst.register(mirrorWithoutWire()) +shimFirst.register(webSideDefinition()) + +check('the pre-fix shim still wrote its checkpoint row (why the row assertions missed this)', () => { + assertRowWritten(shimFirst, session, 'shim-first') +}) + +expectRed( + 'shim first without wire: the host definition is shadowed and the wire read loses the key', + () => assertSnapshotServes(shimFirst, session, 'shim-first'), + 'lib/index.js:147 skips a definition without wire and the first definition keeps the key, so snapshot() serves {values:{}} — the real TUI-first reading before this fix', +) + +expectRed( + 'blank pinned true must break the fold matrix', + () => assertFoldSemantics(mutantFold('blank-always-true'), 'mutant'), + 'turn/start would no longer clear blank, so the shell stays hidden forever', +) + +expectRed( + 'blank pinned false must break the fold matrix', + () => assertFoldSemantics(mutantFold('blank-always-false'), 'mutant'), + 'a session with no turn at all would claim to be a real conversation and become visible', +) + +expectRed( + 'lastPromptAt pinned null must break the fold matrix', + () => assertFoldSemantics(mutantFold('no-prompt-time'), 'mutant'), + 'updatedAt = max(createdAt, lastPromptAt ?? 0) would stop following the newest prompt', +) + +/** A schemastery-style validator: callable, no `.parse` (the AC-7 ④ failure). */ +const callableValidator = ((value: unknown) => value) as ((value: unknown) => unknown) & { parse?: (value: unknown) => unknown } + +expectRed( + 'a validator without .parse must fail the cross-runtime read', + () => assertValidatorServesBothWriters(callableValidator, 'mutant validator'), + 'a callable that echoes its input cannot serve the host\'s stateSchema.parse call, and accepts garbage', +) + +const withoutParse = await freshRegistry() +withoutParse.register({ + key: SESSION_LIST_METADATA_KEY, + stateSchema: callableValidator, + init: () => ({ blank: true, lastPromptAt: null }), + apply: (state: unknown) => state, + stateVersion: 1, +}) + +expectRed( + 'a validator without .parse must break the restore path', + () => { + withoutParse.restore( + { [SESSION_LIST_METADATA_KEY]: { ver: 1, seq: -1, val: { blank: true, lastPromptAt: null } } }, + SPOKEN_LOG, + 0, + header, + 0, + ) + }, + 'lib/index.js:297 parses outside a try, so a validator that cannot parse throws through the restore', +) + +await checkAsync('the real schemastery Schema has no .parse (the fact this control rests on)', async () => { + const module = await import('@deepseek-ai/schemastery') as unknown as { + Schema?: { parse?: unknown } + default?: { parse?: unknown } + } + const schema = module.Schema ?? module.default + assert.notEqual(schema, undefined, 'schemastery resolved, so the fact is checkable') + assert.equal( + schema?.parse, + undefined, + 'callable-only: passing it as stateSchema is the silent failure AC-7 ④ names', + ) +}) + +// ── 8. the boot-session wiring order (A-8 / T-FIX-05) ────────────────────── + +section(SECTIONS[7]) + +/** + * `plugin.ts` as text: the second half of the fix is a POSITION inside + * `apply()`, so the source itself is the subject. Every anchor below is a + * structural fragment of a statement (never a comment), asserted to occur + * exactly once — an anchor that names no single site would make the order + * assertion vacuous (F-07 / L-048). + */ +const pluginText = readFileSync(new URL('../src/dsh-adapter/plugin.ts', import.meta.url), 'utf8') + +/** + * Absolute offsets of every CALL of `name(` in a plugin text. The module's own + * `function …` definition and any mention inside a comment are excluded, so a + * reworded docstring cannot redden this (F-10's measured false-red class). + * @param source - A `plugin.ts` text. + * @param name - The called name. + * @returns Offsets into `source`, in source order. + */ +function callSitesIn(source: string, name: string): number[] { + const sites: number[] = [] + for (const match of source.matchAll(new RegExp(`\\b${name}\\s*\\(`, 'gu'))) { + const site = match.index ?? -1 + if (site < 0) continue + const prefix = source.slice(source.lastIndexOf('\n', site) + 1, site).trimStart() + if (prefix.startsWith('//') || prefix.startsWith('*') || prefix.startsWith('/*')) continue + sites.push(site) + } + return sites +} + +/** + * The offset of the ONE occurrence of a structural anchor. + * @param source - A `plugin.ts` text. + * @param anchor - A statement fragment that must occur exactly once. + * @returns The offset of that occurrence. + */ +function uniqueAnchor(source: string, anchor: string): number { + const first = source.indexOf(anchor) + assert.notEqual( + first, + -1, + `plugin.ts no longer contains ${JSON.stringify(anchor)} — re-read apply() before trusting the boot-order assertion`, + ) + assert.equal( + source.indexOf(anchor, first + anchor.length), + -1, + `${JSON.stringify(anchor)} occurs more than once in plugin.ts, so it names no single statement (F-07)`, + ) + return first +} + +/** + * The order the boot fix rests on: the mirror must be registered BEFORE the + * boot agent is resolved. + * + * `resolveAgent` is what creates or resumes the boot session, and the host + * writes one checkpoint row per registered key at that session's `create`. A + * mirror attached after it therefore misses the boot session's own creation + * record: `dsh web` reads `metadata?.blank ?? false` and lists the session as + * an untitled shell until its next checkpoint. That is the UAT-observed shape — + * visible on the landing page, healed by the first event — and it is why + * "registered before the channel opens" was not enough (KNOWN-ISSUES A-8). + * @param source - A `plugin.ts` text: the real one, or the pre-fix order. + */ +function assertRegistrationPrecedesBootAgent(source: string): void { + const registrations = callSitesIn(source, 'attachSessionListMetadata') + assert.equal( + registrations.length, + 1, + `the mirror must be attached exactly once in apply() (found ${registrations.length} call sites): zero is the original bug, two would be a second definition`, + ) + const registrationAt = registrations[0] as number + // Both halves of `const { agent, handle, … } = … await resolveAgent(…)` — the + // statement that creates or resumes the boot session, and the call itself. + const bootAgentAt = Math.min( + uniqueAnchor(source, 'const { agent, handle'), + uniqueAnchor(source, 'await resolveAgent('), + ) + assert.ok( + registrationAt < bootAgentAt, + `the mirror is registered at byte ${registrationAt}, after the boot agent is resolved at byte ${bootAgentAt}: ` + + 'the boot session\'s creation checkpoint would carry no sessionListMetadata row, so `dsh web` shows an untitled shell until the next checkpoint (A-8)', + ) +} + +check('order: the session-list mirror is registered before the boot agent is resolved', () => { + assertRegistrationPrecedesBootAgent(pluginText) +}) + +/** + * The pre-fix layout, rebuilt in memory: the two positions swapped, i.e. the + * registration put back where it sat before T-FIX-05 — after the boot session + * exists. Nothing is written to disk; the result drives the SAME assertion body + * as the check above, so the order assertion has to have discriminating power + * (L-044) rather than merely being green on the shipped file. + * + * A failed mutation throws here rather than inside the control below: a + * `preFixOrder()` that threw *there* would make `expectRed` report a vacuous + * pass (LESSONS L-048 ①). + * @returns A `plugin.ts` text carrying the pre-fix order. + */ +function preFixOrder(): string { + const registrationCall = 'attachSessionListMetadata(ctx)\n' + assert.equal( + callSitesIn(pluginText, 'attachSessionListMetadata').length, + 1, + 'the swap needs exactly one call site to move, or it would be rearranging something else', + ) + const withoutRegistration = pluginText.replace(registrationCall, '') + assert.equal( + withoutRegistration.length, + pluginText.length - registrationCall.length, + 'the call site was NOT removed (a string-pattern replace that matched nothing would test the fixed text and pass)', + ) + assert.equal( + callSitesIn(withoutRegistration, 'attachSessionListMetadata').length, + 0, + 'the removal must leave no call site behind', + ) + const afterBootSession = uniqueAnchor(withoutRegistration, 'let startupSession: AgentSession') + return `${withoutRegistration.slice(0, afterBootSession)}${registrationCall}${withoutRegistration.slice(afterBootSession)}` +} + +/** The swapped text, filled by the check below so a broken swap reddens the run. */ +let preFixText = '' + +check('order: the in-memory swap keeps one call site, so the control can only go red on the order', () => { + preFixText = preFixOrder() + assert.notEqual(preFixText, pluginText, 'the swap must actually change the text, or the control below proves nothing') + assert.equal( + callSitesIn(preFixText, 'attachSessionListMetadata').length, + 1, + 'the pre-fix text must still carry exactly one call site — otherwise the control below would be red for the wrong reason', + ) +}) + +expectRed( + 'order: the pre-fix layout (registered after the boot agent) must go red', + () => assertRegistrationPrecedesBootAgent(preFixText), + 'T-FIX-05 exists because the two positions are not interchangeable: resolving the agent first creates the boot session — and therefore its first checkpoint — without our row (A-8)', +) + +// ── summary ──────────────────────────────────────────────────────────────── + +const probeStatus = host === undefined + ? 'SKIPPED (host package not locatable — see the SKIP block above)' + : `HIT (host ${host.controllerVersion})` +console.log('') +if (failed.length > 0) { + console.error(`verify-session-list-metadata: FAIL (${failed.length}/${checks} checks red; drift probe: ${probeStatus})`) + for (const label of failed) console.error(` - ${label}`) + process.exit(1) +} +console.log(`verify-session-list-metadata: OK (${checks} checks; drift probe: ${probeStatus})`) +process.exit(0) diff --git a/scripts/verify-session-title-lineage.ts b/scripts/verify-session-title-lineage.ts index 1bd651ae1..107b12df0 100644 --- a/scripts/verify-session-title-lineage.ts +++ b/scripts/verify-session-title-lineage.ts @@ -77,10 +77,23 @@ const ev = (seq: number, type: string, data: Record = {}): AnyE // ==== 2. 真链路:switchModel 建子会话时写的 meta.parentSession =============== const stubAgentCtx = { on: () => () => {} } function makeAgent(id: string, sessionId: string, sessionEvents: readonly unknown[]) { + // The live Session contract includes the durable `append` the cut-policy + // replay uses (`/model` no longer only reads the cut — it writes the facts a + // conversation-less cut carries into the unseeded child, T-FIX-17). The + // double records them in its own log, and `seq` stays the exclusive offset. + const events = [...sessionEvents] return { id, status: 'idle', - session: { id: sessionId, seq: sessionEvents.length, events: sessionEvents, header: {} }, + session: { + id: sessionId, + get seq() { return events.length }, + events, + header: {}, + append(type: string, data: Record): void { + events.push({ seq: events.length, time: 1_700_000_000_000 + events.length, type, data }) + }, + }, ctx: stubAgentCtx, followup() {}, steer() {}, @@ -130,12 +143,28 @@ const blankSource = [ check('blank: switch succeeds and creates exactly one child', switched === true && adopted, `creates=${creates.length}`) const meta = (creates[0]?.['meta'] ?? {}) as Record check('blank: the child records no parentSession', meta['parentSession'] === undefined, JSON.stringify(meta)) - check('blank: the child still inherits the seed', ((creates[0]?.['seed'] ?? []) as unknown[]).length === blankSource.length) - // The inherited cut survives the missing lineage: whatever shape the runtime - // line encodes it in, a seeded root still marks its prefix as inherited. + // T-FIX-10/11/12 changed this semantics ON PURPOSE (declared in PR #1407's + // "Change outline"): a source that never held a conversation no longer seeds + // its `/model` child at all, so there is no inherited cut left to mark. The + // child is created as an ordinary fresh session (`createFreshAgent`: no + // `seed`, no `seedLength`/`isSeeded`, no `parentSession`) because the host + // materialises the seed prefix BEFORE `session/created` (agent-loop + // `appendUnstoredSuffix` → `writer.append` → `persistBatch(…, + // isMaterialized: false)` → `materialize()`), which would publish a seeded + // child of an empty source as a permission-initialization shell — exactly the + // blank sidebar row this change exists to remove. The two checks below are + // the previous pair ("still inherits the seed" / "is a seeded root") restated + // for that semantics; the `prompted` case keeps the opposite direction. + // Evidence: POST-ARCHIVE-ADDENDUM.md §11 (PR #1407 CI forensics) and + // T-FIX-10-SUMMARY.md「为什么必须换 createFreshAgent 而不只是 seed: []」. + check( + 'blank: the child starts unseeded (an empty cut copies nothing)', + ((creates[0]?.['seed'] ?? []) as unknown[]).length === 0, + JSON.stringify(creates[0]?.['seed'] ?? null), + ) check( - 'blank: the child is a seeded root (the inherited cut is still marked)', - meta['isSeeded'] === true || meta['seedLength'] === blankSource.length, + 'blank: the child is a fresh root (no inherited cut is marked)', + meta['isSeeded'] === undefined && meta['seedLength'] === undefined, JSON.stringify(meta), ) } @@ -156,6 +185,15 @@ const blankSource = [ String(meta['parentSession']) === 'source-session', JSON.stringify(meta), ) + // The opposite direction of the `blank` pair above, and the reason the fix is + // a verdict instead of "never seed": a source that holds a conversation still + // copies its whole prefix and still marks that cut as inherited. + const seed = (creates[0]?.['seed'] ?? []) as unknown[] + check( + 'prompted: the child still inherits the whole source prefix', + seed.length > 0 && (meta['isSeeded'] === true || meta['seedLength'] === seed.length), + JSON.stringify(meta), + ) } process.exit(failed) diff --git a/scripts/verify-session-write-lease.ts b/scripts/verify-session-write-lease.ts new file mode 100644 index 000000000..13c79c2d9 --- /dev/null +++ b/scripts/verify-session-write-lease.ts @@ -0,0 +1,515 @@ +/** + * Session write-lease regression — the exit sweep must not delete a session + * another process is writing (change `dsh-tui-unspoken-session-leak`, + * REVIEW CR-2 / T-FIX-18). + * + * `dsh web` opens its own "new session" placeholder in the SHARED session + * store: no person prompted there (layer ① correctly says `hasPrompt:false`), + * its log is a header-only shell (layer ② sees no conversation) and `dsh web` + * never writes the TUI's mount ledger (layer ③'s `held-elsewhere` cannot see + * it). The host's exclusive write lease can, and this file drives it for real: + * the fixture owns its lease through the shipping JSONL persistence backend + * (the same `acquireWriteLease` a peer's write handle takes), against a real + * temporary sessions root that `findSessionLogFile` resolves. + * + * Covered: + * 1. The probe answers from the host's own arbiter: `held` while a + * `SessionWriteLease` is open on the fixture log, `free` once released. + * 2. ① A write-leased shell is SPARED by the sweep with the new + * `write-leased` reason, and its log is still on disk afterwards. + * 3. ② The identical fixture with no holder is DELETED — the exit sweep's + * behavior is unchanged where nothing holds the session. + * 4. ③ A probe that cannot answer (platform-unsupported / any non-contention + * failure) spares: an unproven session is never deleted. + * 5. ④ Negative control: a probe that always reports `free` deletes the very + * session case ② keeps — case ① cannot be satisfied by a constant, and + * "not consulted at all" is shown to be a different (and unsafe) world. + * 6. ⑤ The reason is distinguishable from the ledger's `held-elsewhere`: a + * lease-only holder reports `write-leased`, a ledger holder keeps + * `held-elsewhere`, and the new reason is not a synonym of the old one. + * 7. Probe failures are classified, never thrown: an unreachable service, a + * service without the lease method, a non-contention rejection and a + * lease this process cannot release all read `unknown`. + * 8. The pre-pass is bounded, ordered and fail-soft: the round's cap limits + * the probes, a header without `cwd` proves nothing, a throwing header + * read costs one entry, and an unreadable index proves nothing at all. + * 9. The wiring: `sweepUnspokenOnExit` carries the proof into the round, and + * the exit branch gathers it BEFORE the notice it feeds (so the count in + * the notice still belongs to one finished round). + * + * Run: node --import tsx/esm scripts/verify-session-write-lease.ts + * The sessions root, the DSH home and `~/.dsh-tui` are ALL redirected under one + * disposable mkdtemp directory before the module under test is imported, and + * the real user index is never read (the round's index stays a fixture). + */ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' +// Type-only, so it is erased: the value imports below must stay AFTER the env +// override (sessionsRoots() reads DSH_TUI_SESSION_ROOT at import time). +import type { UnspokenIndexEntry, UnspokenSweepDeps, UnspokenSweepResult } from '../src/dsh-adapter/unspoken-sessions.js' + +const root = mkdtempSync(join(tmpdir(), 'dsh-tui-write-lease-')) +// Registered BEFORE the imports below, so a red run still cleans up. +process.on('exit', () => { + rmSync(root, { recursive: true, force: true }) +}) +process.env.HOME = root +process.env.USERPROFILE = root +process.env.DSH_HOME = join(root, 'dsh') +const sessionsRoot = join(root, 'logs') +process.env.DSH_TUI_SESSION_ROOT = sessionsRoot + +const { Context } = await import('@deepseek-ai/cordis') +const { default: JsonlSessionPersistence } = await import('@deepseek-ai/dsh-session-persistence-jsonl') +const { provenWriteLeaseFree, sweepUnspokenSessions } = await import('../src/dsh-adapter/unspoken-sessions.js') +const { createWriteLeaseProbe } = await import('../src/dsh-adapter/compat/writeLease.js') +const { sweepUnspokenOnExit } = await import('../src/dsh-adapter/plugin.js') +const { readFileSync } = await import('node:fs') + +let cases = 0 +let checks = 0 +let failures = 0 + +/** Count one assertion and label its failure with what it was proving. */ +async function check(label: string, body: () => void | Promise): Promise { + checks += 1 + try { + await body() + console.log(` ok ${label}`) + } catch (error) { + failures += 1 + const message = String((error as Error).message ?? error).replaceAll('\n', ' | ').slice(0, 400) + console.error(` FAIL ${label}\n ${message}`) + } +} + +/** Announce one case; the checks under it carry the verdict. */ +function case_(label: string): void { + cases += 1 + console.log(`\n${cases}. ${label}`) +} + +/** Start one case's fixture from an empty index (nested cases keep theirs). */ +const freshIndex = (): void => { + index.clear() +} + +/* ── the shipping backend, on the temporary root ─────────────────────────── */ + +/** The lease surface this regression needs from the persistence service. */ +interface LeaseSeam { + acquireWriteLease(header: { readonly id: string; readonly cwd: string }): Promise<{ release(): Promise }> +} + +const ctx = new Context() +const fiber = ctx.plugin(JsonlSessionPersistence as never, { root: sessionsRoot }) + +/** Resolve the persistence service the backend registered. */ +async function persistenceService(): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const service = (ctx as { get(name: string): unknown }).get('sessionPersistence') + if (service !== undefined) return service as LeaseSeam + await new Promise(resolve => setTimeout(resolve, 25)) + } + throw new Error('the JSONL persistence service never became ready') +} + +const service = await persistenceService() +const probe = createWriteLeaseProbe(() => service) + +/* ── the fixture ─────────────────────────────────────────────────────────── */ + +const index = new Map() +/** One shell: promptless in the index, exactly what the sweep collects. */ +const shell = (sessionId: string): void => { + index.set(sessionId, { derived: { hasPrompt: false } }) +} + +/** + * Hold one session's write lease, as a peer writer (`dsh web`) would: the + * backend creates the session directory and keeps the kernel lock until the + * returned holder is released. + */ +const holdLease = async (sessionId: string, cwd: string): Promise<{ release(): Promise }> => + service.acquireWriteLease({ id: sessionId, cwd }) + +/** The session directory the backend derived for one id. */ +const dirOf = (sessionId: string): string | undefined => { + for (const project of readdirSync(sessionsRoot)) { + const candidate = join(sessionsRoot, project, sessionId) + if (existsSync(candidate)) return candidate + } + return undefined +} + +/** + * Write the header-only durable log the sweep reads: one physical `session` + * row and no events, the shape `dsh web`'s own placeholder materializes. + * @param sessionId - Session directory name. + * @param cwd - The `cwd` the physical header records (what the lease is keyed by). + * @returns The artifact path. + */ +const writeShellLog = (sessionId: string, cwd: string): string => { + const dir = dirOf(sessionId) + assert.ok(dir !== undefined, `holding the lease must create ${sessionId}'s directory`) + const path = join(dir, 'session.v4.jsonl.zstd') + writeFileSync(path, zstdCompressSync(Buffer.from(`${JSON.stringify({ type: 'session', version: 4, id: sessionId, cwd })}\n`))) + return path +} + +/** The round's process facts, with the lease proof under test. */ +const sweepDeps = (writeLeaseFree?: (sessionId: string) => boolean): UnspokenSweepDeps => ({ + readIndex: () => index, + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, + ...(writeLeaseFree === undefined ? {} : { writeLeaseFree }), +}) + +/** The reason one id was spared, if it was. */ +const reasonOf = (result: UnspokenSweepResult, sessionId: string): string | undefined => + result.skipped.find(row => row.id === sessionId)?.reason + +/** Run the shipping pre-pass against the fixture index. */ +const prove = async (sessionId: string, implementation = probe): Promise => { + const predicate = await provenWriteLeaseFree(implementation, { readIndex: () => index }) + return predicate(sessionId) +} + +/* ── 1. the probe reads the host's arbiter ───────────────────────────────── */ + +case_('the probe reads the host write lease: held while a writer owns the session, free once released') +{ + freshIndex() + const cwd = '/fixture/lease-probe' + shell('probe-shell') + const holder = await holdLease('probe-shell', cwd) + writeShellLog('probe-shell', cwd) + const held = await probe('probe-shell', cwd) + await check('an open write lease reads held', () => { + assert.equal(held, 'held') + }) + await holder.release() + const freed = await probe('probe-shell', cwd) + await check('the released lease reads free', () => { + assert.equal(freed, 'free') + }) +} + +/* ── 2. ① a write-leased shell survives the sweep ────────────────────────── */ + +case_('① a shell another writer holds is spared with write-leased, and its log stays on disk') +{ + freshIndex() + const sessionId = 'leased-shell' + const cwd = '/fixture/leased' + shell(sessionId) + const holder = await holdLease(sessionId, cwd) + const logPath = writeShellLog(sessionId, cwd) + const writeLeaseFree = await provenWriteLeaseFree(probe, { readIndex: () => index }) + await check('the pre-pass does not prove the held session free', () => { + assert.equal(writeLeaseFree(sessionId), false) + }) + const result = sweepUnspokenSessions(sweepDeps(writeLeaseFree)) + await check('nothing is deleted', () => { + assert.deepEqual([...result.deleted], []) + }) + await check('the reason is the write lease, not the ledger', () => { + assert.equal(reasonOf(result, sessionId), 'write-leased') + }) + await check('the log is still there', () => { + assert.ok(existsSync(logPath), `the held session log was removed: ${logPath}`) + }) + + /* ── 3. ② the same shape with no holder is still deleted ──────────────── */ + case_('② the identical shell with no holder is still deleted — the round is otherwise unchanged') + await holder.release() + const released = await provenWriteLeaseFree(probe, { readIndex: () => index }) + await check('the pre-pass proves the released session free', () => { + assert.equal(released(sessionId), true) + }) + const swept = sweepUnspokenSessions(sweepDeps(released)) + await check('it is deleted', () => { + assert.deepEqual([...swept.deleted], [sessionId]) + }) + await check('and its log is gone', () => { + assert.equal(existsSync(logPath), false) + }) +} + +/* ── 4. ③ a probe that cannot answer spares ─────────────────────────────── */ + +case_('③ a probe that fails for a non-contention reason spares the session rather than guessing') +{ + freshIndex() + const sessionId = 'unprovable-shell' + const cwd = '/fixture/unprovable' + shell(sessionId) + const holder = await holdLease(sessionId, cwd) + await holder.release() + const logPath = writeShellLog(sessionId, cwd) + const unsupported = async (): Promise => { + // The POSIX addon's own refusal on an unsupported platform, verbatim. + throw Object.assign(new Error('flock is not supported on win32-x64'), { + code: 'ERR_FLOCK_UNSUPPORTED_PLATFORM', + syscall: 'flock', + }) + } + const writeLeaseFree = await provenWriteLeaseFree(unsupported, { readIndex: () => index }) + const result = sweepUnspokenSessions(sweepDeps(writeLeaseFree)) + await check('an unavailable probe proves nothing', () => { + assert.equal(writeLeaseFree(sessionId), false) + }) + await check('the session is spared with the same reason', () => { + assert.equal(reasonOf(result, sessionId), 'write-leased') + }) + await check('the log is untouched', () => { + assert.ok(existsSync(logPath)) + }) + await check('a probe that answers unknown spares too', async () => { + assert.equal(await prove(sessionId, async () => 'unknown'), false) + }) +} + +/* ── 5. ④ the negative control ──────────────────────────────────────────── */ + +case_('④ negative control: a probe that always says free deletes the leased session') +{ + freshIndex() + const sessionId = 'constant-free-shell' + const cwd = '/fixture/constant-free' + shell(sessionId) + const holder = await holdLease(sessionId, cwd) + const logPath = writeShellLog(sessionId, cwd) + const constantFree = await provenWriteLeaseFree(async () => 'free', { readIndex: () => index }) + await check('the constant satisfies the proof', () => { + assert.equal(constantFree(sessionId), true) + }) + const result = sweepUnspokenSessions(sweepDeps(constantFree)) + await check('the leased session is deleted — so case ① cannot be green on a constant', () => { + assert.deepEqual([...result.deleted], [sessionId]) + assert.equal(existsSync(logPath), false) + }) + await holder.release() + + case_('④b negative control: the round with no proof at all is the pre-fix world, which deletes it too') + const secondId = 'no-proof-shell' + const secondCwd = '/fixture/no-proof' + shell(secondId) + const secondHolder = await holdLease(secondId, secondCwd) + const secondLog = writeShellLog(secondId, secondCwd) + const unguarded = sweepUnspokenSessions(sweepDeps()) + await check('without the seam the layer is not consulted and the session goes', () => { + assert.deepEqual([...unguarded.deleted], [secondId]) + assert.equal(existsSync(secondLog), false) + }) + await secondHolder.release() +} + +/* ── 6. ⑤ the new reason is not a synonym of the ledger's ───────────────── */ + +case_('⑤ a ledger holder keeps held-elsewhere while a lease-only holder reports write-leased') +{ + freshIndex() + const ledgerId = 'ledger-shell' + const leaseId = 'lease-only-shell' + for (const id of [ledgerId, leaseId]) shell(id) + const holder = await holdLease(leaseId, '/fixture/lease-only') + writeShellLog(leaseId, '/fixture/lease-only') + // The ledger fixture needs a real shell log too: layer ② runs before layer ③ + // and an absent log would spare it for the wrong reason (`log-absent`). + const ledgerWriter = await holdLease(ledgerId, '/fixture/ledger') + await ledgerWriter.release() + writeShellLog(ledgerId, '/fixture/ledger') + const writeLeaseFree = await provenWriteLeaseFree(probe, { readIndex: () => index }) + const result = sweepUnspokenSessions({ + ...sweepDeps(writeLeaseFree), + // The ledger's own fact, injected the way the exit path injects it. + occupiedElsewhere: () => new Set([ledgerId]), + }) + await check('the ledger fact keeps its own reason', () => { + assert.equal(reasonOf(result, ledgerId), 'held-elsewhere') + }) + await check('the lease fact reports its own, and the two differ', () => { + assert.equal(reasonOf(result, leaseId), 'write-leased') + assert.notEqual(reasonOf(result, ledgerId), reasonOf(result, leaseId)) + }) + await holder.release() +} + +/* ── 7. probe failures are classified, never thrown ─────────────────────── */ + +case_('probe failures read unknown: no service, no lease method, a foreign error, an unreleasable lease') +{ + const cwd = '/fixture/classified' + await check('a host that throws is unknown', async () => { + const throwing = createWriteLeaseProbe(() => { throw new Error('context gone') }) + assert.equal(await throwing('x', cwd), 'unknown') + }) + await check('a service without the lease method is unknown', async () => { + assert.equal(await createWriteLeaseProbe(() => ({}))('x', cwd), 'unknown') + }) + await check('an absent service is unknown', async () => { + assert.equal(await createWriteLeaseProbe(() => undefined)('x', cwd), 'unknown') + }) + await check('a non-contention rejection is unknown', async () => { + const foreign = createWriteLeaseProbe(() => ({ + acquireWriteLease: async () => { throw Object.assign(new Error('EACCES: open'), { code: 'EACCES' }) }, + })) + assert.equal(await foreign('x', cwd), 'unknown') + }) + await check('a lease that cannot be released is unknown, not free', async () => { + const stuck = createWriteLeaseProbe(() => ({ + acquireWriteLease: async () => ({ release: async () => { throw new Error('close failed') } }), + })) + assert.equal(await stuck('x', cwd), 'unknown') + }) + await check('the host contention verdict is held', async () => { + const contended = createWriteLeaseProbe(() => ({ + acquireWriteLease: async () => { + throw Object.assign(new Error('session "x" is already owned by an active write handle'), { + name: 'SessionAlreadyOwnedError', + }) + }, + })) + assert.equal(await contended('x', cwd), 'held') + }) + await check('a granted lease is free and is handed straight back', async () => { + let released = false + const granted = createWriteLeaseProbe(() => ({ + acquireWriteLease: async () => ({ release: async () => { released = true } }), + })) + assert.equal(await granted('x', cwd), 'free') + assert.ok(released, 'the probe must not keep the lock it took') + }) +} + +/* ── 8. the pre-pass is bounded and fail-soft ───────────────────────────── */ + +case_('the pre-pass honors the round cap, skips entries it cannot name, and proves nothing without an index') +{ + const cappedIndex = new Map([ + ['cap-a', { derived: { hasPrompt: false } }], + ['cap-b', { derived: { hasPrompt: false } }], + ['prompted', { derived: { hasPrompt: true } }], + ]) + const probed: string[] = [] + const counted = async (sessionId: string): Promise<'free'> => { + probed.push(sessionId) + return 'free' + } + const mixed = new Map([ + ['bad', { derived: { hasPrompt: false } }], + ['good', { derived: { hasPrompt: false } }], + ]) + const capped = await provenWriteLeaseFree(counted, { + readIndex: () => cappedIndex, + readSessionHeader: sessionId => ({ id: sessionId, cwd: '/fixture/cap' }) as never, + maxCandidates: 1, + }) + await check('only the cap\'s worth of entries is probed, in the round\'s order', () => { + assert.deepEqual(probed, ['cap-a']) + assert.equal(capped('cap-a'), true) + assert.equal(capped('cap-b'), false) + }) + await check('a prompted entry is never probed', () => { + assert.equal(probed.includes('prompted'), false) + }) + await check('a header without cwd proves nothing', async () => { + const noCwd = await provenWriteLeaseFree(counted, { + readIndex: () => new Map([['no-cwd', { derived: { hasPrompt: false } }]]), + readSessionHeader: () => undefined, + }) + assert.equal(noCwd('no-cwd'), false) + }) + await check('a throwing header read costs one entry, not the round', async () => { + const predicate = await provenWriteLeaseFree(counted, { + readIndex: () => mixed, + readSessionHeader: sessionId => { + if (sessionId === 'bad') throw new Error('torn first frame') + return { id: sessionId, cwd: '/fixture/mixed' } as never + }, + }) + assert.equal(predicate('bad'), false) + assert.equal(predicate('good'), true) + }) + await check('an unreadable index proves nothing at all', async () => { + const unreadable = await provenWriteLeaseFree(counted, { + readIndex: () => { throw new Error('index unreadable') }, + }) + assert.equal(unreadable('cap-a'), false) + }) + await check('a probe that throws spares its own entry only', async () => { + const predicate = await provenWriteLeaseFree(async sessionId => { + if (sessionId === 'bad') throw new Error('probe boom') + return 'free' + }, { + readIndex: () => mixed, + readSessionHeader: sessionId => ({ id: sessionId, cwd: '/fixture/mixed' }) as never, + }) + assert.equal(predicate('bad'), false) + assert.equal(predicate('good'), true) + }) +} + +/* ── 9. the wiring ──────────────────────────────────────────────────────── */ + +case_('the exit wiring carries the proof into the round and gathers it before the notice') +{ + freshIndex() + const sessionId = 'wired-shell' + const cwd = '/fixture/wired' + shell(sessionId) + const holder = await holdLease(sessionId, cwd) + writeShellLog(sessionId, cwd) + const writeLeaseFree = await provenWriteLeaseFree(probe, { readIndex: () => index }) + const swept = sweepUnspokenOnExit({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + listedSessions: () => [{ id: sessionId }], + writeLeaseFree, + // The store seams the exit regression also injects: this file keeps its + // fixtures out of the real index, the wiring under test is the lease seam. + sweep: deps => sweepUnspokenSessions({ ...deps, readIndex: () => index }), + }) + await check('sweepUnspokenOnExit carries writeLeaseFree into the round', () => { + assert.notEqual(swept, undefined) + assert.deepEqual([...(swept as UnspokenSweepResult).deleted], []) + assert.equal(reasonOf(swept as UnspokenSweepResult, sessionId), 'write-leased') + }) + await holder.release() + + const source = readFileSync(new URL('../src/dsh-adapter/plugin.ts', import.meta.url), 'utf8') + const from = source.indexOf('// Judge against the live session behind the channel') + const to = source.indexOf('\n },', from) + const block = from === -1 || to <= from ? '' : source.slice(from, to) + await check('the branch builds the proof through the probe factory', () => { + assert.match(block, /await\s+provenWriteLeaseFree\(\s*createWriteLeaseProbe\(/u) + }) + await check('the proof is gathered BEFORE the round it feeds, and the notice comes after the round', () => { + const proofAt = block.search(/await\s+provenWriteLeaseFree\(/u) + const sweepAt = block.search(/\bsweepUnspokenOnExit\s*\(/u) + const noticeAt = block.search(/composeExitNotice\s*\(/u) + assert.ok(proofAt !== -1 && sweepAt !== -1 && noticeAt !== -1, `anchors: proof@${proofAt} sweep@${sweepAt} notice@${noticeAt}`) + assert.ok(proofAt < sweepAt && sweepAt < noticeAt, `order: proof@${proofAt} sweep@${sweepAt} notice@${noticeAt}`) + }) + await check('a pre-pass that cannot run leaves the round with a fail-closed predicate', () => { + assert.match(block, /let\s+writeLeaseFree[^\n]*=\s*\(\)\s*=>\s*false/u) + }) +} + +try { + await Promise.resolve((fiber as { dispose(): unknown }).dispose()).catch(() => {}) +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log(`\n${cases - failures}/${cases} cases passed, ${checks} checks`) +if (failures > 0) { + console.error(`${failures} session write-lease regressions failed`) + process.exit(1) +} +console.log('session write-lease regression: OK') diff --git a/scripts/verify-unspoken-session-sweep.tsx b/scripts/verify-unspoken-session-sweep.tsx new file mode 100644 index 000000000..67ad9a85b --- /dev/null +++ b/scripts/verify-unspoken-session-sweep.tsx @@ -0,0 +1,779 @@ +/** + * Unspoken-session sweep regression — the exit-time cleanup of shells no + * person ever spoke to (change `dsh-tui-unspoken-session-leak`, DESIGN D5 / + * ADR-0012). + * + * Every case runs against a REAL temporary sessions root and a REAL TUI + * session index, so the three layers are exercised through their shipping + * implementations (`sessions/store.readIndex`, `compat/sessionLog`'s bounded + * reader and delete primitive, `sessionHistory`'s per-session notes) rather + * than through doubles. The dependencies the module cannot know — which + * session this process is bound to, which runs are live, what lineage is a + * delegated run — are injected, and the pure decision rules are injectable so + * the negative control below can drive the SAME pipeline with reversed rules. + * + * Covered: + * 1. A boot-only shell in the index is collected, including a pre-existing + * ("historical") entry this run never created. + * 2. A human `user/message` without a `turn/start` is preserved — the case + * the host's own `blank` rule would delete. + * 3. A `turn/start` is preserved. + * 4. Delegated runs and their descendants are preserved (the delete + * primitive has no `kind`/`parentSession` check of its own). + * 5. Sessions this process still holds (bound / live background) are + * preserved. + * 6. An absent, dangling, corrupt or budget-truncated log is skipped, never + * deleted, and reported with the layer that spared it. + * 7. ADVERSARIAL: an index that says `hasPrompt:false` while the log holds a + * human message is preserved (AC-6's core assertion). + * 8. Negative control: replacing the three rules with "always delete" / + * "never delete", or dropping only the log rule, changes the answer — so + * the positive assertions cannot be satisfied by a constant. + * 9. Bounds and fail-soft: the candidate cap and the per-log event budget + * spare rather than delete, and a throwing dependency cannot abort the + * round. + * 10. The reported partition covers the index exactly once, so nothing is + * silently dropped. + * 11. The real sweep removes exactly the collected ids, leaves the index + * untouched (listing GC converges it) and forgets the session's + * `last-used` / agent-view / resume notes. + * 12. A refused or throwing delete is reported and leaves the log in place. + * 13. Layer ③ reads each candidate's OWN header (from its log's first frame): + * `origin:'subagent'` and `delegationDepth > 0` spare a run the listing + * cache never saw, a fork whose listed ancestor is delegated is spared as + * a descendant, and a plain fork of a non-delegated parent is left to + * layer ② (which sees the inherited conversation in the fork's own log). + * The pre-fix listing-only rule is re-run as the negative control: it + * really does collect the header-delegated shell (F-03's minimum case). + * 14. A session another live process holds is spared with `held-elsewhere` — + * driven both through the injected seam and through the real + * `session-mounts.json` ledger and `sweepUnspokenOnExit`'s own wiring. + * + * Run: node --import tsx/esm scripts/verify-unspoken-session-sweep.tsx + * The sessions root, the DSH home and `~/.dsh-tui` are ALL redirected under + * one disposable mkdtemp directory before the module under test is imported. + */ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib' +// Type-only, so it is erased: the value import below must stay AFTER the env +// override (DATA_DIR and sessionsRoots() read the environment at import time). +import type { UnspokenSweepDeps } from '../src/dsh-adapter/unspoken-sessions.js' + +const root = mkdtempSync(join(tmpdir(), 'dsh-tui-sweep-')) +// Registered BEFORE the imports below: a missing or broken module under test +// throws out of the top-level `await import`, which the `finally` at the +// bottom would never reach — and a regression that litters the temp directory +// every time it is red is a regression nobody runs. +process.on('exit', () => { + rmSync(root, { recursive: true, force: true }) +}) +process.env.HOME = root +process.env.USERPROFILE = root +process.env.DSH_HOME = join(root, 'dsh') +process.env.DSH_TUI_SESSION_ROOT = join(root, 'logs') + +const dataDir = join(root, '.dsh-tui') +const indexFile = join(dataDir, 'session-index.json') +const sessionsRoot = join(root, 'logs') +const workspaceDir = join(sessionsRoot, 'enc-workspace') +const resumeFile = join(dataDir, 'resume.txt') +const lastUsedFile = join(dataDir, 'last-used.json') +const agentViewFile = join(dataDir, 'agent-view-sessions.json') + +// Import AFTER the env override: DATA_DIR (utils/paths) is a module-level +// constant and sessionsRoots() (compat/sessionLog) prefers +// DSH_TUI_SESSION_ROOT, so both must see the temporary root from the start. +const { collectUnspokenSessionIds, delegatedSessionIds, readSessionHeaderFromLog, sweepUnspokenSessions, unspokenJudges } = + await import('../src/dsh-adapter/unspoken-sessions.js') +const { readIndex } = await import('../src/dsh-adapter/sessions/store.js') +const { readResumeTarget } = await import('../src/sessionHistory.js') + +let cases = 0 +let checks = 0 +let failures = 0 + +/** Count one assertion and label its failure with what it was proving. */ +function check(label: string, body: () => void): void { + checks += 1 + try { + body() + } catch (error) { + throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function test(name: string, run: () => void): void { + cases += 1 + try { + run() + console.log(`PASS ${name}`) + } catch (error) { + failures += 1 + console.error(`FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/* ------------------------------------------------------------------ * + * Fixtures: real artifacts, real index, all under the temporary root. + * ------------------------------------------------------------------ */ + +/** One JSON line per zstd frame is the shipping layout; frames are written whole. */ +const frame = (rows: readonly unknown[]): Buffer => + zstdCompressSync(Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n')) + +const header = (id: string): Record => ({ type: 'session', version: 4, id, createdAt: 1, cwd: join(root, 'project') }) +/** The only durable trace a session that booted and was never spoken to leaves. */ +const boot: Record = { type: 'sandbox/mode', seq: 0, time: 0, data: { mode: 'workspace-write' } } +const turnStart: Record = { type: 'turn/start', seq: 4, time: 4, data: {} } +const human = (source?: unknown): Record => ({ + type: 'user/message', + seq: 1, + time: 1, + data: { content: [{ type: 'text', text: 'typed by a person' }], ...(source === undefined ? {} : { source }) }, +}) +const injected = (kind: string): Record => ({ + type: 'user/message', + seq: 1, + time: 1, + data: { content: [{ type: 'text', text: 'system payload' }], source: { kind } }, +}) +const spliced = (messages: readonly unknown[]): Record => ({ + type: 'agent/inbox/spliced', + seq: 2, + time: 2, + data: { inserted: messages }, +}) + +/** A checksum-protected frame with a flipped final byte: decode throws. */ +const corruptFrame = zstdCompressSync(Buffer.from('{"type":"plugin/noise"}\n'), { + params: { [constants.ZSTD_c_checksumFlag]: 1 }, +}) +corruptFrame[corruptFrame.length - 1] ^= 0xff + +const logDir = (id: string): string => join(workspaceDir, id) +const logFile = (id: string): string => join(logDir(id), 'session.v4.jsonl.zstd') + +function writeLog(id: string, rows: readonly unknown[], extraFrames: readonly Buffer[] = []): void { + mkdirSync(logDir(id), { recursive: true }) + writeFileSync(logFile(id), Buffer.concat([frame([header(id), boot, ...rows]), ...extraFrames])) +} + +/** + * The same layout with extra PHYSICAL header fields (`origin`, + * `delegationDepth`, `parentSession`) — the fields dsh-session persists on the + * log's first line and the only place a candidate's own lineage can be read + * from (`sessions/header.ts:102-113`). + */ +function writeLogWithHeader(id: string, fields: Record, rows: readonly unknown[] = []): void { + mkdirSync(logDir(id), { recursive: true }) + writeFileSync(logFile(id), frame([{ ...header(id), ...fields }, boot, ...rows])) +} + +interface IndexRow { + /** Omitted means "no `derived`" — the unknown branch of layer ①. */ + readonly hasPrompt?: boolean + readonly branch?: string +} + +/** Write the REAL index file (`~/.dsh-tui/session-index.json`, schema v4). */ +function writeIndexFixture(rows: Readonly>, marker: string): void { + const entries: Record = {} + for (const [id, row] of Object.entries(rows)) { + entries[id] = { + ...(row.branch === undefined ? {} : { branch: row.branch }), + ...(row.hasPrompt === undefined ? {} : { + derived: { + revision: `rev-${id}`, + bytes: 240, + modifiedAt: 1_700_000_000_000, + anchor: `anchor-${id}`, + title: `title-${id}`, + titleSource: 'fallback', + titleComplete: true, + hasPrompt: row.hasPrompt, + }, + }), + } + } + rmSync(indexFile, { force: true }) + mkdirSync(dataDir, { recursive: true }) + writeFileSync(indexFile, JSON.stringify({ version: 4, entries })) + // A stale module-level cache would make every later assertion meaningless, + // so prove the fixture actually reached the reader before asserting on it. + assert.equal(readIndex().has(marker), true, `fixture index did not reach readIndex (${marker})`) +} + +function buildTree(index: Readonly>, logs: Readonly>): void { + rmSync(sessionsRoot, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + mkdirSync(workspaceDir, { recursive: true }) + const marker = Object.keys(index)[0] + assert.ok(marker !== undefined, 'fixture index must not be empty') + writeIndexFixture(index, marker) + for (const [id, rows] of Object.entries(logs)) writeLog(id, rows) +} + +const reasonOf = (result: { skipped: readonly { id: string, reason: string }[] }, id: string): string | undefined => + result.skipped.find(entry => entry.id === id)?.reason + +/* ------------------------------------------------------------------ * + * Shared tree: one entry per layer, every branch of every layer. + * ------------------------------------------------------------------ */ + +const TREE_INDEX: Record = { + 'shell-legacy': { hasPrompt: false, branch: 'an-old-branch' }, + 'shell-fresh': { hasPrompt: false }, + 'shell-unknown': {}, + 'shell-prompted': { hasPrompt: true }, + 'spoke-first': { hasPrompt: false }, + 'spoke-sourceless': { hasPrompt: false }, + 'spliced-human': { hasPrompt: false }, + 'turned': { hasPrompt: false }, + 'subagent-run': { hasPrompt: false }, + 'subagent-child': { hasPrompt: false }, + 'bound-session': { hasPrompt: false }, + 'live-background': { hasPrompt: false }, + 'corrupt-frame': { hasPrompt: false }, + 'dangling-index': { hasPrompt: false }, + 'malformed-user': { hasPrompt: false }, + 'bulk-shell': { hasPrompt: false }, + 'injected-only': { hasPrompt: false }, + 'long-ago-shell': { hasPrompt: false }, +} + +const TREE_LOGS: Record = { + 'shell-legacy': [], + 'shell-fresh': [], + 'shell-unknown': [], + 'shell-prompted': [], + 'spoke-first': [human({ kind: 'user' })], + 'spoke-sourceless': [human()], + 'spliced-human': [spliced([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])], + 'turned': [turnStart], + 'subagent-run': [], + 'subagent-child': [], + 'bound-session': [], + 'live-background': [], + 'corrupt-frame': [], + 'malformed-user': [{ type: 'user/message', seq: 1, time: 1 }], + // A shell that booted many times: still no turn, still no human message. + 'bulk-shell': Array.from({ length: 40 }, (_, index) => ({ type: 'plugin/noise', seq: 100 + index, time: index, data: {} })), + // The realistic boot-only log shape: user-ROLE messages that no person wrote. + 'injected-only': [injected('system-prompt'), spliced([{ role: 'user', source: { kind: 'subagent-settled' }, content: [] }])], + 'long-ago-shell': [], +} + +const DEPENDENT_IDS = new Set(['subagent-run', 'subagent-child']) +/** The facts only the assembler knows (T05 wires these from the live ctx). */ +const treeDeps = () => ({ + currentSessionId: () => 'bound-session', + liveSessionIds: () => new Set(['live-background']), + isSubagentOrDescendant: (id: string) => DEPENDENT_IDS.has(id), +}) + +function buildSharedTree(): void { + buildTree(TREE_INDEX, TREE_LOGS) + // The corrupt log needs a hand-built byte tail, so it is finished here. + writeFileSync(logFile('corrupt-frame'), Buffer.concat([frame([header('corrupt-frame'), boot]), corruptFrame])) +} + +// Loaded once, right before the cases that need the exit helper and the mount +// ledger: plugin.ts drags in the renderer, and this regression is the fast, +// focused one for the sweep module. +const { sweepUnspokenOnExit, exitListingGap } = await import('../src/dsh-adapter/plugin.js') +const { readMountLedgerStrict, readSessionOwners } = await import('../src/sessionMounts.js') + +try { + buildSharedTree() + + test('1. boot-only shells are collected, including a pre-existing historical entry', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('exactly the five shells nobody spoke in are deletion candidates', () => { + assert.deepEqual(result.ids, ['bulk-shell', 'injected-only', 'long-ago-shell', 'shell-fresh', 'shell-legacy']) + }) + check('the historical entry kept its branch note and was still collected', () => { + assert.equal(readIndex().get('shell-legacy')?.branch, 'an-old-branch') + assert.ok(result.ids.includes('shell-legacy')) + }) + check('a long-lived but still wordless shell is not excluded by size or age', () => { + assert.ok(result.ids.includes('bulk-shell')) + assert.ok(result.ids.includes('long-ago-shell')) + }) + }) + + test('2. a human message without turn/start is preserved (the host blank rule would delete it)', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('an explicit human source spares the session', () => { + assert.equal(reasonOf(result, 'spoke-first'), 'human-message') + }) + check('a missing source counts as human, matching digest.ts', () => { + assert.equal(reasonOf(result, 'spoke-sourceless'), 'human-message') + }) + check('spared sessions stay on disk', () => { + assert.ok(existsSync(logDir('spoke-first'))) + assert.ok(!result.ids.includes('spoke-first')) + assert.ok(!result.ids.includes('spoke-sourceless')) + }) + }) + + test('3. a turn/start is preserved', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('turn/start is the second log-layer verdict', () => { + assert.equal(reasonOf(result, 'turned'), 'turn-start') + assert.ok(existsSync(logDir('turned'))) + }) + }) + + test('4. delegated runs and their descendants are preserved', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('a sub-agent run is spared by the held layer', () => { + assert.equal(reasonOf(result, 'subagent-run'), 'subagent') + }) + check('a descendant of a delegated run is spared too', () => { + assert.equal(reasonOf(result, 'subagent-child'), 'subagent') + }) + check('lineage is derived from real parent links, not from the id', () => { + assert.deepEqual( + [...delegatedSessionIds([ + { id: 'root' }, + { id: 'fork-of-root', parent: 'root' }, + { id: 'delegated', delegated: true }, + { id: 'child-of-delegated', parent: 'delegated' }, + { id: 'fork-of-child', parent: 'child-of-delegated' }, + ])].sort(), + ['child-of-delegated', 'delegated', 'fork-of-child'], + ) + }) + check('a fork of a root session is not delegated', () => { + assert.equal(delegatedSessionIds([{ id: 'root' }, { id: 'fork-of-root', parent: 'root' }]).has('fork-of-root'), false) + }) + }) + + test('5. sessions this process holds are preserved', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('the bound session is spared', () => { + assert.equal(reasonOf(result, 'bound-session'), 'current-session') + }) + check('a live background session is spared', () => { + assert.equal(reasonOf(result, 'live-background'), 'live-session') + }) + }) + + test('6. absent, dangling, corrupt and truncated logs are skipped, never deleted', () => { + const result = collectUnspokenSessionIds({ ...treeDeps(), maxEventsPerLog: 16 }) + check('the fixture corruption really is undecodable', () => { + assert.throws(() => zstdDecompressSync(corruptFrame)) + }) + check('a corrupt log reports the log layer, not a delete', () => { + assert.equal(reasonOf(result, 'corrupt-frame'), 'log-unreadable') + assert.ok(existsSync(logDir('corrupt-frame'))) + }) + check('an index entry with no artifact is a dangling entry', () => { + assert.equal(reasonOf(result, 'dangling-index'), 'log-absent') + }) + check('a log past the event budget cannot prove emptiness', () => { + assert.equal(reasonOf(result, 'bulk-shell'), 'log-incomplete') + assert.ok(!result.ids.includes('bulk-shell')) + }) + check('an unreadable user payload is evidence, not absence of evidence', () => { + assert.equal(reasonOf(result, 'malformed-user'), 'human-message') + }) + check('system-role user messages are not human and do not spare a shell', () => { + assert.ok(result.ids.includes('injected-only')) + assert.ok(!result.ids.includes('corrupt-frame')) + }) + }) + + test('7. adversarial: index says hasPrompt:false while the log holds a human message', () => { + const index = readIndex() + check('the fixture really does disagree with the log (otherwise this case proves nothing)', () => { + assert.equal(index.get('spoke-first')?.derived?.hasPrompt, false) + assert.equal(index.get('spliced-human')?.derived?.hasPrompt, false) + }) + const result = collectUnspokenSessionIds(treeDeps()) + check('the log layer overrides a stale index for a plain human message', () => { + assert.equal(reasonOf(result, 'spoke-first'), 'human-message') + }) + check('the splice arm of digest.ts spares an inbox-spliced human message', () => { + assert.equal(reasonOf(result, 'spliced-human'), 'human-message') + assert.ok(existsSync(logDir('spliced-human'))) + }) + check('neither adversarial id is ever a deletion candidate', () => { + assert.ok(!result.ids.includes('spoke-first')) + assert.ok(!result.ids.includes('spliced-human')) + }) + }) + + test('8. negative control: reversed rules change the answer on the same input', () => { + const deps = treeDeps() + const positive = collectUnspokenSessionIds(deps) + const alwaysDelete = collectUnspokenSessionIds(deps, { + index: () => undefined, + log: () => undefined, + held: () => undefined, + }) + const neverDelete = collectUnspokenSessionIds(deps, { + index: () => 'index-has-prompt', + log: () => 'log-absent', + held: () => 'current-session', + }) + const logBlind = collectUnspokenSessionIds(deps, { + index: (entry) => entry?.derived === undefined ? 'index-unknown' : entry.derived.hasPrompt ? 'index-has-prompt' : undefined, + log: () => undefined, + held: (id) => id === deps.currentSessionId() ? 'current-session' : deps.liveSessionIds().has(id) ? 'live-session' : deps.isSubagentOrDescendant(id) ? 'subagent' : undefined, + }) + check('the positive answer is non-empty, so the control has something to miss', () => { + assert.ok(positive.ids.length > 0) + }) + check('always-delete reaches exactly the sessions the positive run spares', () => { + assert.notDeepEqual(alwaysDelete.ids, positive.ids) + for (const id of ['spoke-first', 'spliced-human', 'turned', 'subagent-run', 'bound-session', 'corrupt-frame', 'dangling-index']) { + assert.ok(alwaysDelete.ids.includes(id), `always-delete did not reach ${id}`) + assert.ok(!positive.ids.includes(id), `${id} must never be a positive candidate`) + } + assert.equal(alwaysDelete.skipped.some(entry => entry.reason === 'human-message'), false) + }) + check('never-delete misses every shell the positive run collects', () => { + assert.deepEqual(neverDelete.ids, []) + assert.notDeepEqual(neverDelete.ids, positive.ids) + for (const id of positive.ids) assert.ok(!neverDelete.ids.includes(id)) + }) + check('dropping only the log layer deletes sessions with conversation evidence', () => { + assert.notDeepEqual(logBlind.ids, positive.ids) + assert.ok(logBlind.ids.includes('spoke-first')) + assert.ok(logBlind.ids.includes('turned')) + assert.ok(!positive.ids.includes('spoke-first')) + }) + check('the positive run keeps every layer doing work', () => { + const reasons = new Set(positive.skipped.map(entry => entry.reason)) + for (const reason of ['index-has-prompt', 'index-unknown', 'human-message', 'turn-start', 'subagent', 'current-session', 'live-session', 'log-absent', 'log-unreadable']) { + assert.ok(reasons.has(reason), `no candidate hit ${reason}`) + } + }) + }) + + test('9. the round is bounded and fail-soft', () => { + const capped = collectUnspokenSessionIds({ ...treeDeps(), maxCandidates: 1 }) + check('candidates past the cap are spared and reported', () => { + assert.deepEqual(capped.ids, []) + assert.equal(reasonOf(capped, 'shell-fresh'), 'candidate-cap') + assert.equal(reasonOf(capped, 'shell-legacy'), 'candidate-cap') + }) + check('the same log is a candidate once the event budget covers it', () => { + const wide = collectUnspokenSessionIds({ ...treeDeps(), maxEventsPerLog: 1024 }) + assert.ok(wide.ids.includes('bulk-shell')) + }) + const throwing = collectUnspokenSessionIds({ + ...treeDeps(), + readLog: () => { throw new Error('fixture read failure') }, + }) + check('a throwing log read cannot abort the round', () => { + assert.deepEqual(throwing.ids, []) + assert.equal(reasonOf(throwing, 'shell-fresh'), 'unexpected-error') + assert.equal(reasonOf(throwing, 'spoke-first'), 'unexpected-error') + }) + const throwingHeld = collectUnspokenSessionIds({ + ...treeDeps(), + currentSessionId: () => { throw new Error('fixture binding failure') }, + }) + check('a throwing process-layer dependency cannot abort the round either', () => { + assert.deepEqual(throwingHeld.ids, []) + assert.equal(reasonOf(throwingHeld, 'shell-fresh'), 'unexpected-error') + }) + const throwingHeader = collectUnspokenSessionIds({ + ...treeDeps(), + readSessionHeader: () => { throw new Error('fixture header failure') }, + }) + check('a throwing header read is one candidate\'s problem, never the round\'s', () => { + assert.deepEqual(throwingHeader.ids, []) + assert.equal(reasonOf(throwingHeader, 'shell-fresh'), 'unexpected-error') + }) + }) + + test('10. every index entry is either collected or reported with one reason', () => { + const result = collectUnspokenSessionIds(treeDeps()) + check('collected plus skipped covers the index exactly once', () => { + const seen = [...result.ids, ...result.skipped.map(entry => entry.id)] + assert.equal(new Set(seen).size, seen.length, 'an id was reported twice') + assert.deepEqual([...seen].sort(), Object.keys(TREE_INDEX).sort()) + }) + check('an entry with no derived record is unknown, therefore spared', () => { + assert.equal(reasonOf(result, 'shell-unknown'), 'index-unknown') + }) + check('an entry the index calls prompted is spared without reading its log', () => { + assert.equal(reasonOf(result, 'shell-prompted'), 'index-has-prompt') + }) + }) + + test('11. the real sweep deletes exactly the collected ids and leaves the index alone', () => { + rmSync(sessionsRoot, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + mkdirSync(workspaceDir, { recursive: true }) + writeIndexFixture({ 'wipe-shell': { hasPrompt: false }, 'keep-spoke': { hasPrompt: false } }, 'wipe-shell') + writeLog('wipe-shell', []) + writeLog('keep-spoke', [human({ kind: 'user' })]) + writeFileSync(lastUsedFile, JSON.stringify({ 'wipe-shell': 1, 'keep-spoke': 2 })) + writeFileSync(agentViewFile, JSON.stringify({ 'wipe-shell': 1, 'keep-spoke': 2 })) + writeFileSync(resumeFile, 'wipe-shell') + const indexBefore = readFileSync(indexFile, 'utf8') + + const result = sweepUnspokenSessions(treeDeps()) + + check('only the unspoken shell was deleted', () => { + assert.deepEqual(result.deleted, ['wipe-shell']) + }) + check('the log directory is gone and the spoken one survives', () => { + assert.equal(existsSync(logDir('wipe-shell')), false) + assert.equal(existsSync(logDir('keep-spoke')), true) + }) + check('the index is not rewritten (the listing GC converges it)', () => { + assert.equal(readFileSync(indexFile, 'utf8'), indexBefore) + assert.equal(readIndex().has('wipe-shell'), true) + }) + check('the per-session notes follow the deleted session only', () => { + assert.deepEqual(JSON.parse(readFileSync(lastUsedFile, 'utf8')), { 'keep-spoke': 2 }) + assert.deepEqual(JSON.parse(readFileSync(agentViewFile, 'utf8')), { 'keep-spoke': 2 }) + assert.equal(readResumeTarget(), undefined) + }) + check('a second round reports the deleted shell as absent instead of failing', () => { + const again = sweepUnspokenSessions(treeDeps()) + assert.deepEqual(again.deleted, []) + assert.equal(reasonOf(again, 'wipe-shell'), 'log-absent') + }) + }) + + test('12. a refused delete is reported and never removes the log', () => { + rmSync(sessionsRoot, { recursive: true, force: true }) + rmSync(dataDir, { recursive: true, force: true }) + mkdirSync(workspaceDir, { recursive: true }) + writeIndexFixture({ 'refused-shell': { hasPrompt: false }, 'throwing-delete': { hasPrompt: false } }, 'refused-shell') + writeLog('refused-shell', []) + writeLog('throwing-delete', []) + const result = sweepUnspokenSessions({ + ...treeDeps(), + deleteLog: (id: string) => { + if (id === 'throwing-delete') throw new Error('fixture delete failure') + return 'unavailable' + }, + }) + check('a declined delete keeps the directory and is counted', () => { + assert.deepEqual(result.deleted, []) + assert.equal(reasonOf(result, 'refused-shell'), 'delete-unavailable') + assert.ok(existsSync(logDir('refused-shell'))) + }) + check('a throwing delete is downgraded to the same unavailable outcome', () => { + assert.equal(reasonOf(result, 'throwing-delete'), 'delete-unavailable') + assert.ok(existsSync(logDir('throwing-delete'))) + }) + check('an injected forget hook cannot break the round', () => { + const noisy = sweepUnspokenSessions({ + ...treeDeps(), + deleteLog: () => 'deleted', + forgetState: () => { throw new Error('fixture forget failure') }, + }) + assert.deepEqual(noisy.deleted, ['refused-shell', 'throwing-delete']) + }) + }) + test('13. a candidate\'s own header decides delegation, not the listing cache', () => { + // The listing cache is a BOOT-time snapshot (F-03). A delegated run created + // after boot is in the index but not in that listing, so a rule that reads + // only the listing would hand it to the delete primitive. This tree is + // exactly that shape: one session the listing knows about (`list-delegated`) + // and five it does not. + const headerIndex: Record = { + 'sub-header-only': { hasPrompt: false }, + 'deep-header': { hasPrompt: false }, + 'fork-of-delegated': { hasPrompt: false }, + 'fork-of-header-delegated': { hasPrompt: false }, + 'fork-of-root': { hasPrompt: false }, + 'fork-inherited-turn': { hasPrompt: false }, + 'plain-encoding-shell': { hasPrompt: false }, + } + buildTree(headerIndex, {}) + // The header is the ONLY source for these rows; the listing below is stale + // by construction and names an id that is not even in the index. + writeLogWithHeader('sub-header-only', { origin: 'subagent', delegationDepth: 1 }) + writeLogWithHeader('deep-header', { delegationDepth: 2 }) + writeLogWithHeader('fork-of-delegated', { parentSession: 'list-delegated' }) + // One hop further: the ancestor is delegated by ITS OWN header and is in no + // listing, which is exactly the post-boot case this layer exists for. + writeLogWithHeader('fork-of-header-delegated', { parentSession: 'sub-header-only' }) + writeLogWithHeader('fork-of-root', { parentSession: 'list-root' }) + // A fork's own log carries the inherited prefix, so layer ② sees whatever + // the ancestor already said — this is the evidence the fork rule rests on. + writeLogWithHeader('fork-inherited-turn', { parentSession: 'list-root' }, [turnStart]) + // A `compression:"none"` backend writes a PLAIN `session.jsonl`, which the + // header reader's lookup deliberately does not reach — and neither does the + // delete primitive's. This is the case that makes "an unreadable header adds + // no protection" safe: unknown here still cannot remove anything. + mkdirSync(logDir('plain-encoding-shell'), { recursive: true }) + writeFileSync( + join(logDir('plain-encoding-shell'), 'session.jsonl'), + [header('plain-encoding-shell'), boot].map(row => JSON.stringify(row)).join('\n') + '\n', + ) + + check('the shipping header reader reads the first physical frame, and only claims what is there', () => { + assert.equal(readSessionHeaderFromLog('sub-header-only')?.origin, 'subagent') + assert.equal(readSessionHeaderFromLog('deep-header')?.delegationDepth, 2) + assert.equal(readSessionHeaderFromLog('fork-of-root')?.parentSession, 'list-root') + assert.equal(readSessionHeaderFromLog('fork-of-root')?.origin, undefined) + assert.equal(readSessionHeaderFromLog('no-such-log-at-all'), undefined, 'no log is "unknown", never "root"') + }) + + const listed = new Set(['list-delegated']) + const headerDeps = () => ({ + currentSessionId: () => 'bound-session', + liveSessionIds: () => new Set(), + isSubagentOrDescendant: (id: string) => listed.has(id), + }) + const result = collectUnspokenSessionIds(headerDeps()) + + check('a run whose own header says origin:subagent is spared (the boot-time listing never saw it)', () => { + assert.equal(reasonOf(result, 'sub-header-only'), 'subagent') + assert.ok(!result.ids.includes('sub-header-only')) + assert.ok(existsSync(logDir('sub-header-only'))) + }) + check('a nonzero delegationDepth alone is delegation too (upstream keeps it optional)', () => { + assert.equal(reasonOf(result, 'deep-header'), 'subagent') + assert.ok(!result.ids.includes('deep-header')) + }) + check('a fork of a listed delegated run is spared as a descendant', () => { + assert.equal(reasonOf(result, 'fork-of-delegated'), 'subagent') + }) + check('a fork whose ancestor is delegated by its OWN header is spared too', () => { + assert.equal(reasonOf(result, 'fork-of-header-delegated'), 'subagent') + assert.ok(!result.ids.includes('fork-of-header-delegated')) + }) + check('a plain fork of a non-delegated parent is NOT spared by the header rule', () => { + assert.ok(result.ids.includes('fork-of-root'), 'the listing decides forks; the header only adds delegation') + }) + check('a fork whose inherited log carries a turn/start is spared by the log layer', () => { + assert.equal(reasonOf(result, 'fork-inherited-turn'), 'turn-start') + assert.ok(!result.ids.includes('fork-inherited-turn')) + }) + + // The negative control that makes the five assertions above mean something: + // the shipped pre-F-03 rule (listing cache only) really does collect the + // header-delegated shells — R-A's `sub-live` handed to the delete primitive. + const shipping = unspokenJudges(headerDeps()) + const listingOnly = collectUnspokenSessionIds(headerDeps(), { + index: shipping.index, + log: shipping.log, + held: id => headerDeps().isSubagentOrDescendant(id) ? 'subagent' : undefined, + }) + check('the pre-fix listing-only rule collects the header-delegated shells (this case can go red)', () => { + assert.ok(listingOnly.ids.includes('sub-header-only')) + assert.ok(listingOnly.ids.includes('deep-header')) + assert.ok(listingOnly.ids.includes('fork-of-header-delegated')) + assert.ok(!result.ids.includes('sub-header-only'), 'and the shipped rule must not') + }) + + check('an unreadable header is UNKNOWN (not "a root conversation"), and unknown cannot delete', () => { + assert.equal(readSessionHeaderFromLog('plain-encoding-shell'), undefined) + assert.ok(result.ids.includes('plain-encoding-shell'), 'with no header and no listing verdict it is a candidate') + const swept = sweepUnspokenSessions(headerDeps()) + assert.equal(reasonOf(swept, 'plain-encoding-shell'), 'delete-unavailable') + assert.equal(existsSync(join(logDir('plain-encoding-shell'), 'session.jsonl')), true) + }) + }) + + test('14. a session another live process holds is never swept', () => { + const ledgerFile = join(dataDir, 'session-mounts.json') + const foreignPid = process.ppid + + buildTree({ 'held-elsewhere': { hasPrompt: false }, 'plain-shell': { hasPrompt: false } }, {}) + writeLog('held-elsewhere', []) + writeLog('plain-shell', []) + // The real ledger document (`publishMounts` writes version 1 + owners). The + // version is module-private, so it is pinned by the reader check below: a + // bump fails loudly there instead of silently disarming this case. + writeFileSync(ledgerFile, JSON.stringify({ + version: 1, + owners: [ + { pid: foreignPid, startedAt: 1, sessionIds: ['held-elsewhere'] }, + { pid: process.pid, startedAt: 0, sessionIds: ['plain-shell'] }, + ], + })) + check('the fixture ledger really reaches readSessionOwners (a foreign live holder and this process)', () => { + const read = readMountLedgerStrict() + if (!read.ok) assert.fail(`the fixture ledger must parse: ${read.detail}`) + const owners = readSessionOwners() + assert.equal(owners.get('held-elsewhere')?.pid, foreignPid, 'the foreign holder must survive the live-pid filter') + assert.equal(owners.get('plain-shell')?.pid, process.pid) + }) + + const occupiedElsewhere = (): ReadonlySet => new Set(['held-elsewhere']) + const result = collectUnspokenSessionIds({ ...treeDeps(), occupiedElsewhere }) + check('a foreign holder is spared and reported with its own reason', () => { + assert.equal(reasonOf(result, 'held-elsewhere'), 'held-elsewhere') + assert.ok(!result.ids.includes('held-elsewhere')) + }) + check('the same fixture with NO ledger fact is a deletion candidate (this case can go red)', () => { + const withoutLedger = collectUnspokenSessionIds({ ...treeDeps(), occupiedElsewhere: () => new Set() }) + assert.ok(withoutLedger.ids.includes('held-elsewhere'), 'an empty ledger is the pre-F-04 answer') + }) + check('a session only this process holds is not in the foreign set (the live/bound layer covers it)', () => { + assert.ok(result.ids.includes('plain-shell'), 'nothing claims it, so it stays a candidate') + assert.equal(occupiedElsewhere().has('plain-shell'), false) + }) + check('this process\'s own facts are reported before the foreign one', () => { + const both = collectUnspokenSessionIds({ + ...treeDeps(), + liveSessionIds: () => new Set(['held-elsewhere']), + occupiedElsewhere, + }) + assert.equal(reasonOf(both, 'held-elsewhere'), 'live-session') + }) + + const swept = sweepUnspokenSessions({ ...treeDeps(), occupiedElsewhere }) + check('the real sweep never touches the foreign holder\'s log', () => { + assert.deepEqual(swept.deleted, ['plain-shell']) + assert.equal(existsSync(logDir('held-elsewhere')), true) + assert.equal(existsSync(logDir('plain-shell')), false) + }) + + // The production wiring, behaviorally: `sweepUnspokenOnExit` builds the set + // from the REAL ledger and keeps only the records another process wrote. + let captured: UnspokenSweepDeps | undefined + writeLog('plain-shell', []) + sweepUnspokenOnExit({ + currentSessionId: () => 'cur', + liveSessionIds: () => new Set(), + listedSessions: () => [{ id: 'list-root' }], + sweep: deps => { + captured = deps + return { deleted: [], skipped: [] } + }, + }) + check('the exit sweep wires the cross-process ledger in, filtered to foreign holders', () => { + const foreign = captured?.occupiedElsewhere?.() + assert.notEqual(foreign, undefined, 'sweepUnspokenOnExit must pass the ledger seam (F-04)') + assert.equal(foreign?.has('held-elsewhere'), true, 'a foreign holder must be in the spared set') + assert.equal(foreign?.has('plain-shell'), false, 'our own record is not a foreign holder') + }) + + check('the exit path can say WHY it had no listing (F-13: "no source" is not "no listing")', () => { + const gap = exitListingGap({ cachedSessions: () => [] }) + const none = exitListingGap({ cachedSessions: () => undefined }) + const missing = exitListingGap({}) + const threw = exitListingGap({ cachedSessions: () => { throw new Error('cache boom') } }) + assert.equal(gap, 'listed', 'a readable listing is reported as read, not as a gap') + assert.match(none, /no listing/u) + assert.match(missing, /no source/u) + assert.match(threw, /read failed/u) + assert.equal(new Set([none, missing, threw]).size, 3, 'the three ways to lose the listing must read differently') + }) + }) +} finally { + rmSync(root, { recursive: true, force: true }) +} + +console.log(`\n${cases - failures}/${cases} cases passed, ${checks} checks`) +if (failures > 0) { + console.error(`${failures} unspoken-session sweep regressions failed`) + process.exit(1) +} +console.log('unspoken-session sweep regression: OK') diff --git a/src/dsh-adapter/activity-store.ts b/src/dsh-adapter/activity-store.ts index 56646b997..72feedfa1 100644 --- a/src/dsh-adapter/activity-store.ts +++ b/src/dsh-adapter/activity-store.ts @@ -61,6 +61,25 @@ export interface ActivityView { readonly lang: 'zh' | 'en' } +/** + * One projection unit as the host registry accepts it. + * + * The state and event types belong to whichever module owns the projection's + * semantics; this seam only names what `register` hands over, so a module that + * reads projections never has to learn a writer's shape. `apply` must return + * the SAME reference when an event changes nothing — the host compares by value + * to decide whether a unit has to be re-published. + */ +export interface ProjectionRegistrationLike { + readonly key: string + readonly stateVersion: number + readonly stateSchema: { parse(value: unknown): unknown } + init(...args: readonly unknown[]): unknown + // `never` parameters: a concrete definition accepts its own state and event + // types, which are narrower than anything this seam could name. + apply(state: never, event: never): unknown +} + /** The slice of the host projection registry this module uses. */ export interface ProjectionRegistryLike { onChanged(listener: ( @@ -70,6 +89,12 @@ export interface ProjectionRegistryLike { seq: number, ) => void): () => void snapshot(session: unknown, keys?: readonly string[]): { readonly values: Record } + /** + * Register one projection unit for this composition's lifetime, returning the + * early release. Optional on purpose: the registration seam is a host-line + * capability, and a host line without it must degrade instead of failing here. + */ + register?(definition: ProjectionRegistrationLike): () => void } /** diff --git a/src/dsh-adapter/channel/background-action.ts b/src/dsh-adapter/channel/background-action.ts index 8d54d001b..ebba7dcb0 100644 --- a/src/dsh-adapter/channel/background-action.ts +++ b/src/dsh-adapter/channel/background-action.ts @@ -10,6 +10,7 @@ import { t } from '../../i18n.js' import { reserveMount, type MountReservation } from '../../sessionMounts.js' import { mountFailureText } from '../../sessions/resumeFailure.js' import { composePreset } from '../presets.js' +import { createFreshAgent } from '../fresh-agent.js' import { createDshSession, dshHandleOf } from '../backend/session.js' import { attachSessionToWorkspace } from '../workspace.js' import { resetSessionProjection } from './session-reset.js' @@ -69,7 +70,11 @@ export function createBackgroundCurrentAction( resolveModelRoute({ provider: options.configuredProvider, model: options.configuredModel }, readModelPref(), { provider: options.provider, model: options.model }), { provider: options.provider, model: options.model }, ) - const candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await agents.create({ + // `/bg` starts an unseeded session nobody has typed into yet, so it + // shares the fresh-session gate: creation-time checkpoints (the + // projection cache flushes on `session/created`) must not publish the + // permission-only shell before the first real event. + const candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await createFreshAgent(ctx, agents, { sessionId, meta: { cwd: state.cwd, ...(composed.agentPreset === undefined ? {} : { agentPreset: composed.agentPreset }) }, agentOptions: route.route, diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index 88026d256..70217e24b 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -8,8 +8,10 @@ import { WORKING_GATE_NOTICES } from '../../commands.js' import { writeModelPref } from '../../modelPrefs.js' import { touchSession } from '../../sessionHistory.js' import { createDshSession, dshHandleOf } from '../backend/session.js' -import { liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' +import { liveSessionCreateOptions, sliceLiveSessionSeed, snapshotLiveSessionEvents } from '../compat/index.js' +import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' +import { holdsNoConversation, latestPolicyFacts, replayPolicyFacts } from '../unspoken-sessions.js' import { reserveNewSession } from '../../sessionMounts.js' import { attachSessionToWorkspace } from '../workspace.js' import type { DshChannelBinding } from './binding.js' @@ -50,37 +52,86 @@ export function createModelSwitchAction( if (state.working) { deps.notify(t(WORKING_GATE_NOTICES.model), { color: 'warning' }); return false } const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise } | undefined if (agents === undefined) { deps.notify(t('model-switch-unavailable'), { color: 'error' }); return false } + const source = deps.binding.agent.session + // The cut is what the child inherits, so the cut is what the verdict asks + // about — never the session it was cut from. A `/model` cut is the whole + // source log, but the same verdict serves the actions whose cut can stop + // short of the conversation (`/rewind`, `/tree`): judging the source there + // would say "this one holds a conversation" while the prefix on offer holds + // the initialization alone. A seed would copy that prefix, and the host + // stores every seed at publication (agent-loop `appendUnstoredSuffix`), so + // the replacement's log would exist before its first real event — the + // permission-only shell the fresh-session deferral keeps out of JSONL. The + // evidence is read from the slice already in hand (in memory, never a + // second read of the log), so a shell left behind by an earlier process + // counts exactly like a local one; the fresh verdict answers first, so a + // deferred session is not even sliced. The replacement therefore starts + // unseeded, as an ordinary fresh session, under that same deferral. let seed: readonly SessionEvent[] + // The cut: what the child would inherit — here the whole settled source + // log. It is read even when the verdict below empties the seed, because the + // cut is also where the session's POLICY FACTS live. + let cut: readonly SessionEvent[] + const neverUsed = isUnstoredFreshSession(source) try { - // A compaction checkpoint may not settle after the model fork snapshot. + // A compaction checkpoint may not settle after the model fork snapshot — + // and the cut must be read from the settled log, never from before it. await deps.settleCompaction() - // No boundary = the whole source log (continue the conversation). Slice - // the SOURCE snapshot: sessions.fork() registers a real child, and its - // snapshot length is not the inherited cut. - seed = sliceLiveSessionSeed(deps.binding.agent.session) + // A never-used source holds its initialization alone, so its own snapshot + // IS the cut; a used one is sliced from the SOURCE snapshot — + // sessions.fork() registers a real child, and its snapshot length is not + // the inherited cut. + cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source) + // The never-used shortcut answers first; the cut criterion behind it has + // one source: unspoken-sessions.ts. + seed = neverUsed || holdsNoConversation(cut) ? [] : cut } catch (error) { deps.notify(t('model-switch-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }); return false } + const cutHoldsNoConversation = seed.length === 0 + // An unseeded child copies nothing, so the cut's policy facts are all it + // still inherits: without them it falls back to the deployment defaults, + // which may be WIDER than the session it came from (CR-1). + const policyFacts = cutHoldsNoConversation ? latestPolicyFacts(cut) : [] const childId = SessionId(randomUUID()) // Announce the id before the factory: from the moment `agents.create` // returns this process holds the only write handle on a log the publisher // will not name until its next beat. const { reservation } = await reserveNewSession(String(childId)) - const composed = await composePreset(ctx, runningPresetOf(deps.binding.agent.session)) + const composed = await composePreset(ctx, runningPresetOf(source)) let candidate: AgentSession try { - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await agents.create(liveSessionCreateOptions({ - sessionId: childId, - seed, - runtimeSession: deps.binding.agent.session, - inheritedCount: seed.length, - cwd: state.cwd, - // A session nobody has typed into has no conversation to relate, and - // lineage would cost its first real prompt the generated title — the - // child stands as its own root instead (session-lineage.ts). - parentSession: childRecordsLineage(seed) ? deps.binding.agent.session.id : undefined, - agentPreset: composed.agentPreset, - agentOptions: { provider, model }, - setup: composed.setup, - })))) + const create = (): Promise => cutHoldsNoConversation + // No seed and no parent either: a cut that holds no conversation has + // nothing for lineage to describe, and the child stands as its own root + // (session-lineage.ts). + ? createFreshAgent(ctx, agents, { + sessionId: childId, + meta: { cwd: state.cwd, ...(composed.agentPreset === undefined ? {} : { agentPreset: composed.agentPreset }) }, + agentOptions: { provider, model }, + setup: composed.setup, + }) + : agents.create(liveSessionCreateOptions({ + sessionId: childId, + seed, + runtimeSession: source, + inheritedCount: seed.length, + cwd: state.cwd, + // A session nobody has typed into has no conversation to relate, and + // lineage would cost its first real prompt the generated title — the + // child stands as its own root instead (session-lineage.ts). + parentSession: childRecordsLineage(seed) ? source.id : undefined, + agentPreset: composed.agentPreset, + agentOptions: { provider, model }, + setup: composed.setup, + })) + candidate = await deps.binding.prepare(adoption, async () => { + const handle = await create() + // AFTER the factory returns, never inside its `setup`: an append there + // would leave `seq !== 0` and the deferral would return in silence + // (KNOWN-ISSUES B-14 ①). Before the first real event, so the replay can + // never overtake work the person actually did in the child. + if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts) + return createDshSession(ctx, handle) + }) } catch (error) { reservation.abandon(); deps.notify(t('model-switch-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error', timeoutMs: 8000 }); return false } try { await attachSessionToWorkspace(ctx, state.cwd, childId) } catch (error) { deps.notify(t('model-switch-attach-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'warning', timeoutMs: 8000 }) } diff --git a/src/dsh-adapter/channel/session-fork.ts b/src/dsh-adapter/channel/session-fork.ts index 6e9675fab..ec849acac 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -5,8 +5,10 @@ import { randomUUID } from 'node:crypto' import { t } from '../../i18n.js' import { WORKING_GATE_NOTICES } from '../../commands.js' import { resolveDshProfileName } from '../../update.js' -import { appendSessionTitle, liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' +import { appendSessionTitle, liveSessionCreateOptions, sliceLiveSessionSeed, snapshotLiveSessionEvents } from '../compat/index.js' +import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' +import { holdsNoConversation, latestPolicyFacts, replayPolicyFacts } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveMount, type MountReservation } from '../../sessionMounts.js' import { mountFailureText } from '../../sessions/resumeFailure.js' @@ -41,17 +43,46 @@ export function createForkSessionAction( } await deps.settleCompaction() const source = deps.source() - const childId = SessionId(randomUUID()) + // The cut is what the child inherits, so the cut is what the verdict asks + // about — never the session it was cut from. A `/fork` cut is the whole + // source log, but the same verdict serves the actions whose cut can stop + // short of the conversation (`/rewind`, `/tree`): judging the source there + // would say "this one holds a conversation" while the prefix on offer holds + // the initialization alone. A seed would copy that prefix, and the host + // stores every seed at publication (agent-loop `appendUnstoredSuffix`), so + // the fork's log would exist before its first real event — the + // permission-only shell the fresh-session deferral keeps out of JSONL. The + // evidence is read from the slice already in hand (in memory, never a + // second read of the log), so a shell left behind by an earlier process + // counts exactly like a local one; the fresh verdict answers first, so a + // deferred session is not even sliced. There is nothing to copy anyway: the + // fork starts unseeded, as an ordinary fresh session. let seed: readonly SessionEvent[] + // The cut: what the child would inherit — here the whole source log. It is + // read even when the verdict below empties the seed, because the cut is + // also where the session's POLICY FACTS live. + let cut: readonly SessionEvent[] + const neverUsed = isUnstoredFreshSession(source) try { - // No boundary: the whole (turn-closed) source log. Slice the SOURCE - // snapshot — sessions.fork() would register a child and append - // session/end-seed, so snapshot.length is not a lineage cut. - seed = sliceLiveSessionSeed(source) + // No boundary: the whole (turn-closed) source log. A never-used source + // holds its initialization alone, so its own snapshot IS the cut; a used + // one is sliced from the SOURCE snapshot — sessions.fork() would register + // a child and append session/end-seed, so snapshot.length is not a + // lineage cut. + cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source) + // The never-used shortcut answers first; the cut criterion behind it has + // one source: unspoken-sessions.ts. + seed = neverUsed || holdsNoConversation(cut) ? [] : cut } catch (error) { deps.notify(t('fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return false } + const cutHoldsNoConversation = seed.length === 0 + // An unseeded child copies nothing, so the cut's policy facts are all it + // still inherits: without them it falls back to the deployment defaults, + // which may be WIDER than the session it came from (CR-1). + const policyFacts = cutHoldsNoConversation ? latestPolicyFacts(cut) : [] + const childId = SessionId(randomUUID()) const forkComposed = await composePreset(ctx, runningPresetOf(source)) // Reserve BEFORE the factory, and hold it past `detached.release()`. // @@ -66,24 +97,36 @@ export function createForkSessionAction( const reservation: MountReservation = reserved.ok ? reserved.reservation : { settle: () => {}, abandon: () => {} } let detached: { handle: AgentHandle; release(): Promise } try { - detached = await deps.createDetachedHandle(() => agents.create(liveSessionCreateOptions({ - sessionId: childId, - seed, - runtimeSession: source, - inheritedCount: seed.length, - cwd: state.cwd, - // NO parentSession: a /fork copy is an independent conversation - // (kimi-code semantics), not a rewind branch — recording lineage - // would fold it into the source's family in /resume. - agentPreset: forkComposed.agentPreset, - agentOptions: { provider: state.provider, model: state.model }, - setup: forkComposed.setup, - }))) + detached = await deps.createDetachedHandle(() => cutHoldsNoConversation + ? createFreshAgent(ctx, agents, { + sessionId: childId, + meta: { cwd: state.cwd, ...(forkComposed.agentPreset === undefined ? {} : { agentPreset: forkComposed.agentPreset }) }, + agentOptions: { provider: state.provider, model: state.model }, + setup: forkComposed.setup, + }) + : agents.create(liveSessionCreateOptions({ + sessionId: childId, + seed, + runtimeSession: source, + inheritedCount: seed.length, + cwd: state.cwd, + // NO parentSession: a /fork copy is an independent conversation + // (kimi-code semantics), not a rewind branch — recording lineage + // would fold it into the source's family in /resume. + agentPreset: forkComposed.agentPreset, + agentOptions: { provider: state.provider, model: state.model }, + setup: forkComposed.setup, + }))) } catch { reservation.abandon() deps.notify(t('fork-create-failed'), { color: 'error' }) return false } + // AFTER the factory returns, never inside its `setup`: an append there + // would leave `seq !== 0` and the deferral would return in silence + // (KNOWN-ISSUES B-14 ①). Before any first real event, so the replay can + // never overtake work the person actually did in the child. + if (cutHoldsNoConversation) replayPolicyFacts(detached.handle.agent.session, policyFacts) if (!deps.owner.current()) { await detached.release(); reservation.abandon(); return false } try { await attachSessionToWorkspace(ctx, state.cwd, childId) @@ -98,6 +141,10 @@ export function createForkSessionAction( } try { const sourceTitle = state.sessionTitle.trim() + // For a source that holds no conversation this is a silent no-op + // (`appendSessionTitle` reports 'unavailable' for a missing log): the + // child has no log yet, and a detached fork is released before it can + // have one. appendSessionTitle(String(childId), `Fork: ${sourceTitle === '' ? String(source.id).slice(0, 8) : sourceTitle}`) } finally { // The offline title write is the last touch; from here the fork is the @@ -109,7 +156,13 @@ export function createForkSessionAction( const command = process.platform === 'win32' ? `dsh-tui --resume ${childId}` : `DSH_TUI_RESUME_SESSION=${childId} ${boot}` - deps.notify(t('fork-done', { id: String(childId), command }), { timeoutMs: 8000 }) + // Only a fork that HAS a log may be advertised for resume. The unseeded + // branch keeps no artifact before its first real event, so the command + // would name a session that does not exist — the notice says what is + // missing instead ('fork-done-unstored'). + deps.notify(cutHoldsNoConversation + ? t('fork-done-unstored', { id: String(childId) }) + : t('fork-done', { id: String(childId), command }), { timeoutMs: 8000 }) return true } } diff --git a/src/dsh-adapter/channel/session-lineage.ts b/src/dsh-adapter/channel/session-lineage.ts index 5923dbc19..151d4d64f 100644 --- a/src/dsh-adapter/channel/session-lineage.ts +++ b/src/dsh-adapter/channel/session-lineage.ts @@ -2,8 +2,12 @@ * Lineage decision consulted by `/model` only. * * `/rewind` and the `/tree` branch also re-create the live session as a child, - * but they always record `parentSession` and do not call this helper. A blank - * log cannot reach them (`boundary < 0`). This module is not their gate. + * but they decide the lineage themselves and do not call this helper. Their + * seeded branch records `parentSession` (`session-rewind.ts:144`, + * `session-tree-actions.ts:166`); a cut that holds no conversation takes the + * unseeded `createFreshAgent` branch and records none, so that child stands as + * its own root on the same reasoning the paragraph below gives. This module is + * not their gate. * * The decision is load-bearing beyond grouping. Upstream's automatic session * title only ever runs on a session WITHOUT a parent: `dsh-session-title` diff --git a/src/dsh-adapter/channel/session-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index be47085d0..341117f1a 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -6,9 +6,11 @@ import { randomUUID } from 'node:crypto' import { t } from '../../i18n.js' import { createDshSession, dshHandleOf } from '../backend/session.js' import { liveSessionCreateOptions, liveSessionOffset, sliceLiveSessionSeed, snapshotLiveSessionEvents } from '../compat/index.js' +import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { dispatchTuiDecision } from '../extension-events.js' import { normalizeRewindDoneSummary } from './decisions.js' import { composePreset, runningPresetOf } from '../presets.js' +import { holdsNoConversation, latestPolicyFacts, replayPolicyFacts } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import type { DshChannelBinding } from './binding.js' @@ -70,43 +72,93 @@ export function createRewindToAction( if (event.type === 'turn/start') { boundary = event.seq - 1; break } if (event.type === 'turn/end') break } + const source = deps.binding.agent.session + // The cut is what the child inherits, so the cut is what the verdict asks + // about — never the session it was cut from. A rewind boundary can land + // before every real event: the first message's boundary is the seq before + // its turn/start, and that turn opens behind the initialization + // `session/created` wrote (seq 0-2), so the prefix on offer holds the + // initialization alone while the source is a whole conversation. A seed + // would copy that prefix, and the host stores every seed at publication + // (agent-loop `appendUnstoredSuffix`), so the child's log would exist + // before its first real event — the permission-only shell the fresh-session + // deferral keeps out of JSONL. Asking the source would say "this one holds + // a conversation" and seed the shell anyway. There is no history to cut, so + // such a cut yields an unseeded child instead; the evidence is read from + // the slice already in hand (in memory, never a second read of the log), + // and the never-used verdict answers first, so a deferred session is not + // even sliced. let seed: readonly SessionEvent[] + // The cut: what the child would inherit — the boundary prefix here. It is + // read even when the verdict below empties the seed, because the cut is + // also where the session's POLICY FACTS live. + let cut: readonly SessionEvent[] + const neverUsed = isUnstoredFreshSession(source) try { if (boundary < 0) throw new Error('cannot rewind to the very first message') - // Slice the SOURCE snapshot through an inclusive seq. Never - // sessions.fork(): that registers a real child whose snapshot includes - // child-owned session/end-seed, so snapshot.length is not the inherited - // cut. agents.create owns the new session id. - seed = sliceLiveSessionSeed(deps.binding.agent.session, boundary) + // A never-used source holds its initialization alone, so its own snapshot + // IS the cut; a used one is sliced from the SOURCE snapshot through an + // inclusive seq. Never sessions.fork(): that registers a real child whose + // snapshot includes child-owned session/end-seed, so snapshot.length is + // not the inherited cut. agents.create owns the new session id. + cut = neverUsed ? snapshotLiveSessionEvents(source) : sliceLiveSessionSeed(source, boundary) + // The never-used shortcut answers first; the cut criterion behind it has + // one source: unspoken-sessions.ts. + seed = neverUsed || holdsNoConversation(cut) ? [] : cut } catch (error) { deps.notify(t('rewind-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return null } - const composed = await composePreset(ctx, runningPresetOf(deps.binding.agent.session)) + const cutHoldsNoConversation = seed.length === 0 + // An unseeded child copies nothing, so the cut's policy facts are all it + // still inherits: without them it falls back to the deployment defaults, + // which may be WIDER than the session it came from (CR-1). + const policyFacts = cutHoldsNoConversation ? latestPolicyFacts(cut) : [] + const composed = await composePreset(ctx, runningPresetOf(source)) // Announce the id before the factory: the rewind creates the child's log // here, and the publisher only learns the id from the registry on its next // beat. const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await agents.create(liveSessionCreateOptions({ - sessionId: childId, - seed, - runtimeSession: deps.binding.agent.session, - inheritedCount: seed.length, - cwd: state.cwd, - parentSession: deps.binding.agent.session.id, - agentPreset: composed.agentPreset, - agentOptions: { provider: state.provider, model: state.model }, - setup: async (agentCtx, agent) => { - // The cut keeps pre-turn inbox insertions but drops their claims. - // Newer hosts replay those inherited splices, so cancel the restored - // queue durably in the CHILD before publication or preset setup. - // Clearing only state.pending would hide, not revoke, the old work. - agent.inbox.clear() - return composed.setup?.(agentCtx, agent) - }, - })))) + const create = (): Promise => cutHoldsNoConversation + // No seed and no parent: a cut that holds no conversation has no + // history to inherit and nothing for lineage to describe, so the child + // is an ordinary fresh session and stands as its own root + // (session-lineage.ts). + ? createFreshAgent(ctx, agents, { + sessionId: childId, + meta: { cwd: state.cwd, ...(composed.agentPreset === undefined ? {} : { agentPreset: composed.agentPreset }) }, + agentOptions: { provider: state.provider, model: state.model }, + setup: composed.setup, + }) + : agents.create(liveSessionCreateOptions({ + sessionId: childId, + seed, + runtimeSession: source, + inheritedCount: seed.length, + cwd: state.cwd, + parentSession: source.id, + agentPreset: composed.agentPreset, + agentOptions: { provider: state.provider, model: state.model }, + setup: async (agentCtx, agent) => { + // The cut keeps pre-turn inbox insertions but drops their claims. + // Newer hosts replay those inherited splices, so cancel the restored + // queue durably in the CHILD before publication or preset setup. + // Clearing only state.pending would hide, not revoke, the old work. + agent.inbox.clear() + return composed.setup?.(agentCtx, agent) + }, + })) + candidate = await deps.binding.prepare(adoption, async () => { + const handle = await create() + // AFTER the factory returns, never inside its `setup`: an append there + // would leave `seq !== 0` and the deferral would return in silence + // (KNOWN-ISSUES B-14 ①). Before the first real event, so the replay can + // never overtake work the person actually did in the child. + if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts) + return createDshSession(ctx, handle) + }) } catch { reservation.abandon() deps.notify(t('rewind-create-failed'), { color: 'error' }) diff --git a/src/dsh-adapter/channel/session-tree-actions.ts b/src/dsh-adapter/channel/session-tree-actions.ts index d0dd2eb45..c3073743c 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -8,7 +8,9 @@ import { createDshSession, dshHandleOf } from '../backend/session.js' import { appendInterruptedTurnEnd, liveSessionCreateOptions, liveSessionOffset, snapshotLiveSessionEvents } from '../compat/index.js' import { readPersistedSession, type SessionReader } from '../compat/persistence.js' import { closeLiveForkTurn } from '../compat/liveSession.js' +import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, resolvePersistedPreset, runningPresetOf } from '../presets.js' +import { holdsNoConversation, latestPolicyFacts, replayPolicyFacts } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import { forkTarget, rewindTarget, turnUserText } from '../sessionTree.js' @@ -101,7 +103,35 @@ export function createTreeRewindAction( deps.notify(t('rewind-settling'), { color: 'error' }) return null } - const seed = sourceEvents.filter(event => event.seq <= target.boundary) + // The cut is what the child inherits, so the cut is what the verdict asks + // about — never the session it was cut from. A tree rewind can stop before + // every real event: the first message's boundary is the seq before its + // turn/start, and that turn opens behind the initialization + // `session/created` wrote (seq 0-2), so the prefix on offer holds the + // initialization alone while the source is a whole conversation. A seed + // would copy that prefix, and the host stores every seed at publication + // (agent-loop `appendUnstoredSuffix`), so the child's log would exist + // before its first real event — the permission-only shell the fresh-session + // deferral keeps out of JSONL. The evidence is the slice the seed IS, in + // memory, never a second read of the log. The never-used verdict answers + // first, and it only speaks for the LIVE source (the deferral is this + // process's own bookkeeping, and a persisted foreign source is not in it), + // so a foreign source is judged by its cut alone. + // The cut: the prefix the child would inherit — the whole source log for a + // live entry session, a boundary prefix for a tree node. It is read for + // both halves of the verdict below: which conversation it holds, and which + // POLICY FACTS it carries (the unseeded branch copies neither, and a child + // that loses the second falls back to the deployment defaults, which may be + // WIDER than the session it came from, CR-1). + const cut = sourceEvents.filter(event => event.seq <= target.boundary) + // The never-used shortcut answers first, and only for the LIVE source (the + // deferral is this process's own bookkeeping, and a persisted foreign + // source is not in it); the cut criterion behind it has one source: + // unspoken-sessions.ts. + const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession) + const seed = neverUsed || holdsNoConversation(cut) ? [] : cut + const cutHoldsNoConversation = seed.length === 0 + const policyFacts = cutHoldsNoConversation ? latestPolicyFacts(cut) : [] const inheritedCount = seed.length const closeAfterCreate = target.closeTurn !== undefined && entrySession.header?.version >= 3 if (target.closeTurn !== undefined && !closeAfterCreate) { @@ -112,27 +142,47 @@ export function createTreeRewindAction( const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await agents.create(liveSessionCreateOptions({ - sessionId: childId, - seed, - runtimeSession: entrySession, - inheritedCount, - cwd: sourceCwd, - parentSession: SessionId(sessionId), - agentPreset: composed.agentPreset, - agentOptions: { provider: state.provider, model: state.model }, - setup: closeAfterCreate || mode === 'rewind' ? async (agentCtx, agent) => { - // V3 requires seed.length === inheritedEventCount. The constructor - // inserts the inherited marker, then these closers belong to the - // child and persist before publication, without falsifying the cut. - if (closeAfterCreate) closeLiveForkTurn(agent.session, target.closeTurn!) - // Re-editing starts with no historical pending work. Cancel through - // the child's Inbox so a later resume cannot resurrect the queue. - // A plain fork deliberately keeps its separate semantics. - if (mode === 'rewind') agent.inbox.clear() - return composed.setup?.(agentCtx, agent) - } : composed.setup, - })))) + const create = (): Promise => cutHoldsNoConversation + // No seed and no parent: a cut that holds no conversation has no + // history to inherit and nothing for lineage to describe, so the child + // is an ordinary fresh session and stands as its own root + // (session-lineage.ts). + ? createFreshAgent(ctx, agents, { + sessionId: childId, + meta: { cwd: sourceCwd, ...(composed.agentPreset === undefined ? {} : { agentPreset: composed.agentPreset }) }, + agentOptions: { provider: state.provider, model: state.model }, + setup: composed.setup, + }) + : agents.create(liveSessionCreateOptions({ + sessionId: childId, + seed, + runtimeSession: entrySession, + inheritedCount, + cwd: sourceCwd, + parentSession: SessionId(sessionId), + agentPreset: composed.agentPreset, + agentOptions: { provider: state.provider, model: state.model }, + setup: closeAfterCreate || mode === 'rewind' ? async (agentCtx, agent) => { + // V3 requires seed.length === inheritedEventCount. The constructor + // inserts the inherited marker, then these closers belong to the + // child and persist before publication, without falsifying the cut. + if (closeAfterCreate) closeLiveForkTurn(agent.session, target.closeTurn!) + // Re-editing starts with no historical pending work. Cancel through + // the child's Inbox so a later resume cannot resurrect the queue. + // A plain fork deliberately keeps its separate semantics. + if (mode === 'rewind') agent.inbox.clear() + return composed.setup?.(agentCtx, agent) + } : composed.setup, + })) + candidate = await deps.binding.prepare(adoption, async () => { + const handle = await create() + // AFTER the factory returns, never inside its `setup`: an append there + // would leave `seq !== 0` and the deferral would return in silence + // (KNOWN-ISSUES B-14 ①). Before the first real event, so the replay can + // never overtake work the person actually did in the child. + if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts) + return createDshSession(ctx, handle) + }) } catch { reservation.abandon() deps.notify(t('rewind-create-failed'), { color: 'error' }) diff --git a/src/dsh-adapter/compat/writeLease.ts b/src/dsh-adapter/compat/writeLease.ts new file mode 100644 index 000000000..b2f7f1315 --- /dev/null +++ b/src/dsh-adapter/compat/writeLease.ts @@ -0,0 +1,123 @@ +/** + * The exclusive write lease the host's JSONL persistence layer holds on one + * session's log, probed from a process that does not hold it. + * + * `dsh web` opens its own "new session" placeholder in the SHARED session + * store (`~/.dsh/sessions`): the placeholder is a real session of this install, + * its derived index entry correctly says no person ever prompted there, and it + * is in no TUI mount ledger (`sessionMounts.readSessionOwners`) because + * `dsh web` never writes that ledger. Nothing in the sweep's layers ①/②/③ can + * therefore tell it apart from a historical shell, and the exit sweep would + * delete the session the user is looking at (REVIEW CR-2). + * + * The host's own arbiter answers where the ledger cannot. One writer owns a + * session log at a time, and the JSONL persistence layer takes that ownership + * for the whole life of a write handle — a non-blocking `flock(2)` on the + * session directory's `session.lock` on POSIX, a named kernel semaphore + * derived from that path on Windows, released by the kernel on process death + * (`dsh-session-persistence-jsonl/lib/index.js:616-720`). That is exactly the + * ownership a peer's write handle holds, `dsh web`'s included. + * + * The lease is reached through that same public API instead of being + * re-derived here, so each platform keeps its own arbiter and this module + * restates neither the lock file name nor the semaphore name: + * `sessionPersistence.acquireWriteLease({id, cwd})` resolves when nobody owns + * the session and rejects `SessionAlreadyOwnedError` when somebody does. A + * probe that acquires releases at once — the acquisition is the fact, the lock + * is never kept. + * + * The POSIX addon (`@deepseek-ai/node-addon-system/flock`) is deliberately NOT + * the probe: it is POSIX-only by construction (`lib/flock.js` refuses every + * other platform with `ERR_FLOCK_UNSUPPORTED_PLATFORM`, and the package ships + * no win32 prebuild), so a probe built on it could not see a Windows holder at + * all — every Windows session would read `unknown`, the sweep would spare its + * whole index, and the cleanup this change exists to keep would silently stop + * on the platform it ships on most. Going through the service also keeps this + * module free of a second dependency and of a second opinion about where a + * session's lock lives. + * + * `acquireWriteLease` is reached structurally: the session-persistence SERVICE + * declares the port (`create`/`open`/`stat`/`list`) and not the lease, which + * only the JSONL backend implements. Reading the service structurally is this + * adapter's established shape for exactly that case + * (`channel/session-tree.ts:24`, `channel/subagent-transcript.ts:68`); a + * composition that serves no lease (another backend, a browser host, a service + * that is already gone) reads as `unknown`, which spares. + * + * Never throws and never blocks on anything but the probe itself: no service, + * no lease method, a missing `cwd`, an unexpected `errno` and a failed release + * all report `unknown`, and only `free` may let a session be removed + * (DESIGN D5: an unknown stays). + * + * @module @deepseek-harness-tui/dsh-tui/dsh-adapter/compat/writeLease + */ + +/** What one lease probe concluded. Every value but `free` spares the session. */ +export type WriteLeaseState = 'free' | 'held' | 'unknown' + +/** + * The lease port, narrowed out of the persistence service. `acquireWriteLease` + * resolves to the held lease when nobody owns the session. + */ +interface WriteLeasePort { + acquireWriteLease?(header: { readonly id: string; readonly cwd: string }): Promise +} + +/** The lease object's one teardown; closing it is what releases the lock. */ +interface HeldWriteLease { + release?(): unknown +} + +/** + * Whether a failure is the host's own "another handle owns this session" + * verdict, matched by name. The class lives in + * `@deepseek-ai/dsh-session-persistence` and sets `name` in its constructor + * (`lib/index.js:44-52`); matching it structurally keeps this module from + * adding one more package reference for one string. + * @param error - Whatever the acquisition rejected with. + * @returns True when the session is already owned by an active write handle. + */ +function isAlreadyOwned(error: unknown): boolean { + return error instanceof Error && error.name === 'SessionAlreadyOwnedError' +} + +/** + * Build the probe for one persistence seam. + * + * The host thunk is called once per probe rather than captured: the service is + * resolved lazily from the running context, and the exit path must not hold a + * service reference across the teardown it precedes. + * + * @param host - Resolves the persistence service (`ctx.sessionPersistence`). + * @returns A probe that reports `free` only when no writer holds the session. + */ +export function createWriteLeaseProbe( + host: () => unknown, +): (sessionId: string, cwd: string) => Promise { + return async (sessionId, cwd) => { + let port: WriteLeasePort | undefined + try { + port = (await host()) as WriteLeasePort | undefined + } catch { + // A service that cannot even be resolved proves nothing. + return 'unknown' + } + const acquire = port?.acquireWriteLease + if (typeof acquire !== 'function') return 'unknown' + let lease: HeldWriteLease + try { + lease = await acquire.call(port, { id: sessionId, cwd }) + } catch (error) { + return isAlreadyOwned(error) ? 'held' : 'unknown' + } + // The acquisition was the answer; a lock this process cannot hand back is + // not a fact about the session, so it cannot be reported as `free`. + if (typeof lease.release !== 'function') return 'unknown' + try { + await lease.release() + } catch { + return 'unknown' + } + return 'free' + } +} diff --git a/src/dsh-adapter/fresh-agent.ts b/src/dsh-adapter/fresh-agent.ts index 4a4c92c16..cf5e6f18f 100644 --- a/src/dsh-adapter/fresh-agent.ts +++ b/src/dsh-adapter/fresh-agent.ts @@ -59,7 +59,77 @@ function captureWriter(source: JsonlPersistence, id: string, receive: (writer: S } } -const INITIAL_POLICY_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy', 'plan/mode']) +/** + * The session-policy vocabulary the deferral holds back: the facts a fresh + * session may already carry without being published. Exported as the ONE + * definition of that set — the four seeded channel actions replay exactly + * these into an unseeded child, and appending any other type would start the + * deferral and publish the very shell the unseeded branch exists to avoid + * (`latestPolicyFacts`, `unspoken-sessions.ts`). + * + * This set is the policy ATOMS only. The policy plane a switch leaves behind + * is wider — see {@link isPolicyPlaneActivity}. + */ +export const INITIAL_POLICY_EVENTS: ReadonlySet = new Set(['permission/preset', 'sandbox/mode', 'approval/policy', 'plan/mode']) + +/** The command envelope a session-policy switch runs through. */ +const POLICY_ACTIVITY_EVENTS: ReadonlySet = new Set(['command/run', 'command/done']) + +/** + * Whether a message's `source` marks it as typed by the person at the + * keyboard — `digest.ts:66-70`, verbatim: plugin injections, instruction + * snapshots, skill catalogues and sub-agent reports all arrive as user-role + * messages too, and counting them would spare every shell there is. + * + * Defined HERE because the deferral needs it too and `unspoken-sessions.ts` + * already imports this module (the reverse import would be a cycle); the cut + * verdict and the exit sweep import it back, so "a person spoke here" keeps one + * definition across the deferral, the verdict and the sweep. + */ +export function isHumanSource(source: unknown): boolean { + if (source === undefined || source === null) return true + if (typeof source !== 'object') return false + return (source as Record)['kind'] === 'user' +} + +/** + * Whether one event is policy-plane bookkeeping the deferral holds back rather + * than something a person can see: the initialization atoms, the command + * ENVELOPE a policy switch runs through, and the inbox splice that carries only + * its notice. + * + * Why the envelope counts: switching the permission preset (Shift+Tab, + * `/permission`, or the official switch the TUI drives) executes the registry + * command the host exposes for it (`mode-permission.ts`'s + * `executeRegistryCommand('permission', …)`), and the command service logs + * `command/run` + `command/done` around it. Those types are outside + * {@link INITIAL_POLICY_EVENTS}, so the deferral used to start on the first of + * them and publish a permission-only shell — measured on a real tree + * (2026-10-10): an idle fresh session that only switched preset stored an + * 11-event log, which is the 「未命名」 row this change exists to remove. + * + * Both safe directions are kept: a splice that carries a HUMAN message is + * conversation and publishes (the live prompt delivers the typed message + * through exactly that event), and an unreadable splice payload publishes too — + * "the log does not say" must never become "the log says no" (the same widening + * `conversationEvidence` applies on the sweep side). + * + * @param event - One session event, in arrival order. + * @returns True when the deferral must keep waiting for conversation. + */ +export function isPolicyPlaneActivity(event: SessionEvent): boolean { + if (INITIAL_POLICY_EVENTS.has(event.type) || POLICY_ACTIVITY_EVENTS.has(event.type)) return true + if (event.type !== 'agent/inbox/spliced') return false + const inserted = (event.data as { readonly inserted?: unknown } | undefined)?.inserted + if (!Array.isArray(inserted)) return false + for (const message of inserted) { + if (message === null || typeof message !== 'object') continue + const entry = message as Record + if (entry['role'] === 'user' && isHumanSource(entry['source'])) return false + } + return true +} + const unstoredSessions = new WeakSet() /** Initialization-only fresh session with no persistence requested yet. */ @@ -114,7 +184,15 @@ function deferInitialPolicy(ctx: Context, session: FilteredSession, writer: Sess })().finally(() => { draining = undefined }) const guardedFlush = async (): Promise => { - start() + // Counterpart of guardedClose: a flush does not publish a session either + // while it only holds initialization. Host consumers checkpoint a session + // without any real work — the projection cache flushes from its + // `session/created` hook and from its own event throttle — and an + // unconditional drain here re-materialized exactly the permission-only + // shell the deferral exists to keep out of JSONL. The listener still + // participates, so `ctx.sessions.flush()` keeps reporting a durability + // listener; it just has nothing to record before the first real event. + if (!started) return await drain() await flush.call(writer) } @@ -140,7 +218,7 @@ function deferInitialPolicy(ctx: Context, session: FilteredSession, writer: Sess } }, 'dsh-tui fresh session policy') stopEvents = ctx.on('session/event', (subject, event) => { - if (subject !== session || (!started && INITIAL_POLICY_EVENTS.has(event.type))) return + if (subject !== session || (!started && isPolicyPlaneActivity(event))) return start() if (failed || draining !== undefined) return void drain().catch((error: unknown) => { diff --git a/src/dsh-adapter/plugin.ts b/src/dsh-adapter/plugin.ts index 0798f8066..067a980fe 100644 --- a/src/dsh-adapter/plugin.ts +++ b/src/dsh-adapter/plugin.ts @@ -67,8 +67,11 @@ import { attachHerdrIntegration } from '../herdr.js' import { logMouseDebug } from '../utils/debug.js' import { Chat } from '../screens/Chat.js' import { openInjectChannel, type InjectController } from './inject-channel.js' -import { startSessionMountHeartbeat } from './session-mount-heartbeat.js' -import { reserveMount, reserveNewSession } from '../sessionMounts.js' +import { startSessionMountHeartbeat, mountedSessionIds } from './session-mount-heartbeat.js' +import { attachSessionListMetadata } from './session-list-metadata.js' +import { delegatedSessionIds, provenWriteLeaseFree, sweepUnspokenSessions, type UnspokenSessionLineage, type UnspokenSweepDeps, type UnspokenSweepResult } from './unspoken-sessions.js' +import { createWriteLeaseProbe } from './compat/writeLease.js' +import { reserveMount, reserveNewSession, ownerIsSelf, readSessionOwners } from '../sessionMounts.js' import { getHostDialogStore, type TuiDialogRuntime } from './dialogs.js' import { getHostStatusStore, type TuiStatusRuntime } from './status.js' import { createActivityStore } from './activity-store.js' @@ -251,6 +254,21 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, // Validate settings before creating an agent or taking over the terminal. const tuiSettingsNs = resolveSettingsNamespace(configOwner, Config) as SettingsNamespace + // Write side of the mirrored session-list projection (ADR-0011): registers + // `sessionListMetadata` on the host's registry so `dsh web` can tell a + // session nobody ever spoke in from one with a conversation. + // + // It is attached HERE — before `resolveAgent` below creates or resumes the + // boot session — and that position is the whole point: the host writes its + // checkpoint rows at the session's `create`, so a definition attached later + // leaves the boot session's own creation record without a row. `dsh web` + // then falls back to `metadata?.blank ?? false` and lists it as an untitled + // shell until its next checkpoint (issue #1342, back through another door). + // Deferred through `inject` (the registry belongs to a sibling plugin) and a + // no-op on a host line without the seam — a hidden row must stay exactly what + // it is today. + attachSessionListMetadata(ctx) + // Modern hosts own a declarative registry; old hosts discover directories. // A modern bundle failure must not silently fall back to obsolete files. if (!await registerBundledPresets(ctx)) try { @@ -1955,14 +1973,55 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, } // Same resumability as the markers above. refreshLastRunRecord() - void finishExit( - ctx, - instance, - bootedFullscreen, - hint, - undefined, - () => disposeRootAndExit(ctx, 0), - ) + // ADR-0012 decision 3: this branch — and only this branch — sweeps the + // shells nobody ever spoke in. The round has to finish before the call, + // because finishExit writes its notice right after the terminal cleanup + // (D6); it is synchronous and fail-soft, so it cannot hold the exit up. + // Its one asynchronous input — the host's own write lease, which is what + // a `dsh web` session is held by and what the mount ledger cannot see + // (CR-2) — is proved here, BEFORE the round, so the round and the notice + // below still read one finished answer. + void (async () => { + // Fail-closed: a pre-pass that cannot run proves nothing, and nothing + // proven is nothing deleted. Every probe failure inside it already + // leaves its own session unproven; this guards the pre-pass itself. + let writeLeaseFree: (sessionId: string) => boolean = () => false + try { + writeLeaseFree = await provenWriteLeaseFree(createWriteLeaseProbe(() => ctx.get('sessionPersistence'))) + } catch { + // The initial predicate stands. + } + const swept = sweepUnspokenOnExit({ + currentSessionId: () => channel.agentId, + liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId), + listedSessions: () => readExitListing(channel), + writeLeaseFree, + }) + if (swept !== undefined) { + try { + ctx.logger.debug(`dsh-tui: clean exit swept ${swept.deleted.length} unspoken session(s), spared ${swept.skipped.length}`) + } catch { + // Diagnostics belong to the opt-in channel; a sink that throws is + // not a reason to skip the terminal restore that follows. + } + } else { + // F-13: "no round" has four different causes and used to be silent, so + // a silently disabled layer ③ could never be told from an empty index. + try { + ctx.logger.debug(`dsh-tui: clean exit swept nothing (${exitListingGap(channel)}); the session index is spared`) + } catch { + // Same contract as the line above. + } + } + void finishExit( + ctx, + instance, + bootedFullscreen, + composeExitNotice(hint, swept?.deleted.length ?? 0), + undefined, + () => disposeRootAndExit(ctx, 0), + ) + })() }, }) const handleExit = funnel.handleExit @@ -2655,6 +2714,223 @@ type InkShutdownState = { displayCursor?: { x: number; y: number } | null } +/** One listed session, as much of it as the sweep's lineage layer reads. */ +export interface ListedSessionKind { + readonly id: string + readonly kind: { readonly kind: string, readonly parent?: string | undefined } +} + +/** What the clean-exit sweep needs from the process around it. */ +export interface ExitSweepInput { + /** The session behind the channel right now; never a candidate. */ + readonly currentSessionId: () => string | undefined + /** Live agents this process still holds. */ + readonly liveSessionIds: () => ReadonlySet + /** + * This install's synchronous session listing (`ChannelUi.cachedSessions`), + * or undefined when it has never listed. See {@link readExitListing}. + */ + readonly listedSessions: () => readonly UnspokenSessionLineage[] | undefined + /** + * The write-lease proof the round's last gate reads, gathered by the caller + * through {@link provenWriteLeaseFree} (the probe is asynchronous and the + * round is not). Absent leaves the host's lease unconsulted, which is the + * behaviour every caller but this branch wants. + */ + readonly writeLeaseFree?: (sessionId: string) => boolean + /** + * The round to run. Injected only by the exit regression (fault injection + * and deps capture); production always uses the shipping sweep. + */ + readonly sweep?: (deps: UnspokenSweepDeps) => UnspokenSweepResult +} + +/** + * The clean-exit sweep (ADR-0012 decision 3): the shells nobody ever spoke in + * are removed on the normal-exit branch, and nowhere else. + * + * Layer ③ is bound to this process — the session behind the channel, every + * agent the registry still lists ({@link liveExitSessionIds}), the delegated + * lineage of the install's last listing ({@link readExitListing}) and one + * candidate's own header, which the sweep reads itself — plus the sessions a + * LIVE peer holds ({@link foreignHeldSessionIds}) and the sessions whose + * exclusive write lease nothing else holds ({@link ExitSweepInput.writeLeaseFree}, + * gathered through `provenWriteLeaseFree`). The ledger half is there because + * the delete entry points refuse a session another terminal drives and an exit + * sweep that skipped that check could remove one out from under it (REVIEW + * F-04); the lease half is there because the ledger only knows TUI mounts and + * a `dsh web` session is held by a writer that never writes it (REVIEW CR-2). + * + * Every source is a thunk and the whole round is wrapped, because this runs + * inside the exit funnel *before* `finishExit`: a hostile dependency may cost + * the round, never the shutdown. The round is synchronous and bounded on + * purpose — the notice it feeds is written immediately after the terminal + * cleanup, so a round that awaited its own inputs could not reach it + * (DESIGN D6/D7). The one asynchronous input, the host write lease, is + * therefore gathered by the caller before this call rather than awaited + * inside it. + * + * @param input - The process facts, the listing, and the round's own seam. + * @returns The round's result, or undefined when nothing could be proven. + */ +export function sweepUnspokenOnExit(input: ExitSweepInput): UnspokenSweepResult | undefined { + try { + const listed = input.listedSessions() + // No listing has ever completed ⇒ layer ③ cannot tell a delegated run + // from a conversation, and guessing would widen the delete set. The index + // is spared instead (ADR-0012: an unknown stays). + if (listed === undefined) return undefined + const delegated = delegatedSessionIds(listed) + const sweep = input.sweep ?? sweepUnspokenSessions + return sweep({ + currentSessionId: input.currentSessionId, + liveSessionIds: input.liveSessionIds, + isSubagentOrDescendant: id => delegated.has(id), + occupiedElsewhere: foreignHeldSessionIds, + writeLeaseFree: input.writeLeaseFree, + }) + } catch { + // Fail-soft: an exit must never be held up by its own cleanup (D7). + return undefined + } +} + +/** + * Sessions a LIVE process other than this one holds, from the mount ledger. + * + * The ledger is the mount protocol's own answer to "who is driving this log" + * (`sessionMounts.ts:1-30`), and the interactive delete paths already refuse a + * foreign occupant (`useSessionSupervisor.ts:610-616`). The read is synchronous + * and drops dead pids, so an exit that consults it neither waits nor honours a + * crashed terminal's claim. + * + * This process's own record is excluded: `liveExitSessionIds` and + * `currentSessionId` already cover what THIS process holds, and the ledger's + * self-record exists for peers, not for us. A ledger this read cannot parse + * reports no holders (the ledger's display read is deliberately lenient); the + * sweep then behaves exactly as it did before this source existed. + * + * @returns Session ids held by other live processes. + */ +function foreignHeldSessionIds(): ReadonlySet { + const ids = new Set() + for (const [sessionId, owner] of readSessionOwners()) { + if (!ownerIsSelf(owner)) ids.add(sessionId) + } + return ids +} + +/** + * Layer ③'s live set: the bound session plus every agent the registry still + * lists ({@link mountedSessionIds}, which already drops sub-agent runs — those + * are layer ③'s other half, through {@link delegatedSessionIds}). + * + * The registry read is duck-typed and yields nothing when the composition + * serves no `agents` service; the bound id is then the only entry, and the + * round spares less than it could. That limitation is recorded in the task's + * SUMMARY rather than papered over with a guess. + * + * @param ctx - Plugin context, for the agent registry. + * @param currentSessionId - The session behind the channel. + * @returns Session ids that must never be swept. + */ +export function liveExitSessionIds(ctx: Context, currentSessionId: string | undefined): ReadonlySet { + const ids = new Set() + if (currentSessionId !== undefined) ids.add(currentSessionId) + for (const id of mountedSessionIds(ctx)) ids.add(id) + return ids +} + +/** + * The sweep's lineage input, from the channel's own synchronous listing cache. + * + * Never a fresh scan: the exit path must not wait on the store, so this reads + * what the picker or agent view already computed (`cachedSessions` — its + * in-memory last listing, else a previous run's snapshot). A host without that + * method, a throwing read, and "never listed" all report unknown, and the + * sweep then spares the index instead of classifying from nothing. + * + * @param channel - The mounted channel, for its listing cache. + * @returns One lineage row per listed session, or undefined when unknown. + */ +export function readExitListing( + channel: { cachedSessions?(): readonly ListedSessionKind[] | undefined }, +): readonly UnspokenSessionLineage[] | undefined { + try { + const rows = channel.cachedSessions?.() + return rows?.map(row => ({ + id: row.id, + // The listed kinds are a closed sum this structural view cannot see the + // members of (`sessions/header.ts` classify is the authority). Anything + // that is neither a root conversation nor a fork therefore counts as + // delegated: over-marking only ever SPARES a session, while guessing the + // other way would widen the delete surface (ADR-0012: when in doubt, + // keep it). + delegated: row.kind.kind !== 'root' && row.kind.kind !== 'fork', + parent: row.kind.kind === 'root' ? undefined : row.kind.parent, + })) + } catch { + return undefined + } +} + +/** The ways the clean-exit sweep can end up with no listing to judge lineage by. */ +export type ExitListingGap = + /** The mounted channel exposes no listing cache at all (an older host line). */ + | 'no source' + /** The cache answered, but nothing usable: never listed, or a rejected snapshot. */ + | 'no listing' + /** The cache threw while being read. */ + | 'read failed' + /** The listing was readable after all — the round must have failed elsewhere. */ + | 'listed' + +/** + * Name the reason the clean-exit sweep had no listing (REVIEW F-13). + * + * `readExitListing` folds three different worlds into one `undefined` — a host + * line whose channel exposes no cache, a cache that has never produced a + * listing, and a listing (or stored snapshot) that no longer decodes — and the + * sweep treats all three as "unknown ⇒ spare the index", which is right. The + * exit used to say nothing at all in that case, so "layer ③ ran and found + * nothing" and "layer ③ silently never ran" read identically in the debug + * channel. This names the world. + * + * Called only when the round produced no result, so its one extra read of the + * cache (a synchronous in-memory or snapshot read, the same one the round + * already paid for) costs nothing on a normal exit. `sessions/snapshot.ts` + * discards a whole snapshot when one row carries an unknown kind, which is why + * "no listing" names both possibilities rather than inventing the distinction. + * + * @param channel - The mounted channel, for its listing cache. + * @returns One short reason, stable enough to grep for in a debug log. + */ +export function exitListingGap( + channel: { cachedSessions?(): readonly ListedSessionKind[] | undefined }, +): ExitListingGap { + if (typeof channel.cachedSessions !== 'function') return 'no source' + try { + return channel.cachedSessions() === undefined ? 'no listing' : 'listed' + } catch { + return 'read failed' + } +} + +/** + * The clean exit's notice: the resume hint the user already gets, plus the + * sweep's one line — and only when the round removed something, so a quiet + * exit reads byte-for-byte as it did before (ADR-0012 decision 4). + * + * @param hint - The existing resume hint, when the session is resumable. + * @param cleaned - Sessions the sweep deleted. + * @returns The notice for `finishExit`, or undefined when there is none. + */ +export function composeExitNotice(hint: string | undefined, cleaned: number): string | undefined { + if (cleaned <= 0) return hint + const line = t('exit-cleaned-unspoken-sessions', { count: cleaned }) + return hint === undefined ? line : `${hint}\n${line}` +} + /** * Finish terminal I/O before handing control to a process-level exit action. * Exported for scripts/verify-shutdown-fallback. diff --git a/src/dsh-adapter/session-list-metadata.ts b/src/dsh-adapter/session-list-metadata.ts new file mode 100644 index 000000000..8d3b8d197 --- /dev/null +++ b/src/dsh-adapter/session-list-metadata.ts @@ -0,0 +1,232 @@ +/** + * The `sessionListMetadata` session projection — this app's half of the web + * sidebar's "is this session blank?" marker. + * + * ## Why a mirror instead of an import + * + * `dsh web` hides the sessions it believes never saw a prompt by reading a + * projection key that only the web host registered (`ApiSessionList`). A session + * created here therefore carried no row for that key at all, and the sidebar + * fell back to `metadata?.blank ?? false` — a fallback that deliberately keeps + * unknown sessions visible. Registering the **same key** with the **same + * version** and the **same fold** is what puts the row there. The semantics stay + * owned by the web host: this module adds no field and invents no rule. + * + * ## Mirror source (host line `0.2.0-rc.2`) — re-read it before editing + * + * | what | host location | + * |---|---| + * | state schema `{blank, lastPromptAt}` | `dsh-api-session-controller/lib/types/list.js:9-12` | + * | fold | `dsh-api-session-controller/lib/types/list.js:27-35` | + * | registration (`stateVersion: 1`, identity `wire`, no `init` args) | `dsh-api-session-controller/lib/types/list.js:59-66` | + * | key ownership, refs, version check, first-registration-wins | `dsh-session-projection/lib/index.js:81-93` | + * | checkpoint writes one row per registered key | `dsh-session-projection/lib/index.js:195-207` | + * | every WIRE read skips a definition without `wire` | `dsh-session-projection/lib/index.js:147`, `:170`, `:249` | + * | `stateSchema.parse`: hot read guarded, cold `restore()` NOT guarded | `dsh-session-projection/lib/index.js:255`, `:297` | + * + * **Any change on either side must be made on both sides.** A drifted fold or + * version fails silently rather than loudly: the row is written with the wrong + * meaning, or discarded by the version check, and the original symptom + * (untitled shells in the sidebar) simply comes back with nothing in the log. + * `scripts/verify-session-list-metadata.ts` folds this definition side by side + * with the installed host's own `applySessionListMetadata` and goes red when the + * two disagree; run it after any host upgrade. + * + * ## Why the identity wire is registered + * + * A registration without `wire` still writes its row (`index.js:195-207`), which + * is why the original fix looked sufficient — but every *read* face the web + * sidebar uses (`snapshot`, `cachedSnapshot`, `viewCheckpoint`, `restore`) + * returns nothing for a key whose winning definition has no `wire` + * (`index.js:147`, `:170`, `:249`). Because the first registration of a key + * keeps the key (`:81-93`), a mirror registered before the host's own definition + * blanked the key for that read face: `snapshot(session, ['sessionListMetadata'])` + * served `{values: {}}`, the sidebar fell back to `metadata?.blank ?? false` and + * #1342 came straight back. The wire is the identity (`view: state => state`, + * `list.js:64`) parsed with the VERY SAME schema object the state already + * passed, so it cannot be the stricter of the two `viewSchema.parse` call sites + * (`index.js:259`, `:305`) — it can only fail where `stateSchema` would have. + * + * ## What is deliberately absent + * + * No second schema, no copy of the state, and none of the legacy definition-level + * spellings (`schema` / `viewSchema` / `view`): those belong to older host lines + * whose registration shape this mirror does not speak (ADR-0011 decision 8), and + * a separate copy could drift into being stricter than the state it views. + * + * ## Failure is not an option here + * + * Registration is best-effort by design: a host line without the registration + * seam, or one that already owns the key at another `stateVersion` (the host + * `register` throws), must leave the app running exactly as before. The reason + * goes to the opt-in debug channel and nowhere else. + * @module dsh-tui/dsh-adapter/session-list-metadata + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { z } from 'zod' +import type { ProjectionRegistrationLike, ProjectionRegistryLike } from './activity-store.js' + +/** The projection key the web host owns and this app mirrors. */ +export const SESSION_LIST_METADATA_KEY = 'sessionListMetadata' + +/** + * The persisted-state version of that unit. + * + * Must equal the host's `stateVersion` (`list.js:65`): a second registration of + * one key is accepted only at the same version, and a stored row is discarded + * when its `ver` differs (`index.js:85-93`, `:297`). + */ +export const SESSION_LIST_METADATA_STATE_VERSION = 1 + +/** + * The mirrored value: what `ApiSessionList` folds and what the sidebar reads. + * + * Structural copy of the host's **state** (there is no separate view — the host + * registers `view: state => state`, `list.js:64`). + */ +export interface SessionListMetadataState { + /** `true` while no turn has ever started; the sidebar hides sessions that stay blank. */ + readonly blank: boolean + /** Wall clock of the last human prompt, or `null` before the first one. */ + readonly lastPromptAt: number | null +} + +/** Field-for-field the host's `sessionListMetadataSchema` (`list.js:9-12`). */ +const sessionListMetadataSchema = z.object({ + blank: z.boolean(), + lastPromptAt: z.number().nullable(), +}) + +/** + * The value a session starts from (`list.js:62`). + * @returns fresh metadata for a session that has not seen an event yet. + */ +export function initSessionListMetadata(): SessionListMetadataState { + return { blank: true, lastPromptAt: null } +} + +/** + * Advance the mirrored metadata by one committed event. + * + * Verbatim copy of the host's `applySessionListMetadata` (`list.js:27-35`), + * including both of its observable properties: an event that changes nothing + * returns the **same reference** (the host re-publishes on `Object.is`), and + * `blank` can only ever go from `true` to `false`. + * + * `event.data.source` is read unguarded on purpose — that is what the host does, + * and the session codec refuses to store a `user/message` without a valid source + * at all ("seed user/message ... has invalid source"). A defensive branch would + * be a *second* semantic, and because the first registration wins + * (`index.js:85-93`) the two sides would then disagree depending on mount order. + * @param state - metadata before the event. + * @param event - next committed session event. + * @returns the original state, or the advanced value. + */ +export function applySessionListMetadata( + state: SessionListMetadataState, + event: SessionEvent, +): SessionListMetadataState { + const blank = state.blank && event.type !== 'turn/start' + const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user' + ? event.time + : state.lastPromptAt + return blank === state.blank && lastPromptAt === state.lastPromptAt + ? state + : { blank, lastPromptAt } +} + +/** + * The mirror's registration plus the identity wire its read faces need. + * + * {@link ProjectionRegistrationLike} is the activity store's slice and has no + * read face of its own, so `wire` is added here rather than there. The host's + * registration carries exactly these two members (`list.js:64`). + */ +interface WiredProjectionRegistration extends ProjectionRegistrationLike { + readonly wire: { + readonly viewSchema: { parse(value: unknown): unknown } + /** The identity view: the host serves the state itself, uncloned. */ + view(state: SessionListMetadataState): SessionListMetadataState + } +} + +/** + * The definition handed to the host registry — the mirror's whole contract. + * @returns one registration object (the caller owns nothing on failure). + */ +function sessionListMetadataDefinition(): WiredProjectionRegistration { + return { + key: SESSION_LIST_METADATA_KEY, + stateSchema: sessionListMetadataSchema, + init: initSessionListMetadata, + apply: applySessionListMetadata, + // Identity, and the SAME schema object the state was parsed with: the host + // parses this view outside a try (`index.js:259`, `:305`), so it must not be + // able to reject a value `stateSchema` accepted (`list.js:64`). + wire: { viewSchema: sessionListMetadataSchema, view: state => state }, + stateVersion: SESSION_LIST_METADATA_STATE_VERSION, + } +} + +/** + * Register the mirrored projection on the host's registry, whatever happens. + * + * Never throws: a missing entry point (a host line before the registration + * seam) and a version conflict (the host refuses to share a key, `index.js:85-93`) + * both leave the composition as it was. The registration itself is bound to the + * registry's own fiber by the host, so a re-provided service re-runs this from + * the fresh registry instead of stacking references on the old one. + * @param registry - The host projection registry slice. + * @param debug - Opt-in diagnostics sink (never stdout). + */ +function registerSessionListMetadata( + registry: ProjectionRegistryLike, + debug: (message: string) => void, +): void { + if (typeof registry.register !== 'function') { + debug(`dsh-tui: sessionProjections has no register(); the ${SESSION_LIST_METADATA_KEY} mirror stays off on this host line`) + return + } + try { + registry.register(sessionListMetadataDefinition()) + } catch (error) { + debug(`dsh-tui: could not register the ${SESSION_LIST_METADATA_KEY} mirror; web keeps its current session list: ${String(error)}`) + } +} + +/** + * The opt-in debug channel, when the composition has one. + * @param ctx - Context to read the logger from. + * @returns a sink that swallows its own failures — diagnostics must never be a startup risk. + */ +function debugSink(ctx: Context): (message: string) => void { + const logger = (ctx as unknown as { logger?: { debug?: (message: string) => void } }).logger + return message => { + try { + logger?.debug?.(message) + } catch { + // A logger that cannot log is not a reason to skip a registration. + } + } +} + +/** + * Wire the mirrored projection into the host composition. + * + * Deferred through `inject` for the same reason the activity feed is: the + * registry belongs to a plugin mounted alongside this one, so it may not exist + * yet — and on a host line without it, never. Everything past the presence check + * is best-effort; see {@link registerSessionListMetadata}. + * @param ctx - Host context of the composition root. + */ +export function attachSessionListMetadata(ctx: Context): void { + ctx.inject(['sessionProjections'] as never, ((projectionCtx: Context) => { + const registry = (projectionCtx as unknown as { + sessionProjections?: ProjectionRegistryLike + }).sessionProjections + if (registry === undefined) return + registerSessionListMetadata(registry, debugSink(ctx)) + }) as never) +} diff --git a/src/dsh-adapter/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts new file mode 100644 index 000000000..0aa5b57c5 --- /dev/null +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -0,0 +1,749 @@ +/** + * The unspoken-session sweep: delete the shells no person ever spoke to. + * + * A TUI session is created before it is bound to an agent, so booting the app + * (or switching workspaces, or opening the agent view) leaves a persisted + * session behind that nothing ever said a word in. Those shells show up in + * `dsh web`'s sidebar and pile up on disk. This module decides which of them + * may be removed and removes them — once, on the way out. + * + * The decision is three conservative layers, and **any** layer that does not + * pass spares the session (DESIGN D5 / ADR-0012 decision 1): + * + * 1. INDEX — the TUI's own `derived.hasPrompt === false` + * (`sessions/store.ts:61-89`, written from `sessions/digest.ts:152-209`). + * It is monotone and treats every unknown as "prompted", so `false` + * implies the whole human turn surface was empty when it was derived. + * A missing entry or a missing `derived` record is NOT a candidate. + * 2. LOG — a bounded re-read of the artifact must find neither a + * `turn/start` nor a human `user/message`. The human rule is + * `digest.ts:66-98`'s (`source` absent/null or `{kind:'user'}` is human; + * `agent/inbox/spliced` entries with `role: 'user'` count too). An + * absent, dangling, corrupt or budget-truncated log proves nothing and + * spares the session. The reader drops seq-less header rows and rows + * marked `ignorable` (`compat/sessionLog.ts:821`); neither can hide human + * evidence here — `ignorable` is set only on unrecognised, purely + * informational records (`dsh-session/lib/types/types.d.ts:503-507`), and + * every conversational event type is a known one. + * 3. PROCESS — the session this process is bound to, a live background + * session, any session a LIVE peer process holds in the mount ledger, and + * any delegated run or descendant of one are spared. The shipping delete + * primitive has no `kind`/`parentSession` check + * (`compat/sessionLog.ts:1280-1304`; see ADR-0012's note that only the UI + * filters sub-agents), so this layer is ours to add. + * + * Delegation is decided from the candidate's **own header** — the first + * frame of its log, which carries `origin` / `parentSession` / + * `delegationDepth` (`sessions/header.ts:16-25`, `:102-113`) — because the + * install's listing is a boot-time snapshot: a delegated run created after + * it is in the index but in no listing, and a rule that reads only the + * listing hands that run to the delete primitive (REVIEW F-03). The listing + * keeps its own job, the **descendant closure**: a fork whose ancestor the + * listing (or the ancestor's own header) calls delegated is spared too. An + * UNREADABLE header adds no protection rather than a guess — the listing + * still speaks, and this read resolves the log through the same lookup the + * delete primitive uses, so a log whose header cannot be read is a log that + * primitive refuses to remove. + * + * Cross-process holding comes from the same ledger the mount paths consult + * (`sessionMounts.readSessionOwners`, synchronous, dead pids already + * dropped): the delete entry points refuse a session a peer holds + * (`useSessionSupervisor.ts:610-616`), and an exit sweep that skipped that + * check could delete a session another terminal is still driving + * (REVIEW F-04). + * + * The ledger only knows TUI mounts, so a session a NON-TUI writer holds is + * invisible to it: `dsh web` keeps its own "new session" placeholder in the + * shared store without ever writing the ledger, and that placeholder is a + * promptless shell with a real log — every layer above would collect it. + * The host's exclusive write lease is the arbiter that does cover it, and + * {@link UnspokenSweepDeps.writeLeaseFree} is how a caller that can afford + * the probe feeds it in (REVIEW CR-2, {@link provenWriteLeaseFree}). It is + * a separate fact from the ledger rather than more of it, and it reports + * its own reason, because the two disagree exactly where this bug lives. + * + * The action is the existing primitive plus the same per-session note cleanup + * the picker's delete uses (`channel/session-metadata.ts:233-242`): + * `deleteSessionLog` → `forgetSession` → `forgetAgentViewSession` → drop the + * resume target when it names this session. The index is deliberately NOT + * rewritten — `sessions/store.ts:253-259` documents that absence of a forget + * path, and `sessions/list.ts:364-368` converges it on the next listing. + * Projection caches and the workspace ledger are not session-scoped and are + * never touched. + * + * The round is bounded, synchronous and fail-soft: at most + * {@link DEFAULT_MAX_CANDIDATES} candidates and {@link DEFAULT_MAX_EVENTS_PER_LOG} + * events per log are examined, a single failure is reported and skipped, and + * nothing here throws, retries, or spawns a process — an exit path must never + * be blocked by its own cleanup. Synchronous on purpose: an exit funnel can + * call it inline without leaving pending I/O or an unawaited promise behind. + * The one asynchronous fact it cannot produce itself is the host write lease, + * so that is gathered BEFORE the round by {@link provenWriteLeaseFree} and + * handed in as {@link UnspokenSweepDeps.writeLeaseFree}: the decision pipeline + * stays a synchronous function of its facts. + * + * The four seeded channel actions (`/model`, `/fork`, `/rewind`, `/tree`) share + * this module's reading of the cut they offer a child, so neither rule is + * restated per action: {@link holdsNoConversation} decides whether that cut + * holds a conversation at all, and {@link latestPolicyFacts} extracts the + * policy facts a conversation-less cut still carries — the facts + * {@link replayPolicyFacts} puts back into the unseeded child. + * + * @module @deepseek-harness-tui/dsh-tui/dsh-adapter/unspoken-sessions + */ +import { clearResumeTarget, forgetAgentViewSession, forgetSession, readResumeTarget } from '../sessionHistory.js' +import { deleteSessionLog, findSessionLogFile, readSessionEventsFromLog } from './compat/sessionLog.js' +import type { WriteLeaseState } from './compat/writeLease.js' +import { INITIAL_POLICY_EVENTS, isHumanSource } from './fresh-agent.js' +import { readHeader, type RawSessionHeader } from './sessions/header.js' +import { decodeFrame, readWindow, walkFrames } from './sessions/frames.js' +import { readIndex as readSessionIndex } from './sessions/store.js' + +/** Candidates examined per round. Past it a shell is spared, not deleted. */ +const DEFAULT_MAX_CANDIDATES = 512 +/** + * Events collected per log. A shell holds a handful; a conversation reaches + * its `turn/start` within the first few. Anything longer than this cannot be + * *proven* empty, so it is spared (`log-incomplete`). + */ +const DEFAULT_MAX_EVENTS_PER_LOG = 1024 + +/** + * Bytes read from the head of one log to reach its first frame. The physical + * header is the first line of the file (`compat/sessionLog.ts:809`), so this is + * the head-window budget `sessions/digest.ts` already reads with; a first frame + * larger than it reports "unknown", which on its own never deletes anything. + */ +const HEADER_WINDOW_BYTES = 64 * 1024 + +/** Shared empty set — "no ledger was read" must not allocate per round. */ +const NO_SESSIONS: ReadonlySet = new Set() + +/** + * One session's physical header, from the first frame of its log. + * + * The first line of a session log IS the physical `session` record, and it + * carries `origin` / `parentSession` / `delegationDepth`. The bounded EVENT + * reader cannot serve it: physical header rows have no `seq` + * (`compat/sessionLog.ts:821`), so they never reach `events`. This decodes only + * the first frame of a 64 KiB head window, which makes one candidate one small + * read instead of a full parse. + * + * The log is located exactly as the delete primitive locates it + * ({@link findSessionLogFile} — the shipping compressed generations), so + * "this read cannot see the header" and "the primitive cannot see the log" + * are the same fact rather than two that can drift apart. + * + * Never throws: an absent, undecodable, misnamed or oversized-first-frame log + * reports `undefined`, and the caller treats that as "no new information" + * (see {@link UnspokenSweepDeps.readSessionHeader}). + * + * @param sessionId - Session whose header should be read. + * @returns The narrowed header, or undefined when it cannot be read. + */ +export function readSessionHeaderFromLog(sessionId: string): RawSessionHeader | undefined { + try { + const path = findSessionLogFile(sessionId) + if (path === undefined) return undefined + const window = readWindow(path, HEADER_WINDOW_BYTES) + if (window === undefined) return undefined + const first = walkFrames(window.buffer, 0, 1)[0] + if (first === undefined) return undefined + const line = decodeFrame(window.buffer, first)?.[0] + return line === undefined ? undefined : readHeader(line) + } catch { + return undefined + } +} + +/** + * Whether a header marks a DELEGATED run — never a fork. + * + * `origin === 'subagent'` is upstream's own classification + * (`sessions/header.ts:102-113`); a nonzero `delegationDepth` records the same + * fact without it (upstream keeps that field optional, so requiring it would + * under-mark). `parentSession` alone is deliberately NOT enough: a `/rewind` + * fork records one exactly like a delegated child does, and a fork inherits its + * ancestor's conversation — the log layer sees the inherited `turn/start` or + * human message in the fork's own artifact and spares it there. + * @param header - A narrowed header. + * @returns True when the session is a delegated run. + */ +function isDelegatedHeader(header: RawSessionHeader): boolean { + return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0 +} + +/** + * One index entry, as much of it as layer ① reads. `SessionIndex` satisfies + * this structurally. + */ +export interface UnspokenIndexEntry { + readonly derived?: { readonly hasPrompt: boolean } | undefined +} + +/** + * A bounded log read, as much of it as layer ② reads. `SessionLogRead` + * (`compat/sessionLog.ts:766-783`) satisfies this structurally. + */ +export interface UnspokenLogRead { + readonly events: readonly unknown[] + /** False when the read stopped at a budget: absence is unproven. */ + readonly complete: boolean + /** True when an existing log could not be decoded at all. */ + readonly failed?: boolean +} + +/** Why a session was spared. Every value means "keep it". */ +export type UnspokenSkipReason = + /** ① The index says a human prompted here, or cannot say otherwise. */ + | 'index-has-prompt' + /** ① The entry carries no derived record. */ + | 'index-unknown' + /** Bound: this round's candidate budget was already spent. */ + | 'candidate-cap' + /** ② No artifact was found for the entry (missing or dangling). */ + | 'log-absent' + /** ② The artifact exists but could not be decoded. */ + | 'log-unreadable' + /** ② The read hit its event budget, so emptiness is unproven. */ + | 'log-incomplete' + /** ② The log carries a `turn/start`. */ + | 'turn-start' + /** ② The log carries a human message. */ + | 'human-message' + /** ③ This process is bound to the session right now. */ + | 'current-session' + /** ③ This process holds the session as a live background run. */ + | 'live-session' + /** ③ The session is a delegated run or descends from one. */ + | 'subagent' + /** ③ Another LIVE process holds the session in the mount ledger. */ + | 'held-elsewhere' + /** + * ③ A writer's exclusive lease on the session log is held by another + * process, or could not be disproved. Distinct from `held-elsewhere` on + * purpose: the ledger only knows TUI mounts, while this fact is the host + * persistence layer's own arbiter — the one that sees `dsh web` (CR-2). + */ + | 'write-leased' + /** Sweep only: the delete primitive declined (absent or uncontained). */ + | 'delete-unavailable' + /** A dependency threw; the session is spared rather than guessed at. */ + | 'unexpected-error' + +/** One spared session and the layer that spared it. */ +export interface UnspokenSkip { + readonly id: string + readonly reason: UnspokenSkipReason +} + +export interface UnspokenCollection { + /** Index entries that passed all three layers — deletion candidates. */ + readonly ids: readonly string[] + /** + * Every index entry that was not collected, with its reason. `ids` and + * `skipped` partition the index, so nothing is silently dropped. + */ + readonly skipped: readonly UnspokenSkip[] +} + +export interface UnspokenSweepResult { + /** Sessions whose log directory was actually removed, in index order. */ + readonly deleted: readonly string[] + /** Everything spared, including deletes the primitive refused. */ + readonly skipped: readonly UnspokenSkip[] +} + +/** + * What the sweep cannot know from inside this module. The store-layer reads + * (index, log, header) have real shipping defaults; the process-layer facts are + * required, because a missing one would silently widen the delete set. + * `occupiedElsewhere` is the one optional process fact: absent means no ledger + * was consulted, which is what every caller but the exit path wants. + */ +export interface UnspokenSweepDeps { + /** ① Index snapshot. Defaults to the TUI session index. */ + readonly readIndex?: () => ReadonlyMap + /** ② Bounded log read. Defaults to the shipping bounded reader. */ + readonly readLog?: (sessionId: string, maxEvents: number) => UnspokenLogRead | undefined + /** ③ The session this process is bound to, if any. */ + readonly currentSessionId: () => string | undefined + /** ③ Sessions this process still holds (live background runs). */ + readonly liveSessionIds: () => ReadonlySet + /** ③ Whether a session is a delegated run or a descendant of one. */ + readonly isSubagentOrDescendant: (sessionId: string) => boolean + /** + * ③ One candidate's own physical header. Defaults to + * {@link readSessionHeaderFromLog}, so the shipping exit path reads it + * without wiring anything. + * + * `undefined` means "no new information": the session is judged by the + * listing lineage alone, exactly as before this source existed. That is safe + * rather than optimistic because the default reader and the delete primitive + * locate a log through the same lookup — a log whose header cannot be read + * is a log the primitive refuses to remove (`delete-unavailable`). + */ + readonly readSessionHeader?: (sessionId: string) => RawSessionHeader | undefined + /** + * ③ Sessions some OTHER live process holds, from the mount ledger + * (`sessionMounts.readSessionOwners`, synchronous, dead pids already + * dropped). Absent means no ledger was read — the shipping behaviour + * everywhere except the exit path, which is the only caller that has one. + */ + readonly occupiedElsewhere?: () => ReadonlySet + /** + * ③ Whether this process PROVED that no writer holds the session log's + * exclusive lease ({@link provenWriteLeaseFree}). True is a proof — only a + * proof lets the session be removed; false, a throw, or an id this pre-pass + * never proved all spare it with `write-leased`. + * + * Absent means the layer was not consulted, which is the shipping behaviour + * of every caller but the exit path: the probe is a kernel round-trip per + * candidate, so it is opted into rather than defaulted (see + * {@link UnspokenSweepDeps.occupiedElsewhere} for the same shape). + */ + readonly writeLeaseFree?: (sessionId: string) => boolean + /** Remove one session's log directory. Defaults to `deleteSessionLog`. */ + readonly deleteLog?: (sessionId: string) => 'deleted' | 'unavailable' + /** Forget a deleted session's notes. Defaults to the picker's own trio. */ + readonly forgetState?: (sessionId: string) => void + /** Candidate budget for one round. */ + readonly maxCandidates?: number + /** Event budget for one log read. */ + readonly maxEventsPerLog?: number +} + +/** + * The three decision rules as pure functions. `undefined` means the layer + * passed. Injectable so the regression can drive this exact pipeline with + * reversed rules and prove the assertions have discriminating power + * (AC-7 ③, LESSONS L-044). + * + * `held` is the whole process layer: this process's binding and live runs, the + * ledger's foreign holders, and the candidate's own header lineage. It is one + * rule rather than four because every one of them answers the same question — + * "may something else still be using this session?" — and the FIRST answer + * wins, so the reported reason is deterministic. + */ +export interface UnspokenJudges { + readonly index: (entry: UnspokenIndexEntry | undefined) => UnspokenSkipReason | undefined + readonly log: (read: UnspokenLogRead | undefined) => UnspokenSkipReason | undefined + readonly held: (sessionId: string) => UnspokenSkipReason | undefined +} + +/** + * The line a listed session descends from, for {@link delegatedSessionIds}. + */ +export interface UnspokenSessionLineage { + readonly id: string + /** True for a delegated run: `SessionKind.kind === 'subagent'` + * (`sessions/header.ts:102-113`) or a header with `origin: 'subagent'`. */ + readonly delegated?: boolean + /** Its parent session, when the header records one (fork or delegated). */ + readonly parent?: string | undefined +} + +/** + * Ids that are delegated runs or descend from one, by walking the parent + * links of one listing. A `/rewind` fork of a sub-agent is a descendant even + * though it is a real conversation, and ADR-0012 excludes it either way. + * @param sessions - One entry per listed session. + * @returns Every id that layer ③ must spare. + */ +export function delegatedSessionIds(sessions: Iterable): ReadonlySet { + const parentOf = new Map() + const delegated = new Set() + for (const session of sessions) { + if (session.delegated === true) delegated.add(session.id) + if (session.parent !== undefined && session.parent.length > 0) parentOf.set(session.id, session.parent) + } + // Closure over the links; each pass adds at least one id or stops, so this + // terminates in at most one pass per session. + for (let grew = true; grew;) { + grew = false + for (const [id, parent] of parentOf) { + if (delegated.has(id) || !delegated.has(parent)) continue + delegated.add(id) + grew = true + } + } + return delegated +} + +/** The shipping rules, bound to the injected process-layer facts. */ +export function unspokenJudges(deps: UnspokenSweepDeps): UnspokenJudges { + const readSessionHeader = deps.readSessionHeader ?? readSessionHeaderFromLog + /** Whether one session's own header marks it a delegated run. */ + const delegatedHeaderOf = (sessionId: string): boolean => { + const header = readSessionHeader(sessionId) + return header !== undefined && isDelegatedHeader(header) + } + let binding: { + readonly current: string | undefined + readonly live: ReadonlySet + readonly occupied: ReadonlySet + } | undefined + return { + index: entry => + entry?.derived === undefined ? 'index-unknown' : entry.derived.hasPrompt ? 'index-has-prompt' : undefined, + log: read => { + if (read === undefined) return 'log-absent' + if (read.failed === true) return 'log-unreadable' + if (!read.complete) return 'log-incomplete' + for (const event of read.events) { + const evidence = conversationEvidence(event) + if (evidence !== undefined) return evidence + } + return undefined + }, + held: sessionId => { + binding ??= { + current: deps.currentSessionId(), + live: deps.liveSessionIds(), + occupied: deps.occupiedElsewhere?.() ?? NO_SESSIONS, + } + if (sessionId === binding.current) return 'current-session' + if (binding.live.has(sessionId)) return 'live-session' + if (binding.occupied.has(sessionId)) return 'held-elsewhere' + // The candidate's OWN header first: the listing is a boot-time snapshot, + // so a delegated run created after it is named by no listing at all. + const own = readSessionHeader(sessionId) + if (own !== undefined && isDelegatedHeader(own)) return 'subagent' + // Descendant closure. The listing's verdict on the ancestor comes first + // (it covers whole chains); one hop up the candidate's own header chain + // backs it up, because a listing that predates a delegated run cannot + // name that run's descendants either. + const parent = own?.parentSession + if (parent !== undefined && (deps.isSubagentOrDescendant(parent) || delegatedHeaderOf(parent))) { + return 'subagent' + } + return deps.isSubagentOrDescendant(sessionId) ? 'subagent' : undefined + }, + } +} + +/** The one judges instance {@link holdsNoConversation} asks; only `log` is used. */ +const CUT_JUDGES = unspokenJudges({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, +}) + +/** + * The cut criterion the seeded channel actions share: does this cut inherit no + * conversation at all? It is the exit sweep's own "did a person speak here" + * rule, asked through {@link unspokenJudges}' `log` instead of restated — a + * FOURTH human-speech rule is exactly what the three existing ones must not + * become (KNOWN-ISSUES B-1). + * + * The judges' three process-layer facts are never read here: only `log` is + * asked, and `held` reads them lazily, so they stay inert rather than + * fabricated. + * + * @param events - The cut itself (the slice a child would inherit), never the + * session it was cut from. + * @returns True when the cut holds only initialization. + */ +export function holdsNoConversation(events: readonly unknown[]): boolean { + return CUT_JUDGES.log({ events, complete: true }) === undefined +} + +/** One policy fact a cut carries: the recorded type and payload, verbatim. */ +export interface PolicyFact { + readonly type: string + readonly data: unknown +} + +/** + * The policy facts a cut carries: the LAST value of each session-policy event + * type, in the order the cut recorded them. + * + * A cut that holds no conversation is not an empty inheritance. The session's + * plan mode, sandbox mode, approval policy and durable permission preset live + * in the same prefix, and a child created without a seed falls back to the + * deployment defaults — which may be WIDER than the session it was cut from. + * That is why the four seeded channel actions replay these facts into the + * unseeded branch before the child's first real event. + * + * The four types are the deferral's own {@link INITIAL_POLICY_EVENTS}, + * imported rather than restated: appending a type the deferral does NOT ignore + * starts it and publishes the permission-only shell the unseeded branch exists + * to avoid. One value per type is enough because every consumer of these + * events folds them last-wins (`mode-actions.ts`'s folds, `dsh-plan-mode`'s + * log projection, `dsh-sandbox-policy`'s `sandboxMode` unit, + * `dsh-user-approval`'s `overrideOf`), so the replay reproduces the cut's + * effective policy exactly — and in the cut's own relative order, so the pair + * means the same thing in the child's log. + * + * @param events - The cut itself (the slice a child would inherit). + * @returns One fact per policy type the cut recorded, in recording order. + */ +export function latestPolicyFacts(events: readonly unknown[]): readonly PolicyFact[] { + const lastAt = new Map() + for (const [index, event] of events.entries()) { + const type = policyEventType(event) + if (type !== undefined) lastAt.set(type, index) + } + return [...lastAt] + .sort(([, left], [, right]) => left - right) + .map(([type, index]) => ({ type, data: (events[index] as { readonly data: unknown }).data })) +} + +/** + * Replay a cut's policy facts into a child created WITHOUT a seed. + * + * Call this AFTER the factory returns and BEFORE the child's first real event. + * Never from inside `setup`: `fresh-agent.ts:72` arms the deferral only while + * the session is still at `seq === 0`, so an event appended there leaves the + * gate uninstalled in silence and the child is stored immediately + * (KNOWN-ISSUES B-14 ①). After the factory the gate is armed, and the four + * policy types are exactly the ones it ignores, so the child stays unpublished + * until it has something a person can see. + * + * The append is the same durable write path the mode and permission actions + * already use (`mode-actions.ts`: one `session.append(type, data)` per fact); + * these events are copied from the cut, never manufactured. + * + * @param session - The child's live session. + * @param events - The cut the child was made from. + * @returns The facts appended, in order. + */ +export function replayPolicyFacts(session: unknown, events: readonly unknown[]): readonly PolicyFact[] { + const facts = latestPolicyFacts(events) + if (facts.length === 0) return facts + const target = session as { append(type: string, data: unknown): unknown } + for (const fact of facts) target.append(fact.type, fact.data) + return facts +} + +/** + * One event's type when it is a policy fact the deferral holds back, else + * undefined. The vocabulary is `fresh-agent.ts`'s, so this cannot drift from + * the set whose membership makes the replay safe. + */ +function policyEventType(event: unknown): string | undefined { + if (event === null || typeof event !== 'object' || Array.isArray(event)) return undefined + const type = (event as { readonly type?: unknown }).type + return typeof type === 'string' && INITIAL_POLICY_EVENTS.has(type) ? type : undefined +} + +/** + * What one collected event proves about whether a person ever spoke here. + * Mirrors `digest.ts:66-98`; the one deliberate widening covers a KNOWN type + * whose payload this code cannot read — that counts as human evidence, because + * "the log does not say" must never become "the log says no" on an + * irreversible action. An event whose `type` is unknown stays outside that + * widening and proves nothing, exactly as any other unrelated event does. + */ +function conversationEvidence(event: unknown): 'turn-start' | 'human-message' | undefined { + if (event === null || typeof event !== 'object' || Array.isArray(event)) return undefined + const envelope = event as Record + const type = envelope['type'] + if (type === 'turn/start') return 'turn-start' + if (type !== 'user/message' && type !== 'agent/inbox/spliced') return undefined + const data = envelope['data'] + if (data === null || typeof data !== 'object' || Array.isArray(data)) return 'human-message' + const record = data as Record + if (type === 'user/message') return isHumanSource(record['source']) ? 'human-message' : undefined + const inserted = record['inserted'] + if (!Array.isArray(inserted)) return 'human-message' + for (const message of inserted) { + if (message === null || typeof message !== 'object') continue + const entry = message as Record + if (entry['role'] === 'user' && isHumanSource(entry['source'])) return 'human-message' + } + return undefined +} + +/** + * Decide which indexed sessions are unspoken shells. Read-only: the same + * fixtures can be judged more than once (and by reversed judges). + * + * @param deps - The store seams and the process-layer facts. + * @param judges - The decision rules. Overridden only to prove their + * discriminating power; production always uses {@link unspokenJudges}. + * @returns The candidates and the full spared partition of the index. + */ +export function collectUnspokenSessionIds( + deps: UnspokenSweepDeps, + judges: UnspokenJudges = unspokenJudges(deps), +): UnspokenCollection { + const ids: string[] = [] + const skipped: UnspokenSkip[] = [] + const readIdx: () => ReadonlyMap = deps.readIndex ?? readSessionIndex + const readLog: (sessionId: string, maxEvents: number) => UnspokenLogRead | undefined = deps.readLog ?? readSessionEventsFromLog + const maxCandidates = deps.maxCandidates ?? DEFAULT_MAX_CANDIDATES + const maxEventsPerLog = deps.maxEventsPerLog ?? DEFAULT_MAX_EVENTS_PER_LOG + let index: ReadonlyMap + try { + index = readIdx() + } catch { + // An unreadable index is not an empty index: spare everything. + return { ids, skipped } + } + // A stable order, so a bounded round always makes the same choice. + const entries = [...index.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + let examined = 0 + for (const [id, entry] of entries) { + try { + const indexed = judges.index(entry) + if (indexed !== undefined) { + skipped.push({ id, reason: indexed }) + continue + } + if (examined >= maxCandidates) { + skipped.push({ id, reason: 'candidate-cap' }) + continue + } + examined += 1 + const logged = judges.log(readLog(id, maxEventsPerLog)) + if (logged !== undefined) { + skipped.push({ id, reason: logged }) + continue + } + const held = judges.held(id) + if (held !== undefined) { + skipped.push({ id, reason: held }) + continue + } + ids.push(id) + } catch { + // One broken dependency costs one candidate, never the round. + skipped.push({ id, reason: 'unexpected-error' }) + } + } + return { ids, skipped } +} + +/** + * One session's write-lease probe: the session and the `cwd` its own header + * records, resolving to what the host's arbiter answered. + */ +export type WriteLeaseProbe = (sessionId: string, cwd: string) => Promise + +/** + * The write-lease pre-pass: prove which unspoken index entries no writer holds, + * one kernel probe per entry. + * + * The round itself must stay synchronous (an exit funnel calls it inline), and + * a lease can only be asked for asynchronously — so the one process fact that + * needs a round-trip is gathered here, before the round, exactly as the other + * process-layer facts are: as data the synchronous pipeline reads. + * + * Only layer ①'s surface is probed, because a candidate's lease cannot be + * named before its `cwd` is known and that comes from its own log header: the + * entries that pass "no person prompted here" are the only ones the round can + * ever collect. Nothing is deleted here and nothing is cached across rounds — + * the answer is a predicate over this round's observations. + * + * Bounded and fail-soft by construction: at most + * {@link DEFAULT_MAX_CANDIDATES} entries, in the round's own stable order with + * the round's own cap, so a bounded exit probes what the round would examine. A + * missing `cwd` or a failing probe costs that entry alone, an index this read + * cannot produce proves nothing at all — and an unproven session is spared. + * + * @param probe - One session's lease probe ({@link WriteLeaseProbe}). + * @param deps - The store seams this pre-pass reads. The defaults are the + * shipping index and the shipping header reader, so the exit path injects + * nothing; a regression injects the same fixtures its round uses. + * @returns A predicate: true only for ids this round PROVED unheld. + */ +export async function provenWriteLeaseFree( + probe: WriteLeaseProbe, + deps: Pick = {}, +): Promise<(sessionId: string) => boolean> { + const free = new Set() + const readIdx: () => ReadonlyMap = deps.readIndex ?? readSessionIndex + const readHeader: (sessionId: string) => RawSessionHeader | undefined = deps.readSessionHeader ?? readSessionHeaderFromLog + const maxCandidates = deps.maxCandidates ?? DEFAULT_MAX_CANDIDATES + let index: ReadonlyMap + try { + index = readIdx() + } catch { + // An unreadable index is not an empty index: it proves nothing. + return () => false + } + const entries = [...index.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + let examined = 0 + for (const [id, entry] of entries) { + if (entry.derived === undefined || entry.derived.hasPrompt) continue + if (examined >= maxCandidates) break + examined += 1 + try { + const cwd = readHeader(id)?.cwd + if (cwd === undefined) continue + if (await probe(id, cwd) === 'free') free.add(id) + } catch { + // One unprovable entry costs that entry; the round still runs on the rest. + } + } + return id => free.has(id) +} + +/** + * Collect, then delete. Never throws; a refused or throwing delete is + * reported as `delete-unavailable` and leaves the artifact in place. + * + * The last gate before the primitive is the write-lease proof, when the caller + * supplied one: a candidate whose log may still be held is spared here rather + * than collected-and-deleted, so the ledger's `held-elsewhere` and the host + * lease's `write-leased` stay separately reportable. + * + * @param deps - See {@link collectUnspokenSessionIds}. + * @param judges - See {@link collectUnspokenSessionIds}. + * @returns The deleted ids and the full spared partition. + */ +export function sweepUnspokenSessions( + deps: UnspokenSweepDeps, + judges: UnspokenJudges = unspokenJudges(deps), +): UnspokenSweepResult { + const collected = collectUnspokenSessionIds(deps, judges) + const deleted: string[] = [] + const skipped: UnspokenSkip[] = [...collected.skipped] + const removeLog: (sessionId: string) => 'deleted' | 'unavailable' = deps.deleteLog ?? deleteSessionLog + const forgetState = deps.forgetState ?? defaultForgetState + const writeLeaseFree = deps.writeLeaseFree + for (const id of collected.ids) { + if (writeLeaseFree !== undefined) { + let free: boolean + try { + free = writeLeaseFree(id) + } catch { + // A proof that throws is not a proof; the session stays. + free = false + } + if (!free) { + skipped.push({ id, reason: 'write-leased' }) + continue + } + } + let outcome: 'deleted' | 'unavailable' + try { + outcome = removeLog(id) + } catch { + outcome = 'unavailable' + } + if (outcome !== 'deleted') { + skipped.push({ id, reason: 'delete-unavailable' }) + continue + } + deleted.push(id) + try { + forgetState(id) + } catch { + // The notes are a cache; the log this session was is already gone. + } + } + return { deleted, skipped } +} + +/** + * The per-session notes the picker's delete also drops + * (`channel/session-metadata.ts:233-242`). Best effort by construction — + * every callee swallows its own failures. + */ +function defaultForgetState(sessionId: string): void { + forgetSession(sessionId) + forgetAgentViewSession(sessionId) + if (readResumeTarget() === sessionId) clearResumeTarget() +} diff --git a/src/i18n.ts b/src/i18n.ts index 44978bdb1..f13a8e4b2 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -637,6 +637,14 @@ const dict = { zh: '已分叉({{id}})——仍在原会话中\n新进程进入分叉:{{command}}', en: 'Forked ({{id}}) — still in the original session\nEnter the fork in a new process: {{command}}', }, + // A fork of a session that holds no conversation keeps NO log until its own + // first real event (the fresh-session deferral), so there is no artifact a + // resume command could enter. This notice says that instead of printing a + // command that cannot work yet. + 'fork-done-unstored': { + zh: '已分叉({{id}})——仍在原会话中\n源会话还没有内容,分叉副本没有日志可恢复', + en: 'Forked ({{id}}) — still in the original session\nThe source session has no content yet, so the fork has no log to resume', + }, // ── /tree screen (session family tree) ───────────────────────────────── 'tree-title': { zh: '会话树', en: 'Session tree' }, 'tree-sessions': { zh: '会话', en: 'sessions' }, @@ -1120,6 +1128,15 @@ const dict = { 'color-unknown': { zh: '未知颜色「{{name}}」· 可选:{{list}}', en: 'Unknown color "{{name}}" · available: {{list}}' }, 'color-set': { zh: '会话颜色已设为 {{name}}', en: 'Session color set to {{name}}' }, 'exit-press-again': { zh: '再次按 Ctrl+C 退出', en: 'Press Ctrl+C again to exit' }, + // The clean-exit sweep's one line (ADR-0012 decision 4). Rendered only when + // the round removed something, so a quiet exit reads exactly as it did. + 'exit-cleaned-unspoken-sessions': { + zh: '已清理 {{count}} 个从未有人发言的会话', + en: { + one: 'Removed {{count}} session nobody ever spoke in', + other: 'Removed {{count}} sessions nobody ever spoke in', + }, + }, 'esc-again-rewind': { zh: '再次按 Esc 时间回溯', en: 'Press Esc again to rewind' }, 'esc-again-clear': { zh: '再次按 Esc 清空', en: 'Press Esc again to clear' }, 'new-session-started': { zh: '已新建会话', en: 'New session started' },