Skip to content

[canonical-hours-25ff14] fix(worker): guard the tick with a DO-backed lease - #31

Merged
jamestexas merged 1 commit into
mainfrom
fix/canonical-hours-25ff14-tick-lease-v2
Jul 27, 2026
Merged

[canonical-hours-25ff14] fix(worker): guard the tick with a DO-backed lease#31
jamestexas merged 1 commit into
mainfrom
fix/canonical-hours-25ff14-tick-lease-v2

Conversation

@jamestexas

Copy link
Copy Markdown
Contributor

Closes the overlap gap in runTick for the deployment this project actually has.

What the bead got wrong

It was filed against Vercel Cron, and deferred until "whoever picks up the deployment tasks". Two corrections:

  1. There is no Vercel deployment. The project is eve-compliant but runs on Cloudflare, developed locally via workerd. Close-condition (a) — "confirm Vercel Cron reuses a warm process" — can never be satisfied.
  2. The exposure is live, not deferred. worker/index.ts already calls runTick in the worker isolate from two trigger paths: POST /tick and the MCP trigger_tick tool. runTick's guard is a module-scope running flag, which lives per isolate — Cloudflare can dispatch two concurrent calls to two isolates, each seeing its own unset flag.

Prior research on the bead concluded a fix "needs new external infra (Redis/Vercel KV/a DB) that this project doesn't have". Sound for a Vercel target; not true here.

The fix

CanonicalHoursBoardObject is a Durable Object that already owns the board. A DO is single-threaded per object id, so its check-and-set is atomic across every isolate — a distributed lock already deployed, nothing to provision. It also works in the local workerd loop, since cloister supports DO namespaces with durableObjectStorage = (localDisk = ...).

Details that matter:

  • The lease uses the same object id as the board store (idFromName("default")), so it lives on the DO it guards rather than a second instance.
  • Released in a finallyrunTick absorbs its own errors, but a throw from configFromEnv/buildSources would otherwise strand it.
  • It expires. An isolate evicted mid-tick can never release, and a permanently-held lock converts an overlap bug into a total stall — strictly the worse failure. Expiry is evaluated on read, not by alarm, so a dead holder can't keep its own lease alive.
  • Returns "skipped_overlap", the same value runTick uses for the in-process case, so callers can't tell which guard fired.
  • The module-scope flag stays — still the cheapest correct guard for same-isolate concurrency, and vespers is consumed outside this worker.

Testing

The decision lives in agent/lib/tick-lease.ts as a pure function rather than inline in the DO method: it holds the edge cases, and worker/index.ts imports cloudflare:workers, which the node runner can't load. 6 tests cover unset/held/expired, the exact-expiry boundary, a backwards clock jump, and that the TTL leaves a slow tick room to finish.

272/272 tests pass (main's 266 plus these 6), tsc clean.

Note

Does not touch canonical-hours-07c93c. PR #30 is open and doing that ledger work; this shares only the observation that the DO is the right primitive for both.

🤖 Generated with Claude Code

… lease

runTick's overlap guard is a module-scope `running` flag, so it protects
only against concurrency within one isolate. Cloudflare dispatches
concurrent requests across many isolates, each with its own unset flag, so
two `POST /tick` calls (or the MCP `trigger_tick` tool) can both proceed.

Two corrections to the bead's framing:

1. It assumed Vercel Cron. There is no Vercel deployment — the project is
   eve-compliant but runs on Cloudflare, developed locally via workerd. Its
   close-condition (a), "confirm Vercel Cron reuses a warm process", can
   never be satisfied.
2. It filed this as deferred until deployment. It is not: worker/index.ts
   already calls runTick in the worker isolate from two live trigger paths.

Earlier research on the bead concluded a fix "needs new external infra
(Redis/Vercel KV/a DB) that this project doesn't have". That was sound for
a Vercel target but does not hold here: CanonicalHoursBoardObject is a
Durable Object that already owns the board, and a DO is single-threaded per
object id, so its check-and-set is atomic across isolates. Nothing to
provision. It also works in the local workerd loop, since cloister supports
DO namespaces with localDisk storage.

The lease is taken on the SAME object id the board store uses
(idFromName("default")), so it lives on the DO it guards rather than a
second instance, and is released in a `finally` so a throw from
configFromEnv/buildSources cannot strand it.

It expires. An isolate evicted mid-tick can never release, and a
permanently-held lock would convert an overlap bug into a total stall —
strictly the worse failure. Expiry is evaluated on read, not by alarm, so a
dead holder cannot keep its own lease alive.

The decision is a pure function in agent/lib/tick-lease.ts rather than
inline in the DO method: it holds the edge cases worth testing, and
worker/index.ts imports `cloudflare:workers`, which the node test runner
cannot load. 6 tests cover unset/held/expired, the exact-expiry boundary, a
backwards clock jump, and that the TTL leaves a slow tick room to finish.

The module-scope flag stays: it is still the cheapest correct guard for
same-isolate concurrency, and vespers is consumed outside this worker.

272/272 tests pass (266 on main plus these 6); tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamestexas
jamestexas merged commit 3b3a2cb into main Jul 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant