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

@@ -323,35 +323,33 @@ export class ApprovalService extends Service {
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
}
/**
* A session's approval-policy OVERRIDE — the fold alone, never the
* configured default. The read half of delegation inheritance: the subagent
* driver captures this synchronously at delegation, so a parent switch
* racing the child's asynchronous creation belongs to the parent's future,
* not to the child ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to fold.
* @returns the last switched policy, or `undefined` for a never-switched session.
* A session's approval-policy OVERRIDE — the override chain alone, never
* the configured default: the fold of the session's OWN switches (events
* past the seed boundary — a fork seed's stale parent switch is subsumed by
* the baseline captured after it), else the header's inherited delegation
* baseline. The subagent driver stamps `overrideOf(parent.session)` into
* each child's creation meta, so a `'never'` (headless/CI) parent cannot
* mint children that fall back to a prompting default, at any depth
* ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).
* @param session - the session whose override chain to resolve.
* @returns the effective override, or `undefined` for a session following
* the configured default.
* @throws when the durable header baseline is outside the closed policy
* vocabulary (a corrupt or foreign log; durable-boundary validation).
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
return effectiveApprovalPolicy(session.events)
}
/**
* Stamp a captured override onto a child session through the canonical
* write path — the write half of delegation inheritance: a `'never'`
* (headless/CI) parent must not mint children that fall back to a prompting
* default. A child whose log (e.g. a fork seed) already folds to the policy
* is left untouched. Callers must append inside an open child turn — a bare
* between-turn event is crash-tail garbage on reload.
* @param child - the child session the override is appended to.
* @param policy - the captured {@link overrideOf} value to stamp.
*/
stampOverride(child: Session, policy: ApprovalPolicy): void {
if (effectiveApprovalPolicy(child.events) === policy) return
setApprovalPolicy(child, policy)
const own = effectiveApprovalPolicy(session.events.slice(session.header.seedLength ?? 0))
if (own !== undefined) return own
const baseline = session.header.approvalPolicy
if (baseline === undefined) return undefined
if (!APPROVAL_POLICIES.includes(baseline as ApprovalPolicy)) {
throw new Error(`session header approvalPolicy "${baseline}" is outside the closed policy vocabulary`)
}
return baseline as ApprovalPolicy
}
/**