subagent: carry inherited policy overrides in the child session header

Review fix (ds-review-bot critical #2 on #623): the first-turn event stamp
had a durability hole no turn anchoring can close — an idle SessionStart-
style injection persists a complete one-shot turn before any prompt turn
opens, so a crash in that window left a resumable-looking child with no
inherited policy, falling back to a possibly wider deployment default.

The captured overrides now ride the child's creation meta into its
immutable SessionHeader (sandboxMode/approvalPolicy, neutral strings at the
session boundary — the delegationDepth precedent), durable from the moment
the session exists: no listener ordering can starve the baseline and no
crash window can lose it. overrideOf(session) on both policy services
resolves fold(events past header.seedLength) ?? header baseline, validating
against the closed vocabulary on read; stampOverride and the prompt-submit
listener machinery are deleted. The header field rides both persistence
backends (JSONL header line; SQLite sessions columns, SCHEMA_VERSION 11 —
pre-release, no migration). pty-local reads through overrideOf so PTY
spawns see the baseline too.

Red-first: header-durability-before-any-turn test (the injection crash
window shape), baseline/seed-boundary/closed-vocabulary contract tests in
both service suites; the real-wall suite (race, veto, fork stale-seed,
grandchild) re-anchored on header assertions and green. The Agent Note's
Alternatives now records the superseded event-stamping iteration with the
review evidence; bilingual docs updated.
This commit is contained in:
kingwl
2026-07-26 18:16:45 +08:00
parent 166628c0b3
commit c53e9c90db
41 changed files with 387 additions and 264 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d
README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1
README.md: 75540b957230676850a7abaf7a58877ff7dcbcc5
README.zh.md: d82d5f8d0ef263aa3506ed56b7a2b0a83a1cb99d

View File

@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
session.jsonl # only with compression: 'none'
```
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.

View File

@@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence``dsh-ses
session.jsonl # only with compression: 'none'
```
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON或仅在 `packChunks` 下写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。

View File

@@ -38,6 +38,8 @@ export interface HeaderLine {
parentSession?: SessionId
seedLength?: number
delegationDepth: number
sandboxMode?: string
approvalPolicy?: string
}
/**
@@ -55,6 +57,8 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
delegationDepth: header.delegationDepth ?? 0,
...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {},
...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {},
}
}
@@ -72,6 +76,8 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
delegationDepth: line.delegationDepth,
...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {},
...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {},
}
}
@@ -90,6 +96,10 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
&& (value as { delegationDepth: number }).delegationDepth >= 0
&& !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
&& ((value as { sandboxMode?: unknown }).sandboxMode === undefined
|| typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string')
&& ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined
|| typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string')
)
}

View File

@@ -301,15 +301,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
private writeRow(meta: SessionHeader): void {
this.db.prepare(`
INSERT INTO sessions
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
created_at = excluded.created_at,
cwd = excluded.cwd,
parent_session = excluded.parent_session,
seed_length = excluded.seed_length,
delegation_depth = excluded.delegation_depth
delegation_depth = excluded.delegation_depth,
sandbox_mode = excluded.sandbox_mode,
approval_policy = excluded.approval_policy
`).run(
meta.id,
meta.version,
@@ -319,6 +321,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
meta.seedLength ?? null,
meta.delegationDepth ?? null,
randomUUID(),
meta.sandboxMode ?? null,
meta.approvalPolicy ?? null,
)
}
}

View File

@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 10
export const SCHEMA_VERSION = 11
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
@@ -41,6 +41,10 @@ export interface SessionRow {
/** Monotonic log-change token incremented in each mutating transaction. */
revision: number
delegation_depth: number | null
/** The inherited sandbox-mode delegation baseline, or NULL. */
sandbox_mode: string | null
/** The inherited approval-policy delegation baseline, or NULL. */
approval_policy: string | null
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -124,7 +128,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
revision INTEGER NOT NULL,
sandbox_mode TEXT,
approval_policy TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS events (
@@ -181,6 +187,8 @@ export function rowToMeta(row: SessionRow): SessionHeader {
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {},
...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {},
}
}

View File

@@ -170,6 +170,8 @@ describe('rowToMeta', () => {
incarnation: 'fractional',
revision: 1,
delegation_depth: null,
sandbox_mode: null,
approval_policy: null,
})).toThrow('stored session createdAt must be a non-negative safe integer')
})
})
@@ -606,7 +608,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(10)
expect(SCHEMA_VERSION).toBe(11)
})
it('keeps the revision stable for an empty repair hook', async () => {