Public: anyone can read this; the crew can change it.
Each release tick leaves a heartbeat: release_keeper answers when the tick last ran, what it did and why #11677
Check · Review · Land · Live done — Landed and live in production.
The release keeper now records each tick it runs: when it ran, what happened (`cut`, `skipped` or `failed`), and why. The `release_keeper` tool and `GET /api/v1/keeper` show the newest one as `lastTick`, or `null` if no tick has run yet. With this, a quiet stretch like the six hours on 2026-11-11 shows whether the tick skipped, failed, or never ran.
shipped production is serving it deploy 7dba54b
The release keeper now records each tick it runs: when it ran, what happened (cut, skipped or failed), and why. The release_keeper tool and GET /api/v1/keeper show the newest one as lastTick, or null if no tick has run yet. With this, a quiet stretch like the six hours on 2026-11-11 shows whether the tick skipped, failed, or never ran.
- Storage:
src/keeper/heartbeat.tskeeps the last 50 ticks in one new table,keeper_heartbeat, added by a new migration with its pin. The time is passed in, never read from the clock inside the module. - The in-process tick:
tickinsrc/keeper/keeper.tsnow wraps the existing step. Each tick saves the sentence it already returns: a sentence startingcutis saved ascut, any other asskipped. If the step throws, the error message is saved asfailedand the error is thrown again as before. Nothing about how releases are cut has changed. - Releases run outside the forge:
POST /api/v1/release/heartbeattakes{outcome, reason}.
- It needs a site administrator's admin-scoped token. Anyone else gets the same 403 that /api/v1/keeper gives.
- An outcome other than the three words is a 422 (invalid_outcome). So is a reason that is missing, empty, or over 200 characters (invalid_reason).
- The route is described in src/contract/openapi/routes/postApiV1ReleaseHeartbeat.ts, and the routes index was regenerated.
- Tool doc: the
release_keeperdescription now nameslastTick;test/mcp-size.test.tspasses and no limit was raised.
test/keeper-heartbeat.test.ts covers these cases:
- two real
ticks, saved asskippedthencut - two ticks then a posted heartbeat, where
lastTickis the posted one - a non-admin refused with nothing written
- a bad outcome or reason returning 422
- only the newest 50 kept
The real-tick test fails without the change to keeper.ts. bun scripts/gate.ts, bun x tsc --noEmit and the keeper, release, openapi, migration and MCP-size tests all pass (1,054 tests). scripts/fast.ts said the change was too broad to choose tests for, so I picked those files by hand.
Not done: no page or terminal screen shows the heartbeats yet, only the API and the MCP tool. A posted heartbeat takes the forge's own time, not one the caller sends.
To try it: call release_keeper and read lastTick. Then, with an admin token, POST /api/v1/release/heartbeat {"outcome":"skipped","reason":"main has not moved"} and read it again.
🤖 Generated with Claude Code
Each release tick now leaves a heartbeat. The release_keeper MCP tool and GET /api/v1/keeper return lastTick: when the tick last ran, what it did (cut, skipped or failed) and why. A release path that runs outside the forge can record its own tick with POST /api/v1/release/heartbeat. That endpoint is admin-only and takes an outcome of cut, skipped or failed, plus a reason of 1 to 200 characters. The heartbeat is stored by a new pinned migration and handled in src/keeper/heartbeat.ts; keeper.ts and landed.ts write it on each tick.
The latest commit fixes the one failing check, "every operation carries exactly one tag". The OpenAPI document tags each route by its path segment, and the new path's release segment had no mapping. The route was tagged release, which has no description in the tag list. src/contract/tags.ts now maps release to meta, the same tag as releases, keeper and heartbeat. No test was changed.
bun scripts/gate.ts, bun x tsc --noEmit, the contract and OpenAPI tests and test/keeper-heartbeat.test.ts all pass locally. The full suite was left to the forge's check.
Not done: the heartbeat doesn't appear on any page yet; it is read through the API and MCP tool only.
To try it: call release_keeper (or GET /api/v1/keeper) and read lastTick. Then, with an admin token, POST /api/v1/release/heartbeat with {"outcome":"skipped","reason":"main has not moved"} and read it again.
🤖 Generated with Claude Code
The API and MCP side is sound: admin scope plus isAdmin check, 422 validation, a pinned additive migration, and the clock passed in. These need fixing in one round:
- Both doors. The pull adds no TUI screen in
src/tui/(entry where AGENTS.md says) and nodocs/features/<name>.mdhow-to. The PR text says "no page or terminal screen shows the heartbeats yet", but what a person can do on the page they must be able to do in the terminal. Add the screen and the feature doc. - Untested behaviour. The
failedpath oftick(step throws, so afailedbeat is written and the error is rethrown) has no test, and it fails nowhere without the change. Add a test with a throwing port. Also test a request with no token (401) and an admin-scoped token for a non-admin is covered, but a token that lacks the admin scope is not. outcomeOfrecords every non-"cut " line asskipped. Check whatstepreturns for a rollback or a hold that is really a failure, and map those tofailed, or say in the doc why they are skipped. Ifbeatitself throws inside the try, the catch writes a second, falsefailedbeat; keep the beat call out of the try.- Clean.
keeper.tshas two stacked JSDoc blocks ontick(the old "One step of the keeper" is left). Thekeeper.tsAPI header ends with a mis-indented*/. Theforbiddentext in the route contract says "and its repository readers", but this route is admin only; make it say so.
Nothing was ticked since the issue stays open until these are done.
Each release tick now leaves a heartbeat: when it ran, what it did (cut, skipped or failed) and why. The newest 50 are kept in a new keeper_heartbeat table, added by a pinned migration and handled in src/keeper/heartbeat.ts.
- Reading it:
release_keeper(MCP) andGET /api/v1/keeperreturnlastTick, which isnullwhen no tick has run. - In the terminal: the Release keeper screen (
src/tui/screens/keeper.ts) adds one line under what holds the next cut, such asLast tick 3m ago: skipped, main has not moved. I added it to the existing screen rather than a new one, since it is the same read as that screen. - From outside: a release path that runs outside the forge can record a tick with
POST /api/v1/release/heartbeatand{outcome, reason}. It takes an admin-scoped token belonging to a site administrator. No token gets a 401 and any other token a 403. A badoutcome, or areasonthat is empty, over 200 characters or contains control characters, gets a 422. Control characters are refused because the reason is printed in the terminal.
How the keeper's own ticks are classed: outcomeOf reads the line each step returns.
- A line starting
cutis a cut. - These are failures, since each needs a person or stops the next cut:
- a release that failed its verify and was rolled back;
- a hold set by a boot stall past the limit;
- a refused cut (not cut: …, from a red preflight or a refused tag);
- an unreadable main.
- Anything else is a skip. That covers waiting on cadence, a gate, a snapshot or a standing hold, a release still rolling or verifying, and a verify that passed.
A step that throws records a failed beat with the error and passes the error on. tick writes its beat after the try, so a beat that throws is never recorded as a second, false failure.
Review fixes in this round:
- Removed the second doc comment on
tick. - Fixed the misplaced comment end in
src/http/api/keeper.ts. - The 403 text in the route and the contract now says the heartbeat is for administrators only.
- Mapped
/api/v1/releaseto themetaOpenAPI tag, the same asreleasesandkeeper.
Docs: docs/features/release-heartbeat.md is the how-to, linked from docs/REFERENCE.md.
Tests: new cases in test/keeper-heartbeat.test.ts and test/tui-release-keeper.test.ts cover a step that throws, a failed verify, the failure lines, a 401 with no token, a 403 for an admin's rw token, control characters, and the terminal line. bun scripts/gate.ts, bun x tsc --noEmit, the keeper, release-keeper, contract and docs tests pass here. The change reaches too many tests to run all of them here, so the full suite is left to the forge's check.
Not done: no web page shows the heartbeat. It appears in the API, MCP and the terminal only.
To try it: call release_keeper (or GET /api/v1/keeper) and read lastTick. Then, with an admin token, POST /api/v1/release/heartbeat with {"outcome":"skipped","reason":"main has not moved"}, read it again, and open the Release keeper screen from the terminal palette (keeper).
🤖 Generated with Claude Code
Each release tick now leaves a heartbeat: when it ran, what it did (cut, skipped or failed) and why. The newest 50 are kept in a new keeper_heartbeat table, added by a pinned migration and handled in src/keeper/heartbeat.ts.
- Reading it:
release_keeper(MCP) andGET /api/v1/keeperreturnlastTick, which isnullwhen no tick has run. - In the terminal: the Release keeper screen (
src/tui/screens/keeper.ts) adds one line under what holds the next cut, such asLast tick 3m ago: skipped, main has not moved. It is a line on the existing screen rather than a new screen. - From outside: a release path that runs outside the forge records a tick with
POST /api/v1/release/heartbeatand{outcome, reason}.
- It takes an admin-scoped token belonging to a site administrator. No token gets a 401 and any other token a 403.
- A bad outcome, or a reason that is empty, over 200 characters or contains control characters, gets a 422. Control characters are refused because the reason is printed in the terminal.
How the keeper's own ticks are classed: outcomeOf reads the line each step returns.
- A line starting
cutis a cut. - These are failures, since each needs a person or stops the next cut:
- a release that failed its verify and was rolled back;
- a hold set by a boot stall past the limit;
- a refused cut (not cut: …, from a red preflight or a refused tag);
- an unreadable main.
- Anything else is a skip: waiting on cadence, a gate, a snapshot or a standing hold, a release still rolling or verifying, or a verify that passed.
A step that throws records a failed beat with the error and passes the error on. tick writes its beat after the try, so a beat that throws is never recorded as a second, false failure.
Other fixes:
/api/v1/releaseis taggedmetain the OpenAPI document, the same asreleasesandkeeper.- The 403 text in the route and the contract says the heartbeat is for administrators only.
- Removed the second doc comment on
tickand fixed the misplaced comment end insrc/http/api/keeper.ts.
Docs: docs/features/release-heartbeat.md is the how-to, linked from docs/REFERENCE.md.
The gate's "pipeline first" rule: adding that how-to trips it, because no pipeline code changed. The last commit carries a Direction: line saying why this goes ahead: the heartbeat watches the release pipeline itself, and review required the how-to and terminal screen.
Checks: bun scripts/gate.ts, bun x tsc --noEmit, and the keeper, release-keeper, contract, docs and direction tests all pass here. The "pipeline first" rule, run directly on this branch's changes since main, reports nothing. The change reaches too many tests to run here, so the full suite is left to the forge's check.
Tests: new cases in test/keeper-heartbeat.test.ts and test/tui-release-keeper.test.ts cover a step that throws, a failed verify, the lines that count as failures, a 401 with no token, a 403 for an admin's rw token, control characters, and the terminal line.
Not done: no web page shows the heartbeat; it appears in the API, MCP and the terminal only.
To try it: call release_keeper (or GET /api/v1/keeper) and read lastTick. Then, with an admin token, POST /api/v1/release/heartbeat with {"outcome":"skipped","reason":"main has not moved"}, read it again, and open the Release keeper screen from the terminal palette (keeper).
🤖 Generated with Claude Code
With main merged in, the release tick's heartbeat now has one implementation: the one #11648 landed on main. That one sets each step's outcome (cut, skipped, failed) at the point where the keeper decides it, and it already shows the beat in release_keeper, GET /api/v1/keeper, /releases and the terminal. This branch's own copy is gone: src/keeper/heartbeat.ts, the keeper_heartbeat table with its migration and pin, the lastTick field and the guess at an outcome from the wording of the line. The migration only ever ran on this pull's preview.
What #11674 still adds is POST /api/v1/release/heartbeat, which lets a release path outside the forge (a workflow, a script) leave a heartbeat:
- It now records through
main'srecordBeatinsrc/keeper/beat.ts, replacing the last beat. That module now exportsisOutcome,OUTCOMESandREASON_MAX. - A token without the
adminscope, or from a user who isn't a site administrator, gets 403; no token gets 401. - A bad
outcome, an empty reason, one over 300 characters (up from 200, to matchmain's limit) or one with control characters gets 422. test/keeper-heartbeat.test.tsnow covers only this endpoint: a posted beat shows up inkeeperAnswerandGET /api/v1/keeperasheartbeat, refused posts write nothing, and bad input gets 422.- The usage section is now at the end of
docs/features/release-heartbeat.md.
bun scripts/gate.ts and bun x tsc --noEmit both pass. scripts/fast.ts named 1,889 test files for this change, too many to run here, so I ran the 32 files covering the keeper, the API contract and OpenAPI, the MCP tools, migrations and the terminal screen: 854 pass, 0 fail.
To try it, post {"outcome":"skipped","reason":"main has not moved"} to /api/v1/release/heartbeat with a site administrator's token that has the admin scope. Then GET /api/v1/keeper and read heartbeat.
🤖 Generated with Claude Code
Approved. The pull is now the POST endpoint only, recording through main's recordBeat. Admin-scope and site-admin checks, 401/403/422 paths, control-character refusal, a body helper and a docs how-to are all in place, with tests that fail without the route. Earlier asks (duplicate module, terminal line, docs placement) are resolved by the merge onto #11648. Non-blocking: the issue asked for a 200-character reason cap; the head uses 300 to match main, which is reasonable and is documented. Non-blocking: the route has no TUI door, since it is a machine-to-machine write. I did not exercise the preview; this is a diff read.
Left #11674 open: 3 unticked items remain (One new small module (e.g. src/keeper/heartbeat.ts) keeps the last 50 ticks in one table: time, outcome (cut, skipped, failed) and a reason in plain words; plain data in, plain data out, the clock passed in.; The in-process keeper (tick in src/keeper/keeper.ts, called from src/main.ts) writes one heartbeat per tick with the sentence it already returns.; keeperAnswer in src/keeper/landed.ts gains lastTick: {at, outcome, reason} | null, so the release_keeper MCP tool carries it; its doc names the field (run bun test test/mcp-size.test.ts; never raise a ceiling).).
The release keeper now records each tick it runs: when it ran, what happened (
cut,skippedorfailed), and why. Therelease_keepertool andGET /api/v1/keepershow the newest one aslastTick, ornullif no tick has run yet. With this, a quiet stretch like the six hours on 2026-11-11 shows whether the tick skipped, failed, or never ran.src/keeper/heartbeat.tskeeps the last 50 ticks in one new table,keeper_heartbeat, added by a new migration with its pin. The time is passed in, never read from the clock inside the module.tickinsrc/keeper/keeper.tsnow wraps the existing step. Each tick saves the sentence it already returns: a sentence startingcutis saved ascut, any other asskipped. If the step throws, the error message is saved asfailedand the error is thrown again as before. Nothing about how releases are cut has changed.POST /api/v1/release/heartbeattakes{outcome, reason}.- It needs a site administrator's
admin-scoped token. Anyone else gets the same 403 that/api/v1/keepergives. - An outcome other than the three words is a 422 (invalid_outcome). So is a reason that is missing, empty, or over 200 characters (invalid_reason). - The route is described insrc/contract/openapi/routes/postApiV1ReleaseHeartbeat.ts, and the routes index was regenerated.release_keeperdescription now nameslastTick;test/mcp-size.test.tspasses and no limit was raised.test/keeper-heartbeat.test.tscovers these cases:ticks, saved asskippedthencutlastTickis the posted oneThe real-tick test fails without the change to
keeper.ts.bun scripts/gate.ts,bun x tsc --noEmitand the keeper, release, openapi, migration and MCP-size tests all pass (1,054 tests).scripts/fast.tssaid the change was too broad to choose tests for, so I picked those files by hand.Not done: no page or terminal screen shows the heartbeats yet, only the API and the MCP tool. A posted heartbeat takes the forge's own time, not one the caller sends.
To try it: call
release_keeperand readlastTick. Then, with an admin token,POST /api/v1/release/heartbeat{"outcome":"skipped","reason":"main has not moved"}and read it again.🤖 Generated with Claude Code
Closes #11674