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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 文件名。
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user