diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts index 91d8360be085..0e7596e81cf4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts @@ -6,12 +6,16 @@ const mocks = vi.hoisted(() => ({ finalizeStream: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), + settleRequesterAfterSessionSpawns: vi.fn(), runPrompt: vi.fn(), })); vi.mock("../logger.js", () => ({ log: { debug: mocks.logDebug, error: mocks.logError }, })); +vi.mock("../../subagent-registry.js", () => ({ + settleRequesterAfterSessionSpawns: mocks.settleRequesterAfterSessionSpawns, +})); vi.mock("../runs.js", () => ({ clearActiveEmbeddedRun: mocks.clearActiveEmbeddedRun })); vi.mock("./attempt-prompt-phase.js", () => ({ runEmbeddedAttemptPromptPhase: mocks.runPrompt, @@ -328,4 +332,68 @@ describe("runEmbeddedAttemptSettledPhase", () => { expect.stringContaining("unsubscribe failed, possible resource leak"), ); }); + + it("re-arms delivered children only after a yielded requester becomes idle", async () => { + const fixture = createFixture(); + mocks.completeResult.mockImplementationOnce(() => { + fixture.order.push("result"); + return { + ...fixture.result, + yieldDetected: true, + acceptedSessionSpawns: [ + { runId: "child-run", childSessionKey: "agent:main:subagent:child" }, + ], + }; + }); + mocks.settleRequesterAfterSessionSpawns.mockImplementationOnce(() => { + fixture.order.push("resume-requester"); + return true; + }); + + await runEmbeddedAttemptSettledPhase(fixture.input); + + expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({ + requesterSessionKey: "agent:main", + requesterTurnRunId: "run-1", + requesterYielded: true, + acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], + }); + expect(fixture.order.indexOf("clear-active-run")).toBeLessThan( + fixture.order.indexOf("resume-requester"), + ); + }); + + it("releases requester-turn retention after a normal final answer", async () => { + const fixture = createFixture(); + mocks.completeResult.mockReturnValueOnce({ + ...fixture.result, + yieldDetected: false, + acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], + }); + + await runEmbeddedAttemptSettledPhase(fixture.input); + + expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({ + requesterSessionKey: "agent:main", + requesterTurnRunId: "run-1", + requesterYielded: false, + acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], + }); + }); + + it("surfaces durable re-arm failures after releasing the active requester", async () => { + const fixture = createFixture(); + const failure = new Error("sqlite unavailable"); + mocks.completeResult.mockReturnValueOnce({ + ...fixture.result, + yieldDetected: true, + acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }], + }); + mocks.settleRequesterAfterSessionSpawns.mockImplementationOnce(() => { + throw failure; + }); + + await expect(runEmbeddedAttemptSettledPhase(fixture.input)).rejects.toThrow(failure); + expect(fixture.order).toContain("clear-active-run"); + }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts index b1de7325da62..c23679a3ab30 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts @@ -1,6 +1,7 @@ /** Runs prompt dispatch, stream settlement, cleanup, and result projection. */ import type { AssistantMessage } from "../../../llm/types.js"; import type { AgentMessage } from "../../runtime/index.js"; +import { settleRequesterAfterSessionSpawns } from "../../subagent-registry.js"; import type { NormalizedUsage } from "../../usage.js"; import { log } from "../logger.js"; import type { PromptCacheBreak, PromptCacheChange } from "../prompt-cache-observability.js"; @@ -410,5 +411,13 @@ export async function runEmbeddedAttemptSettledPhase( trajectoryRecorder, }); state.trajectoryEndRecorded = true; + if (attempt.sessionKey && result.acceptedSessionSpawns?.length) { + settleRequesterAfterSessionSpawns({ + requesterSessionKey: attempt.sessionKey, + requesterTurnRunId: attempt.runId, + requesterYielded: result.yieldDetected === true, + acceptedSessionSpawns: result.acceptedSessionSpawns, + }); + } return result; } diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 2cbc3cbc12b7..57fcc21990cd 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -288,6 +288,8 @@ export function createOpenClawTools( const taskSuggestionSessionKey = normalizeOptionalString( options?.runSessionKey ?? options?.agentSessionKey, ); + const requesterSessionKey = options?.agentSessionKey; + const requesterTurnRunId = options?.runId; const imageToolAgentDir = options?.agentDir; const imageTool = resolveImageToolFactoryAvailable({ config: availabilityConfig ?? resolvedConfig, @@ -647,6 +649,7 @@ export function createOpenClawTools( ? [ createSessionsSpawnTool({ agentSessionKey: options?.agentSessionKey, + requesterTurnRunId: options?.runId, completionOwnerKey: options?.runSessionKey, agentChannel: options?.agentChannel, agentAccountId: options?.agentAccountId, @@ -671,6 +674,13 @@ export function createOpenClawTools( : []), createSessionsYieldTool({ sessionId: options?.sessionId, + onBeforeYield: + requesterSessionKey && requesterTurnRunId + ? async () => { + const { markRequesterTurnYielded } = await import("./subagent-registry.js"); + markRequesterTurnYielded({ requesterSessionKey, requesterTurnRunId }); + } + : undefined, onYield: options?.onYield, }), createSubagentsTool({ diff --git a/src/agents/subagent-announce.requester-settle-wake.test.ts b/src/agents/subagent-announce.requester-settle-wake.test.ts index 8ceb17a8b24c..26f2326fc6a0 100644 --- a/src/agents/subagent-announce.requester-settle-wake.test.ts +++ b/src/agents/subagent-announce.requester-settle-wake.test.ts @@ -105,11 +105,18 @@ function transitionBatch(runIds: readonly string[], state: RequesterSettleWakeBa } } -function completeBatch(runIds: readonly string[]): void { - completeBatchSpy(runIds); +function completeBatch(runIds: readonly string[], rearmGeneration?: number): void { + if (rearmGeneration === undefined) { + completeBatchSpy(runIds); + } else { + completeBatchSpy(runIds, rearmGeneration); + } const selected = new Set(runIds); for (const entry of listedRequesterRuns()) { - if (selected.has(entry.runId)) { + if ( + selected.has(entry.runId) && + entry.requesterSettleWake?.rearmGeneration === rearmGeneration + ) { entry.requesterSettleWake = undefined; } } @@ -359,9 +366,18 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(deliverSpy).not.toHaveBeenCalled(); }); - it("does not add a wake turn after a single delivered completion", async () => { + it("does not add a wake turn for an ordinary frozen single completion", async () => { registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([ - makeSettledChild({ runId: "run-b", delivery: { status: "delivered" } }), + makeSettledChild({ + runId: "run-b", + delivery: { status: "delivered" }, + requesterSettleWake: { + status: "dispatching", + attemptCount: 1, + batchRunIds: ["run-b"], + requesterYieldBatch: true, + }, + }), ]); const woke = await maybeWakeRequesterAfterAllChildrenSettled(wakeParams()); @@ -370,6 +386,33 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => { expect(deliverSpy).not.toHaveBeenCalled(); }); + it("wakes after a requester yields with one already-delivered completion", async () => { + const child = makeSettledChild({ + runId: "run-b", + delivery: { status: "delivered" }, + requesterSettleWake: { + status: "pending", + attemptCount: 0, + batchRunIds: ["run-b"], + requesterYieldBatch: true, + afterRequesterYield: true, + rearmGeneration: 1, + }, + }); + registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([child]); + + const woke = await maybeWakeRequesterAfterAllChildrenSettled( + wakeParams({ settledEntry: child }), + ); + + expect(woke).toBe(true); + expect(deliverSpy).toHaveBeenCalledOnce(); + expect(deliveredCallArg().directIdempotencyKey).toBe( + `announce:requester-settle:${REQUESTER}:run-b:yield-1`, + ); + expect(completeBatchSpy).toHaveBeenCalledWith(["run-b"], 1); + }); + it("wakes for a single required completion whose announce never delivered", async () => { registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([ makeSettledChild({ diff --git a/src/agents/subagent-announce.requester-settle-wake.ts b/src/agents/subagent-announce.requester-settle-wake.ts index 069595684f7c..46b3ab603f1b 100644 --- a/src/agents/subagent-announce.requester-settle-wake.ts +++ b/src/agents/subagent-announce.requester-settle-wake.ts @@ -123,6 +123,13 @@ function readSharedBatchState(batch: readonly SubagentRunRecord[]): RequesterSet ...(source?.replayCount !== undefined ? { replayCount: source.replayCount } : {}), ...(source?.nextAttemptAt !== undefined ? { nextAttemptAt: source.nextAttemptAt } : {}), ...(source?.batchRunIds ? { batchRunIds: [...source.batchRunIds] } : {}), + ...(states.some((state) => state.requesterYieldBatch === true) + ? { requesterYieldBatch: true } + : {}), + ...(states.some((state) => state.afterRequesterYield === true) + ? { afterRequesterYield: true } + : {}), + ...(source?.rearmGeneration !== undefined ? { rearmGeneration: source.rearmGeneration } : {}), ...(source?.lastError !== undefined ? { lastError: source.lastError } : {}), }; } @@ -141,10 +148,27 @@ function deferRequesterSettleWakeBatch(params: { Date.now() + REQUESTER_SETTLE_WAKE_RETRY_DELAYS_MS[0], ), batchRunIds: [...params.batchRunIds], + ...(params.state.requesterYieldBatch === true ? { requesterYieldBatch: true } : {}), + ...(params.state.afterRequesterYield === true ? { afterRequesterYield: true } : {}), + ...(params.state.rearmGeneration !== undefined + ? { rearmGeneration: params.state.rearmGeneration } + : {}), ...(params.state.lastError !== undefined ? { lastError: params.state.lastError } : {}), }); } +function completeRequesterSettleWakeBatch(params: { + runIds: readonly string[]; + state: RequesterSettleWakeBatchState; + completeBatch(runIds: readonly string[], rearmGeneration?: number): void; +}): void { + if (params.state.rearmGeneration === undefined) { + params.completeBatch(params.runIds); + return; + } + params.completeBatch(params.runIds, params.state.rearmGeneration); +} + /** * Wakes a registry-less top-level requester once its last spawned child * reaches terminal settle. Durable state transitions happen synchronously @@ -155,19 +179,30 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { requesterOrigin?: DeliveryContext; settledEntry: SubagentRunRecord; transitionBatch: (runIds: readonly string[], state: RequesterSettleWakeBatchState) => void; - completeBatch(runIds: readonly string[]): void; + completeBatch(runIds: readonly string[], rearmGeneration?: number): void; signal?: AbortSignal; }): Promise { if (params.signal?.aborted) { return false; } + const completeBatch = (runIds: readonly string[], rearmGeneration?: number): void => { + if (rearmGeneration === undefined) { + params.completeBatch(runIds); + return; + } + params.completeBatch(runIds, rearmGeneration); + }; const requesterSessionKey = params.requesterSessionKey.trim(); const initialState = params.settledEntry.requesterSettleWake; if (!requesterSessionKey || !initialState) { return false; } if (isCronSessionKey(requesterSessionKey)) { - params.completeBatch([params.settledEntry.runId]); + completeRequesterSettleWakeBatch({ + runIds: [params.settledEntry.runId], + state: initialState, + completeBatch, + }); return false; } @@ -183,12 +218,17 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { registryRuntime.hasDescendantRunAwaitingSettle(requesterSessionKey, currentSettledEntry.runId); const frozenBatchRunIds = currentSettledEntry.requesterSettleWake.batchRunIds; + const currentRearmGeneration = currentSettledEntry.requesterSettleWake.rearmGeneration; let settledBatch: SubagentRunRecord[]; if (frozenBatchRunIds && frozenBatchRunIds.length > 0) { const runsById = new Map(requesterRuns.map((entry) => [entry.runId, entry])); settledBatch = frozenBatchRunIds .map((runId) => runsById.get(runId)) - .filter((entry): entry is SubagentRunRecord => Boolean(entry?.requesterSettleWake)); + .filter( + (entry): entry is SubagentRunRecord => + Boolean(entry?.requesterSettleWake) && + entry?.requesterSettleWake?.rearmGeneration === currentRearmGeneration, + ); } else { settledBatch = buildConnectedSettledWave( requesterRuns.filter((entry) => entry.requesterSettleWake && hasSubagentRunEnded(entry)), @@ -200,11 +240,12 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { } const batchRunIds = settledBatch.map((entry) => entry.runId).toSorted(); + const selectedState = readSharedBatchState(settledBatch); if (requesterHasUnsettledDescendants()) { if (frozenBatchRunIds && frozenBatchRunIds.length > 0) { deferRequesterSettleWakeBatch({ batchRunIds, - state: readSharedBatchState(settledBatch), + state: selectedState, transitionBatch: params.transitionBatch, }); } @@ -214,18 +255,31 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { const hasUndeliveredRequiredCompletion = requiredSettled.some( (entry) => entry.delivery?.status !== "delivered", ); + // A frozen single-child batch can be re-admitted after its requester yielded. + // The earlier steered completion died with that run, so the idle requester needs a fresh turn. + const requesterYieldedAfterDelivery = selectedState.afterRequesterYield === true; if ( requiredSettled.length === 0 || - (requiredSettled.length < 2 && !hasUndeliveredRequiredCompletion) || + (requiredSettled.length < 2 && + !hasUndeliveredRequiredCompletion && + !requesterYieldedAfterDelivery) || getSubagentDepthFromSessionStore(requesterSessionKey) >= 1 ) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state: selectedState, + completeBatch, + }); return false; } const { entry: requesterEntry } = loadRequesterSessionEntry(requesterSessionKey); if (!hasUsableSessionEntry(requesterEntry)) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state: selectedState, + completeBatch, + }); return false; } @@ -241,7 +295,14 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { const wakeMessage = buildRequesterSettleWakeMessage({ findings }); const requesterSessionOrigin = normalizeDeliveryContext(params.requesterOrigin); const directOrigin = resolveAnnounceOrigin(requesterEntry, requesterSessionOrigin); - const wakeKeyBase = `requester-settle:${requesterSessionKey}:${batchRunIds.join(",")}`; + const wakeKeyBase = [ + `requester-settle:${requesterSessionKey}:${batchRunIds.join(",")}`, + selectedState.rearmGeneration === undefined + ? undefined + : `yield-${selectedState.rearmGeneration}`, + ] + .filter(Boolean) + .join(":"); if (activeRequesterSettleWakeBatches.has(wakeKeyBase)) { return false; } @@ -278,7 +339,11 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { attemptIndex = Math.max(0, state.attemptCount - 1); } else { if (state.attemptCount >= REQUESTER_SETTLE_WAKE_MAX_ATTEMPTS) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state, + completeBatch, + }); return false; } attemptIndex = state.attemptCount; @@ -286,6 +351,9 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { status: "dispatching", attemptCount: state.attemptCount + 1, batchRunIds, + ...(state.requesterYieldBatch === true ? { requesterYieldBatch: true } : {}), + ...(state.afterRequesterYield === true ? { afterRequesterYield: true } : {}), + ...(state.rearmGeneration !== undefined ? { rearmGeneration: state.rearmGeneration } : {}), }; params.transitionBatch(batchRunIds, state); } @@ -321,7 +389,11 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { replayCount >= REQUESTER_SETTLE_WAKE_MAX_AMBIGUOUS_REPLAYS || retryDelayMs === undefined ) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state, + completeBatch, + }); return false; } const nextAttemptAt = Date.now() + retryDelayMs; @@ -331,6 +403,9 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { replayCount, nextAttemptAt, batchRunIds, + ...(state.requesterYieldBatch === true ? { requesterYieldBatch: true } : {}), + ...(state.afterRequesterYield === true ? { afterRequesterYield: true } : {}), + ...(state.rearmGeneration !== undefined ? { rearmGeneration: state.rearmGeneration } : {}), lastError, }; params.transitionBatch(batchRunIds, state); @@ -340,18 +415,30 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { return false; } if (delivery.delivered) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state, + completeBatch, + }); return true; } if (delivery.terminal === true || delivery.reason === "requester_abandoned") { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state, + completeBatch, + }); return false; } const attemptCount = attemptIndex + 1; const retryDelayMs = REQUESTER_SETTLE_WAKE_RETRY_DELAYS_MS[attemptIndex]; if (attemptCount >= REQUESTER_SETTLE_WAKE_MAX_ATTEMPTS || retryDelayMs === undefined) { - params.completeBatch(batchRunIds); + completeRequesterSettleWakeBatch({ + runIds: batchRunIds, + state, + completeBatch, + }); return false; } const lastError = delivery.error ?? delivery.reason ?? "undelivered"; @@ -361,6 +448,9 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: { attemptCount, nextAttemptAt, batchRunIds, + ...(state.requesterYieldBatch === true ? { requesterYieldBatch: true } : {}), + ...(state.afterRequesterYield === true ? { afterRequesterYield: true } : {}), + ...(state.rearmGeneration !== undefined ? { rearmGeneration: state.rearmGeneration } : {}), lastError, }); logWarn( diff --git a/src/agents/subagent-delivery-state.ts b/src/agents/subagent-delivery-state.ts index b967ada8bc9c..4b644aa15965 100644 --- a/src/agents/subagent-delivery-state.ts +++ b/src/agents/subagent-delivery-state.ts @@ -50,6 +50,13 @@ export function normalizeSubagentRunState(entry: SubagentRunRecord): SubagentRun const legacy = entry as LegacySubagentRunRecord; const taskRunId = typeof entry.taskRunId === "string" ? entry.taskRunId.trim() : ""; entry.taskRunId = taskRunId || undefined; + const requesterTurnRunId = + typeof entry.requesterTurnRunId === "string" ? entry.requesterTurnRunId.trim() : ""; + entry.requesterTurnRunId = requesterTurnRunId || undefined; + entry.requesterTurnYielded = + requesterTurnRunId && entry.requesterTurnYielded === true ? true : undefined; + entry.retireAfterRequesterTurn = + requesterTurnRunId && entry.retireAfterRequesterTurn === true ? true : undefined; entry.generation = typeof entry.generation === "number" && Number.isSafeInteger(entry.generation) && diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 23f5614fdf86..7f9345041e2e 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -3575,14 +3575,75 @@ describe("requester settle wake trigger", () => { expect(later.requesterSettleWake).toEqual({ status: "pending", attemptCount: 0 }); }); - it("preserves delete-mode child results for the settle wake after delivered cleanup clears them", async () => { + it("preserves a yielded batch re-armed during an earlier successful wake", async () => { const entry = createRunEntry({ + requesterTurnRunId: "run-requester", + requesterTurnYielded: true, + endedAt: 4_000, + expectsCompletionMessage: true, + delivery: { status: "delivered" }, + }); + const settleWake = vi.fn( + async ( + params: Parameters< + LifecycleControllerParams["maybeWakeRequesterAfterAllChildrenSettled"] + >[0], + ) => { + const firstInvocation = settleWake.mock.calls.length === 1; + if (firstInvocation) { + controller.settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: entry.requesterSessionKey, + requesterTurnRunId: "run-requester", + requesterYielded: true, + acceptedSessionSpawns: [{ runId: entry.runId, childSessionKey: entry.childSessionKey }], + }); + params.transitionBatch([entry.runId], { + status: "dispatching", + attemptCount: 1, + batchRunIds: [entry.runId], + }); + } + params.completeBatch( + [entry.runId], + firstInvocation ? undefined : entry.requesterSettleWake?.rearmGeneration, + ); + return firstInvocation; + }, + ); + const controller = createLifecycleController({ + entry, + maybeWakeRequesterAfterAllChildrenSettled: settleWake, + }); + + controller.completeCleanupBookkeeping({ + runId: entry.runId, + entry, + cleanup: "keep", + completedAt: 5_000, + }); + + await waitForLifecycleState(() => expect(settleWake).toHaveBeenCalledTimes(2)); + expect(entry.requesterSettleWake).toBeUndefined(); + }); + + it("retains a delete-mode child after no-wake until its requester turn settles", async () => { + const entry = createRunEntry({ + requesterTurnRunId: "run-requester", cleanup: "delete", expectsCompletionMessage: true, completion: { required: true, resultText: "delete-mode findings" }, }); const runs = new Map([[entry.runId, entry]]); - const settleWake = vi.fn(async () => false); + const settleWake = vi.fn( + async ( + params: Parameters< + LifecycleControllerParams["maybeWakeRequesterAfterAllChildrenSettled"] + >[0], + ) => { + params.completeBatch([entry.runId]); + return false; + }, + ); const runSubagentAnnounceFlow = vi.fn(async () => true); const controller = createLifecycleController({ entry, @@ -3600,10 +3661,11 @@ describe("requester settle wake trigger", () => { }); await waitForLifecycleState(() => expect(settleWake).toHaveBeenCalledTimes(1)); - // Delete-mode keeps the canonical row and result until the durable wake - // outbox reaches success or a terminal/no-wake disposition. + // The no-wake decision completed, but the spawning turn can still yield. expect(entry.completion?.resultText).toBe("delete-mode findings"); expect(runs.has(entry.runId)).toBe(true); + expect(entry.requesterSettleWake).toBeUndefined(); + expect(entry.retireAfterRequesterTurn).toBe(true); expect(settleWake).toHaveBeenCalledWith( expect.objectContaining({ settledEntry: expect.objectContaining({ diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 63549ad046f9..2a1e4c6ac90a 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -28,6 +28,7 @@ import { import { isProvisionalSubagentKillTask } from "../tasks/task-cancellation-state.js"; import { resolveRequiredCompletionDeliveryFailureTerminalResult } from "../tasks/task-completion-contract.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; +import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js"; import { retireSessionMcpRuntimeForSessionKey } from "./agent-bundle-mcp-tools.js"; import { buildAnnounceIdFromChildRun, @@ -69,6 +70,7 @@ import { resolveAnnounceRetryDelayMs, safeRemoveAttachmentsDir, } from "./subagent-registry-helpers.js"; +import { settleRequesterTurnAfterSessionSpawns } from "./subagent-registry-requester-yield.js"; import type { PendingFinalDeliveryPayload, RequesterSettleWakeState, @@ -204,6 +206,7 @@ export function createSubagentRegistryLifecycleController(params: { warn(message: string, meta?: Record): void; }) { const scheduledResumeTimers = new Set>(); + const pendingRequesterSettleWakeRearms = new Set(); const scheduledRequesterSettleWakeRuns = new Set(); const scheduledRequesterSettleWakeTimers = new Map>(); const terminalCompletionLocks = new Map>(); @@ -289,6 +292,7 @@ export function createSubagentRegistryLifecycleController(params: { clearTimeout(timer); } scheduledRequesterSettleWakeTimers.clear(); + pendingRequesterSettleWakeRearms.clear(); }; const runDetachedCleanupAttempt = (args: { @@ -779,7 +783,11 @@ export function createSubagentRegistryLifecycleController(params: { ) => { const entries = runIds .map((runId) => params.runs.get(runId)) - .filter((entry): entry is SubagentRunRecord => Boolean(entry?.requesterSettleWake)); + .filter( + (entry): entry is SubagentRunRecord => + Boolean(entry?.requesterSettleWake) && + entry?.requesterSettleWake?.rearmGeneration === state.rearmGeneration, + ); const previousStates = entries.map((entry) => structuredClone(entry.requesterSettleWake)); for (const entry of entries) { entry.requesterSettleWake = { @@ -799,16 +807,31 @@ export function createSubagentRegistryLifecycleController(params: { } }; - const completeRequesterSettleWakeBatch = (runIds: readonly string[]) => { + const completeRequesterSettleWakeBatch = ( + runIds: readonly string[], + rearmGeneration?: number, + ) => { const entries = runIds .map((runId) => [runId, params.runs.get(runId)] as const) - .filter((pair): pair is readonly [string, SubagentRunRecord] => - Boolean(pair[1]?.requesterSettleWake), + .filter( + (pair): pair is readonly [string, SubagentRunRecord] => + Boolean(pair[1]?.requesterSettleWake) && + pair[1]?.requesterSettleWake?.rearmGeneration === rearmGeneration, ); const requesterSessionKeys = new Set(entries.map(([, entry]) => entry.requesterSessionKey)); - const previousStates = entries.map(([, entry]) => structuredClone(entry.requesterSettleWake)); + const previousStates = entries.map(([, entry]) => ({ + requesterSettleWake: structuredClone(entry.requesterSettleWake), + retireAfterRequesterTurn: entry.retireAfterRequesterTurn, + })); for (const [runId, entry] of entries) { - if (entry.requesterSettleWake?.retireAfterSettle === true) { + if (entry.requesterTurnRunId) { + entry.retireAfterRequesterTurn = + entry.retireAfterRequesterTurn === true || + entry.requesterSettleWake?.retireAfterSettle === true + ? true + : undefined; + entry.requesterSettleWake = undefined; + } else if (entry.requesterSettleWake?.retireAfterSettle === true) { params.runs.delete(runId); } else { entry.requesterSettleWake = undefined; @@ -818,8 +841,10 @@ export function createSubagentRegistryLifecycleController(params: { params.persistOrThrow(); } catch (error) { entries.forEach(([runId, entry], index) => { + const previous = previousStates[index]; params.runs.set(runId, entry); - entry.requesterSettleWake = previousStates[index]; + entry.requesterSettleWake = previous?.requesterSettleWake; + entry.retireAfterRequesterTurn = previous?.retireAfterRequesterTurn; }); throw error; } @@ -852,6 +877,11 @@ export function createSubagentRegistryLifecycleController(params: { ...(existing?.replayCount !== undefined ? { replayCount: existing.replayCount } : {}), ...(existing?.nextAttemptAt !== undefined ? { nextAttemptAt: existing.nextAttemptAt } : {}), ...(existing?.batchRunIds ? { batchRunIds: [...existing.batchRunIds] } : {}), + ...(existing?.requesterYieldBatch === true ? { requesterYieldBatch: true } : {}), + ...(existing?.afterRequesterYield === true ? { afterRequesterYield: true } : {}), + ...(existing?.rearmGeneration !== undefined + ? { rearmGeneration: existing.rearmGeneration } + : {}), ...(existing?.lastError !== undefined ? { lastError: existing.lastError } : {}), ...(existing?.retireAfterSettle === true || options?.retireAfterSettle === true ? { retireAfterSettle: true } @@ -940,9 +970,16 @@ export function createSubagentRegistryLifecycleController(params: { }) .finally(() => { scheduledRequesterSettleWakeRuns.delete(runId); + const wasRearmedWhileRunning = pendingRequesterSettleWakeRearms.delete(runId); const current = params.runs.get(runId); if (current === entry && current.requesterSettleWake) { - scheduleRequesterSettleWakeRetry(runId, current); + if (wasRearmedWhileRunning) { + // A requester yield can freeze a delivered batch while this run is + // resolving its earlier no-wake decision. Admit that durable update now. + scheduleRequesterSettleWake(runId, current); + } else { + scheduleRequesterSettleWakeRetry(runId, current); + } } }); } @@ -2307,6 +2344,24 @@ export function createSubagentRegistryLifecycleController(params: { completeSubagentRun, finalizeResumedAnnounceGiveUp, refreshFrozenResultFromSession, + settleRequesterTurnAfterSessionSpawns: (args: { + requesterSessionKey: string; + requesterTurnRunId: string; + requesterYielded: boolean; + acceptedSessionSpawns: readonly AcceptedSessionSpawn[]; + }) => + settleRequesterTurnAfterSessionSpawns({ + ...args, + runs: params.runs, + persistOrThrow: () => params.persistOrThrow(), + schedule: (runId, entry) => { + if (scheduledRequesterSettleWakeRuns.has(runId)) { + pendingRequesterSettleWakeRearms.add(runId); + return; + } + scheduleRequesterSettleWake(runId, entry); + }, + }), resumeRequesterSettleWake: scheduleRequesterSettleWake, startSubagentAnnounceCleanupFlow, }; diff --git a/src/agents/subagent-registry-requester-yield.test.ts b/src/agents/subagent-registry-requester-yield.test.ts new file mode 100644 index 000000000000..7ba7ce80ceaa --- /dev/null +++ b/src/agents/subagent-registry-requester-yield.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from "vitest"; +import { + markRequesterTurnYieldedInRuns, + settleRequesterTurnAfterSessionSpawns, +} from "./subagent-registry-requester-yield.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +const REQUESTER = "agent:main:main"; +const REQUESTER_TURN = "run-requester"; + +function makeRun(runId: string, requesterTurnYielded = true): SubagentRunRecord { + return { + runId, + requesterTurnRunId: REQUESTER_TURN, + ...(requesterTurnYielded ? { requesterTurnYielded: true } : {}), + childSessionKey: `agent:main:subagent:${runId}`, + requesterSessionKey: REQUESTER, + requesterDisplayKey: "main", + task: "finish", + cleanup: "keep", + createdAt: 1_000, + endedAt: 2_000, + expectsCompletionMessage: true, + delivery: { status: "delivered" }, + }; +} + +function accepted(entry: SubagentRunRecord) { + return { runId: entry.runId, childSessionKey: entry.childSessionKey }; +} + +describe("settleRequesterTurnAfterSessionSpawns", () => { + it("persists explicit yield intent before settlement", () => { + const entry = makeRun("run-child", false); + const persistOrThrow = vi.fn(); + + expect( + markRequesterTurnYieldedInRuns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + runs: new Map([[entry.runId, entry]]), + persistOrThrow, + }), + ).toBe(1); + expect(entry.requesterTurnYielded).toBe(true); + expect(persistOrThrow).toHaveBeenCalledOnce(); + }); + + it("persists and schedules the exact yielded child batch", () => { + const first = makeRun("run-b"); + const second = makeRun("run-a"); + const persistOrThrow = vi.fn(); + const schedule = vi.fn(); + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: true, + acceptedSessionSpawns: [accepted(first), accepted(second)], + runs: new Map([ + [first.runId, first], + [second.runId, second], + ]), + persistOrThrow, + schedule, + }), + ).toBe(true); + + expect(persistOrThrow).toHaveBeenCalledOnce(); + expect(first.requesterSettleWake?.batchRunIds).toEqual(["run-a", "run-b"]); + expect(second.requesterSettleWake?.batchRunIds).toEqual(["run-a", "run-b"]); + expect(first.requesterSettleWake).toMatchObject({ + requesterYieldBatch: true, + afterRequesterYield: true, + rearmGeneration: 1, + }); + expect(first.requesterTurnRunId).toBeUndefined(); + expect(schedule).toHaveBeenCalledOnce(); + }); + + it("freezes active yielded children without scheduling before terminal delivery", () => { + const entry = makeRun("run-child"); + entry.endedAt = undefined; + entry.delivery = { status: "pending" }; + const schedule = vi.fn(); + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: true, + acceptedSessionSpawns: [accepted(entry)], + runs: new Map([[entry.runId, entry]]), + persistOrThrow: vi.fn(), + schedule, + }), + ).toBe(true); + expect(entry.requesterSettleWake).toMatchObject({ + batchRunIds: [entry.runId], + requesterYieldBatch: true, + }); + expect(entry.requesterSettleWake?.afterRequesterYield).toBeUndefined(); + expect(schedule).not.toHaveBeenCalled(); + }); + + it("re-arms a completion whose delivery is in progress when its requester yields", () => { + const entry = makeRun("run-child"); + entry.delivery = { status: "in_progress" }; + const schedule = vi.fn(); + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: true, + acceptedSessionSpawns: [accepted(entry)], + runs: new Map([[entry.runId, entry]]), + persistOrThrow: vi.fn(), + schedule, + }), + ).toBe(true); + expect(entry.requesterSettleWake).toMatchObject({ + requesterYieldBatch: true, + afterRequesterYield: true, + }); + expect(schedule).not.toHaveBeenCalled(); + }); + + it("ignores accepted spawns that do not produce completion messages", () => { + const completion = makeRun("run-completion"); + const inline = makeRun("run-inline"); + inline.requesterTurnRunId = undefined; + inline.expectsCompletionMessage = false; + inline.delivery = { status: "not_required" }; + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: true, + acceptedSessionSpawns: [accepted(completion), accepted(inline)], + runs: new Map([ + [completion.runId, completion], + [inline.runId, inline], + ]), + persistOrThrow: vi.fn(), + schedule: vi.fn(), + }), + ).toBe(true); + expect(completion.requesterSettleWake?.afterRequesterYield).toBe(true); + expect(inline.requesterSettleWake).toBeUndefined(); + }); + + it("re-arms a delivered delete-mode row retained through requester settlement", () => { + const entry = makeRun("run-delete"); + entry.cleanup = "delete"; + entry.cleanupCompletedAt = 2_100; + entry.retireAfterRequesterTurn = true; + const runs = new Map([[entry.runId, entry]]); + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: true, + acceptedSessionSpawns: [accepted(entry)], + runs, + persistOrThrow: vi.fn(), + schedule: vi.fn(), + }), + ).toBe(true); + expect(runs.get(entry.runId)).toBe(entry); + expect(entry.requesterSettleWake).toMatchObject({ + afterRequesterYield: true, + retireAfterSettle: true, + }); + expect(entry.retireAfterRequesterTurn).toBeUndefined(); + }); + + it("retires a completed delete-mode row after a normal requester answer", () => { + const entry = makeRun("run-delete", false); + entry.retireAfterRequesterTurn = true; + const runs = new Map([[entry.runId, entry]]); + + expect( + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: false, + acceptedSessionSpawns: [accepted(entry)], + runs, + persistOrThrow: vi.fn(), + schedule: vi.fn(), + }), + ).toBe(true); + expect(runs.has(entry.runId)).toBe(false); + }); + + it("rolls back every row when durable persistence fails", () => { + const entry = makeRun("run-delete", false); + entry.retireAfterRequesterTurn = true; + const runs = new Map([[entry.runId, entry]]); + const failure = new Error("sqlite unavailable"); + + expect(() => + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey: REQUESTER, + requesterTurnRunId: REQUESTER_TURN, + requesterYielded: false, + acceptedSessionSpawns: [accepted(entry)], + runs, + persistOrThrow: () => { + throw failure; + }, + schedule: vi.fn(), + }), + ).toThrow(failure); + expect(runs.get(entry.runId)).toBe(entry); + expect(entry.requesterTurnRunId).toBe(REQUESTER_TURN); + expect(entry.retireAfterRequesterTurn).toBe(true); + }); +}); diff --git a/src/agents/subagent-registry-requester-yield.ts b/src/agents/subagent-registry-requester-yield.ts new file mode 100644 index 000000000000..4a8429a96dd3 --- /dev/null +++ b/src/agents/subagent-registry-requester-yield.ts @@ -0,0 +1,152 @@ +/** Settles durable child ownership when the spawning requester turn ends. */ +import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js"; +import type { SubagentRunRecord } from "./subagent-registry.types.js"; + +/** Persists explicit yield intent before the requester run is aborted. */ +export function markRequesterTurnYieldedInRuns(params: { + requesterSessionKey: string; + requesterTurnRunId: string; + runs: Map; + persistOrThrow(): void; +}): number { + const requesterSessionKey = params.requesterSessionKey.trim(); + const requesterTurnRunId = params.requesterTurnRunId.trim(); + if (!requesterSessionKey || !requesterTurnRunId) { + return 0; + } + const entries = [...params.runs.values()].filter( + (entry) => + entry.requesterSessionKey === requesterSessionKey && + entry.requesterTurnRunId === requesterTurnRunId, + ); + if (entries.every((entry) => entry.requesterTurnYielded === true)) { + return entries.length; + } + const previous = entries.map((entry) => entry.requesterTurnYielded); + for (const entry of entries) { + entry.requesterTurnYielded = true; + } + try { + params.persistOrThrow(); + } catch (error) { + entries.forEach((entry, index) => { + entry.requesterTurnYielded = previous[index]; + }); + throw error; + } + return entries.length; +} + +export function settleRequesterTurnAfterSessionSpawns(params: { + requesterSessionKey: string; + requesterTurnRunId: string; + requesterYielded: boolean; + acceptedSessionSpawns: readonly AcceptedSessionSpawn[]; + runs: Map; + persistOrThrow(): void; + schedule(runId: string, entry: SubagentRunRecord): void; +}): boolean { + const requesterSessionKey = params.requesterSessionKey.trim(); + const requesterTurnRunId = params.requesterTurnRunId.trim(); + const spawnsByRunId = new Map( + params.acceptedSessionSpawns.map((spawn) => [spawn.runId, spawn] as const), + ); + if (!requesterSessionKey || !requesterTurnRunId || spawnsByRunId.size === 0) { + return false; + } + + // Registry markers select completion-producing children. Accepted inline or + // otherwise non-completion spawns are intentionally outside this batch. + const entries = [...params.runs.values()].filter( + (entry) => + entry.requesterSessionKey === requesterSessionKey && + entry.requesterTurnRunId === requesterTurnRunId, + ); + for (const entry of entries) { + const spawn = spawnsByRunId.get(entry.runId); + if ( + !spawn || + entry.expectsCompletionMessage !== true || + entry.childSessionKey !== spawn.childSessionKey || + (params.requesterYielded && entry.requesterTurnYielded !== true) + ) { + return false; + } + } + + const firstEntry = entries[0]; + if (!firstEntry) { + return false; + } + const batchRunIds = entries.map((entry) => entry.runId).toSorted(); + const previousStates = entries.map((entry) => ({ + requesterSettleWake: structuredClone(entry.requesterSettleWake), + requesterTurnRunId: entry.requesterTurnRunId, + requesterTurnYielded: entry.requesterTurnYielded, + retireAfterRequesterTurn: entry.retireAfterRequesterTurn, + })); + let rearmGeneration: number | undefined; + if (params.requesterYielded) { + rearmGeneration = + Math.max(0, ...entries.map((entry) => entry.requesterSettleWake?.rearmGeneration ?? 0)) + 1; + for (const entry of entries) { + const existing = entry.requesterSettleWake; + // An in-progress delivery may already target the requester run being aborted. + // Re-arm it like a delivered result so that completion cannot die with that turn. + const completionMayBeAttachedToYieldedTurn = + typeof entry.endedAt === "number" && + (entry.delivery?.status === "delivered" || entry.delivery?.status === "in_progress"); + entry.requesterSettleWake = { + status: "pending", + attemptCount: 0, + batchRunIds, + requesterYieldBatch: true, + ...(completionMayBeAttachedToYieldedTurn ? { afterRequesterYield: true } : {}), + rearmGeneration, + ...(existing?.retireAfterSettle === true || entry.retireAfterRequesterTurn === true + ? { retireAfterSettle: true } + : {}), + }; + entry.requesterTurnRunId = undefined; + entry.requesterTurnYielded = undefined; + entry.retireAfterRequesterTurn = undefined; + } + } else { + for (const entry of entries) { + entry.requesterTurnRunId = undefined; + entry.requesterTurnYielded = undefined; + if (entry.retireAfterRequesterTurn === true) { + if (entry.requesterSettleWake) { + entry.requesterSettleWake.retireAfterSettle = true; + entry.retireAfterRequesterTurn = undefined; + } else { + params.runs.delete(entry.runId); + } + } + } + } + try { + params.persistOrThrow(); + } catch (error) { + entries.forEach((entry, index) => { + const previous = previousStates[index]; + params.runs.set(entry.runId, entry); + entry.requesterSettleWake = previous?.requesterSettleWake; + entry.requesterTurnRunId = previous?.requesterTurnRunId; + entry.requesterTurnYielded = previous?.requesterTurnYielded; + entry.retireAfterRequesterTurn = previous?.retireAfterRequesterTurn; + }); + throw error; + } + + if ( + rearmGeneration !== undefined && + entries.every( + (entry) => typeof entry.endedAt === "number" && entry.delivery?.status === "delivered", + ) + ) { + // Active children keep the frozen batch but let their normal cleanup owner schedule it. + params.schedule(firstEntry.runId, firstEntry); + } + return true; +} diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index 7b81b50bf075..c017edad6392 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -168,6 +168,7 @@ export function markSubagentRunPausedAfterYield(params: { export type RegisterSubagentRunParams = { runId: string; + requesterTurnRunId?: string; childSessionKey: string; controllerSessionKey?: string; requesterSessionKey: string; @@ -734,6 +735,7 @@ export function createSubagentRunManager(params: { const runId = registerParams.runId.trim(); const childSessionKey = registerParams.childSessionKey.trim(); const requesterSessionKey = registerParams.requesterSessionKey.trim(); + const requesterTurnRunId = registerParams.requesterTurnRunId?.trim(); const controllerSessionKey = registerParams.controllerSessionKey?.trim() || requesterSessionKey; if (!runId || !childSessionKey || !requesterSessionKey) { return; @@ -755,6 +757,9 @@ export function createSubagentRunManager(params: { const entry: SubagentRunRecord = normalizeSubagentRunState({ runId, taskRunId: runId, + ...(requesterTurnRunId && registerParams.expectsCompletionMessage === true + ? { requesterTurnRunId } + : {}), childSessionKey, controllerSessionKey, requesterSessionKey, diff --git a/src/agents/subagent-registry.persistence.resume.test.ts b/src/agents/subagent-registry.persistence.resume.test.ts index 5eeab51d6e49..fe13f005d823 100644 --- a/src/agents/subagent-registry.persistence.resume.test.ts +++ b/src/agents/subagent-registry.persistence.resume.test.ts @@ -118,4 +118,61 @@ describe("subagent registry persistence resume", () => { }); }); }); + + it("retries pending child delivery before a recovered requester-turn wake", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); + const stateDir = tempStateDir; + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => { + const run: SubagentRunRecord = { + runId: "run-pending-delivery", + requesterTurnRunId: "run-requester", + childSessionKey: "agent:main:subagent:pending-delivery", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "deliver before waking requester", + cleanup: "keep", + createdAt: 100, + startedAt: 110, + endedAt: 200, + endedReason: "subagent-complete", + outcome: { status: "ok" }, + execution: { status: "terminal", startedAt: 110, endedAt: 200 }, + expectsCompletionMessage: true, + completion: { required: true, resultText: "done", capturedAt: 200 }, + delivery: { + status: "pending", + payload: { + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + childSessionKey: "agent:main:subagent:pending-delivery", + childRunId: "run-pending-delivery", + task: "deliver before waking requester", + startedAt: 110, + endedAt: 200, + outcome: { status: "ok" }, + expectsCompletionMessage: true, + }, + }, + cleanupHandled: false, + }; + saveSubagentRegistryToSqlite(new Map([[run.runId, run]])); + await writeSubagentSessionEntry({ + stateDir, + agentId: "main", + sessionKey: run.childSessionKey, + sessionId: "sess-pending-delivery", + defaultSessionId: "sess-pending-delivery", + }); + + mod.initSubagentRegistry(); + + await vi.waitFor(() => expect(announceSpy).toHaveBeenCalled(), { + timeout: 1_000, + interval: 10, + }); + expect(announceSpy).toHaveBeenCalledWith( + expect.objectContaining({ childRunId: "run-pending-delivery" }), + ); + }); + }); }); diff --git a/src/agents/subagent-registry.store.sqlite.test.ts b/src/agents/subagent-registry.store.sqlite.test.ts index e8009d970d2b..879e7d8a6905 100644 --- a/src/agents/subagent-registry.store.sqlite.test.ts +++ b/src/agents/subagent-registry.store.sqlite.test.ts @@ -83,6 +83,9 @@ describe("subagent registry sqlite store", () => { it("persists subagent runs in the shared sqlite state database", async () => { await withTempStateEnv(async () => { const run = createRun({ + requesterTurnRunId: "run-requester", + requesterTurnYielded: true, + retireAfterRequesterTurn: true, endedReason: "subagent-error", outcome: { status: "error", error: "restart interrupted run", endedAt: 250 }, terminalOwner: "interrupted-recovery", @@ -93,6 +96,9 @@ describe("subagent registry sqlite store", () => { replayCount: 1, nextAttemptAt: 30_000, batchRunIds: ["run-one", "run-two"], + requesterYieldBatch: true, + afterRequesterYield: true, + rearmGeneration: 3, lastError: "provider timeout", retireAfterSettle: true, }, @@ -106,6 +112,9 @@ describe("subagent registry sqlite store", () => { childSessionKey: run.childSessionKey, requesterSessionKey: run.requesterSessionKey, task: run.task, + requesterTurnRunId: "run-requester", + requesterTurnYielded: true, + retireAfterRequesterTurn: true, endedAt: run.endedAt, outcome: run.outcome, terminalOwner: "interrupted-recovery", diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 85dbd8e4c0e4..7024a7a1c99f 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -29,6 +29,7 @@ import { SUBAGENT_KILL_TASK_ERROR } from "../tasks/detached-task-runtime-contrac import { finalizeTaskRunByRunId, findDetachedTaskRun } from "../tasks/detached-task-runtime.js"; import { isProvisionalSubagentKillTask } from "../tasks/task-cancellation-state.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; +import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js"; import { ackLeasedAgentSteeringItemsFromSubagentRuns, leasePendingAgentSteeringItemsFromSubagentRuns, @@ -79,6 +80,7 @@ import { listRunsForControllerFromRuns, listDescendantRunsForRequesterFromRuns, } from "./subagent-registry-queries.js"; +import { markRequesterTurnYieldedInRuns } from "./subagent-registry-requester-yield.js"; import { createSubagentRunManager, markSubagentRunPausedAfterYield, @@ -786,6 +788,7 @@ const { finalizeResumedAnnounceGiveUp, refreshFrozenResultFromSession, resumeRequesterSettleWake, + settleRequesterTurnAfterSessionSpawns, startSubagentAnnounceCleanupFlow, } = subagentLifecycleController; @@ -858,7 +861,16 @@ function resumeSubagentRun(runId: string) { resumedRuns.add(runId); return; } - if (entry.requesterSettleWake) { + const yieldedWakeWaitingForDelivery = + entry.requesterSettleWake?.requesterYieldBatch === true && + (entry.delivery?.status === "pending" || + entry.delivery?.status === "in_progress" || + entry.delivery?.status === "failed"); + if ( + entry.requesterSettleWake && + typeof entry.endedAt === "number" && + !yieldedWakeWaitingForDelivery + ) { resumeRequesterSettleWake(runId, entry); return; } @@ -960,6 +972,34 @@ function restoreSubagentRunsOnce() { ) { persistSubagentRuns(); } + const requesterTurns = new Map>(); + for (const entry of subagentRuns.values()) { + const requesterTurnRunId = entry.requesterTurnRunId?.trim(); + if (!requesterTurnRunId) { + continue; + } + let turns = requesterTurns.get(entry.requesterSessionKey); + if (!turns) { + turns = new Map(); + requesterTurns.set(entry.requesterSessionKey, turns); + } + const entries = turns.get(requesterTurnRunId) ?? []; + entries.push(entry); + turns.set(requesterTurnRunId, entries); + } + for (const [requesterSessionKey, turns] of requesterTurns) { + for (const [requesterTurnRunId, entries] of turns) { + settleRequesterTurnAfterSessionSpawns({ + requesterSessionKey, + requesterTurnRunId, + requesterYielded: entries.every((entry) => entry.requesterTurnYielded === true), + acceptedSessionSpawns: entries.map((entry) => ({ + runId: entry.runId, + childSessionKey: entry.childSessionKey, + })), + }); + } + } if (subagentRuns.size === 0) { return; } @@ -2049,6 +2089,29 @@ export function initSubagentRegistry() { restoreSubagentRunsOnce(); } +/** Re-admits a delivered child batch after its requester explicitly yields. */ +export function settleRequesterAfterSessionSpawns(params: { + requesterSessionKey: string; + requesterTurnRunId: string; + requesterYielded: boolean; + acceptedSessionSpawns: readonly AcceptedSessionSpawn[]; +}): boolean { + return settleRequesterTurnAfterSessionSpawns(params); +} + +/** Records sessions_yield before the active requester run is aborted. */ +export function markRequesterTurnYielded(params: { + requesterSessionKey: string; + requesterTurnRunId: string; +}): number { + restoreSubagentRunsOnce(); + return markRequesterTurnYieldedInRuns({ + ...params, + runs: subagentRuns, + persistOrThrow: persistSubagentRunsOrThrow, + }); +} + const SUBAGENT_REGISTRY_TEST_HANDLE = Symbol.for("openclaw.subagentRegistryTestApi"); if (process.env.VITEST || process.env.NODE_ENV === "test") { (globalThis as Record)[SUBAGENT_REGISTRY_TEST_HANDLE] = { diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index b7337b977eca..7754055a576b 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -103,8 +103,14 @@ export type RequesterSettleWakeState = { replayCount?: number; /** Persisted retry deadline; restore waits until this instant. */ nextAttemptAt?: number; - /** Frozen wave membership once the first delivery attempt is admitted. */ + /** Frozen wave membership after delivery admission or requester-yield re-admission. */ batchRunIds?: string[]; + /** Batch frozen while its spawning requester turn was yielding. */ + requesterYieldBatch?: true; + /** Present only when an idle requester needs a new turn after yielding. */ + afterRequesterYield?: true; + /** Monotonic process generation protecting a newer yield from stale completion. */ + rearmGeneration?: number; lastError?: string | null; /** Cleanup wanted to retire this row; defer deletion until the outbox resolves. */ retireAfterSettle?: boolean; @@ -123,6 +129,12 @@ export type SubagentRunRecord = { runId: string; /** Detached task owner; steer/restart changes runId but continues the same task. */ taskRunId?: string; + /** Requester attempt that must settle before this completion row can retire. */ + requesterTurnRunId?: string; + /** Durable proof that this requester attempt invoked sessions_yield. */ + requesterTurnYielded?: true; + /** Cleanup retirement deferred until requesterTurnRunId settles. */ + retireAfterRequesterTurn?: boolean; childSessionKey: string; controllerSessionKey?: string; requesterSessionKey: string; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 548173e6cee8..b00c97a33f33 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -168,6 +168,7 @@ type SpawnSubagentParams = { type SpawnSubagentContext = { agentSessionKey?: string; + requesterTurnRunId?: string; /** Separate key used only for completion routing, not sandbox policy. */ completionOwnerKey?: string; agentChannel?: string; @@ -1636,6 +1637,7 @@ export async function spawnSubagentDirect( try { registerSubagentRun({ runId: childRunId, + requesterTurnRunId: ctx.requesterTurnRunId, childSessionKey, controllerSessionKey: ownership.controllerSessionKey, requesterSessionKey: ownership.completionRequesterSessionKey, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 36d6af9147b4..14cbaa8652da 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -218,6 +218,7 @@ function resolveAcpUnavailableMessage(opts?: { sandboxed?: boolean; config?: Ope export function createSessionsSpawnTool( opts?: { agentSessionKey?: string; + requesterTurnRunId?: string; /** Separate key used only for completion routing (registerSubagentRun requesterSessionKey). */ completionOwnerKey?: string; agentChannel?: GatewayMessageChannel; @@ -434,6 +435,7 @@ export function createSessionsSpawnTool( try { registerSubagentRun({ runId: childRunId, + requesterTurnRunId: opts?.requesterTurnRunId, childSessionKey, controllerSessionKey: ownership.controllerSessionKey, requesterSessionKey: ownership.completionRequesterSessionKey, @@ -509,6 +511,7 @@ export function createSessionsSpawnTool( }, { agentSessionKey: opts?.agentSessionKey, + requesterTurnRunId: opts?.requesterTurnRunId, completionOwnerKey: opts?.completionOwnerKey, agentChannel: opts?.agentChannel, agentAccountId: opts?.agentAccountId, diff --git a/src/agents/tools/sessions-yield-tool.test.ts b/src/agents/tools/sessions-yield-tool.test.ts index b6dab8f7b073..6e6a2f4a9856 100644 --- a/src/agents/tools/sessions-yield-tool.test.ts +++ b/src/agents/tools/sessions-yield-tool.test.ts @@ -44,6 +44,38 @@ describe("sessions_yield tool", () => { expect(onYield).toHaveBeenCalledWith("Waiting for fact-checker"); }); + it("persists yield intent before aborting the requester run", async () => { + const order: string[] = []; + const tool = createSessionsYieldTool({ + sessionId: "test-session", + onBeforeYield: () => { + order.push("persist"); + }, + onYield: () => { + order.push("abort"); + }, + }); + + await tool.execute("call-1", {}); + + expect(order).toEqual(["persist", "abort"]); + }); + + it("does not abort the requester when yield intent cannot persist", async () => { + const failure = new Error("sqlite unavailable"); + const onYield = vi.fn(); + const tool = createSessionsYieldTool({ + sessionId: "test-session", + onBeforeYield: () => { + throw failure; + }, + onYield, + }); + + await expect(tool.execute("call-1", {})).rejects.toThrow(failure); + expect(onYield).not.toHaveBeenCalled(); + }); + it("returns error without onYield callback", async () => { const tool = createSessionsYieldTool({ sessionId: "test-session" }); const result = await tool.execute("call-1", {}); diff --git a/src/agents/tools/sessions-yield-tool.ts b/src/agents/tools/sessions-yield-tool.ts index 0bb48d6fd2d2..051c24b69517 100644 --- a/src/agents/tools/sessions-yield-tool.ts +++ b/src/agents/tools/sessions-yield-tool.ts @@ -14,6 +14,7 @@ const SessionsYieldToolSchema = Type.Object({ /** Creates the sessions_yield tool for runtimes that support yield callbacks. */ export function createSessionsYieldTool(opts?: { sessionId?: string; + onBeforeYield?: () => Promise | void; onYield?: (message: string) => Promise | void; }): AnyAgentTool { return { @@ -30,6 +31,7 @@ export function createSessionsYieldTool(opts?: { if (!opts?.onYield) { return jsonResult({ status: "error", error: "Yield not supported in this context" }); } + await opts.onBeforeYield?.(); // The runtime owns the actual pause/end-turn behavior; this tool records intent. await opts.onYield(message); return jsonResult({ status: "yielded", message });