Skip to content

Commit 0b88da2

Browse files
committed
fix(cron): surface successful run delivery errors to run logs
A successful isolated agent turn keeps status=ok when post-run delivery fails (#94058), but the ok/not-delivered resolveRunOutcome branch only forwarded delivery diagnostics and dropped the delivery dispatch error. That left lastDeliveryError empty and the finished-event deliveryError field blank, so CLI/UI/API run logs could not show why delivery did not land for an otherwise successful run. Thread the delivery error on a dedicated CronRunOutcome.deliveryError field from resolveRunOutcome through executeDetachedCronJob and applyJobResult into resolveDeliveryState, so it persists as lastDeliveryError and reaches the finished event (and thus the run log) without conflating it with a run-level error (lastError stays empty on a successful run). Add a service/run-log readback test proving the delivery error survives the cron boundary into a persisted run-log entry, and extend the isolated-turn regression to assert the dedicated deliveryError field.
1 parent fa1e053 commit 0b88da2

6 files changed

Lines changed: 127 additions & 1 deletion

File tree

src/cron/isolated-agent/run.message-tool-policy.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,6 +1865,11 @@ describe("runCronIsolatedAgentTurn delivery instruction", () => {
18651865
// Execution succeeded: status stays ok despite the delivery failure.
18661866
expect(result.status).toBe("ok");
18671867
expect(result.error).toBeUndefined();
1868+
// The delivery dispatch error is surfaced on a dedicated `deliveryError`
1869+
// field (not the run-level `error`) so the service can persist it as
1870+
// `lastDeliveryError` and emit it on the finished event for CLI/UI/API run
1871+
// logs (#95419) without mislabeling the successful run as a failure.
1872+
expect(result.deliveryError).toBe("Message failed");
18681873
// Delivery failure metadata is preserved and decoupled from status.
18691874
expect(result.delivered).toBe(false);
18701875
expect(result.deliveryAttempted).toBe(true);

src/cron/isolated-agent/run.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,12 +1233,19 @@ async function finalizeCronRun(params: {
12331233
deliveryAttempted?: boolean;
12341234
delivery?: CronDeliveryTrace;
12351235
deliveryDiagnostics?: RunCronAgentTurnResult["diagnostics"];
1236+
deliveryError?: string;
12361237
}) =>
12371238
prepared.withRunSession({
12381239
status: hasFatalErrorPayload ? "error" : "ok",
12391240
...(hasFatalErrorPayload
12401241
? { error: embeddedRunError ?? "cron isolated run returned an error payload" }
12411242
: {}),
1243+
// A delivery dispatch failure on an otherwise successful run keeps
1244+
// `status: "ok"` (#94058); surface the delivery error separately so the
1245+
// service can persist it as `lastDeliveryError` and emit it on the
1246+
// finished event for CLI/UI/API run logs (#95419) without mislabeling
1247+
// the run-level `error`.
1248+
...(result?.deliveryError ? { deliveryError: result.deliveryError } : {}),
12421249
summary,
12431250
outputText,
12441251
delivered: result?.delivered,
@@ -1384,6 +1391,10 @@ async function finalizeCronRun(params: {
13841391
deliveryAttempted: resultWithDeliveryMeta.deliveryAttempted,
13851392
delivery: deliveryTrace,
13861393
deliveryDiagnostics: resultWithDeliveryMeta.diagnostics,
1394+
// Carry the delivery dispatch error so it persists as
1395+
// `lastDeliveryError` and reaches CLI/UI/API run logs (#95419)
1396+
// instead of being dropped when status is kept `ok`.
1397+
deliveryError: deliveryResult.result.error,
13871398
});
13881399
}
13891400
return resultWithDeliveryMeta;

src/cron/service.persists-delivered-status.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Delivered status tests cover persistence of cron delivery outcomes.
22
import { describe, expect, it, vi } from "vitest";
3+
import { appendCronRunLog, readCronRunLogEntriesSync } from "./run-log.js";
34
import { CronService } from "./service.js";
45
import {
56
createFinishedBarrier,
@@ -92,10 +93,13 @@ function createIsolatedCronWithFinishedBarrier(params: {
9293
status?: "ok" | "error";
9394
delivered?: boolean;
9495
error?: string;
96+
deliveryError?: string;
9597
onFinished?: (evt: {
9698
jobId: string;
99+
error?: string;
97100
delivered?: boolean;
98101
deliveryStatus?: string;
102+
deliveryError?: string;
99103
failureNotificationDelivery?: {
100104
delivered?: boolean;
101105
status: string;
@@ -114,14 +118,17 @@ function createIsolatedCronWithFinishedBarrier(params: {
114118
status: params.status ?? ("ok" as const),
115119
summary: "done",
116120
...(params.error === undefined ? {} : { error: params.error }),
121+
...(params.deliveryError === undefined ? {} : { deliveryError: params.deliveryError }),
117122
...(params.delivered === undefined ? {} : { delivered: params.delivered }),
118123
})),
119124
onEvent: (evt) => {
120125
if (evt.action === "finished") {
121126
params.onFinished?.({
122127
jobId: evt.jobId,
128+
error: evt.error,
123129
delivered: evt.delivered,
124130
deliveryStatus: evt.deliveryStatus,
131+
deliveryError: evt.deliveryError,
125132
failureNotificationDelivery: evt.failureNotificationDelivery,
126133
});
127134
}
@@ -190,10 +197,13 @@ async function runIsolatedJobAndReadState(params: {
190197
status?: "ok" | "error";
191198
delivered?: boolean;
192199
error?: string;
200+
deliveryError?: string;
193201
onFinished?: (evt: {
194202
jobId: string;
203+
error?: string;
195204
delivered?: boolean;
196205
deliveryStatus?: string;
206+
deliveryError?: string;
197207
failureNotificationDelivery?: {
198208
delivered?: boolean;
199209
status: string;
@@ -208,6 +218,7 @@ async function runIsolatedJobAndReadState(params: {
208218
...(params.status !== undefined ? { status: params.status } : {}),
209219
...(params.delivered !== undefined ? { delivered: params.delivered } : {}),
210220
...(params.error !== undefined ? { error: params.error } : {}),
221+
...(params.deliveryError !== undefined ? { deliveryError: params.deliveryError } : {}),
211222
onFinished: (evt) => {
212223
params.onFinished?.(evt);
213224
finishedEvents.get(evt.jobId)?.(evt);
@@ -457,4 +468,78 @@ describe("CronService persists delivered status", () => {
457468
expect(capturedEvent?.delivered).toBe(true);
458469
expect(capturedEvent?.deliveryStatus).toBe("delivered");
459470
});
471+
472+
it("surfaces a successful run's delivery error to CLI/UI/API run logs across the cron boundary", async () => {
473+
// Regression for https://github.com/openclaw/openclaw/issues/95419:
474+
// when an isolated turn succeeds but post-run delivery fails, the run keeps
475+
// `status: "ok"` (#94058) while the runner now reports the dispatch failure
476+
// on a dedicated `deliveryError` field. That diagnostic must travel through
477+
// service state -> the finished event -> the persisted run-log entry so the
478+
// CLI/UI/API run logs can show *why* delivery did not land, instead of the
479+
// failure being silently dropped because the run is not marked an error.
480+
let capturedEvent:
481+
| {
482+
jobId: string;
483+
error?: string;
484+
delivered?: boolean;
485+
deliveryStatus?: string;
486+
deliveryError?: string;
487+
}
488+
| undefined;
489+
const updated = await runIsolatedJobAndReadState({
490+
job: buildAnnounceIsolatedAgentTurnJob("delivery-error-readback"),
491+
status: "ok",
492+
delivered: false,
493+
deliveryError: "Message delivery failed",
494+
onFinished: (evt) => {
495+
capturedEvent = evt;
496+
},
497+
});
498+
499+
// The run itself succeeded: the run-level error stays empty so the run is
500+
// not mislabeled as a failure, while delivery is recorded as not-delivered
501+
// and `lastDeliveryError` carries the dispatch diagnostic.
502+
expectSuccessfulCronRun(updated);
503+
expect(updated?.state.lastError).toBeUndefined();
504+
expect(updated?.state.lastDelivered).toBe(false);
505+
expect(updated?.state.lastDeliveryStatus).toBe("not-delivered");
506+
expect(updated?.state.lastDeliveryError).toBe("Message delivery failed");
507+
508+
// The finished event mirrors the persisted state: it carries the delivery
509+
// error (the field the gateway forwards into the run log) without polluting
510+
// the run-level error.
511+
expect(capturedEvent?.error).toBeUndefined();
512+
expect(capturedEvent?.delivered).toBe(false);
513+
expect(capturedEvent?.deliveryStatus).toBe("not-delivered");
514+
expect(capturedEvent?.deliveryError).toBe("Message delivery failed");
515+
516+
// Cross-cron-boundary readback: persist the finished event into the run log
517+
// exactly as the gateway does (server-cron.ts forwards `evt.deliveryError`),
518+
// then read it back the way the CLI/UI/API do. The delivery diagnostic must
519+
// survive the round-trip while the run-log `error` column stays empty.
520+
const runLogStore = await makeStorePath();
521+
await appendCronRunLog({
522+
storePath: runLogStore.storePath,
523+
entry: {
524+
ts: Date.now(),
525+
jobId: capturedEvent!.jobId,
526+
action: "finished",
527+
status: "ok",
528+
error: capturedEvent?.error,
529+
delivered: capturedEvent?.delivered,
530+
deliveryStatus: "not-delivered",
531+
deliveryError: capturedEvent?.deliveryError,
532+
},
533+
});
534+
535+
const readBack = readCronRunLogEntriesSync({
536+
storePath: runLogStore.storePath,
537+
jobId: capturedEvent!.jobId,
538+
});
539+
expect(readBack).toHaveLength(1);
540+
expect(readBack[0]?.status).toBe("ok");
541+
expect(readBack[0]?.error).toBeUndefined();
542+
expect(readBack[0]?.deliveryStatus).toBe("not-delivered");
543+
expect(readBack[0]?.deliveryError).toBe("Message delivery failed");
544+
});
460545
});

src/cron/service/ops.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ async function finishPreparedManualRun(
960960
{
961961
status: coreResult.status,
962962
error: coreResult.error,
963+
deliveryError: coreResult.deliveryError,
963964
diagnostics: coreResult.diagnostics,
964965
delivered: coreResult.delivered,
965966
provider: coreResult.provider,

src/cron/service/timer.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ export function applyJobResult(
655655
result: {
656656
status: CronRunStatus;
657657
error?: string;
658+
deliveryError?: string;
658659
diagnostics?: CronRunOutcome["diagnostics"];
659660
delivered?: boolean;
660661
provider?: string;
@@ -703,7 +704,11 @@ export function applyJobResult(
703704
job,
704705
runStatus: result.status,
705706
delivered: result.delivered,
706-
error: result.error,
707+
// A successful run keeps `error` empty but may carry a dedicated
708+
// `deliveryError` when post-run delivery failed (#94058/#95419); prefer it
709+
// so `lastDeliveryError` is populated without conflating it with a
710+
// run-level failure. Error runs fall back to the run error as before.
711+
error: result.deliveryError ?? result.error,
707712
globalFailureDestination: state.deps.cronConfig?.failureDestination,
708713
});
709714
job.state.lastDelivered = deliveryState.delivered;
@@ -974,6 +979,7 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
974979
applyJobResult(state, result.job, {
975980
status: result.status,
976981
error: result.error,
982+
deliveryError: result.deliveryError,
977983
diagnostics: result.diagnostics,
978984
delivered: result.delivered,
979985
provider: result.provider,
@@ -997,6 +1003,7 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
9971003
const shouldDelete = applyJobResult(state, job, {
9981004
status: result.status,
9991005
error: result.error,
1006+
deliveryError: result.deliveryError,
10001007
diagnostics: result.diagnostics,
10011008
delivered: result.delivered,
10021009
provider: result.provider,
@@ -2098,6 +2105,7 @@ async function executeDetachedCronJob(
20982105
return {
20992106
status: res.status,
21002107
error: res.error,
2108+
deliveryError: res.deliveryError,
21012109
summary: res.summary,
21022110
delivered: res.delivered,
21032111
deliveryAttempted: res.deliveryAttempted,
@@ -2150,6 +2158,10 @@ async function executeDetachedCronJob(
21502158
return {
21512159
status: res.status,
21522160
error: res.error,
2161+
// Forward the post-run delivery failure recorded on an otherwise
2162+
// successful run so the service can persist it as `lastDeliveryError` and
2163+
// emit it on the finished event for CLI/UI/API run logs (#95419).
2164+
deliveryError: res.deliveryError,
21532165
summary: res.summary,
21542166
delivered: res.delivered,
21552167
deliveryAttempted: res.deliveryAttempted,

src/cron/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ export type CronRunDiagnostics = {
173173
export type CronRunOutcome = {
174174
status: CronRunStatus;
175175
error?: string;
176+
/**
177+
* Post-run delivery dispatch failure recorded on an otherwise successful run.
178+
*
179+
* A successful isolated agent turn keeps `status: "ok"` even when the
180+
* post-run delivery phase fails (#94058). The execution `error` field stays
181+
* empty in that case so the run is not mislabeled as a failure, but the
182+
* delivery diagnostic must still reach `lastDeliveryError` and the finished
183+
* event so CLI/UI/API run logs can surface why delivery did not land
184+
* (#95419). This field carries that delivery error across the cron boundary
185+
* without conflating it with a run-level `error`.
186+
*/
187+
deliveryError?: string;
176188
/** Optional classifier for execution errors to guide fallback behavior. */
177189
errorKind?: "delivery-target";
178190
summary?: string;

0 commit comments

Comments
 (0)