@cat-factory/worker 0.99.1 → 0.99.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -168,6 +168,332 @@ const GITHUB_SYNC_QUEUE_NAME = 'cat-factory-github-sync';
168
168
  * DELETEs ~720×/day against the single D1 writer. Routed by `controller.cron`.
169
169
  */
170
170
  const RETENTION_CRON = '0 3 * * *';
171
+ /**
172
+ * Daily pass: prune the unbounded ledgers/projections to their retention
173
+ * windows. The tables exist regardless of whether GitHub/agents are
174
+ * configured, so this runs unconditionally; an unused table reclaims nothing.
175
+ */
176
+ function runDailyRetentionSweeps(env, ctx, clock) {
177
+ // ADR 0026 D6.1: the Worker has no boot moment, so the O(1) ENCRYPTION_KEY drift check
178
+ // rides the daily cron. It seeds the fingerprint on first run and logs a definitive
179
+ // drift signal on a key change. Independent of (and cheaper than) the retention work.
180
+ if (env.ENCRYPTION_KEY) {
181
+ const encryptionKey = env.ENCRYPTION_KEY;
182
+ ctx.waitUntil(checkKeyFingerprint({
183
+ store: new D1KeyFingerprintStore({ db: env.DB }),
184
+ masterKeyBase64: encryptionKey,
185
+ logger: keyFingerprintLogger,
186
+ }).catch((error) => logger.error({ cron: 'key-fingerprint', err: errInfo(error) }, 'key fingerprint check failed')));
187
+ // ADR 0026 D6.2: the drift sweep — decrypt every sealed credential and raise/clear ONE
188
+ // `key_drift` card per affected workspace. Rides the same daily cron as the fingerprint.
189
+ ctx.waitUntil(sweepKeyDriftAndRaise(buildContainer(env), (info) => new WebCryptoSecretCipher({ masterKeyBase64: encryptionKey, info }), keyFingerprintLogger).catch((error) => logger.error({ cron: 'key-drift', err: errInfo(error) }, 'key drift sweep failed')));
190
+ }
191
+ // This branch never calls buildContainer (no request container is built for the
192
+ // sweep), so do the same fail-fast the build does: a clear error beats an opaque
193
+ // NPE deep in a telemetry repo when the binding is unbound.
194
+ const telemetryDb = requireTelemetryDb(env);
195
+ ctx.waitUntil(sweepRetention({
196
+ tokenUsageRepository: new D1TokenUsageRepository({ db: env.DB }),
197
+ rateLimitRepository: new D1RateLimitRepository({
198
+ db: env.DB,
199
+ idGenerator: new CryptoIdGenerator(),
200
+ }),
201
+ commitRepository: new D1CommitProjectionRepository({ db: env.DB }),
202
+ // Telemetry tables live in the dedicated TELEMETRY_DB database.
203
+ llmCallMetricRepository: new D1LlmCallMetricRepository({ db: telemetryDb }),
204
+ agentContextSnapshotRepository: new D1AgentContextSnapshotRepository({
205
+ db: telemetryDb,
206
+ }),
207
+ agentSearchQueryRepository: new D1AgentSearchQueryRepository({ db: telemetryDb }),
208
+ // Modeled subscription quota-cycle counters live in the main DB (migration 0047).
209
+ subscriptionQuotaCycleRepository: new D1SubscriptionQuotaCycleRepository({ db: env.DB }),
210
+ pipelineScheduleRepository: new D1PipelineScheduleRepository({ db: env.DB }),
211
+ passwordResetTokenRepository: new D1PasswordResetTokenRepository({ db: env.DB }),
212
+ notificationRepository: new D1NotificationRepository({ db: env.DB }),
213
+ // Prune the separate provisioning-log database when its binding is present.
214
+ ...(env.PROVISIONING_DB
215
+ ? {
216
+ provisioningLogRepository: new D1ProvisioningLogRepository({
217
+ db: env.PROVISIONING_DB,
218
+ }),
219
+ }
220
+ : {}),
221
+ clock,
222
+ policy: loadConfig(env).retention,
223
+ })
224
+ .then((result) => logger.info({ cron: 'retention', ...result }, 'retention sweep complete'))
225
+ .catch((error) => logger.error({ cron: 'retention', err: errInfo(error) }, 'retention sweep failed')));
226
+ // Binary-artifact retention (UI screenshots + reference designs) is per-workspace, and
227
+ // the blob backend is per-account (R2 or S3), so it resolves each workspace's store. Run
228
+ // whenever storage could be configured: the R2 default (ARTIFACT_BUCKET) OR a per-account
229
+ // S3 backend (which needs the encryption key to unseal its credentials).
230
+ if (env.ARTIFACT_BUCKET || env.ENCRYPTION_KEY) {
231
+ const settingsRepo = new D1WorkspaceSettingsRepository({ db: env.DB });
232
+ ctx.waitUntil(sweepBinaryArtifactRetention({
233
+ resolveStore: buildCloudflareArtifactStoreResolver(env, env.DB, clock, new CryptoIdGenerator()),
234
+ listWorkspaceIds: () => new D1WorkspaceRepository({ db: env.DB })
235
+ .listVisible(null)
236
+ .then((ws) => ws.map((w) => w.id)),
237
+ retentionDaysFor: (workspaceId) => settingsRepo
238
+ .get(workspaceId)
239
+ .then((s) => s?.artifactRetentionDays ?? DEFAULT_WORKSPACE_SETTINGS.artifactRetentionDays),
240
+ now: clock.now(),
241
+ })
242
+ .then((removed) => logger.info({ cron: 'retention', binaryArtifacts: removed }, 'artifact retention sweep complete'))
243
+ .catch((error) => logger.error({ cron: 'retention', err: errInfo(error) }, 'artifact retention sweep failed')));
244
+ }
245
+ }
246
+ /**
247
+ * Re-drive any agent run — execution OR bootstrap — whose Workflows instance
248
+ * died. One sweep over the unified agent_runs table dispatches by kind.
249
+ */
250
+ function redriveStuckAgentRuns(env, ctx, clock) {
251
+ if (env.EXECUTION_WORKFLOW || env.BOOTSTRAP_WORKFLOW || env.ENV_CONFIG_REPAIR_WORKFLOW) {
252
+ const execLookup = env.EXECUTION_WORKFLOW ? new WorkflowsLookup(env.EXECUTION_WORKFLOW) : null;
253
+ const bootLookup = env.BOOTSTRAP_WORKFLOW ? new WorkflowsLookup(env.BOOTSTRAP_WORKFLOW) : null;
254
+ const repairLookup = env.ENV_CONFIG_REPAIR_WORKFLOW
255
+ ? new WorkflowsLookup(env.ENV_CONFIG_REPAIR_WORKFLOW)
256
+ : null;
257
+ const execRunner = env.EXECUTION_WORKFLOW
258
+ ? new WorkflowsWorkRunner({ workflow: env.EXECUTION_WORKFLOW, queue: env.EXECUTION_QUEUE })
259
+ : null;
260
+ const bootRunner = env.BOOTSTRAP_WORKFLOW
261
+ ? new WorkflowsBootstrapRunner(env.BOOTSTRAP_WORKFLOW)
262
+ : null;
263
+ const repairRunner = env.ENV_CONFIG_REPAIR_WORKFLOW
264
+ ? new WorkflowsEnvConfigRepairRunner(env.ENV_CONFIG_REPAIR_WORKFLOW)
265
+ : null;
266
+ ctx.waitUntil(sweepStuckRuns({
267
+ agentRunRepository: new D1AgentRunRepository({ db: env.DB }),
268
+ instanceState: (ref) => {
269
+ const lookup = ref.kind === 'bootstrap'
270
+ ? bootLookup
271
+ : ref.kind === 'env-config-repair'
272
+ ? repairLookup
273
+ : execLookup;
274
+ // No binding for this kind → can't classify, so treat as alive (skip).
275
+ return lookup ? lookup.instanceState(ref.id) : Promise.resolve('alive');
276
+ },
277
+ redrive: async (ref) => {
278
+ if (ref.kind === 'bootstrap')
279
+ await bootRunner?.startRun(ref.workspaceId, ref.id);
280
+ else if (ref.kind === 'env-config-repair')
281
+ await repairRunner?.startRun(ref.workspaceId, ref.id);
282
+ else
283
+ await execRunner?.startRun(ref.workspaceId, ref.id);
284
+ },
285
+ // The durable instance is terminal and can't be recreated → finalize the
286
+ // run as stopped so it stops showing `running` forever (also reclaims any
287
+ // leftover container). Reuses the same stop path the user-facing button hits.
288
+ finalizeOrphan: async (ref) => {
289
+ const container = buildContainer(env);
290
+ const reason = 'The run was stopped automatically: its durable driver ended without finalizing it.';
291
+ if (ref.kind === 'bootstrap') {
292
+ if (container.bootstrap) {
293
+ await container.bootstrap.service.stop(ref.workspaceId, ref.id, {
294
+ reason,
295
+ kind: 'unknown',
296
+ });
297
+ }
298
+ }
299
+ else if (ref.kind === 'env-config-repair') {
300
+ if (container.envConfigRepair) {
301
+ await container.envConfigRepair.service.stop(ref.workspaceId, ref.id, {
302
+ reason,
303
+ kind: 'unknown',
304
+ });
305
+ }
306
+ }
307
+ else {
308
+ await container.executionService.stopRun(ref.workspaceId, ref.id, {
309
+ reason,
310
+ kind: 'unknown',
311
+ });
312
+ }
313
+ },
314
+ // An execution whose instance stays missing past this deadline is failed
315
+ // `stalled` rather than re-created forever (symmetric with the Node sweeper).
316
+ failStalled: async (ref) => {
317
+ const container = buildContainer(env);
318
+ await container.executionService.failRun(ref.workspaceId, ref.id, 'Run stalled: its durable driver was lost and automatic recovery could not resume it.', 'stalled', null);
319
+ },
320
+ clock,
321
+ leaseMs: SWEEP_LEASE_MS,
322
+ hardStallMs: SWEEP_HARD_STALL_MS,
323
+ orphanedSince: runSweepOrphanedSince,
324
+ })
325
+ // Surface what the sweep did — the key signal for "are runs getting stuck?"
326
+ // Only log when it actually acted.
327
+ .then(({ redriven, finalized, stalled }) => {
328
+ if (redriven > 0 || finalized > 0 || stalled > 0) {
329
+ logger.warn({ cron: 'run-sweeper', redriven, finalized, stalled }, 'swept stuck runs');
330
+ }
331
+ })
332
+ .catch((error) => logger.error({ cron: 'run-sweeper', err: errInfo(error) }, 'run sweep failed')));
333
+ }
334
+ }
335
+ /**
336
+ * Env-test self-tests live in their own table (not agent_runs), so the unified run
337
+ * sweep never sees them — this sibling sweep re-drives a run whose Workflows instance
338
+ * was lost and finalizes (cleanup + failed) one whose instance is terminal.
339
+ */
340
+ function redriveStuckEnvTests(env, ctx, clock) {
341
+ if (env.ENV_TEST_WORKFLOW) {
342
+ const envTestLookup = new WorkflowsLookup(env.ENV_TEST_WORKFLOW);
343
+ const envTestRunner = new WorkflowsEnvironmentTestRunner(env.ENV_TEST_WORKFLOW);
344
+ ctx.waitUntil(sweepStuckEnvTests({
345
+ repository: new D1EnvironmentTestRunRepository({ db: env.DB }),
346
+ instanceState: (runId) => envTestLookup.instanceState(runId),
347
+ redrive: (workspaceId, runId) => envTestRunner.startRun(workspaceId, runId),
348
+ finalizeOrphan: async (workspaceId, runId) => {
349
+ const container = buildContainer(env);
350
+ await container.environments?.environmentTest?.expire(workspaceId, runId, 'The environment test was stopped automatically: its durable driver ended without finalizing it.');
351
+ },
352
+ clock,
353
+ leaseMs: SWEEP_LEASE_MS,
354
+ })
355
+ .then(({ redriven, finalized }) => {
356
+ if (redriven > 0 || finalized > 0) {
357
+ logger.warn({ cron: 'env-test-sweeper', redriven, finalized }, 'swept stuck env-test runs');
358
+ }
359
+ })
360
+ .catch((error) => logger.error({ cron: 'env-test-sweeper', err: errInfo(error) }, 'env-test sweep failed')));
361
+ }
362
+ }
363
+ /**
364
+ * Reclaim expired personal-credential activations (individual-usage subscriptions).
365
+ * Each is a short-lived, system-encrypted per-run copy of a user's token; the TTL
366
+ * bounds standing exposure and a finished run's rows are deleted at completion, but
367
+ * this backstop also clears any that outlived their TTL. The table always exists.
368
+ */
369
+ function reclaimExpiredActivations(env, ctx, clock) {
370
+ const activations = new D1SubscriptionActivationRepository({ db: env.DB });
371
+ ctx.waitUntil(activations
372
+ .deleteExpired(clock.now())
373
+ .then((reclaimed) => {
374
+ if (reclaimed > 0)
375
+ logger.info({ cron: 'activation-sweeper', reclaimed }, 'reclaimed activations');
376
+ })
377
+ .catch((error) => logger.error({ cron: 'activation-sweeper', err: errInfo(error) }, 'activation sweep failed')));
378
+ }
379
+ /**
380
+ * Instance-level container reaper: kill any per-run container that outlived its
381
+ * legitimate maximum lifetime. This is the load-bearing backstop the run-record
382
+ * nets miss — a terminal run whose container survived, or a stuck-`running` run
383
+ * a live driver keeps warm (so its idle sleep clock never starts). Keys off the
384
+ * real live-container inventory, not the run record, and kills via the same
385
+ * EXEC_CONTAINER binding (no Cloudflare API token). With normal runs now self-
386
+ * reclaiming, a reaped container is a genuine leak — the registry logs each loudly.
387
+ */
388
+ function reapStaleContainers(env, ctx, clock) {
389
+ if (env.EXEC_CONTAINER) {
390
+ const reaper = new ContainerInstanceRegistry(env.EXEC_CONTAINER, new D1LiveContainerRepository({ db: env.DB }), clock);
391
+ const maxAgeMs = loadConfig(env).execution.containerMaxAgeMs;
392
+ ctx.waitUntil(reaper
393
+ .reapStaleBefore(clock.now() - maxAgeMs)
394
+ .then(({ reaped }) => {
395
+ if (reaped > 0)
396
+ logger.warn({ cron: 'container-reaper', reaped }, 'reaped leaked containers');
397
+ })
398
+ .catch((error) => logger.error({ cron: 'container-reaper', err: errInfo(error) }, 'container reap failed')));
399
+ }
400
+ }
401
+ /**
402
+ * The remaining every-2-min backstops: notification escalation, recurring pipelines,
403
+ * the initiative loop, Kaizen gradings, GitHub reconcile, environment teardown, and the
404
+ * platform observability/health sweeps. Each is an independent, no-op-when-unwired sweep.
405
+ */
406
+ function runPeriodicBackstops(env, ctx, clock) {
407
+ // Escalate long-waiting notifications yellow → red (every 2 min). Runs no longer
408
+ // time out waiting for a human, so the escalating notification — past each
409
+ // workspace's `waitingEscalationMinutes` threshold — is the overdue-human signal.
410
+ ctx.waitUntil(escalateStaleNotifications(buildContainer(env), clock.now())
411
+ .then((escalated) => {
412
+ if (escalated > 0)
413
+ logger.info({ cron: 'notification-escalation', escalated }, 'escalated notifications');
414
+ })
415
+ .catch((error) => logger.error({ cron: 'notification-escalation', err: errInfo(error) }, 'notification escalation failed')));
416
+ // Fire any due recurring pipelines (every 2 min; the actual cadence is hours).
417
+ // Each due schedule starts its pipeline against its reused block, skipping any
418
+ // whose block already has an active run. No-op when the feature isn't wired.
419
+ ctx.waitUntil(Promise.resolve(buildContainer(env).recurring?.service.runDue(clock.now()))
420
+ .then((result) => {
421
+ if (result && (result.fired > 0 || result.skipped > 0)) {
422
+ logger.info({ cron: 'recurring-pipelines', ...result }, 'fired recurring pipelines');
423
+ }
424
+ })
425
+ .catch((error) => logger.error({ cron: 'recurring-pipelines', err: errInfo(error) }, 'recurring-pipeline sweep failed')));
426
+ // Tick the initiative execution loop (every 2 min): reconcile each executing initiative's
427
+ // spawned tasks and spawn the next wave up to its concurrency cap. Terminal child runs poke
428
+ // the loop directly, so this is the backstop cadence. No-op when initiatives aren't wired.
429
+ ctx.waitUntil(Promise.resolve(buildContainer(env).initiatives?.loop.runDue(clock.now()))
430
+ .then((result) => {
431
+ if (result && (result.spawned > 0 || result.completed > 0)) {
432
+ logger.info({ cron: 'initiative-loop', ...result }, 'ticked initiative loop');
433
+ }
434
+ })
435
+ .catch((error) => logger.error({ cron: 'initiative-loop', err: errInfo(error) }, 'initiative-loop sweep failed')));
436
+ // Run any pending Kaizen gradings (every 2 min): the engine only inserts `scheduled`
437
+ // rows at run completion, so this background pass does the actual LLM grading (and
438
+ // re-drives `running` rows orphaned by a crashed sweep). Bounded per pass to stay
439
+ // within the cron budget; no-op when the Kaizen feature isn't wired. The grader's
440
+ // model is resolved per-workspace (Model Configuration), so this is workspace-wide.
441
+ if (!kaizenSweeping) {
442
+ kaizenSweeping = true;
443
+ ctx.waitUntil(Promise.resolve(buildContainer(env).kaizen?.service.runPending(clock.now() - KAIZEN_STALE_MS, KAIZEN_SWEEP_BATCH))
444
+ .then((processed) => {
445
+ if (processed && processed > 0)
446
+ logger.info({ cron: 'kaizen-sweeper', processed }, 'ran pending kaizen gradings');
447
+ })
448
+ .catch((error) => logger.error({ cron: 'kaizen-sweeper', err: errInfo(error) }, 'kaizen sweep failed'))
449
+ .finally(() => {
450
+ kaizenSweeping = false;
451
+ }));
452
+ }
453
+ // Reconcile GitHub projections that may have missed a webhook (no-op unless
454
+ // the integration is configured).
455
+ ctx.waitUntil(reconcileStaleRepos(env, clock, GITHUB_RECONCILE_STALE_MS)
456
+ .then((scheduled) => {
457
+ if (scheduled > 0)
458
+ // `sweep:` (not `cron:`) so the summary shares a field with the pass's
459
+ // per-repo lines, which the shared reconcile core emits on both facades.
460
+ logger.info({ sweep: 'github-reconcile', scheduled }, 'scheduled repo resyncs');
461
+ })
462
+ .catch((error) => logger.error({ sweep: 'github-reconcile', err: errInfo(error) }, 'github reconcile failed')));
463
+ // Tear down ephemeral environments whose TTL has elapsed (no-op unless the
464
+ // environment integration is configured).
465
+ ctx.waitUntil(sweepExpiredEnvironments(env, clock).catch((error) => logger.error({ cron: 'env-sweeper', err: errInfo(error) }, 'environment sweep failed')));
466
+ // Push deployment-level (platform-operator) observability aggregates to the OTLP
467
+ // endpoint as OpenTelemetry gauge metrics, once per cron tick. Opt-in on top of the base
468
+ // OTel exporter (OTEL_PLATFORM_METRICS); a no-op otherwise. Per account, enumerated from
469
+ // the workspace projection — the same `listVisible(null)` shape the artifact sweep uses.
470
+ // The container (hence the platform-observability read) is built only when opted in.
471
+ {
472
+ const otel = loadConfig(env).otel;
473
+ const sweep = runPlatformMetricsSweep({
474
+ otel,
475
+ platformObservability: otel.platformMetrics.enabled
476
+ ? buildContainer(env).platformObservability
477
+ : undefined,
478
+ workspaceRepository: new D1WorkspaceRepository({ db: env.DB }),
479
+ logger,
480
+ });
481
+ if (sweep)
482
+ ctx.waitUntil(sweep);
483
+ }
484
+ // Raise/clear `platform_health` notifications when the deployment's OWN run health crosses
485
+ // an operator threshold, per account (the push counterpart to the operator dashboard read).
486
+ // Opt-in (`PLATFORM_ALERTS`); the container (hence the platform-observability read) is built
487
+ // only when opted in so a deployment that hasn't opted in pays nothing.
488
+ if (loadConfig(env).platformAlerts.enabled) {
489
+ ctx.waitUntil(sweepPlatformHealth(buildContainer(env), logger)
490
+ .then(({ raised, cleared }) => {
491
+ if (raised > 0 || cleared > 0)
492
+ logger.info({ cron: 'platform-health', raised, cleared }, 'platform health sweep');
493
+ })
494
+ .catch((error) => logger.error({ cron: 'platform-health', err: errInfo(error) }, 'platform health sweep failed')));
495
+ }
496
+ }
171
497
  export default {
172
498
  // Validate the registered extensions (gates / agent kinds) ONCE, on the first request —
173
499
  // by which point every `register*` import side effect has run. A typo'd gate helperKind or
@@ -185,311 +511,17 @@ export default {
185
511
  },
186
512
  async scheduled(controller, env, ctx) {
187
513
  const clock = new SystemClock();
188
- // Daily pass: prune the unbounded ledgers/projections to their retention
189
- // windows. The tables exist regardless of whether GitHub/agents are
190
- // configured, so this runs unconditionally; an unused table reclaims nothing.
514
+ // Daily pass: prune the unbounded ledgers/projections to their retention windows.
191
515
  if (controller.cron === RETENTION_CRON) {
192
- // ADR 0026 D6.1: the Worker has no boot moment, so the O(1) ENCRYPTION_KEY drift check
193
- // rides the daily cron. It seeds the fingerprint on first run and logs a definitive
194
- // drift signal on a key change. Independent of (and cheaper than) the retention work.
195
- if (env.ENCRYPTION_KEY) {
196
- const encryptionKey = env.ENCRYPTION_KEY;
197
- ctx.waitUntil(checkKeyFingerprint({
198
- store: new D1KeyFingerprintStore({ db: env.DB }),
199
- masterKeyBase64: encryptionKey,
200
- logger: keyFingerprintLogger,
201
- }).catch((error) => logger.error({ cron: 'key-fingerprint', err: errInfo(error) }, 'key fingerprint check failed')));
202
- // ADR 0026 D6.2: the drift sweep — decrypt every sealed credential and raise/clear ONE
203
- // `key_drift` card per affected workspace. Rides the same daily cron as the fingerprint.
204
- ctx.waitUntil(sweepKeyDriftAndRaise(buildContainer(env), (info) => new WebCryptoSecretCipher({ masterKeyBase64: encryptionKey, info }), keyFingerprintLogger).catch((error) => logger.error({ cron: 'key-drift', err: errInfo(error) }, 'key drift sweep failed')));
205
- }
206
- // This branch never calls buildContainer (no request container is built for the
207
- // sweep), so do the same fail-fast the build does: a clear error beats an opaque
208
- // NPE deep in a telemetry repo when the binding is unbound.
209
- const telemetryDb = requireTelemetryDb(env);
210
- ctx.waitUntil(sweepRetention({
211
- tokenUsageRepository: new D1TokenUsageRepository({ db: env.DB }),
212
- rateLimitRepository: new D1RateLimitRepository({
213
- db: env.DB,
214
- idGenerator: new CryptoIdGenerator(),
215
- }),
216
- commitRepository: new D1CommitProjectionRepository({ db: env.DB }),
217
- // Telemetry tables live in the dedicated TELEMETRY_DB database.
218
- llmCallMetricRepository: new D1LlmCallMetricRepository({ db: telemetryDb }),
219
- agentContextSnapshotRepository: new D1AgentContextSnapshotRepository({
220
- db: telemetryDb,
221
- }),
222
- agentSearchQueryRepository: new D1AgentSearchQueryRepository({ db: telemetryDb }),
223
- // Modeled subscription quota-cycle counters live in the main DB (migration 0047).
224
- subscriptionQuotaCycleRepository: new D1SubscriptionQuotaCycleRepository({ db: env.DB }),
225
- pipelineScheduleRepository: new D1PipelineScheduleRepository({ db: env.DB }),
226
- passwordResetTokenRepository: new D1PasswordResetTokenRepository({ db: env.DB }),
227
- notificationRepository: new D1NotificationRepository({ db: env.DB }),
228
- // Prune the separate provisioning-log database when its binding is present.
229
- ...(env.PROVISIONING_DB
230
- ? {
231
- provisioningLogRepository: new D1ProvisioningLogRepository({
232
- db: env.PROVISIONING_DB,
233
- }),
234
- }
235
- : {}),
236
- clock,
237
- policy: loadConfig(env).retention,
238
- })
239
- .then((result) => logger.info({ cron: 'retention', ...result }, 'retention sweep complete'))
240
- .catch((error) => logger.error({ cron: 'retention', err: errInfo(error) }, 'retention sweep failed')));
241
- // Binary-artifact retention (UI screenshots + reference designs) is per-workspace, and
242
- // the blob backend is per-account (R2 or S3), so it resolves each workspace's store. Run
243
- // whenever storage could be configured: the R2 default (ARTIFACT_BUCKET) OR a per-account
244
- // S3 backend (which needs the encryption key to unseal its credentials).
245
- if (env.ARTIFACT_BUCKET || env.ENCRYPTION_KEY) {
246
- const settingsRepo = new D1WorkspaceSettingsRepository({ db: env.DB });
247
- ctx.waitUntil(sweepBinaryArtifactRetention({
248
- resolveStore: buildCloudflareArtifactStoreResolver(env, env.DB, clock, new CryptoIdGenerator()),
249
- listWorkspaceIds: () => new D1WorkspaceRepository({ db: env.DB })
250
- .listVisible(null)
251
- .then((ws) => ws.map((w) => w.id)),
252
- retentionDaysFor: (workspaceId) => settingsRepo
253
- .get(workspaceId)
254
- .then((s) => s?.artifactRetentionDays ?? DEFAULT_WORKSPACE_SETTINGS.artifactRetentionDays),
255
- now: clock.now(),
256
- })
257
- .then((removed) => logger.info({ cron: 'retention', binaryArtifacts: removed }, 'artifact retention sweep complete'))
258
- .catch((error) => logger.error({ cron: 'retention', err: errInfo(error) }, 'artifact retention sweep failed')));
259
- }
516
+ runDailyRetentionSweeps(env, ctx, clock);
260
517
  return;
261
518
  }
262
519
  // Frequent pass (every 2 min): time-sensitive backstops.
263
- // Re-drive any agent run — execution OR bootstrap — whose Workflows instance
264
- // died. One sweep over the unified agent_runs table dispatches by kind.
265
- if (env.EXECUTION_WORKFLOW || env.BOOTSTRAP_WORKFLOW || env.ENV_CONFIG_REPAIR_WORKFLOW) {
266
- const execLookup = env.EXECUTION_WORKFLOW ? new WorkflowsLookup(env.EXECUTION_WORKFLOW) : null;
267
- const bootLookup = env.BOOTSTRAP_WORKFLOW ? new WorkflowsLookup(env.BOOTSTRAP_WORKFLOW) : null;
268
- const repairLookup = env.ENV_CONFIG_REPAIR_WORKFLOW
269
- ? new WorkflowsLookup(env.ENV_CONFIG_REPAIR_WORKFLOW)
270
- : null;
271
- const execRunner = env.EXECUTION_WORKFLOW
272
- ? new WorkflowsWorkRunner({ workflow: env.EXECUTION_WORKFLOW, queue: env.EXECUTION_QUEUE })
273
- : null;
274
- const bootRunner = env.BOOTSTRAP_WORKFLOW
275
- ? new WorkflowsBootstrapRunner(env.BOOTSTRAP_WORKFLOW)
276
- : null;
277
- const repairRunner = env.ENV_CONFIG_REPAIR_WORKFLOW
278
- ? new WorkflowsEnvConfigRepairRunner(env.ENV_CONFIG_REPAIR_WORKFLOW)
279
- : null;
280
- ctx.waitUntil(sweepStuckRuns({
281
- agentRunRepository: new D1AgentRunRepository({ db: env.DB }),
282
- instanceState: (ref) => {
283
- const lookup = ref.kind === 'bootstrap'
284
- ? bootLookup
285
- : ref.kind === 'env-config-repair'
286
- ? repairLookup
287
- : execLookup;
288
- // No binding for this kind → can't classify, so treat as alive (skip).
289
- return lookup ? lookup.instanceState(ref.id) : Promise.resolve('alive');
290
- },
291
- redrive: async (ref) => {
292
- if (ref.kind === 'bootstrap')
293
- await bootRunner?.startRun(ref.workspaceId, ref.id);
294
- else if (ref.kind === 'env-config-repair')
295
- await repairRunner?.startRun(ref.workspaceId, ref.id);
296
- else
297
- await execRunner?.startRun(ref.workspaceId, ref.id);
298
- },
299
- // The durable instance is terminal and can't be recreated → finalize the
300
- // run as stopped so it stops showing `running` forever (also reclaims any
301
- // leftover container). Reuses the same stop path the user-facing button hits.
302
- finalizeOrphan: async (ref) => {
303
- const container = buildContainer(env);
304
- const reason = 'The run was stopped automatically: its durable driver ended without finalizing it.';
305
- if (ref.kind === 'bootstrap') {
306
- if (container.bootstrap) {
307
- await container.bootstrap.service.stop(ref.workspaceId, ref.id, {
308
- reason,
309
- kind: 'unknown',
310
- });
311
- }
312
- }
313
- else if (ref.kind === 'env-config-repair') {
314
- if (container.envConfigRepair) {
315
- await container.envConfigRepair.service.stop(ref.workspaceId, ref.id, {
316
- reason,
317
- kind: 'unknown',
318
- });
319
- }
320
- }
321
- else {
322
- await container.executionService.stopRun(ref.workspaceId, ref.id, {
323
- reason,
324
- kind: 'unknown',
325
- });
326
- }
327
- },
328
- // An execution whose instance stays missing past this deadline is failed
329
- // `stalled` rather than re-created forever (symmetric with the Node sweeper).
330
- failStalled: async (ref) => {
331
- const container = buildContainer(env);
332
- await container.executionService.failRun(ref.workspaceId, ref.id, 'Run stalled: its durable driver was lost and automatic recovery could not resume it.', 'stalled', null);
333
- },
334
- clock,
335
- leaseMs: SWEEP_LEASE_MS,
336
- hardStallMs: SWEEP_HARD_STALL_MS,
337
- orphanedSince: runSweepOrphanedSince,
338
- })
339
- // Surface what the sweep did — the key signal for "are runs getting stuck?"
340
- // Only log when it actually acted.
341
- .then(({ redriven, finalized, stalled }) => {
342
- if (redriven > 0 || finalized > 0 || stalled > 0) {
343
- logger.warn({ cron: 'run-sweeper', redriven, finalized, stalled }, 'swept stuck runs');
344
- }
345
- })
346
- .catch((error) => logger.error({ cron: 'run-sweeper', err: errInfo(error) }, 'run sweep failed')));
347
- }
348
- // Env-test self-tests live in their own table (not agent_runs), so the unified run
349
- // sweep above never sees them — this sibling sweep re-drives a run whose Workflows
350
- // instance was lost and finalizes (cleanup + failed) one whose instance is terminal.
351
- if (env.ENV_TEST_WORKFLOW) {
352
- const envTestLookup = new WorkflowsLookup(env.ENV_TEST_WORKFLOW);
353
- const envTestRunner = new WorkflowsEnvironmentTestRunner(env.ENV_TEST_WORKFLOW);
354
- ctx.waitUntil(sweepStuckEnvTests({
355
- repository: new D1EnvironmentTestRunRepository({ db: env.DB }),
356
- instanceState: (runId) => envTestLookup.instanceState(runId),
357
- redrive: (workspaceId, runId) => envTestRunner.startRun(workspaceId, runId),
358
- finalizeOrphan: async (workspaceId, runId) => {
359
- const container = buildContainer(env);
360
- await container.environments?.environmentTest?.expire(workspaceId, runId, 'The environment test was stopped automatically: its durable driver ended without finalizing it.');
361
- },
362
- clock,
363
- leaseMs: SWEEP_LEASE_MS,
364
- })
365
- .then(({ redriven, finalized }) => {
366
- if (redriven > 0 || finalized > 0) {
367
- logger.warn({ cron: 'env-test-sweeper', redriven, finalized }, 'swept stuck env-test runs');
368
- }
369
- })
370
- .catch((error) => logger.error({ cron: 'env-test-sweeper', err: errInfo(error) }, 'env-test sweep failed')));
371
- }
372
- // Reclaim expired personal-credential activations (individual-usage subscriptions).
373
- // Each is a short-lived, system-encrypted per-run copy of a user's token; the TTL
374
- // bounds standing exposure and a finished run's rows are deleted at completion, but
375
- // this backstop also clears any that outlived their TTL. The table always exists.
376
- {
377
- const activations = new D1SubscriptionActivationRepository({ db: env.DB });
378
- ctx.waitUntil(activations
379
- .deleteExpired(clock.now())
380
- .then((reclaimed) => {
381
- if (reclaimed > 0)
382
- logger.info({ cron: 'activation-sweeper', reclaimed }, 'reclaimed activations');
383
- })
384
- .catch((error) => logger.error({ cron: 'activation-sweeper', err: errInfo(error) }, 'activation sweep failed')));
385
- }
386
- // Instance-level container reaper: kill any per-run container that outlived its
387
- // legitimate maximum lifetime. This is the load-bearing backstop the run-record
388
- // nets miss — a terminal run whose container survived, or a stuck-`running` run
389
- // a live driver keeps warm (so its idle sleep clock never starts). Keys off the
390
- // real live-container inventory, not the run record, and kills via the same
391
- // EXEC_CONTAINER binding (no Cloudflare API token). With normal runs now self-
392
- // reclaiming, a reaped container is a genuine leak — the registry logs each loudly.
393
- if (env.EXEC_CONTAINER) {
394
- const reaper = new ContainerInstanceRegistry(env.EXEC_CONTAINER, new D1LiveContainerRepository({ db: env.DB }), clock);
395
- const maxAgeMs = loadConfig(env).execution.containerMaxAgeMs;
396
- ctx.waitUntil(reaper
397
- .reapStaleBefore(clock.now() - maxAgeMs)
398
- .then(({ reaped }) => {
399
- if (reaped > 0)
400
- logger.warn({ cron: 'container-reaper', reaped }, 'reaped leaked containers');
401
- })
402
- .catch((error) => logger.error({ cron: 'container-reaper', err: errInfo(error) }, 'container reap failed')));
403
- }
404
- // Escalate long-waiting notifications yellow → red (every 2 min). Runs no longer
405
- // time out waiting for a human, so the escalating notification — past each
406
- // workspace's `waitingEscalationMinutes` threshold — is the overdue-human signal.
407
- ctx.waitUntil(escalateStaleNotifications(buildContainer(env), clock.now())
408
- .then((escalated) => {
409
- if (escalated > 0)
410
- logger.info({ cron: 'notification-escalation', escalated }, 'escalated notifications');
411
- })
412
- .catch((error) => logger.error({ cron: 'notification-escalation', err: errInfo(error) }, 'notification escalation failed')));
413
- // Fire any due recurring pipelines (every 2 min; the actual cadence is hours).
414
- // Each due schedule starts its pipeline against its reused block, skipping any
415
- // whose block already has an active run. No-op when the feature isn't wired.
416
- ctx.waitUntil(Promise.resolve(buildContainer(env).recurring?.service.runDue(clock.now()))
417
- .then((result) => {
418
- if (result && (result.fired > 0 || result.skipped > 0)) {
419
- logger.info({ cron: 'recurring-pipelines', ...result }, 'fired recurring pipelines');
420
- }
421
- })
422
- .catch((error) => logger.error({ cron: 'recurring-pipelines', err: errInfo(error) }, 'recurring-pipeline sweep failed')));
423
- // Tick the initiative execution loop (every 2 min): reconcile each executing initiative's
424
- // spawned tasks and spawn the next wave up to its concurrency cap. Terminal child runs poke
425
- // the loop directly, so this is the backstop cadence. No-op when initiatives aren't wired.
426
- ctx.waitUntil(Promise.resolve(buildContainer(env).initiatives?.loop.runDue(clock.now()))
427
- .then((result) => {
428
- if (result && (result.spawned > 0 || result.completed > 0)) {
429
- logger.info({ cron: 'initiative-loop', ...result }, 'ticked initiative loop');
430
- }
431
- })
432
- .catch((error) => logger.error({ cron: 'initiative-loop', err: errInfo(error) }, 'initiative-loop sweep failed')));
433
- // Run any pending Kaizen gradings (every 2 min): the engine only inserts `scheduled`
434
- // rows at run completion, so this background pass does the actual LLM grading (and
435
- // re-drives `running` rows orphaned by a crashed sweep). Bounded per pass to stay
436
- // within the cron budget; no-op when the Kaizen feature isn't wired. The grader's
437
- // model is resolved per-workspace (Model Configuration), so this is workspace-wide.
438
- if (!kaizenSweeping) {
439
- kaizenSweeping = true;
440
- ctx.waitUntil(Promise.resolve(buildContainer(env).kaizen?.service.runPending(clock.now() - KAIZEN_STALE_MS, KAIZEN_SWEEP_BATCH))
441
- .then((processed) => {
442
- if (processed && processed > 0)
443
- logger.info({ cron: 'kaizen-sweeper', processed }, 'ran pending kaizen gradings');
444
- })
445
- .catch((error) => logger.error({ cron: 'kaizen-sweeper', err: errInfo(error) }, 'kaizen sweep failed'))
446
- .finally(() => {
447
- kaizenSweeping = false;
448
- }));
449
- }
450
- // Reconcile GitHub projections that may have missed a webhook (no-op unless
451
- // the integration is configured).
452
- ctx.waitUntil(reconcileStaleRepos(env, clock, GITHUB_RECONCILE_STALE_MS)
453
- .then((scheduled) => {
454
- if (scheduled > 0)
455
- // `sweep:` (not `cron:`) so the summary shares a field with the pass's
456
- // per-repo lines, which the shared reconcile core emits on both facades.
457
- logger.info({ sweep: 'github-reconcile', scheduled }, 'scheduled repo resyncs');
458
- })
459
- .catch((error) => logger.error({ sweep: 'github-reconcile', err: errInfo(error) }, 'github reconcile failed')));
460
- // Tear down ephemeral environments whose TTL has elapsed (no-op unless the
461
- // environment integration is configured).
462
- ctx.waitUntil(sweepExpiredEnvironments(env, clock).catch((error) => logger.error({ cron: 'env-sweeper', err: errInfo(error) }, 'environment sweep failed')));
463
- // Push deployment-level (platform-operator) observability aggregates to the OTLP
464
- // endpoint as OpenTelemetry gauge metrics, once per cron tick. Opt-in on top of the base
465
- // OTel exporter (OTEL_PLATFORM_METRICS); a no-op otherwise. Per account, enumerated from
466
- // the workspace projection — the same `listVisible(null)` shape the artifact sweep uses.
467
- // The container (hence the platform-observability read) is built only when opted in.
468
- {
469
- const otel = loadConfig(env).otel;
470
- const sweep = runPlatformMetricsSweep({
471
- otel,
472
- platformObservability: otel.platformMetrics.enabled
473
- ? buildContainer(env).platformObservability
474
- : undefined,
475
- workspaceRepository: new D1WorkspaceRepository({ db: env.DB }),
476
- logger,
477
- });
478
- if (sweep)
479
- ctx.waitUntil(sweep);
480
- }
481
- // Raise/clear `platform_health` notifications when the deployment's OWN run health crosses
482
- // an operator threshold, per account (the push counterpart to the operator dashboard read).
483
- // Opt-in (`PLATFORM_ALERTS`); the container (hence the platform-observability read) is built
484
- // only when opted in so a deployment that hasn't opted in pays nothing.
485
- if (loadConfig(env).platformAlerts.enabled) {
486
- ctx.waitUntil(sweepPlatformHealth(buildContainer(env), logger)
487
- .then(({ raised, cleared }) => {
488
- if (raised > 0 || cleared > 0)
489
- logger.info({ cron: 'platform-health', raised, cleared }, 'platform health sweep');
490
- })
491
- .catch((error) => logger.error({ cron: 'platform-health', err: errInfo(error) }, 'platform health sweep failed')));
492
- }
520
+ redriveStuckAgentRuns(env, ctx, clock);
521
+ redriveStuckEnvTests(env, ctx, clock);
522
+ reclaimExpiredActivations(env, ctx, clock);
523
+ reapStaleContainers(env, ctx, clock);
524
+ runPeriodicBackstops(env, ctx, clock);
493
525
  },
494
526
  async queue(batch, env) {
495
527
  // Route by source queue — the single handler serves both queues.