mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
2632969aae
commit
bf2da1ce1a
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user