@@ -4,10 +4,12 @@ import path from "node:path";
44import { afterEach , describe , expect , it , vi } from "vitest" ;
55import { setupCronServiceSuite , writeCronStoreSnapshot } from "../../cron/service.test-harness.js" ;
66import { createCronServiceState } from "../../cron/service/state.js" ;
7- import { executeJobCore , onTimer } from "../../cron/service/timer.js" ;
7+ import { executeJobCore , executeJobCoreWithTimeout , onTimer } from "../../cron/service/timer.js" ;
88import { loadCronStore } from "../../cron/store.js" ;
99import type { CronJob } from "../../cron/types.js" ;
10+ import { resetActiveCronTaskRunsForTests } from "../../tasks/cron-task-cancel.js" ;
1011import * as detachedTaskRuntime from "../../tasks/detached-task-runtime.js" ;
12+ import { cancelDetachedTaskRunById } from "../../tasks/task-executor.js" ;
1113import { findTaskByRunId , resetTaskRegistryForTests } from "../../tasks/task-registry.js" ;
1214import { formatTaskStatusDetail } from "../../tasks/task-status.js" ;
1315
@@ -48,6 +50,7 @@ function createDueIsolatedAgentJob(params: { now: number }): CronJob {
4850}
4951
5052afterEach ( ( ) => {
53+ resetActiveCronTaskRunsForTests ( ) ;
5154 resetTaskRegistryForTests ( ) ;
5255} ) ;
5356
@@ -272,6 +275,150 @@ describe("cron service timer seam coverage", () => {
272275 await timerRun ;
273276 } ) ;
274277
278+ it ( "cancels an active scheduled isolated cron task through the task ledger" , async ( ) => {
279+ const { storePath } = await makeStorePath ( ) ;
280+ const now = Date . parse ( "2026-03-23T12:00:00.000Z" ) ;
281+ const enqueueSystemEvent = vi . fn ( ) ;
282+ const requestHeartbeat = vi . fn ( ) ;
283+ let capturedAbortSignal : AbortSignal | undefined ;
284+ let resolveRun : ( ( value : { status : "ok" ; summary : string } ) => void ) | undefined ;
285+ const runIsolatedAgentJob = vi . fn (
286+ ( { abortSignal } : { abortSignal ?: AbortSignal } ) =>
287+ new Promise < { status : "ok" ; summary : string } > ( ( resolve ) => {
288+ capturedAbortSignal = abortSignal ;
289+ resolveRun = resolve ;
290+ } ) ,
291+ ) ;
292+
293+ await writeCronStoreSnapshot ( {
294+ storePath,
295+ jobs : [ createDueIsolatedAgentJob ( { now } ) ] ,
296+ } ) ;
297+
298+ const state = createCronServiceState ( {
299+ storePath,
300+ cronEnabled : true ,
301+ log : logger ,
302+ nowMs : ( ) => now ,
303+ enqueueSystemEvent,
304+ requestHeartbeat,
305+ runIsolatedAgentJob,
306+ } ) ;
307+
308+ const timerRun = onTimer ( state ) ;
309+ await vi . waitFor ( ( ) => {
310+ expect ( runIsolatedAgentJob ) . toHaveBeenCalledTimes ( 1 ) ;
311+ } ) ;
312+
313+ const task = findTaskByRunId ( `cron:isolated-agent-job:${ now } ` ) ;
314+ if ( ! task ) {
315+ throw new Error ( "expected active cron task ledger record" ) ;
316+ }
317+
318+ const cancelled = await cancelDetachedTaskRunById ( {
319+ cfg : { } as never ,
320+ taskId : task . taskId ,
321+ } ) ;
322+
323+ expect ( cancelled . cancelled ) . toBe ( true ) ;
324+ expect ( capturedAbortSignal ?. aborted ) . toBe ( true ) ;
325+ expect ( findTaskByRunId ( `cron:isolated-agent-job:${ now } ` ) ?. status ) . toBe ( "cancelled" ) ;
326+
327+ resolveRun ?.( { status : "ok" , summary : "done" } ) ;
328+ await timerRun ;
329+ expect ( findTaskByRunId ( `cron:isolated-agent-job:${ now } ` ) ?. status ) . toBe ( "cancelled" ) ;
330+ } ) ;
331+
332+ it ( "keeps timeout watchdog as a backstop after operator cancellation" , async ( ) => {
333+ vi . useFakeTimers ( ) ;
334+ const { storePath } = await makeStorePath ( ) ;
335+ const now = Date . parse ( "2026-03-23T12:00:00.000Z" ) ;
336+ const controller = new AbortController ( ) ;
337+ const runIsolatedAgentJob = vi . fn ( ( { onExecutionStarted } ) => {
338+ onExecutionStarted ?.( { phase : "running" } ) ;
339+ return new Promise < never > ( ( ) => { } ) ;
340+ } ) ;
341+ const state = createCronServiceState ( {
342+ storePath,
343+ cronEnabled : true ,
344+ log : logger ,
345+ nowMs : ( ) => now ,
346+ enqueueSystemEvent : vi . fn ( ) ,
347+ requestHeartbeat : vi . fn ( ) ,
348+ runIsolatedAgentJob,
349+ cleanupTimedOutAgentRun : vi . fn ( async ( ) => { } ) ,
350+ } ) ;
351+ const job : CronJob = {
352+ ...createDueIsolatedAgentJob ( { now } ) ,
353+ payload : { kind : "agentTurn" , message : "ignore abort" , timeoutSeconds : 1 } ,
354+ } ;
355+
356+ const resultPromise = executeJobCoreWithTimeout ( state , job , controller ) ;
357+ await vi . waitFor ( ( ) => {
358+ expect ( runIsolatedAgentJob ) . toHaveBeenCalledTimes ( 1 ) ;
359+ } ) ;
360+ controller . abort ( "Cancelled by operator." ) ;
361+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
362+
363+ await expect ( resultPromise ) . resolves . toMatchObject ( {
364+ status : "error" ,
365+ error : expect . stringContaining ( "timed out" ) ,
366+ } ) ;
367+ expect ( state . deps . cleanupTimedOutAgentRun ) . toHaveBeenCalled ( ) ;
368+ } ) ;
369+
370+ it ( "does not report main-session cron tasks as cancelled after enqueue" , async ( ) => {
371+ const { storePath } = await makeStorePath ( ) ;
372+ const now = Date . parse ( "2026-03-23T12:00:00.000Z" ) ;
373+ const enqueueSystemEvent = vi . fn ( ) ;
374+ const requestHeartbeat = vi . fn ( ) ;
375+ let resolveHeartbeat : ( ( value : { status : "ran" ; durationMs : number } ) => void ) | undefined ;
376+ const runHeartbeatOnce = vi . fn (
377+ ( ) =>
378+ new Promise < { status : "ran" ; durationMs : number } > ( ( resolve ) => {
379+ resolveHeartbeat = resolve ;
380+ } ) ,
381+ ) ;
382+
383+ await writeCronStoreSnapshot ( {
384+ storePath,
385+ jobs : [ createDueMainJob ( { now, wakeMode : "now" } ) ] ,
386+ } ) ;
387+
388+ const state = createCronServiceState ( {
389+ storePath,
390+ cronEnabled : true ,
391+ log : logger ,
392+ nowMs : ( ) => now ,
393+ enqueueSystemEvent,
394+ requestHeartbeat,
395+ runHeartbeatOnce,
396+ runIsolatedAgentJob : vi . fn ( async ( ) => ( { status : "ok" as const } ) ) ,
397+ } ) ;
398+
399+ const timerRun = onTimer ( state ) ;
400+ await vi . waitFor ( ( ) => {
401+ expect ( runHeartbeatOnce ) . toHaveBeenCalledTimes ( 1 ) ;
402+ } ) ;
403+
404+ const task = findTaskByRunId ( `cron:main-heartbeat-job:${ now } ` ) ;
405+ if ( ! task ) {
406+ throw new Error ( "expected active main-session cron task ledger record" ) ;
407+ }
408+
409+ const cancelled = await cancelDetachedTaskRunById ( {
410+ cfg : { } as never ,
411+ taskId : task . taskId ,
412+ } ) ;
413+
414+ expect ( cancelled . cancelled ) . toBe ( false ) ;
415+ expect ( cancelled . reason ) . toContain ( "main-session cron jobs enqueue work" ) ;
416+ expect ( findTaskByRunId ( `cron:main-heartbeat-job:${ now } ` ) ?. status ) . toBe ( "running" ) ;
417+
418+ resolveHeartbeat ?.( { status : "ran" , durationMs : 1 } ) ;
419+ await timerRun ;
420+ } ) ;
421+
275422 it ( "keeps scheduler progress when task ledger creation fails" , async ( ) => {
276423 const { storePath } = await makeStorePath ( ) ;
277424 const now = Date . parse ( "2026-03-23T12:00:00.000Z" ) ;
0 commit comments