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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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.ts keeps 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: tick in src/keeper/keeper.ts now wraps the existing step. Each tick saves the sentence it already returns: a sentence starting cut is saved as cut, any other as skipped. If the step throws, the error message is saved as failed and the error is thrown again as before. Nothing about how releases are cut has changed.
  • Releases run outside the forge: POST /api/v1/release/heartbeat takes {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_keeper description now names lastTick; test/mcp-size.test.ts passes and no limit was raised.

test/keeper-heartbeat.test.ts covers these cases:

  • two real ticks, saved as skipped then cut
  • two ticks then a posted heartbeat, where lastTick is 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

Closes #11674

commented

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.ts keeps 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: tick in src/keeper/keeper.ts now wraps the existing step. Each tick saves the sentence it already returns: a sentence starting cut is saved as cut, any other as skipped. If the step throws, the error message is saved as failed and the error is thrown again as before. Nothing about how releases are cut has changed.
  • Releases run outside the forge: POST /api/v1/release/heartbeat takes {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_keeper description now names lastTick; test/mcp-size.test.ts passes and no limit was raised.

test/keeper-heartbeat.test.ts covers these cases:

  • two real ticks, saved as skipped then cut
  • two ticks then a posted heartbeat, where lastTick is 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

commented

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

Changes requested — before the latest push

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:

  1. Both doors. The pull adds no TUI screen in src/tui/ (entry where AGENTS.md says) and no docs/features/<name>.md how-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.
  2. Untested behaviour. The failed path of tick (step throws, so a failed beat 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.
  3. outcomeOf records every non-"cut " line as skipped. Check what step returns for a rollback or a hold that is really a failure, and map those to failed, or say in the doc why they are skipped. If beat itself throws inside the try, the catch writes a second, false failed beat; keep the beat call out of the try.
  4. Clean. keeper.ts has two stacked JSDoc blocks on tick (the old "One step of the keeper" is left). The keeper.ts API header ends with a mis-indented */. The forbidden text 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.

commented

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) and GET /api/v1/keeper return lastTick, which is null when 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 as Last 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/heartbeat and {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 cut is 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/release to the meta OpenAPI tag, the same as releases and keeper.

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

commented

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) and GET /api/v1/keeper return lastTick, which is null when 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 as Last 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/heartbeat and {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 cut is 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/release is tagged meta in the OpenAPI document, the same as releases and keeper.
  • The 403 text in the route and the contract says the heartbeat is for administrators only.
  • Removed the second doc comment on tick and fixed the misplaced comment end in src/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

commented

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's recordBeat in src/keeper/beat.ts, replacing the last beat. That module now exports isOutcome, OUTCOMES and REASON_MAX.
  • A token without the admin scope, 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 match main's limit) or one with control characters gets 422.
  • test/keeper-heartbeat.test.ts now covers only this endpoint: a posted beat shows up in keeperAnswer and GET /api/v1/keeper as heartbeat, 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

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.

commented

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).).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs