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:
@@ -48,9 +48,9 @@ export interface CreateAgentOptions {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
|
||||
* recursion budget. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
|
||||
* fork lineage, the `seedLength` seed boundary, the `delegationDepth`
|
||||
* recursion budget, and the inherited `sandboxMode`/`approvalPolicy`
|
||||
* delegation baselines. Mirrors the corresponding fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it). This is durable session data,
|
||||
@@ -62,6 +62,8 @@ export interface CreateAgentOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
readonly sandboxMode?: string
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
|
||||
@@ -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: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c
|
||||
README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb
|
||||
README.md: d1f13669c224ea9fe70a08e6ecacd15118bfb632
|
||||
README.zh.md: ef9dd562a2c849f933ed8dafbfa65c92a3e6bcad
|
||||
|
||||
@@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `delegationDepth`, and the inherited `sandboxMode`/`approvalPolicy` delegation baselines.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
|
||||
@@ -44,7 +44,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
@@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### 公共 API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`delegationDepth`,以及继承的 `sandboxMode`/`approvalPolicy` 委派基线。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
|
||||
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` 只接受已在 `OutOfBandSessionEventMap` 中显式准入的插件事件类型。若轮次已打开,它会直接追加;否则会原子地开启一个零步骤插件轮次,依次追加、关闭并刷新。即使目标事件追加失败,仍会关闭并刷新合成轮次,且在整个序列结算前延后脱离操作。
|
||||
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取最近的原始轮次边界,因为更晚的注入或插件所有的零步骤轮次具有自己的结果。
|
||||
@@ -44,7 +44,7 @@
|
||||
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
|
||||
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
|
||||
- `session.seq`、`session.id`:当前序号和只读类型化身份。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`/`sandboxMode`/`approvalPolicy`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
|
||||
### 无损 JSON 工具
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
### 元数据类型(`types.ts`)
|
||||
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth?, sandboxMode?, approvalPolicy? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
|
||||
### 扩展点
|
||||
|
||||
|
||||
@@ -151,6 +151,14 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
// Neutral strings only: the owning policy packages validate the values
|
||||
// against their closed vocabularies on read (durable boundary).
|
||||
if (record.sandboxMode !== undefined && typeof record.sandboxMode !== 'string') {
|
||||
throw new Error('session header sandboxMode must be a string')
|
||||
}
|
||||
if (record.approvalPolicy !== undefined && typeof record.approvalPolicy !== 'string') {
|
||||
throw new Error('session header approvalPolicy must be a string')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -680,6 +688,8 @@ export class SessionStore extends Service {
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
...meta?.sandboxMode === undefined ? {} : { sandboxMode: meta.sandboxMode },
|
||||
...meta?.approvalPolicy === undefined ? {} : { approvalPolicy: meta.approvalPolicy },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,24 @@ export interface SessionHeader {
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
/**
|
||||
* The sandbox-mode override inherited from the delegating parent at
|
||||
* creation (the delegation-inheritance baseline). A neutral string here:
|
||||
* the policy owner (`dsh-sandbox-policy`) validates it against its closed
|
||||
* vocabulary on every read, this being a durable boundary. Absent for
|
||||
* top-level sessions and for children of unswitched parents, which keep
|
||||
* following the LIVE deployment default. Header-carried (the
|
||||
* `delegationDepth` precedent) so the baseline is durable from the creation
|
||||
* moment — no first-turn event survives every crash window, because an
|
||||
* idle injection can persist a complete turn before any prompt turn opens.
|
||||
*/
|
||||
readonly sandboxMode?: string
|
||||
/**
|
||||
* The approval-policy override inherited from the delegating parent at
|
||||
* creation. Same contract as {@link SessionHeader.sandboxMode}; validated
|
||||
* by `dsh-user-approval` on read.
|
||||
*/
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +91,8 @@ export interface CreateSessionOptions {
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
readonly sandboxMode?: string
|
||||
readonly approvalPolicy?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@ describe('ToolRegistry', () => {
|
||||
*/
|
||||
function fakeAgent(): Agent {
|
||||
return {
|
||||
session: { events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
session: { events: [{ type: 'turn/start' }], header: { version: 0, id: 'fake-ask-session', createdAt: 0 }, append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user