Skip to content

Commit 233744e

Browse files
feat(workflow-schedule): emit cron TimerSet for schedule-triggered deployments (CL-8169) (#765)
* Add tests for the cron emitter's re-arm A tick that fires re-arms from its own instant, so a slow or late fire lands on the next tick after the one it ran rather than on one that has already passed — the scheduler seam drops a past-due cron TimerSet as a missed tick, so arming one would stall the schedule for good. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG * workflow-schedule: emit the cron tick for schedule-triggered deployments PR #761 deleted the hub's cron poller and left `@corbits/workflow-schedule` with the grammar and `armCronTimer` but no emitter, so a schedule-triggered workflow only fired via run-now. `createCronEmitter` is that emitter: it reads the live schedule-triggered deployments, arms each one's next tick, fires it, and re-arms from the instant the tick fell on. A tick's identity is derived, not minted — `timerIdForTick(deploymentId, fireAt)` is a pure function, so two emitters arming the same tick name it the same thing. Where it lives and why the fire is a trigger, not a seam TimerFired: the `@intx/workflow-host` scheduler seam (`vendor/intx/workflow-host/src/seams/scheduler.ts`) is constructed per workflow-run child, scoped to that child's own run repo — see `apps/sidecar/src/workflow-substrate-factory/index.ts`, which builds it with `listActiveDeployments: () => [workflowRunRepoId]` — and the supervisor is the sole writer of that ref. A `TimerFired` there unblocks a `waitForTimer` inside a run that is already executing; it cannot start a fresh one, and a definition cannot re-arm its own cron. So a tick that must launch a run fires through the deployment's own native trigger, the same signed-mail primitive `runNowScheduledDefinition` uses (`apps/hub/src/native-workflow-routine-launch.ts`): a scheduled tick and a run-now are now one primitive with a different clock. The hub composition root supplies only the two host bindings the emitter cannot own — which deployments are live (`listDeployedCronDefinitions`, new in `@corbits/workflows`) and how a tick fires — and no DB claim column comes back: the exactly-once column #761 dropped stays dropped. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG
1 parent b888616 commit 233744e

9 files changed

Lines changed: 424 additions & 10 deletions

File tree

‎apps/hub/package.json‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,24 @@
2222
"@corbits/bench": "workspace:*",
2323
"@corbits/chat": "workspace:*",
2424
"@corbits/code-review-workflow": "workspace:*",
25+
"@corbits/codex-provider": "workspace:*",
2526
"@corbits/collateral-generation-workflow": "workspace:*",
2627
"@corbits/commands": "workspace:*",
27-
"@corbits/error-sink": "workspace:*",
28+
"@corbits/connections": "workspace:*",
2829
"@corbits/credential-providers": "workspace:*",
2930
"@corbits/diligence-brief-workflow": "workspace:*",
3031
"@corbits/docker-provisioner": "workspace:*",
3132
"@corbits/e2b-sandbox-sidecar": "workspace:*",
3233
"@corbits/echo-workflow": "workspace:*",
34+
"@corbits/error-sink": "workspace:*",
3335
"@corbits/evals": "workspace:*",
3436
"@corbits/exa-topic-watch-workflow": "workspace:*",
3537
"@corbits/granola-call-workflow": "workspace:*",
38+
"@corbits/hub-api-client": "workspace:*",
3639
"@corbits/inbox": "workspace:*",
3740
"@corbits/inference-catalog": "workspace:*",
38-
"@corbits/last-30-days-research-workflow": "workspace:*",
3941
"@corbits/insights": "workspace:*",
42+
"@corbits/last-30-days-research-workflow": "workspace:*",
4043
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d",
4144
"@corbits/mcp-tools": "workspace:*",
4245
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3",
@@ -54,7 +57,9 @@
5457
"@corbits/url-path": "workspace:*",
5558
"@corbits/webhook-triggers": "workspace:*",
5659
"@corbits/workbench-digest-workflow": "workspace:*",
60+
"@corbits/workflow-schedule": "workspace:*",
5761
"@corbits/workflows": "workspace:*",
62+
"@corbits/xai-provider": "workspace:*",
5863
"@intx/authz": "0.3.0",
5964
"@intx/crypto": "0.3.0",
6065
"@intx/db": "workspace:*",
@@ -67,10 +72,6 @@
6772
"@intx/workflow": "workspace:*",
6873
"@intx/workflow-deploy": "workspace:*",
6974
"@modelcontextprotocol/sdk": "catalog:",
70-
"@corbits/connections": "workspace:*",
71-
"@corbits/codex-provider": "workspace:*",
72-
"@corbits/xai-provider": "workspace:*",
73-
"@corbits/hub-api-client": "workspace:*",
7475
"arktype": "catalog:",
7576
"better-auth": "catalog:",
7677
"drizzle-orm": "catalog:",

‎apps/hub/src/index.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ import { createHubNotifyDeliveryDeps } from "./notify-delivery";
230230
import {
231231
createWorkflowAuthorRegistry,
232232
createWorkflowAuthorRoutes,
233+
listDeployedCronDefinitions,
234+
SCHEDULE_TICK_CONTENT,
233235
WorkflowAuthorError,
234236
type WorkflowDeployer,
235237
} from "@corbits/workflows";
@@ -263,7 +265,11 @@ import {
263265
import type { SidecarProvisioner } from "@intx/hub-sessions";
264266
import { withTurnPartWriteDefaults } from "./turn-part-content-default";
265267
import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory";
266-
import { runNowScheduledDefinition } from "./native-workflow-routine-launch";
268+
import {
269+
runNowScheduledDefinition,
270+
triggerNativeWorkflowRoutineRun,
271+
} from "./native-workflow-routine-launch";
272+
import { createCronEmitter } from "@corbits/workflow-schedule/emitter";
267273
import { createToolGrantsForPins } from "./tool-grants";
268274
import { drainHubServer, shutdownHub } from "./shutdown";
269275
import {
@@ -2307,6 +2313,42 @@ export async function createHub(config: HubConfig) {
23072313
bus: mailboxBus,
23082314
});
23092315

2316+
// Recurring auto-fire for schedule-triggered deployments. The cron and
2317+
// the tick arithmetic belong to `@corbits/workflow-schedule`; this root
2318+
// only supplies the two host bindings the emitter cannot own — which
2319+
// deployments are live, and how a tick fires. The fire is the same
2320+
// native trigger `runNowScheduledDefinition` uses, so a scheduled tick
2321+
// and a run-now are the same primitive with a different clock.
2322+
const cronEmitter = createCronEmitter({
2323+
listCronDeployments: async () =>
2324+
(await listDeployedCronDefinitions(db)).map((definition) => ({
2325+
...definition,
2326+
deploymentId: definition.definitionId,
2327+
})),
2328+
fire: async (definition) => {
2329+
await triggerNativeWorkflowRoutineRun(
2330+
{ db, sidecarRouter },
2331+
{
2332+
tenantId: definition.tenantId,
2333+
definitionId: definition.definitionId,
2334+
principalId: definition.creatorPrincipalId,
2335+
fromDomain: definition.tenantDomain,
2336+
content: SCHEDULE_TICK_CONTENT,
2337+
},
2338+
);
2339+
},
2340+
onError: (error, deployment) => {
2341+
reportError(error, {
2342+
operation: "workflow-schedule.cron-emitter",
2343+
extra: { deploymentId: deployment.deploymentId },
2344+
});
2345+
},
2346+
clock: () => new Date(),
2347+
});
2348+
void cronEmitter.rescan().catch((error: unknown) => {
2349+
reportError(error, { operation: "workflow-schedule.cron-emitter.boot" });
2350+
});
2351+
23102352
app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir)));
23112353

23122354
// Stock Interchange currently leaves tenant creation and dispatch ungated.
@@ -2347,6 +2389,7 @@ export async function createHub(config: HubConfig) {
23472389
chatOrchestrator.dispose();
23482390
credentialExpirySweep.stop();
23492391
inboxUnsnoozeSweep.stop();
2392+
cronEmitter.stop();
23502393
await insightsUsage.close();
23512394
await insightsLatency.close();
23522395
await benchSettings.close();

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/workflow-schedule/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"type": "module",
88
"exports": {
99
".": "./src/index.ts",
10-
"./timer": "./src/timer.ts"
10+
"./timer": "./src/timer.ts",
11+
"./emitter": "./src/emitter.ts"
1112
},
1213
"scripts": {
1314
"typecheck": "tsc --noEmit",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// The emitter's own logic worth proving: a tick re-arms after it fires,
2+
// at the next instant after the tick that ran — never at the one it just
3+
// ran, and never at a tick that fell during a slow fire.
4+
import { describe, expect, test } from "bun:test";
5+
6+
import {
7+
createCronEmitter,
8+
timerIdForTick,
9+
type CronDeployment,
10+
} from "./emitter";
11+
12+
const deployment: CronDeployment = {
13+
deploymentId: "def_digest",
14+
cron: "* * * * *",
15+
};
16+
17+
describe("createCronEmitter re-arm", () => {
18+
test("re-arms at the tick after the one that fired, skipping ticks a slow fire ran through", async () => {
19+
// 20ms before the tick, so the armed `setTimeout` really elapses.
20+
let now = new Date("2026-01-01T09:00:59.980Z");
21+
const fired: string[] = [];
22+
const emitter = createCronEmitter({
23+
listCronDeployments: () => Promise.resolve([deployment]),
24+
fire: (_deployment, tick) => {
25+
fired.push(tick.fireAt);
26+
// The fire takes three minutes of wall clock.
27+
now = new Date("2026-01-01T09:04:10.000Z");
28+
return Promise.resolve();
29+
},
30+
onError: (error) => {
31+
throw error;
32+
},
33+
clock: () => now,
34+
rescanIntervalMs: 60_000,
35+
});
36+
37+
await emitter.rescan();
38+
expect(emitter.armed()).toEqual([
39+
{
40+
deploymentId: "def_digest",
41+
tick: {
42+
timerId: timerIdForTick(
43+
"def_digest",
44+
new Date("2026-01-01T09:01:00.000Z"),
45+
),
46+
fireAt: "2026-01-01T09:01:00.000Z",
47+
cron: "* * * * *",
48+
},
49+
},
50+
]);
51+
52+
await Bun.sleep(40);
53+
expect(fired).toEqual(["2026-01-01T09:01:00.000Z"]);
54+
// Re-armed strictly after the slow fire's end, not at 09:02 (a tick
55+
// that already passed — the scheduler seam drops a past-due cron
56+
// TimerSet, so arming one would stall the schedule for good).
57+
expect(emitter.armed()[0]?.tick.fireAt).toBe("2026-01-01T09:05:00.000Z");
58+
emitter.stop();
59+
});
60+
61+
test("a second rescan does not re-arm an already-armed tick", async () => {
62+
const now = new Date("2026-01-01T09:00:30.000Z");
63+
const emitter = createCronEmitter({
64+
listCronDeployments: () => Promise.resolve([deployment]),
65+
fire: () => Promise.resolve(),
66+
onError: (error) => {
67+
throw error;
68+
},
69+
clock: () => now,
70+
rescanIntervalMs: 60_000,
71+
});
72+
await emitter.rescan();
73+
const first = emitter.armed()[0]?.tick.timerId;
74+
await emitter.rescan();
75+
expect(emitter.armed()).toHaveLength(1);
76+
expect(emitter.armed()[0]?.tick.timerId).toBe(first);
77+
emitter.stop();
78+
});
79+
80+
test("a deployment that left the list is disarmed", async () => {
81+
const now = new Date("2026-01-01T09:00:30.000Z");
82+
let listed: readonly CronDeployment[] = [deployment];
83+
const emitter = createCronEmitter({
84+
listCronDeployments: () => Promise.resolve(listed),
85+
fire: () => Promise.resolve(),
86+
onError: (error) => {
87+
throw error;
88+
},
89+
clock: () => now,
90+
rescanIntervalMs: 60_000,
91+
});
92+
await emitter.rescan();
93+
expect(emitter.armed()).toHaveLength(1);
94+
listed = [];
95+
await emitter.rescan();
96+
expect(emitter.armed()).toHaveLength(0);
97+
emitter.stop();
98+
});
99+
});

0 commit comments

Comments
 (0)