Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(worker): bound cron handlers and split the cron queue into two lanes

merged#2588CedarCopilot

CedarCopilot wants to merge 4 commits into staging from fix/cron-poller-timeout-and-slow-queue

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Aug 26, 2026, 11:13 AM
  2. Merged
    Aug 26, 2026, 2:17 PM
  3. Live on staging
    Aug 26, 2026, 2:17 PM
  4. Live on prod
    Aug 26, 2026, 2:17 PM
  5. Observed 2 days
    Aug 26, 2026, 2:17 PM
  6. Pipelines steady after this deploy
    Aug 26, 2026, 2:17 PM
  7. Pipelines steady after this deploy
    Aug 26, 2026, 2:17 PM
  8. Aug 26, 2026, 2:18 PM
  9. Unobserved

    Live on prod, no production signal yet

    Aug 28, 2026, 9:11 PM

Behaviors Libra is checking

Background jobs behavior from runtime/worker-entry.ts keeps working in prod.Inconclusivelow confidence

Strict CloudWatch fallback saw 2,368 success-shaped log lines matching /worker-entry.ts, handler-ceiling.test, sqs-ack.test, handler-ceiling, sqs-ack, but no tied operation was present, so Libra is not calling this working.

prod, checked Aug 28, 2026, 8:11 PM
Observability telemetry behavior from stacks/observability-stack.ts keeps working in prod.Inconclusivelow confidence

Strict CloudWatch fallback saw 2,341 success-shaped log lines matching /observability-stack.ts, handler-ceiling.test, sqs-ack.test, handler-ceiling, sqs-ack, but no tied operation was present, so Libra is not calling this working.

prod, checked Aug 28, 2026, 9:11 PM
Background jobs behavior from runtime/worker-entry.ts keeps working in staging.Inconclusivelow confidence

Strict CloudWatch fallback saw 847 success-shaped log lines matching /worker-entry.ts, handler-ceiling.test, sqs-ack.test, handler-ceiling, sqs-ack, but no tied operation was present, so Libra is not calling this working.

staging, checked Aug 29, 2026, 10:23 AM
Observability telemetry behavior from stacks/observability-stack.ts keeps working in staging.Inconclusivelow confidence

Strict CloudWatch fallback saw 829 success-shaped log lines matching /observability-stack.ts, handler-ceiling.test, sqs-ack.test, handler-ceiling, sqs-ack, but no tied operation was present, so Libra is not calling this working.

staging, checked Aug 29, 2026, 11:25 AM

Failures attributed to this change

No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 1 hit · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Aug 26, 2026, 3:01 PM
No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Aug 26, 2026, 3:37 PM
No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Aug 26, 2026, 4:17 PM

Libra has verdicts on 0 of 2 tracked behaviors on prod; 2 are still being checked. Libra checks hourly for 3 days after each deploy.

The incident

On 2026-08-26 every cron on cron-task-queue stopped for eight hours.

NumberOfMessagesReceived    ~68/hr through 07:00 UTC, then exactly 0 for 08:00-16:35
ApproximateAgeOfOldestMessage   844s at 04:00  ->  43,693s (12.1 hrs) at 16:30
Queue depth                 15 -> 667

ECS recorded no restarts and the containers reported normal memory the whole time, so nothing alerted and nothing recycled them. Webhook-driven work on other queues was completely unaffected, which is why it looked fine from outside.

58 of 71 meeting preps that day fired late, worst case 422 minutes. 40+ users across ~25 orgs got prep after their meeting had already started. Two customers reported it before we noticed.

Two independent causes. One fix each.

1. A handler could hold its polling slot forever

startTaskTokenQueuePoller awaited config.handler(body) inline with nothing around it. A handler that never settles wedges that loop permanently , it never calls ReceiveMessage again. Three wedged loops is the entire outage.

The timeout on the producing SqsSendMessage did not help and never could: it bounds only how long Step Functions waits for the callback token. It expired the token (every drained task logged TaskTimedOut: Provided task does not exist anymore) and never touched the work running on the worker.

handlerTimeoutMs is now required on the poller config, for the same reason SurfaceWorkingContext.taskId is required: a ceiling you can forget is a ceiling that turns out to be missing on the one queue that needed it. The cron lanes read it from the message, which the orchestrator stamps from the CDK task list, so that list stays the single source of truth for both the Step Function timeout and the worker ceiling.

Abandoning frees the slot but cannot cancel the work , JS cannot interrupt an awaited promise. So abandonments are counted, and past a threshold the container hands itself back to ECS rather than leaking orphans that still hold their pg connections.

2. Slow crons and urgent crons shared three polling slots

processCrmDealReconciliation is allowed 60 minutes and processExternalCrmSync 20. They shared a queue and a concurrency budget with processScheduledExecutions, whose whole job is to dispatch meeting prep on time.

Reconciliation being slow is normal. Prep being late is a bug. One queue cannot express that difference, so there are now two: cron-task-queue (fast, concurrency 6) and cron-slow-task-queue (slow, concurrency 3).

The lane is declared per task, not inferred from timeoutMinutes. processAopAutomations declares 20 minutes but has to fire within ±5 of a user's chosen cron minute, so it is fast-lane despite a slow-lane-sized ceiling. Inferring would have quietly broken it.

The fast queue's visibility timeout drops from 70 to 30 minutes, above its longest ceiling (20) so the handler ceiling always fires first and a wedged task is abandoned rather than redelivered and re-run.

Verification

  • pnpm --filter @zero/server run types clean
  • aws/ CDK tsc --noEmit clean
  • pnpm deps:check clean (1566 modules, no violations)
  • eslint clean on changed files
  • New unit tests for the ceiling, 5 passing, covering the outage shape (a handler that never settles) and the invariant that an abandoned handler's late rejection cannot surface as an unhandledRejection

Deploy notes

  • Adds an SQS queue, so this needs a CDK deploy, not just a service update.
  • The ~667 messages currently queued predate the timeoutMinutes stamp and fall back to the lane default. They are expired-token cron ticks; the work still runs, only the callback is dead.
  • This does not retroactively fix the preps that were missed. Overdue rows for meetings that already ended should be cancelled deliberately rather than left to fire late.
  • Still outstanding and not in this PR: a CloudWatch alarm on NumberOfMessagesReceived == 0 for that queue. Either that or `ApproximateAgeOfOldestMes
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/.env.exampleno production surface mapped
  • apps/server/src/runtime/__tests__/handler-ceiling.test.tsno production surface mapped
  • apps/server/src/runtime/__tests__/sqs-ack.test.tsno production surface mapped
  • apps/server/src/runtime/handler-ceiling.tsno production surface mapped
  • apps/server/src/runtime/sqs-ack.tsno production surface mapped
  • apps/server/src/runtime/worker-entry.tsno production surface mapped
  • aws/lib/runtime-contract.tsno production surface mapped
  • aws/lib/stacks/app-stack.tsno production surface mapped
  • aws/lib/stacks/data-stack.tsno production surface mapped
  • aws/lib/stacks/observability-stack.tsno production surface mapped