From d313e919d3209633cff3ce6fce61cf9d4d3f1174 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:00:35 +0800 Subject: [PATCH 01/24] docs(interaction): document the exit sweep and correct the Ctrl+X description Task: T04 --- docs/interaction.en.md | 11 ++++++++++- docs/interaction.md | 7 ++++++- guide/dsh-tui-guide/interaction.en.md | 11 ++++++++++- guide/dsh-tui-guide/interaction.md | 7 ++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/interaction.en.md b/docs/interaction.en.md index ac562819f..25433c508 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -403,6 +403,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 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). @@ -410,7 +419,7 @@ those sessions as usual — "no registration" is not "no history". - `Ctrl+S` to reveal delegated runs. - `Ctrl+A` this-project/all-projects. - `Ctrl+B` branch filter. -- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete, `Ctrl+X` clearing empty sessions. +- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete (DSH sessions have no delete entry; `Ctrl+X` stops a background session, it is not an empty-session cleanup). - Pinning is still reachable by clicking the row's `★`. - Not returned to the new screen: dispatching a background session and replying to one, the `Space` peek panel, and `Shift+Enter` dispatch-and-attach. After `/bg`, reach a diff --git a/docs/interaction.md b/docs/interaction.md index 2d3e663ee..9d1c22ec5 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -403,12 +403,17 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在退出提示里报出数量。 + +- 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 +- 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 + **旧界面相较之下的行为变更**(有意移除,不再回归): - 会话级右键菜单(重命名/删除单个会话)。 - `Ctrl+P` 固定快捷键、`Ctrl+S` 展开委托运行。 - `Ctrl+A`「仅本项目/全部项目」、`Ctrl+B` 分支筛选。 -- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除、`Ctrl+X` 清理空会话。 +- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除(DSH 会话没有删除入口;`Ctrl+X` 是停止后台会话,不是清理空会话)。 仍然保留与未回归的部分: diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index ac562819f..25433c508 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -403,6 +403,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 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). @@ -410,7 +419,7 @@ those sessions as usual — "no registration" is not "no history". - `Ctrl+S` to reveal delegated runs. - `Ctrl+A` this-project/all-projects. - `Ctrl+B` branch filter. -- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete, `Ctrl+X` clearing empty sessions. +- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete (DSH sessions have no delete entry; `Ctrl+X` stops a background session, it is not an empty-session cleanup). - Pinning is still reachable by clicking the row's `★`. - Not returned to the new screen: dispatching a background session and replying to one, the `Space` peek panel, and `Shift+Enter` dispatch-and-attach. After `/bg`, reach a diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 2d3e663ee..9d1c22ec5 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -403,12 +403,17 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在退出提示里报出数量。 + +- 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 +- 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 + **旧界面相较之下的行为变更**(有意移除,不再回归): - 会话级右键菜单(重命名/删除单个会话)。 - `Ctrl+P` 固定快捷键、`Ctrl+S` 展开委托运行。 - `Ctrl+A`「仅本项目/全部项目」、`Ctrl+B` 分支筛选。 -- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除、`Ctrl+X` 清理空会话。 +- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除(DSH 会话没有删除入口;`Ctrl+X` 是停止后台会话,不是清理空会话)。 仍然保留与未回归的部分: From d793249d212897977adf777b95eca59111aa5cca Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:01:13 +0800 Subject: [PATCH 02/24] chore(deps): add zod for the session-list projection schema Task: T01 --- package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e536ffa5..727c092ee 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 From 983c1674582b8020e858c502b43789336cb28042 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:11:16 +0800 Subject: [PATCH 03/24] feat(adapter): sweep never-spoken sessions on a normal exit Task: T03 --- scripts/verify-unspoken-session-sweep.tsx | 551 ++++++++++++++++++++++ src/dsh-adapter/unspoken-sessions.ts | 384 +++++++++++++++ 2 files changed, 935 insertions(+) create mode 100644 scripts/verify-unspoken-session-sweep.tsx create mode 100644 src/dsh-adapter/unspoken-sessions.ts diff --git a/scripts/verify-unspoken-session-sweep.tsx b/scripts/verify-unspoken-session-sweep.tsx new file mode 100644 index 000000000..3bad3a98e --- /dev/null +++ b/scripts/verify-unspoken-session-sweep.tsx @@ -0,0 +1,551 @@ +/** + * 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. + * + * 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' + +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, sweepUnspokenSessions } = + 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])) +} + +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])) +} + +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') + }) + }) + + 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']) + }) + }) +} 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/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts new file mode 100644 index 000000000..3acaa9a15 --- /dev/null +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -0,0 +1,384 @@ +/** + * 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, 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. + * + * 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. + * + * @module @deepseek-harness-tui/dsh-tui/dsh-adapter/unspoken-sessions + */ +import { clearResumeTarget, forgetAgentViewSession, forgetSession, readResumeTarget } from '../sessionHistory.js' +import { deleteSessionLog, readSessionEventsFromLog } from './compat/sessionLog.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 + +/** + * 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' + /** 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 first three read + * the local store and have real defaults; the process-layer facts are + * required, because a missing one would silently widen the delete set. + */ +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 + /** 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). + */ +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 { + let binding: { readonly current: string | undefined, readonly live: 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() } + if (sessionId === binding.current) return 'current-session' + if (binding.live.has(sessionId)) return 'live-session' + return deps.isSubagentOrDescendant(sessionId) ? 'subagent' : undefined + }, + } +} + +/** + * What one collected event proves about whether a person ever spoke here. + * Mirrors `digest.ts:66-98`; the one deliberate widening is that a payload + * this code cannot read counts as human evidence, because "the log does not + * say" must never become "the log says no" on an irreversible action. + */ +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 +} + +/** + * 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. + */ +function isHumanSource(source: unknown): boolean { + if (source === undefined || source === null) return true + if (typeof source !== 'object') return false + return (source as Record)['kind'] === 'user' +} + +/** + * 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 } +} + +/** + * Collect, then delete. Never throws; a refused or throwing delete is + * reported as `delete-unavailable` and leaves the artifact in place. + * + * @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 + for (const id of collected.ids) { + 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() +} From 90ee4ba3c71fc77ada8d031607b4b2ef0d2b647c Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:13:10 +0800 Subject: [PATCH 04/24] feat(adapter): mirror the session-list metadata projection Task: T02 --- scripts/verify-session-list-metadata.ts | 887 +++++++++++++++++++++++ src/dsh-adapter/activity-store.ts | 25 + src/dsh-adapter/session-list-metadata.ts | 198 +++++ 3 files changed, 1110 insertions(+) create mode 100644 scripts/verify-session-list-metadata.ts create mode 100644 src/dsh-adapter/session-list-metadata.ts diff --git a/scripts/verify-session-list-metadata.ts b/scripts/verify-session-list-metadata.ts new file mode 100644 index 000000000..db1bf3ead --- /dev/null +++ b/scripts/verify-session-list-metadata.ts @@ -0,0 +1,887 @@ +#!/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 and `init` 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. The legacy spellings (`schema` / `viewSchema` / `view`) must stay + * absent: `wire` buys this mirror nothing and adds two `viewSchema.parse` + * call sites that the host does NOT guard (`lib/index.js:259`, `:305`). + * 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. + * 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. + * + * 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)', +] 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 + 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 no wire and no legacy spelling', () => { + assert.equal(definition.wire, undefined, 'the write path does not need wire (lib/index.js:195-207)') + 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('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`) +} + +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') +}) + +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('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') +}) + +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]) + +check('no projection service at all: attach is a no-op', () => { + attachSessionListMetadata(compositionRoot({}) as never) +}) + +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', +) + +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', + ) +}) + +// ── 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/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/session-list-metadata.ts b/src/dsh-adapter/session-list-metadata.ts new file mode 100644 index 000000000..429676584 --- /dev/null +++ b/src/dsh-adapter/session-list-metadata.ts @@ -0,0 +1,198 @@ +/** + * 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`, 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` | + * | `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. + * + * ## What is deliberately absent + * + * No `wire`, and none of the legacy spellings (`schema` / `viewSchema` / `view`). + * The write path does not need them (`index.js:195-207` clones the *state*, and + * `checkpoint()` is not gated on `wire`), this app has no runtime consumer of the + * value, and a wire would add a `viewSchema.parse` call site the host does NOT + * guard (`:259`, `:305`). + * + * ## 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 definition handed to the host registry — the mirror's whole contract. + * @returns one registration object (the caller owns nothing on failure). + */ +function sessionListMetadataDefinition(): ProjectionRegistrationLike { + return { + key: SESSION_LIST_METADATA_KEY, + stateSchema: sessionListMetadataSchema, + init: initSessionListMetadata, + apply: applySessionListMetadata, + 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) +} From 0a9eb9c9714d3918cf41b81968cc59db085a5291 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:26:42 +0800 Subject: [PATCH 05/24] feat(adapter): sweep never-spoken sessions on a clean exit Task: T05 --- scripts/verify-session-cleanup-exit.tsx | 363 ++++++++++++++++++++++++ src/dsh-adapter/plugin.ts | 161 ++++++++++- src/i18n.ts | 9 + 3 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-session-cleanup-exit.tsx diff --git a/scripts/verify-session-cleanup-exit.tsx b/scripts/verify-session-cleanup-exit.tsx new file mode 100644 index 000000000..692ea5098 --- /dev/null +++ b/scripts/verify-session-cleanup-exit.tsx @@ -0,0 +1,363 @@ +/** + * 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, and the delegated lineage of the listing; + * - 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. + * + * The clean-exit branch itself is a closure inside `apply()`, so its wiring is + * asserted against the source — the same shape verify-shutdown-fallback uses + * for its crash hand-off — while every helper it calls is driven directly, + * with the store seams faked and a throwaway session root. + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { + composeExitNotice, + finishExit, + liveExitSessionIds, + readExitListing, + sweepUnspokenOnExit, + type ExitSweepInput, +} from '../src/dsh-adapter/plugin.js' +import { sweepUnspokenSessions, type UnspokenSweepDeps, type UnspokenSweepResult } from '../src/dsh-adapter/unspoken-sessions.js' +import { getLang, setLang, t } from '../src/i18n.js' +import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, DISABLE_WIN32_INPUT_MODE } from '../src/ink/termio/csi.js' +import { DBP, DFE, DISABLE_MOUSE_TRACKING, SHOW_CURSOR } from '../src/ink/termio/dec.js' +import { CLEAR_ITERM2_PROGRESS } from '../src/ink/termio/osc.js' +import instances from '../src/ink/instances.js' + +let failures = 0 +const results: string[] = [] +const check = (name: string, ok: boolean, detail = '') => { + results.push(`${ok ? 'PASS' : 'FAIL'}: ${name}${ok || detail === '' ? '' : ` — ${detail}`}`) + if (!ok) failures++ +} + +// ── 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 root = mkdtempSync(join(tmpdir(), 'dsh-tui-exit-sweep-')) +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, +}) + +/** The exit helper as the clean-exit branch calls it, with the store faked. */ +const sweepWithFixture = ( + input: ExitSweepInput, + overrides: Partial = {}, +): UnspokenSweepResult | undefined => + sweepUnspokenOnExit({ + ...input, + sweep: deps => sweepUnspokenSessions({ ...deps, ...fixtureSeams(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, +}) + +// ── 1. the clean exit sweeps, and the count reaches the notice ────────────── +{ + const swept = sweepWithFixture(exitInput()) + check('clean exit: exactly the sessions no human spoke in are deleted', + swept?.deleted.join(',') === 'shell-a,shell-b', String(swept?.deleted)) + check('clean exit: their log directories are gone from disk', + !existsSync(dirOf('shell-a')) && !existsSync(dirOf('shell-b'))) + check('clean exit: a human message with no turn/start survives (AC-6 ①)', existsSync(dirOf('human-first'))) + check('clean exit: a conversation with a turn/start survives', existsSync(dirOf('spoken'))) + check('clean exit: a delegated run survives (AC-6 ③)', existsSync(dirOf('sub-run'))) + check('clean exit: the live background run and the bound session survive (AC-6 ④)', + existsSync(dirOf('bg')) && existsSync(dirOf('cur'))) + check('clean exit: the spared set is reported as a full partition, not a log', + swept !== undefined && swept.skipped.some(row => row.id === 'sub-run' && row.reason === 'subagent') && + swept.skipped.some(row => row.id === 'bg' && row.reason === 'live-session') && + swept.skipped.some(row => row.id === 'cur' && row.reason === 'current-session') && + swept.skipped.some(row => row.id === 'spoken' && row.reason === 'turn-start'), + JSON.stringify(swept?.skipped)) + + const original = getLang() + setLang('zh') + const zhNotice = composeExitNotice('Resume with the command below:\n/path', 2) + setLang('en') + const enNotice = composeExitNotice(undefined, 2) + const enSingular = composeExitNotice(undefined, 1) + setLang(original) + check('notice: the resume hint is kept, and the count line follows it on its own line', + zhNotice?.startsWith('Resume with the command below:\n/path\n') === true && zhNotice.split('\n').length === 3, String(zhNotice)) + check('notice: the count line is the dictionary entry, with the count substituted', + zhNotice?.endsWith(t('exit-cleaned-unspoken-sessions', { count: 2 })) === true, String(zhNotice)) + check('notice: zh and en both report the number (localized, not hard-coded)', + enNotice !== undefined && enNotice !== zhNotice && enNotice.includes('2'), String(enNotice)) + check('notice: English picks its plural form from the count', + enSingular !== undefined && enSingular !== enNotice && enSingular.includes('1'), String(enSingular)) + check('notice: nothing cleaned leaves the notice byte-for-byte what it was', + composeExitNotice('hint', 0) === 'hint' && composeExitNotice(undefined, 0) === undefined) +} + +// ── 2. layer ③ is wired with this process's own view ─────────────────────── +{ + 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: [] } + }, + }) + check('layer ③: the sweep receives the bound session as currentSessionId', + captured?.currentSessionId() === 'cur') + check('layer ③: the sweep receives the live-session set', captured?.liveSessionIds().has('bg') === true) + check('layer ③: a delegated run is spared', captured?.isSubagentOrDescendant('sub-run') === true) + check('layer ③: a fork of a delegated run counts as a descendant', + captured?.isSubagentOrDescendant('fork-of-sub') === true) + check('layer ③: an ordinary conversation is not delegated', + captured?.isSubagentOrDescendant('root-1') === false) + check('layer ③: the round runs exactly once and its result is reported', + rounds === 1 && swept?.deleted.join(',') === 'x') +} + +// ── 3. the listing seam: lineage mapping, and "unknown" is not "empty" ───── +{ + const lineages = readExitListing({ + cachedSessions: () => [ + { 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 never) + check('listing: a delegated run is flagged and carries its parent', + lineages?.[2]?.delegated === true && lineages[2]?.parent === 'root-1') + check('listing: a fork keeps its parent and is not itself delegated', + lineages?.[1]?.delegated === false && lineages[1]?.parent === 'root-1') + check('listing: a root records no parent', + lineages?.[0]?.parent === undefined && lineages?.[0]?.delegated === false) + check('listing: a parentless delegated run is still delegated', lineages?.[3]?.delegated === true) + // Guard against lineage drift: a kind this build does not know must count as + // delegated, because over-marking only spares — under-marking deletes. + const unknownKind = readExitListing({ cachedSessions: () => [{ id: 'mystery', kind: { kind: 'imported' } }] } as never) + check('listing: a kind this build does not know counts as delegated (over-marking only spares)', + unknownKind?.[0]?.delegated === true) + check('listing: an empty listing is empty, not unknown', readExitListing({ cachedSessions: () => [] } as never)?.length === 0) + check('listing: a host without the cache reports unknown', readExitListing({} as never) === undefined) + check('listing: a throwing cache reports unknown', readExitListing({ + cachedSessions: () => { throw new Error('cache boom') }, + } as never) === undefined) +} + +// ── 4. no listing yet ⇒ no round: the index is spared, never guessed ─────── +{ + ensureDirs() + removed.length = 0 + const swept = sweepWithFixture(exitInput({ listedSessions: () => undefined })) + check('unknown lineage: the round reports nothing and deletes nothing', + swept === undefined && removed.length === 0) + check('unknown lineage: every shell is still on disk', existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b'))) + check('unknown lineage: the notice stays the resume hint', + composeExitNotice('hint', swept?.deleted.length ?? 0) === 'hint') +} + +// ── 5. fail-soft: a hostile dependency never blocks the shutdown ─────────── +{ + ensureDirs() + removed.length = 0 + const cases: Array<[string, () => UnspokenSweepResult | undefined]> = [ + ['the listing throws', () => sweepWithFixture(exitInput({ + listedSessions: () => { throw new Error('listing boom') }, + }))], + ['the index read throws', () => sweepWithFixture(exitInput(), { + readIndex: () => { throw new Error('index boom') }, + })], + ['a log read throws', () => sweepWithFixture(exitInput(), { + readLog: () => { throw new Error('log boom') }, + })], + ['the delete primitive throws', () => sweepWithFixture(exitInput(), { + deleteLog: () => { throw new Error('delete boom') }, + })], + ['the round itself throws', () => sweepUnspokenOnExit({ + ...exitInput(), + sweep: () => { throw new Error('round boom') }, + })], + ['the process-layer fact throws', () => sweepWithFixture(exitInput({ + currentSessionId: () => { throw new Error('bound boom') }, + }))], + ] + const outcomes: string[] = [] + let threw = false + for (const [label, run] of cases) { + try { + const swept = run() + outcomes.push(`${label}:${swept === undefined ? 'skipped' : swept.deleted.length}`) + } catch (error) { + threw = true + outcomes.push(`${label}:THREW ${String(error)}`) + } + } + check('fail-soft: no hostile dependency escapes as a throw', !threw, outcomes.join(' | ')) + check('fail-soft: a broken index, log or delete deletes nothing and reports the round', + outcomes.slice(1, 4).join(',') === 'the index read throws:0,a log read throws:0,the delete primitive throws:0', + outcomes.join(' | ')) + check('fail-soft: a throwing listing or round is reported as no round at all', + outcomes[0] === 'the listing throws:skipped' && outcomes[4] === 'the round itself throws:skipped', + outcomes.join(' | ')) + check('fail-soft: a shell whose dependencies failed is still on disk', + existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b'))) +} + +// ── 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() + } +} + +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 at = -1 + let ordered = true + for (const marker of order) { + const found = written.indexOf(marker) + if (found <= at) { ordered = false; break } + at = found + } + check('shutdown: the terminal restore sequence is written in the shipped order', ordered, JSON.stringify(written)) + check('shutdown: the sweep line is the last thing written, after the restore sequence', + written.endsWith(`${composeExitNotice('hint', 2) ?? ''}\n`), JSON.stringify(written.slice(-80))) + check('shutdown: the notice still precedes the dispose hand-off', + done && written.indexOf('hint') > written.indexOf(SHOW_CURSOR)) +} + +// ── 7. the wiring: only the clean-exit branch sweeps ────────────────────── +{ + const pluginSource = readFileSync(new URL('../src/dsh-adapter/plugin.ts', import.meta.url), 'utf8') + const occurrences = (needle: string): number => pluginSource.split(needle).length - 1 + check('wiring: the projection mirror is attached once at the composition root', + occurrences('attachSessionListMetadata(ctx)') === 1) + check('wiring: the sweep has exactly one call site', occurrences('sweepUnspokenOnExit({') === 1) + const sweepAt = pluginSource.indexOf('sweepUnspokenOnExit({') + const cleanExit = pluginSource.indexOf('// Judge against the live session behind the channel') + check('wiring: that call site is the normal-exit fall-through, not a handoff branch', + cleanExit !== -1 && sweepAt > cleanExit, `sweep@${sweepAt} branch@${cleanExit}`) + const noticeAt = pluginSource.indexOf('composeExitNotice(hint, swept?.deleted.length ?? 0)') + const cleanExitCall = pluginSource.indexOf('void finishExit(', cleanExit) + const cleanExitCallText = pluginSource.slice(cleanExitCall, pluginSource.indexOf('\n )', cleanExitCall)) + check('wiring: the sweep runs before the notice is composed, and the notice is that call\'s own argument', + sweepAt < noticeAt && noticeAt > cleanExitCall && + cleanExitCallText.includes('composeExitNotice(hint, swept?.deleted.length ?? 0)'), + `sweep@${sweepAt} notice@${noticeAt} finishExit@${cleanExitCall}`) + // The branch is a closure inside apply(), so its THREE process facts can only + // be asserted here. This is the check that catches "wired with an empty set" + // — the shape that silently widens the delete surface. + const sweepCallText = pluginSource.slice(sweepAt, pluginSource.indexOf('\n })', sweepAt)) + check('wiring: the branch feeds the sweep the bound session, the live set and the listing cache', + sweepCallText.includes('currentSessionId: () => channel.agentId') && + sweepCallText.includes('liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId)') && + sweepCallText.includes('listedSessions: () => readExitListing(channel)'), + sweepCallText.replace(/\s+/gu, ' ')) + check('wiring: no other exit path composes a swept notice', occurrences('composeExitNotice(') === 2) + const finishBody = pluginSource.slice( + pluginSource.indexOf('export async function finishExit('), + pluginSource.indexOf('function readInkShutdownState('), + ) + check('wiring: finishExit itself stays sweep-free, so its five other callers cannot inherit it', + !finishBody.includes('sweepUnspoken')) + check('wiring: the crash / update / kernel-switch / restart / startup notices are untouched', + pluginSource.includes('crashLine,') && pluginSource.includes('hintText,') && + pluginSource.includes("t('restart-starting'),") && pluginSource.includes('formatHandoffNotice(') && + pluginSource.includes('dsh-tui startup failed:')) + check('wiring: the count line is read from the dictionary, not a literal', + pluginSource.includes("t('exit-cleaned-unspoken-sessions'")) +} + +rmSync(root, { recursive: true, force: true }) +console.log(results.join('\n')) +console.log(`verify-session-cleanup-exit: ${results.length - failures}/${results.length} checks passed`) +if (failures > 0) process.exit(1) diff --git a/src/dsh-adapter/plugin.ts b/src/dsh-adapter/plugin.ts index ba8231c35..019118dc3 100644 --- a/src/dsh-adapter/plugin.ts +++ b/src/dsh-adapter/plugin.ts @@ -64,7 +64,9 @@ 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 { startSessionMountHeartbeat, mountedSessionIds } from './session-mount-heartbeat.js' +import { attachSessionListMetadata } from './session-list-metadata.js' +import { delegatedSessionIds, sweepUnspokenSessions, type UnspokenSessionLineage, type UnspokenSweepDeps, type UnspokenSweepResult } from './unspoken-sessions.js' import { reserveMount, reserveNewSession } from '../sessionMounts.js' import { getHostDialogStore, type TuiDialogRuntime } from './dialogs.js' import { getHostStatusStore, type TuiStatusRuntime } from './status.js' @@ -652,6 +654,14 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, // empty and the channel falls back to the last-request sample — as does a // non-DSH session, whose id the DSH meter never projects. const contextOccupancyStore = createContextOccupancyStore(ctx) + // 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. 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. Registered here, before the channel opens, so the boot session's + // own checkpoints already carry the key. + attachSessionListMetadata(ctx) // The channel holds a backend session; this DSH one owns the resolved // handle (disposed by the binding when a later adoption replaces it). let startupSession: AgentSession @@ -1834,11 +1844,28 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, } // Same resumability as the markers above. refreshLastRunRecord() + // 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. + const swept = sweepUnspokenOnExit({ + currentSessionId: () => channel.agentId, + liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId), + listedSessions: () => readExitListing(channel), + }) + 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. + } + } void finishExit( ctx, instance, bootedFullscreen, - hint, + composeExitNotice(hint, swept?.deleted.length ?? 0), undefined, () => disposeRootAndExit(ctx, 0), ) @@ -2521,6 +2548,136 @@ 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 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}), and the + * delegated lineage of the install's last listing ({@link readExitListing}). + * + * 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 an awaited sweep could not reach it (DESIGN D6/D7). + * + * @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), + }) + } catch { + // Fail-soft: an exit must never be held up by its own cleanup (D7). + return undefined + } +} + +/** + * 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 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/i18n.ts b/src/i18n.ts index 6850ad92c..be3640970 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1115,6 +1115,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' }, From e02fadb96d2602890b10b8586a4bcfa6b422a441 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 13:39:57 +0800 Subject: [PATCH 06/24] test(ci): register the session-list metadata and sweep regressions Task: T06 --- scripts/run-ci-group.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/scripts/run-ci-group.mjs b/scripts/run-ci-group.mjs index 21332d18b..42aa91070 100644 --- a/scripts/run-ci-group.mjs +++ b/scripts/run-ci-group.mjs @@ -351,6 +351,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 管线 + 最小滑块消费者。 @@ -592,9 +601,30 @@ 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']], // /resume 会话浏览器按键流回归:子运行折叠/展开、空会话不列出、搜索、 // Esc 先清查询再退出、rename 后光标按 id 跟随目标(不是按行号)、 // confirm-delete 只认无修饰 Enter、Esc 取消。真实 Chat 渲染驱动。 From 1718a1961efc3e1af15194dd89a03972f1f7d4ca Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 21:58:54 +0800 Subject: [PATCH 07/24] docs(interaction): drop the Ctrl+X correction and scope the sweep notice Task: T-FIX-01 --- docs/interaction.en.md | 4 ++-- docs/interaction.md | 4 ++-- guide/dsh-tui-guide/interaction.en.md | 4 ++-- guide/dsh-tui-guide/interaction.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/interaction.en.md b/docs/interaction.en.md index 25433c508..b31b9d54e 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -405,7 +405,7 @@ those sessions as usual — "no registration" is not "no history". 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 reports the count in its exit notice. +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. @@ -419,7 +419,7 @@ and reports the count in its exit notice. - `Ctrl+S` to reveal delegated runs. - `Ctrl+A` this-project/all-projects. - `Ctrl+B` branch filter. -- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete (DSH sessions have no delete entry; `Ctrl+X` stops a background session, it is not an empty-session cleanup). +- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete, `Ctrl+X` clearing empty sessions. - Pinning is still reachable by clicking the row's `★`. - Not returned to the new screen: dispatching a background session and replying to one, the `Space` peek panel, and `Shift+Enter` dispatch-and-attach. After `/bg`, reach a diff --git a/docs/interaction.md b/docs/interaction.md index 9d1c22ec5..40f6e5172 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -403,7 +403,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 -正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在退出提示里报出数量。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在有清理时于退出提示里报出数量。 - 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 - 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 @@ -413,7 +413,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 会话级右键菜单(重命名/删除单个会话)。 - `Ctrl+P` 固定快捷键、`Ctrl+S` 展开委托运行。 - `Ctrl+A`「仅本项目/全部项目」、`Ctrl+B` 分支筛选。 -- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除(DSH 会话没有删除入口;`Ctrl+X` 是停止后台会话,不是清理空会话)。 +- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除、`Ctrl+X` 清理空会话。 仍然保留与未回归的部分: diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index 25433c508..b31b9d54e 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -405,7 +405,7 @@ those sessions as usual — "no registration" is not "no history". 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 reports the count in its exit notice. +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. @@ -419,7 +419,7 @@ and reports the count in its exit notice. - `Ctrl+S` to reveal delegated runs. - `Ctrl+A` this-project/all-projects. - `Ctrl+B` branch filter. -- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete (DSH sessions have no delete entry; `Ctrl+X` stops a background session, it is not an empty-session cleanup). +- `Tab` preview, `Ctrl+R` rename, `Ctrl+D` delete, `Ctrl+X` clearing empty sessions. - Pinning is still reachable by clicking the row's `★`. - Not returned to the new screen: dispatching a background session and replying to one, the `Space` peek panel, and `Shift+Enter` dispatch-and-attach. After `/bg`, reach a diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 9d1c22ec5..40f6e5172 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -403,7 +403,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 这些会话照常列出并可恢复——"没有登记"不等于"没有历史"。 - 该分组只在本界面内存活,不会写回登记。 -正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在退出提示里报出数量。 +正常退出时(`/exit`、`/quit`、`/q`、空闲时连按两次 `Ctrl+C`、`Ctrl+D`),TUI 会清理**从未被人类发言**的会话(含历史遗留),并在有清理时于退出提示里报出数量。 - 只在正常退出这一条路径上清理:`/update`、内核切换、`/restart` 等交接,以及崩溃与信号退出都不清理。 - 有人类发言的会话(哪怕回合没起来)与子代理**永不删除**。 @@ -413,7 +413,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 会话级右键菜单(重命名/删除单个会话)。 - `Ctrl+P` 固定快捷键、`Ctrl+S` 展开委托运行。 - `Ctrl+A`「仅本项目/全部项目」、`Ctrl+B` 分支筛选。 -- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除(DSH 会话没有删除入口;`Ctrl+X` 是停止后台会话,不是清理空会话)。 +- `Tab` 预览、`Ctrl+R` 重命名、`Ctrl+D` 删除、`Ctrl+X` 清理空会话。 仍然保留与未回归的部分: From 0f2f838572e62e1a483dfc2e1b42e8a092fd3905 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 22:08:45 +0800 Subject: [PATCH 08/24] fix(adapter): register the projection wire and guard the sweep by header lineage Task: T-FIX-02 --- scripts/verify-session-list-metadata.ts | 110 ++++++++++- scripts/verify-unspoken-session-sweep.tsx | 230 +++++++++++++++++++++- src/dsh-adapter/plugin.ts | 86 +++++++- src/dsh-adapter/session-list-metadata.ts | 48 ++++- src/dsh-adapter/unspoken-sessions.ts | 158 ++++++++++++++- 5 files changed, 605 insertions(+), 27 deletions(-) diff --git a/scripts/verify-session-list-metadata.ts b/scripts/verify-session-list-metadata.ts index db1bf3ead..96984053c 100644 --- a/scripts/verify-session-list-metadata.ts +++ b/scripts/verify-session-list-metadata.ts @@ -13,12 +13,18 @@ * What this script pins, and why each case exists: * * 1. **The definition handed to the registry.** Key, `stateVersion`, field - * names/types and `init` 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. The legacy spellings (`schema` / `viewSchema` / `view`) must stay - * absent: `wire` buys this mirror nothing and adds two `viewSchema.parse` - * call sites that the host does NOT guard (`lib/index.js:259`, `:305`). + * 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. @@ -29,7 +35,9 @@ * 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. + * 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. @@ -228,6 +236,8 @@ interface ProjectionRow { readonly ver: number; readonly seq: number; readonly v 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[], @@ -282,13 +292,32 @@ check('registration carries the host stateVersion', () => { assert.equal(SESSION_LIST_METADATA_STATE_VERSION, 1, 'the exported constant is what gets registered') }) -check('registration carries no wire and no legacy spelling', () => { - assert.equal(definition.wire, undefined, 'the write path does not need wire (lib/index.js:195-207)') +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)') @@ -517,6 +546,29 @@ function assertRowWritten(registry: HostRegistryLike, sessionLike: unknown, wher 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() @@ -526,6 +578,10 @@ 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) @@ -535,12 +591,22 @@ 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()) @@ -808,6 +874,32 @@ expectRed( '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'), diff --git a/scripts/verify-unspoken-session-sweep.tsx b/scripts/verify-unspoken-session-sweep.tsx index 3bad3a98e..67ad9a85b 100644 --- a/scripts/verify-unspoken-session-sweep.tsx +++ b/scripts/verify-unspoken-session-sweep.tsx @@ -38,6 +38,16 @@ * 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 @@ -48,6 +58,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync 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 @@ -73,7 +86,7 @@ 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, sweepUnspokenSessions } = +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') @@ -148,6 +161,17 @@ function writeLog(id: string, rows: readonly unknown[], extraFrames: readonly Bu 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 @@ -256,6 +280,12 @@ function buildSharedTree(): void { 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() @@ -453,6 +483,14 @@ try { 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', () => { @@ -539,6 +577,196 @@ try { 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 }) } diff --git a/src/dsh-adapter/plugin.ts b/src/dsh-adapter/plugin.ts index 019118dc3..c8e772dac 100644 --- a/src/dsh-adapter/plugin.ts +++ b/src/dsh-adapter/plugin.ts @@ -67,7 +67,7 @@ import { openInjectChannel, type InjectController } from './inject-channel.js' import { startSessionMountHeartbeat, mountedSessionIds } from './session-mount-heartbeat.js' import { attachSessionListMetadata } from './session-list-metadata.js' import { delegatedSessionIds, sweepUnspokenSessions, type UnspokenSessionLineage, type UnspokenSweepDeps, type UnspokenSweepResult } from './unspoken-sessions.js' -import { reserveMount, reserveNewSession } from '../sessionMounts.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' @@ -1860,6 +1860,14 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, // 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, @@ -2577,8 +2585,12 @@ export interface ExitSweepInput { * 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}), and the - * delegated lineage of the install's last listing ({@link readExitListing}). + * 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}), 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). * * 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 @@ -2602,6 +2614,7 @@ export function sweepUnspokenOnExit(input: ExitSweepInput): UnspokenSweepResult currentSessionId: input.currentSessionId, liveSessionIds: input.liveSessionIds, isSubagentOrDescendant: id => delegated.has(id), + occupiedElsewhere: foreignHeldSessionIds, }) } catch { // Fail-soft: an exit must never be held up by its own cleanup (D7). @@ -2609,6 +2622,31 @@ export function sweepUnspokenOnExit(input: ExitSweepInput): UnspokenSweepResult } } +/** + * 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 @@ -2663,6 +2701,48 @@ export function readExitListing( } } +/** 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 diff --git a/src/dsh-adapter/session-list-metadata.ts b/src/dsh-adapter/session-list-metadata.ts index 429676584..8d3b8d197 100644 --- a/src/dsh-adapter/session-list-metadata.ts +++ b/src/dsh-adapter/session-list-metadata.ts @@ -18,9 +18,10 @@ * |---|---| * | 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`, no `init` args) | `dsh-api-session-controller/lib/types/list.js:59-66` | + * | 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 @@ -31,13 +32,27 @@ * 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 `wire`, and none of the legacy spellings (`schema` / `viewSchema` / `view`). - * The write path does not need them (`index.js:195-207` clones the *state*, and - * `checkpoint()` is not gated on `wire`), this app has no runtime consumer of the - * value, and a wire would add a `viewSchema.parse` call site the host does NOT - * guard (`:259`, `:305`). + * 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 * @@ -122,16 +137,35 @@ export function applySessionListMetadata( : { 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(): ProjectionRegistrationLike { +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, } } diff --git a/src/dsh-adapter/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index 3acaa9a15..5ac9aa77e 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -26,11 +26,32 @@ * 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, and any delegated run or descendant of one are spared. The - * shipping delete primitive has no `kind`/`parentSession` check + * 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 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 @@ -50,7 +71,9 @@ * @module @deepseek-harness-tui/dsh-tui/dsh-adapter/unspoken-sessions */ import { clearResumeTarget, forgetAgentViewSession, forgetSession, readResumeTarget } from '../sessionHistory.js' -import { deleteSessionLog, readSessionEventsFromLog } from './compat/sessionLog.js' +import { deleteSessionLog, findSessionLogFile, readSessionEventsFromLog } from './compat/sessionLog.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. */ @@ -62,6 +85,71 @@ const DEFAULT_MAX_CANDIDATES = 512 */ 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. @@ -106,6 +194,8 @@ export type UnspokenSkipReason = | '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' /** Sweep only: the delete primitive declined (absent or uncontained). */ | 'delete-unavailable' /** A dependency threw; the session is spared rather than guessed at. */ @@ -135,9 +225,11 @@ export interface UnspokenSweepResult { } /** - * What the sweep cannot know from inside this module. The first three read - * the local store and have real defaults; the process-layer facts are + * 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. */ @@ -150,6 +242,25 @@ export interface UnspokenSweepDeps { 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 /** 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. */ @@ -165,6 +276,12 @@ export interface UnspokenSweepDeps { * 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 @@ -213,7 +330,17 @@ export function delegatedSessionIds(sessions: Iterable): /** The shipping rules, bound to the injected process-layer facts. */ export function unspokenJudges(deps: UnspokenSweepDeps): UnspokenJudges { - let binding: { readonly current: string | undefined, readonly live: ReadonlySet } | undefined + 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, @@ -228,9 +355,26 @@ export function unspokenJudges(deps: UnspokenSweepDeps): UnspokenJudges { return undefined }, held: sessionId => { - binding ??= { current: deps.currentSessionId(), live: deps.liveSessionIds() } + 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 }, } From d20eb5ba184c33dfbc4a192fcb8ade1c182af6b2 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Thu, 8 Oct 2026 22:25:56 +0800 Subject: [PATCH 09/24] test(adapter): make the exit-sweep regression self-verifying Task: T-FIX-03 --- scripts/verify-session-cleanup-exit.tsx | 1106 ++++++++++++++++++----- scripts/verify-session-list-metadata.ts | 63 +- 2 files changed, 956 insertions(+), 213 deletions(-) diff --git a/scripts/verify-session-cleanup-exit.tsx b/scripts/verify-session-cleanup-exit.tsx index 692ea5098..a72b85e04 100644 --- a/scripts/verify-session-cleanup-exit.tsx +++ b/scripts/verify-session-cleanup-exit.tsx @@ -1,3 +1,4 @@ +#!/usr/bin/env node /** * Clean-exit sweep regression (ADR-0012 decisions 3/5 · AC-5 / AC-6): * @@ -11,42 +12,215 @@ * 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, and the delegated lineage of the listing; + * 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. * - * The clean-exit branch itself is a closure inside `apply()`, so its wiring is - * asserted against the source — the same shape verify-shutdown-fallback uses - * for its crash hand-off — while every helper it calls is driven directly, - * with the store seams faked and a throwaway session root. + * ## 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 (`plugin.ts:664`); + * - 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()` at `plugin.ts:1852-1853` + * $ 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 (`plugin.ts:1851-1855`), 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 (`src/utils/paths.ts:14-22`), 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 { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' + +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 { +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, - type ExitSweepInput, -} from '../src/dsh-adapter/plugin.js' -import { sweepUnspokenSessions, type UnspokenSweepDeps, type UnspokenSweepResult } from '../src/dsh-adapter/unspoken-sessions.js' -import { getLang, setLang, t } from '../src/i18n.js' -import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, DISABLE_WIN32_INPUT_MODE } from '../src/ink/termio/csi.js' -import { DBP, DFE, DISABLE_MOUSE_TRACKING, SHOW_CURSOR } from '../src/ink/termio/dec.js' -import { CLEAR_ITERM2_PROGRESS } from '../src/ink/termio/osc.js' -import instances from '../src/ink/instances.js' +} = 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[] = [] -const check = (name: string, ok: boolean, detail = '') => { +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 @@ -56,7 +230,6 @@ const check = (name: string, ok: boolean, detail = '') => { // what the assertions below actually exercise. const SHELLS = ['shell-a', 'shell-b'] const SPARED = ['spoken', 'human-first', 'sub-run', 'bg', 'cur'] -const root = mkdtempSync(join(tmpdir(), 'dsh-tui-exit-sweep-')) 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([ @@ -86,16 +259,20 @@ const fixtureSeams = (overrides: Partial = {}): Partial) => UnspokenSweepResult | undefined + /** The exit helper as the clean-exit branch calls it, with the store faked. */ -const sweepWithFixture = ( - input: ExitSweepInput, - overrides: Partial = {}, -): UnspokenSweepResult | undefined => +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', @@ -107,46 +284,351 @@ const exitInput = (overrides: Partial = {}): ExitSweepInput => ( ...overrides, }) -// ── 1. the clean exit sweeps, and the count reaches the notice ────────────── -{ - const swept = sweepWithFixture(exitInput()) - check('clean exit: exactly the sessions no human spoke in are deleted', - swept?.deleted.join(',') === 'shell-a,shell-b', String(swept?.deleted)) - check('clean exit: their log directories are gone from disk', - !existsSync(dirOf('shell-a')) && !existsSync(dirOf('shell-b'))) - check('clean exit: a human message with no turn/start survives (AC-6 ①)', existsSync(dirOf('human-first'))) - check('clean exit: a conversation with a turn/start survives', existsSync(dirOf('spoken'))) - check('clean exit: a delegated run survives (AC-6 ③)', existsSync(dirOf('sub-run'))) - check('clean exit: the live background run and the bound session survive (AC-6 ④)', - existsSync(dirOf('bg')) && existsSync(dirOf('cur'))) - check('clean exit: the spared set is reported as a full partition, not a log', - swept !== undefined && swept.skipped.some(row => row.id === 'sub-run' && row.reason === 'subagent') && - swept.skipped.some(row => row.id === 'bg' && row.reason === 'live-session') && - swept.skipped.some(row => row.id === 'cur' && row.reason === 'current-session') && - swept.skipped.some(row => row.id === 'spoken' && row.reason === 'turn-start'), - JSON.stringify(swept?.skipped)) +/** 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 = composeExitNotice('Resume with the command below:\n/path', 2) + const zhNotice = compose(undefined, 2) setLang('en') - const enNotice = composeExitNotice(undefined, 2) - const enSingular = composeExitNotice(undefined, 1) + const enNotice = compose(undefined, 2) + const enSingular = compose(undefined, 1) setLang(original) - check('notice: the resume hint is kept, and the count line follows it on its own line', - zhNotice?.startsWith('Resume with the command below:\n/path\n') === true && zhNotice.split('\n').length === 3, String(zhNotice)) - check('notice: the count line is the dictionary entry, with the count substituted', - zhNotice?.endsWith(t('exit-cleaned-unspoken-sessions', { count: 2 })) === true, String(zhNotice)) - check('notice: zh and en both report the number (localized, not hard-coded)', - enNotice !== undefined && enNotice !== zhNotice && enNotice.includes('2'), String(enNotice)) - check('notice: English picks its plural form from the count', - enSingular !== undefined && enSingular !== enNotice && enSingular.includes('1'), String(enSingular)) - check('notice: nothing cleaned leaves the notice byte-for-byte what it was', - composeExitNotice('hint', 0) === 'hint' && composeExitNotice(undefined, 0) === undefined) + 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({ @@ -163,104 +645,45 @@ const exitInput = (overrides: Partial = {}): ExitSweepInput => ( return { deleted: ['x'], skipped: [] } }, }) - check('layer ③: the sweep receives the bound session as currentSessionId', - captured?.currentSessionId() === 'cur') - check('layer ③: the sweep receives the live-session set', captured?.liveSessionIds().has('bg') === true) - check('layer ③: a delegated run is spared', captured?.isSubagentOrDescendant('sub-run') === true) - check('layer ③: a fork of a delegated run counts as a descendant', - captured?.isSubagentOrDescendant('fork-of-sub') === true) - check('layer ③: an ordinary conversation is not delegated', - captured?.isSubagentOrDescendant('root-1') === false) - check('layer ③: the round runs exactly once and its result is reported', - rounds === 1 && swept?.deleted.join(',') === 'x') + 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" ───── -{ - const lineages = readExitListing({ - cachedSessions: () => [ - { 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 never) - check('listing: a delegated run is flagged and carries its parent', - lineages?.[2]?.delegated === true && lineages[2]?.parent === 'root-1') - check('listing: a fork keeps its parent and is not itself delegated', - lineages?.[1]?.delegated === false && lineages[1]?.parent === 'root-1') - check('listing: a root records no parent', - lineages?.[0]?.parent === undefined && lineages?.[0]?.delegated === false) - check('listing: a parentless delegated run is still delegated', lineages?.[3]?.delegated === true) - // Guard against lineage drift: a kind this build does not know must count as - // delegated, because over-marking only spares — under-marking deletes. - const unknownKind = readExitListing({ cachedSessions: () => [{ id: 'mystery', kind: { kind: 'imported' } }] } as never) - check('listing: a kind this build does not know counts as delegated (over-marking only spares)', - unknownKind?.[0]?.delegated === true) - check('listing: an empty listing is empty, not unknown', readExitListing({ cachedSessions: () => [] } as never)?.length === 0) - check('listing: a host without the cache reports unknown', readExitListing({} as never) === undefined) - check('listing: a throwing cache reports unknown', readExitListing({ - cachedSessions: () => { throw new Error('cache boom') }, - } as never) === undefined) +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 ─────── -{ - ensureDirs() - removed.length = 0 - const swept = sweepWithFixture(exitInput({ listedSessions: () => undefined })) - check('unknown lineage: the round reports nothing and deletes nothing', - swept === undefined && removed.length === 0) - check('unknown lineage: every shell is still on disk', existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b'))) - check('unknown lineage: the notice stays the resume hint', - composeExitNotice('hint', swept?.deleted.length ?? 0) === 'hint') +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 ─────────── -{ - ensureDirs() - removed.length = 0 - const cases: Array<[string, () => UnspokenSweepResult | undefined]> = [ - ['the listing throws', () => sweepWithFixture(exitInput({ - listedSessions: () => { throw new Error('listing boom') }, - }))], - ['the index read throws', () => sweepWithFixture(exitInput(), { - readIndex: () => { throw new Error('index boom') }, - })], - ['a log read throws', () => sweepWithFixture(exitInput(), { - readLog: () => { throw new Error('log boom') }, - })], - ['the delete primitive throws', () => sweepWithFixture(exitInput(), { - deleteLog: () => { throw new Error('delete boom') }, - })], - ['the round itself throws', () => sweepUnspokenOnExit({ - ...exitInput(), - sweep: () => { throw new Error('round boom') }, - })], - ['the process-layer fact throws', () => sweepWithFixture(exitInput({ - currentSessionId: () => { throw new Error('bound boom') }, - }))], - ] - const outcomes: string[] = [] - let threw = false - for (const [label, run] of cases) { - try { - const swept = run() - outcomes.push(`${label}:${swept === undefined ? 'skipped' : swept.deleted.length}`) - } catch (error) { - threw = true - outcomes.push(`${label}:THREW ${String(error)}`) - } - } - check('fail-soft: no hostile dependency escapes as a throw', !threw, outcomes.join(' | ')) - check('fail-soft: a broken index, log or delete deletes nothing and reports the round', - outcomes.slice(1, 4).join(',') === 'the index read throws:0,a log read throws:0,the delete primitive throws:0', - outcomes.join(' | ')) - check('fail-soft: a throwing listing or round is reported as no round at all', - outcomes[0] === 'the listing throws:skipped' && outcomes[4] === 'the round itself throws:skipped', - outcomes.join(' | ')) - check('fail-soft: a shell whose dependencies failed is still on disk', - existsSync(dirOf('shell-a')) && existsSync(dirOf('shell-b'))) +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 ────────── @@ -275,18 +698,17 @@ class CapturingStream extends Writable { } } -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, - }) -} - -{ +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 @@ -301,63 +723,325 @@ const swapStdout = (stream: NodeJS.WriteStream): void => { 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 at = -1 + let markerAt = -1 let ordered = true for (const marker of order) { const found = written.indexOf(marker) - if (found <= at) { ordered = false; break } - at = found + if (found <= markerAt) { ordered = false; break } + markerAt = found } - check('shutdown: the terminal restore sequence is written in the shipped order', ordered, JSON.stringify(written)) - check('shutdown: the sweep line is the last thing written, after the restore sequence', - written.endsWith(`${composeExitNotice('hint', 2) ?? ''}\n`), JSON.stringify(written.slice(-80))) - check('shutdown: the notice still precedes the dispose hand-off', - done && written.indexOf('hint') > written.indexOf(SHOW_CURSOR)) -} - -// ── 7. the wiring: only the clean-exit branch sweeps ────────────────────── -{ - const pluginSource = readFileSync(new URL('../src/dsh-adapter/plugin.ts', import.meta.url), 'utf8') - const occurrences = (needle: string): number => pluginSource.split(needle).length - 1 - check('wiring: the projection mirror is attached once at the composition root', - occurrences('attachSessionListMetadata(ctx)') === 1) - check('wiring: the sweep has exactly one call site', occurrences('sweepUnspokenOnExit({') === 1) - const sweepAt = pluginSource.indexOf('sweepUnspokenOnExit({') - const cleanExit = pluginSource.indexOf('// Judge against the live session behind the channel') - check('wiring: that call site is the normal-exit fall-through, not a handoff branch', - cleanExit !== -1 && sweepAt > cleanExit, `sweep@${sweepAt} branch@${cleanExit}`) - const noticeAt = pluginSource.indexOf('composeExitNotice(hint, swept?.deleted.length ?? 0)') - const cleanExitCall = pluginSource.indexOf('void finishExit(', cleanExit) - const cleanExitCallText = pluginSource.slice(cleanExitCall, pluginSource.indexOf('\n )', cleanExitCall)) - check('wiring: the sweep runs before the notice is composed, and the notice is that call\'s own argument', - sweepAt < noticeAt && noticeAt > cleanExitCall && - cleanExitCallText.includes('composeExitNotice(hint, swept?.deleted.length ?? 0)'), - `sweep@${sweepAt} notice@${noticeAt} finishExit@${cleanExitCall}`) - // The branch is a closure inside apply(), so its THREE process facts can only - // be asserted here. This is the check that catches "wired with an empty set" - // — the shape that silently widens the delete surface. - const sweepCallText = pluginSource.slice(sweepAt, pluginSource.indexOf('\n })', sweepAt)) - check('wiring: the branch feeds the sweep the bound session, the live set and the listing cache', - sweepCallText.includes('currentSessionId: () => channel.agentId') && - sweepCallText.includes('liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId)') && - sweepCallText.includes('listedSessions: () => readExitListing(channel)'), - sweepCallText.replace(/\s+/gu, ' ')) - check('wiring: no other exit path composes a swept notice', occurrences('composeExitNotice(') === 2) - const finishBody = pluginSource.slice( - pluginSource.indexOf('export async function finishExit('), - pluginSource.indexOf('function readInkShutdownState('), - ) - check('wiring: finishExit itself stays sweep-free, so its five other callers cannot inherit it', - !finishBody.includes('sweepUnspoken')) - check('wiring: the crash / update / kernel-switch / restart / startup notices are untouched', - pluginSource.includes('crashLine,') && pluginSource.includes('hintText,') && - pluginSource.includes("t('restart-starting'),") && pluginSource.includes('formatHandoffNotice(') && - pluginSource.includes('dsh-tui startup failed:')) - check('wiring: the count line is read from the dictionary, not a literal', - pluginSource.includes("t('exit-cleaned-unspoken-sessions'")) -} - -rmSync(root, { recursive: true, force: true }) -console.log(results.join('\n')) -console.log(`verify-session-cleanup-exit: ${results.length - failures}/${results.length} checks passed`) -if (failures > 0) process.exit(1) + 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 index 96984053c..043a27124 100644 --- a/scripts/verify-session-list-metadata.ts +++ b/scripts/verify-session-list-metadata.ts @@ -627,8 +627,67 @@ await checkAsync('the key disappears only when the last registration is released section(SECTIONS[4]) -check('no projection service at all: attach is a no-op', () => { - attachSessionListMetadata(compositionRoot({}) as never) +/** + * 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[] = [] From 56a72b684751b4e05e965827e0049e80655e2ece Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 12:33:15 +0800 Subject: [PATCH 10/24] fix(adapter): register the session-list projection before the boot agent Task: T-FIX-05 --- scripts/verify-session-list-metadata.ts | 155 ++++++++++++++++++++++++ src/dsh-adapter/plugin.ts | 23 ++-- 2 files changed, 170 insertions(+), 8 deletions(-) diff --git a/scripts/verify-session-list-metadata.ts b/scripts/verify-session-list-metadata.ts index 043a27124..c86f428de 100644 --- a/scripts/verify-session-list-metadata.ts +++ b/scripts/verify-session-list-metadata.ts @@ -51,6 +51,15 @@ * 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 @@ -81,6 +90,7 @@ const SECTIONS = [ '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')) { @@ -1023,6 +1033,151 @@ await checkAsync('the real schemastery Schema has no .parse (the fact this contr ) }) +// ── 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 diff --git a/src/dsh-adapter/plugin.ts b/src/dsh-adapter/plugin.ts index c8e772dac..d3cf1ba07 100644 --- a/src/dsh-adapter/plugin.ts +++ b/src/dsh-adapter/plugin.ts @@ -250,6 +250,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 { @@ -654,14 +669,6 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, // empty and the channel falls back to the last-request sample — as does a // non-DSH session, whose id the DSH meter never projects. const contextOccupancyStore = createContextOccupancyStore(ctx) - // 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. 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. Registered here, before the channel opens, so the boot session's - // own checkpoints already carry the key. - attachSessionListMetadata(ctx) // The channel holds a backend session; this DSH one owns the resolved // handle (disposed by the binding when a later adoption replaces it). let startupSession: AgentSession From 32d9f7522ef5267e172cd402f7f6e115ac346831 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 12:42:57 +0800 Subject: [PATCH 11/24] test(adapter): drop stale plugin.ts line pins from the sweep regression Task: T-FIX-07 --- scripts/verify-session-cleanup-exit.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/verify-session-cleanup-exit.tsx b/scripts/verify-session-cleanup-exit.tsx index 77bde34a9..a5b295331 100644 --- a/scripts/verify-session-cleanup-exit.tsx +++ b/scripts/verify-session-cleanup-exit.tsx @@ -64,12 +64,15 @@ * `git checkout HEAD -- src/dsh-adapter/plugin.ts`: * * (a) the branch feeds empty process facts — write `currentSessionId: () => undefined` - * and `liveSessionIds: () => new Set()` at `plugin.ts:1852-1853` + * 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 (`plugin.ts:1851-1855`), leaving `const swept = undefined as - * UnspokenSweepResult | undefined` so the notice falls back to `hint` + * 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 …` From 0b5085a99e69dcdc534beb09bdc72c772198016d Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 12:46:48 +0800 Subject: [PATCH 12/24] test(adapter): describe the DATA_DIR constant instead of pinning its line range Task: T-FIX-08 --- scripts/verify-session-cleanup-exit.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/verify-session-cleanup-exit.tsx b/scripts/verify-session-cleanup-exit.tsx index a5b295331..17a8bda8a 100644 --- a/scripts/verify-session-cleanup-exit.tsx +++ b/scripts/verify-session-cleanup-exit.tsx @@ -102,9 +102,10 @@ * * ## Isolation (B-12) * - * `DATA_DIR` is a module-level constant (`src/utils/paths.ts:14-22`), 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 + * `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. From efb63aad1881b50dbed6dde9799fc55e69f5073f Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 15:14:03 +0800 Subject: [PATCH 13/24] fix(session): keep never-used fresh sessions out of JSONL despite create-time flushes Task: T-FIX-09 --- scripts/verify-empty-session-persistence.ts | 183 ++++++++++++++++++- src/dsh-adapter/channel/background-action.ts | 7 +- src/dsh-adapter/fresh-agent.ts | 10 +- 3 files changed, 188 insertions(+), 12 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 0718586db..fb1f98e93 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -5,11 +5,32 @@ * 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 five + * channel actions that created sessions outside the gate are covered by their + * creation shape. + * * 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. + * 2. Real revert: 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). */ 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' @@ -78,6 +99,9 @@ const { createFreshAgent, isUnstoredFreshSession } = await import('../src/dsh-ad const { createChannel } = await import('../src/dsh-adapter/channel.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 negativeControls = process.argv.includes('--negative-controls') class ScriptedAdapter extends LlmAdapter { async resolveModel(provider: string, model: string) { return { provider, id: model, name: model } } @@ -110,13 +134,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 +248,109 @@ 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) + 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 copy a source prefix into the child. 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. + // Verified shape, not silence: an idle seeded child stores exactly its own + // log — the inherited prefix plus the child's own seed marker — and no + // create-time checkpoint adds anything to it. + const seedSource = await fresh('channel-seed-source') + const seed = seedSource.agent.session.snapshotEvents() + assert.deepEqual(seed.map(event => event.type), policyTypes) + const seededSites: readonly { name: string; parentSession: SessionId | undefined }[] = [ + { name: 'channel-model-switch', parentSession: undefined }, + { name: 'channel-session-fork', parentSession: undefined }, + { name: 'channel-session-rewind', parentSession: seedSource.agent.session.id }, + { name: 'channel-session-tree-actions', parentSession: seedSource.agent.session.id }, + ] + for (const site of seededSites) { + const id = options(site.name).sessionId + createFlushes.add(String(id)) + const child = await ctx.agents.create(liveSessionCreateOptions({ + sessionId: id, + seed, + runtimeSession: seedSource.agent.session, + inheritedCount: seed.length, + cwd: root, + ...(site.parentSession === undefined ? {} : { parentSession: site.parentSession }), + agentOptions: { provider: 'scripted', model: 'scripted' }, + })) + handles.push(child) + await sleep(250) + const persisted = await stored(child.agent.session) + assert.deepEqual(persisted, child.agent.session.snapshotEvents(), `${site.name}: an idle seeded child stores exactly its own log (inherited prefix plus the child's seed marker)`) + assert.deepEqual(persisted.slice(0, 3).map(event => event.type), policyTypes, `${site.name}: no create-time checkpoint adds a suffix`) + } + + // 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) + 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 // 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 +492,20 @@ 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') +} + function verifyHandoffs(savedId: SessionId): void { const cases = [ { name: 'startup empty /restart', kind: 'restart', session: '', args: [], expected: [] }, @@ -372,6 +534,7 @@ function verifyHandoffs(savedId: SessionId): void { } try { + verifyChannelWiring() const savedId = await verify('none') await verify('zstd') verifyHandoffs(savedId) diff --git a/src/dsh-adapter/channel/background-action.ts b/src/dsh-adapter/channel/background-action.ts index 89c196d88..e32d4274d 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/fresh-agent.ts b/src/dsh-adapter/fresh-agent.ts index 4a4c92c16..dd0adeb28 100644 --- a/src/dsh-adapter/fresh-agent.ts +++ b/src/dsh-adapter/fresh-agent.ts @@ -114,7 +114,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) } From 127d193a013d230984f15c496a71987d5a375bd9 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 15:30:44 +0800 Subject: [PATCH 14/24] fix(session): do not seed a child from a never-used source session Task: T-FIX-10 --- scripts/verify-empty-session-persistence.ts | 294 +++++++++++++++--- src/dsh-adapter/channel/model-switch.ts | 52 +++- src/dsh-adapter/channel/session-fork.ts | 43 ++- src/dsh-adapter/channel/session-rewind.ts | 60 ++-- .../channel/session-tree-actions.ts | 65 ++-- 5 files changed, 402 insertions(+), 112 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index fb1f98e93..42dfb2956 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -10,9 +10,18 @@ * 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 five - * channel actions that created sessions outside the gate are covered by their - * creation shape. + * 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 `isUnstoredFreshSession` (the repo's own + * never-used verdict, `src/dsh-adapter/fresh-agent.ts`) and starts an unseeded + * fresh session instead. Both halves are pinned here: the creation shapes below + * drive the real host, and `verifySeededWiring` reads the four actions to prove + * the verdict is what selects the unseeded branch. * * Run: node --import tsx/esm scripts/verify-empty-session-persistence.ts * @@ -21,12 +30,22 @@ * 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. - * 2. Real revert: restore `start(); await drain()` at the head of + * shell" a discriminating assertion instead of a vacuous one. It also + * replays the pre-fix SEED for the same never-used sources, and reverses + * the four wiring checks (`=> neverUsed` → `=> false`, and the verdict + * dropped) 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 + * `const create = (): Promise => neverUsed` with `=> false` — + * then the same command → expect FAIL "channel-model-switch: the + * never-used verdict selects the unseeded branch". The creation-shape + * cases stay green there: they drive the creation, the wiring check reads + * the action. */ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -97,6 +116,7 @@ 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 { createChannel } = await import('../src/dsh-adapter/channel.js') +const { extractEntries } = 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') @@ -301,41 +321,13 @@ async function verify(compression: 'zstd' | 'none'): Promise { assert.ok(await settled(() => existsSync(artifact(ungated.agent.session))), 'the ungated /bg shape still publishes the shell') await ungated.dispose() - // The other four copy a source prefix into the child. 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. - // Verified shape, not silence: an idle seeded child stores exactly its own - // log — the inherited prefix plus the child's own seed marker — and no - // create-time checkpoint adds anything to it. - const seedSource = await fresh('channel-seed-source') - const seed = seedSource.agent.session.snapshotEvents() - assert.deepEqual(seed.map(event => event.type), policyTypes) - const seededSites: readonly { name: string; parentSession: SessionId | undefined }[] = [ - { name: 'channel-model-switch', parentSession: undefined }, - { name: 'channel-session-fork', parentSession: undefined }, - { name: 'channel-session-rewind', parentSession: seedSource.agent.session.id }, - { name: 'channel-session-tree-actions', parentSession: seedSource.agent.session.id }, - ] - for (const site of seededSites) { - const id = options(site.name).sessionId - createFlushes.add(String(id)) - const child = await ctx.agents.create(liveSessionCreateOptions({ - sessionId: id, - seed, - runtimeSession: seedSource.agent.session, - inheritedCount: seed.length, - cwd: root, - ...(site.parentSession === undefined ? {} : { parentSession: site.parentSession }), - agentOptions: { provider: 'scripted', model: 'scripted' }, - })) - handles.push(child) - await sleep(250) - const persisted = await stored(child.agent.session) - assert.deepEqual(persisted, child.agent.session.snapshotEvents(), `${site.name}: an idle seeded child stores exactly its own log (inherited prefix plus the child's seed marker)`) - assert.deepEqual(persisted.slice(0, 3).map(event => event.type), policyTypes, `${site.name}: no create-time checkpoint adds a suffix`) - } + // 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 @@ -352,6 +344,121 @@ async function verify(compression: 'zstd' | 'none'): Promise { await verifyCheckpointPublication() persistence.create = originalCreate + /** + * 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. + 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) + 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() + // Hold the first suffix in the public writer while more events arrive, // then fail the next suffix. A checkpoint must retry that exact prefix. const entered = Promise.withResolvers() @@ -506,6 +613,112 @@ function verifyChannelWiring(): void { console.log('PASS /bg creation wiring') } +/** + * The four seeded channel actions. `verifySeededFamily` drives the creation + * SHAPES through the real host; it would stay green if an action went back to + * seeding unconditionally, because the shapes are driven here rather than by + * the action. So pin the wiring: each action must ask the never-used verdict + * and let THAT verdict select the unseeded branch, in that order. 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 wiring + * to prove the checks can fail (L-044). + */ +const SEEDED_SITES: readonly { + readonly name: string + readonly file: string + /** The expression the verdict is read from, for the negative control. */ + readonly subject: string + readonly markers: readonly string[] +}[] = [ + { + name: 'channel-model-switch', + file: 'model-switch.ts', + subject: 'source', + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'seed = neverUsed ? [] : sliceLiveSessionSeed(source)', + 'const create = (): Promise => neverUsed', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + ], + }, + { + name: 'channel-session-fork', + file: 'session-fork.ts', + subject: 'source', + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'seed = neverUsed ? [] : sliceLiveSessionSeed(source)', + 'deps.createDetachedHandle(() => neverUsed', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + ], + }, + { + name: 'channel-session-rewind', + file: 'session-rewind.ts', + subject: 'source', + markers: [ + 'const neverUsed = isUnstoredFreshSession(source)', + 'seed = neverUsed ? [] : sliceLiveSessionSeed(source, boundary)', + 'const create = (): Promise => neverUsed', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + ], + }, + { + name: 'channel-session-tree-actions', + file: 'session-tree-actions.ts', + subject: 'entrySession', + markers: [ + // A persisted foreign source is on disk and never in this state, so the + // verdict is asked about the LIVE source only. + 'const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession)', + 'const seed = neverUsed ? [] : sourceEvents.filter(event => event.seq <= target.boundary)', + 'const create = (): Promise => neverUsed', + '? createFreshAgent(ctx, agents, {', + ': agents.create(liveSessionCreateOptions({', + ], + }, +] + +/** 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') + } + 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 never-used verdict selects the unseeded branch`) + if (negativeControls) { + // The exact revert this task forbids — seed unconditionally — and the + // verdict dropped entirely. Both must be caught (L-044 / L-048). + const unconditional = seededWiringViolations(site, source.replaceAll('=> neverUsed', '=> false')) + assert.ok(unconditional.length > 0, `negative control: ${site.name} wiring catches seeding unconditionally`) + const verdictless = seededWiringViolations(site, source.replaceAll(`isUnstoredFreshSession(${site.subject})`, 'false')) + assert.ok(verdictless.length > 0, `negative control: ${site.name} wiring catches a dropped verdict`) + console.log(`PASS negative control: ${site.name} wiring catches "seed unconditionally" and a dropped verdict`) + } + console.log(`PASS ${site.name} seeded wiring`) + } +} + function verifyHandoffs(savedId: SessionId): void { const cases = [ { name: 'startup empty /restart', kind: 'restart', session: '', args: [], expected: [] }, @@ -535,6 +748,7 @@ function verifyHandoffs(savedId: SessionId): void { try { verifyChannelWiring() + verifySeededWiring() const savedId = await verify('none') await verify('zstd') verifyHandoffs(savedId) diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index 88026d256..0d4b5f974 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -9,6 +9,7 @@ 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 { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' import { reserveNewSession } from '../../sessionMounts.js' import { attachSessionToWorkspace } from '../workspace.js' @@ -50,6 +51,14 @@ 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 + // A session nobody has typed into is not a conversation to continue. A + // seed would copy its initialization, 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 replacement therefore + // starts unseeded, as an ordinary fresh session, under that same deferral. + const neverUsed = isUnstoredFreshSession(source) let seed: readonly SessionEvent[] try { // A compaction checkpoint may not settle after the model fork snapshot. @@ -57,30 +66,41 @@ export function createModelSwitchAction( // 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) + seed = neverUsed ? [] : sliceLiveSessionSeed(source) } catch (error) { deps.notify(t('model-switch-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }); return false } 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 => neverUsed + // No seed and no parent either: a never-used session has no conversation + // 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 () => createDshSession(ctx, await create())) } 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..98c84b2d9 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -6,6 +6,7 @@ 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 { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveMount, type MountReservation } from '../../sessionMounts.js' @@ -41,13 +42,20 @@ export function createForkSessionAction( } await deps.settleCompaction() const source = deps.source() + // A session nobody has typed into holds initialization, not a conversation. + // 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. There is nothing to copy + // anyway: the fork starts unseeded, as an ordinary fresh session. + const neverUsed = isUnstoredFreshSession(source) const childId = SessionId(randomUUID()) let seed: readonly SessionEvent[] 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) + seed = neverUsed ? [] : sliceLiveSessionSeed(source) } catch (error) { deps.notify(t('fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return false @@ -66,19 +74,26 @@ 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(() => neverUsed + ? 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' }) diff --git a/src/dsh-adapter/channel/session-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index be47085d0..9e5b70681 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -6,6 +6,7 @@ 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' @@ -70,6 +71,14 @@ 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 + // A session nobody has typed into holds initialization, not a 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. There is no history to cut, + // so a never-used source yields an unseeded child instead. + const neverUsed = isUnstoredFreshSession(source) let seed: readonly SessionEvent[] try { if (boundary < 0) throw new Error('cannot rewind to the very first message') @@ -77,36 +86,47 @@ export function createRewindToAction( // 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) + seed = neverUsed ? [] : sliceLiveSessionSeed(source, boundary) } 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 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 => neverUsed + // No seed and no parent: a never-used session has no history to cut and + // no conversation 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 () => createDshSession(ctx, await create())) } 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..3e763f5b2 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -8,6 +8,7 @@ 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 { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' @@ -101,7 +102,16 @@ export function createTreeRewindAction( deps.notify(t('rewind-settling'), { color: 'error' }) return null } - const seed = sourceEvents.filter(event => event.seq <= target.boundary) + // A LIVE source nobody has typed into holds initialization, not a + // 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. There is no history to + // cut, so the child starts unseeded instead. A persisted foreign source is + // never in that state: its log is on disk, and reaching its entries at all + // proves it holds real content. + const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession) + const seed = neverUsed ? [] : sourceEvents.filter(event => event.seq <= target.boundary) const inheritedCount = seed.length const closeAfterCreate = target.closeTurn !== undefined && entrySession.header?.version >= 3 if (target.closeTurn !== undefined && !closeAfterCreate) { @@ -112,27 +122,38 @@ 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 => neverUsed + // No seed and no parent: a never-used session has no history to cut and + // no conversation 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 () => createDshSession(ctx, await create())) } catch { reservation.abandon() deps.notify(t('rewind-create-failed'), { color: 'error' }) From bccc7d88a35024ac89a29226aab33b68d62980bd Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 15:48:36 +0800 Subject: [PATCH 15/24] fix(session): stop advertising a resume id for an unstored fork and widen the never-used criterion Task: T-FIX-11 --- scripts/verify-empty-session-persistence.ts | 243 +++++++++++++++--- src/dsh-adapter/channel/model-switch.ts | 49 +++- src/dsh-adapter/channel/session-fork.ts | 58 ++++- src/dsh-adapter/channel/session-rewind.ts | 45 +++- .../channel/session-tree-actions.ts | 44 +++- src/i18n.ts | 8 + 6 files changed, 363 insertions(+), 84 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 42dfb2956..b4531363c 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -17,11 +17,18 @@ * 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 `isUnstoredFreshSession` (the repo's own - * never-used verdict, `src/dsh-adapter/fresh-agent.ts`) and starts an unseeded - * fresh session instead. Both halves are pinned here: the creation shapes below - * drive the real host, and `verifySeededWiring` reads the four actions to prove - * the verdict is what selects the unseeded branch. + * event. Each of them now asks whether the SOURCE HOLDS NO CONVERSATION: the + * deferral's own `isUnstoredFreshSession` (`src/dsh-adapter/fresh-agent.ts`) + * first, then the exit sweep's evidence rule (`src/dsh-adapter/unspoken-sessions.ts`'s + * `conversationEvidence`, asked through its exported judges, read from the live + * snapshot the seed is cut from). The second half is what an already-stored + * shell satisfies and the first cannot see: a shell left by an earlier process, + * one web created, or one whose `agent-preset/selected` already started the + * deferral. A conversation-less source 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, and + * `verifySeededWiring` reads the four actions to prove the verdict is what + * selects the unseeded branch. * * Run: node --import tsx/esm scripts/verify-empty-session-persistence.ts * @@ -31,9 +38,12 @@ * 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, and reverses - * the four wiring checks (`=> neverUsed` → `=> false`, and the verdict - * dropped) to prove those checks can fail (LESSONS L-044 / L-048). + * replays the pre-fix SEED for the same never-used sources and for an + * on-disk shell (asserting that the child DOES appear and that the pre-fix + * notice DOES advertise a resume command), and reverses the four wiring + * checks three ways — `=> sourceHoldsNoConversation` → `=> false`, the + * widened evidence line removed, 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` @@ -41,11 +51,20 @@ * 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 - * `const create = (): Promise => neverUsed` with `=> false` — - * then the same command → expect FAIL "channel-model-switch: the - * never-used verdict selects the unseeded branch". The creation-shape - * cases stay green there: they drive the creation, the wiring check reads - * the action. + * `=> sourceHoldsNoConversation` with `=> false` — then the same command → + * expect FAIL "channel-model-switch: the never-used 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 `sourceHoldsNoConversation = isUnstoredFreshSession(source)` + + * `|| CONVERSATION_EVIDENCE.log({ … }) === undefined` with + * `sourceHoldsNoConversation = isUnstoredFreshSession(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. */ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -116,10 +135,12 @@ 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 { createChannel } = await import('../src/dsh-adapter/channel.js') +const { createForkSessionAction } = await import('../src/dsh-adapter/channel/session-fork.js') const { extractEntries } = 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 { t } = await import('../src/i18n.js') const negativeControls = process.argv.includes('--negative-controls') @@ -459,6 +480,123 @@ async function verify(compression: 'zstd' | 'none'): Promise { } 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() + // Hold the first suffix in the public writer while more events arrive, // then fail the next suffix. A checkpoint must retry that exact prefix. const entered = Promise.withResolvers() @@ -615,29 +753,39 @@ function verifyChannelWiring(): void { /** * The four seeded channel actions. `verifySeededFamily` drives the creation - * SHAPES through the real host; it would stay green if an action went back to - * seeding unconditionally, because the shapes are driven here rather than by - * the action. So pin the wiring: each action must ask the never-used verdict - * and let THAT verdict select the unseeded branch, in that order. 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 wiring - * to prove the checks can fail (L-044). + * SHAPES through the real host and `verifyOnDiskShellSource` drives `/fork` + * itself; 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 ask the conversation verdict — the deferral's + * `isUnstoredFreshSession` AND the sweep's evidence rule — and let THAT verdict + * select the unseeded branch, in that order. 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 widening narrowed back to + * `isUnstoredFreshSession`, the verdict dropped, and `/fork`'s notice reverting + * to an advertised resume command. */ const SEEDED_SITES: readonly { readonly name: string readonly file: string - /** The expression the verdict is read from, for the negative control. */ + /** The widening's own line: the evidence rule the verdict now also asks. */ + readonly evidence: string + /** The session expression the verdict is read from, for the "verdict dropped" reversal. */ readonly subject: 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', + evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', subject: 'source', markers: [ - 'const neverUsed = isUnstoredFreshSession(source)', - 'seed = neverUsed ? [] : sliceLiveSessionSeed(source)', - 'const create = (): Promise => neverUsed', + 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', + '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source)', + 'const create = (): Promise => sourceHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -645,23 +793,29 @@ const SEEDED_SITES: readonly { { name: 'channel-session-fork', file: 'session-fork.ts', + evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', subject: 'source', + notice: "? t('fork-done-unstored', { id: String(childId) })", markers: [ - 'const neverUsed = isUnstoredFreshSession(source)', - 'seed = neverUsed ? [] : sliceLiveSessionSeed(source)', - 'deps.createDetachedHandle(() => neverUsed', + 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', + '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source)', + 'deps.createDetachedHandle(() => sourceHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', + "? t('fork-done-unstored', { id: String(childId) })", ], }, { name: 'channel-session-rewind', file: 'session-rewind.ts', + evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', subject: 'source', markers: [ - 'const neverUsed = isUnstoredFreshSession(source)', - 'seed = neverUsed ? [] : sliceLiveSessionSeed(source, boundary)', - 'const create = (): Promise => neverUsed', + 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', + '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source, boundary)', + 'const create = (): Promise => sourceHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -669,13 +823,15 @@ const SEEDED_SITES: readonly { { name: 'channel-session-tree-actions', file: 'session-tree-actions.ts', + evidence: '|| CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined', subject: 'entrySession', markers: [ // A persisted foreign source is on disk and never in this state, so the // verdict is asked about the LIVE source only. - 'const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession)', - 'const seed = neverUsed ? [] : sourceEvents.filter(event => event.seq <= target.boundary)', - 'const create = (): Promise => neverUsed', + 'const sourceHoldsNoConversation = forkFromLive && (isUnstoredFreshSession(entrySession)', + '|| CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined', + 'const seed = sourceHoldsNoConversation ? [] : sourceEvents.filter(event => event.seq <= target.boundary)', + 'const create = (): Promise => sourceHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -691,6 +847,9 @@ function seededWiringViolations( if (!/import \{ createFreshAgent, isUnstoredFreshSession \} from '\.\.\/fresh-agent\.js'/.test(source)) { violations.push('does not import createFreshAgent + isUnstoredFreshSession') } + if (!/import \{ unspokenJudges \} from '\.\.\/unspoken-sessions\.js'/.test(source)) { + violations.push('does not ask the sweep evidence rule through unspokenJudges') + } let cursor = -1 for (const marker of site.markers) { const at = source.indexOf(marker) @@ -707,13 +866,23 @@ function verifySeededWiring(): void { const source = readFileSync(path, 'utf8') assert.deepEqual(seededWiringViolations(site, source), [], `${site.name}: the never-used verdict selects the unseeded branch`) if (negativeControls) { - // The exact revert this task forbids — seed unconditionally — and the - // verdict dropped entirely. Both must be caught (L-044 / L-048). - const unconditional = seededWiringViolations(site, source.replaceAll('=> neverUsed', '=> false')) + // The reversals this task forbids — seed unconditionally, narrow the + // widened verdict back to T-FIX-10's criterion, drop the verdict, and put + // the resume command back in `/fork`'s notice. Every one must be caught + // (L-044 / L-048). + const unconditional = seededWiringViolations(site, source.replaceAll('=> sourceHoldsNoConversation', '=> false')) assert.ok(unconditional.length > 0, `negative control: ${site.name} wiring catches seeding unconditionally`) + const narrowed = seededWiringViolations(site, source.replace(site.evidence, '')) + assert.ok(narrowed.length > 0, `negative control: ${site.name} wiring catches the widened verdict narrowed back to isUnstoredFreshSession`) const verdictless = seededWiringViolations(site, source.replaceAll(`isUnstoredFreshSession(${site.subject})`, 'false')) assert.ok(verdictless.length > 0, `negative control: ${site.name} wiring catches a dropped verdict`) - console.log(`PASS negative control: ${site.name} wiring catches "seed unconditionally" and a dropped verdict`) + 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 widening narrowed away, a dropped verdict${site.notice === undefined ? '' : ' and the resume notice coming back'}`) } console.log(`PASS ${site.name} seeded wiring`) } diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index 0d4b5f974..1ee346859 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -8,9 +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 { unspokenJudges } from '../unspoken-sessions.js' import { reserveNewSession } from '../../sessionMounts.js' import { attachSessionToWorkspace } from '../workspace.js' import type { DshChannelBinding } from './binding.js' @@ -23,6 +24,21 @@ type Binding = DshChannelBinding type SwitchState = Parameters[0] & Pick +/** + * The exit sweep's own "did a person speak here" rule, asked through its + * exported judges instead of restated here: `unspokenJudges().log` IS + * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human + * message). A FOURTH human-speech rule is exactly what the three existing ones + * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read + * lazily by the `held` rule, which is never asked here, so they stay inert + * rather than fabricated. + */ +const CONVERSATION_EVIDENCE = unspokenJudges({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, +}) + /** Model-route adoption transaction. It settles compaction before its fork snapshot and owns the post-commit reset. */ export function createModelSwitchAction( ctx: Context, @@ -52,21 +68,30 @@ export function createModelSwitchAction( 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 - // A session nobody has typed into is not a conversation to continue. A - // seed would copy its initialization, 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 replacement therefore - // starts unseeded, as an ordinary fresh session, under that same deferral. - const neverUsed = isUnstoredFreshSession(source) + // A source that holds no conversation is not one to continue, whether the + // shell came from the deferral this process installed (the never-used + // verdict) or was already on disk when the process started. A seed would + // copy that shell, 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 live snapshot 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 snapshotted. The replacement + // therefore starts unseeded, as an ordinary fresh session, under that same + // deferral. + let sourceHoldsNoConversation: boolean let seed: readonly SessionEvent[] 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 verdict must be read from the settled log, never from before it. await deps.settleCompaction() + sourceHoldsNoConversation = isUnstoredFreshSession(source) + || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = neverUsed ? [] : sliceLiveSessionSeed(source) + seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source) } catch (error) { deps.notify(t('model-switch-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }); return false } const childId = SessionId(randomUUID()) // Announce the id before the factory: from the moment `agents.create` @@ -76,8 +101,8 @@ export function createModelSwitchAction( const composed = await composePreset(ctx, runningPresetOf(source)) let candidate: AgentSession try { - const create = (): Promise => neverUsed - // No seed and no parent either: a never-used session has no conversation + const create = (): Promise => sourceHoldsNoConversation + // No seed and no parent either: a conversation-less session has nothing // for lineage to describe, and the child stands as its own root // (session-lineage.ts). ? createFreshAgent(ctx, agents, { diff --git a/src/dsh-adapter/channel/session-fork.ts b/src/dsh-adapter/channel/session-fork.ts index 98c84b2d9..0638f2050 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -5,9 +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 { unspokenJudges } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveMount, type MountReservation } from '../../sessionMounts.js' import { mountFailureText } from '../../sessions/resumeFailure.js' @@ -16,6 +17,21 @@ import type { ChannelState } from './types.js' type ForkState = Pick +/** + * The exit sweep's own "did a person speak here" rule, asked through its + * exported judges instead of restated here: `unspokenJudges().log` IS + * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human + * message). A FOURTH human-speech rule is exactly what the three existing ones + * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read + * lazily by the `held` rule, which is never asked here, so they stay inert + * rather than fabricated. + */ +const CONVERSATION_EVIDENCE = unspokenJudges({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, +}) + /** Create a detached `/fork` copy without adopting it into the foreground. */ export function createForkSessionAction( ctx: Context, @@ -42,24 +58,32 @@ export function createForkSessionAction( } await deps.settleCompaction() const source = deps.source() - // A session nobody has typed into holds initialization, not a conversation. - // 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. There is nothing to copy - // anyway: the fork starts unseeded, as an ordinary fresh session. - const neverUsed = isUnstoredFreshSession(source) - const childId = SessionId(randomUUID()) + // A source that holds no conversation is not one to continue, whether the + // shell came from the deferral this process installed (the never-used + // verdict) or was already on disk when the process started. A seed would + // copy that shell, 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 live snapshot + // 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 + // snapshotted. There is nothing to copy anyway: the fork starts unseeded, + // as an ordinary fresh session. + let sourceHoldsNoConversation: boolean let seed: readonly SessionEvent[] try { + sourceHoldsNoConversation = isUnstoredFreshSession(source) + || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = neverUsed ? [] : sliceLiveSessionSeed(source) + seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source) } catch (error) { deps.notify(t('fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return false } + const childId = SessionId(randomUUID()) const forkComposed = await composePreset(ctx, runningPresetOf(source)) // Reserve BEFORE the factory, and hold it past `detached.release()`. // @@ -74,7 +98,7 @@ export function createForkSessionAction( const reservation: MountReservation = reserved.ok ? reserved.reservation : { settle: () => {}, abandon: () => {} } let detached: { handle: AgentHandle; release(): Promise } try { - detached = await deps.createDetachedHandle(() => neverUsed + detached = await deps.createDetachedHandle(() => sourceHoldsNoConversation ? createFreshAgent(ctx, agents, { sessionId: childId, meta: { cwd: state.cwd, ...(forkComposed.agentPreset === undefined ? {} : { agentPreset: forkComposed.agentPreset }) }, @@ -113,6 +137,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 @@ -124,7 +152,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(sourceHoldsNoConversation + ? 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-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index 9e5b70681..eef90a82d 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -10,6 +10,7 @@ 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 { unspokenJudges } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import type { DshChannelBinding } from './binding.js' @@ -19,6 +20,21 @@ import type { ChannelState, ChatRow } from './types.js' type Binding = DshChannelBinding type RewindState = Pick +/** + * The exit sweep's own "did a person speak here" rule, asked through its + * exported judges instead of restated here: `unspokenJudges().log` IS + * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human + * message). A FOURTH human-speech rule is exactly what the three existing ones + * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read + * lazily by the `held` rule, which is never asked here, so they stay inert + * rather than fabricated. + */ +const CONVERSATION_EVIDENCE = unspokenJudges({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, +}) + async function waitForTurnEnd( session: unknown, fromSeq: number, @@ -72,21 +88,28 @@ export function createRewindToAction( if (event.type === 'turn/end') break } const source = deps.binding.agent.session - // A session nobody has typed into holds initialization, not a 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. There is no history to cut, - // so a never-used source yields an unseeded child instead. - const neverUsed = isUnstoredFreshSession(source) + // A source that holds no conversation is not one to continue, whether the + // shell came from the deferral this process installed (the never-used + // verdict) or was already on disk when the process started. A seed would + // copy that shell, 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. There is no history to cut, so such a source yields an + // unseeded child instead; the evidence is read from the live snapshot in + // hand (in memory, never a second read of the log). Reaching this branch at + // all still needs a rewind row, and only real content offers one + // (`Chat.tsx`), so it is the depth behind `/model` and `/fork`. + let sourceHoldsNoConversation: boolean let seed: readonly SessionEvent[] try { if (boundary < 0) throw new Error('cannot rewind to the very first message') + sourceHoldsNoConversation = isUnstoredFreshSession(source) + || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = neverUsed ? [] : sliceLiveSessionSeed(source, boundary) + seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source, boundary) } catch (error) { deps.notify(t('rewind-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return null @@ -98,9 +121,9 @@ export function createRewindToAction( const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - const create = (): Promise => neverUsed - // No seed and no parent: a never-used session has no history to cut and - // no conversation for lineage to describe, so the child is an ordinary + const create = (): Promise => sourceHoldsNoConversation + // No seed and no parent: a conversation-less session has no history to + // cut 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, diff --git a/src/dsh-adapter/channel/session-tree-actions.ts b/src/dsh-adapter/channel/session-tree-actions.ts index 3e763f5b2..3116eb010 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -10,6 +10,7 @@ import { readPersistedSession, type SessionReader } from '../compat/persistence. import { closeLiveForkTurn } from '../compat/liveSession.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, resolvePersistedPreset, runningPresetOf } from '../presets.js' +import { unspokenJudges } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import { forkTarget, rewindTarget, turnUserText } from '../sessionTree.js' @@ -20,6 +21,21 @@ import type { ChannelState } from './types.js' type Binding = DshChannelBinding type TreeRewindState = Pick +/** + * The exit sweep's own "did a person speak here" rule, asked through its + * exported judges instead of restated here: `unspokenJudges().log` IS + * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human + * message). A FOURTH human-speech rule is exactly what the three existing ones + * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read + * lazily by the `held` rule, which is never asked here, so they stay inert + * rather than fabricated. + */ +const CONVERSATION_EVIDENCE = unspokenJudges({ + currentSessionId: () => undefined, + liveSessionIds: () => new Set(), + isSubagentOrDescendant: () => false, +}) + async function waitForTurnEnd(session: unknown, fromSeq: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -102,16 +118,20 @@ export function createTreeRewindAction( deps.notify(t('rewind-settling'), { color: 'error' }) return null } - // A LIVE source nobody has typed into holds initialization, not a - // 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. There is no history to - // cut, so the child starts unseeded instead. A persisted foreign source is + // A LIVE source that holds no conversation is not one to continue, whether + // the shell came from the deferral this process installed (the never-used + // verdict) or was already on disk when the process started. A seed would + // copy that shell, 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 snapshot the seed is cut from below, in + // memory, never a second read of the log. A persisted foreign source is // never in that state: its log is on disk, and reaching its entries at all - // proves it holds real content. - const neverUsed = forkFromLive && isUnstoredFreshSession(entrySession) - const seed = neverUsed ? [] : sourceEvents.filter(event => event.seq <= target.boundary) + // proves it holds real content — so the verdict is asked about the LIVE + // source only. + const sourceHoldsNoConversation = forkFromLive && (isUnstoredFreshSession(entrySession) + || CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined) + const seed = sourceHoldsNoConversation ? [] : sourceEvents.filter(event => event.seq <= target.boundary) const inheritedCount = seed.length const closeAfterCreate = target.closeTurn !== undefined && entrySession.header?.version >= 3 if (target.closeTurn !== undefined && !closeAfterCreate) { @@ -122,9 +142,9 @@ export function createTreeRewindAction( const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - const create = (): Promise => neverUsed - // No seed and no parent: a never-used session has no history to cut and - // no conversation for lineage to describe, so the child is an ordinary + const create = (): Promise => sourceHoldsNoConversation + // No seed and no parent: a conversation-less session has no history to + // cut 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, diff --git a/src/i18n.ts b/src/i18n.ts index 2f1a7749c..2ebdf0fd1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -636,6 +636,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' }, From dc9aebf6ddf540bea2f2b8093e7006ccb579edde Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 20:15:04 +0800 Subject: [PATCH 16/24] fix(session): judge the inherited cut, not the source session, for seeding Task: T-FIX-12 --- scripts/verify-empty-session-persistence.ts | 328 +++++++++++++----- src/dsh-adapter/channel/model-switch.ts | 43 +-- src/dsh-adapter/channel/session-fork.ts | 39 ++- src/dsh-adapter/channel/session-rewind.ts | 42 ++- .../channel/session-tree-actions.ts | 41 ++- 5 files changed, 338 insertions(+), 155 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index b4531363c..695c51c81 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -17,18 +17,24 @@ * 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 SOURCE HOLDS NO CONVERSATION: the + * 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`) - * first, then the exit sweep's evidence rule (`src/dsh-adapter/unspoken-sessions.ts`'s - * `conversationEvidence`, asked through its exported judges, read from the live - * snapshot the seed is cut from). The second half is what an already-stored - * shell satisfies and the first cannot see: a shell left by an earlier process, - * one web created, or one whose `agent-preset/selected` already started the - * deferral. A conversation-less source 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, and - * `verifySeededWiring` reads the four actions to prove the verdict is what - * selects the unseeded branch. + * answers first for the live source, then the exit sweep's evidence rule + * (`src/dsh-adapter/unspoken-sessions.ts`'s `conversationEvidence`, asked + * through its exported judges) 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. * * Run: node --import tsx/esm scripts/verify-empty-session-persistence.ts * @@ -38,12 +44,14 @@ * 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 and for an - * on-disk shell (asserting that the child DOES appear and that the pre-fix - * notice DOES advertise a resume command), and reverses the four wiring - * checks three ways — `=> sourceHoldsNoConversation` → `=> false`, the - * widened evidence line removed, and the verdict dropped entirely — to - * prove those checks can fail (LESSONS L-044 / L-048). + * 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` @@ -51,20 +59,33 @@ * 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 - * `=> sourceHoldsNoConversation` with `=> false` — then the same command → - * expect FAIL "channel-model-switch: the never-used verdict selects the - * unseeded branch". The creation-shape cases stay green there: they drive - * the creation, the wiring check reads the action. + * `=> 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 `sourceHoldsNoConversation = isUnstoredFreshSession(source)` + - * `|| CONVERSATION_EVIDENCE.log({ … }) === undefined` with - * `sourceHoldsNoConversation = isUnstoredFreshSession(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. + * lines `seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)` + * + `if (CONVERSATION_EVIDENCE.log({ … }) === undefined) 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 (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = []` + * with + * `if (CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined) 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`). */ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -76,7 +97,7 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { 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 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' @@ -136,7 +157,9 @@ process.env.DSH_TUI_LANG = 'en' const { createFreshAgent, isUnstoredFreshSession } = 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 { extractEntries } = await import('../src/dsh-adapter/sessionTree.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') @@ -393,7 +416,8 @@ async function verify(compression: 'zstd' | 'none'): Promise { // 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. + // 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)') @@ -597,6 +621,141 @@ async function verify(compression: 'zstd' | 'none'): Promise { } 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, 'the child starts from its own initialization') + 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) + 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. const entered = Promise.withResolvers() @@ -753,24 +912,30 @@ function verifyChannelWiring(): void { /** * The four seeded channel actions. `verifySeededFamily` drives the creation - * SHAPES through the real host and `verifyOnDiskShellSource` drives `/fork` - * itself; 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 ask the conversation verdict — the deferral's - * `isUnstoredFreshSession` AND the sweep's evidence rule — and let THAT verdict - * select the unseeded branch, in that order. 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 widening narrowed back to - * `isUnstoredFreshSession`, the verdict dropped, and `/fork`'s notice reverting - * to an advertised resume command. + * SHAPES through the real host, `verifyOnDiskShellSource` drives `/fork` itself + * and `verifyCutPrefixSeeding` drives `/rewind` itself; 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 — and let THAT verdict select the unseeded branch. 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, 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 = 'CONVERSATION_EVIDENCE.log({ events: seed, complete: true })' + const SEEDED_SITES: readonly { readonly name: string readonly file: string - /** The widening's own line: the evidence rule the verdict now also asks. */ - readonly evidence: string - /** The session expression the verdict is read from, for the "verdict dropped" reversal. */ + /** 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 /** A notice line, when the site has one, for the "resume command is back" reversal. */ readonly notice?: string @@ -779,13 +944,13 @@ const SEEDED_SITES: readonly { { name: 'channel-model-switch', file: 'model-switch.ts', - evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + sourceEvents: 'snapshotLiveSessionEvents(source)', subject: 'source', markers: [ - 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', - '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', - 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source)', - 'const create = (): Promise => sourceHoldsNoConversation', + 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', + `if (${CUT_EVIDENCE} === undefined) seed = []`, + 'const cutHoldsNoConversation = seed.length === 0', + 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -793,14 +958,14 @@ const SEEDED_SITES: readonly { { name: 'channel-session-fork', file: 'session-fork.ts', - evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + sourceEvents: 'snapshotLiveSessionEvents(source)', subject: 'source', notice: "? t('fork-done-unstored', { id: String(childId) })", markers: [ - 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', - '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', - 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source)', - 'deps.createDetachedHandle(() => sourceHoldsNoConversation', + 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', + `if (${CUT_EVIDENCE} === undefined) seed = []`, + 'const cutHoldsNoConversation = seed.length === 0', + 'deps.createDetachedHandle(() => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', "? t('fork-done-unstored', { id: String(childId) })", @@ -809,13 +974,13 @@ const SEEDED_SITES: readonly { { name: 'channel-session-rewind', file: 'session-rewind.ts', - evidence: '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', + sourceEvents: 'snapshotLiveSessionEvents(source)', subject: 'source', markers: [ - 'sourceHoldsNoConversation = isUnstoredFreshSession(source)', - '|| CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined', - 'seed = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source, boundary)', - 'const create = (): Promise => sourceHoldsNoConversation', + 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary)', + `if (${CUT_EVIDENCE} === undefined) seed = []`, + 'const cutHoldsNoConversation = seed.length === 0', + 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -823,15 +988,17 @@ const SEEDED_SITES: readonly { { name: 'channel-session-tree-actions', file: 'session-tree-actions.ts', - evidence: '|| CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined', + sourceEvents: 'sourceEvents', subject: 'entrySession', markers: [ - // A persisted foreign source is on disk and never in this state, so the - // verdict is asked about the LIVE source only. - 'const sourceHoldsNoConversation = forkFromLive && (isUnstoredFreshSession(entrySession)', - '|| CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined', - 'const seed = sourceHoldsNoConversation ? [] : sourceEvents.filter(event => event.seq <= target.boundary)', - 'const create = (): Promise => sourceHoldsNoConversation', + 'let seed = 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. + 'if ((forkFromLive && isUnstoredFreshSession(entrySession))', + `|| ${CUT_EVIDENCE} === undefined) seed = []`, + 'const cutHoldsNoConversation = seed.length === 0', + 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', ': agents.create(liveSessionCreateOptions({', ], @@ -864,17 +1031,22 @@ 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 never-used verdict selects the unseeded branch`) + assert.deepEqual(seededWiringViolations(site, source), [], `${site.name}: the cut verdict selects the unseeded branch`) if (negativeControls) { - // The reversals this task forbids — seed unconditionally, narrow the - // widened verdict back to T-FIX-10's criterion, drop the verdict, and put - // the resume command back in `/fork`'s notice. Every one must be caught - // (L-044 / L-048). - const unconditional = seededWiringViolations(site, source.replaceAll('=> sourceHoldsNoConversation', '=> false')) + // The reversals this task forbids — seed unconditionally, put the verdict + // back on the SOURCE session, drop the never-used shortcut, drop the + // verdict, 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 narrowed = seededWiringViolations(site, source.replace(site.evidence, '')) - assert.ok(narrowed.length > 0, `negative control: ${site.name} wiring catches the widened verdict narrowed back to isUnstoredFreshSession`) - const verdictless = seededWiringViolations(site, source.replaceAll(`isUnstoredFreshSession(${site.subject})`, 'false')) + const sourceJudged = seededWiringViolations(site, source.replace(CUT_EVIDENCE, `CONVERSATION_EVIDENCE.log({ events: ${site.sourceEvents}, complete: true })`)) + 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 notice = site.notice === undefined ? [] @@ -882,7 +1054,7 @@ function verifySeededWiring(): void { 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 widening narrowed away, a dropped verdict${site.notice === undefined ? '' : ' and the resume notice coming back'}`) + 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${site.notice === undefined ? '' : ' and the resume notice coming back'}`) } console.log(`PASS ${site.name} seeded wiring`) } diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index 1ee346859..50c66391d 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -8,7 +8,7 @@ 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, snapshotLiveSessionEvents } from '../compat/index.js' +import { liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' import { unspokenJudges } from '../unspoken-sessions.js' @@ -68,31 +68,32 @@ export function createModelSwitchAction( 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 - // A source that holds no conversation is not one to continue, whether the - // shell came from the deferral this process installed (the never-used - // verdict) or was already on disk when the process started. A seed would - // copy that shell, 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 live snapshot 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 snapshotted. The replacement - // therefore starts unseeded, as an ordinary fresh session, under that same - // deferral. - let sourceHoldsNoConversation: boolean + // 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[] try { // A compaction checkpoint may not settle after the model fork snapshot — - // and the verdict must be read from the settled log, never from before it. + // and the cut must be read from the settled log, never from before it. await deps.settleCompaction() - sourceHoldsNoConversation = isUnstoredFreshSession(source) - || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source) + seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) + if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] } 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 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 @@ -101,9 +102,9 @@ export function createModelSwitchAction( const composed = await composePreset(ctx, runningPresetOf(source)) let candidate: AgentSession try { - const create = (): Promise => sourceHoldsNoConversation - // No seed and no parent either: a conversation-less session has nothing - // for lineage to describe, and the child stands as its own root + 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, diff --git a/src/dsh-adapter/channel/session-fork.ts b/src/dsh-adapter/channel/session-fork.ts index 0638f2050..924f00544 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -5,7 +5,7 @@ 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, snapshotLiveSessionEvents } from '../compat/index.js' +import { appendSessionTitle, liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' import { unspokenJudges } from '../unspoken-sessions.js' @@ -58,31 +58,32 @@ export function createForkSessionAction( } await deps.settleCompaction() const source = deps.source() - // A source that holds no conversation is not one to continue, whether the - // shell came from the deferral this process installed (the never-used - // verdict) or was already on disk when the process started. A seed would - // copy that shell, 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 live snapshot - // 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 - // snapshotted. There is nothing to copy anyway: the fork starts unseeded, - // as an ordinary fresh session. - let sourceHoldsNoConversation: boolean + // 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[] try { - sourceHoldsNoConversation = isUnstoredFreshSession(source) - || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source) + seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) + if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] } catch (error) { deps.notify(t('fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return false } + const cutHoldsNoConversation = seed.length === 0 const childId = SessionId(randomUUID()) const forkComposed = await composePreset(ctx, runningPresetOf(source)) // Reserve BEFORE the factory, and hold it past `detached.release()`. @@ -98,7 +99,7 @@ export function createForkSessionAction( const reservation: MountReservation = reserved.ok ? reserved.reservation : { settle: () => {}, abandon: () => {} } let detached: { handle: AgentHandle; release(): Promise } try { - detached = await deps.createDetachedHandle(() => sourceHoldsNoConversation + detached = await deps.createDetachedHandle(() => cutHoldsNoConversation ? createFreshAgent(ctx, agents, { sessionId: childId, meta: { cwd: state.cwd, ...(forkComposed.agentPreset === undefined ? {} : { agentPreset: forkComposed.agentPreset }) }, @@ -156,7 +157,7 @@ export function createForkSessionAction( // 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(sourceHoldsNoConversation + 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-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index eef90a82d..c29a3a8d9 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -88,32 +88,35 @@ export function createRewindToAction( if (event.type === 'turn/end') break } const source = deps.binding.agent.session - // A source that holds no conversation is not one to continue, whether the - // shell came from the deferral this process installed (the never-used - // verdict) or was already on disk when the process started. A seed would - // copy that shell, 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. There is no history to cut, so such a source yields an - // unseeded child instead; the evidence is read from the live snapshot in - // hand (in memory, never a second read of the log). Reaching this branch at - // all still needs a rewind row, and only real content offers one - // (`Chat.tsx`), so it is the depth behind `/model` and `/fork`. - let sourceHoldsNoConversation: boolean + // 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[] try { if (boundary < 0) throw new Error('cannot rewind to the very first message') - sourceHoldsNoConversation = isUnstoredFreshSession(source) - || CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined // 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 = sourceHoldsNoConversation ? [] : sliceLiveSessionSeed(source, boundary) + seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary) + if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] } catch (error) { deps.notify(t('rewind-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return null } + const cutHoldsNoConversation = seed.length === 0 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 @@ -121,10 +124,11 @@ export function createRewindToAction( const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - const create = (): Promise => sourceHoldsNoConversation - // No seed and no parent: a conversation-less session has no history to - // cut and nothing for lineage to describe, so the child is an ordinary - // fresh session and stands as its own root (session-lineage.ts). + 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 }) }, diff --git a/src/dsh-adapter/channel/session-tree-actions.ts b/src/dsh-adapter/channel/session-tree-actions.ts index 3116eb010..fb15082bb 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -118,20 +118,24 @@ export function createTreeRewindAction( deps.notify(t('rewind-settling'), { color: 'error' }) return null } - // A LIVE source that holds no conversation is not one to continue, whether - // the shell came from the deferral this process installed (the never-used - // verdict) or was already on disk when the process started. A seed would - // copy that shell, 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 snapshot the seed is cut from below, in - // memory, never a second read of the log. A persisted foreign source is - // never in that state: its log is on disk, and reaching its entries at all - // proves it holds real content — so the verdict is asked about the LIVE - // source only. - const sourceHoldsNoConversation = forkFromLive && (isUnstoredFreshSession(entrySession) - || CONVERSATION_EVIDENCE.log({ events: sourceEvents, complete: true }) === undefined) - const seed = sourceHoldsNoConversation ? [] : 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. + let seed = sourceEvents.filter(event => event.seq <= target.boundary) + if ((forkFromLive && isUnstoredFreshSession(entrySession)) + || CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] + const cutHoldsNoConversation = seed.length === 0 const inheritedCount = seed.length const closeAfterCreate = target.closeTurn !== undefined && entrySession.header?.version >= 3 if (target.closeTurn !== undefined && !closeAfterCreate) { @@ -142,10 +146,11 @@ export function createTreeRewindAction( const { reservation } = await reserveNewSession(String(childId)) let candidate: AgentSession try { - const create = (): Promise => sourceHoldsNoConversation - // No seed and no parent: a conversation-less session has no history to - // cut and nothing for lineage to describe, so the child is an ordinary - // fresh session and stands as its own root (session-lineage.ts). + 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 }) }, From 19512e92da87c90efbdb604b995d381934bf07d6 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 20:34:04 +0800 Subject: [PATCH 17/24] docs(session): align two comments with the code and pin the gate-installed shape Task: T-FIX-13 --- scripts/verify-empty-session-persistence.ts | 67 +++++++++++++++++++++ src/dsh-adapter/channel/session-lineage.ts | 8 ++- src/dsh-adapter/unspoken-sessions.ts | 8 ++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 695c51c81..c9e5ce8d5 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -36,6 +36,19 @@ * and `verifySeededWiring` reads the four actions to prove the cut verdict is * what selects the unseeded branch. * + * 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: @@ -86,6 +99,14 @@ * 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`). */ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -388,6 +409,52 @@ async function verify(compression: 'zstd' | 'none'): Promise { await verifyCheckpointPublication() persistence.create = originalCreate + /** + * 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) + 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 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/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index 5ac9aa77e..128946d10 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -382,9 +382,11 @@ export function unspokenJudges(deps: UnspokenSweepDeps): UnspokenJudges { /** * What one collected event proves about whether a person ever spoke here. - * Mirrors `digest.ts:66-98`; the one deliberate widening is that a payload - * this code cannot read counts as human evidence, because "the log does not - * say" must never become "the log says no" on an irreversible action. + * 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 From 7bb1d7b917eee02e179aa2c0ddfa4fb6802014f5 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 21:55:49 +0800 Subject: [PATCH 18/24] test(session): label the new persistence probes for the fixed-window gate Task: T-FIX-14 --- scripts/verify-empty-session-persistence.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index c9e5ce8d5..326145649 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -379,7 +379,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { // 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) + 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) @@ -401,7 +401,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { preFixId = String(options('negative-control').sessionId) createFlushes.add(preFixId) const preFix = await fresh('negative-control') - await sleep(250) + 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') } @@ -429,7 +429,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { }) 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) + 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) @@ -544,7 +544,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { agentOptions: { provider: 'scripted', model: 'scripted' }, })) handles.push(seeded) - await sleep(250) + 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`) @@ -794,7 +794,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { 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) + 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') From 819c47b53837a06c232ffa970d849c941023d0bc Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 22:47:50 +0800 Subject: [PATCH 19/24] test(session): assert the unseeded child for a never-used source Task: T-FIX-15 --- scripts/verify-session-title-lineage.ts | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/verify-session-title-lineage.ts b/scripts/verify-session-title-lineage.ts index 1bd651ae1..92477ee34 100644 --- a/scripts/verify-session-title-lineage.ts +++ b/scripts/verify-session-title-lineage.ts @@ -130,12 +130,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 is a seeded root (the inherited cut is still marked)', - meta['isSeeded'] === true || meta['seedLength'] === blankSource.length, + '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 fresh root (no inherited cut is marked)', + meta['isSeeded'] === undefined && meta['seedLength'] === undefined, JSON.stringify(meta), ) } @@ -156,6 +172,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) From 13f9cd15ff3a0a03de177b80eac4dba4e29f04ff Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 22:54:42 +0800 Subject: [PATCH 20/24] refactor(session): keep the conversation criterion in one place Task: T-FIX-16 --- scripts/verify-empty-session-persistence.ts | 24 ++++++++--------- src/dsh-adapter/channel/model-switch.ts | 20 +++----------- src/dsh-adapter/channel/session-fork.ts | 20 +++----------- src/dsh-adapter/channel/session-rewind.ts | 20 +++----------- .../channel/session-tree-actions.ts | 20 +++----------- src/dsh-adapter/unspoken-sessions.ts | 26 +++++++++++++++++++ 6 files changed, 50 insertions(+), 80 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 326145649..6ba7e44de 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -22,7 +22,7 @@ * 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 its exported judges) reads that slice. Judging the SOURCE left a hole + * 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 @@ -79,7 +79,7 @@ * 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 (CONVERSATION_EVIDENCE.log({ … }) === undefined) seed = []` with + * + `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 @@ -90,9 +90,9 @@ * 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 (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = []` + * `if (holdsNoConversation(seed)) seed = []` * with - * `if (CONVERSATION_EVIDENCE.log({ events: snapshotLiveSessionEvents(source), complete: true }) === undefined) seed = []` + * `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 @@ -995,7 +995,7 @@ function verifyChannelWiring(): void { */ /** The one line every site must carry: the verdict asks the CUT, never the source. */ -const CUT_EVIDENCE = 'CONVERSATION_EVIDENCE.log({ events: seed, complete: true })' +const CUT_EVIDENCE = 'holdsNoConversation(seed)' const SEEDED_SITES: readonly { readonly name: string @@ -1015,7 +1015,7 @@ const SEEDED_SITES: readonly { subject: 'source', markers: [ 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', - `if (${CUT_EVIDENCE} === undefined) seed = []`, + `if (${CUT_EVIDENCE}) seed = []`, 'const cutHoldsNoConversation = seed.length === 0', 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', @@ -1030,7 +1030,7 @@ const SEEDED_SITES: readonly { notice: "? t('fork-done-unstored', { id: String(childId) })", markers: [ 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', - `if (${CUT_EVIDENCE} === undefined) seed = []`, + `if (${CUT_EVIDENCE}) seed = []`, 'const cutHoldsNoConversation = seed.length === 0', 'deps.createDetachedHandle(() => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', @@ -1045,7 +1045,7 @@ const SEEDED_SITES: readonly { subject: 'source', markers: [ 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary)', - `if (${CUT_EVIDENCE} === undefined) seed = []`, + `if (${CUT_EVIDENCE}) seed = []`, 'const cutHoldsNoConversation = seed.length === 0', 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', @@ -1063,7 +1063,7 @@ const SEEDED_SITES: readonly { // source (the deferral is this process's own bookkeeping): the evidence // rule below reads the CUT for a persisted foreign source too. 'if ((forkFromLive && isUnstoredFreshSession(entrySession))', - `|| ${CUT_EVIDENCE} === undefined) seed = []`, + `|| ${CUT_EVIDENCE}) seed = []`, 'const cutHoldsNoConversation = seed.length === 0', 'const create = (): Promise => cutHoldsNoConversation', '? createFreshAgent(ctx, agents, {', @@ -1081,8 +1081,8 @@ function seededWiringViolations( if (!/import \{ createFreshAgent, isUnstoredFreshSession \} from '\.\.\/fresh-agent\.js'/.test(source)) { violations.push('does not import createFreshAgent + isUnstoredFreshSession') } - if (!/import \{ unspokenJudges \} from '\.\.\/unspoken-sessions\.js'/.test(source)) { - violations.push('does not ask the sweep evidence rule through unspokenJudges') + if (!/import \{ holdsNoConversation \} from '\.\.\/unspoken-sessions\.js'/.test(source)) { + violations.push('does not ask the cut criterion through unspoken-sessions.ts') } let cursor = -1 for (const marker of site.markers) { @@ -1109,7 +1109,7 @@ function verifySeededWiring(): void { // 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, `CONVERSATION_EVIDENCE.log({ events: ${site.sourceEvents}, complete: true })`)) + 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`) diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index 50c66391d..b161a03a7 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -11,7 +11,7 @@ import { createDshSession, dshHandleOf } from '../backend/session.js' import { liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' -import { unspokenJudges } from '../unspoken-sessions.js' +import { holdsNoConversation } from '../unspoken-sessions.js' import { reserveNewSession } from '../../sessionMounts.js' import { attachSessionToWorkspace } from '../workspace.js' import type { DshChannelBinding } from './binding.js' @@ -24,21 +24,6 @@ type Binding = DshChannelBinding type SwitchState = Parameters[0] & Pick -/** - * The exit sweep's own "did a person speak here" rule, asked through its - * exported judges instead of restated here: `unspokenJudges().log` IS - * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human - * message). A FOURTH human-speech rule is exactly what the three existing ones - * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read - * lazily by the `held` rule, which is never asked here, so they stay inert - * rather than fabricated. - */ -const CONVERSATION_EVIDENCE = unspokenJudges({ - currentSessionId: () => undefined, - liveSessionIds: () => new Set(), - isSubagentOrDescendant: () => false, -}) - /** Model-route adoption transaction. It settles compaction before its fork snapshot and owns the post-commit reset. */ export function createModelSwitchAction( ctx: Context, @@ -91,7 +76,8 @@ export function createModelSwitchAction( // the SOURCE snapshot: sessions.fork() registers a real child, and its // snapshot length is not the inherited cut. seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) - if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] + // The cut criterion has one source: unspoken-sessions.ts. + if (holdsNoConversation(seed)) seed = [] } 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 const childId = SessionId(randomUUID()) diff --git a/src/dsh-adapter/channel/session-fork.ts b/src/dsh-adapter/channel/session-fork.ts index 924f00544..1a92ffdf2 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -8,7 +8,7 @@ import { resolveDshProfileName } from '../../update.js' import { appendSessionTitle, liveSessionCreateOptions, sliceLiveSessionSeed } from '../compat/index.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, runningPresetOf } from '../presets.js' -import { unspokenJudges } from '../unspoken-sessions.js' +import { holdsNoConversation } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveMount, type MountReservation } from '../../sessionMounts.js' import { mountFailureText } from '../../sessions/resumeFailure.js' @@ -17,21 +17,6 @@ import type { ChannelState } from './types.js' type ForkState = Pick -/** - * The exit sweep's own "did a person speak here" rule, asked through its - * exported judges instead of restated here: `unspokenJudges().log` IS - * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human - * message). A FOURTH human-speech rule is exactly what the three existing ones - * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read - * lazily by the `held` rule, which is never asked here, so they stay inert - * rather than fabricated. - */ -const CONVERSATION_EVIDENCE = unspokenJudges({ - currentSessionId: () => undefined, - liveSessionIds: () => new Set(), - isSubagentOrDescendant: () => false, -}) - /** Create a detached `/fork` copy without adopting it into the foreground. */ export function createForkSessionAction( ctx: Context, @@ -78,7 +63,8 @@ export function createForkSessionAction( // snapshot — sessions.fork() would register a child and append // session/end-seed, so snapshot.length is not a lineage cut. seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) - if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] + // The cut criterion has one source: unspoken-sessions.ts. + if (holdsNoConversation(seed)) seed = [] } catch (error) { deps.notify(t('fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return false diff --git a/src/dsh-adapter/channel/session-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index c29a3a8d9..e95418211 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -10,7 +10,7 @@ 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 { unspokenJudges } from '../unspoken-sessions.js' +import { holdsNoConversation } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import type { DshChannelBinding } from './binding.js' @@ -20,21 +20,6 @@ import type { ChannelState, ChatRow } from './types.js' type Binding = DshChannelBinding type RewindState = Pick -/** - * The exit sweep's own "did a person speak here" rule, asked through its - * exported judges instead of restated here: `unspokenJudges().log` IS - * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human - * message). A FOURTH human-speech rule is exactly what the three existing ones - * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read - * lazily by the `held` rule, which is never asked here, so they stay inert - * rather than fabricated. - */ -const CONVERSATION_EVIDENCE = unspokenJudges({ - currentSessionId: () => undefined, - liveSessionIds: () => new Set(), - isSubagentOrDescendant: () => false, -}) - async function waitForTurnEnd( session: unknown, fromSeq: number, @@ -111,7 +96,8 @@ export function createRewindToAction( // child-owned session/end-seed, so snapshot.length is not the inherited // cut. agents.create owns the new session id. seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary) - if (CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] + // The cut criterion has one source: unspoken-sessions.ts. + if (holdsNoConversation(seed)) seed = [] } catch (error) { deps.notify(t('rewind-fork-failed', { err: error instanceof Error ? error.message : String(error) }), { color: 'error' }) return null diff --git a/src/dsh-adapter/channel/session-tree-actions.ts b/src/dsh-adapter/channel/session-tree-actions.ts index fb15082bb..6621b7fc4 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -10,7 +10,7 @@ import { readPersistedSession, type SessionReader } from '../compat/persistence. import { closeLiveForkTurn } from '../compat/liveSession.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, resolvePersistedPreset, runningPresetOf } from '../presets.js' -import { unspokenJudges } from '../unspoken-sessions.js' +import { holdsNoConversation } from '../unspoken-sessions.js' import { attachSessionToWorkspace } from '../workspace.js' import { reserveNewSession } from '../../sessionMounts.js' import { forkTarget, rewindTarget, turnUserText } from '../sessionTree.js' @@ -21,21 +21,6 @@ import type { ChannelState } from './types.js' type Binding = DshChannelBinding type TreeRewindState = Pick -/** - * The exit sweep's own "did a person speak here" rule, asked through its - * exported judges instead of restated here: `unspokenJudges().log` IS - * `unspoken-sessions.ts`'s `conversationEvidence` (a `turn/start` or a human - * message). A FOURTH human-speech rule is exactly what the three existing ones - * must not become (KNOWN-ISSUES B-1). Its three process-layer facts are read - * lazily by the `held` rule, which is never asked here, so they stay inert - * rather than fabricated. - */ -const CONVERSATION_EVIDENCE = unspokenJudges({ - currentSessionId: () => undefined, - liveSessionIds: () => new Set(), - isSubagentOrDescendant: () => false, -}) - async function waitForTurnEnd(session: unknown, fromSeq: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -133,8 +118,9 @@ export function createTreeRewindAction( // process's own bookkeeping, and a persisted foreign source is not in it), // so a foreign source is judged by its cut alone. let seed = sourceEvents.filter(event => event.seq <= target.boundary) + // The cut criterion has one source: unspoken-sessions.ts. if ((forkFromLive && isUnstoredFreshSession(entrySession)) - || CONVERSATION_EVIDENCE.log({ events: seed, complete: true }) === undefined) seed = [] + || holdsNoConversation(seed)) seed = [] const cutHoldsNoConversation = seed.length === 0 const inheritedCount = seed.length const closeAfterCreate = target.closeTurn !== undefined && entrySession.header?.version >= 3 diff --git a/src/dsh-adapter/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index 128946d10..fe430ea63 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -380,6 +380,32 @@ export function unspokenJudges(deps: UnspokenSweepDeps): UnspokenJudges { } } +/** 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 +} + /** * 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 From 565325dc109f916ae1295e75a9a395d20f91d4d3 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 22:54:44 +0800 Subject: [PATCH 21/24] docs(readme): state that an explicit flush no longer publishes an activity-free session Task: T-FIX-16 --- README.md | 2 +- README_ZH.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 75231e132..c19baa4ef 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ Full reference: [Interaction and commands](docs/interaction.en.md). 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 paints the last successful list immediately while it checks the persistence store for changes. Titles that require a deeper 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 e53bacc37..c3a68a102 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -251,7 +251,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 在 `/provider` 模型列表聚焦一项并按 `Tab`,可编辑上下文窗口、最大输出 token、推理档位和图片输入能力。 会话管理界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 -使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中;后续会话活动或显式持久化 flush 才会保存完整日志。尚未落盘的空会话重启时会重新创建。 +使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中,直到后续会话活动才会保存完整日志;显式持久化 flush 仍会照常执行,但不会为只有初始化的会话落盘。尚未落盘的空会话重启时会重新创建。正常退出会清理人类从未发言过的会话。 移除工作区登记后,其历史会话仍可从侧栏的「仅历史」目录进入。 「仅历史」目录只提供编辑和新建会话操作;重命名与移除仅适用于已登记工作区。 From c081df10ebb5daf1da6c2e4272012597912209a4 Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 23:31:56 +0800 Subject: [PATCH 22/24] fix(session): replay the cut's policy facts into an unseeded child Task: T-FIX-17 --- scripts/verify-empty-session-persistence.ts | 387 ++++++++++++++++-- scripts/verify-session-title-lineage.ts | 15 +- src/dsh-adapter/channel/model-switch.ts | 37 +- src/dsh-adapter/channel/session-fork.ts | 33 +- src/dsh-adapter/channel/session-rewind.ts | 37 +- .../channel/session-tree-actions.ts | 30 +- src/dsh-adapter/fresh-agent.ts | 10 +- src/dsh-adapter/unspoken-sessions.ts | 87 ++++ 8 files changed, 572 insertions(+), 64 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index 6ba7e44de..f2102d39f 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -36,6 +36,21 @@ * 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 @@ -107,6 +122,15 @@ * 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' @@ -116,14 +140,17 @@ 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, 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 @@ -184,6 +211,8 @@ const { extractEntries, rewindTarget } = await import('../src/dsh-adapter/sessio 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') @@ -203,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[] = [] @@ -784,7 +843,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { 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, 'the child starts from its own initialization') + 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 @@ -823,6 +882,7 @@ async function verify(compression: 'zstd' | 'none'): Promise { } 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. const entered = Promise.withResolvers() @@ -979,23 +1039,29 @@ function verifyChannelWiring(): void { /** * The four seeded channel actions. `verifySeededFamily` drives the creation - * SHAPES through the real host, `verifyOnDiskShellSource` drives `/fork` itself - * and `verifyCutPrefixSeeding` drives `/rewind` itself; 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 — and let THAT verdict select the unseeded branch. Every + * 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, and `/fork`'s notice reverting to an advertised resume - * command. + * 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(seed)' +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 @@ -1004,6 +1070,8 @@ const SEEDED_SITES: readonly { 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[] @@ -1013,13 +1081,19 @@ const SEEDED_SITES: readonly { file: 'model-switch.ts', sourceEvents: 'snapshotLiveSessionEvents(source)', subject: 'source', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', markers: [ - 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', - `if (${CUT_EVIDENCE}) seed = []`, + '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)', ], }, { @@ -1027,14 +1101,18 @@ const SEEDED_SITES: readonly { 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: [ - 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source)', - `if (${CUT_EVIDENCE}) seed = []`, + '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) })", ], }, @@ -1043,13 +1121,19 @@ const SEEDED_SITES: readonly { file: 'session-rewind.ts', sourceEvents: 'snapshotLiveSessionEvents(source)', subject: 'source', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', markers: [ - 'seed = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary)', - `if (${CUT_EVIDENCE}) seed = []`, + '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)', ], }, { @@ -1057,17 +1141,22 @@ const SEEDED_SITES: readonly { file: 'session-tree-actions.ts', sourceEvents: 'sourceEvents', subject: 'entrySession', + replay: 'if (cutHoldsNoConversation) replayPolicyFacts(handle.agent.session, policyFacts)', markers: [ - 'let seed = sourceEvents.filter(event => event.seq <= target.boundary)', + '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. - 'if ((forkFromLive && isUnstoredFreshSession(entrySession))', - `|| ${CUT_EVIDENCE}) seed = []`, + '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)', ], }, ] @@ -1081,8 +1170,10 @@ function seededWiringViolations( if (!/import \{ createFreshAgent, isUnstoredFreshSession \} from '\.\.\/fresh-agent\.js'/.test(source)) { violations.push('does not import createFreshAgent + isUnstoredFreshSession') } - if (!/import \{ holdsNoConversation \} from '\.\.\/unspoken-sessions\.js'/.test(source)) { - violations.push('does not ask the cut criterion through unspoken-sessions.ts') + // 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) { @@ -1098,15 +1189,16 @@ 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`) + 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, 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. + // 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})`)) @@ -1115,13 +1207,17 @@ function verifySeededWiring(): void { 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${site.notice === undefined ? '' : ' and the resume notice coming back'}`) + 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`) } @@ -1154,11 +1250,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-title-lineage.ts b/scripts/verify-session-title-lineage.ts index 92477ee34..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() {}, diff --git a/src/dsh-adapter/channel/model-switch.ts b/src/dsh-adapter/channel/model-switch.ts index b161a03a7..70217e24b 100644 --- a/src/dsh-adapter/channel/model-switch.ts +++ b/src/dsh-adapter/channel/model-switch.ts @@ -8,10 +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 } from '../unspoken-sessions.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' @@ -68,18 +68,29 @@ export function createModelSwitchAction( // 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 — // 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 = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) - // The cut criterion has one source: unspoken-sessions.ts. - if (holdsNoConversation(seed)) seed = [] + // 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 @@ -112,7 +123,15 @@ export function createModelSwitchAction( agentOptions: { provider, model }, setup: composed.setup, })) - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await create())) + 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 1a92ffdf2..ec849acac 100644 --- a/src/dsh-adapter/channel/session-fork.ts +++ b/src/dsh-adapter/channel/session-fork.ts @@ -5,10 +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 } from '../unspoken-sessions.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' @@ -58,18 +58,30 @@ export function createForkSessionAction( // 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 = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source) - // The cut criterion has one source: unspoken-sessions.ts. - if (holdsNoConversation(seed)) seed = [] + // 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()`. @@ -110,6 +122,11 @@ export function createForkSessionAction( 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) diff --git a/src/dsh-adapter/channel/session-rewind.ts b/src/dsh-adapter/channel/session-rewind.ts index e95418211..341117f1a 100644 --- a/src/dsh-adapter/channel/session-rewind.ts +++ b/src/dsh-adapter/channel/session-rewind.ts @@ -10,7 +10,7 @@ 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 } from '../unspoken-sessions.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' @@ -89,20 +89,31 @@ export function createRewindToAction( // 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 = isUnstoredFreshSession(source) ? [] : sliceLiveSessionSeed(source, boundary) - // The cut criterion has one source: unspoken-sessions.ts. - if (holdsNoConversation(seed)) seed = [] + // 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 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 @@ -139,7 +150,15 @@ export function createRewindToAction( return composed.setup?.(agentCtx, agent) }, })) - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await create())) + 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 6621b7fc4..c3073743c 100644 --- a/src/dsh-adapter/channel/session-tree-actions.ts +++ b/src/dsh-adapter/channel/session-tree-actions.ts @@ -10,7 +10,7 @@ import { readPersistedSession, type SessionReader } from '../compat/persistence. import { closeLiveForkTurn } from '../compat/liveSession.js' import { createFreshAgent, isUnstoredFreshSession } from '../fresh-agent.js' import { composePreset, resolvePersistedPreset, runningPresetOf } from '../presets.js' -import { holdsNoConversation } from '../unspoken-sessions.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' @@ -117,11 +117,21 @@ export function createTreeRewindAction( // 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. - let seed = sourceEvents.filter(event => event.seq <= target.boundary) - // The cut criterion has one source: unspoken-sessions.ts. - if ((forkFromLive && isUnstoredFreshSession(entrySession)) - || holdsNoConversation(seed)) seed = [] + // 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) { @@ -164,7 +174,15 @@ export function createTreeRewindAction( return composed.setup?.(agentCtx, agent) } : composed.setup, })) - candidate = await deps.binding.prepare(adoption, async () => createDshSession(ctx, await create())) + 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/fresh-agent.ts b/src/dsh-adapter/fresh-agent.ts index dd0adeb28..8692f12de 100644 --- a/src/dsh-adapter/fresh-agent.ts +++ b/src/dsh-adapter/fresh-agent.ts @@ -59,7 +59,15 @@ 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`). + */ +export const INITIAL_POLICY_EVENTS: ReadonlySet = new Set(['permission/preset', 'sandbox/mode', 'approval/policy', 'plan/mode']) const unstoredSessions = new WeakSet() /** Initialization-only fresh session with no persistence requested yet. */ diff --git a/src/dsh-adapter/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index fe430ea63..8e277d676 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -68,10 +68,18 @@ * 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 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 { INITIAL_POLICY_EVENTS } 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' @@ -406,6 +414,85 @@ 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 From 4cfd0bc1089307f47e93d76496a2c36f61974caa Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Fri, 9 Oct 2026 23:46:32 +0800 Subject: [PATCH 23/24] fix(session): keep sessions another process write-leases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit sweep deleted a dsh web session: dsh web opens its own new-session placeholder in the shared store, never writes the TUI mount ledger, and the placeholder is a promptless shell, so every existing layer collected it. Prove the host's exclusive write lease before deleting a candidate instead: sessionPersistence.acquireWriteLease is the same arbiter a peer write handle holds (POSIX flock, Windows named semaphore), one probe per promptless index entry, and only a proven-free session is removed — held, unsupported, failing or unproven all keep it, reported as the new write-leased reason next to the ledger's held-elsewhere. The probe is asynchronous and the round is not, so the proof is gathered before the synchronous round and the notice still reports that round's own count. Task: T-FIX-18 --- scripts/run-ci-group.mjs | 9 + scripts/verify-session-write-lease.ts | 515 ++++++++++++++++++++++++++ src/dsh-adapter/compat/writeLease.ts | 123 ++++++ src/dsh-adapter/plugin.ts | 92 +++-- src/dsh-adapter/unspoken-sessions.ts | 118 ++++++ 5 files changed, 827 insertions(+), 30 deletions(-) create mode 100644 scripts/verify-session-write-lease.ts create mode 100644 src/dsh-adapter/compat/writeLease.ts diff --git a/scripts/run-ci-group.mjs b/scripts/run-ci-group.mjs index 3d2a188a1..d3de11a57 100644 --- a/scripts/run-ci-group.mjs +++ b/scripts/run-ci-group.mjs @@ -638,6 +638,15 @@ const GROUPS = { // 预算、依赖抛错不中止本轮(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-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/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/plugin.ts b/src/dsh-adapter/plugin.ts index 821e88e85..fd1055d82 100644 --- a/src/dsh-adapter/plugin.ts +++ b/src/dsh-adapter/plugin.ts @@ -70,7 +70,8 @@ import { Chat } from '../screens/Chat.js' import { openInjectChannel, type InjectController } from './inject-channel.js' import { startSessionMountHeartbeat, mountedSessionIds } from './session-mount-heartbeat.js' import { attachSessionListMetadata } from './session-list-metadata.js' -import { delegatedSessionIds, sweepUnspokenSessions, type UnspokenSessionLineage, type UnspokenSweepDeps, type UnspokenSweepResult } from './unspoken-sessions.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' @@ -1927,35 +1928,51 @@ export async function apply(ctx: Context, runtimeConfig: RuntimeConfig, // 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. - const swept = sweepUnspokenOnExit({ - currentSessionId: () => channel.agentId, - liveSessionIds: () => liveExitSessionIds(ctx, channel.agentId), - listedSessions: () => readExitListing(channel), - }) - if (swept !== undefined) { + // 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 { - ctx.logger.debug(`dsh-tui: clean exit swept ${swept.deleted.length} unspoken session(s), spared ${swept.skipped.length}`) + writeLeaseFree = await provenWriteLeaseFree(createWriteLeaseProbe(() => ctx.get('sessionPersistence'))) } catch { - // Diagnostics belong to the opt-in channel; a sink that throws is - // not a reason to skip the terminal restore that follows. + // The initial predicate stands. } - } 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. + 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), - ) + void finishExit( + ctx, + instance, + bootedFullscreen, + composeExitNotice(hint, swept?.deleted.length ?? 0), + undefined, + () => disposeRootAndExit(ctx, 0), + ) + })() }, }) const handleExit = funnel.handleExit @@ -2654,6 +2671,13 @@ export interface ExitSweepInput { * 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. @@ -2669,15 +2693,22 @@ export interface ExitSweepInput { * 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}), 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). + * 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 an awaited sweep could not reach it (DESIGN D6/D7). + * 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. @@ -2696,6 +2727,7 @@ export function sweepUnspokenOnExit(input: ExitSweepInput): UnspokenSweepResult 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). diff --git a/src/dsh-adapter/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index 8e277d676..0b7d4b428 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -52,6 +52,16 @@ * 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 @@ -67,6 +77,10 @@ * 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 @@ -79,6 +93,7 @@ */ 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 } from './fresh-agent.js' import { readHeader, type RawSessionHeader } from './sessions/header.js' import { decodeFrame, readWindow, walkFrames } from './sessions/frames.js' @@ -204,6 +219,13 @@ export type UnspokenSkipReason = | '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. */ @@ -269,6 +291,18 @@ export interface UnspokenSweepDeps { * 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. */ @@ -593,10 +627,80 @@ export function collectUnspokenSessionIds( 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. @@ -610,7 +714,21 @@ export function sweepUnspokenSessions( 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) From 041635a851376374e71afe0ee024da8e0a3d5cea Mon Sep 17 00:00:00 2001 From: baobaolaodie Date: Sat, 10 Oct 2026 23:17:11 +0800 Subject: [PATCH 24/24] fix(session): hold the policy plane back from the fresh-session gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the permission preset in an idle session published it: the switch runs the registry command the host exposes for it (mode-permission.ts drives `/permission`), and the command service logs command/run + command/done around it. Those types are outside the initialization vocabulary, so the deferral started on the first of them and the JSONL received an 11-event permission-only shell — measured on a real tree (2026-10-10), and the same shell is the "unnamed" row the web sidebar showed. The gate now asks isPolicyPlaneActivity: the initialization atoms, the command envelope a policy switch runs through, and the inbox splice that carries only its notice. A splice that carries a HUMAN message still 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". isHumanSource moves next to it because unspoken-sessions.ts already imports this module and 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. scripts/verify-empty-session-persistence.ts gains the red-first case: the switch alone publishes nothing, the first human message still publishes it, every envelope type is shown to be outside INITIAL_POLICY_EVENTS, and --negative-controls proves the same envelope is storable without the gate. Task: T-FIX-19 --- scripts/verify-empty-session-persistence.ts | 77 ++++++++++++++++++++- src/dsh-adapter/fresh-agent.ts | 64 ++++++++++++++++- src/dsh-adapter/unspoken-sessions.ts | 14 +--- 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/scripts/verify-empty-session-persistence.ts b/scripts/verify-empty-session-persistence.ts index f2102d39f..f88a818cc 100644 --- a/scripts/verify-empty-session-persistence.ts +++ b/scripts/verify-empty-session-persistence.ts @@ -202,7 +202,7 @@ 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') @@ -468,6 +468,81 @@ async function verify(compression: 'zstd' | 'none'): Promise { 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 diff --git a/src/dsh-adapter/fresh-agent.ts b/src/dsh-adapter/fresh-agent.ts index 8692f12de..cf5e6f18f 100644 --- a/src/dsh-adapter/fresh-agent.ts +++ b/src/dsh-adapter/fresh-agent.ts @@ -66,8 +66,70 @@ function captureWriter(source: JsonlPersistence, id: string, receive: (writer: S * 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. */ @@ -156,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/unspoken-sessions.ts b/src/dsh-adapter/unspoken-sessions.ts index 0b7d4b428..0aa5b57c5 100644 --- a/src/dsh-adapter/unspoken-sessions.ts +++ b/src/dsh-adapter/unspoken-sessions.ts @@ -94,7 +94,7 @@ 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 } from './fresh-agent.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' @@ -555,18 +555,6 @@ function conversationEvidence(event: unknown): 'turn-start' | 'human-message' | return undefined } -/** - * 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. - */ -function isHumanSource(source: unknown): boolean { - if (source === undefined || source === null) return true - if (typeof source !== 'object') return false - return (source as Record)['kind'] === 'user' -} - /** * Decide which indexed sessions are unspoken shells. Read-only: the same * fixtures can be judged more than once (and by reversed judges).