fix(agents): parent resumes after yielded child handoff (#110922)

* fix(agents): resume handoff after requester yield

* fix(agents): retain handoff through requester settle

* fix(agents): avoid duplicate future-child wake

* fix(agents): rearm in-flight handoff on yield
This commit is contained in:
Peter Steinberger
2026-07-18 20:39:23 +01:00
committed by GitHub
parent 2632969aae
commit bf2da1ce1a
19 changed files with 936 additions and 32 deletions
@@ -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");
});
});
@@ -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;
}
+10
View File
@@ -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({
@@ -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({
@@ -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<boolean> {
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(
+7
View File
@@ -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) &&
+66 -4
View File
@@ -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({
+63 -8
View File
@@ -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<string, unknown>): void;
}) {
const scheduledResumeTimers = new Set<ReturnType<typeof setTimeout>>();
const pendingRequesterSettleWakeRearms = new Set<string>();
const scheduledRequesterSettleWakeRuns = new Set<string>();
const scheduledRequesterSettleWakeTimers = new Map<string, ReturnType<typeof setTimeout>>();
const terminalCompletionLocks = new Map<string, Promise<void>>();
@@ -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,
};
@@ -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);
});
});
@@ -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<string, SubagentRunRecord>;
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<string, SubagentRunRecord>;
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;
}
@@ -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,
@@ -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" }),
);
});
});
});
@@ -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",
+64 -1
View File
@@ -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<string, Map<string, SubagentRunRecord[]>>();
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<PropertyKey, unknown>)[SUBAGENT_REGISTRY_TEST_HANDLE] = {
+13 -1
View File
@@ -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;
+2
View File
@@ -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,
+3
View File
@@ -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,
@@ -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", {});
+2
View File
@@ -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> | void;
onYield?: (message: string) => Promise<void> | 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 });