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
@@ -100,16 +100,17 @@ export async function startInProcessRun(
subagentDepth: childDepth,
}
// Policy inheritance, read half: capture the parent's sandbox/approval
// OVERRIDES synchronously, before the first await — the delegation moment
// is the semantic snapshot point, and a parent switch racing the child's
// Policy inheritance: capture the parent's sandbox/approval OVERRIDES
// synchronously, before the first await — the delegation moment is the
// semantic snapshot point, and a parent switch racing the child's
// asynchronous creation must belong to the parent's future, not the child.
// Both services are consumed opportunistically — without them, delegation
// stays policy-free.
const sandboxPolicy = parent.ctx.get('sandboxPolicy')
const approval = parent.ctx.get('approval')
const inheritedMode = sandboxPolicy?.overrideOf(parent.session)
const inheritedPolicy = approval?.overrideOf(parent.session)
// The captured values ride the child's creation meta into its immutable
// header, so the baseline is durable from the moment the session exists —
// no first-turn event could survive every crash window (an idle injection
// can persist a complete turn before any prompt turn opens). Both services
// are consumed opportunistically — without them, delegation is policy-free.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
@@ -120,23 +121,6 @@ export async function startInProcessRun(
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
// Write half: stamp the captured overrides once, anchored inside the
// child's FIRST turn (prompt-submit runs after turn/start, before prompt
// assembly) — a bare between-turn append would be crash-tail garbage on
// reload, and stamping here also orders the override after any stale
// switch a fork seed carried, so the ordinary last-event-wins fold
// resolves it. PREPENDED so a veto-capable listener (a denying
// UserPromptSubmit hook) cannot close the first turn without the stamp —
// the stamp must be durable even for a blocked first prompt. One-shot:
// later turns must not re-stamp over a switch the child made itself.
if (inheritedMode !== undefined || inheritedPolicy !== undefined) {
const disposeInherit = childCtx.on('agent/prompt-submit', (childAgent, _content, _source, _signal, next) => {
disposeInherit()
if (inheritedMode !== undefined) sandboxPolicy?.stampOverride(childAgent.session, inheritedMode)
if (inheritedPolicy !== undefined) approval?.stampOverride(childAgent.session, inheritedPolicy)
return next()
}, { prepend: true })
}
}
const flags = { cancelled: false }
@@ -148,6 +132,8 @@ export async function startInProcessRun(
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...seedLength > 0 ? { seedLength } : {},
...inheritedMode !== undefined ? { sandboxMode: inheritedMode } : {},
...inheritedPolicy !== undefined ? { approvalPolicy: inheritedPolicy } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,