mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): fold subagent lifecycle into one controller (#121972)
* refactor(agents): fold lifecycle factories into SubagentLifecycleController * refactor(agents): make composition root explicit * fix(agents): atomic terminal-discard for dismissed deliveries * fix(agents): preserve lifecycle build boundaries * fix(agents): satisfy lifecycle static gates * refactor(agents): move lifecycle context types out of the controller module to break the architecture cycle
This commit is contained in:
committed by
GitHub
parent
e5be751e4f
commit
f104ce8b09
@@ -636,6 +636,7 @@ async function runSweepSample(childCount: number): Promise<Sample> {
|
||||
resumeRequesterSettleWake: () => {},
|
||||
startSubagentAnnounceCleanupFlow: () => true,
|
||||
completeCleanupBookkeeping: () => {},
|
||||
discardTerminalDelivery: () => {},
|
||||
shouldEmitEndedHookForRun: () => false,
|
||||
emitSubagentEndedHookForRun: async () => {},
|
||||
callGateway: (async <T>() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
|
||||
@@ -18,6 +19,7 @@ import type { TaskRecord } from "../../../tasks/task-registry.types.js";
|
||||
import { resetTaskRegistryForTests } from "../../../tasks/task-runtime.test-helpers.js";
|
||||
import { withEnvAsync } from "../../../test-utils/env.js";
|
||||
import { createSubagentRunRecord } from "../../subagent-test-fixtures.test-helpers.js";
|
||||
import { SubagentLifecycleController } from "../registry/subagent-registry-lifecycle.js";
|
||||
import { subagentRuns } from "../registry/subagent-registry-memory.js";
|
||||
import { loadSubagentRegistryFromSqlite } from "../registry/subagent-registry.store.sqlite.js";
|
||||
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
|
||||
@@ -33,6 +35,8 @@ import {
|
||||
|
||||
const resumeSubagentRun = vi.hoisted(() => vi.fn());
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const discardTerminalDelivery = (entry: SubagentRunRecord, completedAt: number) =>
|
||||
SubagentLifecycleController.discardTerminalDelivery(entry, completedAt);
|
||||
|
||||
vi.mock("../registry/subagent-registry.js", () => ({ resumeSubagentRun }));
|
||||
|
||||
@@ -373,6 +377,12 @@ describe("atomic subagent completion admission store", () => {
|
||||
});
|
||||
|
||||
const cappedSubagent = structuredClone(subagentRuns.get(input.subagent.runId)!);
|
||||
const attachmentsRootDir = path.join(tempDir, "attachments");
|
||||
const attachmentsDir = path.join(attachmentsRootDir, "completion-run");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(attachmentsDir, "result.txt"), "retained result");
|
||||
cappedSubagent.attachmentsRootDir = attachmentsRootDir;
|
||||
cappedSubagent.attachmentsDir = attachmentsDir;
|
||||
Object.assign(cappedSubagent.delivery!, {
|
||||
status: "suspended",
|
||||
generation: 10,
|
||||
@@ -400,8 +410,43 @@ describe("atomic subagent completion admission store", () => {
|
||||
reason: "completion delivery redrive limit reached",
|
||||
});
|
||||
expect(resumeSubagentRun).not.toHaveBeenCalled();
|
||||
const discardInsideTransaction = vi.fn((entry: SubagentRunRecord, completedAt: number) => {
|
||||
expect(database.db.isTransaction).toBe(true);
|
||||
discardTerminalDelivery(entry, completedAt);
|
||||
});
|
||||
database.db.exec(`
|
||||
CREATE TRIGGER fail_dismissed_task_persist
|
||||
BEFORE UPDATE ON task_runs
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'injected dismissal persistence failure');
|
||||
END;
|
||||
`);
|
||||
|
||||
const dismissed = dismissSubagentCompletionDelivery(input.task.taskId);
|
||||
await expect(
|
||||
dismissSubagentCompletionDelivery(input.task.taskId, {
|
||||
discardTerminalDelivery: discardInsideTransaction,
|
||||
databaseOptions: { database },
|
||||
}),
|
||||
).rejects.toThrow("injected dismissal persistence failure");
|
||||
expect(subagentRuns.get(input.subagent.runId)?.delivery?.status).toBe("suspended");
|
||||
expect(getTaskById(input.task.taskId)?.deliveryStatus).toBe("failed");
|
||||
const rolledBackSubagent = database.db
|
||||
.prepare("SELECT payload_json FROM subagent_runs WHERE run_id = ?")
|
||||
.get(input.subagent.runId) as { payload_json: string };
|
||||
expect(JSON.parse(rolledBackSubagent.payload_json).delivery.status).toBe("suspended");
|
||||
const rolledBackTask = database.db
|
||||
.prepare("SELECT delivery_status FROM task_runs WHERE task_id = ?")
|
||||
.get(input.task.taskId) as { delivery_status: string };
|
||||
expect(rolledBackTask.delivery_status).toBe("failed");
|
||||
await expect(fs.stat(attachmentsDir)).resolves.toBeDefined();
|
||||
database.db.exec("DROP TRIGGER fail_dismissed_task_persist");
|
||||
|
||||
const dismissed = await dismissSubagentCompletionDelivery(input.task.taskId, {
|
||||
discardTerminalDelivery: discardInsideTransaction,
|
||||
databaseOptions: { database },
|
||||
});
|
||||
expect(discardInsideTransaction).toHaveBeenCalledTimes(2);
|
||||
await expect(fs.stat(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(dismissed).toMatchObject({
|
||||
ok: true,
|
||||
task: {
|
||||
@@ -413,7 +458,11 @@ describe("atomic subagent completion admission store", () => {
|
||||
expect(subagentRuns.get(input.subagent.runId)?.delivery).toMatchObject({
|
||||
status: "discarded",
|
||||
disposition: "intentional_non_delivery",
|
||||
payload: undefined,
|
||||
suspendedAt: undefined,
|
||||
suspendedReason: undefined,
|
||||
});
|
||||
expect(subagentRuns.get(input.subagent.runId)?.cleanupCompletedAt).toBeTypeOf("number");
|
||||
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
subagentRuns.clear();
|
||||
@@ -426,6 +475,15 @@ describe("atomic subagent completion admission store", () => {
|
||||
terminalOutcome: "blocked",
|
||||
progressSummary: "canonical result",
|
||||
});
|
||||
expect(subagentRuns.get(input.subagent.runId)).toMatchObject({
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: expect.any(Number),
|
||||
delivery: {
|
||||
status: "discarded",
|
||||
disposition: "intentional_non_delivery",
|
||||
},
|
||||
});
|
||||
expect(subagentRuns.get(input.subagent.runId)?.delivery).not.toHaveProperty("payload");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,12 +122,13 @@ export function settleSubagentCompletionDelivery(params: {
|
||||
subagent: SubagentRunRecord;
|
||||
task: TaskRecord;
|
||||
databaseOptions?: OpenClawStateDatabaseOptions;
|
||||
mutateSubagent?: (entry: SubagentRunRecord) => unknown;
|
||||
}): void {
|
||||
const boundSubagent = bindSubagentRunRecord(params.subagent);
|
||||
const boundTask = bindTaskRecord(params.task);
|
||||
runOpenClawStateWriteTransaction(
|
||||
(database) => {
|
||||
upsertSubagentRunRowInDatabase(database, boundSubagent);
|
||||
invokeSynchronousHook(() => params.mutateSubagent?.(params.subagent));
|
||||
upsertSubagentRunRowInDatabase(database, bindSubagentRunRecord(params.subagent));
|
||||
upsertTaskRunRowInDatabase(database, boundTask);
|
||||
},
|
||||
params.databaseOptions,
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
} from "../../../tasks/runtime-internal.js";
|
||||
import type { TaskRecord } from "../../../tasks/task-registry.types.js";
|
||||
import { ensureDeliveryState } from "../registry/subagent-delivery-state.js";
|
||||
import { ANNOUNCE_COMPLETION_HARD_EXPIRY_MS } from "../registry/subagent-registry-helpers.js";
|
||||
import {
|
||||
ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
|
||||
safeRemoveAttachmentsDir,
|
||||
} from "../registry/subagent-registry-helpers.js";
|
||||
import type { SubagentLifecycleController } from "../registry/subagent-registry-lifecycle.js";
|
||||
import { subagentRuns } from "../registry/subagent-registry-memory.js";
|
||||
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
|
||||
import {
|
||||
@@ -35,6 +39,12 @@ const SUSPENDED_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
||||
const MAX_DELIVERY_GENERATION = 10;
|
||||
const CANONICAL_RESULT_PROMPT =
|
||||
"A completed subagent task is ready for parent review. The canonical result follows.";
|
||||
type CompletionDeliveryRecoveryResult = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
task?: TaskRecord;
|
||||
duplicateRisk?: boolean;
|
||||
};
|
||||
|
||||
function resolveTask(entry: SubagentRunRecord): TaskRecord | undefined {
|
||||
return findTaskByRunId(entry.taskRunId ?? entry.runId);
|
||||
@@ -236,12 +246,7 @@ export async function settleCorrelatedSubagentDelivery(
|
||||
export async function retrySubagentCompletionDelivery(
|
||||
taskId: string,
|
||||
databaseOptions?: OpenClawStateDatabaseOptions,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
task?: TaskRecord;
|
||||
duplicateRisk?: boolean;
|
||||
}> {
|
||||
): Promise<CompletionDeliveryRecoveryResult> {
|
||||
const task = getTaskById(taskId);
|
||||
const current = task ? findSubagentForTask(task) : undefined;
|
||||
if (!task || !current || current.expectsCompletionMessage !== true) {
|
||||
@@ -309,11 +314,13 @@ export async function retrySubagentCompletionDelivery(
|
||||
return { ok: true, task: getTaskById(taskId), duplicateRisk: true };
|
||||
}
|
||||
|
||||
export function dismissSubagentCompletionDelivery(taskId: string): {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
task?: TaskRecord;
|
||||
} {
|
||||
export async function dismissSubagentCompletionDelivery(
|
||||
taskId: string,
|
||||
options: {
|
||||
discardTerminalDelivery: typeof SubagentLifecycleController.discardTerminalDelivery;
|
||||
databaseOptions?: OpenClawStateDatabaseOptions;
|
||||
},
|
||||
): Promise<CompletionDeliveryRecoveryResult> {
|
||||
const task = getTaskById(taskId);
|
||||
const current = task ? findSubagentForTask(task) : undefined;
|
||||
if (!task || !current || current.delivery?.status !== "suspended") {
|
||||
@@ -321,12 +328,6 @@ export function dismissSubagentCompletionDelivery(taskId: string): {
|
||||
}
|
||||
const now = Date.now();
|
||||
const subagent = structuredClone(current);
|
||||
const delivery = ensureDeliveryState(subagent);
|
||||
delivery.status = "discarded";
|
||||
delivery.disposition = "intentional_non_delivery";
|
||||
delivery.dismissedAt = now;
|
||||
delivery.queueId = undefined;
|
||||
delivery.nextAttemptAt = undefined;
|
||||
const projectedTask: TaskRecord = {
|
||||
...task,
|
||||
deliveryStatus: "dismissed",
|
||||
@@ -336,7 +337,15 @@ export function dismissSubagentCompletionDelivery(taskId: string): {
|
||||
cleanupAfter: Math.max(task.cleanupAfter ?? 0, now + SUSPENDED_RETENTION_MS),
|
||||
lastEventAt: now,
|
||||
};
|
||||
settleSubagentCompletionDelivery({ subagent, task: projectedTask });
|
||||
settleSubagentCompletionDelivery({
|
||||
subagent,
|
||||
task: projectedTask,
|
||||
databaseOptions: options.databaseOptions,
|
||||
mutateSubagent: (entry) => options.discardTerminalDelivery(entry, now),
|
||||
});
|
||||
publishCommittedRecords(subagent, projectedTask);
|
||||
if (subagent.cleanup === "delete" || !subagent.retainAttachmentsOnKeep) {
|
||||
await safeRemoveAttachmentsDir(subagent);
|
||||
}
|
||||
return { ok: true, task: getTaskById(taskId) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { defaultRuntime } from "../../../runtime.js";
|
||||
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
|
||||
import {
|
||||
ensureCompletionState,
|
||||
ensureDeliveryState,
|
||||
getDeliveryLastError,
|
||||
isDeliverySuspended,
|
||||
} from "./subagent-delivery-state.js";
|
||||
import { SUBAGENT_ENDED_REASON_COMPLETE } from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import {
|
||||
resolveCleanupCompletionReason,
|
||||
resolveDeferredCleanupDecision,
|
||||
} from "./subagent-registry-cleanup.js";
|
||||
import {
|
||||
ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
|
||||
ANNOUNCE_EXPIRY_MS,
|
||||
logAnnounceGiveUp,
|
||||
MIN_ANNOUNCE_RETRY_DELAY_MS,
|
||||
resolveAnnounceRetryDelayMs,
|
||||
safeRemoveAttachmentsDir,
|
||||
} from "./subagent-registry-helpers.js";
|
||||
import {
|
||||
beginSubagentCleanup,
|
||||
retireSupersededCleanupIfNeeded,
|
||||
retireSupersededCleanupInBackground,
|
||||
runDetachedCleanupAttempt,
|
||||
scheduleResumeSubagentRun,
|
||||
suspendPendingFinalDelivery,
|
||||
} from "./subagent-registry-lifecycle-cleanup.js";
|
||||
import type { SubagentLifecycleAnnounceCleanupContext } from "./subagent-registry-lifecycle-context.js";
|
||||
import {
|
||||
buildSafeLifecycleErrorMeta,
|
||||
clearSubagentPendingDelivery,
|
||||
emitCompletionEndedHookIfNeeded,
|
||||
formatAnnounceDeliveryError,
|
||||
hasPriorRequesterDeliveryMirror,
|
||||
loadPendingFinalDeliveryPayload,
|
||||
markPendingFinalDelivery,
|
||||
maskLifecycleIdentifier,
|
||||
recordAnnounceDeliveryResult,
|
||||
safeMarkRequiredCompletionDeliveryBlocked,
|
||||
safeSetSubagentTaskDeliveryStatus,
|
||||
} from "./subagent-registry-lifecycle-delivery.js";
|
||||
import { loadSubagentSessionEntry } from "./subagent-session-reconciliation.js";
|
||||
|
||||
type RunSubagentAnnounceFlow =
|
||||
(typeof import("../announce/subagent-announce.js"))["runSubagentAnnounceFlow"];
|
||||
type SubagentAnnounceFlowOutcome = Awaited<ReturnType<RunSubagentAnnounceFlow>>;
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js";
|
||||
|
||||
const shouldSuspendPendingFinalDelivery = (entry: SubagentRunRecord) =>
|
||||
entry.expectsCompletionMessage === true &&
|
||||
entry.endedReason === SUBAGENT_ENDED_REASON_COMPLETE &&
|
||||
entry.execution.outcome?.status === "ok";
|
||||
|
||||
export const finalizeResumedAnnounceGiveUp = async (
|
||||
context: SubagentLifecycleAnnounceCleanupContext,
|
||||
giveUpParams: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
reason: "expiry" | "permanent_failure";
|
||||
cleanup?: "delete" | "keep";
|
||||
cleanupGeneration?: number;
|
||||
retryCount?: number;
|
||||
completedAt?: number;
|
||||
},
|
||||
) => {
|
||||
const params = context.options;
|
||||
const { runId, entry, reason, cleanup, cleanupGeneration, retryCount, completedAt } =
|
||||
giveUpParams;
|
||||
if (shouldSuspendPendingFinalDelivery(entry)) {
|
||||
suspendPendingFinalDelivery(context, {
|
||||
runId,
|
||||
entry,
|
||||
reason,
|
||||
error: getDeliveryLastError(entry),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const deliveryError = getDeliveryLastError(entry) ?? reason;
|
||||
clearSubagentPendingDelivery(entry);
|
||||
const failedDelivery = ensureDeliveryState(entry);
|
||||
failedDelivery.status = "failed";
|
||||
failedDelivery.lastError = deliveryError;
|
||||
if (retryCount != null) {
|
||||
failedDelivery.attemptCount = retryCount;
|
||||
failedDelivery.lastAttemptAt = completedAt ?? Date.now();
|
||||
}
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError,
|
||||
});
|
||||
safeMarkRequiredCompletionDeliveryBlocked(params, {
|
||||
entry,
|
||||
reason: deliveryError,
|
||||
});
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
const completion = ensureCompletionState(entry);
|
||||
completion.fallbackResultText = undefined;
|
||||
completion.fallbackCapturedAt = undefined;
|
||||
if ((cleanup ?? entry.cleanup) === "delete" || !entry.retainAttachmentsOnKeep) {
|
||||
await safeRemoveAttachmentsDir(entry);
|
||||
}
|
||||
if (
|
||||
cleanupGeneration !== undefined &&
|
||||
!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)
|
||||
) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
const completionReason = resolveCleanupCompletionReason(entry);
|
||||
logAnnounceGiveUp(entry, reason);
|
||||
// Retry-limit / expiry give-up should not leave cleanup stuck behind the
|
||||
// best-effort ended hook. Mark the run cleaned first, then fire the hook.
|
||||
context.completeCleanupBookkeeping({
|
||||
runId,
|
||||
entry,
|
||||
cleanup: cleanup ?? entry.cleanup,
|
||||
completedAt: completedAt ?? Date.now(),
|
||||
});
|
||||
if (!shouldSuppressSubagentRecoverySessionEffects(entry)) {
|
||||
await emitCompletionEndedHookIfNeeded(
|
||||
params,
|
||||
entry,
|
||||
completionReason,
|
||||
() =>
|
||||
context.isEndedHookOwnerCurrent(runId, entry) &&
|
||||
!shouldSuppressSubagentRecoverySessionEffects(entry),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const retryDeferredCompletedAnnounces = (
|
||||
context: SubagentLifecycleAnnounceCleanupContext,
|
||||
excludeRunId?: string,
|
||||
) => {
|
||||
const params = context.options;
|
||||
const now = Date.now();
|
||||
for (const [runId, entry] of params.runs.entries()) {
|
||||
if (excludeRunId && runId === excludeRunId) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.execution.endedAt !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (entry.cleanupCompletedAt || entry.cleanupHandled) {
|
||||
continue;
|
||||
}
|
||||
if (isDeliverySuspended(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (params.suppressAnnounceForSteerRestart(entry)) {
|
||||
continue;
|
||||
}
|
||||
const endedAgo = now - (entry.execution.endedAt ?? now);
|
||||
if (entry.expectsCompletionMessage !== true && endedAgo > ANNOUNCE_EXPIRY_MS) {
|
||||
const cleanupGeneration = beginSubagentCleanup(context, runId);
|
||||
if (cleanupGeneration === undefined) {
|
||||
continue;
|
||||
}
|
||||
runDetachedCleanupAttempt(context, {
|
||||
runId,
|
||||
entry,
|
||||
cleanupGeneration,
|
||||
run: async () => {
|
||||
await finalizeResumedAnnounceGiveUp(context, {
|
||||
runId,
|
||||
entry,
|
||||
reason: "expiry",
|
||||
});
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
params.resumedRuns.delete(runId);
|
||||
params.resumeSubagentRun(runId);
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeSubagentCleanup = async (
|
||||
context: SubagentLifecycleAnnounceCleanupContext,
|
||||
runId: string,
|
||||
cleanup: "delete" | "keep",
|
||||
announceOutcome: SubagentAnnounceFlowOutcome,
|
||||
cleanupGeneration: number,
|
||||
options?: {
|
||||
skipAnnounce?: boolean;
|
||||
skipDeliveryStatus?: boolean;
|
||||
skipRequesterDelivery?: boolean;
|
||||
},
|
||||
) => {
|
||||
const params = context.options;
|
||||
const entry = params.runs.get(runId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
if (entry.expectsCompletionMessage === false || options?.skipRequesterDelivery) {
|
||||
clearSubagentPendingDelivery(entry);
|
||||
if (options?.skipRequesterDelivery) {
|
||||
ensureDeliveryState(entry).status = "not_required";
|
||||
entry.suppressCompletionDelivery = undefined;
|
||||
}
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
const shouldDeleteAttachments = cleanup === "delete" || !entry.retainAttachmentsOnKeep;
|
||||
if (shouldDeleteAttachments) {
|
||||
await safeRemoveAttachmentsDir(entry);
|
||||
}
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
context.completeCleanupBookkeeping({
|
||||
runId,
|
||||
entry,
|
||||
cleanup,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
if (!shouldSuppressSubagentRecoverySessionEffects(entry)) {
|
||||
await emitCompletionEndedHookIfNeeded(
|
||||
params,
|
||||
entry,
|
||||
resolveCleanupCompletionReason(entry),
|
||||
() =>
|
||||
context.isEndedHookOwnerCurrent(runId, entry) &&
|
||||
!shouldSuppressSubagentRecoverySessionEffects(entry),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (announceOutcome === "delivered" || announceOutcome === "intentional_non_delivery") {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
const shouldCreditDelivery =
|
||||
announceOutcome === "delivered" &&
|
||||
(!options?.skipAnnounce ||
|
||||
delivery.status === "delivered" ||
|
||||
typeof delivery.announcedAt === "number");
|
||||
if (shouldCreditDelivery) {
|
||||
const deliveredAt = delivery.deliveredAt ?? delivery.announcedAt ?? Date.now();
|
||||
delivery.status = "delivered";
|
||||
delivery.deliveredAt = deliveredAt;
|
||||
delivery.announcedAt = delivery.announcedAt ?? deliveredAt;
|
||||
if (!options?.skipAnnounce) {
|
||||
delivery.announcedAt = deliveredAt;
|
||||
params.persist(runId);
|
||||
}
|
||||
}
|
||||
if (announceOutcome === "delivered") {
|
||||
clearSubagentPendingDelivery(entry);
|
||||
} else {
|
||||
// The requester-settle batch owns the real delivery now. Retire the
|
||||
// per-child retry obligation without converting the handoff into success.
|
||||
delivery.status = "pending";
|
||||
delivery.disposition = "intentional_non_delivery";
|
||||
delivery.payload = undefined;
|
||||
delivery.createdAt = undefined;
|
||||
delivery.attemptCount = undefined;
|
||||
delivery.nextAttemptAt = undefined;
|
||||
}
|
||||
const finalDelivery = ensureDeliveryState(entry);
|
||||
if (shouldCreditDelivery) {
|
||||
finalDelivery.status = "delivered";
|
||||
finalDelivery.suspendedAt = undefined;
|
||||
finalDelivery.suspendedReason = undefined;
|
||||
}
|
||||
if (shouldCreditDelivery && !options?.skipDeliveryStatus) {
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "delivered",
|
||||
});
|
||||
} else if (announceOutcome === "intentional_non_delivery" && !options?.skipDeliveryStatus) {
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "pending",
|
||||
});
|
||||
}
|
||||
if (announceOutcome === "delivered") {
|
||||
finalDelivery.lastError = undefined;
|
||||
finalDelivery.lastDropReason = undefined;
|
||||
}
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
const completion = ensureCompletionState(entry);
|
||||
completion.fallbackResultText = undefined;
|
||||
completion.fallbackCapturedAt = undefined;
|
||||
const completionReason = resolveCleanupCompletionReason(entry);
|
||||
const shouldDeleteAttachments = cleanup === "delete" || !entry.retainAttachmentsOnKeep;
|
||||
if (shouldDeleteAttachments) {
|
||||
await safeRemoveAttachmentsDir(entry);
|
||||
}
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
context.completeCleanupBookkeeping({
|
||||
runId,
|
||||
entry,
|
||||
cleanup,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
// Hook loading is best-effort; durable delivery and cleanup must already
|
||||
// be terminal before plugin code can fail or stall.
|
||||
if (!shouldSuppressSubagentRecoverySessionEffects(entry)) {
|
||||
await emitCompletionEndedHookIfNeeded(
|
||||
params,
|
||||
entry,
|
||||
completionReason,
|
||||
() =>
|
||||
context.isEndedHookOwnerCurrent(runId, entry) &&
|
||||
!shouldSuppressSubagentRecoverySessionEffects(entry),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (announceOutcome === "session_queued") {
|
||||
// The correlated queue owns transport now. Settlement, not admission,
|
||||
// decides delivered versus blocked and re-enters cleanup afterward.
|
||||
entry.cleanupHandled = false;
|
||||
params.resumedRuns.delete(runId);
|
||||
params.persist(runId);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const deferredDecision = resolveDeferredCleanupDecision({
|
||||
entry,
|
||||
now,
|
||||
activeDescendantRuns: Math.max(0, params.countPendingDescendantRuns(entry.childSessionKey)),
|
||||
announceExpiryMs: ANNOUNCE_EXPIRY_MS,
|
||||
announceCompletionHardExpiryMs: ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
|
||||
deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS,
|
||||
resolveAnnounceRetryDelayMs,
|
||||
});
|
||||
|
||||
if (deferredDecision.kind === "defer-descendants") {
|
||||
ensureDeliveryState(entry).lastAttemptAt = now;
|
||||
entry.wakeOnDescendantSettle = true;
|
||||
entry.cleanupHandled = false;
|
||||
params.resumedRuns.delete(runId);
|
||||
params.persist(runId);
|
||||
scheduleResumeSubagentRun(context, runId, entry, deferredDecision.delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deferredDecision.kind === "give-up") {
|
||||
await finalizeResumedAnnounceGiveUp(context, {
|
||||
runId,
|
||||
entry,
|
||||
reason: deferredDecision.reason,
|
||||
cleanup,
|
||||
cleanupGeneration,
|
||||
retryCount: deferredDecision.retryCount,
|
||||
completedAt: now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
markPendingFinalDelivery({
|
||||
entry,
|
||||
error: "announce deferred or direct delivery failed",
|
||||
});
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
delivery.windowStartedAt ??= entry.execution.endedAt ?? now;
|
||||
delivery.deadlineAt ??= delivery.windowStartedAt + ANNOUNCE_COMPLETION_HARD_EXPIRY_MS;
|
||||
delivery.nextAttemptAt = now + (deferredDecision.resumeDelayMs ?? 0);
|
||||
entry.cleanupHandled = false;
|
||||
params.resumedRuns.delete(runId);
|
||||
params.persist(runId);
|
||||
if (deferredDecision.resumeDelayMs == null) {
|
||||
return;
|
||||
}
|
||||
scheduleResumeSubagentRun(context, runId, entry, deferredDecision.resumeDelayMs);
|
||||
};
|
||||
|
||||
export const startSubagentAnnounceCleanupFlow = (
|
||||
context: SubagentLifecycleAnnounceCleanupContext,
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
): boolean => {
|
||||
const params = context.options;
|
||||
if (entry.killReconciliation) {
|
||||
// Restores and unrelated cleanup retries must not publish a provisional
|
||||
// kill. The sweeper re-enters here after durable reconciliation.
|
||||
return false;
|
||||
}
|
||||
const cleanup = entry.cleanup;
|
||||
let suppressSessionEffects = shouldSuppressSubagentRecoverySessionEffects(entry);
|
||||
if (typeof entry.delivery?.announcedAt === "number" || entry.delivery?.status === "delivered") {
|
||||
const cleanupGeneration = beginSubagentCleanup(context, runId);
|
||||
if (cleanupGeneration === undefined) {
|
||||
return false;
|
||||
}
|
||||
runDetachedCleanupAttempt(context, {
|
||||
runId,
|
||||
entry,
|
||||
cleanupGeneration,
|
||||
run: async () => {
|
||||
await finalizeSubagentCleanup(context, runId, cleanup, "delivered", cleanupGeneration, {
|
||||
skipAnnounce: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const cleanupGeneration = beginSubagentCleanup(context, runId);
|
||||
if (cleanupGeneration === undefined) {
|
||||
return false;
|
||||
}
|
||||
const cleanupSessionEntry = suppressSessionEffects
|
||||
? undefined
|
||||
: loadSubagentSessionEntry({ childSessionKey: entry.childSessionKey });
|
||||
const cleanupSessionIdentity =
|
||||
cleanupSessionEntry?.sessionId && cleanupSessionEntry.lifecycleRevision
|
||||
? {
|
||||
sessionId: cleanupSessionEntry.sessionId,
|
||||
lifecycleRevision: cleanupSessionEntry.lifecycleRevision,
|
||||
}
|
||||
: undefined;
|
||||
const suppressChildSessionEffects = () => {
|
||||
suppressSessionEffects = true;
|
||||
if (entry.execution.suppressSessionEffects !== true) {
|
||||
const previousExecution = entry.execution;
|
||||
entry.execution = {
|
||||
...entry.execution,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
try {
|
||||
params.persistOrThrow(runId);
|
||||
} catch (error) {
|
||||
entry.execution = previousExecution;
|
||||
suppressSessionEffects = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const childSessionEffectsAllowed = () => {
|
||||
if (!suppressSessionEffects && shouldSuppressSubagentRecoverySessionEffects(entry)) {
|
||||
suppressChildSessionEffects();
|
||||
}
|
||||
return (
|
||||
!suppressSessionEffects && context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)
|
||||
);
|
||||
};
|
||||
const skipRequesterDelivery = entry.suppressCompletionDelivery === true;
|
||||
if (entry.expectsCompletionMessage === false || skipRequesterDelivery) {
|
||||
runDetachedCleanupAttempt(context, {
|
||||
runId,
|
||||
entry,
|
||||
cleanupGeneration,
|
||||
run: async () => {
|
||||
// This driver is detached. Yield once so synchronous successor
|
||||
// registration can invalidate it before sessions.delete is submitted.
|
||||
await Promise.resolve();
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
if (cleanup === "delete" && childSessionEffectsAllowed()) {
|
||||
if (!cleanupSessionIdentity) {
|
||||
// Without both lifecycle identities, key-only deletion could remove
|
||||
// a successor that reused this child session after cleanup yielded.
|
||||
suppressChildSessionEffects();
|
||||
} else {
|
||||
// This durable boundary prevents a late yield from reviving a run
|
||||
// after deletion may already have reached the gateway.
|
||||
entry.deleteCleanupDispatchedAt ??= Date.now();
|
||||
params.persist(runId);
|
||||
const sessionCleanup = await deleteSubagentSessionForCleanup({
|
||||
callGateway: params.callGateway,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
spawnMode: entry.spawnMode,
|
||||
expectedSessionId: cleanupSessionIdentity.sessionId,
|
||||
expectedLifecycleRevision: cleanupSessionIdentity.lifecycleRevision,
|
||||
onError: (error) =>
|
||||
params.warn("sessions.delete failed during subagent cleanup", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskLifecycleIdentifier(runId, "run"),
|
||||
childSessionKey: maskLifecycleIdentifier(entry.childSessionKey, "session"),
|
||||
}),
|
||||
});
|
||||
if (sessionCleanup === "failed") {
|
||||
throw new Error("subagent session cleanup did not complete");
|
||||
}
|
||||
if (sessionCleanup === "changed") {
|
||||
suppressChildSessionEffects();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
await finalizeSubagentCleanup(context, runId, cleanup, "delivered", cleanupGeneration, {
|
||||
skipAnnounce: true,
|
||||
skipDeliveryStatus: true,
|
||||
skipRequesterDelivery,
|
||||
});
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const pendingPayload = loadPendingFinalDeliveryPayload(entry);
|
||||
const requesterOrigin = normalizeDeliveryContext(pendingPayload.requesterOrigin);
|
||||
let latestDeliveryError = getDeliveryLastError(entry);
|
||||
const finalizeAnnounceCleanup = async (announceOutcome: SubagentAnnounceFlowOutcome) => {
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
const shouldCreditPriorDelivery =
|
||||
announceOutcome !== "delivered" && (await hasPriorRequesterDeliveryMirror(params, entry));
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
await retireSupersededCleanupIfNeeded(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
if (shouldCreditPriorDelivery) {
|
||||
latestDeliveryError = undefined;
|
||||
}
|
||||
if (announceOutcome !== "delivered" && latestDeliveryError) {
|
||||
ensureDeliveryState(entry).lastError = latestDeliveryError;
|
||||
}
|
||||
await finalizeSubagentCleanup(
|
||||
context,
|
||||
runId,
|
||||
cleanup,
|
||||
shouldCreditPriorDelivery ? "delivered" : announceOutcome,
|
||||
cleanupGeneration,
|
||||
);
|
||||
};
|
||||
|
||||
const announceParams: Parameters<RunSubagentAnnounceFlow>[0] = {
|
||||
childSessionKey: pendingPayload.childSessionKey,
|
||||
childRunId: pendingPayload.childRunId,
|
||||
requesterSessionKey: pendingPayload.requesterSessionKey,
|
||||
requesterOrigin,
|
||||
requesterDisplayKey: pendingPayload.requesterDisplayKey,
|
||||
task: pendingPayload.task,
|
||||
timeoutMs: params.subagentAnnounceTimeoutMs,
|
||||
cleanup: suppressSessionEffects ? "keep" : cleanup,
|
||||
roundOneReply: entry.completion?.resultText ?? undefined,
|
||||
terminalReply: pendingPayload.terminalReply,
|
||||
fallbackReply: entry.completion?.fallbackResultText ?? undefined,
|
||||
waitForCompletion: false,
|
||||
startedAt: pendingPayload.startedAt,
|
||||
endedAt: pendingPayload.endedAt,
|
||||
label: pendingPayload.label,
|
||||
outcome: pendingPayload.outcome,
|
||||
spawnMode: pendingPayload.spawnMode,
|
||||
expectsCompletionMessage: pendingPayload.expectsCompletionMessage,
|
||||
wakeOnDescendantSettle: pendingPayload.wakeOnDescendantSettle === true,
|
||||
suppressChildSessionEffects: suppressSessionEffects,
|
||||
isChildSessionEffectsAllowed: childSessionEffectsAllowed,
|
||||
isCompletionDeliveryAllowed: () =>
|
||||
context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration),
|
||||
isCompletionOwnedByRequesterYield: () =>
|
||||
entry.requesterTurnYielded === true ||
|
||||
entry.requesterSettleWake?.requesterYieldBatch === true,
|
||||
onBeforeDeleteChildSession:
|
||||
cleanup === "delete"
|
||||
? () => {
|
||||
if (!childSessionEffectsAllowed()) {
|
||||
return false;
|
||||
}
|
||||
// Announce owns delete submission; fence late yields at the
|
||||
// exact handoff instead of when cleanup merely starts.
|
||||
entry.deleteCleanupDispatchedAt ??= Date.now();
|
||||
params.persist(runId);
|
||||
return true;
|
||||
}
|
||||
: undefined,
|
||||
onDeliveryResult: (delivery) => {
|
||||
if (!context.isCleanupAttemptCurrent(runId, entry, cleanupGeneration)) {
|
||||
retireSupersededCleanupInBackground(context, runId, entry, cleanupGeneration);
|
||||
return;
|
||||
}
|
||||
recordAnnounceDeliveryResult(entry, delivery);
|
||||
if (delivery.delivered) {
|
||||
const deliveryState = ensureDeliveryState(entry);
|
||||
deliveryState.status = "delivered";
|
||||
deliveryState.announcedAt = deliveryState.deliveredAt ?? Date.now();
|
||||
deliveryState.lastError = undefined;
|
||||
deliveryState.suspendedAt = undefined;
|
||||
deliveryState.suspendedReason = undefined;
|
||||
// Identified platform delivery precedes best-effort transcript
|
||||
// mirroring; task ownership must become durable at that same edge.
|
||||
params.persist(runId);
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "delivered",
|
||||
});
|
||||
latestDeliveryError = undefined;
|
||||
return;
|
||||
}
|
||||
if (delivery.path === "none" && delivery.disposition !== "intentional_non_delivery") {
|
||||
ensureDeliveryState(entry).lastDropReason = "sink_unavailable";
|
||||
}
|
||||
latestDeliveryError = formatAnnounceDeliveryError(delivery);
|
||||
if (ensureDeliveryState(entry).lastError !== latestDeliveryError) {
|
||||
ensureDeliveryState(entry).lastError = latestDeliveryError;
|
||||
params.persist(runId);
|
||||
}
|
||||
},
|
||||
};
|
||||
runDetachedCleanupAttempt(context, {
|
||||
runId,
|
||||
entry,
|
||||
cleanupGeneration,
|
||||
run: async () => {
|
||||
let announceOutcome: SubagentAnnounceFlowOutcome = "retryable";
|
||||
try {
|
||||
announceOutcome = await params.runSubagentAnnounceFlow(announceParams);
|
||||
} catch (error) {
|
||||
defaultRuntime.log(
|
||||
`[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
await finalizeAnnounceCleanup(announceOutcome);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
import { runWithGatewayIndependentRootWorkAdmission } from "../../../process/gateway-work-admission.js";
|
||||
import { defaultRuntime } from "../../../runtime.js";
|
||||
import { retireSessionMcpRuntimeForSessionKey } from "../../agent-bundle-mcp-tools.js";
|
||||
import { removeInternalSessionEffectsSession } from "../../internal-session-effects.js";
|
||||
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import type { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import type { SubagentRegistryLifecycleParams } from "./subagent-registry-lifecycle-contracts.js";
|
||||
import type { createSubagentRegistryLifecycleRequesterWake } from "./subagent-registry-lifecycle-requester-wake.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
export function createSubagentRegistryLifecycleBookkeeping(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
common: ReturnType<typeof createSubagentRegistryLifecycleCommon>,
|
||||
requesterWake: ReturnType<typeof createSubagentRegistryLifecycleRequesterWake>,
|
||||
retryDeferredCompletedAnnounces: (excludeRunId?: string) => void,
|
||||
) {
|
||||
const { buildSafeLifecycleErrorMeta, maskRunId, maskSessionKey, newerGenerationOwnsSession } =
|
||||
common;
|
||||
const { persistRequesterSettleWakePending, scheduleRequesterSettleWake } = requesterWake;
|
||||
|
||||
const completeCleanupBookkeeping = (cleanupParams: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
cleanup: "delete" | "keep";
|
||||
completedAt: number;
|
||||
preserveTranscript?: boolean;
|
||||
provisionalKill?: boolean;
|
||||
// Set by the suspended-delivery discard path: the settle wake already ran
|
||||
// when the delivery was suspended, so a discard hours later must not
|
||||
// re-evaluate the requester drain.
|
||||
skipRequesterSettleWake?: boolean;
|
||||
}) => {
|
||||
const suppressSessionEffects = shouldSuppressSubagentRecoverySessionEffects(
|
||||
cleanupParams.entry,
|
||||
);
|
||||
const runCleanupTail = (label: string, run: () => Promise<unknown>) => {
|
||||
// These best-effort tails can outlive the durable registry transition,
|
||||
// but they still mutate session-owned resources and must block snapshots.
|
||||
void runWithGatewayIndependentRootWorkAdmission(run).catch((error: unknown) => {
|
||||
defaultRuntime.log(
|
||||
`[warn] subagent ${label} failed (${cleanupParams.runId}): ${String(error)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
const scheduleCleanupTails = (options: {
|
||||
allowRetiredRow: boolean;
|
||||
isDeleteCleanup: boolean;
|
||||
}) => {
|
||||
// Retained bookkeeping requires the exact row. Immediate retirement
|
||||
// removes it first, so absence remains ownership only while no newer
|
||||
// child generation exists; any replacement blocks the stale cleanup.
|
||||
const postBookkeepingEffectsAllowed = () => {
|
||||
const current = params.runs.get(cleanupParams.runId);
|
||||
const rowOwnershipMatches =
|
||||
current === cleanupParams.entry || (options.allowRetiredRow && current === undefined);
|
||||
return (
|
||||
rowOwnershipMatches &&
|
||||
!newerGenerationOwnsSession(cleanupParams.entry) &&
|
||||
!shouldSuppressSubagentRecoverySessionEffects(cleanupParams.entry)
|
||||
);
|
||||
};
|
||||
if (postBookkeepingEffectsAllowed() && !cleanupParams.preserveTranscript) {
|
||||
runCleanupTail("session cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await removeInternalSessionEffectsSession(cleanupParams.entry.execution.transcriptTarget);
|
||||
});
|
||||
}
|
||||
if (postBookkeepingEffectsAllowed() && cleanupParams.entry.spawnMode !== "session") {
|
||||
runCleanupTail("bundle MCP cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await retireSessionMcpRuntimeForSessionKey({
|
||||
sessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: "subagent-run-cleanup",
|
||||
preserveActiveLeases: true,
|
||||
onError: (error, sessionId) => {
|
||||
params.warn("failed to retire subagent bundle MCP runtime", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
sessionId,
|
||||
runId: maskRunId(cleanupParams.runId),
|
||||
childSessionKey: maskSessionKey(cleanupParams.entry.childSessionKey),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
if (
|
||||
!cleanupParams.provisionalKill &&
|
||||
postBookkeepingEffectsAllowed() &&
|
||||
(options.isDeleteCleanup || !cleanupParams.entry.collect)
|
||||
) {
|
||||
runCleanupTail("context-engine cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await params.notifyContextEngineSubagentEnded(
|
||||
{
|
||||
childSessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: options.isDeleteCleanup ? "deleted" : "completed",
|
||||
agentDir: cleanupParams.entry.agentDir,
|
||||
workspaceDir: cleanupParams.entry.workspaceDir,
|
||||
},
|
||||
{ isCurrent: postBookkeepingEffectsAllowed },
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
if (cleanupParams.provisionalKill) {
|
||||
// The provider result or bounded kill reconciliation owns terminal settle.
|
||||
// Its kill marker was committed by the caller before reaching this tail.
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup: false });
|
||||
return;
|
||||
}
|
||||
const isDeleteCleanup = cleanupParams.cleanup === "delete";
|
||||
if (isDeleteCleanup) {
|
||||
params.clearPendingLifecycleError(cleanupParams.runId);
|
||||
}
|
||||
if (cleanupParams.entry.collect) {
|
||||
// Delete-mode session cleanup already ran before this durable bookkeeping.
|
||||
// Preserve only the collector result tombstone for waits and group caps.
|
||||
const previousCleanupCompletedAt = cleanupParams.entry.cleanupCompletedAt;
|
||||
const previousExecution = cleanupParams.entry.execution;
|
||||
const previousRequesterSettleWake = cleanupParams.entry.requesterSettleWake;
|
||||
const previousTerminalOwner = cleanupParams.entry.terminalOwner;
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
cleanupParams.entry.requesterSettleWake = undefined;
|
||||
if (suppressSessionEffects) {
|
||||
cleanupParams.entry.execution = {
|
||||
...cleanupParams.entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
cleanupParams.entry.terminalOwner = undefined;
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
cleanupParams.entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
cleanupParams.entry.execution = previousExecution;
|
||||
cleanupParams.entry.requesterSettleWake = previousRequesterSettleWake;
|
||||
cleanupParams.entry.terminalOwner = previousTerminalOwner;
|
||||
throw error;
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
const retireAfterSettle =
|
||||
isDeleteCleanup ||
|
||||
(cleanupParams.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
|
||||
cleanupParams.entry.suppressAnnounceReason !== "killed");
|
||||
if (retireAfterSettle) {
|
||||
// Reconciled keep-mode kills retire the registry row, not the child session.
|
||||
if (!isDeleteCleanup) {
|
||||
params.clearPendingLifecycleError(cleanupParams.runId);
|
||||
}
|
||||
if (cleanupParams.skipRequesterSettleWake) {
|
||||
params.runs.delete(cleanupParams.runId);
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
params.runs.set(cleanupParams.runId, cleanupParams.entry);
|
||||
throw error;
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: true, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
persistRequesterSettleWakePending(cleanupParams.entry, {
|
||||
cleanupCompletedAt: cleanupParams.completedAt,
|
||||
retireAfterSettle: true,
|
||||
retireInterruptedRecovery: suppressSessionEffects,
|
||||
});
|
||||
// The settle wake may synchronously retire this durably marked row before
|
||||
// the detached tails start. Absence is still stale-safe because any
|
||||
// replacement row or newer child generation rejects the cleanup.
|
||||
scheduleCleanupTails({ allowRetiredRow: true, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
scheduleRequesterSettleWake(cleanupParams.runId, cleanupParams.entry);
|
||||
return;
|
||||
}
|
||||
if (!cleanupParams.skipRequesterSettleWake) {
|
||||
persistRequesterSettleWakePending(cleanupParams.entry, {
|
||||
cleanupCompletedAt: cleanupParams.completedAt,
|
||||
retireInterruptedRecovery: suppressSessionEffects,
|
||||
});
|
||||
} else {
|
||||
const previousCleanupCompletedAt = cleanupParams.entry.cleanupCompletedAt;
|
||||
const previousExecution = cleanupParams.entry.execution;
|
||||
const previousTerminalOwner = cleanupParams.entry.terminalOwner;
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
if (suppressSessionEffects) {
|
||||
cleanupParams.entry.execution = {
|
||||
...cleanupParams.entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
cleanupParams.entry.terminalOwner = undefined;
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
cleanupParams.entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
cleanupParams.entry.execution = previousExecution;
|
||||
cleanupParams.entry.terminalOwner = previousTerminalOwner;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
if (!cleanupParams.skipRequesterSettleWake) {
|
||||
scheduleRequesterSettleWake(cleanupParams.runId, cleanupParams.entry);
|
||||
}
|
||||
};
|
||||
return { completeCleanupBookkeeping };
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { runWithoutOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
|
||||
import {
|
||||
isGatewayRestartDraining,
|
||||
runWithGatewayIndependentRootWorkAdmission,
|
||||
} from "../../../process/gateway-work-admission.js";
|
||||
import { defaultRuntime } from "../../../runtime.js";
|
||||
import { retireSessionMcpRuntimeForSessionKey } from "../../agent-bundle-mcp-tools.js";
|
||||
import {
|
||||
ensureCompletionState,
|
||||
ensureDeliveryState,
|
||||
getDeliveryLastError,
|
||||
} from "./subagent-delivery-state.js";
|
||||
import {
|
||||
logAnnounceGiveUp,
|
||||
MIN_ANNOUNCE_RETRY_DELAY_MS,
|
||||
resolveAnnounceRetryDelayMs,
|
||||
} from "./subagent-registry-helpers.js";
|
||||
import type { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import type {
|
||||
SubagentRegistryLifecycleParams,
|
||||
SubagentRegistryLifecycleState,
|
||||
} from "./subagent-registry-lifecycle-contracts.js";
|
||||
import type { createSubagentRegistryLifecycleDelivery } from "./subagent-registry-lifecycle-delivery.js";
|
||||
import type { createSubagentRegistryLifecycleRequesterWake } from "./subagent-registry-lifecycle-requester-wake.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
const MAX_DETACHED_CLEANUP_RETRIES = 3;
|
||||
|
||||
export function createSubagentRegistryLifecycleCleanupBase(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
state: SubagentRegistryLifecycleState,
|
||||
common: ReturnType<typeof createSubagentRegistryLifecycleCommon>,
|
||||
deliveryHelpers: ReturnType<typeof createSubagentRegistryLifecycleDelivery>,
|
||||
requesterWake: ReturnType<typeof createSubagentRegistryLifecycleRequesterWake>,
|
||||
) {
|
||||
const { scheduledResumeTimers, cleanupGenerations, terminalGenerations } = state;
|
||||
// Exhaustion intentionally leaves the durable row unlocked; only a process
|
||||
// restart gets a fresh retry budget after this process stops scheduling it.
|
||||
const cleanupFailureCounts = new WeakMap<SubagentRunRecord, number>();
|
||||
const { buildSafeLifecycleErrorMeta, maskRunId, maskSessionKey, newerGenerationOwnsSession } =
|
||||
common;
|
||||
const {
|
||||
markPendingFinalDelivery,
|
||||
safeMarkRequiredCompletionDeliveryBlocked,
|
||||
safeSetSubagentTaskDeliveryStatus,
|
||||
} = deliveryHelpers;
|
||||
const { markRequesterSettleWakePending, scheduleRequesterSettleWake } = requesterWake;
|
||||
|
||||
const isCleanupGenerationCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
params.runs.get(runId) === entry &&
|
||||
entry.pauseReason !== "sessions_yield" &&
|
||||
cleanupGenerations.get(entry) === generation &&
|
||||
!newerGenerationOwnsSession(entry);
|
||||
|
||||
const scheduleResumeSubagentRun = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
delayMs: number,
|
||||
cleanupGeneration?: number,
|
||||
) => {
|
||||
const timer = setTimeout(() => {
|
||||
scheduledResumeTimers.delete(timer);
|
||||
void runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
if (params.runs.get(runId) !== entry) {
|
||||
return;
|
||||
}
|
||||
if (cleanupGeneration !== undefined) {
|
||||
if (!isCleanupGenerationCurrent(runId, entry, cleanupGeneration)) {
|
||||
return;
|
||||
}
|
||||
if (entry.cleanupHandled) {
|
||||
entry.cleanupHandled = false;
|
||||
params.persist(runId);
|
||||
}
|
||||
}
|
||||
params.resumedRuns.delete(runId);
|
||||
params.resumeSubagentRun(runId);
|
||||
}).catch((err: unknown) => {
|
||||
defaultRuntime.log(`[warn] subagent cleanup resume failed (${runId}): ${String(err)}`);
|
||||
const current = params.runs.get(runId);
|
||||
if (
|
||||
isGatewayRestartDraining() &&
|
||||
current === entry &&
|
||||
typeof current.cleanupCompletedAt !== "number"
|
||||
) {
|
||||
scheduleResumeSubagentRun(
|
||||
runId,
|
||||
entry,
|
||||
Math.max(delayMs, MIN_ANNOUNCE_RETRY_DELAY_MS),
|
||||
cleanupGeneration,
|
||||
);
|
||||
}
|
||||
});
|
||||
}, delayMs);
|
||||
timer.unref?.();
|
||||
scheduledResumeTimers.add(timer);
|
||||
};
|
||||
|
||||
const runDetachedCleanupAttempt = (args: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
cleanupGeneration: number;
|
||||
run: () => Promise<void>;
|
||||
}) => {
|
||||
// Completion makes the task projection non-blocking before delivery and
|
||||
// cleanup finish. This independent lease bridges that handoff and owns the
|
||||
// full detached attempt, including its final durable registry write.
|
||||
// Completion outlives the spawning attempt; inherited lock owners would
|
||||
// reject requester transcript writes after that attempt is disposed.
|
||||
runWithoutOwnedSessionTranscriptWrites(() => {
|
||||
void runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
try {
|
||||
await args.run();
|
||||
cleanupFailureCounts.delete(args.entry);
|
||||
} catch (err) {
|
||||
defaultRuntime.log(
|
||||
`[warn] subagent cleanup finalize failed (${args.runId}): ${String(err)}`,
|
||||
);
|
||||
const current = params.runs.get(args.runId);
|
||||
if (
|
||||
!current ||
|
||||
current.cleanupCompletedAt ||
|
||||
!isCleanupAttemptCurrent(args.runId, args.entry, args.cleanupGeneration)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
current.cleanupHandled = false;
|
||||
params.resumedRuns.delete(args.runId);
|
||||
params.persist(args.runId);
|
||||
const failureCount = (cleanupFailureCounts.get(current) ?? 0) + 1;
|
||||
cleanupFailureCounts.set(current, failureCount);
|
||||
if (failureCount <= MAX_DETACHED_CLEANUP_RETRIES) {
|
||||
scheduleResumeSubagentRun(
|
||||
args.runId,
|
||||
current,
|
||||
resolveAnnounceRetryDelayMs(failureCount),
|
||||
args.cleanupGeneration,
|
||||
);
|
||||
}
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
defaultRuntime.log(
|
||||
`[warn] subagent cleanup admission failed (${args.runId}): ${String(err)}`,
|
||||
);
|
||||
if (isGatewayRestartDraining()) {
|
||||
scheduleResumeSubagentRun(
|
||||
args.runId,
|
||||
args.entry,
|
||||
MIN_ANNOUNCE_RETRY_DELAY_MS,
|
||||
args.cleanupGeneration,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const suspendPendingFinalDelivery = (args: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
reason: "expiry" | "permanent_failure";
|
||||
error?: string;
|
||||
}) => {
|
||||
const previousEntry = structuredClone(args.entry);
|
||||
markPendingFinalDelivery({
|
||||
entry: args.entry,
|
||||
error: args.error ?? getDeliveryLastError(args.entry) ?? args.reason,
|
||||
});
|
||||
const now = Date.now();
|
||||
const delivery = ensureDeliveryState(args.entry);
|
||||
delivery.status = "suspended";
|
||||
delivery.suspendedAt ??= now;
|
||||
delivery.suspendedReason = args.reason;
|
||||
args.entry.cleanupHandled = false;
|
||||
args.entry.wakeOnDescendantSettle = undefined;
|
||||
const completion = ensureCompletionState(args.entry);
|
||||
completion.fallbackResultText = undefined;
|
||||
completion.fallbackCapturedAt = undefined;
|
||||
params.resumedRuns.delete(args.runId);
|
||||
safeSetSubagentTaskDeliveryStatus({
|
||||
entry: args.entry,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: getDeliveryLastError(args.entry) ?? args.reason,
|
||||
});
|
||||
safeMarkRequiredCompletionDeliveryBlocked({
|
||||
entry: args.entry,
|
||||
reason: getDeliveryLastError(args.entry) ?? args.reason,
|
||||
});
|
||||
logAnnounceGiveUp(args.entry, args.reason);
|
||||
markRequesterSettleWakePending(args.entry);
|
||||
try {
|
||||
params.persistOrThrow(args.runId);
|
||||
} catch (error) {
|
||||
const mutableEntry = args.entry as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(mutableEntry)) {
|
||||
delete mutableEntry[key];
|
||||
}
|
||||
Object.assign(args.entry, previousEntry);
|
||||
throw error;
|
||||
}
|
||||
// Suspension is terminal for automatic retries, so it settles this child
|
||||
// for requester-drain purposes even though cleanup stays incomplete.
|
||||
scheduleRequesterSettleWake(args.runId, args.entry);
|
||||
};
|
||||
|
||||
const beginSubagentCleanup = (runId: string) => {
|
||||
const entry = params.runs.get(runId);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry.cleanupCompletedAt || entry.cleanupHandled) {
|
||||
return false;
|
||||
}
|
||||
entry.cleanupHandled = true;
|
||||
cleanupGenerations.set(entry, (cleanupGenerations.get(entry) ?? 0) + 1);
|
||||
params.persist(runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCleanupAttemptCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
entry.cleanupHandled === true && isCleanupGenerationCurrent(runId, entry, generation);
|
||||
|
||||
const retireSupersededCleanupIfNeeded = async (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): Promise<boolean> => {
|
||||
if (
|
||||
params.runs.get(runId) !== entry ||
|
||||
cleanupGenerations.get(entry) !== generation ||
|
||||
!newerGenerationOwnsSession(entry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Cleanup can yield to attachment, mirror, or announce work. A successor
|
||||
// registered while it was suspended owns every session-scoped side effect.
|
||||
await params.retireSupersededRun(runId, entry);
|
||||
return true;
|
||||
};
|
||||
|
||||
const retireSupersededCleanupInBackground = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
) => {
|
||||
// Delivery callbacks are synchronous and may arrive after their announce
|
||||
// attempt returns. Give the async retirement tail its own snapshot blocker.
|
||||
void runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
await retireSupersededCleanupIfNeeded(runId, entry, generation);
|
||||
}).catch((error: unknown) => {
|
||||
defaultRuntime.log(
|
||||
`[warn] subagent superseded cleanup retirement failed (${runId}): ${String(error)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isTerminalCallbackCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
params.runs.get(runId) === entry &&
|
||||
entry.pauseReason !== "sessions_yield" &&
|
||||
terminalGenerations.get(entry) === generation;
|
||||
|
||||
const isEndedHookOwnerCurrent = (runId: string, entry: SubagentRunRecord): boolean => {
|
||||
const current = params.runs.get(runId);
|
||||
return (current === undefined || current === entry) && !newerGenerationOwnsSession(entry);
|
||||
};
|
||||
|
||||
const retireRunModeBundleMcpRuntime = async (cleanupParams: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
reason: string;
|
||||
}) => {
|
||||
if (cleanupParams.entry.spawnMode === "session") {
|
||||
return;
|
||||
}
|
||||
await retireSessionMcpRuntimeForSessionKey({
|
||||
sessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: cleanupParams.reason,
|
||||
preserveActiveLeases: true,
|
||||
onError: (error, sessionId) => {
|
||||
params.warn("failed to retire subagent bundle MCP runtime", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
sessionId,
|
||||
runId: maskRunId(cleanupParams.runId),
|
||||
childSessionKey: maskSessionKey(cleanupParams.entry.childSessionKey),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginSubagentCleanup,
|
||||
isCleanupAttemptCurrent,
|
||||
isEndedHookOwnerCurrent,
|
||||
isTerminalCallbackCurrent,
|
||||
retireSupersededCleanupIfNeeded,
|
||||
retireRunModeBundleMcpRuntime,
|
||||
retireSupersededCleanupInBackground,
|
||||
runDetachedCleanupAttempt,
|
||||
scheduleResumeSubagentRun,
|
||||
suspendPendingFinalDelivery,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { formatErrorMessage, readErrorName } from "../../../infra/errors.js";
|
||||
import type {
|
||||
SubagentRegistryLifecycleParams,
|
||||
SubagentRegistryLifecycleState,
|
||||
} from "./subagent-registry-lifecycle-contracts.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
|
||||
|
||||
export function createSubagentRegistryLifecycleCommon(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
state: SubagentRegistryLifecycleState,
|
||||
) {
|
||||
const {
|
||||
scheduledResumeTimers,
|
||||
scheduledRequesterSettleWakeTimers,
|
||||
pendingRequesterSettleWakeRearms,
|
||||
terminalCompletionLocks,
|
||||
} = state;
|
||||
|
||||
const newerGenerationOwnsSession = (entry: SubagentRunRecord): boolean =>
|
||||
entry.killReconciliation?.supersededAt !== undefined ||
|
||||
Array.from(params.runs.values()).some(
|
||||
(candidate) =>
|
||||
candidate.runId !== entry.runId &&
|
||||
candidate.childSessionKey === entry.childSessionKey &&
|
||||
compareSubagentRunGeneration(candidate, entry) > 0,
|
||||
);
|
||||
|
||||
const acquireTerminalCompletionLock = async (runId: string): Promise<() => void> => {
|
||||
const previous = terminalCompletionLocks.get(runId) ?? Promise.resolve();
|
||||
let releaseLock = () => {};
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
terminalCompletionLocks.set(runId, current);
|
||||
await previous;
|
||||
return () => {
|
||||
releaseLock();
|
||||
if (terminalCompletionLocks.get(runId) === current) {
|
||||
terminalCompletionLocks.delete(runId);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const clearScheduledResumeTimers = () => {
|
||||
for (const timer of scheduledResumeTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
scheduledResumeTimers.clear();
|
||||
for (const scheduled of scheduledRequesterSettleWakeTimers.values()) {
|
||||
clearTimeout(scheduled.timer);
|
||||
}
|
||||
scheduledRequesterSettleWakeTimers.clear();
|
||||
pendingRequesterSettleWakeRearms.clear();
|
||||
};
|
||||
|
||||
const maskRunId = (runId: string): string => {
|
||||
const trimmed = runId.trim();
|
||||
if (!trimmed) {
|
||||
return "unknown";
|
||||
}
|
||||
if (trimmed.length <= 8) {
|
||||
return "***";
|
||||
}
|
||||
return `${sliceUtf16Safe(trimmed, 0, 4)}…${sliceUtf16Safe(trimmed, -4)}`;
|
||||
};
|
||||
|
||||
const maskSessionKey = (sessionKey: string): string => {
|
||||
const trimmed = sessionKey.trim();
|
||||
if (!trimmed) {
|
||||
return "unknown";
|
||||
}
|
||||
const prefix = trimmed.split(":").slice(0, 2).join(":") || "session";
|
||||
return `${prefix}:…`;
|
||||
};
|
||||
|
||||
const buildSafeLifecycleErrorMeta = (err: unknown): Record<string, string> => {
|
||||
const message = formatErrorMessage(err);
|
||||
const name = readErrorName(err);
|
||||
return name ? { name, message } : { message };
|
||||
};
|
||||
|
||||
return {
|
||||
acquireTerminalCompletionLock,
|
||||
buildSafeLifecycleErrorMeta,
|
||||
clearScheduledResumeTimers,
|
||||
maskRunId,
|
||||
maskSessionKey,
|
||||
newerGenerationOwnsSession,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { createLazyImportLoader } from "../../../shared/lazy-promise.js";
|
||||
import type { SubagentRunOutcome } from "../announce/subagent-announce-output.js";
|
||||
import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import {
|
||||
resolveSubagentRunDeadlineMs,
|
||||
resolveSubagentRunEffectiveEndedAt,
|
||||
} from "./subagent-run-timeout.js";
|
||||
|
||||
type BrowserCleanupModule = Pick<
|
||||
typeof import("../../../browser-lifecycle-cleanup.js"),
|
||||
"cleanupBrowserSessionsForLifecycleEnd"
|
||||
>;
|
||||
|
||||
const browserCleanupLoader = createLazyImportLoader<BrowserCleanupModule>(
|
||||
() => import("../../../browser-lifecycle-cleanup.js"),
|
||||
);
|
||||
|
||||
export async function loadCleanupBrowserSessionsForLifecycleEnd(): Promise<
|
||||
BrowserCleanupModule["cleanupBrowserSessionsForLifecycleEnd"]
|
||||
> {
|
||||
return (await browserCleanupLoader.load()).cleanupBrowserSessionsForLifecycleEnd;
|
||||
}
|
||||
|
||||
export function shouldPreservePublishedExplicitRunTimeout(params: {
|
||||
entry: SubagentRunRecord;
|
||||
}): boolean {
|
||||
if (
|
||||
typeof params.entry.runTimeoutSeconds !== "number" ||
|
||||
!Number.isFinite(params.entry.runTimeoutSeconds) ||
|
||||
params.entry.runTimeoutSeconds <= 0 ||
|
||||
params.entry.execution.outcome?.status !== "timeout" ||
|
||||
typeof params.entry.execution.endedAt !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const deadlineMs = resolveSubagentRunDeadlineMs(params.entry);
|
||||
if (deadlineMs === undefined || params.entry.execution.endedAt < deadlineMs) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.entry.cleanupHandled ||
|
||||
typeof params.entry.cleanupCompletedAt === "number" ||
|
||||
typeof params.entry.endedHookEmittedAt === "number" ||
|
||||
params.entry.delivery?.status === "delivered" ||
|
||||
typeof params.entry.delivery?.announcedAt === "number"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveExpiredExplicitRunDeadlineMs(params: {
|
||||
entry: SubagentRunRecord;
|
||||
nextEndedAt: number;
|
||||
observedStartedAt?: number;
|
||||
}): number | undefined {
|
||||
const effectiveEndedAt = resolveSubagentRunEffectiveEndedAt(
|
||||
params.entry,
|
||||
params.nextEndedAt,
|
||||
params.observedStartedAt,
|
||||
);
|
||||
return effectiveEndedAt < params.nextEndedAt ? effectiveEndedAt : undefined;
|
||||
}
|
||||
|
||||
export function isOlderEquivalentTerminalCallback(params: {
|
||||
entry: SubagentRunRecord;
|
||||
endedAt: number;
|
||||
outcome: SubagentRunOutcome;
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
}): boolean {
|
||||
const current = params.entry.execution.outcome;
|
||||
if (
|
||||
typeof params.entry.execution.endedAt !== "number" ||
|
||||
params.endedAt >= params.entry.execution.endedAt ||
|
||||
params.entry.endedReason !== params.reason ||
|
||||
current?.status !== params.outcome.status
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
current.status !== "error" ||
|
||||
params.outcome.status !== "error" ||
|
||||
current.error === params.outcome.error
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
// This type-only leaf exists solely to keep lifecycle sibling modules from importing the controller.
|
||||
// Keeping the controller out of their dependency graph satisfies the architecture cycle gate.
|
||||
import type { cleanupBrowserSessionsForLifecycleEnd } from "../../../browser-lifecycle-cleanup.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { callGateway as defaultCallGateway } from "../../../gateway/call.js";
|
||||
import type { DetachedTaskFindResult } from "../../../tasks/detached-task-runtime-contract.js";
|
||||
import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
type CaptureSubagentCompletionReply =
|
||||
(typeof import("../announce/subagent-announce.js"))["captureSubagentCompletionReply"];
|
||||
type RunSubagentAnnounceFlow =
|
||||
(typeof import("../announce/subagent-announce.js"))["runSubagentAnnounceFlow"];
|
||||
type MaybeWakeRequesterAfterAllChildrenSettled =
|
||||
(typeof import("../announce/subagent-announce.requester-settle-wake.js"))["maybeWakeRequesterAfterAllChildrenSettled"];
|
||||
type BrowserCleanup = typeof cleanupBrowserSessionsForLifecycleEnd;
|
||||
|
||||
export type SubagentLifecycleOptions = {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
subagentAnnounceTimeoutMs: number;
|
||||
getRuntimeConfig(): OpenClawConfig;
|
||||
persist(...runIds: string[]): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
clearPendingLifecycleError(runId: string): void;
|
||||
countPendingDescendantRuns(rootSessionKey: string): number;
|
||||
suppressAnnounceForSteerRestart(entry?: SubagentRunRecord): boolean;
|
||||
resolveSubagentTask(entry: SubagentRunRecord): DetachedTaskFindResult;
|
||||
shouldEmitEndedHookForRun(args: {
|
||||
entry: SubagentRunRecord;
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
}): boolean;
|
||||
emitSubagentEndedHookForRun(args: {
|
||||
entry: SubagentRunRecord;
|
||||
reason?: SubagentLifecycleEndedReason;
|
||||
sendFarewell?: boolean;
|
||||
accountId?: string;
|
||||
isCurrent?: () => boolean;
|
||||
}): Promise<void>;
|
||||
emitSubagentProgressEndedForRun(entry: SubagentRunRecord): Promise<void>;
|
||||
notifyContextEngineSubagentEnded(
|
||||
args: {
|
||||
childSessionKey: string;
|
||||
reason: "completed" | "deleted";
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
},
|
||||
options?: { isCurrent?: () => boolean },
|
||||
): Promise<void>;
|
||||
retireSupersededRun(runId: string, entry: SubagentRunRecord): Promise<void>;
|
||||
resumeSubagentRun(runId: string): void;
|
||||
callGateway: typeof defaultCallGateway;
|
||||
captureSubagentCompletionReply: CaptureSubagentCompletionReply;
|
||||
cleanupBrowserSessionsForLifecycleEnd?: BrowserCleanup;
|
||||
loadCleanupBrowserSessionsForLifecycleEnd?: () => Promise<BrowserCleanup>;
|
||||
runSubagentAnnounceFlow: RunSubagentAnnounceFlow;
|
||||
maybeWakeRequesterAfterAllChildrenSettled: MaybeWakeRequesterAfterAllChildrenSettled;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export interface SubagentLifecycleCommonContext {
|
||||
readonly options: SubagentLifecycleOptions;
|
||||
newerGenerationOwnsSession(entry: SubagentRunRecord): boolean;
|
||||
}
|
||||
|
||||
export interface SubagentLifecycleCompletionContext extends SubagentLifecycleCommonContext {
|
||||
acquireTerminalCompletionLock(runId: string): Promise<() => void>;
|
||||
bumpCleanupGeneration(entry: SubagentRunRecord): number;
|
||||
bumpTerminalGeneration(entry: SubagentRunRecord): number;
|
||||
hasProgressEnded(entry: SubagentRunRecord): boolean;
|
||||
isTerminalCallbackCurrent(runId: string, entry: SubagentRunRecord, generation: number): boolean;
|
||||
markProgressEnded(entry: SubagentRunRecord): void;
|
||||
startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecord): boolean;
|
||||
}
|
||||
|
||||
export interface SubagentLifecycleCleanupContext extends SubagentLifecycleCommonContext {
|
||||
addScheduledResumeTimer(timer: ReturnType<typeof setTimeout>): void;
|
||||
bumpCleanupGeneration(entry: SubagentRunRecord): number;
|
||||
clearCleanupFailureCount(entry: SubagentRunRecord): void;
|
||||
deleteScheduledResumeTimer(timer: ReturnType<typeof setTimeout>): void;
|
||||
incrementCleanupFailureCount(entry: SubagentRunRecord): number;
|
||||
isCleanupAttemptCurrent(runId: string, entry: SubagentRunRecord, generation: number): boolean;
|
||||
isCleanupGeneration(entry: SubagentRunRecord, generation: number): boolean;
|
||||
isCleanupGenerationCurrent(runId: string, entry: SubagentRunRecord, generation: number): boolean;
|
||||
isEndedHookOwnerCurrent(runId: string, entry: SubagentRunRecord): boolean;
|
||||
startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecord): boolean;
|
||||
}
|
||||
|
||||
export interface SubagentLifecycleAnnounceCleanupContext
|
||||
extends SubagentLifecycleCleanupContext, SubagentLifecycleWakeContext {
|
||||
completeCleanupBookkeeping(args: CleanupBookkeepingParams): void;
|
||||
}
|
||||
|
||||
export interface SubagentLifecycleWakeContext extends SubagentLifecycleCommonContext {
|
||||
deleteRequesterSettleWakeTimer(runId: string): void;
|
||||
getRequesterSettleWakeTimer(runId: string): ScheduledRequesterSettleWake | undefined;
|
||||
hasScheduledRequesterSettleWakeRun(runId: string): boolean;
|
||||
markRequesterSettleWakeRearm(runId: string): void;
|
||||
markRequesterSettleWakeRunScheduled(runId: string): void;
|
||||
setRequesterSettleWakeTimer(runId: string, value: ScheduledRequesterSettleWake): void;
|
||||
takeRequesterSettleWakeRearm(runId: string): boolean;
|
||||
unmarkRequesterSettleWakeRunScheduled(runId: string): void;
|
||||
}
|
||||
|
||||
export type CleanupBookkeepingParams = {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
cleanup: "delete" | "keep";
|
||||
completedAt: number;
|
||||
preserveTranscript?: boolean;
|
||||
provisionalKill?: boolean;
|
||||
skipRequesterSettleWake?: boolean;
|
||||
};
|
||||
|
||||
export type ScheduledRequesterSettleWake = {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
deadline: number;
|
||||
rearmGeneration?: number;
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { cleanupBrowserSessionsForLifecycleEnd } from "../../../browser-lifecycle-cleanup.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { callGateway as defaultCallGateway } from "../../../gateway/call.js";
|
||||
import type { DetachedTaskFindResult } from "../../../tasks/detached-task-runtime-contract.js";
|
||||
import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
type CaptureSubagentCompletionReply =
|
||||
(typeof import("../announce/subagent-announce.js"))["captureSubagentCompletionReply"];
|
||||
type RunSubagentAnnounceFlow =
|
||||
(typeof import("../announce/subagent-announce.js"))["runSubagentAnnounceFlow"];
|
||||
type MaybeWakeRequesterAfterAllChildrenSettled =
|
||||
(typeof import("../announce/subagent-announce.requester-settle-wake.js"))["maybeWakeRequesterAfterAllChildrenSettled"];
|
||||
export type SubagentRegistryLifecycleParams = {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
subagentAnnounceTimeoutMs: number;
|
||||
getRuntimeConfig(): OpenClawConfig;
|
||||
persist(...runIds: string[]): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
clearPendingLifecycleError(runId: string): void;
|
||||
countPendingDescendantRuns(rootSessionKey: string): number;
|
||||
suppressAnnounceForSteerRestart(entry?: SubagentRunRecord): boolean;
|
||||
resolveSubagentTask(entry: SubagentRunRecord): DetachedTaskFindResult;
|
||||
shouldEmitEndedHookForRun(args: {
|
||||
entry: SubagentRunRecord;
|
||||
reason: SubagentLifecycleEndedReason;
|
||||
}): boolean;
|
||||
emitSubagentEndedHookForRun(args: {
|
||||
entry: SubagentRunRecord;
|
||||
reason?: SubagentLifecycleEndedReason;
|
||||
sendFarewell?: boolean;
|
||||
accountId?: string;
|
||||
isCurrent?: () => boolean;
|
||||
}): Promise<void>;
|
||||
emitSubagentProgressEndedForRun(entry: SubagentRunRecord): Promise<void>;
|
||||
notifyContextEngineSubagentEnded(
|
||||
args: {
|
||||
childSessionKey: string;
|
||||
reason: "completed" | "deleted";
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
},
|
||||
options?: { isCurrent?: () => boolean },
|
||||
): Promise<void>;
|
||||
retireSupersededRun(runId: string, entry: SubagentRunRecord): Promise<void>;
|
||||
resumeSubagentRun(runId: string): void;
|
||||
callGateway: typeof defaultCallGateway;
|
||||
captureSubagentCompletionReply: CaptureSubagentCompletionReply;
|
||||
cleanupBrowserSessionsForLifecycleEnd?: typeof cleanupBrowserSessionsForLifecycleEnd;
|
||||
runSubagentAnnounceFlow: RunSubagentAnnounceFlow;
|
||||
maybeWakeRequesterAfterAllChildrenSettled: MaybeWakeRequesterAfterAllChildrenSettled;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export type SubagentRegistryLifecycleState = {
|
||||
scheduledResumeTimers: Set<ReturnType<typeof setTimeout>>;
|
||||
pendingRequesterSettleWakeRearms: Set<string>;
|
||||
scheduledRequesterSettleWakeRuns: Set<string>;
|
||||
scheduledRequesterSettleWakeTimers: Map<
|
||||
string,
|
||||
{
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
deadline: number;
|
||||
rearmGeneration?: number;
|
||||
}
|
||||
>;
|
||||
terminalCompletionLocks: Map<string, Promise<void>>;
|
||||
terminalGenerations: WeakMap<SubagentRunRecord, number>;
|
||||
cleanupGenerations: WeakMap<SubagentRunRecord, number>;
|
||||
progressEndedEntries: WeakSet<SubagentRunRecord>;
|
||||
};
|
||||
|
||||
export function createSubagentRegistryLifecycleState(): SubagentRegistryLifecycleState {
|
||||
return {
|
||||
scheduledResumeTimers: new Set(),
|
||||
pendingRequesterSettleWakeRearms: new Set(),
|
||||
scheduledRequesterSettleWakeRuns: new Set(),
|
||||
scheduledRequesterSettleWakeTimers: new Map(),
|
||||
terminalCompletionLocks: new Map(),
|
||||
terminalGenerations: new WeakMap(),
|
||||
cleanupGenerations: new WeakMap(),
|
||||
progressEndedEntries: new WeakSet(),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { resolveStorePath } from "../../../config/sessions/paths.js";
|
||||
import {
|
||||
loadSessionEntryReadOnly,
|
||||
type SessionTranscriptRuntimeTarget,
|
||||
} from "../../../config/sessions/session-accessor.js";
|
||||
import { resolveSessionStorePathForScope } from "../../../config/sessions/session-store-path.js";
|
||||
import { formatErrorMessage, readErrorName } from "../../../infra/errors.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
|
||||
import type { DetachedTaskFindResult } from "../../../tasks/detached-task-runtime-contract.js";
|
||||
@@ -31,475 +33,486 @@ import {
|
||||
import type { SubagentLifecycleEndedReason } from "./subagent-lifecycle-events.js";
|
||||
import { resolveFinalizedSubagentTaskState } from "./subagent-registry-completion.js";
|
||||
import { capFrozenResultText } from "./subagent-registry-helpers.js";
|
||||
import type { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import type {
|
||||
SubagentRegistryLifecycleParams,
|
||||
SubagentRegistryLifecycleState,
|
||||
} from "./subagent-registry-lifecycle-contracts.js";
|
||||
SubagentLifecycleCommonContext,
|
||||
SubagentLifecycleOptions,
|
||||
} from "./subagent-registry-lifecycle-context.js";
|
||||
import type { PendingFinalDeliveryPayload, SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
|
||||
|
||||
const DELIVERY_MIRROR_HISTORY_MAX_CHARS = 128 * 1024;
|
||||
|
||||
export function createSubagentRegistryLifecycleDelivery(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
_state: SubagentRegistryLifecycleState,
|
||||
common: ReturnType<typeof createSubagentRegistryLifecycleCommon>,
|
||||
) {
|
||||
const { newerGenerationOwnsSession, buildSafeLifecycleErrorMeta, maskRunId, maskSessionKey } =
|
||||
common;
|
||||
export function buildSafeLifecycleErrorMeta(error: unknown): Record<string, string> {
|
||||
const message = formatErrorMessage(error);
|
||||
const name = readErrorName(error);
|
||||
return name ? { name, message } : { message };
|
||||
}
|
||||
|
||||
const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => {
|
||||
const errors = [
|
||||
delivery.error,
|
||||
delivery.reason,
|
||||
...(delivery.phases ?? []).map((phase) =>
|
||||
phase.error ? `${phase.phase}: ${phase.error}` : undefined,
|
||||
),
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return errors.length > 0
|
||||
? uniqueStrings(errors).join("; ")
|
||||
: `delivery path ${delivery.path} did not complete`;
|
||||
};
|
||||
export function maskLifecycleIdentifier(value: string, kind: "run" | "session"): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "unknown";
|
||||
}
|
||||
return kind === "session"
|
||||
? `${trimmed.split(":").slice(0, 2).join(":") || "session"}:…`
|
||||
: trimmed.length <= 8
|
||||
? "***"
|
||||
: `${sliceUtf16Safe(trimmed, 0, 4)}…${sliceUtf16Safe(trimmed, -4)}`;
|
||||
}
|
||||
|
||||
const recordAnnounceDeliveryResult = (
|
||||
entry: SubagentRunRecord,
|
||||
delivery: SubagentAnnounceDeliveryResult,
|
||||
) => {
|
||||
const deliveryState = ensureDeliveryState(entry);
|
||||
if (typeof delivery.enqueuedAt === "number") {
|
||||
deliveryState.enqueuedAt ??= delivery.enqueuedAt;
|
||||
}
|
||||
if (delivery.delivered) {
|
||||
const deliveredAt =
|
||||
typeof delivery.deliveredAt === "number" ? delivery.deliveredAt : Date.now();
|
||||
deliveryState.deliveredAt = deliveredAt;
|
||||
deliveryState.lastDropReason = undefined;
|
||||
}
|
||||
deliveryState.disposition =
|
||||
delivery.disposition ?? (delivery.delivered ? "delivered" : "retryable");
|
||||
};
|
||||
export const formatAnnounceDeliveryError = (delivery: SubagentAnnounceDeliveryResult): string => {
|
||||
const errors = [
|
||||
delivery.error,
|
||||
delivery.reason,
|
||||
...(delivery.phases ?? []).map((phase) =>
|
||||
phase.error ? `${phase.phase}: ${phase.error}` : undefined,
|
||||
),
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return errors.length > 0
|
||||
? uniqueStrings(errors).join("; ")
|
||||
: `delivery path ${delivery.path} did not complete`;
|
||||
};
|
||||
|
||||
const hasPriorRequesterDeliveryMirror = async (entry: SubagentRunRecord): Promise<boolean> => {
|
||||
const completion = ensureCompletionState(entry);
|
||||
const expectedText = extractTextFromChatContent(completion.resultText, { joinWith: "" });
|
||||
if (entry.expectsCompletionMessage !== true || expectedText == null) {
|
||||
return false;
|
||||
}
|
||||
const mirrorNotBefore = entry.execution.startedAt ?? entry.createdAt;
|
||||
const mirrorNotAfter = Date.now() + 30_000;
|
||||
const expectedIdempotencyKey = buildAnnounceIdempotencyKey(
|
||||
buildAnnounceIdFromChildRun({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
childRunId: entry.runId,
|
||||
}),
|
||||
);
|
||||
const isExpectedMirrorIdempotencyKey = (value: unknown): boolean =>
|
||||
typeof value === "string" &&
|
||||
(value === expectedIdempotencyKey ||
|
||||
value.startsWith(`${expectedIdempotencyKey}:internal-source-reply:`) ||
|
||||
value.startsWith(`${expectedIdempotencyKey}:message-tool:internal-source-reply:`) ||
|
||||
value.startsWith(`${entry.runId}:message-tool:`) ||
|
||||
value.startsWith(`${entry.runId}:internal-source-reply:`));
|
||||
try {
|
||||
const history = await params.callGateway<{
|
||||
messages?: unknown[];
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: {
|
||||
sessionKey: entry.requesterSessionKey,
|
||||
limit: 25,
|
||||
maxChars: DELIVERY_MIRROR_HISTORY_MAX_CHARS,
|
||||
},
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const mirror = history.messages?.find((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
const timestamp = record.timestamp;
|
||||
if (
|
||||
typeof timestamp !== "number" ||
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp < mirrorNotBefore ||
|
||||
timestamp > mirrorNotAfter ||
|
||||
!isExpectedMirrorIdempotencyKey(record.idempotencyKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const text = extractTextFromChatContent(record.content, { joinWith: "" });
|
||||
return (
|
||||
record.role === "assistant" &&
|
||||
record.provider === "openclaw" &&
|
||||
record.model === "delivery-mirror" &&
|
||||
text === expectedText
|
||||
);
|
||||
});
|
||||
if (mirror) {
|
||||
ensureDeliveryState(entry).deliveredAt = (mirror as { timestamp: number }).timestamp;
|
||||
export const recordAnnounceDeliveryResult = (
|
||||
entry: SubagentRunRecord,
|
||||
delivery: SubagentAnnounceDeliveryResult,
|
||||
) => {
|
||||
const deliveryState = ensureDeliveryState(entry);
|
||||
if (typeof delivery.enqueuedAt === "number") {
|
||||
deliveryState.enqueuedAt ??= delivery.enqueuedAt;
|
||||
}
|
||||
if (delivery.delivered) {
|
||||
const deliveredAt =
|
||||
typeof delivery.deliveredAt === "number" ? delivery.deliveredAt : Date.now();
|
||||
deliveryState.deliveredAt = deliveredAt;
|
||||
deliveryState.lastDropReason = undefined;
|
||||
}
|
||||
deliveryState.disposition =
|
||||
delivery.disposition ?? (delivery.delivered ? "delivered" : "retryable");
|
||||
};
|
||||
|
||||
export const hasPriorRequesterDeliveryMirror = async (
|
||||
params: SubagentLifecycleOptions,
|
||||
entry: SubagentRunRecord,
|
||||
): Promise<boolean> => {
|
||||
const completion = ensureCompletionState(entry);
|
||||
const expectedText = extractTextFromChatContent(completion.resultText, { joinWith: "" });
|
||||
if (entry.expectsCompletionMessage !== true || expectedText == null) {
|
||||
return false;
|
||||
}
|
||||
const mirrorNotBefore = entry.execution.startedAt ?? entry.createdAt;
|
||||
const mirrorNotAfter = Date.now() + 30_000;
|
||||
const expectedIdempotencyKey = buildAnnounceIdempotencyKey(
|
||||
buildAnnounceIdFromChildRun({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
childRunId: entry.runId,
|
||||
}),
|
||||
);
|
||||
const isExpectedMirrorIdempotencyKey = (value: unknown): boolean =>
|
||||
typeof value === "string" &&
|
||||
(value === expectedIdempotencyKey ||
|
||||
value.startsWith(`${expectedIdempotencyKey}:internal-source-reply:`) ||
|
||||
value.startsWith(`${expectedIdempotencyKey}:message-tool:internal-source-reply:`) ||
|
||||
value.startsWith(`${entry.runId}:message-tool:`) ||
|
||||
value.startsWith(`${entry.runId}:internal-source-reply:`));
|
||||
try {
|
||||
const history = await params.callGateway<{
|
||||
messages?: unknown[];
|
||||
}>({
|
||||
method: "chat.history",
|
||||
params: {
|
||||
sessionKey: entry.requesterSessionKey,
|
||||
limit: 25,
|
||||
maxChars: DELIVERY_MIRROR_HISTORY_MAX_CHARS,
|
||||
},
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const mirror = history.messages?.find((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Boolean(mirror);
|
||||
} catch {
|
||||
return false;
|
||||
const record = message as Record<string, unknown>;
|
||||
const timestamp = record.timestamp;
|
||||
if (
|
||||
typeof timestamp !== "number" ||
|
||||
!Number.isFinite(timestamp) ||
|
||||
timestamp < mirrorNotBefore ||
|
||||
timestamp > mirrorNotAfter ||
|
||||
!isExpectedMirrorIdempotencyKey(record.idempotencyKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const text = extractTextFromChatContent(record.content, { joinWith: "" });
|
||||
return (
|
||||
record.role === "assistant" &&
|
||||
record.provider === "openclaw" &&
|
||||
record.model === "delivery-mirror" &&
|
||||
text === expectedText
|
||||
);
|
||||
});
|
||||
if (mirror) {
|
||||
ensureDeliveryState(entry).deliveredAt = (mirror as { timestamp: number }).timestamp;
|
||||
}
|
||||
};
|
||||
return Boolean(mirror);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSubagentTaskTarget = (
|
||||
entry: SubagentRunRecord,
|
||||
resolution = params.resolveSubagentTask(entry),
|
||||
) => {
|
||||
const durableTaskRunId = entry.taskRunId ?? entry.runId;
|
||||
return {
|
||||
runId:
|
||||
resolution.lookup === "available"
|
||||
? (resolution.task?.runId ?? durableTaskRunId)
|
||||
: durableTaskRunId,
|
||||
sessionKey:
|
||||
resolution.lookup === "available"
|
||||
? (resolution.task?.childSessionKey ?? entry.childSessionKey)
|
||||
: entry.childSessionKey,
|
||||
};
|
||||
const resolveSubagentTaskTarget = (
|
||||
params: SubagentLifecycleOptions,
|
||||
entry: SubagentRunRecord,
|
||||
resolution = params.resolveSubagentTask(entry),
|
||||
) => {
|
||||
const durableTaskRunId = entry.taskRunId ?? entry.runId;
|
||||
return {
|
||||
runId:
|
||||
resolution.lookup === "available"
|
||||
? (resolution.task?.runId ?? durableTaskRunId)
|
||||
: durableTaskRunId,
|
||||
sessionKey:
|
||||
resolution.lookup === "available"
|
||||
? (resolution.task?.childSessionKey ?? entry.childSessionKey)
|
||||
: entry.childSessionKey,
|
||||
};
|
||||
};
|
||||
|
||||
const safeSetSubagentTaskDeliveryStatus = (args: {
|
||||
export const safeSetSubagentTaskDeliveryStatus = (
|
||||
params: SubagentLifecycleOptions,
|
||||
args: {
|
||||
entry: SubagentRunRecord;
|
||||
deliveryStatus: Extract<TaskDeliveryStatus, "pending" | "delivered" | "failed">;
|
||||
deliveryError?: string;
|
||||
}) => {
|
||||
const target = resolveSubagentTaskTarget(args.entry);
|
||||
try {
|
||||
setDetachedTaskDeliveryStatusByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
error: args.deliveryStatus === "failed" ? args.deliveryError : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to update subagent background task delivery state", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskRunId(target.runId),
|
||||
childSessionKey: maskSessionKey(target.sessionKey),
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const target = resolveSubagentTaskTarget(params, args.entry);
|
||||
try {
|
||||
setDetachedTaskDeliveryStatusByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
error: args.deliveryStatus === "failed" ? args.deliveryError : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to update subagent background task delivery state", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskLifecycleIdentifier(target.runId, "run"),
|
||||
childSessionKey: maskLifecycleIdentifier(target.sessionKey, "session"),
|
||||
deliveryStatus: args.deliveryStatus,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const safeFinalizeSubagentTaskRun = (args: {
|
||||
export const safeFinalizeSubagentTaskRun = (
|
||||
params: SubagentLifecycleOptions,
|
||||
args: {
|
||||
entry: SubagentRunRecord;
|
||||
outcome: SubagentRunOutcome;
|
||||
taskResolution?: DetachedTaskFindResult;
|
||||
}): ReturnType<typeof completeTaskRunByRunId> => {
|
||||
const terminal = resolveFinalizedSubagentTaskState(args.entry);
|
||||
if (!terminal) {
|
||||
return [];
|
||||
}
|
||||
const target = resolveSubagentTaskTarget(args.entry, args.taskResolution);
|
||||
const { status, error, terminalOutcome, ...details } = terminal;
|
||||
const suppressDelivery = args.entry.suppressCompletionDelivery === true;
|
||||
try {
|
||||
if (status === "succeeded") {
|
||||
return completeTaskRunByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
...details,
|
||||
terminalOutcome,
|
||||
suppressDelivery,
|
||||
});
|
||||
}
|
||||
return failTaskRunByRunId({
|
||||
},
|
||||
): ReturnType<typeof completeTaskRunByRunId> => {
|
||||
const terminal = resolveFinalizedSubagentTaskState(args.entry);
|
||||
if (!terminal) {
|
||||
return [];
|
||||
}
|
||||
const target = resolveSubagentTaskTarget(params, args.entry, args.taskResolution);
|
||||
const { status, error, terminalOutcome, ...details } = terminal;
|
||||
const suppressDelivery = args.entry.suppressCompletionDelivery === true;
|
||||
try {
|
||||
if (status === "succeeded") {
|
||||
return completeTaskRunByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
...details,
|
||||
status,
|
||||
error,
|
||||
terminalOutcome,
|
||||
suppressDelivery,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to finalize subagent background task state", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskRunId(args.entry.runId),
|
||||
childSessionKey: maskSessionKey(args.entry.childSessionKey),
|
||||
outcomeStatus: args.outcome.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return failTaskRunByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
...details,
|
||||
status,
|
||||
error,
|
||||
suppressDelivery,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to finalize subagent background task state", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskLifecycleIdentifier(args.entry.runId, "run"),
|
||||
childSessionKey: maskLifecycleIdentifier(args.entry.childSessionKey, "session"),
|
||||
outcomeStatus: args.outcome.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const safeMarkRequiredCompletionDeliveryBlocked = (args: {
|
||||
export const safeMarkRequiredCompletionDeliveryBlocked = (
|
||||
params: SubagentLifecycleOptions,
|
||||
args: {
|
||||
entry: SubagentRunRecord;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (
|
||||
args.entry.expectsCompletionMessage !== true ||
|
||||
args.entry.execution.outcome?.status !== "ok"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const endedAt = args.entry.execution.endedAt ?? Date.now();
|
||||
const terminalResult = resolveRequiredCompletionDeliveryFailureTerminalResult(args.reason);
|
||||
const target = resolveSubagentTaskTarget(args.entry);
|
||||
try {
|
||||
completeTaskRunByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
endedAt,
|
||||
lastEventAt: Date.now(),
|
||||
progressSummary: resolveSubagentCompletionResultText(args.entry),
|
||||
terminalSummary: terminalResult.terminalSummary,
|
||||
terminalOutcome: terminalResult.terminalOutcome,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to mark subagent completion delivery blocked", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskRunId(args.entry.runId),
|
||||
childSessionKey: maskSessionKey(args.entry.childSessionKey),
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
args.entry.expectsCompletionMessage !== true ||
|
||||
args.entry.execution.outcome?.status !== "ok"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const endedAt = args.entry.execution.endedAt ?? Date.now();
|
||||
const terminalResult = resolveRequiredCompletionDeliveryFailureTerminalResult(args.reason);
|
||||
const target = resolveSubagentTaskTarget(params, args.entry);
|
||||
try {
|
||||
completeTaskRunByRunId({
|
||||
runId: target.runId,
|
||||
runtime: "subagent",
|
||||
sessionKey: target.sessionKey,
|
||||
endedAt,
|
||||
lastEventAt: Date.now(),
|
||||
progressSummary: resolveSubagentCompletionResultText(args.entry),
|
||||
terminalSummary: terminalResult.terminalSummary,
|
||||
terminalOutcome: terminalResult.terminalOutcome,
|
||||
});
|
||||
} catch (err) {
|
||||
params.warn("failed to mark subagent completion delivery blocked", {
|
||||
error: buildSafeLifecycleErrorMeta(err),
|
||||
runId: maskLifecycleIdentifier(args.entry.runId, "run"),
|
||||
childSessionKey: maskLifecycleIdentifier(args.entry.childSessionKey, "session"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const freezeRunResultAtCompletion = async (
|
||||
entry: SubagentRunRecord,
|
||||
outcome: SubagentRunOutcome,
|
||||
): Promise<boolean> => {
|
||||
if (ensureCompletionState(entry).resultText !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (outcome.status === "error") {
|
||||
const completion = ensureCompletionState(entry);
|
||||
completion.resultText = null;
|
||||
completion.capturedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
let resultText: string | null;
|
||||
try {
|
||||
const transcriptTarget = entry.execution.transcriptTarget;
|
||||
const agentId =
|
||||
transcriptTarget?.agentId ?? resolveAgentIdFromSessionKey(entry.childSessionKey);
|
||||
const sessionKey = transcriptTarget?.sessionKey ?? entry.childSessionKey;
|
||||
const configuredStorePath = agentId
|
||||
? (transcriptTarget?.storePath ??
|
||||
resolveStorePath(params.getRuntimeConfig().session?.store, { agentId }))
|
||||
: undefined;
|
||||
const storePath = configuredStorePath
|
||||
? resolveSessionStorePathForScope({
|
||||
agentId,
|
||||
sessionKey,
|
||||
storePath: configuredStorePath,
|
||||
})
|
||||
: undefined;
|
||||
const sessionId =
|
||||
transcriptTarget?.sessionId ??
|
||||
(agentId && storePath
|
||||
? loadSessionEntryReadOnly({ agentId, sessionKey, storePath })?.sessionId
|
||||
: undefined);
|
||||
const sessionTarget: SessionTranscriptRuntimeTarget | undefined =
|
||||
agentId && sessionId && storePath
|
||||
? { agentId, sessionId, sessionKey, storePath }
|
||||
: undefined;
|
||||
const captured = await params.captureSubagentCompletionReply(entry.childSessionKey, {
|
||||
waitForReply: entry.expectsCompletionMessage === true,
|
||||
outcome,
|
||||
...(sessionTarget ? { sessionTarget } : {}),
|
||||
});
|
||||
resultText = captured?.trim() ? capFrozenResultText(captured) : null;
|
||||
} catch {
|
||||
resultText = null;
|
||||
}
|
||||
const liveEntry = params.runs.get(entry.runId);
|
||||
if (
|
||||
entry.pauseReason === "sessions_yield" ||
|
||||
liveEntry?.pauseReason === "sessions_yield" ||
|
||||
newerGenerationOwnsSession(entry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
export const freezeRunResultAtCompletion = async (
|
||||
context: SubagentLifecycleCommonContext,
|
||||
entry: SubagentRunRecord,
|
||||
outcome: SubagentRunOutcome,
|
||||
): Promise<boolean> => {
|
||||
const params = context.options;
|
||||
if (ensureCompletionState(entry).resultText !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (outcome.status === "error") {
|
||||
const completion = ensureCompletionState(entry);
|
||||
if (completion.resultText !== undefined) {
|
||||
return false;
|
||||
}
|
||||
completion.resultText = resultText;
|
||||
completion.resultText = null;
|
||||
completion.capturedAt = Date.now();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
let resultText: string | null;
|
||||
try {
|
||||
const transcriptTarget = entry.execution.transcriptTarget;
|
||||
const agentId =
|
||||
transcriptTarget?.agentId ?? resolveAgentIdFromSessionKey(entry.childSessionKey);
|
||||
const sessionKey = transcriptTarget?.sessionKey ?? entry.childSessionKey;
|
||||
const configuredStorePath = agentId
|
||||
? (transcriptTarget?.storePath ??
|
||||
resolveStorePath(params.getRuntimeConfig().session?.store, { agentId }))
|
||||
: undefined;
|
||||
const storePath = configuredStorePath
|
||||
? resolveSessionStorePathForScope({
|
||||
agentId,
|
||||
sessionKey,
|
||||
storePath: configuredStorePath,
|
||||
})
|
||||
: undefined;
|
||||
const sessionId =
|
||||
transcriptTarget?.sessionId ??
|
||||
(agentId && storePath
|
||||
? loadSessionEntryReadOnly({ agentId, sessionKey, storePath })?.sessionId
|
||||
: undefined);
|
||||
const sessionTarget: SessionTranscriptRuntimeTarget | undefined =
|
||||
agentId && sessionId && storePath ? { agentId, sessionId, sessionKey, storePath } : undefined;
|
||||
const captured = await params.captureSubagentCompletionReply(entry.childSessionKey, {
|
||||
waitForReply: entry.expectsCompletionMessage === true,
|
||||
outcome,
|
||||
...(sessionTarget ? { sessionTarget } : {}),
|
||||
});
|
||||
resultText = captured?.trim() ? capFrozenResultText(captured) : null;
|
||||
} catch {
|
||||
resultText = null;
|
||||
}
|
||||
const liveEntry = params.runs.get(entry.runId);
|
||||
if (
|
||||
entry.pauseReason === "sessions_yield" ||
|
||||
liveEntry?.pauseReason === "sessions_yield" ||
|
||||
context.newerGenerationOwnsSession(entry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const completion = ensureCompletionState(entry);
|
||||
if (completion.resultText !== undefined) {
|
||||
return false;
|
||||
}
|
||||
completion.resultText = resultText;
|
||||
completion.capturedAt = Date.now();
|
||||
return true;
|
||||
};
|
||||
|
||||
const listPendingCompletionRunsForSession = (sessionKey: string): SubagentRunRecord[] => {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
const listPendingCompletionRunsForSession = (
|
||||
params: SubagentLifecycleOptions,
|
||||
sessionKey: string,
|
||||
): SubagentRunRecord[] => {
|
||||
const key = sessionKey.trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
const out: SubagentRunRecord[] = [];
|
||||
for (const entry of params.runs.values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
const out: SubagentRunRecord[] = [];
|
||||
for (const entry of params.runs.values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (entry.expectsCompletionMessage !== true) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.execution.endedAt !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.cleanupCompletedAt === "number") {
|
||||
continue;
|
||||
}
|
||||
// A paused row's result was deliberately cleared when it yielded; the text
|
||||
// now in its session belongs to whatever turn runs next, not to the paused
|
||||
// work. Refreezing it here would announce a stranger's output as this run's
|
||||
// completion once the row finally settles.
|
||||
if (entry.pauseReason === "sessions_yield") {
|
||||
continue;
|
||||
}
|
||||
out.push(entry);
|
||||
if (entry.expectsCompletionMessage !== true) {
|
||||
continue;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const refreshFrozenResultFromSession = async (sessionKey: string): Promise<boolean> => {
|
||||
const candidates = listPendingCompletionRunsForSession(sessionKey).filter(
|
||||
(entry) => entry.execution.outcome?.status !== "error",
|
||||
);
|
||||
const entry = candidates.toSorted(compareSubagentRunGeneration).at(-1);
|
||||
if (!entry || newerGenerationOwnsSession(entry)) {
|
||||
return false;
|
||||
if (typeof entry.execution.endedAt !== "number") {
|
||||
continue;
|
||||
}
|
||||
const generation = entry.generation;
|
||||
|
||||
let captured: string | undefined;
|
||||
try {
|
||||
captured = await params.captureSubagentCompletionReply(sessionKey);
|
||||
} catch {
|
||||
return false;
|
||||
if (typeof entry.cleanupCompletedAt === "number") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = captured?.trim();
|
||||
if (!trimmed || isSilentAgentReplyText(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
// Reply capture yields while registration can transfer session ownership.
|
||||
// Only the exact row and generation that started capture may commit its text.
|
||||
if (
|
||||
params.runs.get(entry.runId) !== entry ||
|
||||
entry.generation !== generation ||
|
||||
newerGenerationOwnsSession(entry)
|
||||
) {
|
||||
return false;
|
||||
// A paused row's result was deliberately cleared when it yielded; the text
|
||||
// now in its session belongs to whatever turn runs next, not to the paused
|
||||
// work. Refreezing it here would announce a stranger's output as this run's
|
||||
// completion once the row finally settles.
|
||||
if (entry.pauseReason === "sessions_yield") {
|
||||
continue;
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const nextFrozen = capFrozenResultText(trimmed);
|
||||
const completion = ensureCompletionState(entry);
|
||||
if (completion.resultText === nextFrozen) {
|
||||
return false;
|
||||
}
|
||||
completion.resultText = nextFrozen;
|
||||
completion.capturedAt = Date.now();
|
||||
params.persist(entry.runId);
|
||||
return true;
|
||||
};
|
||||
export const refreshFrozenResultFromSession = async (
|
||||
context: SubagentLifecycleCommonContext,
|
||||
sessionKey: string,
|
||||
): Promise<boolean> => {
|
||||
const params = context.options;
|
||||
const candidates = listPendingCompletionRunsForSession(params, sessionKey).filter(
|
||||
(entry) => entry.execution.outcome?.status !== "error",
|
||||
);
|
||||
const entry = candidates.toSorted(compareSubagentRunGeneration).at(-1);
|
||||
if (!entry || context.newerGenerationOwnsSession(entry)) {
|
||||
return false;
|
||||
}
|
||||
const generation = entry.generation;
|
||||
|
||||
const emitCompletionEndedHookIfNeeded = async (
|
||||
entry: SubagentRunRecord,
|
||||
reason: SubagentLifecycleEndedReason,
|
||||
isCurrent?: () => boolean,
|
||||
) => {
|
||||
if (params.shouldEmitEndedHookForRun({ entry, reason })) {
|
||||
await params.emitSubagentEndedHookForRun({
|
||||
entry,
|
||||
reason,
|
||||
sendFarewell: true,
|
||||
isCurrent,
|
||||
});
|
||||
}
|
||||
};
|
||||
let captured: string | undefined;
|
||||
try {
|
||||
captured = await params.captureSubagentCompletionReply(sessionKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const trimmed = captured?.trim();
|
||||
if (!trimmed || isSilentAgentReplyText(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
// Reply capture yields while registration can transfer session ownership.
|
||||
// Only the exact row and generation that started capture may commit its text.
|
||||
if (
|
||||
params.runs.get(entry.runId) !== entry ||
|
||||
entry.generation !== generation ||
|
||||
context.newerGenerationOwnsSession(entry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clearPendingFinalDelivery = (entry: SubagentRunRecord) => {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
delivery.payload = undefined;
|
||||
delivery.createdAt = undefined;
|
||||
delivery.lastAttemptAt = undefined;
|
||||
delivery.attemptCount = undefined;
|
||||
delivery.lastError = undefined;
|
||||
delivery.suspendedAt = undefined;
|
||||
delivery.suspendedReason = undefined;
|
||||
if (delivery.status !== "delivered" && delivery.status !== "failed") {
|
||||
clearDeliveryState(entry);
|
||||
}
|
||||
};
|
||||
const nextFrozen = capFrozenResultText(trimmed);
|
||||
const completion = ensureCompletionState(entry);
|
||||
if (completion.resultText === nextFrozen) {
|
||||
return false;
|
||||
}
|
||||
completion.resultText = nextFrozen;
|
||||
completion.capturedAt = Date.now();
|
||||
params.persist(entry.runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadPendingFinalDeliveryPayload = (
|
||||
entry: SubagentRunRecord,
|
||||
): PendingFinalDeliveryPayload => {
|
||||
return {
|
||||
requesterSessionKey:
|
||||
entry.delivery?.payload?.requesterSessionKey ?? entry.requesterSessionKey,
|
||||
requesterOrigin: entry.delivery?.payload?.requesterOrigin ?? entry.requesterOrigin,
|
||||
requesterDisplayKey:
|
||||
entry.delivery?.payload?.requesterDisplayKey ?? entry.requesterDisplayKey,
|
||||
childSessionKey: entry.delivery?.payload?.childSessionKey ?? entry.childSessionKey,
|
||||
childRunId: entry.delivery?.payload?.childRunId ?? entry.runId,
|
||||
task: entry.delivery?.payload?.task ?? entry.task,
|
||||
label: entry.delivery?.payload?.label ?? entry.label,
|
||||
startedAt: entry.delivery?.payload?.startedAt ?? entry.execution.startedAt,
|
||||
endedAt: entry.delivery?.payload?.endedAt ?? entry.execution.endedAt,
|
||||
outcome: entry.delivery?.payload?.outcome ?? entry.execution.outcome,
|
||||
expectsCompletionMessage:
|
||||
entry.delivery?.payload?.expectsCompletionMessage ?? entry.expectsCompletionMessage,
|
||||
spawnMode: entry.delivery?.payload?.spawnMode ?? entry.spawnMode,
|
||||
wakeOnDescendantSettle:
|
||||
entry.delivery?.payload?.wakeOnDescendantSettle ?? entry.wakeOnDescendantSettle,
|
||||
terminalReply: entry.delivery?.payload?.terminalReply ?? entry.completion?.terminalReply,
|
||||
};
|
||||
};
|
||||
export const emitCompletionEndedHookIfNeeded = async (
|
||||
params: SubagentLifecycleOptions,
|
||||
entry: SubagentRunRecord,
|
||||
reason: SubagentLifecycleEndedReason,
|
||||
isCurrent?: () => boolean,
|
||||
) => {
|
||||
if (params.shouldEmitEndedHookForRun({ entry, reason })) {
|
||||
await params.emitSubagentEndedHookForRun({
|
||||
entry,
|
||||
reason,
|
||||
sendFarewell: true,
|
||||
isCurrent,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markPendingFinalDelivery = (args: { entry: SubagentRunRecord; error?: string }) => {
|
||||
const now = Date.now();
|
||||
const payload: PendingFinalDeliveryPayload = loadPendingFinalDeliveryPayload(args.entry);
|
||||
|
||||
const delivery = ensureDeliveryState(args.entry);
|
||||
delivery.status = "pending";
|
||||
delivery.createdAt ??= now;
|
||||
delivery.lastAttemptAt = now;
|
||||
delivery.attemptCount = (delivery.attemptCount ?? 0) + 1;
|
||||
delivery.lastError = args.error ?? null;
|
||||
delivery.payload = payload;
|
||||
};
|
||||
|
||||
const refreshPendingFinalDeliveryPayload = (entry: SubagentRunRecord): boolean => {
|
||||
const delivery = entry.delivery;
|
||||
if (
|
||||
!delivery?.payload ||
|
||||
delivery.status === "delivered" ||
|
||||
typeof delivery.announcedAt === "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
delivery.payload = {
|
||||
...delivery.payload,
|
||||
startedAt: entry.execution.startedAt,
|
||||
endedAt: entry.execution.endedAt,
|
||||
outcome: entry.execution.outcome,
|
||||
terminalReply: entry.completion?.terminalReply,
|
||||
};
|
||||
return true;
|
||||
};
|
||||
export const clearSubagentPendingDelivery = (entry: SubagentRunRecord) => {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
delivery.payload = undefined;
|
||||
delivery.createdAt = undefined;
|
||||
delivery.lastAttemptAt = undefined;
|
||||
delivery.attemptCount = undefined;
|
||||
delivery.lastError = undefined;
|
||||
delivery.suspendedAt = undefined;
|
||||
delivery.suspendedReason = undefined;
|
||||
if (delivery.status !== "delivered" && delivery.status !== "failed") {
|
||||
clearDeliveryState(entry);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadPendingFinalDeliveryPayload = (
|
||||
entry: SubagentRunRecord,
|
||||
): PendingFinalDeliveryPayload => {
|
||||
return {
|
||||
clearPendingFinalDelivery,
|
||||
emitCompletionEndedHookIfNeeded,
|
||||
formatAnnounceDeliveryError,
|
||||
freezeRunResultAtCompletion,
|
||||
hasPriorRequesterDeliveryMirror,
|
||||
loadPendingFinalDeliveryPayload,
|
||||
markPendingFinalDelivery,
|
||||
recordAnnounceDeliveryResult,
|
||||
refreshFrozenResultFromSession,
|
||||
refreshPendingFinalDeliveryPayload,
|
||||
safeFinalizeSubagentTaskRun,
|
||||
safeMarkRequiredCompletionDeliveryBlocked,
|
||||
safeSetSubagentTaskDeliveryStatus,
|
||||
requesterSessionKey: entry.delivery?.payload?.requesterSessionKey ?? entry.requesterSessionKey,
|
||||
requesterOrigin: entry.delivery?.payload?.requesterOrigin ?? entry.requesterOrigin,
|
||||
requesterDisplayKey: entry.delivery?.payload?.requesterDisplayKey ?? entry.requesterDisplayKey,
|
||||
childSessionKey: entry.delivery?.payload?.childSessionKey ?? entry.childSessionKey,
|
||||
childRunId: entry.delivery?.payload?.childRunId ?? entry.runId,
|
||||
task: entry.delivery?.payload?.task ?? entry.task,
|
||||
label: entry.delivery?.payload?.label ?? entry.label,
|
||||
startedAt: entry.delivery?.payload?.startedAt ?? entry.execution.startedAt,
|
||||
endedAt: entry.delivery?.payload?.endedAt ?? entry.execution.endedAt,
|
||||
outcome: entry.delivery?.payload?.outcome ?? entry.execution.outcome,
|
||||
expectsCompletionMessage:
|
||||
entry.delivery?.payload?.expectsCompletionMessage ?? entry.expectsCompletionMessage,
|
||||
spawnMode: entry.delivery?.payload?.spawnMode ?? entry.spawnMode,
|
||||
wakeOnDescendantSettle:
|
||||
entry.delivery?.payload?.wakeOnDescendantSettle ?? entry.wakeOnDescendantSettle,
|
||||
terminalReply: entry.delivery?.payload?.terminalReply ?? entry.completion?.terminalReply,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const markPendingFinalDelivery = (args: { entry: SubagentRunRecord; error?: string }) => {
|
||||
const now = Date.now();
|
||||
const payload: PendingFinalDeliveryPayload = loadPendingFinalDeliveryPayload(args.entry);
|
||||
|
||||
const delivery = ensureDeliveryState(args.entry);
|
||||
delivery.status = "pending";
|
||||
delivery.createdAt ??= now;
|
||||
delivery.lastAttemptAt = now;
|
||||
delivery.attemptCount = (delivery.attemptCount ?? 0) + 1;
|
||||
delivery.lastError = args.error ?? null;
|
||||
delivery.payload = payload;
|
||||
};
|
||||
|
||||
export const refreshPendingFinalDeliveryPayload = (entry: SubagentRunRecord): boolean => {
|
||||
const delivery = entry.delivery;
|
||||
if (
|
||||
!delivery?.payload ||
|
||||
delivery.status === "delivered" ||
|
||||
typeof delivery.announcedAt === "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
delivery.payload = {
|
||||
...delivery.payload,
|
||||
startedAt: entry.execution.startedAt,
|
||||
endedAt: entry.execution.endedAt,
|
||||
outcome: entry.execution.outcome,
|
||||
terminalReply: entry.completion?.terminalReply,
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import { runWithoutOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
|
||||
import { runWithGatewayIndependentRootWorkContinuation } from "../../../process/gateway-work-admission.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "../announce/subagent-announce-dispatch.js";
|
||||
import { ensureDeliveryState } from "./subagent-delivery-state.js";
|
||||
import type { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import type {
|
||||
SubagentRegistryLifecycleParams,
|
||||
SubagentRegistryLifecycleState,
|
||||
} from "./subagent-registry-lifecycle-contracts.js";
|
||||
import type { createSubagentRegistryLifecycleDelivery } from "./subagent-registry-lifecycle-delivery.js";
|
||||
import type { RequesterSettleWakeState, SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { hasSubagentRunEnded } from "./subagent-run-liveness.js";
|
||||
|
||||
type RequesterSettleWakeBatchState =
|
||||
import("../announce/subagent-announce.requester-settle-wake.js").RequesterSettleWakeBatchState;
|
||||
|
||||
export function createSubagentRegistryLifecycleRequesterWake(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
lifecycleState: SubagentRegistryLifecycleState,
|
||||
common: ReturnType<typeof createSubagentRegistryLifecycleCommon>,
|
||||
deliveryHelpers: ReturnType<typeof createSubagentRegistryLifecycleDelivery>,
|
||||
) {
|
||||
const {
|
||||
pendingRequesterSettleWakeRearms,
|
||||
scheduledRequesterSettleWakeRuns,
|
||||
scheduledRequesterSettleWakeTimers,
|
||||
} = lifecycleState;
|
||||
const { buildSafeLifecycleErrorMeta, maskRunId, maskSessionKey } = common;
|
||||
const { safeMarkRequiredCompletionDeliveryBlocked, safeSetSubagentTaskDeliveryStatus } =
|
||||
deliveryHelpers;
|
||||
|
||||
const transitionRequesterSettleWakeBatch = (
|
||||
runIds: readonly string[],
|
||||
state: RequesterSettleWakeBatchState,
|
||||
) => {
|
||||
const entries = runIds
|
||||
.map((runId) => params.runs.get(runId))
|
||||
.filter(
|
||||
(entry): entry is SubagentRunRecord =>
|
||||
Boolean(entry?.requesterSettleWake) &&
|
||||
entry?.requesterSettleWake?.rearmGeneration === state.rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const previousStates = entries.map((entry) => structuredClone(entry.requesterSettleWake));
|
||||
for (const entry of entries) {
|
||||
entry.requesterSettleWake = {
|
||||
...state,
|
||||
...(entry.requesterSettleWake?.retireAfterSettle === true
|
||||
? { retireAfterSettle: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(...entries.map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
entries.forEach((entry, index) => {
|
||||
entry.requesterSettleWake = previousStates[index];
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const completeRequesterSettleWakeBatch = (
|
||||
runIds: readonly string[],
|
||||
rearmGeneration?: number,
|
||||
outcome?: SubagentAnnounceDeliveryResult,
|
||||
) => {
|
||||
const entries = runIds
|
||||
.map((runId) => [runId, params.runs.get(runId)] as const)
|
||||
.filter(
|
||||
(pair): pair is readonly [string, SubagentRunRecord] =>
|
||||
Boolean(pair[1]?.requesterSettleWake) &&
|
||||
pair[1]?.requesterSettleWake?.rearmGeneration === rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requesterSessionKeys = new Set(entries.map(([, entry]) => entry.requesterSessionKey));
|
||||
const previousStates = entries.map(([, entry]) => ({
|
||||
delivery: structuredClone(entry.delivery),
|
||||
requesterSettleWake: structuredClone(entry.requesterSettleWake),
|
||||
retireAfterRequesterTurn: entry.retireAfterRequesterTurn,
|
||||
}));
|
||||
const settledDeliveries: SubagentRunRecord[] = [];
|
||||
for (const [runId, entry] of entries) {
|
||||
if (
|
||||
outcome &&
|
||||
entry.expectsCompletionMessage === true &&
|
||||
entry.delivery?.status !== "delivered"
|
||||
) {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
if (outcome.delivered) {
|
||||
const deliveredAt = outcome.deliveredAt ?? Date.now();
|
||||
delivery.status = "delivered";
|
||||
delivery.disposition = "delivered";
|
||||
delivery.deliveredAt = deliveredAt;
|
||||
delivery.announcedAt = deliveredAt;
|
||||
delivery.lastError = undefined;
|
||||
delivery.lastDropReason = undefined;
|
||||
} else {
|
||||
delivery.status = "failed";
|
||||
delivery.disposition = outcome.disposition ?? delivery.disposition;
|
||||
delivery.lastError = outcome.error ?? outcome.reason ?? "requester settle wake failed";
|
||||
delivery.deliveredAt = undefined;
|
||||
delivery.announcedAt = undefined;
|
||||
}
|
||||
settledDeliveries.push(entry);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(...entries.map(([runId]) => runId));
|
||||
} catch (error) {
|
||||
entries.forEach(([runId, entry], index) => {
|
||||
const previous = previousStates[index];
|
||||
params.runs.set(runId, entry);
|
||||
entry.delivery = previous?.delivery;
|
||||
entry.requesterSettleWake = previous?.requesterSettleWake;
|
||||
entry.retireAfterRequesterTurn = previous?.retireAfterRequesterTurn;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
for (const entry of settledDeliveries) {
|
||||
if (outcome?.delivered) {
|
||||
safeSetSubagentTaskDeliveryStatus({ entry, deliveryStatus: "delivered" });
|
||||
} else if (outcome) {
|
||||
const error = outcome.error ?? outcome.reason ?? "requester settle wake failed";
|
||||
safeSetSubagentTaskDeliveryStatus({
|
||||
entry,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: error,
|
||||
});
|
||||
safeMarkRequiredCompletionDeliveryBlocked({ entry, reason: error });
|
||||
}
|
||||
}
|
||||
for (const [runId, entry] of entries) {
|
||||
const retryTimer = scheduledRequesterSettleWakeTimers.get(runId);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer.timer);
|
||||
scheduledRequesterSettleWakeTimers.delete(runId);
|
||||
}
|
||||
if (entry.requesterSettleWake === undefined || !params.runs.has(runId)) {
|
||||
params.resumedRuns.delete(runId);
|
||||
params.clearPendingLifecycleError(runId);
|
||||
}
|
||||
}
|
||||
for (const [runId, entry] of params.runs) {
|
||||
if (entry.requesterSettleWake && requesterSessionKeys.has(entry.requesterSessionKey)) {
|
||||
scheduleRequesterSettleWake(runId, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markRequesterSettleWakePending = (
|
||||
entry: SubagentRunRecord,
|
||||
options?: { retireAfterSettle?: boolean },
|
||||
) => {
|
||||
const existing = entry.requesterSettleWake;
|
||||
entry.requesterSettleWake = {
|
||||
status: existing?.status ?? "pending",
|
||||
attemptCount: existing?.attemptCount ?? 0,
|
||||
...(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 }
|
||||
: {}),
|
||||
} satisfies RequesterSettleWakeState;
|
||||
};
|
||||
|
||||
const persistRequesterSettleWakePending = (
|
||||
entry: SubagentRunRecord,
|
||||
options?: {
|
||||
cleanupCompletedAt?: number;
|
||||
retireAfterSettle?: boolean;
|
||||
retireInterruptedRecovery?: boolean;
|
||||
},
|
||||
) => {
|
||||
const previousCleanupCompletedAt = entry.cleanupCompletedAt;
|
||||
const previousExecution = entry.execution;
|
||||
const previousTerminalOwner = entry.terminalOwner;
|
||||
const previousWake = structuredClone(entry.requesterSettleWake);
|
||||
if (options?.cleanupCompletedAt !== undefined) {
|
||||
entry.cleanupCompletedAt = options.cleanupCompletedAt;
|
||||
}
|
||||
if (options?.retireInterruptedRecovery) {
|
||||
entry.execution = {
|
||||
...entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
entry.terminalOwner = undefined;
|
||||
}
|
||||
markRequesterSettleWakePending(entry, options);
|
||||
try {
|
||||
params.persistOrThrow(entry.runId);
|
||||
} catch (error) {
|
||||
entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
entry.execution = previousExecution;
|
||||
entry.terminalOwner = previousTerminalOwner;
|
||||
entry.requesterSettleWake = previousWake;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Once a child reaches a terminal settle, let the announce layer decide
|
||||
// whether its requester's batch has fully drained and, if so, wake the
|
||||
// registry-less top-level requester to synthesize. Settle bookkeeping never
|
||||
// blocks on the wake, but the wake must run as tracked root work: a live
|
||||
// cleanup parent reserves the root synchronously, so restart or suspend
|
||||
// cannot reach quiescence between scheduling and the wake's gateway turn.
|
||||
// Failures are logged only.
|
||||
function retainScheduledRequesterSettleWakeTimer(
|
||||
runId: string,
|
||||
deadline: number,
|
||||
rearmGeneration?: number,
|
||||
): boolean {
|
||||
const scheduled = scheduledRequesterSettleWakeTimers.get(runId);
|
||||
if (!scheduled) {
|
||||
return false;
|
||||
}
|
||||
const hasNewerGeneration =
|
||||
rearmGeneration !== undefined &&
|
||||
(scheduled.rearmGeneration === undefined || rearmGeneration > scheduled.rearmGeneration);
|
||||
if (!hasNewerGeneration && deadline >= scheduled.deadline) {
|
||||
return true;
|
||||
}
|
||||
clearTimeout(scheduled.timer);
|
||||
scheduledRequesterSettleWakeTimers.delete(runId);
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleRequesterSettleWakeRetry(runId: string, entry: SubagentRunRecord): void {
|
||||
const nextAttemptAt = entry.requesterSettleWake?.nextAttemptAt;
|
||||
if (nextAttemptAt === undefined || nextAttemptAt <= Date.now()) {
|
||||
return;
|
||||
}
|
||||
const rearmGeneration = entry.requesterSettleWake?.rearmGeneration;
|
||||
if (retainScheduledRequesterSettleWakeTimer(runId, nextAttemptAt, rearmGeneration)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
if (scheduledRequesterSettleWakeTimers.get(runId)?.timer !== timer) {
|
||||
return;
|
||||
}
|
||||
scheduledRequesterSettleWakeTimers.delete(runId);
|
||||
const current = params.runs.get(runId);
|
||||
if (current === entry && current.requesterSettleWake) {
|
||||
scheduleRequesterSettleWake(runId, current);
|
||||
}
|
||||
},
|
||||
Math.max(0, nextAttemptAt - Date.now()),
|
||||
);
|
||||
timer.unref?.();
|
||||
scheduledRequesterSettleWakeTimers.set(runId, {
|
||||
timer,
|
||||
deadline: nextAttemptAt,
|
||||
rearmGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRequesterSettleWake(runId: string, entry: SubagentRunRecord): void {
|
||||
const requesterSessionKey = entry.requesterSessionKey?.trim();
|
||||
// A replayed lifecycle start can retain an older endedAt; require both
|
||||
// terminal status and end evidence so a live child never wakes its requester.
|
||||
if (
|
||||
entry.collect ||
|
||||
entry.execution.status === "running" ||
|
||||
!hasSubagentRunEnded(entry) ||
|
||||
!requesterSessionKey ||
|
||||
(entry.requesterTurnRunId && entry.requesterTurnYielded === true) ||
|
||||
scheduledRequesterSettleWakeRuns.has(runId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const nextAttemptAt = entry.requesterSettleWake?.nextAttemptAt;
|
||||
const deadline = nextAttemptAt !== undefined && nextAttemptAt > now ? nextAttemptAt : now;
|
||||
if (
|
||||
retainScheduledRequesterSettleWakeTimer(
|
||||
runId,
|
||||
deadline,
|
||||
entry.requesterSettleWake?.rearmGeneration,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextAttemptAt !== undefined && nextAttemptAt > now) {
|
||||
scheduleRequesterSettleWakeRetry(runId, entry);
|
||||
return;
|
||||
}
|
||||
scheduledRequesterSettleWakeRuns.add(runId);
|
||||
// Wake turns outlive their spawning attempt; clear its owner before both
|
||||
// dispatch and chained re-arms so transcript writes acquire a fresh lock.
|
||||
runWithoutOwnedSessionTranscriptWrites(() => {
|
||||
void runWithGatewayIndependentRootWorkContinuation(() =>
|
||||
params.maybeWakeRequesterAfterAllChildrenSettled({
|
||||
requesterSessionKey,
|
||||
requesterOrigin: entry.requesterOrigin,
|
||||
settledEntry: entry,
|
||||
transitionBatch: transitionRequesterSettleWakeBatch,
|
||||
completeBatch: completeRequesterSettleWakeBatch,
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
params.warn("requester settle wake failed", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskRunId(runId),
|
||||
requesterSessionKey: maskSessionKey(requesterSessionKey),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
scheduledRequesterSettleWakeRuns.delete(runId);
|
||||
const wasRearmedWhileRunning = pendingRequesterSettleWakeRearms.delete(runId);
|
||||
const current = params.runs.get(runId);
|
||||
if (current === entry && current.requesterSettleWake) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
markRequesterSettleWakePending,
|
||||
persistRequesterSettleWakePending,
|
||||
scheduleRequesterSettleWake,
|
||||
};
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import type { createSubagentRegistryLifecycleCleanupBase } from "./subagent-registry-lifecycle-cleanup-base.js";
|
||||
import type { createSubagentRegistryLifecycleCleanup } from "./subagent-registry-lifecycle-cleanup.js";
|
||||
import type { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import { loadCleanupBrowserSessionsForLifecycleEnd } from "./subagent-registry-lifecycle-completion-support.js";
|
||||
import type { SubagentRegistryLifecycleParams } from "./subagent-registry-lifecycle-contracts.js";
|
||||
import type { SubagentCompletionRequest, SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
export function createSubagentRegistryLifecycleTerminalCleanup(
|
||||
params: SubagentRegistryLifecycleParams,
|
||||
common: ReturnType<typeof createSubagentRegistryLifecycleCommon>,
|
||||
cleanupBase: ReturnType<typeof createSubagentRegistryLifecycleCleanupBase>,
|
||||
cleanup: ReturnType<typeof createSubagentRegistryLifecycleCleanup>,
|
||||
) {
|
||||
const { buildSafeLifecycleErrorMeta, maskRunId, maskSessionKey, newerGenerationOwnsSession } =
|
||||
common;
|
||||
const { isTerminalCallbackCurrent } = cleanupBase;
|
||||
const { retireRunModeBundleMcpRuntime, startSubagentAnnounceCleanupFlow } = cleanup;
|
||||
|
||||
const complete = async (args: {
|
||||
completeParams: SubagentCompletionRequest;
|
||||
entry: SubagentRunRecord;
|
||||
isProvisionalKill: boolean;
|
||||
retireSupersededSession: (entry: SubagentRunRecord) => Promise<void>;
|
||||
suppressedForSteerRestart: boolean;
|
||||
suppressSessionEffects: boolean;
|
||||
terminalGeneration: number;
|
||||
}) => {
|
||||
const {
|
||||
completeParams,
|
||||
entry,
|
||||
isProvisionalKill,
|
||||
retireSupersededSession,
|
||||
suppressedForSteerRestart,
|
||||
terminalGeneration,
|
||||
} = args;
|
||||
let { suppressSessionEffects } = args;
|
||||
// Session cleanup belongs to the exact registry row and child generation.
|
||||
// A replacement may reuse either the run id or the child session key.
|
||||
const isSessionEffectsOwnerCurrent = () =>
|
||||
isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration) &&
|
||||
!newerGenerationOwnsSession(entry);
|
||||
const refreshSessionEffectsSuppression = () => {
|
||||
if (
|
||||
suppressSessionEffects ||
|
||||
!isSessionEffectsOwnerCurrent() ||
|
||||
!shouldSuppressSubagentRecoverySessionEffects(entry)
|
||||
) {
|
||||
return suppressSessionEffects;
|
||||
}
|
||||
const previousExecution = entry.execution;
|
||||
entry.execution = {
|
||||
...previousExecution,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
try {
|
||||
params.persistOrThrow(completeParams.runId);
|
||||
} catch (error) {
|
||||
entry.execution = previousExecution;
|
||||
throw error;
|
||||
}
|
||||
suppressSessionEffects = true;
|
||||
return true;
|
||||
};
|
||||
if (!completeParams.triggerCleanup || suppressedForSteerRestart) {
|
||||
return;
|
||||
}
|
||||
refreshSessionEffectsSuppression();
|
||||
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
|
||||
return;
|
||||
}
|
||||
if (newerGenerationOwnsSession(entry)) {
|
||||
await retireSupersededSession(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
// registerSubagentRun fires both an in-process listener and a gateway
|
||||
// waitForSubagentCompletion RPC; both can reach this point for the same
|
||||
// runId in embedded mode. Dedupe only the browser driver tab-close IPC
|
||||
// with a sync check-then-set. The retire + announce tail below must still
|
||||
// run for every caller, so a slow or held first browser cleanup cannot
|
||||
// strand a duplicate caller's completion behind it.
|
||||
if (!suppressSessionEffects && entry.browserCleanupDispatchedAt === undefined) {
|
||||
let dispatchedBrowserCleanup = false;
|
||||
let cleanupBrowserSessions = params.cleanupBrowserSessionsForLifecycleEnd;
|
||||
try {
|
||||
cleanupBrowserSessions ??= await loadCleanupBrowserSessionsForLifecycleEnd();
|
||||
} catch (error) {
|
||||
params.warn("failed to load browser cleanup for completed subagent", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskRunId(completeParams.runId),
|
||||
childSessionKey: maskSessionKey(entry.childSessionKey),
|
||||
});
|
||||
}
|
||||
if (cleanupBrowserSessions) {
|
||||
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
|
||||
return;
|
||||
}
|
||||
if (newerGenerationOwnsSession(entry)) {
|
||||
await retireSupersededSession(entry);
|
||||
return;
|
||||
}
|
||||
if (refreshSessionEffectsSuppression()) {
|
||||
return;
|
||||
}
|
||||
// Claim only when this caller is about to dispatch. A concurrent caller
|
||||
// may have claimed while the lazy browser module was loading.
|
||||
if (entry.browserCleanupDispatchedAt === undefined) {
|
||||
entry.browserCleanupDispatchedAt = Date.now();
|
||||
dispatchedBrowserCleanup = true;
|
||||
try {
|
||||
await cleanupBrowserSessions({
|
||||
sessionKeys: [entry.childSessionKey],
|
||||
onWarn: (msg) => params.warn(msg, { runId: entry.runId }),
|
||||
});
|
||||
} catch (error) {
|
||||
params.warn("failed to cleanup browser sessions for completed subagent", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskRunId(completeParams.runId),
|
||||
childSessionKey: maskSessionKey(entry.childSessionKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dispatchedBrowserCleanup) {
|
||||
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
|
||||
return;
|
||||
}
|
||||
refreshSessionEffectsSuppression();
|
||||
if (newerGenerationOwnsSession(entry)) {
|
||||
await retireSupersededSession(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!suppressSessionEffects) {
|
||||
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
|
||||
return;
|
||||
}
|
||||
if (newerGenerationOwnsSession(entry)) {
|
||||
await retireSupersededSession(entry);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await retireRunModeBundleMcpRuntime({
|
||||
runId: completeParams.runId,
|
||||
entry,
|
||||
reason: "subagent-run-complete",
|
||||
});
|
||||
} catch (error) {
|
||||
params.warn("failed to retire subagent bundle MCP runtime after completion", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskRunId(completeParams.runId),
|
||||
childSessionKey: maskSessionKey(entry.childSessionKey),
|
||||
});
|
||||
}
|
||||
if (!isTerminalCallbackCurrent(completeParams.runId, entry, terminalGeneration)) {
|
||||
return;
|
||||
}
|
||||
refreshSessionEffectsSuppression();
|
||||
if (newerGenerationOwnsSession(entry)) {
|
||||
await retireSupersededSession(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isProvisionalKill) {
|
||||
// Browser and MCP resources can close immediately, but completion delivery
|
||||
// waits for the provider result or the killed tombstone reconciliation.
|
||||
return;
|
||||
}
|
||||
|
||||
refreshSessionEffectsSuppression();
|
||||
startSubagentAnnounceCleanupFlow(completeParams.runId, entry);
|
||||
};
|
||||
|
||||
return { complete };
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { runWithoutOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
|
||||
import { runWithGatewayIndependentRootWorkContinuation } from "../../../process/gateway-work-admission.js";
|
||||
import { runWithGatewayIndependentRootWorkAdmission } from "../../../process/gateway-work-admission.js";
|
||||
import { defaultRuntime } from "../../../runtime.js";
|
||||
import { retireSessionMcpRuntimeForSessionKey } from "../../agent-bundle-mcp-tools.js";
|
||||
import { removeInternalSessionEffectsSession } from "../../internal-session-effects.js";
|
||||
import type { SubagentAnnounceDeliveryResult } from "../announce/subagent-announce-dispatch.js";
|
||||
import { ensureDeliveryState } from "./subagent-delivery-state.js";
|
||||
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import type {
|
||||
CleanupBookkeepingParams,
|
||||
SubagentLifecycleWakeContext,
|
||||
} from "./subagent-registry-lifecycle-context.js";
|
||||
import {
|
||||
buildSafeLifecycleErrorMeta,
|
||||
maskLifecycleIdentifier,
|
||||
safeMarkRequiredCompletionDeliveryBlocked,
|
||||
safeSetSubagentTaskDeliveryStatus,
|
||||
} from "./subagent-registry-lifecycle-delivery.js";
|
||||
import type { RequesterSettleWakeState, SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { hasSubagentRunEnded } from "./subagent-run-liveness.js";
|
||||
|
||||
type RequesterSettleWakeBatchState =
|
||||
import("../announce/subagent-announce.requester-settle-wake.js").RequesterSettleWakeBatchState;
|
||||
|
||||
const transitionRequesterSettleWakeBatch = (
|
||||
context: SubagentLifecycleWakeContext,
|
||||
runIds: readonly string[],
|
||||
state: RequesterSettleWakeBatchState,
|
||||
) => {
|
||||
const params = context.options;
|
||||
const entries = runIds
|
||||
.map((runId) => params.runs.get(runId))
|
||||
.filter(
|
||||
(entry): entry is SubagentRunRecord =>
|
||||
Boolean(entry?.requesterSettleWake) &&
|
||||
entry?.requesterSettleWake?.rearmGeneration === state.rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const previousStates = entries.map((entry) => structuredClone(entry.requesterSettleWake));
|
||||
for (const entry of entries) {
|
||||
entry.requesterSettleWake = {
|
||||
...state,
|
||||
...(entry.requesterSettleWake?.retireAfterSettle === true ? { retireAfterSettle: true } : {}),
|
||||
};
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(...entries.map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
entries.forEach((entry, index) => {
|
||||
entry.requesterSettleWake = previousStates[index];
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const completeRequesterSettleWakeBatch = (
|
||||
context: SubagentLifecycleWakeContext,
|
||||
runIds: readonly string[],
|
||||
rearmGeneration?: number,
|
||||
outcome?: SubagentAnnounceDeliveryResult,
|
||||
) => {
|
||||
const params = context.options;
|
||||
const entries = runIds
|
||||
.map((runId) => [runId, params.runs.get(runId)] as const)
|
||||
.filter(
|
||||
(pair): pair is readonly [string, SubagentRunRecord] =>
|
||||
Boolean(pair[1]?.requesterSettleWake) &&
|
||||
pair[1]?.requesterSettleWake?.rearmGeneration === rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requesterSessionKeys = new Set(entries.map(([, entry]) => entry.requesterSessionKey));
|
||||
const previousStates = entries.map(([, entry]) => ({
|
||||
delivery: structuredClone(entry.delivery),
|
||||
requesterSettleWake: structuredClone(entry.requesterSettleWake),
|
||||
retireAfterRequesterTurn: entry.retireAfterRequesterTurn,
|
||||
}));
|
||||
const settledDeliveries: SubagentRunRecord[] = [];
|
||||
for (const [runId, entry] of entries) {
|
||||
if (
|
||||
outcome &&
|
||||
entry.expectsCompletionMessage === true &&
|
||||
entry.delivery?.status !== "delivered"
|
||||
) {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
if (outcome.delivered) {
|
||||
const deliveredAt = outcome.deliveredAt ?? Date.now();
|
||||
delivery.status = "delivered";
|
||||
delivery.disposition = "delivered";
|
||||
delivery.deliveredAt = deliveredAt;
|
||||
delivery.announcedAt = deliveredAt;
|
||||
delivery.lastError = undefined;
|
||||
delivery.lastDropReason = undefined;
|
||||
} else {
|
||||
delivery.status = "failed";
|
||||
delivery.disposition = outcome.disposition ?? delivery.disposition;
|
||||
delivery.lastError = outcome.error ?? outcome.reason ?? "requester settle wake failed";
|
||||
delivery.deliveredAt = undefined;
|
||||
delivery.announcedAt = undefined;
|
||||
}
|
||||
settledDeliveries.push(entry);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(...entries.map(([runId]) => runId));
|
||||
} catch (error) {
|
||||
entries.forEach(([runId, entry], index) => {
|
||||
const previous = previousStates[index];
|
||||
params.runs.set(runId, entry);
|
||||
entry.delivery = previous?.delivery;
|
||||
entry.requesterSettleWake = previous?.requesterSettleWake;
|
||||
entry.retireAfterRequesterTurn = previous?.retireAfterRequesterTurn;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
for (const entry of settledDeliveries) {
|
||||
if (outcome?.delivered) {
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "delivered",
|
||||
});
|
||||
} else if (outcome) {
|
||||
const error = outcome.error ?? outcome.reason ?? "requester settle wake failed";
|
||||
safeSetSubagentTaskDeliveryStatus(params, {
|
||||
entry,
|
||||
deliveryStatus: "failed",
|
||||
deliveryError: error,
|
||||
});
|
||||
safeMarkRequiredCompletionDeliveryBlocked(params, { entry, reason: error });
|
||||
}
|
||||
}
|
||||
for (const [runId, entry] of entries) {
|
||||
const retryTimer = context.getRequesterSettleWakeTimer(runId);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer.timer);
|
||||
context.deleteRequesterSettleWakeTimer(runId);
|
||||
}
|
||||
if (entry.requesterSettleWake === undefined || !params.runs.has(runId)) {
|
||||
params.resumedRuns.delete(runId);
|
||||
params.clearPendingLifecycleError(runId);
|
||||
}
|
||||
}
|
||||
for (const [runId, entry] of params.runs) {
|
||||
if (entry.requesterSettleWake && requesterSessionKeys.has(entry.requesterSessionKey)) {
|
||||
scheduleRequesterSettleWake(context, runId, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const markRequesterSettleWakePending = (
|
||||
entry: SubagentRunRecord,
|
||||
options?: { retireAfterSettle?: boolean },
|
||||
) => {
|
||||
const existing = entry.requesterSettleWake;
|
||||
entry.requesterSettleWake = {
|
||||
status: existing?.status ?? "pending",
|
||||
attemptCount: existing?.attemptCount ?? 0,
|
||||
...(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 }
|
||||
: {}),
|
||||
} satisfies RequesterSettleWakeState;
|
||||
};
|
||||
|
||||
const persistRequesterSettleWakePending = (
|
||||
context: SubagentLifecycleWakeContext,
|
||||
entry: SubagentRunRecord,
|
||||
options?: {
|
||||
cleanupCompletedAt?: number;
|
||||
retireAfterSettle?: boolean;
|
||||
retireInterruptedRecovery?: boolean;
|
||||
},
|
||||
) => {
|
||||
const params = context.options;
|
||||
const previousCleanupCompletedAt = entry.cleanupCompletedAt;
|
||||
const previousExecution = entry.execution;
|
||||
const previousTerminalOwner = entry.terminalOwner;
|
||||
const previousWake = structuredClone(entry.requesterSettleWake);
|
||||
if (options?.cleanupCompletedAt !== undefined) {
|
||||
entry.cleanupCompletedAt = options.cleanupCompletedAt;
|
||||
}
|
||||
if (options?.retireInterruptedRecovery) {
|
||||
entry.execution = {
|
||||
...entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
entry.terminalOwner = undefined;
|
||||
}
|
||||
markRequesterSettleWakePending(entry, options);
|
||||
try {
|
||||
params.persistOrThrow(entry.runId);
|
||||
} catch (error) {
|
||||
entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
entry.execution = previousExecution;
|
||||
entry.terminalOwner = previousTerminalOwner;
|
||||
entry.requesterSettleWake = previousWake;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Once a child reaches a terminal settle, let the announce layer decide
|
||||
// whether its requester's batch has fully drained and, if so, wake the
|
||||
// registry-less top-level requester to synthesize. Settle bookkeeping never
|
||||
// blocks on the wake, but the wake must run as tracked root work: a live
|
||||
// cleanup parent reserves the root synchronously, so restart or suspend
|
||||
// cannot reach quiescence between scheduling and the wake's gateway turn.
|
||||
// Failures are logged only.
|
||||
function retainScheduledRequesterSettleWakeTimer(
|
||||
context: SubagentLifecycleWakeContext,
|
||||
runId: string,
|
||||
deadline: number,
|
||||
rearmGeneration?: number,
|
||||
): boolean {
|
||||
const scheduled = context.getRequesterSettleWakeTimer(runId);
|
||||
if (!scheduled) {
|
||||
return false;
|
||||
}
|
||||
const hasNewerGeneration =
|
||||
rearmGeneration !== undefined &&
|
||||
(scheduled.rearmGeneration === undefined || rearmGeneration > scheduled.rearmGeneration);
|
||||
if (!hasNewerGeneration && deadline >= scheduled.deadline) {
|
||||
return true;
|
||||
}
|
||||
clearTimeout(scheduled.timer);
|
||||
context.deleteRequesterSettleWakeTimer(runId);
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleRequesterSettleWakeRetry(
|
||||
context: SubagentLifecycleWakeContext,
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
): void {
|
||||
const params = context.options;
|
||||
const nextAttemptAt = entry.requesterSettleWake?.nextAttemptAt;
|
||||
if (nextAttemptAt === undefined || nextAttemptAt <= Date.now()) {
|
||||
return;
|
||||
}
|
||||
const rearmGeneration = entry.requesterSettleWake?.rearmGeneration;
|
||||
if (retainScheduledRequesterSettleWakeTimer(context, runId, nextAttemptAt, rearmGeneration)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
if (context.getRequesterSettleWakeTimer(runId)?.timer !== timer) {
|
||||
return;
|
||||
}
|
||||
context.deleteRequesterSettleWakeTimer(runId);
|
||||
const current = params.runs.get(runId);
|
||||
if (current === entry && current.requesterSettleWake) {
|
||||
scheduleRequesterSettleWake(context, runId, current);
|
||||
}
|
||||
},
|
||||
Math.max(0, nextAttemptAt - Date.now()),
|
||||
);
|
||||
timer.unref?.();
|
||||
context.setRequesterSettleWakeTimer(runId, {
|
||||
timer,
|
||||
deadline: nextAttemptAt,
|
||||
rearmGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleRequesterSettleWake(
|
||||
context: SubagentLifecycleWakeContext,
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
): void {
|
||||
const params = context.options;
|
||||
const requesterSessionKey = entry.requesterSessionKey?.trim();
|
||||
// A replayed lifecycle start can retain an older endedAt; require both
|
||||
// terminal status and end evidence so a live child never wakes its requester.
|
||||
if (
|
||||
entry.collect ||
|
||||
entry.execution.status === "running" ||
|
||||
!hasSubagentRunEnded(entry) ||
|
||||
!requesterSessionKey ||
|
||||
(entry.requesterTurnRunId && entry.requesterTurnYielded === true) ||
|
||||
context.hasScheduledRequesterSettleWakeRun(runId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const nextAttemptAt = entry.requesterSettleWake?.nextAttemptAt;
|
||||
const deadline = nextAttemptAt !== undefined && nextAttemptAt > now ? nextAttemptAt : now;
|
||||
if (
|
||||
retainScheduledRequesterSettleWakeTimer(
|
||||
context,
|
||||
runId,
|
||||
deadline,
|
||||
entry.requesterSettleWake?.rearmGeneration,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (nextAttemptAt !== undefined && nextAttemptAt > now) {
|
||||
scheduleRequesterSettleWakeRetry(context, runId, entry);
|
||||
return;
|
||||
}
|
||||
context.markRequesterSettleWakeRunScheduled(runId);
|
||||
// Wake turns outlive their spawning attempt; clear its owner before both
|
||||
// dispatch and chained re-arms so transcript writes acquire a fresh lock.
|
||||
runWithoutOwnedSessionTranscriptWrites(() => {
|
||||
void runWithGatewayIndependentRootWorkContinuation(() =>
|
||||
params.maybeWakeRequesterAfterAllChildrenSettled({
|
||||
requesterSessionKey,
|
||||
requesterOrigin: entry.requesterOrigin,
|
||||
settledEntry: entry,
|
||||
transitionBatch: (runIds, state) =>
|
||||
transitionRequesterSettleWakeBatch(context, runIds, state),
|
||||
completeBatch: (runIds, rearmGeneration, outcome) =>
|
||||
completeRequesterSettleWakeBatch(context, runIds, rearmGeneration, outcome),
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
params.warn("requester settle wake failed", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
runId: maskLifecycleIdentifier(runId, "run"),
|
||||
requesterSessionKey: maskLifecycleIdentifier(requesterSessionKey, "session"),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
context.unmarkRequesterSettleWakeRunScheduled(runId);
|
||||
const wasRearmedWhileRunning = context.takeRequesterSettleWakeRearm(runId);
|
||||
const current = params.runs.get(runId);
|
||||
if (current === entry && current.requesterSettleWake) {
|
||||
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(context, runId, current);
|
||||
} else {
|
||||
scheduleRequesterSettleWakeRetry(context, runId, current);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function completeCleanupBookkeeping(
|
||||
context: SubagentLifecycleWakeContext,
|
||||
cleanupParams: CleanupBookkeepingParams,
|
||||
retryDeferredCompletedAnnounces: (excludeRunId?: string) => void,
|
||||
): void {
|
||||
const params = context.options;
|
||||
const suppressSessionEffects = shouldSuppressSubagentRecoverySessionEffects(cleanupParams.entry);
|
||||
const runCleanupTail = (label: string, run: () => Promise<unknown>) => {
|
||||
// These best-effort tails can outlive the durable registry transition,
|
||||
// but they still mutate session-owned resources and must block snapshots.
|
||||
void runWithGatewayIndependentRootWorkAdmission(run).catch((error: unknown) => {
|
||||
defaultRuntime.log(
|
||||
`[warn] subagent ${label} failed (${cleanupParams.runId}): ${String(error)}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
const scheduleCleanupTails = (options: {
|
||||
allowRetiredRow: boolean;
|
||||
isDeleteCleanup: boolean;
|
||||
}) => {
|
||||
// Retained bookkeeping requires the exact row. Immediate retirement
|
||||
// removes it first, so absence remains ownership only while no newer
|
||||
// child generation exists; any replacement blocks the stale cleanup.
|
||||
const postBookkeepingEffectsAllowed = () => {
|
||||
const current = params.runs.get(cleanupParams.runId);
|
||||
const rowOwnershipMatches =
|
||||
current === cleanupParams.entry || (options.allowRetiredRow && current === undefined);
|
||||
return (
|
||||
rowOwnershipMatches &&
|
||||
!context.newerGenerationOwnsSession(cleanupParams.entry) &&
|
||||
!shouldSuppressSubagentRecoverySessionEffects(cleanupParams.entry)
|
||||
);
|
||||
};
|
||||
if (postBookkeepingEffectsAllowed() && !cleanupParams.preserveTranscript) {
|
||||
runCleanupTail("session cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await removeInternalSessionEffectsSession(cleanupParams.entry.execution.transcriptTarget);
|
||||
});
|
||||
}
|
||||
if (postBookkeepingEffectsAllowed() && cleanupParams.entry.spawnMode !== "session") {
|
||||
runCleanupTail("bundle MCP cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await retireSessionMcpRuntimeForSessionKey({
|
||||
sessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: "subagent-run-cleanup",
|
||||
preserveActiveLeases: true,
|
||||
onError: (error, sessionId) => {
|
||||
params.warn("failed to retire subagent bundle MCP runtime", {
|
||||
error: buildSafeLifecycleErrorMeta(error),
|
||||
sessionId,
|
||||
runId: maskLifecycleIdentifier(cleanupParams.runId, "run"),
|
||||
childSessionKey: maskLifecycleIdentifier(
|
||||
cleanupParams.entry.childSessionKey,
|
||||
"session",
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
if (
|
||||
!cleanupParams.provisionalKill &&
|
||||
postBookkeepingEffectsAllowed() &&
|
||||
(options.isDeleteCleanup || !cleanupParams.entry.collect)
|
||||
) {
|
||||
runCleanupTail("context-engine cleanup", async () => {
|
||||
if (!postBookkeepingEffectsAllowed()) {
|
||||
return;
|
||||
}
|
||||
await params.notifyContextEngineSubagentEnded(
|
||||
{
|
||||
childSessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: options.isDeleteCleanup ? "deleted" : "completed",
|
||||
agentDir: cleanupParams.entry.agentDir,
|
||||
workspaceDir: cleanupParams.entry.workspaceDir,
|
||||
},
|
||||
{ isCurrent: postBookkeepingEffectsAllowed },
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
if (cleanupParams.provisionalKill) {
|
||||
// The provider result or bounded kill reconciliation owns terminal settle.
|
||||
// Its kill marker was committed by the caller before reaching this tail.
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup: false });
|
||||
return;
|
||||
}
|
||||
const isDeleteCleanup = cleanupParams.cleanup === "delete";
|
||||
if (isDeleteCleanup) {
|
||||
params.clearPendingLifecycleError(cleanupParams.runId);
|
||||
}
|
||||
if (cleanupParams.entry.collect) {
|
||||
// Delete-mode session cleanup already ran before this durable bookkeeping.
|
||||
// Preserve only the collector result tombstone for waits and group caps.
|
||||
const previousCleanupCompletedAt = cleanupParams.entry.cleanupCompletedAt;
|
||||
const previousExecution = cleanupParams.entry.execution;
|
||||
const previousRequesterSettleWake = cleanupParams.entry.requesterSettleWake;
|
||||
const previousTerminalOwner = cleanupParams.entry.terminalOwner;
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
cleanupParams.entry.requesterSettleWake = undefined;
|
||||
if (suppressSessionEffects) {
|
||||
cleanupParams.entry.execution = {
|
||||
...cleanupParams.entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
cleanupParams.entry.terminalOwner = undefined;
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
cleanupParams.entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
cleanupParams.entry.execution = previousExecution;
|
||||
cleanupParams.entry.requesterSettleWake = previousRequesterSettleWake;
|
||||
cleanupParams.entry.terminalOwner = previousTerminalOwner;
|
||||
throw error;
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
const retireAfterSettle =
|
||||
isDeleteCleanup ||
|
||||
(cleanupParams.entry.endedReason === SUBAGENT_ENDED_REASON_KILLED &&
|
||||
cleanupParams.entry.suppressAnnounceReason !== "killed");
|
||||
if (retireAfterSettle) {
|
||||
// Reconciled keep-mode kills retire the registry row, not the child session.
|
||||
if (!isDeleteCleanup) {
|
||||
params.clearPendingLifecycleError(cleanupParams.runId);
|
||||
}
|
||||
if (cleanupParams.skipRequesterSettleWake) {
|
||||
params.runs.delete(cleanupParams.runId);
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
params.runs.set(cleanupParams.runId, cleanupParams.entry);
|
||||
throw error;
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: true, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
persistRequesterSettleWakePending(context, cleanupParams.entry, {
|
||||
cleanupCompletedAt: cleanupParams.completedAt,
|
||||
retireAfterSettle: true,
|
||||
retireInterruptedRecovery: suppressSessionEffects,
|
||||
});
|
||||
// The settle wake may synchronously retire this durably marked row before
|
||||
// the detached tails start. Absence is still stale-safe because any
|
||||
// replacement row or newer child generation rejects the cleanup.
|
||||
scheduleCleanupTails({ allowRetiredRow: true, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
scheduleRequesterSettleWake(context, cleanupParams.runId, cleanupParams.entry);
|
||||
return;
|
||||
}
|
||||
if (!cleanupParams.skipRequesterSettleWake) {
|
||||
persistRequesterSettleWakePending(context, cleanupParams.entry, {
|
||||
cleanupCompletedAt: cleanupParams.completedAt,
|
||||
retireInterruptedRecovery: suppressSessionEffects,
|
||||
});
|
||||
} else {
|
||||
const previousCleanupCompletedAt = cleanupParams.entry.cleanupCompletedAt;
|
||||
const previousExecution = cleanupParams.entry.execution;
|
||||
const previousTerminalOwner = cleanupParams.entry.terminalOwner;
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
if (suppressSessionEffects) {
|
||||
cleanupParams.entry.execution = {
|
||||
...cleanupParams.entry.execution,
|
||||
restartRecovery: undefined,
|
||||
suppressSessionEffects: true,
|
||||
};
|
||||
cleanupParams.entry.terminalOwner = undefined;
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow(cleanupParams.runId);
|
||||
} catch (error) {
|
||||
cleanupParams.entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
cleanupParams.entry.execution = previousExecution;
|
||||
cleanupParams.entry.terminalOwner = previousTerminalOwner;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
if (!cleanupParams.skipRequesterSettleWake) {
|
||||
scheduleRequesterSettleWake(context, cleanupParams.runId, cleanupParams.entry);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,15 @@ import {
|
||||
SUBAGENT_ENDED_REASON_KILLED,
|
||||
} from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import {
|
||||
SubagentLifecycleController,
|
||||
type SubagentLifecycleOptions,
|
||||
} from "./subagent-registry-lifecycle.js";
|
||||
import { markSubagentRunPausedAfterYield } from "./subagent-registry-run-manager.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
type LifecycleControllerParams = Parameters<typeof createSubagentRegistryLifecycleController>[0];
|
||||
type LifecycleController = ReturnType<typeof createSubagentRegistryLifecycleController>;
|
||||
type LifecycleControllerParams = SubagentLifecycleOptions;
|
||||
type LifecycleController = SubagentLifecycleController;
|
||||
type SubagentCompletionParams = Parameters<LifecycleController["completeSubagentRun"]>[0];
|
||||
type AnnounceFlowOutcome = Awaited<
|
||||
ReturnType<LifecycleControllerParams["runSubagentAnnounceFlow"]>
|
||||
@@ -109,10 +112,6 @@ const browserLifecycleCleanupMocks = vi.hoisted(() => ({
|
||||
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const completionSupportMocks = vi.hoisted(() => ({
|
||||
loadCleanupBrowserSessionsForLifecycleEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
const bundleMcpRuntimeMocks = vi.hoisted(() => ({
|
||||
retireSessionMcpRuntimeForSessionKey: vi.fn(async () => true),
|
||||
}));
|
||||
@@ -140,12 +139,6 @@ vi.mock("../../../browser-lifecycle-cleanup.js", () => ({
|
||||
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-registry-lifecycle-completion-support.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./subagent-registry-lifecycle-completion-support.js")>()),
|
||||
loadCleanupBrowserSessionsForLifecycleEnd:
|
||||
completionSupportMocks.loadCleanupBrowserSessionsForLifecycleEnd,
|
||||
}));
|
||||
|
||||
vi.mock("../../agent-bundle-mcp-tools.js", () => ({
|
||||
retireSessionMcpRuntimeForSessionKey: bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey,
|
||||
}));
|
||||
@@ -373,7 +366,7 @@ function createLifecycleController({
|
||||
}: {
|
||||
entry: SubagentRunRecord;
|
||||
runs?: Map<string, SubagentRunRecord>;
|
||||
} & Partial<Parameters<typeof createSubagentRegistryLifecycleController>[0]>) {
|
||||
} & Partial<SubagentLifecycleOptions>) {
|
||||
const params: LifecycleControllerParams = {
|
||||
runs,
|
||||
resumedRuns: new Set(),
|
||||
@@ -394,6 +387,8 @@ function createLifecycleController({
|
||||
callGateway: async <T = Record<string, unknown>>(opts: CallGatewayOptions): Promise<T> =>
|
||||
(await gatewayMocks.callGateway(opts)) as T,
|
||||
captureSubagentCompletionReply: vi.fn(async () => "final completion reply"),
|
||||
cleanupBrowserSessionsForLifecycleEnd:
|
||||
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
maybeWakeRequesterAfterAllChildrenSettled: vi.fn(
|
||||
async (wakeParams: {
|
||||
@@ -407,7 +402,7 @@ function createLifecycleController({
|
||||
warn: vi.fn(),
|
||||
};
|
||||
Object.assign(params, overrides);
|
||||
return createSubagentRegistryLifecycleController(params);
|
||||
return new SubagentLifecycleController(params);
|
||||
}
|
||||
|
||||
function completeRun(
|
||||
@@ -484,9 +479,6 @@ describe("subagent registry lifecycle hardening", () => {
|
||||
gatewayMocks.callGateway.mockReset();
|
||||
gatewayMocks.callGateway.mockResolvedValue({});
|
||||
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd.mockClear();
|
||||
completionSupportMocks.loadCleanupBrowserSessionsForLifecycleEnd
|
||||
.mockReset()
|
||||
.mockResolvedValue(browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd);
|
||||
bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey.mockClear();
|
||||
bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey.mockResolvedValue(true);
|
||||
internalSessionEffectsMocks.removeInternalSessionEffectsSession.mockClear();
|
||||
@@ -2684,14 +2676,17 @@ describe("subagent registry lifecycle hardening", () => {
|
||||
const browserLoaderRelease = new Promise<void>((resolve) => {
|
||||
releaseBrowserLoader = resolve;
|
||||
});
|
||||
completionSupportMocks.loadCleanupBrowserSessionsForLifecycleEnd.mockImplementationOnce(
|
||||
async () => {
|
||||
markBrowserLoaderEntered();
|
||||
await browserLoaderRelease;
|
||||
return browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd;
|
||||
},
|
||||
);
|
||||
const controller = createLifecycleController({ entry, runs });
|
||||
const loadCleanupBrowserSessionsForLifecycleEnd = vi.fn(async () => {
|
||||
markBrowserLoaderEntered();
|
||||
await browserLoaderRelease;
|
||||
return browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd;
|
||||
});
|
||||
const controller = createLifecycleController({
|
||||
entry,
|
||||
runs,
|
||||
cleanupBrowserSessionsForLifecycleEnd: undefined,
|
||||
loadCleanupBrowserSessionsForLifecycleEnd,
|
||||
});
|
||||
|
||||
const completion = completeRun(controller, entry, { triggerCleanup: true });
|
||||
await browserLoaderEntered;
|
||||
@@ -2726,19 +2721,22 @@ describe("subagent registry lifecycle hardening", () => {
|
||||
const browserLoaderRelease = new Promise<void>((resolve) => {
|
||||
releaseBrowserLoader = resolve;
|
||||
});
|
||||
completionSupportMocks.loadCleanupBrowserSessionsForLifecycleEnd.mockImplementationOnce(
|
||||
async () => {
|
||||
markBrowserLoaderEntered();
|
||||
await browserLoaderRelease;
|
||||
return browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd;
|
||||
},
|
||||
);
|
||||
const loadCleanupBrowserSessionsForLifecycleEnd = vi.fn(async () => {
|
||||
markBrowserLoaderEntered();
|
||||
await browserLoaderRelease;
|
||||
return browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd;
|
||||
});
|
||||
const persistOrThrow = vi.fn(() => {
|
||||
if (entry.execution.suppressSessionEffects === true) {
|
||||
throw new Error("suppression persistence failed");
|
||||
}
|
||||
});
|
||||
const controller = createLifecycleController({ entry, persistOrThrow });
|
||||
const controller = createLifecycleController({
|
||||
entry,
|
||||
persistOrThrow,
|
||||
cleanupBrowserSessionsForLifecycleEnd: undefined,
|
||||
loadCleanupBrowserSessionsForLifecycleEnd,
|
||||
});
|
||||
|
||||
const completion = completeRun(controller, entry, { triggerCleanup: true });
|
||||
await browserLoaderEntered;
|
||||
|
||||
@@ -1,91 +1,245 @@
|
||||
import { runWithGatewayIndependentRootWorkAdmission } from "../../../process/gateway-work-admission.js";
|
||||
/**
|
||||
* Subagent registry lifecycle transitions.
|
||||
*
|
||||
* Completes/fails task runs, clears delivery state, emits lifecycle events, and cleans attached resources.
|
||||
*/
|
||||
import type { AcceptedSessionSpawn } from "../../accepted-session-spawn.js";
|
||||
import { createSubagentRegistryLifecycleCleanupBase } from "./subagent-registry-lifecycle-cleanup-base.js";
|
||||
import { createSubagentRegistryLifecycleCleanup } from "./subagent-registry-lifecycle-cleanup.js";
|
||||
import { createSubagentRegistryLifecycleCommon } from "./subagent-registry-lifecycle-common.js";
|
||||
import { createSubagentRegistryLifecycleCompletion } from "./subagent-registry-lifecycle-completion.js";
|
||||
import {
|
||||
createSubagentRegistryLifecycleState,
|
||||
type SubagentRegistryLifecycleParams,
|
||||
} from "./subagent-registry-lifecycle-contracts.js";
|
||||
import { createSubagentRegistryLifecycleDelivery } from "./subagent-registry-lifecycle-delivery.js";
|
||||
import { createSubagentRegistryLifecycleRequesterWake } from "./subagent-registry-lifecycle-requester-wake.js";
|
||||
ensureCompletionState,
|
||||
ensureDeliveryState,
|
||||
getDeliveryLastError,
|
||||
} from "./subagent-delivery-state.js";
|
||||
import {
|
||||
finalizeResumedAnnounceGiveUp,
|
||||
retryDeferredCompletedAnnounces,
|
||||
startSubagentAnnounceCleanupFlow,
|
||||
} from "./subagent-registry-lifecycle-announce-cleanup.js";
|
||||
import { completeSubagentRunAttempt } from "./subagent-registry-lifecycle-completion.js";
|
||||
import type {
|
||||
CleanupBookkeepingParams,
|
||||
ScheduledRequesterSettleWake,
|
||||
SubagentLifecycleOptions,
|
||||
} from "./subagent-registry-lifecycle-context.js";
|
||||
import { refreshFrozenResultFromSession } from "./subagent-registry-lifecycle-delivery.js";
|
||||
import {
|
||||
completeCleanupBookkeeping,
|
||||
scheduleRequesterSettleWake,
|
||||
} from "./subagent-registry-lifecycle-wake.js";
|
||||
import { settleRequesterTurnAfterSessionSpawns } from "./subagent-registry-requester-yield.js";
|
||||
import type { SubagentCompletionRequest } from "./subagent-registry.types.js";
|
||||
import type { SubagentCompletionRequest, SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
|
||||
|
||||
export function createSubagentRegistryLifecycleController(params: SubagentRegistryLifecycleParams) {
|
||||
const state = createSubagentRegistryLifecycleState();
|
||||
const common = createSubagentRegistryLifecycleCommon(params, state);
|
||||
const delivery = createSubagentRegistryLifecycleDelivery(params, state, common);
|
||||
const requesterWake = createSubagentRegistryLifecycleRequesterWake(
|
||||
params,
|
||||
state,
|
||||
common,
|
||||
delivery,
|
||||
);
|
||||
const cleanupBase = createSubagentRegistryLifecycleCleanupBase(
|
||||
params,
|
||||
state,
|
||||
common,
|
||||
delivery,
|
||||
requesterWake,
|
||||
);
|
||||
const cleanup = createSubagentRegistryLifecycleCleanup(
|
||||
params,
|
||||
state,
|
||||
common,
|
||||
delivery,
|
||||
requesterWake,
|
||||
cleanupBase,
|
||||
);
|
||||
const completion = createSubagentRegistryLifecycleCompletion(
|
||||
params,
|
||||
state,
|
||||
common,
|
||||
delivery,
|
||||
cleanupBase,
|
||||
cleanup,
|
||||
);
|
||||
const completeSubagentRun = async (completeParams: SubagentCompletionRequest) => {
|
||||
export type { SubagentLifecycleOptions } from "./subagent-registry-lifecycle-context.js";
|
||||
|
||||
export class SubagentLifecycleController {
|
||||
private readonly scheduledResumeTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
private readonly pendingRequesterSettleWakeRearms = new Set<string>();
|
||||
private readonly scheduledRequesterSettleWakeRuns = new Set<string>();
|
||||
private readonly scheduledRequesterSettleWakeTimers = new Map<
|
||||
string,
|
||||
ScheduledRequesterSettleWake
|
||||
>();
|
||||
private readonly terminalCompletionLocks = new Map<string, Promise<void>>();
|
||||
private readonly terminalGenerations = new WeakMap<SubagentRunRecord, number>();
|
||||
private readonly cleanupGenerations = new WeakMap<SubagentRunRecord, number>();
|
||||
private readonly progressEndedEntries = new WeakSet<SubagentRunRecord>();
|
||||
private readonly cleanupFailureCounts = new WeakMap<SubagentRunRecord, number>();
|
||||
|
||||
constructor(readonly options: SubagentLifecycleOptions) {}
|
||||
|
||||
newerGenerationOwnsSession(entry: SubagentRunRecord): boolean {
|
||||
return (
|
||||
entry.killReconciliation?.supersededAt !== undefined ||
|
||||
Array.from(this.options.runs.values()).some(
|
||||
(candidate) =>
|
||||
candidate.runId !== entry.runId &&
|
||||
candidate.childSessionKey === entry.childSessionKey &&
|
||||
compareSubagentRunGeneration(candidate, entry) > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async acquireTerminalCompletionLock(runId: string): Promise<() => void> {
|
||||
const previous = this.terminalCompletionLocks.get(runId) ?? Promise.resolve();
|
||||
let releaseLock = () => {};
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
this.terminalCompletionLocks.set(runId, current);
|
||||
await previous;
|
||||
return () => {
|
||||
releaseLock();
|
||||
if (this.terminalCompletionLocks.get(runId) === current) {
|
||||
this.terminalCompletionLocks.delete(runId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
clearScheduledResumeTimers = () => {
|
||||
for (const timer of this.scheduledResumeTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.scheduledResumeTimers.clear();
|
||||
for (const scheduled of this.scheduledRequesterSettleWakeTimers.values()) {
|
||||
clearTimeout(scheduled.timer);
|
||||
}
|
||||
this.scheduledRequesterSettleWakeTimers.clear();
|
||||
this.pendingRequesterSettleWakeRearms.clear();
|
||||
};
|
||||
|
||||
addScheduledResumeTimer = (timer: ReturnType<typeof setTimeout>): void =>
|
||||
void this.scheduledResumeTimers.add(timer);
|
||||
deleteScheduledResumeTimer = (timer: ReturnType<typeof setTimeout>): void =>
|
||||
void this.scheduledResumeTimers.delete(timer);
|
||||
|
||||
bumpCleanupGeneration(entry: SubagentRunRecord): number {
|
||||
const generation = (this.cleanupGenerations.get(entry) ?? 0) + 1;
|
||||
this.cleanupGenerations.set(entry, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
isCleanupGeneration = (entry: SubagentRunRecord, generation: number): boolean =>
|
||||
this.cleanupGenerations.get(entry) === generation;
|
||||
isCleanupGenerationCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
this.options.runs.get(runId) === entry &&
|
||||
entry.pauseReason !== "sessions_yield" &&
|
||||
this.isCleanupGeneration(entry, generation) &&
|
||||
!this.newerGenerationOwnsSession(entry);
|
||||
isCleanupAttemptCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
entry.cleanupHandled === true && this.isCleanupGenerationCurrent(runId, entry, generation);
|
||||
isEndedHookOwnerCurrent = (runId: string, entry: SubagentRunRecord): boolean => {
|
||||
const current = this.options.runs.get(runId);
|
||||
return (current === undefined || current === entry) && !this.newerGenerationOwnsSession(entry);
|
||||
};
|
||||
|
||||
bumpTerminalGeneration(entry: SubagentRunRecord): number {
|
||||
const generation = (this.terminalGenerations.get(entry) ?? 0) + 1;
|
||||
this.terminalGenerations.set(entry, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
isTerminalCallbackCurrent = (
|
||||
runId: string,
|
||||
entry: SubagentRunRecord,
|
||||
generation: number,
|
||||
): boolean =>
|
||||
this.options.runs.get(runId) === entry &&
|
||||
entry.pauseReason !== "sessions_yield" &&
|
||||
this.terminalGenerations.get(entry) === generation;
|
||||
hasProgressEnded = (entry: SubagentRunRecord): boolean => this.progressEndedEntries.has(entry);
|
||||
markProgressEnded = (entry: SubagentRunRecord): void => void this.progressEndedEntries.add(entry);
|
||||
clearCleanupFailureCount = (entry: SubagentRunRecord): void =>
|
||||
void this.cleanupFailureCounts.delete(entry);
|
||||
|
||||
incrementCleanupFailureCount(entry: SubagentRunRecord): number {
|
||||
const count = (this.cleanupFailureCounts.get(entry) ?? 0) + 1;
|
||||
this.cleanupFailureCounts.set(entry, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
getRequesterSettleWakeTimer = (runId: string): ScheduledRequesterSettleWake | undefined =>
|
||||
this.scheduledRequesterSettleWakeTimers.get(runId);
|
||||
setRequesterSettleWakeTimer = (runId: string, value: ScheduledRequesterSettleWake): void =>
|
||||
void this.scheduledRequesterSettleWakeTimers.set(runId, value);
|
||||
deleteRequesterSettleWakeTimer = (runId: string): void =>
|
||||
void this.scheduledRequesterSettleWakeTimers.delete(runId);
|
||||
hasScheduledRequesterSettleWakeRun = (runId: string): boolean =>
|
||||
this.scheduledRequesterSettleWakeRuns.has(runId);
|
||||
markRequesterSettleWakeRunScheduled = (runId: string): void =>
|
||||
void this.scheduledRequesterSettleWakeRuns.add(runId);
|
||||
unmarkRequesterSettleWakeRunScheduled = (runId: string): void =>
|
||||
void this.scheduledRequesterSettleWakeRuns.delete(runId);
|
||||
markRequesterSettleWakeRearm = (runId: string): void =>
|
||||
void this.pendingRequesterSettleWakeRearms.add(runId);
|
||||
takeRequesterSettleWakeRearm = (runId: string): boolean =>
|
||||
this.pendingRequesterSettleWakeRearms.delete(runId);
|
||||
|
||||
completeSubagentRun = async (completeParams: SubagentCompletionRequest) => {
|
||||
// Task finalization can make the run disappear from suspension blockers
|
||||
// before browser/MCP retirement and cleanup delivery hand off. Own this
|
||||
// entire transition as an independent root so that boundary stays atomic.
|
||||
// Callers can detach while retaining parent ALS, so nesting is intentional.
|
||||
await runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
await completion.completeSubagentRunAttempt(completeParams);
|
||||
await completeSubagentRunAttempt(this, completeParams);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
clearScheduledResumeTimers: common.clearScheduledResumeTimers,
|
||||
completeCleanupBookkeeping: cleanup.completeCleanupBookkeeping,
|
||||
completeSubagentRun,
|
||||
finalizeResumedAnnounceGiveUp: cleanup.finalizeResumedAnnounceGiveUp,
|
||||
refreshFrozenResultFromSession: delivery.refreshFrozenResultFromSession,
|
||||
settleRequesterTurnAfterSessionSpawns: (args: {
|
||||
requesterSessionKey: string;
|
||||
requesterTurnRunId: string;
|
||||
requesterYielded: boolean;
|
||||
acceptedSessionSpawns: readonly AcceptedSessionSpawn[];
|
||||
}) =>
|
||||
settleRequesterTurnAfterSessionSpawns({
|
||||
...args,
|
||||
runs: params.runs,
|
||||
persistOrThrow: (...runIds) => params.persistOrThrow(...runIds),
|
||||
schedule: (runId, entry) => {
|
||||
if (state.scheduledRequesterSettleWakeRuns.has(runId)) {
|
||||
state.pendingRequesterSettleWakeRearms.add(runId);
|
||||
return;
|
||||
}
|
||||
requesterWake.scheduleRequesterSettleWake(runId, entry);
|
||||
},
|
||||
}),
|
||||
resumeRequesterSettleWake: requesterWake.scheduleRequesterSettleWake,
|
||||
startSubagentAnnounceCleanupFlow: cleanup.startSubagentAnnounceCleanupFlow,
|
||||
completeCleanupBookkeeping = (params: CleanupBookkeepingParams) => {
|
||||
completeCleanupBookkeeping(this, params, (excludeRunId) =>
|
||||
retryDeferredCompletedAnnounces(this, excludeRunId),
|
||||
);
|
||||
};
|
||||
|
||||
static discardTerminalDelivery(
|
||||
this: void,
|
||||
entry: SubagentRunRecord,
|
||||
completedAt: number,
|
||||
reason: "dismissed" | "expired" = "dismissed",
|
||||
): void {
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
const payload = delivery.payload;
|
||||
if (reason === "dismissed") {
|
||||
delivery.disposition = "intentional_non_delivery";
|
||||
delivery.dismissedAt = completedAt;
|
||||
} else {
|
||||
delivery.discardedAt = completedAt;
|
||||
delivery.discardReason = "expired";
|
||||
delivery.discardedPayloadSummary = {
|
||||
requesterSessionKey: payload?.requesterSessionKey ?? entry.requesterSessionKey,
|
||||
childSessionKey: payload?.childSessionKey ?? entry.childSessionKey,
|
||||
childRunId: payload?.childRunId ?? entry.runId,
|
||||
endedAt: payload?.endedAt ?? entry.execution.endedAt,
|
||||
status: payload?.outcome?.status ?? entry.execution.outcome?.status,
|
||||
lastError: getDeliveryLastError(entry) ?? null,
|
||||
};
|
||||
}
|
||||
Object.assign(delivery, { status: "discarded", queueId: undefined, nextAttemptAt: undefined });
|
||||
delivery.payload = undefined;
|
||||
Object.assign(delivery, { createdAt: undefined, lastAttemptAt: undefined });
|
||||
Object.assign(delivery, {
|
||||
attemptCount: undefined,
|
||||
lastError: undefined,
|
||||
announcedAt: undefined,
|
||||
});
|
||||
Object.assign(delivery, { suspendedAt: undefined, suspendedReason: undefined });
|
||||
Object.assign(entry, { wakeOnDescendantSettle: undefined, cleanupHandled: true });
|
||||
const completion = ensureCompletionState(entry);
|
||||
Object.assign(completion, { fallbackResultText: undefined, fallbackCapturedAt: undefined });
|
||||
entry.cleanupCompletedAt = completedAt;
|
||||
}
|
||||
|
||||
finalizeResumedAnnounceGiveUp = (params: Parameters<typeof finalizeResumedAnnounceGiveUp>[1]) =>
|
||||
finalizeResumedAnnounceGiveUp(this, params);
|
||||
|
||||
refreshFrozenResultFromSession = (sessionKey: string) =>
|
||||
refreshFrozenResultFromSession(this, sessionKey);
|
||||
|
||||
resumeRequesterSettleWake = (runId: string, entry: SubagentRunRecord) =>
|
||||
scheduleRequesterSettleWake(this, runId, entry);
|
||||
|
||||
settleRequesterTurnAfterSessionSpawns = (args: {
|
||||
requesterSessionKey: string;
|
||||
requesterTurnRunId: string;
|
||||
requesterYielded: boolean;
|
||||
acceptedSessionSpawns: readonly AcceptedSessionSpawn[];
|
||||
}) =>
|
||||
settleRequesterTurnAfterSessionSpawns({
|
||||
...args,
|
||||
runs: this.options.runs,
|
||||
persistOrThrow: (...runIds) => this.options.persistOrThrow(...runIds),
|
||||
schedule: (runId, entry) => {
|
||||
if (this.hasScheduledRequesterSettleWakeRun(runId)) {
|
||||
this.markRequesterSettleWakeRearm(runId);
|
||||
return;
|
||||
}
|
||||
scheduleRequesterSettleWake(this, runId, entry);
|
||||
},
|
||||
});
|
||||
|
||||
startSubagentAnnounceCleanupFlow = (runId: string, entry: SubagentRunRecord): boolean =>
|
||||
startSubagentAnnounceCleanupFlow(this, runId, entry);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
releaseLeasedAgentSteeringItemsFromSubagentRuns,
|
||||
} from "../../agent-steering-queue.js";
|
||||
import type { SubagentRegistryDeps } from "./subagent-registry-deps.js";
|
||||
import type { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import type { SubagentLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import { getSubagentRunsForChildSession } from "./subagent-registry-memory.js";
|
||||
import {
|
||||
countActiveRunsForSessionFromRuns,
|
||||
@@ -20,9 +20,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
persistOrThrow: (...runIds: string[]) => void;
|
||||
restoreOnce: () => void;
|
||||
startAnnounceCleanup: (runId: string, entry: SubagentRunRecord) => boolean;
|
||||
settleRequesterTurn: ReturnType<
|
||||
typeof createSubagentRegistryLifecycleController
|
||||
>["settleRequesterTurnAfterSessionSpawns"];
|
||||
settleRequesterTurn: SubagentLifecycleController["settleRequesterTurnAfterSessionSpawns"];
|
||||
}) {
|
||||
const {
|
||||
runs,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
reconcileOrphanedRestoredRuns,
|
||||
updateSubagentArchiveAtMs,
|
||||
} from "./subagent-registry-helpers.js";
|
||||
import type { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import type { SubagentLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js";
|
||||
import {
|
||||
@@ -53,9 +53,7 @@ export function createSubagentRegistryRestorer(config: {
|
||||
deps: () => SubagentRegistryDeps;
|
||||
persist: (...runIds: string[]) => void;
|
||||
persistOrThrow: (...runIds: string[]) => void;
|
||||
settleRequesterTurn: ReturnType<
|
||||
typeof createSubagentRegistryLifecycleController
|
||||
>["settleRequesterTurnAfterSessionSpawns"];
|
||||
settleRequesterTurn: SubagentLifecycleController["settleRequesterTurnAfterSessionSpawns"];
|
||||
ensureListener: () => void;
|
||||
startSweeper: () => void;
|
||||
resumeRun: (runId: string) => void;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
ensureCompletionState,
|
||||
ensureDeliveryState,
|
||||
getDeliveryLastError,
|
||||
isDeliverySuspended,
|
||||
} from "./subagent-delivery-state.js";
|
||||
import { isDeliverySuspended } from "./subagent-delivery-state.js";
|
||||
import {
|
||||
SUBAGENT_ENDED_REASON_COMPLETE,
|
||||
type SubagentLifecycleEndedReason,
|
||||
} from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import { safeRemoveAttachmentsDir } from "./subagent-registry-helpers.js";
|
||||
import type { SubagentLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
const SUBAGENT_SUSPENDED_DELIVERY_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
||||
@@ -32,6 +28,7 @@ export async function discardSuspendedPendingFinalDelivery(params: {
|
||||
resumedRuns: Set<string>;
|
||||
clearPendingLifecycleError: (runId: string) => void;
|
||||
clearPendingLifecycleTimeout: (runId: string) => void;
|
||||
discardTerminalDelivery: typeof SubagentLifecycleController.discardTerminalDelivery;
|
||||
completeCleanupBookkeeping: (params: {
|
||||
runId: string;
|
||||
entry: SubagentRunRecord;
|
||||
@@ -53,32 +50,7 @@ export async function discardSuspendedPendingFinalDelivery(params: {
|
||||
const { runId, entry, now, reason, resumedRuns } = params;
|
||||
const snapshot = structuredClone(entry);
|
||||
const wasResumed = resumedRuns.has(runId);
|
||||
const delivery = ensureDeliveryState(entry);
|
||||
const payload = delivery.payload;
|
||||
delivery.status = "discarded";
|
||||
delivery.discardedAt = now;
|
||||
delivery.discardReason = reason;
|
||||
delivery.discardedPayloadSummary = {
|
||||
requesterSessionKey: payload?.requesterSessionKey ?? entry.requesterSessionKey,
|
||||
childSessionKey: payload?.childSessionKey ?? entry.childSessionKey,
|
||||
childRunId: payload?.childRunId ?? entry.runId,
|
||||
endedAt: payload?.endedAt ?? entry.execution.endedAt,
|
||||
status: payload?.outcome?.status ?? entry.execution.outcome?.status,
|
||||
lastError: getDeliveryLastError(entry) ?? null,
|
||||
};
|
||||
delivery.payload = undefined;
|
||||
delivery.createdAt = undefined;
|
||||
delivery.lastAttemptAt = undefined;
|
||||
delivery.attemptCount = undefined;
|
||||
delivery.lastError = undefined;
|
||||
delivery.suspendedAt = undefined;
|
||||
delivery.suspendedReason = undefined;
|
||||
entry.wakeOnDescendantSettle = undefined;
|
||||
const completion = ensureCompletionState(entry);
|
||||
completion.fallbackResultText = undefined;
|
||||
completion.fallbackCapturedAt = undefined;
|
||||
entry.cleanupHandled = true;
|
||||
delivery.announcedAt = undefined;
|
||||
params.discardTerminalDelivery(entry, now, reason);
|
||||
const suppressSessionEffects = shouldSuppressSubagentRecoverySessionEffects(entry);
|
||||
const completionReason = entry.endedReason ?? SUBAGENT_ENDED_REASON_COMPLETE;
|
||||
try {
|
||||
|
||||
@@ -124,6 +124,7 @@ function createHarness(runtime: { current?: GatewayRecoveryRuntime }) {
|
||||
resumeRequesterSettleWake: vi.fn(),
|
||||
startSubagentAnnounceCleanupFlow: vi.fn(() => true),
|
||||
completeCleanupBookkeeping,
|
||||
discardTerminalDelivery: vi.fn(),
|
||||
shouldEmitEndedHookForRun: vi.fn(() => false),
|
||||
emitSubagentEndedHookForRun,
|
||||
callGateway,
|
||||
|
||||
@@ -9,7 +9,10 @@ import { SUBAGENT_ENDED_REASON_ERROR } from "./subagent-lifecycle-events.js";
|
||||
import { shouldSuppressSubagentRecoverySessionEffects } from "./subagent-recovery-state.js";
|
||||
import type { createSubagentRegistryCompletionRuntime } from "./subagent-registry-completion-runtime.js";
|
||||
import { reconcileOrphanedRun, safeRemoveAttachmentsDir } from "./subagent-registry-helpers.js";
|
||||
import type { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import type {
|
||||
SubagentLifecycleController,
|
||||
SubagentLifecycleOptions,
|
||||
} from "./subagent-registry-lifecycle.js";
|
||||
import { createInterruptedRecoveryCoordinator } from "./subagent-registry-restart-recovery-coordinator.js";
|
||||
import { isRestoredQueuedFailureSettlementClaimed } from "./subagent-registry-restore.js";
|
||||
import type { createSubagentRunManager } from "./subagent-registry-run-manager.js";
|
||||
@@ -46,9 +49,6 @@ const restartRecoveryLoader = createLazyImportLoader(
|
||||
);
|
||||
const killRuntimeLoader = createLazyImportLoader(() => import("./subagent-control.runtime.js"));
|
||||
|
||||
type LifecycleController = ReturnType<typeof createSubagentRegistryLifecycleController>;
|
||||
type LifecycleOptions = Parameters<typeof createSubagentRegistryLifecycleController>[0];
|
||||
|
||||
export function createSubagentRegistrySweeper(params: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
@@ -91,11 +91,12 @@ export function createSubagentRegistrySweeper(params: {
|
||||
finalizeInterruptedSubagentRun: ReturnType<
|
||||
typeof createSubagentRegistryCompletionRuntime
|
||||
>["finalizeInterruptedSubagentRun"];
|
||||
resumeRequesterSettleWake: LifecycleController["resumeRequesterSettleWake"];
|
||||
startSubagentAnnounceCleanupFlow: LifecycleController["startSubagentAnnounceCleanupFlow"];
|
||||
completeCleanupBookkeeping: LifecycleController["completeCleanupBookkeeping"];
|
||||
shouldEmitEndedHookForRun: LifecycleOptions["shouldEmitEndedHookForRun"];
|
||||
emitSubagentEndedHookForRun: LifecycleOptions["emitSubagentEndedHookForRun"];
|
||||
resumeRequesterSettleWake: SubagentLifecycleController["resumeRequesterSettleWake"];
|
||||
startSubagentAnnounceCleanupFlow: SubagentLifecycleController["startSubagentAnnounceCleanupFlow"];
|
||||
completeCleanupBookkeeping: SubagentLifecycleController["completeCleanupBookkeeping"];
|
||||
discardTerminalDelivery: typeof SubagentLifecycleController.discardTerminalDelivery;
|
||||
shouldEmitEndedHookForRun: SubagentLifecycleOptions["shouldEmitEndedHookForRun"];
|
||||
emitSubagentEndedHookForRun: SubagentLifecycleOptions["emitSubagentEndedHookForRun"];
|
||||
callGateway: typeof callGateway;
|
||||
cleanupCollectorLaunchResources: (entry: SubagentRunRecord) => Promise<boolean>;
|
||||
runContextEngineSubagentEnded: (params: ContextEngineSubagentEndedParams) => Promise<void>;
|
||||
@@ -306,6 +307,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
resumedRuns,
|
||||
clearPendingLifecycleError: params.clearPendingLifecycleError,
|
||||
clearPendingLifecycleTimeout: params.clearPendingLifecycleTimeout,
|
||||
discardTerminalDelivery: params.discardTerminalDelivery,
|
||||
completeCleanupBookkeeping: params.completeCleanupBookkeeping,
|
||||
shouldEmitEndedHookForRun: params.shouldEmitEndedHookForRun,
|
||||
emitSubagentEndedHookForRun: params.emitSubagentEndedHookForRun,
|
||||
|
||||
@@ -180,6 +180,46 @@ describe("subagent registry persistence resume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dismissed terminal delivery dormant and TTL-eligible after restore", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
|
||||
const stateDir = tempStateDir;
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const now = Date.now();
|
||||
const run: SubagentRunRecord = {
|
||||
runId: "run-dismissed-delivery",
|
||||
childSessionKey: "agent:main:subagent:dismissed-delivery",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "retain no delivery obligation",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 10 * 60_000,
|
||||
endedReason: "subagent-complete",
|
||||
execution: {
|
||||
status: "terminal",
|
||||
startedAt: now - 9 * 60_000,
|
||||
endedAt: now - 8 * 60_000,
|
||||
outcome: { status: "ok" },
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
completion: { required: true, resultText: "done", capturedAt: now - 8 * 60_000 },
|
||||
delivery: {
|
||||
status: "discarded",
|
||||
disposition: "intentional_non_delivery",
|
||||
dismissedAt: now - 6 * 60_000,
|
||||
},
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: now - 6 * 60_000,
|
||||
};
|
||||
saveSubagentRegistryToSqlite(new Map([[run.runId, run]]));
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
await mod.testing.sweepOnceForTests();
|
||||
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
expect(mod.getSubagentRunByRunId(run.runId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"settles a restored steered requester turn (yielded: %s)",
|
||||
async (requesterYielded) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type SubagentRegistryDeps,
|
||||
} from "./subagent-registry-deps.js";
|
||||
import { ANNOUNCE_EXPIRY_MS, reconcileOrphanedRun } from "./subagent-registry-helpers.js";
|
||||
import { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import { SubagentLifecycleController } from "./subagent-registry-lifecycle.js";
|
||||
import { createSubagentRegistryListener } from "./subagent-registry-listener.js";
|
||||
import {
|
||||
getSubagentRunsForChildSession,
|
||||
@@ -55,20 +55,11 @@ import {
|
||||
export type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
const log = createSubsystemLogger("agents/subagent-registry");
|
||||
|
||||
type SubagentRegistryRestorer = ReturnType<typeof createSubagentRegistryRestorer>;
|
||||
type SubagentRegistryBootstrapState = {
|
||||
const subagentRegistryBootstrapState: {
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
restorer?: SubagentRegistryRestorer;
|
||||
};
|
||||
|
||||
function getSubagentRegistryBootstrapState(): SubagentRegistryBootstrapState {
|
||||
const owner = getSubagentRegistryBootstrapState as typeof getSubagentRegistryBootstrapState & {
|
||||
state?: SubagentRegistryBootstrapState;
|
||||
};
|
||||
owner.state ??= {};
|
||||
return owner.state;
|
||||
}
|
||||
restorer?: ReturnType<typeof createSubagentRegistryRestorer>;
|
||||
} = {};
|
||||
|
||||
const resumeRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
|
||||
@@ -132,7 +123,7 @@ const contextCleanup = createSubagentRegistryContextCleanup({
|
||||
warn: (message, meta) => log.warn(message, meta),
|
||||
});
|
||||
|
||||
const subagentLifecycleController = createSubagentRegistryLifecycleController({
|
||||
const subagentLifecycleController = new SubagentLifecycleController({
|
||||
runs: subagentRuns,
|
||||
resumedRuns,
|
||||
subagentAnnounceTimeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
|
||||
@@ -140,7 +131,9 @@ const subagentLifecycleController = createSubagentRegistryLifecycleController({
|
||||
persist: persistSubagentRuns,
|
||||
persistOrThrow: persistSubagentRunsOrThrow,
|
||||
clearPendingLifecycleError,
|
||||
countPendingDescendantRuns,
|
||||
// Lifecycle wiring precedes publicApi construction; inject this read query
|
||||
// as a late-bound callback instead of threading a partially built API object.
|
||||
countPendingDescendantRuns: (rootSessionKey) => countPendingDescendantRuns(rootSessionKey),
|
||||
suppressAnnounceForSteerRestart: contextCleanup.suppressAnnounceForSteerRestart,
|
||||
resolveSubagentTask: findSubagentTaskForRun,
|
||||
shouldEmitEndedHookForRun: contextCleanup.shouldEmitEndedHookForRun,
|
||||
@@ -408,6 +401,7 @@ const subagentSweeper = createSubagentRegistrySweeper({
|
||||
resumeRequesterSettleWake,
|
||||
startSubagentAnnounceCleanupFlow,
|
||||
completeCleanupBookkeeping,
|
||||
discardTerminalDelivery: SubagentLifecycleController.discardTerminalDelivery,
|
||||
shouldEmitEndedHookForRun: contextCleanup.shouldEmitEndedHookForRun,
|
||||
emitSubagentEndedHookForRun: contextCleanup.emitSubagentEndedHookForRun,
|
||||
callGateway: (request) => subagentRegistryDeps.callGateway(request),
|
||||
@@ -569,6 +563,7 @@ function addSubagentRunForTests(entry: SubagentRunRecord) {
|
||||
}
|
||||
|
||||
export const markSubagentRunTerminated = subagentRunManager.markSubagentRunTerminated;
|
||||
export const discardSubagentTerminalDelivery = SubagentLifecycleController.discardTerminalDelivery;
|
||||
|
||||
export { prependAgentSteeringPrompt };
|
||||
|
||||
@@ -593,7 +588,7 @@ export const listSwarmRunsForGroup = publicApi.listSwarmRunsForGroup;
|
||||
export const getSwarmRunByLaunchReplayKey = publicApi.getSwarmRunByLaunchReplayKey;
|
||||
export const countActiveRunsForSession = publicApi.countActiveRunsForSession;
|
||||
export function initSubagentRegistry() {
|
||||
const state = getSubagentRegistryBootstrapState();
|
||||
const state = subagentRegistryBootstrapState;
|
||||
if (!state.ready || !state.restorer) {
|
||||
state.pending = true;
|
||||
return;
|
||||
@@ -603,7 +598,7 @@ export function initSubagentRegistry() {
|
||||
export const settleRequesterAfterSessionSpawns = publicApi.settleRequesterAfterSessionSpawns;
|
||||
export const markRequesterTurnYielded = publicApi.markRequesterTurnYielded;
|
||||
|
||||
const bootstrapState = getSubagentRegistryBootstrapState();
|
||||
const bootstrapState = subagentRegistryBootstrapState;
|
||||
bootstrapState.restorer = subagentRestorer;
|
||||
bootstrapState.ready = true;
|
||||
if (bootstrapState.pending) {
|
||||
|
||||
@@ -161,20 +161,24 @@ export const tasksHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, { results });
|
||||
},
|
||||
"tasks.dismiss": ({ params, respond }) => {
|
||||
"tasks.dismiss": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateTasksRecoveryParams, "tasks.dismiss", respond)) {
|
||||
return;
|
||||
}
|
||||
respond(true, {
|
||||
results: params.taskIds.map((taskId) => {
|
||||
const result = dismissSubagentCompletionDelivery(taskId);
|
||||
return {
|
||||
taskId,
|
||||
ok: result.ok,
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
...(result.task ? { task: mapTaskSummary(result.task, { includePrompt: true }) } : {}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
const { discardSubagentTerminalDelivery } =
|
||||
await import("../../agents/subagents/registry/subagent-registry.js");
|
||||
const results = [];
|
||||
for (const taskId of params.taskIds) {
|
||||
const result = await dismissSubagentCompletionDelivery(taskId, {
|
||||
discardTerminalDelivery: discardSubagentTerminalDelivery,
|
||||
});
|
||||
results.push({
|
||||
taskId,
|
||||
ok: result.ok,
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
...(result.task ? { task: mapTaskSummary(result.task, { includePrompt: true }) } : {}),
|
||||
});
|
||||
}
|
||||
respond(true, { results });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { SESSION_VIEWER_PRESENCE_MAX_KEYS } from "../../packages/gateway-protocol/src/schema/sessions-viewer-presence.js";
|
||||
import { SUBAGENT_ENDED_REASON_ERROR } from "../agents/subagents/registry/subagent-lifecycle-events.js";
|
||||
import { createSubagentRegistryLifecycleController } from "../agents/subagents/registry/subagent-registry-lifecycle.js";
|
||||
import { SubagentLifecycleController } from "../agents/subagents/registry/subagent-registry-lifecycle.js";
|
||||
import type { SubagentRunRecord } from "../agents/subagents/registry/subagent-registry.types.js";
|
||||
import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
|
||||
import {
|
||||
@@ -646,7 +646,7 @@ describe("session.message websocket events", () => {
|
||||
});
|
||||
|
||||
const emitSubagentProgressEndedForRun = vi.fn(async () => {});
|
||||
const controller = createSubagentRegistryLifecycleController({
|
||||
const controller = new SubagentLifecycleController({
|
||||
runs: new Map([[entry.runId, entry]]),
|
||||
resumedRuns: new Set(),
|
||||
subagentAnnounceTimeoutMs: 1_000,
|
||||
|
||||
Reference in New Issue
Block a user