From 6c3d76ea26dd4033c60216ba962eca30c2497a20 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 11:54:45 -0700 Subject: [PATCH] test(workflows): pin warm-kept scheduled-fire settling to the outcome window (CL-7418) Summary: - Record at the hub onTurnFinalized seam why no native terminal settle exists: one self-anchored workflow_run per definition must stay live (scheduler throws for non-live anchors; per-tick work is a repo-local child run with no row). - Add run-outcome tests proving a warm-kept scheduled fire settles via runOutcomeStatus past FIRE_RUNNING_WINDOW_MS. Verification: - bun test packages/workflows/src/schedule/run-outcome.test.ts: 6 pass - bun test packages/workflows/src/schedule/: 62 pass - bun run typecheck: exit 0; eslint + prettier on touched files: clean --- apps/hub/src/index.ts | 11 +++ .../src/schedule/run-outcome.test.ts | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 packages/workflows/src/schedule/run-outcome.test.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b6d8ae99c4..edff7ab522 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -930,6 +930,17 @@ export async function createHub(config: HubConfig) { // surviving loss loud (error-level cause, counted) instead of a // swallowed WRN. db: withTurnPartPersistGuard(withTurnPartWriteDefaults(db)), + // CL-7418: deliberately no terminal-status settle here. The folded + // routine-fire settle (CL-6778, keyed off routines.routine_run) died + // with the routines cut (CL-4455) and has no native equivalent: a + // scheduled definition owns a single self-anchored workflow_run that + // must stay live across fires — the scheduler launch path throws + // NativeWorkflowDeploymentMissingError for a non-live anchor, and a + // per-tick execution is a repo-local child run with no row of its + // own (vendor's decideTerminalRunFlip skips it as skip_repo_local). + // Stamping the anchor terminal per fire would brick the schedule, + // so a warm-kept fire settles through the runOutcomeStatus / + // FIRE_RUNNING_WINDOW_MS display-side reading instead. onTurnFinalized: (agentAddress, turn) => { artifactDeliveryHandlerRef.current?.(agentAddress, turn); }, diff --git a/packages/workflows/src/schedule/run-outcome.test.ts b/packages/workflows/src/schedule/run-outcome.test.ts new file mode 100644 index 0000000000..2e49b88cd9 --- /dev/null +++ b/packages/workflows/src/schedule/run-outcome.test.ts @@ -0,0 +1,78 @@ +// A warm-kept scheduled fire still settles. Native ScheduleTrigger +// definitions own one self-anchored workflow_run that must stay live +// across fires (CL-7418), so no server-side stamp ever flips it terminal +// per fire — surfaces read the fire settled through runOutcomeStatus +// past FIRE_RUNNING_WINDOW_MS instead. +import { describe, expect, test } from "bun:test"; + +import { FIRE_RUNNING_WINDOW_MS, runOutcomeStatus } from "./run-outcome"; + +const NOW = Date.parse("2026-09-15T12:00:00.000Z"); +const OLD = new Date(NOW - FIRE_RUNNING_WINDOW_MS - 1).toISOString(); +const FRESH = new Date(NOW - 1_000).toISOString(); + +describe("warm-kept scheduled fire settling (CL-7418)", () => { + test("a fire with no in-flight turn past the window reads completed", () => { + expect( + runOutcomeStatus( + { createdAt: OLD, status: "running", endedAt: null, turns: [] }, + NOW, + ), + ).toBe("completed"); + }); + + test("a fire flagged with no in-flight turn past the window reads completed", () => { + expect( + runOutcomeStatus( + { + createdAt: OLD, + status: "running", + endedAt: null, + hasInFlightTurn: false, + }, + NOW, + ), + ).toBe("completed"); + }); + + test("a fresh scheduled tick inside the window stays running", () => { + expect( + runOutcomeStatus( + { createdAt: FRESH, status: "running", endedAt: null, turns: [] }, + NOW, + ), + ).toBe("running"); + }); + + test("a live tool loop stays running however old the fire", () => { + expect( + runOutcomeStatus( + { + createdAt: OLD, + status: "running", + endedAt: null, + turns: [{ status: "running" }], + }, + NOW, + ), + ).toBe("running"); + }); + + test("an endedAt stamp reads completed even inside the window", () => { + expect( + runOutcomeStatus( + { createdAt: FRESH, status: "running", endedAt: FRESH, turns: [] }, + NOW, + ), + ).toBe("completed"); + }); + + test("non-running statuses pass through untouched", () => { + expect( + runOutcomeStatus( + { createdAt: OLD, status: "failed", endedAt: null, turns: [] }, + NOW, + ), + ).toBe("failed"); + }); +});