a restarted background job survives a refresh
Reported as "I refreshed the page and the pill was gone, but the server is still
running and now I have no way to control it". Not a design choice — a bug, and it
came in with the restart button.
The tray is not stored anywhere. It is a fold over the durable chat event log:
`task:started` minus `task:notification`, per `collectRunningTasks`. That is what
rebuilds it after a refresh.
The restart route announced its respawn with `emitToSession`, which folds into the
in-memory session and writes to open sockets — and never called `appendChatEvent`.
So the pill existed only in the RAM of the browser tab that clicked the button. The
log still read:
seq 10979 task:started "Run dev server"
seq 11362 task:notification failed <- the ORIGINAL dying
Folded on refresh that says "not running", so the chip vanished — while the process
it described was up and serving on 3001. And every task route takes an id, so the
job became unreachable from the UI at the moment the browser forgot the id. Stop,
Pause and Restart would all still have worked; nothing could name the task.
The second half was quieter: `emitToSession` returned false when the session was not
in memory, and the route ignored the return. A platform restart drops the in-memory
session while the job keeps running, so the announcement went nowhere AND was never
written down.
Now it appends first and unconditionally — persistence does not need a listener,
only delivery does — and sends with the seq the log assigned, so a client's replay
cursor stays correct. Both events: the `task:started` is awaited so it is durable
before the client is told the restart worked, and the exit notification is
fire-and-forget because it lands long after the response.
This makes officer a writer of `chat_session_events`, which the note in websocket.ts
said it no longer was. `cut-off` had already been that exception. The real rule is
narrower and is now written down: officer writes events it ORIGINATES, because no
sidecar will. Turn output still belongs to the harness that produced it.
── How it was verified ──
End-to-end, out of band: a synthetic task with a real process holding a real output
file, a transcript in the harness's shape, and the two durable events that reproduce
the reported log. Driven through `buildHonoApp` so the permission gate and auth
middleware were in the path — the last two bugs of this kind both passed tests that
mounted the router alone.
BEFORE restart: 2 durable events -> tray would show 0 running (bug reproduced)
POST /restart -> 200
AFTER restart : 3 durable events -> tray would show 1 running
Not added to `bun test`: nothing in the suite touches Postgres, and this claim is
untestable without it. So this is a probe that ran once, not coverage.
Needs a server restart. tsgo clean, 919 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -296,7 +296,9 @@ chatRouter.post('/tasks/:id/restart', async (ctx) => {
|
||||
|
||||
const result = await restartBackgroundTask(who, taskId, (code: number) => {
|
||||
if (!body.sessionId) return;
|
||||
emitToSession(body.sessionId, {
|
||||
// Fire-and-forget because the exit arrives long after this request answered; `emitToSession` swallows
|
||||
// its own failures, so there is nothing here to await for.
|
||||
void emitToSession(body.sessionId, {
|
||||
type: 'task:notification',
|
||||
taskId,
|
||||
status: code === 0 ? 'completed' : 'failed',
|
||||
@@ -306,8 +308,14 @@ chatRouter.post('/tasks/:id/restart', async (ctx) => {
|
||||
|
||||
// Announced only on success, and only after the process exists — a `task:started` for something that
|
||||
// failed to spawn would leave a chip running forever with nothing behind it.
|
||||
//
|
||||
// Awaited, so the event is in the durable log before the client is told the restart worked. The tray is
|
||||
// rebuilt from that log on every refresh, and a client that reloaded in the gap would have folded the
|
||||
// ORIGINAL task's `task:notification` as the last word and shown nothing — which is precisely the bug
|
||||
// this replaced: a live dev server with no chip and no way to reach it, since every task route needs an
|
||||
// id the browser had just thrown away.
|
||||
if (result.ok && body.sessionId) {
|
||||
emitToSession(body.sessionId, {
|
||||
await emitToSession(body.sessionId, {
|
||||
type: 'task:started',
|
||||
taskId,
|
||||
description: result.command?.split('\n')[0]?.slice(0, 80) ?? 'restarted job',
|
||||
|
||||
Reference in New Issue
Block a user