11// Delivered status tests cover persistence of cron delivery outcomes.
22import { describe , expect , it , vi } from "vitest" ;
3+ import { appendCronRunLog , readCronRunLogEntriesSync } from "./run-log.js" ;
34import { CronService } from "./service.js" ;
45import {
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} ) ;
0 commit comments