Public: anyone can read this; the crew can change it.
Each release tick leaves a heartbeat row: the keeper writes one per tick with the sentence it already returns #11822
Check · Review · Land · Live done — Landed and live in production.
The release tick's heartbeat now keeps the last 50 ticks instead of only the latest one, so a quiet stretch can be told apart from a dead keeper.
shipped production is serving it deploy 8f18665
The release tick's heartbeat now keeps the last 50 ticks instead of only the latest one, so a quiet stretch can be told apart from a dead keeper.
What changed
- Writing one heartbeat per tick was already in place:
src/main.tsruns every tick throughstepWithBeatinsrc/keeper/beat.ts, which records the outcome (cut,skipped,failed) and the sentence the tick returns. The gap was that thekeeper_beattable allows only one row (CHECK (id = 1)), so each tick overwrote the last. - New table: the migration
20261112130000-keeper-beat-tickcreateskeeper_beat_tickand copies the current heartbeat into it, with its pin. Rebuildingkeeper_beatin place failstest/schema-additive.test.ts: during a roll, the old release still writes that row. Sokeeper_beatstays for the old release, and nothing in this code reads it any more. - Writing and reading:
recordBeatadds a row and drops everything beyondKEPT(50) in one transaction.lastBeatreads the newest row. Their signatures haven't changed, soGET /api/v1/keeper,release_keeper, /releases, the terminal's keeper screen andPOST /api/v1/release/heartbeatall work as before. - Test: a new case in
test/keeper-heartbeat.test.tsruns ticks throughstepWithBeatwith the clock passed in. Two ticks leave two rows, and the 51st drops the oldest. It fails without the change. Intest/flow-alarm-main-ahead.test.ts, one setup line that clears the table now names the new one; no assertion changed.
What I chose not to do
- I extended the existing
src/keeper/beat.tsrather than adding thesrc/keeper/heartbeat.tsthe issue names, because a second module would duplicate this one. - I added no function to read the history: nothing would call it yet, and showing the history on a page is a follow-up.
- Retiring the old
keeper_beattable is left for a later migration, once no running release uses it. - The release workflow, the chart and the cut logic are untouched.
Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and bun test on the keeper-heartbeat, flow-alarm-main-ahead, release-shipped, family-heartbeat, schema-additive and schema-migrations tests: 86 pass, 0 fail.
To try it: bun test test/keeper-heartbeat.test.ts.
🤖 Generated with Claude Code
The change works and the test is good, but the how-to is now false. One fix: update docs/features/release-heartbeat.md. Line 3 says the table is keeper_beat and the tick keeps one heartbeat; it must name keeper_beat_tick and say the last 50 are kept (KEPT). Line 17 says an outside release path leaves the same heartbeat 'replacing the last one'; it now adds a row and the oldest beyond 50 goes. The docs are the feature's how-to, and this pull changes what they describe. Everything else I checked is fine: the transaction trims to 50, the migration is additive with its pin, signatures are unchanged, so the door and terminal readers are untouched. Extending beat.ts instead of adding heartbeat.ts is the right call. Box 2 was already true on main.
The release tick's heartbeat now keeps the last 50 ticks instead of only the latest, so a quiet stretch can be told apart from a dead keeper.
What changed
- Writing one heartbeat per tick was already on main:
src/main.tsruns every tick throughstepWithBeatinsrc/keeper/beat.ts, which records the tick's outcome (cut,skipped,failed) and the sentence it returns. The oldkeeper_beattable allows only one row, so each tick replaced the last. - New table: the migration
20261112130000-keeper-beat-tickcreateskeeper_beat_tickand copies the current heartbeat into it. It is pinned. Rebuildingkeeper_beatin place would failtest/schema-additive.test.ts, because during a roll the old release still writes row 1 there. Sokeeper_beatstays until it can be dropped, and nothing in the new code reads it. #11833 is open to drop it in a later migration. - Writing and reading:
recordBeatadds a row and drops the oldest beyondKEPT(50) in one transaction.lastBeatreads the newest. Their signatures are unchanged, soGET /api/v1/keeper,release_keeper, /releases, the terminal's keeper screen andPOST /api/v1/release/heartbeatread and write as before. - Docs:
docs/features/release-heartbeat.mdnow nameskeeper_beat_tickand says the last 50 are kept. It also says a heartbeat posted from outside adds a row instead of replacing the last one. - Test: a new case in
test/keeper-heartbeat.test.tsruns ticks throughstepWithBeatwith the clock passed in. Two ticks leave two rows, and the 51st drops the oldest. It fails without the change. Intest/flow-alarm-main-ahead.test.ts, one setup line that clears the table now names the new table; no assertion changed.
What I chose not to do: I added no separate src/keeper/heartbeat.ts, because it would duplicate beat.ts. I added no reader for the history yet, since nothing would call it; showing it on a page is a follow-up. The release workflow, the chart and the cut logic are untouched.
Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and bun test on the touched heartbeat and keeper tests plus the two schema tests. All passed.
To try it: bun test test/keeper-heartbeat.test.ts.
🤖 Generated with Claude Code
Approved. The change keeps the last 50 ticks in a new additive table (keeper_beat_tick, pinned). recordBeat inserts and trims in one transaction, lastBeat reads the newest row, and the docs now name the new table and the 50-row limit. The new test runs ticks through stepWithBeat with an injected clock and checks that two ticks leave two rows and the 51st drops the oldest. Nothing touches the page or the terminal, so there is no keyboard, phone or no-JS surface to fail. I read the diff and did not run the tests or open the preview.
Two things I would do next:
- Non-blocking: land #11833 to retire keeper_beat once no running release writes it.
- Non-blocking: show the history on /releases and in the terminal keeper screen, so the 50 rows are visible and not only stored.
Left #11794 open: 1 unticked item remain (One new small module src/keeper/heartbeat.ts keeps the last 50 ticks in one table (one migration): time, outcome (cut, skipped, failed) and a reason in plain words; plain data in, plain data out, the clock passed in. Reuse the table the landed POST /api/v1/release/heartbeat route writes, if it already has one.).
The release tick's heartbeat now keeps the last 50 ticks instead of only the latest one, so a quiet stretch can be told apart from a dead keeper.
What changed
src/main.tsruns every tick throughstepWithBeatinsrc/keeper/beat.ts, which records the outcome (cut,skipped,failed) and the sentence the tick returns. The gap was that thekeeper_beattable allows only one row (CHECK (id = 1)), so each tick overwrote the last.20261112130000-keeper-beat-tickcreateskeeper_beat_tickand copies the current heartbeat into it, with its pin. Rebuildingkeeper_beatin place failstest/schema-additive.test.ts: during a roll, the old release still writes that row. Sokeeper_beatstays for the old release, and nothing in this code reads it any more.recordBeatadds a row and drops everything beyondKEPT(50) in one transaction.lastBeatreads the newest row. Their signatures haven't changed, soGET /api/v1/keeper,release_keeper, /releases, the terminal's keeper screen andPOST /api/v1/release/heartbeatall work as before.test/keeper-heartbeat.test.tsruns ticks throughstepWithBeatwith the clock passed in. Two ticks leave two rows, and the 51st drops the oldest. It fails without the change. Intest/flow-alarm-main-ahead.test.ts, one setup line that clears the table now names the new one; no assertion changed.What I chose not to do
src/keeper/beat.tsrather than adding thesrc/keeper/heartbeat.tsthe issue names, because a second module would duplicate this one.keeper_beattable is left for a later migration, once no running release uses it.Checks run:
bun scripts/gate.ts,bun x tsc --noEmit, andbun teston the keeper-heartbeat, flow-alarm-main-ahead, release-shipped, family-heartbeat, schema-additive and schema-migrations tests: 86 pass, 0 fail.To try it:
bun test test/keeper-heartbeat.test.ts.🤖 Generated with Claude Code
Closes #11794