fix(subagent): harden continuable persistence

This commit is contained in:
Dudu-0223
2026-07-24 12:39:07 +08:00
committed by imccyu
parent bb8ea2be51
commit 1ab3cbf673
23 changed files with 412 additions and 56 deletions

View File

@@ -12,9 +12,10 @@ The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior.
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
@@ -22,7 +23,7 @@ When the optional sandbox-policy or approval service is composed, the driver sna
## Cold resume
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start.
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start.
## Cancellation and ownership
@@ -30,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
## Spawn and fork inputs