mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cron): deliver spawned child results when scheduled agents emit no text (#117308)
* fix(cron): deliver spawned child results when parents emit no text * test(cron): cover spawn-only scheduled child delivery end to end
This commit is contained in:
committed by
GitHub
parent
4e47c4cabf
commit
e9e4023e59
@@ -0,0 +1,194 @@
|
||||
title: Isolated cron spawn-only child delivery
|
||||
|
||||
scenario:
|
||||
id: cron-isolated-spawn-only-child-delivery
|
||||
surface: cron
|
||||
coverage:
|
||||
primary:
|
||||
- automation.isolated-cron-execution
|
||||
- agent-runtime.subagent-turns-delivery
|
||||
secondary:
|
||||
- agent-runtime.subagent-turns-subagents
|
||||
- automation.run-history-cron
|
||||
- channels.qa-channel-final-reply
|
||||
regressionRefs:
|
||||
- openclaw/openclaw#117308
|
||||
objective: Verify a naturally fired isolated one-shot cron with a spawn-only parent delivers its child's exact reply before retiring.
|
||||
successCriteria:
|
||||
- The isolated parent successfully spawns exactly one child and finishes without producing assistant text.
|
||||
- The child completes successfully and its exact reply reaches the QA channel once.
|
||||
- Cron history records successful delivery before the one-shot job is retired.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
- docs/channels/qa-channel.md
|
||||
- docs/tools/subagents.md
|
||||
codeRefs:
|
||||
- src/cron/isolated-agent/run-finalize.ts
|
||||
- src/cron/isolated-agent/delivery-dispatch.ts
|
||||
- src/cron/service/timer-outcomes.ts
|
||||
- extensions/qa-lab/src/providers/mock-openai/server.ts
|
||||
execution:
|
||||
kind: flow
|
||||
suiteIsolation: isolated
|
||||
isolationReason: Owns a naturally scheduled one-shot job, subagent lifecycle, and exact QA-channel delivery.
|
||||
channel: qa-channel
|
||||
retryCount: 0
|
||||
timeoutMs: 180000
|
||||
summary: Naturally fire a spawn-only isolated cron and require one exact child delivery before one-shot retirement.
|
||||
config:
|
||||
requiredProviderMode: mock-openai
|
||||
channelId: qa-room
|
||||
fireDelayMs: 12000
|
||||
duplicateWindowMs: 2000
|
||||
promptSnippet: Subagent direct fallback QA check
|
||||
workerSnippet: Subagent direct fallback worker
|
||||
expectedLabel: qa-direct-fallback-worker
|
||||
expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK
|
||||
prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After the spawn is accepted, finish without assistant text. Empty response exhaustion QA check. Do not use ACP."
|
||||
|
||||
flow:
|
||||
steps:
|
||||
- name: naturally fires an isolated spawn-only one-shot job
|
||||
actions:
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 120000
|
||||
- call: reset
|
||||
- set: requestCursorBefore
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor"
|
||||
- set: runStartedAt
|
||||
value:
|
||||
expr: "Date.now()"
|
||||
- set: scheduledFor
|
||||
value:
|
||||
expr: "new Date(runStartedAt + config.fireDelayMs).toISOString()"
|
||||
- call: env.gateway.call
|
||||
saveAs: addedJob
|
||||
args:
|
||||
- cron.add
|
||||
- agentId: qa
|
||||
name:
|
||||
expr: "`qa-cron-spawn-only-${randomUUID()}`"
|
||||
enabled: true
|
||||
deleteAfterRun: true
|
||||
schedule:
|
||||
kind: at
|
||||
at:
|
||||
ref: scheduledFor
|
||||
sessionTarget: isolated
|
||||
wakeMode: now
|
||||
payload:
|
||||
kind: agentTurn
|
||||
timeoutSeconds: 90
|
||||
toolsAllow:
|
||||
- sessions_spawn
|
||||
- read
|
||||
message:
|
||||
ref: config.prompt
|
||||
delivery:
|
||||
mode: announce
|
||||
channel: qa-channel
|
||||
to:
|
||||
expr: "`channel:${config.channelId}`"
|
||||
- timeoutMs: 30000
|
||||
- set: jobId
|
||||
value:
|
||||
expr: "addedJob.id"
|
||||
- assert:
|
||||
expr: "Boolean(jobId) && addedJob.sessionTarget === 'isolated' && addedJob.deleteAfterRun === true && JSON.stringify(addedJob.payload?.toolsAllow) === JSON.stringify(['sessions_spawn', 'read'])"
|
||||
message:
|
||||
expr: "`expected an isolated delete-after-run cron job restricted to sessions_spawn and read, got ${JSON.stringify(addedJob)}`"
|
||||
- call: waitForCronRunCompletion
|
||||
saveAs: completedRun
|
||||
args:
|
||||
- callGateway:
|
||||
expr: "env.gateway.call.bind(env.gateway)"
|
||||
jobId:
|
||||
ref: jobId
|
||||
afterTs:
|
||||
ref: runStartedAt
|
||||
timeoutMs:
|
||||
expr: "liveTurnTimeoutMs(env, config.fireDelayMs + 90000)"
|
||||
- assert:
|
||||
expr: "Date.now() >= new Date(scheduledFor).getTime()"
|
||||
message: one-shot cron completed before its natural scheduled time
|
||||
- assert:
|
||||
expr: "completedRun.status === 'ok' && completedRun.delivered === true && completedRun.deliveryStatus === 'delivered'"
|
||||
message:
|
||||
expr: "`expected successful delivered spawn-only cron run, got ${JSON.stringify(completedRun)}`"
|
||||
- assert:
|
||||
expr: "String(completedRun.summary ?? '').trim() === config.expectedMarker"
|
||||
message:
|
||||
expr: "`cron summary did not preserve the child's exact reply: ${JSON.stringify(completedRun)}`"
|
||||
|
||||
- name: proves the parent accepted its child without producing assistant text
|
||||
actions:
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`)"
|
||||
- set: parentRequests
|
||||
value:
|
||||
expr: "scenarioRequests.filter((request) => String(request.prompt ?? '').includes(config.promptSnippet))"
|
||||
- set: spawnRequests
|
||||
value:
|
||||
expr: "parentRequests.filter((request) => request.plannedToolName === 'sessions_spawn')"
|
||||
- set: terminalRequests
|
||||
value:
|
||||
expr: "parentRequests.filter((request) => !request.plannedToolName)"
|
||||
- assert:
|
||||
expr: "parentRequests.length === 2 && spawnRequests.length === 1 && terminalRequests.length === 1 && spawnRequests[0].plannedToolArgs?.label === config.expectedLabel && spawnRequests[0].cursor < terminalRequests[0].cursor && /empty response exhaustion qa check/i.test(String(terminalRequests[0].allInputText ?? ''))"
|
||||
message:
|
||||
expr: "`expected one child spawn followed by an empty tool-free parent response, saw ${JSON.stringify(parentRequests.map((request) => ({ tool: request.plannedToolName ?? null, label: request.plannedToolArgs?.label ?? null })))}`"
|
||||
- set: acceptedSpawn
|
||||
value:
|
||||
expr: "(() => { let result = JSON.parse(String(terminalRequests[0].toolOutput ?? 'null')); if (result?.status === 'completed') result = result.value; return typeof result === 'string' ? JSON.parse(result) : result; })()"
|
||||
- assert:
|
||||
expr: "acceptedSpawn?.status === 'accepted' && Boolean(String(acceptedSpawn.childSessionKey ?? '').trim()) && Boolean(String(acceptedSpawn.runId ?? '').trim())"
|
||||
message:
|
||||
expr: "`parent did not receive a successful child-spawn result before its empty response: ${JSON.stringify(acceptedSpawn)}`"
|
||||
- set: childRequests
|
||||
value:
|
||||
expr: "scenarioRequests.filter((request) => String(request.prompt ?? '').includes(config.workerSnippet))"
|
||||
- assert:
|
||||
expr: "childRequests.length === 1 && !childRequests[0].plannedToolName"
|
||||
message:
|
||||
expr: "`expected one child assistant reply without additional tools, saw ${JSON.stringify(childRequests.map((request) => request.plannedToolName ?? null))}`"
|
||||
|
||||
- name: delivers the exact child reply once and retires the one-shot job
|
||||
actions:
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: deliveredReply
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === config.channelId && String(candidate.text ?? '').trim() === config.expectedMarker"
|
||||
- expr: "liveTurnTimeoutMs(env, 30000)"
|
||||
- call: sleep
|
||||
args:
|
||||
- expr: "config.duplicateWindowMs"
|
||||
- set: deliveredReplies
|
||||
value:
|
||||
expr: "getTransportSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.channelId && String(message.text ?? '').trim() === config.expectedMarker)"
|
||||
- assert:
|
||||
expr: "deliveredReplies.length === 1"
|
||||
message:
|
||||
expr: "`expected exactly one child reply on qa-channel, saw ${deliveredReplies.length}: ${JSON.stringify(deliveredReplies.map((message) => message.text))}`"
|
||||
- call: waitForCondition
|
||||
saveAs: retiredJobs
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: "env.gateway.call('cron.list', { includeDisabled: true }, { timeoutMs: 30000 }).then((page) => page.jobs.some((job) => job.id === jobId) ? undefined : page.jobs)"
|
||||
- expr: "liveTurnTimeoutMs(env, 30000)"
|
||||
- 250
|
||||
- assert:
|
||||
expr: "!retiredJobs.some((job) => job.id === jobId)"
|
||||
message: successful one-shot job was not retired after exact child delivery
|
||||
detailsExpr: "`child=${config.expectedLabel} accepted=true parentVisibleText=false delivery=${completedRun.deliveryStatus} reply=${deliveredReply.text} outboundCount=${deliveredReplies.length} retired=true`"
|
||||
@@ -27,6 +27,7 @@ export type DispatchCronDeliveryParams = {
|
||||
resolvedDelivery: DeliveryTargetResolution;
|
||||
deliveryRequested: boolean;
|
||||
skipHeartbeatDelivery: boolean;
|
||||
spawnOnlyHandoff: boolean;
|
||||
sourceDeliveryOutcome: SourceDeliveryOutcome;
|
||||
deliveryBestEffort: boolean;
|
||||
deliveryPayloadHasStructuredContent: boolean;
|
||||
|
||||
@@ -214,6 +214,7 @@ function makeBaseParams(overrides: {
|
||||
runStartedAt?: number;
|
||||
sessionTarget?: string;
|
||||
deliveryBestEffort?: boolean;
|
||||
spawnOnlyHandoff?: boolean;
|
||||
runSessionKey?: string;
|
||||
resolvedDeliveryMode?: "explicit" | "implicit";
|
||||
}): Parameters<typeof dispatchCronDelivery>[0] {
|
||||
@@ -245,6 +246,7 @@ function makeBaseParams(overrides: {
|
||||
resolvedDelivery,
|
||||
deliveryRequested: overrides.deliveryRequested ?? true,
|
||||
skipHeartbeatDelivery: false,
|
||||
spawnOnlyHandoff: overrides.spawnOnlyHandoff ?? false,
|
||||
sourceDeliveryOutcome: {
|
||||
visibleDeliveries: [],
|
||||
verifiedMessageToolDelivery: false,
|
||||
@@ -1101,6 +1103,169 @@ describe("dispatchCronDelivery — double-announce guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "active direct",
|
||||
activeDescendants: true,
|
||||
threadId: undefined,
|
||||
deliveryBestEffort: false,
|
||||
},
|
||||
{
|
||||
name: "active threaded",
|
||||
activeDescendants: true,
|
||||
threadId: "42",
|
||||
deliveryBestEffort: false,
|
||||
},
|
||||
{
|
||||
name: "completed direct",
|
||||
activeDescendants: false,
|
||||
threadId: undefined,
|
||||
deliveryBestEffort: false,
|
||||
},
|
||||
{
|
||||
name: "completed threaded",
|
||||
activeDescendants: false,
|
||||
threadId: "42",
|
||||
deliveryBestEffort: false,
|
||||
},
|
||||
{
|
||||
name: "active best-effort direct",
|
||||
activeDescendants: true,
|
||||
threadId: undefined,
|
||||
deliveryBestEffort: true,
|
||||
},
|
||||
])(
|
||||
"delivers $name accepted child results without parent text",
|
||||
async ({ activeDescendants, deliveryBestEffort, threadId }) => {
|
||||
const childReply = "Completed child result visible to the user.";
|
||||
if (activeDescendants) {
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValueOnce(1).mockReturnValueOnce(0);
|
||||
} else {
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
|
||||
}
|
||||
vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined);
|
||||
vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(childReply);
|
||||
|
||||
const params = makeBaseParams({
|
||||
spawnOnlyHandoff: true,
|
||||
deliveryBestEffort,
|
||||
synthesizedText: "",
|
||||
});
|
||||
params.synthesizedText = undefined;
|
||||
params.deliveryPayloads = [];
|
||||
params.summary = undefined;
|
||||
params.outputText = undefined;
|
||||
params.resolvedDelivery = makeResolvedDelivery({ threadId });
|
||||
|
||||
const state = await dispatchCronDelivery(params);
|
||||
|
||||
expect(waitForDescendantSubagentSummary).toHaveBeenCalledTimes(activeDescendants ? 1 : 0);
|
||||
expect(readDescendantSubagentFallbackReply).toHaveBeenCalledWith({
|
||||
sessionKey: params.runSessionKey,
|
||||
runStartedAt: params.runStartedAt,
|
||||
});
|
||||
expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
||||
expectDeliveryCall(0, {
|
||||
channel: "telegram",
|
||||
to: "123456",
|
||||
...(threadId === undefined ? {} : { threadId }),
|
||||
payloads: [{ text: childReply }],
|
||||
});
|
||||
expect(state.delivered).toBe(true);
|
||||
expect(state.deliveryAttempted).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a substantive parent synthesis after an accepted child has completed", async () => {
|
||||
const parentReply = "Combined parent summary already includes every child result.";
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
|
||||
|
||||
const state = await dispatchCronDelivery(
|
||||
makeBaseParams({ spawnOnlyHandoff: false, synthesizedText: parentReply }),
|
||||
);
|
||||
|
||||
expect(readDescendantSubagentFallbackReply).not.toHaveBeenCalled();
|
||||
expectDeliveryCall(0, { payloads: [{ text: parentReply }] });
|
||||
expect(state.delivered).toBe(true);
|
||||
});
|
||||
|
||||
it("immediately delivers a substantive threaded parent while its accepted child runs", async () => {
|
||||
const parentReply = "Parent summary is ready for the existing thread.";
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValue(1);
|
||||
const params = makeBaseParams({ spawnOnlyHandoff: false, synthesizedText: parentReply });
|
||||
params.resolvedDelivery = makeResolvedDelivery({ threadId: "42" });
|
||||
|
||||
const state = await dispatchCronDelivery(params);
|
||||
|
||||
expect(waitForDescendantSubagentSummary).not.toHaveBeenCalled();
|
||||
expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
||||
expectDeliveryCall(0, { threadId: "42", payloads: [{ text: parentReply }] });
|
||||
expect(state.delivered).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "active child times out",
|
||||
activeDescendants: 1,
|
||||
error: "cron child-session handoff timed out before producing a final assistant payload",
|
||||
},
|
||||
{
|
||||
name: "completed child has no output",
|
||||
activeDescendants: 0,
|
||||
error: "cron child-session handoff completed without a final assistant payload",
|
||||
},
|
||||
])("fails an accepted spawn-only handoff when $name", async ({ activeDescendants, error }) => {
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValue(activeDescendants);
|
||||
const params = makeBaseParams({ spawnOnlyHandoff: true, synthesizedText: "" });
|
||||
params.synthesizedText = undefined;
|
||||
params.deliveryPayloads = [];
|
||||
params.summary = undefined;
|
||||
params.outputText = undefined;
|
||||
|
||||
const state = await dispatchCronDelivery(params);
|
||||
|
||||
expectResultFields(state.result, {
|
||||
status: "error",
|
||||
error,
|
||||
delivered: false,
|
||||
deliveryAttempted: true,
|
||||
});
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves abort precedence when an accepted child handoff is interrupted", async () => {
|
||||
const abortReason = "scheduled run aborted while waiting for its child";
|
||||
vi.mocked(countActiveDescendantRuns).mockReturnValue(1);
|
||||
const params = makeBaseParams({ spawnOnlyHandoff: true, synthesizedText: "" });
|
||||
params.synthesizedText = undefined;
|
||||
params.deliveryPayloads = [];
|
||||
params.summary = undefined;
|
||||
params.outputText = undefined;
|
||||
params.isAborted = () => true;
|
||||
params.abortReason = () => abortReason;
|
||||
|
||||
const state = await dispatchCronDelivery(params);
|
||||
|
||||
expect(waitForDescendantSubagentSummary).toHaveBeenCalledTimes(1);
|
||||
expectResultFields(state.result, { status: "error", error: abortReason });
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an empty no-spawn parent silent", async () => {
|
||||
const params = makeBaseParams({ synthesizedText: "" });
|
||||
params.synthesizedText = undefined;
|
||||
params.deliveryPayloads = [];
|
||||
params.summary = undefined;
|
||||
params.outputText = undefined;
|
||||
|
||||
const state = await dispatchCronDelivery(params);
|
||||
|
||||
expect(waitForDescendantSubagentSummary).not.toHaveBeenCalled();
|
||||
expect(readDescendantSubagentFallbackReply).not.toHaveBeenCalled();
|
||||
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
expect(state.deliveryAttempted).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the run-scoped session key for isolated cron descendant fallback delivery", async () => {
|
||||
const runStartedAt = 1_000;
|
||||
const agentSessionKey = "agent:main:cron:daily-monitor";
|
||||
|
||||
@@ -573,10 +573,11 @@ export async function dispatchCronDelivery(
|
||||
const finalizeTextDelivery = async (
|
||||
delivery: SuccessfulCronDeliveryTarget,
|
||||
): Promise<RunCronAgentTurnResult | null> => {
|
||||
if (!synthesizedText) {
|
||||
if (!synthesizedText && !params.spawnOnlyHandoff) {
|
||||
return null;
|
||||
}
|
||||
const initialSynthesizedText = synthesizedText.trim();
|
||||
const initialSynthesizedText = synthesizedText?.trim() ?? "";
|
||||
const spawnOnlyHandoff = params.spawnOnlyHandoff;
|
||||
const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText);
|
||||
const subagentRegistryRuntime = await loadDeliverySubagentRegistryRuntime();
|
||||
const subagentFollowupSessionKey = params.runSessionKey;
|
||||
@@ -584,7 +585,8 @@ export async function dispatchCronDelivery(
|
||||
subagentFollowupSessionKey,
|
||||
);
|
||||
const shouldCheckCompletedDescendants =
|
||||
activeSubagentRuns === 0 && isLikelyInterimCronMessage(initialSynthesizedText);
|
||||
activeSubagentRuns === 0 &&
|
||||
(spawnOnlyHandoff || isLikelyInterimCronMessage(initialSynthesizedText));
|
||||
const needsSubagentFollowupRuntime =
|
||||
shouldCheckCompletedDescendants || activeSubagentRuns > 0 || expectedSubagentFollowup;
|
||||
const subagentFollowupRuntime = needsSubagentFollowupRuntime
|
||||
@@ -602,7 +604,10 @@ export async function dispatchCronDelivery(
|
||||
})
|
||||
: undefined;
|
||||
const hadDescendants = activeSubagentRuns > 0 || Boolean(completedDescendantReply);
|
||||
if (!params.deliveryBestEffort && (activeSubagentRuns > 0 || expectedSubagentFollowup)) {
|
||||
if (
|
||||
(!params.deliveryBestEffort || spawnOnlyHandoff) &&
|
||||
(activeSubagentRuns > 0 || expectedSubagentFollowup)
|
||||
) {
|
||||
let finalReply = await subagentFollowupRuntime?.waitForDescendantSubagentSummary({
|
||||
sessionKey: subagentFollowupSessionKey,
|
||||
initialReply: initialSynthesizedText,
|
||||
@@ -632,6 +637,23 @@ export async function dispatchCronDelivery(
|
||||
synthesizedText = completedDescendantReply;
|
||||
deliveryPayloads = [{ text: completedDescendantReply }];
|
||||
}
|
||||
if (spawnOnlyHandoff && !synthesizedText?.trim()) {
|
||||
// An accepted spawn is the turn's only completion; retiring it without
|
||||
// child output permanently loses one-shot scheduled work.
|
||||
const error = params.isAborted()
|
||||
? params.abortReason()
|
||||
: activeSubagentRuns > 0
|
||||
? "cron child-session handoff timed out before producing a final assistant payload"
|
||||
: "cron child-session handoff completed without a final assistant payload";
|
||||
deliveryAttempted = true;
|
||||
return params.withRunSession({
|
||||
status: "error",
|
||||
error,
|
||||
delivered: false,
|
||||
deliveryAttempted,
|
||||
...params.telemetry,
|
||||
});
|
||||
}
|
||||
if (!params.deliveryBestEffort && activeSubagentRuns > 0) {
|
||||
// Parent orchestration is still in progress; avoid announcing a partial
|
||||
// update to the main requester. Mark deliveryAttempted so the timer does
|
||||
@@ -647,7 +669,7 @@ export async function dispatchCronDelivery(
|
||||
}
|
||||
if (
|
||||
hadDescendants &&
|
||||
synthesizedText.trim() === initialSynthesizedText &&
|
||||
synthesizedText?.trim() === initialSynthesizedText &&
|
||||
isLikelyInterimCronMessage(initialSynthesizedText) &&
|
||||
!isSilentReplyText(initialSynthesizedText, SILENT_REPLY_TOKEN)
|
||||
) {
|
||||
@@ -715,7 +737,8 @@ export async function dispatchCronDelivery(
|
||||
// send through the real outbound adapter so delivered=true always reflects
|
||||
// an actual channel send instead of internal announce routing.
|
||||
const useDirectDelivery =
|
||||
params.deliveryPayloadHasStructuredContent || params.resolvedDelivery.threadId != null;
|
||||
params.deliveryPayloadHasStructuredContent ||
|
||||
(params.resolvedDelivery.threadId != null && !params.spawnOnlyHandoff);
|
||||
if (useDirectDelivery) {
|
||||
const directResult = await deliverViaDirectAndCleanup(params.resolvedDelivery);
|
||||
if (directResult) {
|
||||
|
||||
@@ -333,9 +333,15 @@ export async function finalizeCronRun(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const acceptedSessionSpawn = hasAcceptedSessionSpawn(finalRunResult.acceptedSessionSpawns);
|
||||
const spawnOnlyHandoff =
|
||||
acceptedSessionSpawn &&
|
||||
deliveryPayloads.length === 0 &&
|
||||
normalizeOptionalString(synthesizedText) === undefined;
|
||||
const skipHeartbeatDelivery =
|
||||
prepared.deliveryRequested &&
|
||||
!hasFatalErrorPayload &&
|
||||
!spawnOnlyHandoff &&
|
||||
isHeartbeatOnlyResponse(deliveryPayloads, resolveHeartbeatAckMaxChars(prepared.agentCfg));
|
||||
const sourceDeliveryOutcome = resolveSourceDeliveryOutcome(prepared.sourceDelivery, {
|
||||
didSendViaMessageTool: finalRunResult.didSendViaMessagingTool,
|
||||
@@ -356,7 +362,7 @@ export async function finalizeCronRun(params: {
|
||||
const hasCommittedTerminalProgress =
|
||||
hasCommittedMessagingToolDeliveryEvidence(finalRunResult) ||
|
||||
finalRunResult.didSendDeterministicApprovalPrompt === true ||
|
||||
hasAcceptedSessionSpawn(finalRunResult.acceptedSessionSpawns) ||
|
||||
acceptedSessionSpawn ||
|
||||
(finalRunResult.successfulCronAdds ?? 0) > 0;
|
||||
const hasIntentionalSilentReply =
|
||||
finalRunResult.meta?.terminalReplyKind === "silent-empty" ||
|
||||
@@ -432,6 +438,7 @@ export async function finalizeCronRun(params: {
|
||||
resolvedDelivery: prepared.resolvedDelivery,
|
||||
deliveryRequested: prepared.deliveryRequested,
|
||||
skipHeartbeatDelivery,
|
||||
spawnOnlyHandoff,
|
||||
sourceDeliveryOutcome,
|
||||
deliveryBestEffort: resolveCronDeliveryBestEffort(prepared.input.job),
|
||||
deliveryPayloadHasStructuredContent,
|
||||
@@ -483,6 +490,10 @@ export async function finalizeCronRun(params: {
|
||||
resultWithDeliveryMeta.delivered ?? deliveryResult.delivered,
|
||||
);
|
||||
if (!hasFatalErrorPayload) {
|
||||
// Spawn-only turns are incomplete until a child produces output; keeping
|
||||
// their failure visible prevents a one-shot job from being retired.
|
||||
const incompleteSpawnOnlyHandoff =
|
||||
spawnOnlyHandoff && normalizeOptionalString(deliveryResult.synthesizedText) === undefined;
|
||||
// A successful isolated agent turn must keep `status: "ok"` even when the
|
||||
// post-run delivery phase fails. Collapsing the delivery error into the
|
||||
// execution status made the outer scheduled run report `status=error`
|
||||
@@ -492,6 +503,7 @@ export async function finalizeCronRun(params: {
|
||||
if (
|
||||
deliveryResult.result.status === "error" &&
|
||||
deliveryResult.result.errorKind !== "delivery-target" &&
|
||||
!incompleteSpawnOnlyHandoff &&
|
||||
!params.isAborted()
|
||||
) {
|
||||
const failedDeliveryError = resultWithDeliveryMeta.error;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js";
|
||||
import {
|
||||
cleanupDirectCronSessionMock,
|
||||
dispatchCronDeliveryMock,
|
||||
isHeartbeatOnlyResponseMock,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
resolveCronDeliveryPlanMock,
|
||||
resolveCronPayloadOutcomeMock,
|
||||
@@ -293,6 +294,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
|
||||
});
|
||||
|
||||
it("does not mark empty accepted child-session handoffs as cron errors", async () => {
|
||||
isHeartbeatOnlyResponseMock.mockReturnValue(true);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [],
|
||||
@@ -325,11 +327,158 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
|
||||
|
||||
expect(dispatchCronDeliveryMock).toHaveBeenCalled();
|
||||
expect(dispatchCronDeliveryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spawnOnlyHandoff: true,
|
||||
skipHeartbeatDelivery: false,
|
||||
deliveryPayloads: [],
|
||||
synthesizedText: undefined,
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves incomplete accepted child-session handoffs as cron errors", async () => {
|
||||
const error = "cron child-session handoff timed out before producing a final assistant payload";
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [],
|
||||
acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }],
|
||||
meta: {
|
||||
agentMeta: { usage: { input: 10, output: 0 } },
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
attempts: [],
|
||||
});
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
requested: true,
|
||||
mode: "announce",
|
||||
channel: "messagechat",
|
||||
to: "test-target",
|
||||
});
|
||||
resolveCronPayloadOutcomeMock.mockReturnValue({
|
||||
summary: undefined,
|
||||
outputText: undefined,
|
||||
synthesizedText: undefined,
|
||||
deliveryPayload: undefined,
|
||||
deliveryPayloads: [],
|
||||
deliveryPayloadHasStructuredContent: false,
|
||||
hasFatalErrorPayload: false,
|
||||
hasFatalStructuredErrorPayload: false,
|
||||
embeddedRunError: undefined,
|
||||
});
|
||||
dispatchCronDeliveryMock.mockImplementationOnce(({ withRunSession }) => ({
|
||||
result: withRunSession({ status: "error", error, deliveryAttempted: true }),
|
||||
delivered: false,
|
||||
deliveryAttempted: true,
|
||||
cronRunSessionCleanupAttempted: false,
|
||||
summary: undefined,
|
||||
outputText: undefined,
|
||||
synthesizedText: undefined,
|
||||
deliveryPayloads: [],
|
||||
}));
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.delivered).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps actual heartbeat acknowledgements silent after an accepted child spawn", async () => {
|
||||
const heartbeatPayload = { text: "HEARTBEAT_OK" };
|
||||
isHeartbeatOnlyResponseMock.mockReturnValue(true);
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [heartbeatPayload],
|
||||
acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 1 } } },
|
||||
},
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
attempts: [],
|
||||
});
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
requested: true,
|
||||
mode: "announce",
|
||||
channel: "messagechat",
|
||||
to: "test-target",
|
||||
});
|
||||
resolveCronPayloadOutcomeMock.mockReturnValue({
|
||||
summary: heartbeatPayload.text,
|
||||
outputText: heartbeatPayload.text,
|
||||
synthesizedText: heartbeatPayload.text,
|
||||
deliveryPayload: heartbeatPayload,
|
||||
deliveryPayloads: [heartbeatPayload],
|
||||
deliveryPayloadHasStructuredContent: false,
|
||||
hasFatalErrorPayload: false,
|
||||
hasFatalStructuredErrorPayload: false,
|
||||
embeddedRunError: undefined,
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
|
||||
|
||||
expect(dispatchCronDeliveryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ spawnOnlyHandoff: false, skipHeartbeatDelivery: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves structured-parent delivery failures after accepting a child", async () => {
|
||||
const mediaPayload = { mediaUrl: "https://example.invalid/chart.png" };
|
||||
const error = "Structured message failed";
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
payloads: [mediaPayload],
|
||||
acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 1 } } },
|
||||
},
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-8",
|
||||
attempts: [],
|
||||
});
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
requested: true,
|
||||
mode: "announce",
|
||||
channel: "messagechat",
|
||||
to: "test-target",
|
||||
});
|
||||
resolveCronPayloadOutcomeMock.mockReturnValue({
|
||||
summary: undefined,
|
||||
outputText: undefined,
|
||||
synthesizedText: undefined,
|
||||
deliveryPayload: mediaPayload,
|
||||
deliveryPayloads: [mediaPayload],
|
||||
deliveryPayloadHasStructuredContent: true,
|
||||
hasFatalErrorPayload: false,
|
||||
hasFatalStructuredErrorPayload: false,
|
||||
embeddedRunError: undefined,
|
||||
});
|
||||
dispatchCronDeliveryMock.mockImplementationOnce(({ withRunSession }) => ({
|
||||
result: withRunSession({ status: "error", error, deliveryAttempted: true }),
|
||||
delivered: false,
|
||||
deliveryAttempted: true,
|
||||
cronRunSessionCleanupAttempted: false,
|
||||
summary: undefined,
|
||||
outputText: undefined,
|
||||
synthesizedText: undefined,
|
||||
deliveryPayloads: [mediaPayload],
|
||||
}));
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture());
|
||||
|
||||
expect(dispatchCronDeliveryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spawnOnlyHandoff: false,
|
||||
deliveryPayloadHasStructuredContent: true,
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.deliveryError).toBe(error);
|
||||
});
|
||||
|
||||
it("does not mark empty successful cron-add completions as cron errors", async () => {
|
||||
runWithModelFallbackMock.mockResolvedValueOnce({
|
||||
result: {
|
||||
|
||||
Reference in New Issue
Block a user