Commit Graph
4 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 430346e14b a restarted job outlives the server that restarted it
The other half of the same report. The pill came back after a refresh, and then a
`pm2 restart officer` killed the dev server outright — because `restartBackgroundTask`
spawned it as a plain child of the platform. A job the AGENT starts is a child of the
`officer-claude-code` sidecar, a PM2 peer, so those were never affected. The tray
therefore showed two chips that looked identical and behaved differently, and the
restart button's own help text promised the opposite of what happened.

Measured against a throwaway PM2 app rather than reasoned about, because the two
plausible causes call for different fixes — a signal to the process GROUP, or a walk
of the process TREE:

    plain `sh -c`           killed
    `setsid sh -c`          killed     <- a new session is NOT enough
    `setsid --fork sh -c`   survived

PM2 walks the tree by ppid, so a new session leaves the job a child and it is found
anyway. `--fork` is what matters: setsid forks and its parent exits at once, so the
job is reparented to init before any restart can enumerate it.

── What that costs, and how it is paid ──

The handle `Bun.spawn` returns is now the setsid parent, which exits immediately.
Waiting on it reported every job as finished the moment it started, so exit detection
moved to `watchForExit`: poll who holds the output file, and when nobody does, read
the status the wrapper recorded. That is the same liveness question Stop and Pause
already ask, so it adds no second notion of "running" — and a paused job still holds
its file, so SIGSTOP correctly reads as running rather than as an exit.

Two details that would each have been a bug:

  - A GRACE PERIOD. Between the fork and the job opening its output there is a moment
    with no holders; polling into it announces a completion for a job that never ran.
  - The command is a POSITIONAL PARAMETER to the wrapper, not interpolated into it.
    Interpolation would let a command containing quotes terminate the wrapper's own
    quoting. Tested with `echo "quoted 'both' ways"`.

A missing status file is reported as a clean finish, not a failure: that is what Stop
looks like, and "you stopped it" is nearer the truth than "it crashed".

Also corrected the button text. It said background jobs survive "because they are
their own PM2 processes" — true of agent-started jobs, false of restarted ones, and
now the wrong reason for both. They survive because they are outside this process's
tree.

── Verified ──

End-to-end through `buildHonoApp`, on a synthetic task:

    POST /restart -> 200
    holders: pid 906186 (ppid 1, session leader), pid 906188 (ppid 906186)
             probe process was 906173 — in neither chain
    quotes survived the wrapper
    durable events: task:started -> task:notification (completed), exit file "0"

My own assertion in that probe was wrong and the output is what corrected it: it
demanded ppid 1 of EVERY holder, but only the session root is reparented — its own
children hang off it, which is the point.

Not in `bun test`: nothing in the suite touches Postgres or spawns processes.

tsgo clean, 919 pass / 7 fail unchanged. Needs a server restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:41:36 +00:00
pastilhasandClaude Opus 5 bda5eb15de a job's controls can no longer freeze the platform
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>
2026-08-16 14:28:16 +00:00
pastilhasandClaude Opus 5 19e75651e7 a restart runs the job in its own directory, or not at all
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>
2026-08-16 14:02:50 +00:00
pastilhasandClaude Opus 5 73eb128fb1 pause, resume and restart a background job without asking the agent
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>
2026-08-16 13:26:07 +00:00