Skip to content

Restart the server without anyone noticing: ready and live checks, a quiet reconnect, and a reload after a deploy only when it is safe - #793

Merged
lavaman131 merged 5 commits into
mainfrom
restart-quick-wins
Nov 6, 2026
Merged

lavaman131 merged 5 commits into
mainfrom
restart-quick-wins

Conversation

@lavaman131

@lavaman131 lavaman131 commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Stage 1's quick wins for invisible restarts, from the zero-downtime upgrades design (docs/zero-downtime-upgrades-design.md at revision 29ca4ca of the design branch): "Stage 1: invisible restarts on one server", and "Order and dependencies" (the client fixes and the readiness endpoints help even with today's stop-then-start deploys, so they come first). Four small commits, each with its own tests, and a browser test last, on top of main at 86c5b69 (the composer and Planner status UI, #792). Nothing here starts a second process, hands the lease over, persists Planner or MCP state, or touches stages 2 to 4.

How this sits on #792

#792's model is kept as it is, and its tests pass unchanged (wire.test.ts's outage cases, connection-notice.test.ts, offline-notice.test.tsx, status-copy.test.tsx, connection.e2e.ts, editing.e2e.ts, planner-picker, planner-attachment): Wire reports an outage through onOutage and its probe gives up after 4 s, the workspace's isOffline follows the outage rather than the status, the document says it once in one offline notice (useOfflineNotice) with Retry after enough failed attempts and "Reconnected" when it is over, and the copy is plain and about the connection, with nothing in it about the Planner. A loss that is not a restart goes exactly as it does there. The quick wins layer on that, for a restart only (a 1012 close):

  • No outage while the restart is quiet. For RESTART_QUIET_MS (5 s) after a 1012, Wire raises no outage and does not count the attempts that fail meanwhile (so Retry is not offered early). The single offline notice, its polite word and its Retry stay away because there is no outage for them to follow, and a restart that is over before then is never reported, not even as an outage that ended. If the server is still away when the period ends the outage is raised then (from a timer if no attempt has failed by then), and from there it is Quiet the machine status, and make a lost connection calm #792's: the notice follows its ordinary 1.5 s grace and plain copy, then Retry, then "Reconnected".
  • Every other surface speaks at the same moment. What waits on the notice (useConnectionNotice(lost, wire.restarting), RESTART_GRACE, which is the quiet period and then the same ordinary grace) is the editor's lock, the workspace's dimmed controls, a send held through a blip, a turn's "Connection lost" mark in the transcript, and the machine button, which Quiet the machine status, and make a lost connection calm #792 hides with a lost connection and which is kept until then so that nothing comes or goes.
  • A turn in progress shows as working through a quiet restart (silentRestart()); for any other loss it is marked disconnected as soon as the connection is gone, as in Quiet the machine status, and make a lost connection calm #792.
  • A message sent as the server restarts is held and sent again with the same request id instead of showing "Message not sent".

No text is added or changed: every label, notice and accessible name is main's.

Where this differs from the design

The design was revised while this was written. On two points (1 and 2) it says the opposite of what the task asked for, and four more places differ from it. Each is a deliberate choice, listed here so nobody reads the code as an implementation of the design's text:

  1. Restart jitter. The design says "Add no restart jitter: the first attempt keeps the existing ladder" (apps/web wire.ts, under "What changes, by package"). The task's acceptance criteria require clients to reconnect quietly with jitter, so a tab waits 0 to 1000 ms (RESTART_JITTER_MS) before its first attempt after a 1012. This is a conflict between the design and the task contract, resolved in favour of the task contract.
  2. Five seconds of silence, not 1.5. The design keeps the existing 1.5 s silence for a restart ("What changes, by package", packages/editor: "no 'Updating…' label and no 5 s or 30 s timings are used, because no notice may show within the grace"). Here a restart-marked loss raises no outage for RESTART_QUIET_MS, 5 s, and the surfaces that speak of a lost connection then wait the ordinary 1.5 s on top of it (6.5 s in all, RESTART_GRACE). The design's 1.5 s assumes the Stage 1 hand-over, a second process already waiting to take over. A restart that stops the old process before it starts the new one, as deployments do today, takes longer than 1.5 s, so a 1.5 s silence would say the document is offline in the middle of a quick restart, which the task forbids.
  3. Knocking inside the silence. After the first attempt the design goes on the existing ladder. Its waits (up to 3 s at its third step, 6 s at its fourth) found a server that was already back only after the 5 s, which a browser test showed as a notice. For the first 5 s a failed attempt is followed by another 250 to 1000 ms later and does not count towards the backoff, which then goes on from where it was.
  4. A reload only when the server answers with another build. The design reloads on vite:preloadError alone when nothing is pending ("What changes, by package", apps/web wire.ts). Vite raises it for a network failure too, so with the server away the tab would reload onto the browser's error page, and it is raised for failures that are not a deploy (a request that is blocked or aborted), where the page's own error and Reload button are the recovery and a reload of the same build would not cure it. The reload therefore also asks the server for the page and compares its entry script with the tab's own.
  5. Readiness goes off when a drain starts. The task's brief asks for readiness false from the moment a drain starts, and drain() does that on its first statement. The design says "Readiness is not turned off at SIGTERM" ("Hand-over sequence", step 3.1) and turns it off at its cut, with the process still serving, for a fixed unready window before the listener stops (step 3.5.1). That sequence needs a second process, and today's drain() has no wait for turns: it stops the listener right after, so there is no window in which the process answers while not ready.
  6. The browser test restarts stop-then-start. The design's end-to-end test is "a real overlapping restart", which measures the notice-free gap and fails above 1.5 s. This one has no second process to overlap, so it stops the server, leaves it away for a second and starts it again, and asserts that nothing is said through an outage of 1.5 to 4 s.

The current revision of the design also drops the "Updating…" label and any longer offline threshold for a restart. Neither was implemented here, and there is nothing left to add for them.

What changes

1. Readiness and liveness (design: Stage 1, "What changes, by package", apps/server main.ts; "Security": "Health endpoints are unauthenticated and reveal only a status word")

  • GET /api/health/live answers 200 live while the process runs.
  • GET /api/health/ready answers 200 ready only once the process holds the chopin:writer lease and its startup recovery has finished, 503 starting before that, and 503 draining from the first statement of drain(). Both answer HEAD, need no login, and say one word.
  • startup.test.ts asks a real process against PostgreSQL for both. docs/self-hosting.md describes them. The image's own health check is unchanged, as are the design's other route-ready checks (a database transaction, index.html) and the warm-standby standby word.

2. 1012 on shutdown, and a quiet reconnect (design: "Hand-over sequence", step 3.5.5, and "Reference review" (send 1012 rather than rely on Bun's 1001); "What changes, by package", apps/web wire.ts; "Protocol"; "Compatibility"; "Failure modes", "Proxy answers 4xx in the gap"; open question 6)

  • server.stop(true) closes every socket with 1001. OpenSockets.stopForRestart() now stops the listener, closes every socket (browser, sidebar, agent link) with 1012 and then drops what is left. A real Bun server shows clients 1001 without it and 1012 with it, also under backpressure.
  • Admission's refusals of /ws and /ws/sidebar carry x-chopin-refusal: 1. The browser takes a probe's 4xx as a refusal only with it, or when it is a 401 (a rolled-back server's refusals predate the marker). A proxy's 404 or 403 while no upstream answers used to stop every open tab from reconnecting for good.
  • After a 1012 the tab reconnects after 0 to 1000 ms without probing first (see departures 1 to 3 above). Wire.restarting is true until the socket opens. Unsent edits, drafts and answers were never cleared by a loss.
  • The restart and Quiet the machine status, and make a lost connection calm #792's outage model: see "How this sits on Quiet the machine status, and make a lost connection calm #792".
  • A message sent as the server restarts stays quiet. A request waiting for its answer when the 1012 arrives is rejected as WireLost with restarting set (a plain drop is not marked). A Chat message in that position is held and sent again once the server is back instead of showing "Message not sent": its draft keeps its request id, and replay in chat/service.ts answers a request the server already holds with that message, so it arrives once whether or not the first attempt was read.
  • docs/self-hosting.md, docs/agent-connection.md (1012 in the closing table) and AGENTS.md say what the close and the header mean, and where this departs from the design.

3. A missing asset is a 404, and a failed chunk reloads the tab only when that is safe (design: "What changes, by package", apps/server/src/client.ts and apps/web wire.ts; "Approaches considered", the table under "Old tabs after a deploy"; "Failure modes", "Old tab requests a chunk that is no longer kept")

  • A missing file under /assets/ answers 404 (no-store), not the app's HTML with status 200. Every other missing path is still the app.
  • watchPreloadErrors() reloads the tab for vite:preloadError only when nothing is unsaved, the server answers with a page of another build than the tab runs (it asks for the current address and compares the entry script, entryOf()), and the last reload was not within 10 s (kept in sessionStorage; if storage refuses, no reload is made). The event is never taken, so the failure goes on as before. The page's own recoveries (the failed search dialog, the Projects drawer, the chart) are untouched, and so are their browser tests.
  • "Unsaved" is asked of @chopin/protocol/pending-work, where owners are tracked weakly, and of usePendingWork() for text a component holds. It covers the editor's outbox, a Decision's draft and answer in flight, a research brief's edits, the Chat composer, a new comment's draft, a comment reply typed and not sent or sent and not yet answered, a question typed into the document for research or an investigation, the "Your take" reply on a Decision's page, "What needs to change?" when returning a build to planning, the details of an exploration, an investigation's decision conclusion and rationale, an option or a criterion typed and not yet added, a document title being renamed, and a settled Decision's why being edited. Audited and not counted, because nothing there is authored text a reload would cost more than a moment: search and filter fields, the repository, URL, pairing, machine-name and command fields of the dialogs and the Machines page, the link and image fields of the editor's popovers, a tab's name (written into the document when the field is left, so the outbox holds it), and the excerpt correction form. Text typed into the document itself, widgets included, is in the editor's outbox, and Decision selections and free-text answers are in the questionnaire's.
  • The initial JavaScript is 258,951 B raw / 81,980 B gzip (257,905 B raw / 81,523 B gzip at the previous commit, which the raw limit of 258,000 B had left 95 B under) at the final tree, against limits of 259,000 and 82,328 B (the raw limit raised by 1 KB in commit 3 stands).

4. Browser test of a restart under an open tab (design: "Tests", "New end-to-end test (does not exist today)"; see departure 6)

e2e/restart.e2e.ts starts its own server and database and has eight cases:

  • A person types numbered words without stopping while the server gets SIGTERM, is left away for a second and starts again, with an unsent message in the composer. The page samples every 25 ms whether it says anything about the connection (the status, the composer's error line, the editor's lock, and the document's one offline notice and its polite word). The test asserts that the browser saw 1012 and never 1001, that nothing was said, that the tab did not reload, that the message is still in the composer, that every word is in the stored document once and in order, and that the old server exited 0.
  • A restart that outlasts its quiet period: nothing is said for 5 s, then the one offline notice appears as for any other loss, and goes (with "Reconnected") when the server is back.
  • A message sent as the server restarts, twice: the send is never read by the server, or the server takes it and the answer is lost with the connection. The composer says nothing, the same request id is sent again once the server is back, and the message is in the room once, on the page and in what the server stored.
  • A reply typed and not sent, and then a research question typed into the document, stop a failed chunk from reloading the tab; once they are sent or put away the next failure reloads it.
  • A missing asset (404 from a real server), a failure with the same build served (no reload), the guarded reload with an unsent message, a chunk that fails while the server is away (no reload into the outage), and a real chunk answered 404 (one reload, not a loop). deployed() makes the tab's request for its page name another entry script, and changes nothing else.

The one error the restart cases still allow is the sidebar's idle warm-up of its dialogs failing while no server answers: exactly Failed to fetch dynamically imported module: <this server>/assets/dialog-prefetch-<hash>.js, at most once, and only between the server being stopped and the new one answering. It is raised to the console and nothing on the page shows it (the sampler would catch a notice). Any other page error, another chunk included, fails the case.

Not done, and why

  • Keeping the previous build's hashed assets in the image (design: "What changes, by package", Dockerfile and CI, a PREVIOUS_IMAGE build argument with COPY --from). The Dockerfile builds the client from source and copies only that build's dist; nothing in this repository names a previous image or a registry (CI builds chopin:test with push: false, and the only release workflow publishes the CLI), and the design itself puts release identity and the registry in Stage 2 and outside this repository (open question 12). It waits for the work that gives a server release an identity.
  • From Stage 1's own list, left out as beyond this change: the design's other readiness checks and moving the image's health check to live; the session:restarting frame and the build identifier; the quiet reload prompt for the case where work is pending; the agent link's restart-notice feature; and the remote agent client, MCP session and queued-message assertions of the design's browser test.
  • Nothing from stages 2 to 4, and none of the hand-over (a second process that waits for the lease, standby, persisted holds, deferred turns and MCP sessions). A Planner message that was acknowledged and still queued behind a running turn when the server stops is still lost, as before; the design's durable queue is what covers it.

Reviewing

  • No user-facing text is added or changed: every label, notice and accessible name is main's. The only strings added are the health words, a 404 body and a WebSocket close reason, none of which a person reads.
  • chat.tsx changes by a few lines (the notice's input, the wait of a send held through a blip, the send held again after a restart, usePendingWork for the unsent message, and the turn's working state while a restart is quiet) because the composer and status UI is being redesigned elsewhere. room-workspace.tsx changes by two lines (the controls' grace and the machine button kept while a restart is quiet). comment-layer.tsx changes by two lines.
  • The reviewed whole-file hashes of plan-editor.tsx, comment-layer.tsx, document-rename.tsx and question-view.tsx in the design-contract exceptions are renewed: their edits add a hook call, an argument or an import and leave the className and style flows the exceptions record alone.
  • No assertion of main's tests is weakened. The two helpers of main's wire.test.ts that this series had to change are its refusal fixture, which now sends Chopin's marker header (a refusal without it is a proxy's, by the rule above), and until(), which takes a timeout; a few of main's other tests gained cases (connection-notice.test.ts) or got the production change they needed (client.test.ts: a missing asset is 404).
  • Reload after a deploy, decided: a failed chunk reloads the tab only when another build is being served, so for failures that are not a deploy the chart's, the dialogs' and the drawer's own errors stay. When a deploy is behind it, the tab reloads and skips those errors, but only when it holds nothing unsaved, and at most once every 10 s. That is the industry standard, as asked: Vite's own guidance for vite:preloadError is to reload the page (https://vite.dev/guide/build#load-error-handling), and Next.js does a hard navigation, a full reload, when a tab's deployment no longer matches the server's (deploymentId, and its fallback for chunk failures during navigation, Add experimental flag for chunk failure handling during app navigation vercel/next.js#67926), with a guard against a reload loop. Ours is stricter than both: neither checks for unsaved work first.
  • Not covered by a test: the machine button being kept while a restart is quiet. It needs a paired machine in a restarting server's browser test, which restart.e2e.ts does not have, and the one-line derivation has no unit to test on its own.
  • docs/self-hosting.md still says a restart signs every browser out, which the design records as a correction for hosted mode; it is left alone here.

Verification

  • bun test (the whole repository) passed on the final tree: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository. Each commit's own tree also passed the affected tests after the rebase (21, 352, 1,459 and 292 tests for commits 1 to 4).
  • bun run fix changed nothing, and bun run types and bun run ci passed on the final tree and on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree and 71 on the first three commits (the extra one is in the browser test), tokens, design contract (1,116 sources, 330 reviewed exceptions on the final tree), design record and design check. bun run build passed on the final tree and on commit 2's tree; its initial JavaScript is 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B.
  • bun run e2e with a freshly built client ran the whole browser suite on the final tree: 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first). That includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments, every spec Quiet the machine status, and make a lost connection calm #792 added or changed (connection, editing, planner-attachment, planner-picker) and the specs that touch the machine button and the offline notice (machine-choice, machines, implementation-launcher). e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16, on the tree before the last revision of e2e/experiments.e2e.ts, which it does not touch. (Without --workers=1 the repeats ran side by side and failed: each case starts a server on a fixed port and a database of its own, so they cannot.) e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced and not explained), and passed in the whole-suite run and in 6 other runs, alone and together with restart.
  • The whole suite found what the earlier rounds, which ran a subset, had not, and it is fixed in this series: a first run on the rebased tree failed 8 tests. Two (interface-quality: a failed lazy dialog, navigation-focus-recovery: a failed Projects drawer) were the automatic reload for a failed chunk taking the place of the page's own error, which is why it now needs the server to answer with another build (departure 4); and experiments.e2e.ts is back to main's, no longer needing an unsent message. Two (smoke: a chat disconnect and a stale turn after a reconnect) were a turn staying "working" after any loss, where Quiet the machine status, and make a lost connection calm #792 marks it disconnected at once, which is why that is now only for a restart. Four did not reproduce and are not explained: planner-picker (a teammate's document), responsive-workspace (the wide sidebar) and demo-collaborators (a persona after transport loss) passed on the next run, and document-navigation ("archiving a sidebar row by keyboard moves focus to a neighbouring row") failed in two combined runs on this branch, then passed alone on it (the whole file, 36 of 36), in the same combined set on main (136 passed) and in the final whole-suite run. It shares a catalogue with the tests around it; whether this branch makes it likelier is not known.
  • apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet.test.ts, wire-loss.test.ts, preload-recovery.test.ts, transcript-state.test.ts and connection-notice.test.ts (55 tests) passed 30 of 30.
  • Mutation checks on the final tree, each caught by the case for the code it breaks. The offline notice ignoring the restart's quiet period: in unit tests, quietLeft() returning none (6 cases failed) and no timer for the end of the period (3); in the browser, with the outage raised at once for a restart, both the restart case (the notice at about 3.4 s of the sampler's timeline) and the case of a restart that outlasts its quiet period failed. Also the attempts that failed in the quiet counted towards Retry, the restart grace the ordinary 1.5 s, silentRestart() never silent, every build counted as newer, quiet knocks counted towards the backoff, the restarting marker dropped from a rejected request, a 4xx taken as a refusal, and ResearchDraftStore no longer tracked.
  • Not repeated this round: the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send replaced by the error line, the comment composer and the research store no longer tracked in the reload guard, the allowed idle-prefetch chunk renamed) and the first round's (the 404 under /assets turned off, the unsaved check dropped from the reload, PlanProvider no longer tracked). Not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, and GitHub's checks on a pushed head (nothing was pushed and no PR was opened).
  • GitHub's container check then failed the initial JavaScript budget: the image's Linux build compresses the restart work to 82,789 B gzip, over the 82,328 B limit, where the local build had measured under it. d1206ce sets the limits about 200 B gzip and 500 B raw above what the container measures (83,000 B and 259,500 B), as earlier allowances in apps/web/bundle-budget.ts do.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0)
Assistant-duration: 62m in the first round and 106m in the second (04:12 PM to 5:58 PM PST on 2026-11-05, by date) and 146m in this one, the rebase onto main (06:14 PM to 08:40 PM PST on 2026-11-05, by date), about 314m of work not counting the time between the rounds
User-preference: Deploys must be strictly invisible to users
User-preference: For product behavior questions, use the industry standard for popular projects
Co-authored-by: Alex Lavaee lavaman131@github.com

… it holds the writer lease and has finished starting, and not ready from the moment a drain begins

A proxy or platform that routes new connections had nothing to ask but `/api/session`, which an unauthenticated caller gets from any process that is listening, whether or not it holds the writer lease, has finished recovery, or is already shutting down. Two endpoints now answer one status word and nothing else, to a caller with no login and for GET and HEAD alike.

`GET /api/health/live` is `200 live` while the process runs. `GET /api/health/ready` is `200 ready` only after the process holds the `chopin:writer` lease and its startup recovery (experiments, research, the job runner) has finished. It is `503 starting` before that and `503 draining` from the first statement of `drain()`, before the listener closes, and a drain that begins during startup keeps it from ever becoming ready. Neither answer is cached.

The state is a small `Readiness` that reads the held lease through a closure, so a lost lease reads as not ready; its tests cover each transition. `startup.test.ts` now also asks a real process, started against PostgreSQL, for both endpoints.

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server", "What changes, by package", `apps/server` `main.ts` (add `GET /api/health/live` and `GET /api/health/ready`, both unauthenticated and returning only a status word), and "Security" ("Health endpoints are unauthenticated and reveal only a status word"); "Order and dependencies" puts the readiness endpoints first because they help even with today's stop-then-start deploys. Not part of this change: the design's other route-ready conditions (a database transaction completes, `apps/web/dist/index.html` exists), the warm-standby state in which `live` answers `standby`, and moving the image's `HEALTHCHECK` to `live`; the image health check still calls `/api/session`. docs/self-hosting.md describes the endpoints.

One difference from the design, kept on purpose: readiness goes to `503 draining` from the first statement of `drain()`, as the task's brief asks (false from the moment a drain starts). The design says "Readiness is not turned off at SIGTERM" ("Hand-over sequence", step 3.1) and turns it off at its cut, with the process still serving, for a fixed unready window before the listener stops (step 3.5.1), because a proxy would otherwise hold every new connection through the wait for running turns. That sequence needs a second process, which this change does not add, and today's `drain()` has no such wait: it stops the listener right after turning readiness off, so there is no window in which the process answers while not ready.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0)
Assistant-verification: bun test apps/server/src/http and apps/server/src/storage/postgres/startup.test.ts passed on this commit's own tree: 21 pass, 0 fail across 5 files, including the readiness and liveness cases and the live and ready answers of a real started process; bun run types and bun run ci passed on it
Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository
Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check
Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch
Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B
Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree
Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet
User-preference: Deploys must be strictly invisible to users
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…eat that as a restart: reconnect after a jittered moment, say nothing for five seconds, send again a message the restart cut off, and read a proxy's 4xx as unreachable rather than as a refusal

`server.stop(true)` closes every socket with 1001, "going away", so a restart looked to a tab like any other lost connection: the document said it was offline after 1.5 s, and any 4xx a proxy answered while no upstream existed (a 404 or a 403 with no Chopin behind it) was taken for a refusal and stopped the tab from retrying for good.

Server: `OpenSockets` tracks every socket (browser, sidebar and agent link) and `stopForRestart()` stops the listener, closes each with 1012 and reason "server restarting", then drops what is left, in place of the bare `server.stop(true)` in `drain()`. A real Bun server shows clients 1001 without it and 1012 with it, under backpressure too. Admission's refusals of `/ws` and `/ws/sidebar` carry `x-chopin-refusal: 1` (`refused()`). The two existing 1012 closes in `main.ts` use the shared name, `SERVICE_RESTART`, defined with the header's name in `@chopin/protocol/restart`.

Browser: `lossOf()` reads 1012 as a restart, 4404 as a deleted document and anything else as an ordinary loss; `refusalOf()` reads a probe's answer as a refusal only if it is a 4xx with the marker, or a 401, which still asks for a sign-in and keeps a rolled-back server's unmarked refusals from retrying forever. After a 1012 `Wire` reconnects after 0 to 1000 ms without first probing. For `RESTART_QUIET_MS` (5 s, defined with the other restart constants) after the loss, each failed attempt is followed by another 250 to 1000 ms later (`restartRetryDelay()`) and does not count towards the backoff, so a server that is back is found within about a second; after that the usual backoff goes on from where it was. `Wire.restarting` is true from the 1012 until the socket opens. The outbox, drafts and answers are untouched: they were never cleared by a loss.

How this sits on the outage model of #792, which is kept as it is: `Wire` reports an outage through `onOutage`, the workspace's `isOffline` follows it, and its one offline notice (`useOfflineNotice`) is drawn from it after the usual 1.5 s grace, with the probe giving up after 4 s and the notice's plain copy. For a loss that is not a restart nothing changes: the outage is raised at once. For a restart, `Wire` raises no outage while `RESTART_QUIET_MS` lasts (`quietLeft()` in `wire-loss.ts` says how much is left), and the attempts that fail meanwhile are not counted towards the failures that decide when Retry is offered. So the offline notice, its polite word and its Retry stay away, and a restart that is over before then is never reported, not even as an outage that ended. If the server is still away when the period ends, the outage is raised then, from a timer if no attempt has failed by then, and from there it is #792's: the notice follows its ordinary grace, then Retry after the usual number of failed attempts, and "Reconnected" is said when the connection opens. Every other surface that speaks of a lost connection waits `RESTART_GRACE` (`connectionGrace()`), which is that quiet period and then the same ordinary grace, so they speak at the same moment as the notice does for any other loss: the editor's lock, the workspace's dimmed controls, the machine button (which goes with a lost connection, and is kept until then so that nothing comes or goes), a send held through a blip, and, for a turn in progress, the "Connection lost" mark in the transcript.

Two things a restart used to say inside that silence, now kept quiet. A request still waiting for its answer when the 1012 arrives is rejected as `WireLost` with `restarting` set (a plain drop is not marked), and a Chat message in that position is held and sent again once the server is back instead of showing "Message not sent": its draft keeps its request id, and `replay` in `chat/service.ts` answers a request the server already holds with that message, so it arrives once whether or not the first attempt was read. A restart that outlasts `RESTART_GRACE` leaves the draft in the composer, and the document says it is offline. And a Planner turn in progress goes on showing as working through a restart that is being waited out in silence (`silentRestart()` and `progressOf()` in `transcript-state.ts`). That begins with a 1012, lasts through the moment after the connection opens until the transcript is current again, and ends once the page speaks of the connection; a loss that was not a restart marks the turn disconnected as soon as the connection that carried it is gone, as before (the smoke tests of that stand as they were).

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server": "What changes, by package", `apps/web` `wire.ts` (on a 1012 close, mark the loss as a restart; treat a probe answer as a refusal only if it carries the marker header, or is a 401); the drain's step "closes each socket and link with 1012" ("Hand-over sequence", step 3.5.5) and "Reference review" (send 1012 rather than rely on Bun's 1001); "Protocol" ("Close codes"); "Compatibility"; "Failure modes" ("Proxy answers 4xx in the gap"); and open question 6, which this answers for Bun 1.4.4.

Three departures from that revision, each on purpose:

1. Jitter. The design says "Add no restart jitter: the first attempt keeps the existing ladder". The task's acceptance criteria require clients to reconnect quietly with jitter, so the first attempt waits 0 to 1000 ms (`RESTART_JITTER_MS`). That is a conflict between the design and the task contract, resolved in favour of the task contract.
2. Silence. The design keeps the existing 1.5 s silence for a restart ("What changes, by package", `packages/editor`: "no 'Updating…' label and no 5 s or 30 s timings are used, because no notice may show within the grace"). Here a restart-marked loss raises no outage for `RESTART_QUIET_MS`, 5 s, and the surfaces above then wait the ordinary 1.5 s on top of it. The design's 1.5 s assumes the Stage 1 hand-over, a second process already waiting to take over, which is out of scope. A restart that stops the old process before it starts the new one, as deployments do today, takes longer than 1.5 s, so 1.5 s would say the document is offline in the middle of a quick restart, which the task forbids.
3. Retries. After the first attempt the design goes on the existing ladder. Its waits (up to 3 s at its third step and 6 s at its fourth) found a server that was already back only after the 5 s, which a browser test showed as a notice, so for the first 5 s a failed attempt is followed by another about a second later.

Left out of this change: the `session:restarting` frame, the `build` identifier and the agent link's `restart-notice` feature; the 1012 close of an agent link ends it exactly as the 1001 did. The current revision of the design drops the "Updating…" label and any longer offline threshold, so none was implemented. docs/self-hosting.md, docs/agent-connection.md and AGENTS.md say what the close and the header mean. The reviewed whole-file hash of plan-editor.tsx in the design-contract exceptions is renewed: its edit passes one more argument to a hook and leaves the `className` flow it records alone.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0)
Assistant-verification: bun test of apps/web/src/wire.test.ts, wire-loss.test.ts, wire-quiet.test.ts, the chat directory, packages/editor/src/connection-notice.test.ts and apps/server/src/socket passed on this commit's own tree: 352 pass, 0 fail across 33 files, including the cases of wire-quiet.test.ts on fake sockets and time (a restart raises no outage within its 5 s however many attempts fail, says nothing at all when the server is back in time, raises the outage when the 5 s have passed whether or not an attempt has failed, does not count the attempts that failed inside it, and a loss that was not a restart is reported at once), the WireLost cases, and the cases of silentRestart() and progressOf() (a turn goes on showing as working while a restart is quiet, and is marked disconnected as soon as any other connection is gone); bun run types, bun run ci and bun run build passed on it (initial JavaScript 257,905 B raw / 81,523 B gzip, against limits of 258,000 and 82,328 B)
Assistant-verification: mutation checks on this tree, each caught by the case for the code it breaks: quietLeft() returning none, so the outage is raised at once for a restart (6 cases failed), no timer for the end of the quiet period (3 failed), the attempts that failed in it counted towards Retry (1 failed), the restart grace the ordinary 1.5 s (1 failed), silentRestart() never silent (2 failed), quiet knocks counted towards the backoff (the backoff case failed), the `restarting` marker dropped from a rejected request (the WireLost case failed), and a 4xx taken as a refusal (2 cases failed)
Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository
Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check
Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch
Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B
Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree
Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet
User-preference: Deploys must be strictly invisible to users
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…hunk that is gone only when it holds nothing the server has not taken

After a deploy, a tab opened on the old build asks for chunks of that build. The server answered every missing path with `index.html` and status 200, so the browser got HTML for a module and failed with an error that said nothing about a new build, and nothing in the page handled Vite's `vite:preloadError`.

Server: `serve()` answers 404 (plain text, `cache-control: no-store`) for any missing path under `/assets/`. Every other missing path is still the app, so a room URL survives a reload, and a file that exists is served as before, hashed ones immutable. The test that expected the app's HTML for a missing hashed asset now expects 404, and a new one covers hashed, unhashed, nested and directory paths.

Browser: `watchPreloadErrors()` listens for `vite:preloadError` and reloads the page only when nothing is unsaved and the server answers with a page of another build than the one the tab runs: it asks for the current address and compares the entry script its HTML loads (`entryOf()`, a name that changes with any file of the build) with its own. A chunk also fails to load while the server is restarting, and a reload then lands on the browser's error page; and it fails for reasons that have nothing to do with a deploy (a request that is blocked or aborted, say), which the page's own recovery says what to do about, as it did, and a reload of the same build would not cure. So the failed search dialog, the failed Projects drawer and the failed chart keep their own errors and Reload buttons, and their browser tests are as they were. Work begun while the server was being asked stops the reload too. The event is never taken, so the failure goes on to be thrown to whatever was loading the chunk, as before, and a later failure reloads once the work is done. A reload that did not cure the failure is not repeated within ten seconds, remembered in `sessionStorage`; where storage refuses, no reload is made, so a build that is really broken cannot loop. What is unsaved is asked of `@chopin/protocol/pending-work`, where an owner is tracked weakly and says `unsaved` when read, or, for a component, through `usePendingWork()` while its text is held. Counted: the editor's outbox of unacknowledged updates (`PlanProvider`, the one `provider.ts` keeps until `plan:ack`), a Decision's draft edits and answer in flight (`QuestionnaireController`), a research brief's edits (`ResearchDraftController`), a Chat message not yet sent, a new comment's draft, a comment reply typed and not sent or sent and not yet answered (the words leave the field when sent and return if it is refused), a question typed into the document for research or an investigation and not started, or started and not yet placed (`ResearchDraftStore`), the "Your take" reply on a Decision's page, "What needs to change?" when returning a build to planning, the details of an exploration, an investigation's decision conclusion and rationale, an option or a criterion typed and not yet added, a document title being renamed, and a settled Decision's why being edited. Not counted, because nothing there is authored text that a reload would cost more than a moment: search and filter fields, the repository, URL, pairing, machine-name and command fields of the dialogs and the Machines page, the link and image fields of the editor's popovers, a tab's name (written into the document when the field is left, so the outbox holds it), and the excerpt correction form, which is prefilled from a Chat message and holds a choice from it.

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server": "What changes, by package", `apps/server/src/client.ts` ("Answer 404 for any missing `/assets/*` path instead of `index.html`") and `apps/web` `wire.ts` ("Add a `vite:preloadError` handler that reloads only when the editor outbox is empty and no composer draft is unsent. Otherwise it shows a quiet reload prompt"); "Approaches considered", the table under "Old tabs after a deploy" ("Answer 404 for a missing asset and reload on `vite:preloadError`", which may run only when nothing is pending); and "Failure modes" ("Old tab requests a chunk that is no longer kept"); "Order and dependencies" ships the asset 404 and the client fixes first. Two differences from the design's handler, both on purpose: it reloads on the event alone, and here the server must also answer with another build, because the event also fires while the server is away, which a browser test showed, and for failures that are not a deploy, which the browser tests of the page's own recoveries showed. The design's quiet reload prompt for the case where work is pending is not part of this change, and no text was added or changed for any of the work counted above.

Not done here, and why: keeping the previous builds' hashed assets in the new image (the design's `PREVIOUS_IMAGE` build argument with `COPY --from`). The Dockerfile builds the client from source and copies only that build's `apps/web/dist`; nothing in this repository names a previous image or a registry (the CI container job builds `chopin:test` with `push: false` and the only release workflow publishes the CLI), and the design itself lists release identity and the registry under Stage 2 and as outside this repository (open question 12). Without the previous image the build has no assets to keep, so it is left for the work that gives a server release an identity. docs/self-hosting.md says what a tab does now and why the new image carries only its own files. The client build's initial JavaScript limit rises by 1 KB raw, to 259,000 B (`apps/web/bundle-budget.ts`): the build at the previous commit measured 257,905 B raw, 95 B under the limit, and this change's reload recovery and unsaved-work registry take it to 258,951 B raw / 81,980 B gzip, inside the unchanged gzip limit of 82,328 B. The reviewed whole-file hashes of comment-layer.tsx, document-rename.tsx and question-view.tsx in the design-contract exceptions are renewed: their edits add a hook call and an import and leave the style and class flows they record alone.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0)
Assistant-verification: bun test of apps/server/src/client.test.ts, apps/web/src/preload-recovery.test.ts, packages/protocol, packages/editor, packages/question, apps/web/src/chat and apps/web/bundle-budget.test.ts passed on this commit's own tree: 1459 pass, 0 fail across 173 files, including entryOf() and isNewerBuild() and a failed chunk not reloading a page that holds a typed investigation question; bun run types and bun run ci passed on it
Assistant-verification: mutation checks on this tree, each caught: every build counted as newer failed the two cases of isNewerBuild(), and `ResearchDraftStore` no longer tracked failed its research-draft case and the preload-recovery case
Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository
Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check
Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch
Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B
Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree
Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet
User-preference: Deploys must be strictly invisible to users
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…s, and a chunk a deploy removed, in a real browser: nothing shown, no edit, draft or message lost or doubled, and a reload only when it is safe

Nothing exercised a real restart with a tab left open: the reconnect tests close the browser's own socket, and the build restart test reloads the page afterwards. `e2e/restart.e2e.ts` starts a server of its own (port 8796, a database of its own in the cluster of `E2E_DATABASE_URL_3`, as `build-restart.e2e.ts` does), waits for it on `/api/health/ready`, and has eight cases.

A restart under an open tab: a person types numbered words into a document without stopping while the server is sent SIGTERM, left away for a second, as a platform leaves a gap between the old container and the new one, and started again, with an unsent message in the composer. The old server must exit 0, and the restart must take longer than the 1.5 s a blip is waited out and less than the 4 s the page waits for a restart. The page itself samples, every 25 ms, whether it says anything about the connection: the document status, the composer's error line, the editor's lock, and the document's one offline notice with the word it says once it has gone, so no notice can come and go between two assertions. The browser must have been closed with 1012 and never 1001; nothing may have been said; the tab must not have reloaded, thrown or lost its place; the message must still be in the composer; and the stored document must hold every word exactly once and in order.

A restart that outlasts its quiet period: the server is left away until the page speaks. Nothing is said for the first `RESTART_QUIET_MS` (the lock and the notice included), and then the document's one offline notice appears after its ordinary moment of grace, as for any other lost connection, and goes, with "Reconnected", and the editor is editable again when the server is back.

A message sent as the server restarts, twice: the page's WebSocket goes through Playwright's `routeWebSocket`, and while the restart begins either the send is never read by the server, or the server takes it and the answer is lost with the connection. In both the composer says nothing, the same request id is sent again once the server is back, the draft goes when it is taken, and the message is in the room once, in the page and in what the server stored.

A tab holding a reply or a question typed into the document is not reloaded for a chunk that is gone: with a comment reply typed and not sent, and then a research question typed into the document and not started, a `vite:preloadError` leaves the tab where it is; once the reply is sent and the question put away, the next one reloads it.

An asset of a build that is gone: a missing file under `/assets/` answers 404 and not HTML from a real server, while the entry chunk is served as immutable. A `vite:preloadError` with the same build served does not reload the tab, whatever the reason for it. Once another build is served (`deployed()` makes the tab's request for its page name another entry script, and changes nothing else), a failure with an unsent message does not reload the tab, once the message is cleared it reloads the tab once, and one straight after does not reload it again.

A chunk that fails while the server is away does not reload the tab into the outage: with another build to be served but the server stopped and nothing unsent, the event leaves the tab where it is, and the tab is quiet and editable again once the server is back. This is the case that found the reload would otherwise land on the browser's error page.

A chunk a deploy removed: with a real lazy chunk answered 404, as a server answers for a file of the build before it, and another build served, the tab reloads once and then stops.

The test found two things that were fixed in the commits before it, before the series was finished: after a restart the ladder's waits could find a server that was back only after the quiet 5 s, which showed a notice for a moment; and the reload for a failed chunk also ran while the server is away.

One error is still allowed, and only that one. While no server answers, the sidebar's idle warm-up of its dialogs asks for the `dialog-prefetch` chunk and fails, which the console sees and nothing on the page shows; the restart cases accept exactly that error, for that chunk of this server, at most once, and only between the server being stopped and the new one answering. Any other page error, another chunk included, fails them.

Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server", "Tests", "New end-to-end test (does not exist today)": a real overlapping restart under an open tab, typing continuously while a warm server takes over from the old one, asserting no reload, no notice of any kind, no `replaced` reset, that every character appears once, and that an old chunk URL gets 404; it also measures the notice-free gap and fails above 1.5 s. This test differs on purpose. Chopin has no second process to overlap with, so it restarts stop-then-start, with a gap of a second between the two servers, and asserts no notice through an outage of 1.5 to 4 s, which the 5 s quiet period (`RESTART_QUIET_MS`, a departure from the design's 1.5 s described in the commit that adds it) allows and a 1.5 s silence would not. Left out, because they need the persisted state or the overlap of later work and not this stage: the design's remote agent client holding an open question, the MCP session ID, a message queued behind a running turn, and a second server that waits for the first.

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0)
Assistant-verification: bun test apps/web/src/wire.test.ts and apps/web/src/chat passed on this commit's own tree: 292 pass, 0 fail across 24 files (the commit changes only a browser test); bun run types and bun run ci passed on it
Assistant-verification: mutation check in the browser, on a rebuilt client: with the outage raised at once for a restart, so that the offline notice ignored the quiet period, both the restart case (the notice at about 3.4 s of the sampler's timeline) and the case of a restart that outlasts its quiet period failed
Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository
Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check
Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch
Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart
Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B
Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree
Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet
User-preference: Deploys must be strictly invisible to users
Co-authored-by: Alex Lavaee <lavaman131@github.com>
Assistant-duration: 62m in the first round and 106m in the second (04:12 PM to 5:58 PM PST on 2026-11-05, by `date`) and 146m in this one, the rebase onto main (06:14 PM to 08:40 PM PST on 2026-11-05, by `date`), about 314m of work not counting the time between the rounds
…as the container's Linux build measures it

The container build failed its initial JavaScript budget: 258,947 B raw against 259,000 B, and
82,789 B gzip against 82,328 B. The earlier commit raised the raw limit by 1 KB and kept the gzip
limit, because the build where it was measured compressed under it; the image's Linux build
compresses 461 B larger. The limits now sit about 200 B gzip and 500 B raw above what the
container measures, as earlier allowances in this file do.

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline
Assistant-verification: GitHub container check failed: [initial-javascript-budget] initial JavaScript 258947 B raw / 82789 B gzip exceeds budget 259000 B raw / 82328 B gzip
Assistant-verification: bun test apps/web/bundle-budget passed: 2 pass, 0 fail
Assistant-verification: bun run ci passed: design contract, design record and Impeccable show no new findings
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 merged commit 831a348 into main Nov 6, 2026
46 of 48 checks passed
@lavaman131
lavaman131 deleted the restart-quick-wins branch November 6, 2026 07:26
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