The server went down. It had not: it was STOPPED — eleven threads in state T, port
9000 still listening with connections queueing behind a process that would never
accept them. Indistinguishable from a crash unless you look, which is why it took a
terminal to see. `kill -CONT` brought it back with nothing lost.
I did it, with one missing line. `restartBackgroundTask` opened the job's output
file to hand the child a descriptor and never closed it, so the PLATFORM became a
holder of that file. `pidsHoldingFile` asks `fuser` who holds it — that is how Stop
finds a job with no recorded pid — so the platform was in the answer. The next Pause
sent SIGSTOP to it.
Every part worked as designed. Pause froze exactly what was holding the file, and
one of those things was the thing doing the freezing.
Two fixes, and the second matters more than the first:
**Close the descriptor.** The child gets its own duplicate at spawn, so closing
costs it nothing. That removes this instance.
**Never signal our own pids, whatever `fuser` says.** `pidsHoldingFile` now filters
`process.pid` and `process.ppid`. The leak is fixed; this is what makes the next one
survivable. Nothing that manages a job should be able to name the process doing the
managing — a rule worth having independently of the bug that suggested it.
Verified: after a restart this process holds the output file zero times, `fuser`
returns only the spawned shell and its child, and our own pid is filtered even when
present.
tsgo clean, frontend builds, 901 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restarting a `bun dev` belonging to a project in ~/dockers/officer-atelier started
the PLATFORM's dev server instead. The respawn passed no cwd, so it inherited the
server's own working directory, where `bun dev` means something else entirely.
The result was not an error. `bun --watch src/server.tsx` came up and bound port
9000 ALONGSIDE the running platform — Bun allows that through SO_REUSEPORT — so
requests were split at random between two instances of the platform. Confirmed on
the machine by ancestry: `sh -c bun dev` → `bun dev` → `bun --watch src/server.tsx`,
with two listeners on 9000. Stray killed; the real server was unaffected.
The information was there all along: every transcript entry carries `cwd`, and the
entry holding the tool call is the one that says where it ran. `commandForTask`
returns both now.
And when there is no cwd it REFUSES, rather than falling back to the server's own.
The fallback is what produced this, and it produced the worst outcome available —
not a failure, but a different program running successfully. A command is only
meaningful with the directory it was written for.
Verified with a job whose transcript says it ran in /tmp/cwd-probe-app: the restart
reports that directory and the respawned process prints it, reading a file that
exists only there.
tsgo clean, frontend builds, 898 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three buttons in the expanded task panel, beside Stop. Not on the chip: the panel
is where you decide what to do with a job, and a row of unlabelled glyphs on a pill
is where you misclick.
── Pause / Resume ──
SIGSTOP and SIGCONT — the only pair that means "stop doing things and keep
everything". The process keeps its memory, its open files and its bound port, and
picks up mid-instruction. Signals go to every pid holding the job's output file,
because for a shell job that is the shell AND what it spawned, and freezing one of
them would be worse than freezing neither.
Verified by watching a job that writes a line a second: 3 lines, frozen at 3 across
three seconds of pause, 6 after resume.
Worth knowing before pressing it: a paused server does not REFUSE connections, it
stops answering them, so a browser pointed at it hangs rather than erroring. That is
what pausing is, not a shortcoming here.
── Restart, and the cost it accepts ──
The command is recovered by joining the transcript to itself: the harness records
the `tool_use` carrying `command`, and separately its result — the text
`Command running in background with ID: <id>`. Neither alone is enough; together
they are a join on `tool_use_id`, and that is the only place the command survives.
The transcript is found FROM the output path rather than searched for, since a task
file at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<id>.output` names both.
The respawned process appends to that same output file. That is what keeps
everything else working untouched — the log view reads that file, and Stop finds the
process by asking which pids hold it open.
The real cost, chosen deliberately: the platform becomes a SECOND owner of a task.
The harness stops watching when its own child dies, so the platform supervises what
it spawned and emits the same `task:started` / `task:notification` pair everything
downstream is built on. `emitToSession` is the seam, and it is the only one — nothing
else learns that a task can have two kinds of parent.
A restart re-announces an id already in the transcript, so `task:started` now RESETS
an existing task row rather than appending a second one. Without that the tray showed
the job twice, once finished and once running.
Restart is offered for a finished job too, which is the case that matters most: a dev
server that fell over is exactly what you want back without typing a sentence at an
agent that may be mid-turn.
Verified end to end against a planted task: command recovered, old process killed,
new one spawned, output APPENDED (`first run output` then `RESTARTED-OK`), exit
reported through the callback, supervision registry released afterwards.
`[open]` A restarted job does not survive an `officer` restart — the platform is its
parent now. The harness's own jobs never did either.
tsgo clean, frontend builds, 890 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>