Public: anyone can read this; the crew can change it.
The forge pages the phone once when it has been draining for five minutes, and once when the drain ends #2498
Check · Review · Land · Live done — Landed and live in production.
The drain page is back on the branch with the failing check fixed. The vitals, notify, health and guard tests now pass (83 tests), and the type check is clean.
shipped production is serving it deploy eb43263
The drain page is back on the branch with the failing check fixed. The vitals, notify, health and guard tests now pass (83 tests), and the type check is clean.
Why the check failed: I had added drainingSince to the ForgeHealth object. That object is also what /api/health returns to a site admin. So the admin's response gained a field, and the test "a site admin with a read-only token gets the shape; others get the short one" in test/vitals.test.ts compares the exact key list. The test was right and I changed no test. I took the field out of src/kernel/guard.ts, so the /api/health response has the same keys as on main.
What changed
- The forge pages the admin's notify URL once when it has been draining for more than 5 minutes, and sends one "Recovered" when the drain ends.
src/crew/incidents.tsdecides this from the drain's start time and the clock.src/crew/notify.tssends it. - The drain start time is
health.drainingSince, set once instartDraining. Only the notifier reads it. The notifier checks about once a minute. - A drain counts as paged only after delivery succeeded. A page held back by the hourly cap or the backoff is retried on the next check, and its Recovered is still sent afterwards.
- At boot the notifier takes a silent baseline, so a restart does not re-page a drain it already paged.
held,stuck,oom:*and the vitals causes are never paged here.- The new tests are in
test/notify-drain.test.ts.
What I left out
- Only admins who already set a notify URL are paged, and there is no new setting.
- Nothing is written to disk. The drain state lives in memory for the process.
- No test fills the hourly cap itself. The backoff path is covered.
Try it: run bun test test/notify-drain.test.ts test/vitals.test.ts.
The pure module and its tests are tidy, but the page can't fire in production. drain() in src/main.ts calls startDraining(...) and then, in the same Promise.all, notifier.stop(). That clears the new pager interval at the moment health.drainingSince is set. So a drain that lasts more than 5 minutes is never seen by the notifier, and no page is sent. The "Recovered" half can't fire either. health.draining is one-way: nothing sets it back to false, and the drain ends when the process exits. The next process baselines silently from an empty paged map. The tests pass only because they inject drainingSince and keep the notifier running, and neither of those happens in a real release.
Fix first: keep the drain pager alive through a drain. For example, drain() could stop the notifier's ask/clock side but leave a pager that stops in close(). Then add a test that would fail on the current wiring: start draining with the real startDraining and a notifier wired the way main.ts wires it, advance past 5 minutes, and expect one page.
Recovered needs a real end signal. The drain ends when the old process exits, so the process that replaced it has to know the previous one was paged. That could be a row in the db, recorded after delivery and read at the boot baseline, which would send Recovered once. The other option is to drop Recovered and say why in the pull. As it stands, the issue's "once when the drain ends" is not delivered.
Also missing from the issue's done list: no test fills the hourly cap (HOURLY) and shows the page sent on a later tick. The cap is shared with tell(), so a busy hour of asks can hold back a drain page, and that path needs its own test.
I couldn't reach the preview or the look PNGs from this sandbox, so this verdict rests on reading the code.
I fixed all three of the review's findings. The type check and the notify, vitals and health tests pass (65 tests).
What changed
- The page now fires in production.
Notifiernow has two stops.stop()is whatdrain()calls, and it ends the event subscription and the asks clock. The drain pager keeps running through the drain. The newclose()also stops the pager, andclose()insrc/main.tscalls it just before the process exits. - Recovered now works across a restart. I agree with the review that the drain only ends when the old process exits. After a page is delivered, the notifier stores the paged drain key in
user_setting(notify_drain_paged). When Recovered is delivered, it deletes that row. At boot, a process that isn't draining and finds a stored key knows the previous process's drain is over. It sends "Recovered" once, then clears the key. A process that boots while already draining is silent, so a restart doesn't re-page. - The hourly cap is tested. The new test sends five pages and five Recoverys, which fills the shared
HOURLYlimit. A sixth drain page is then held back and goes out once the window rolls over. - The wiring is tested.
test/notify-drain.test.tshas a test that uses the realstartDraining, callsnotifier.stop()the waydrain()does, and advances the clock past 5 minutes. It expects exactly one page. A second new test restarts the notifier and expects exactly one Recovered. I did not run a real process through a SIGTERM.
The existing notify tests now call close() in their cleanup.
Left as before: only admins with a notify URL are paged, nothing outside user_setting is persisted, and no dependencies were added.
The pager logic in src/crew/incidents.ts is small and clean. Delivery-before-record, the backoff and the hourly cap are handled and tested, and the stop()/close() split keeps the pager alive through drain(). I could not reach the preview from my sandbox, but this change is server-side only.
Fix first (high): in the self-swap and rolling-restart flow, Recovered is lost, and a stale one arrives later. The README's swap starts the new process before the old one drains, and the old process can run on for up to five minutes or more. The new process reads notify_drain_paged only in its boot baseline. When the old process pages after the new one has booted, paged in the new process already holds the old value. After the old process exits, nextPage(undefined, …) in the new process never sees the stored key, so no Recovered is sent. The next restart, maybe days later, then sends a "Recovered" for a drain that ended long ago. That is the opposite of calm. Fix: send Recovered from the draining process itself, in close(), with the bounded send. Its exit is the end of the drain. Keep the stored key only as a fallback for a process that died without close(). Then drop the boot-time inference, or bound it with a freshness check. Add a test where notifier A pages after notifier B has booted, then A closes, and assert exactly one Recovered, sent promptly.
Also:
- README §health still says "A drain that runs too long is not covered on either: the forge keeps no start time for it." That is now false. Say that an admin with a notify URL is paged once when a drain passes five minutes and once when it ends. Nothing for the terminal: this is a push, not a door.
- The
stuck/drilltest passes vacuously (see the line comment).
Recovered now comes from the draining process itself as it exits, so it no longer depends on the order of a swap. The type check and the notify, vitals, health and swap tests pass (64 tests); I did not run a real release.
What changed
- Recovered on exit.
Notifier.close()is now async. It stops the pager, waits for the queued work, then sends one "Recovered" to each admin this process itself paged. That send is bounded by the existing 5-second timeout and skips the hourly cap and backoff.close()insrc/main.tsis async and awaits it beforeprocess.exit. - Stored key removed. The
notify_drain_pagedrow and the boot-time inference are gone. In the swap order the inference would have double-sent. A process that boots mid-drain still starts silent, so a restart does not re-page. - Swap test. In
test/notify-drain.test.ts, notifier B boots first and sees no drain. Notifier A then pages, and exactly one Recovered goes out when A closes. A later tick or close of B sends nothing more. - Guard test dropped. The
stuck/drilltest was dropped: nothing in the change reads the guard, so it could not fail. Not paging those causes holds because the change never reads the guard, not because a test pins it. - README. The health section now says an admin with a notify URL is paged once when a drain passes five minutes and once, by the draining process as it exits, when it ends. It is a push, not a door.
Left as is: a process that dies mid-drain without close() sends no Recovered. I chose silence over a possibly stale "Recovered" after a later restart, and the README says so.
Approved. I read the whole diff against main and the swap/drain wiring in src/main.ts and src/release/swap.ts. At 4a95b080, bun test test/notify-drain.test.ts test/notify.test.ts test/vitals.test.ts passes (30 tests) and tsc --noEmit is clean.
Security: there is no new input surface. Pages go only to users who are still isAdmin and kind === "user" with a stored notify URL, through the existing send, which has its 5 s timeout. Nothing is built from user text, and /api/health keeps the same keys as main. The wiring holds. drain() calls stop(), so the pager keeps running through the drain. close() awaits notifier.close() before process.exit, which still happens on both the shutdown and drainOnHandover paths. Its extra delay is bounded at 5 s per admin who was paged. The swap test pins the one-Recovered-from-the-draining-process design, and the test that drives the real startDraining would fail if the pager stopped with stop().
This is a push, not a door, so src/tty.ts has nothing to mirror, and the README says so and names the silent case. My sandbox could not reach the preview or read the screenshots. Nothing on a page changes here, so I judged it from the code and the tests.
Three low-severity points, none of which blocks the merge: the two line comments below, and Notifier.tick is public only for tests, as settled already is.
The drain page is back on the branch with the failing check fixed. The vitals, notify, health and guard tests now pass (83 tests), and the type check is clean.
Why the check failed: I had added
drainingSinceto theForgeHealthobject. That object is also what/api/healthreturns to a site admin. So the admin's response gained a field, and the test "a site admin with a read-only token gets the shape; others get the short one" intest/vitals.test.tscompares the exact key list. The test was right and I changed no test. I took the field out ofsrc/kernel/guard.ts, so the/api/healthresponse has the same keys as onmain.What changed
src/crew/incidents.tsdecides this from the drain's start time and the clock.src/crew/notify.tssends it.health.drainingSince, set once instartDraining. Only the notifier reads it. The notifier checks about once a minute.held,stuck,oom:*and the vitals causes are never paged here.test/notify-drain.test.ts.What I left out
Try it: run
bun test test/notify-drain.test.ts test/vitals.test.ts.Closes #2491