From 0e4b4c5aed45555116287966917733592a3b0e1c Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 2 Nov 2026 15:25:28 +0000 Subject: [PATCH 1/3] fix: background shells end in the chat, with their exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat agent's background shells stayed "Running" in the chat's panel after they exited, their timers growing and the chip counting them. Its log says a shell ended only in the stream's task_notification line, which berthd read for helpers and never for shells; and that log holds Claude's lines with "<" escaped, so a notification Claude was told as a prompt didn't match either. Both shapes now end the shell, through one handler for helpers and shells. A running shell also ends when its output file's last line says "[exited with code N]", so it shows within a poll even before a notification. The panel shows "Done · exit 0" or "Failed · exit N" and a duration that stops growing. Co-Authored-By: Claude Opus 5.5 --- .../components/conversation/chat-controls.tsx | 65 ++++---- app/src/lib/background-work.test.ts | 40 +++++ app/src/lib/background-work.ts | 54 +++++++ app/src/lib/chat-controls.ts | 5 +- docs/changelog.mdx | 6 + internal/transcript/claude.go | 9 +- internal/transcript/host.go | 11 +- internal/transcript/host_test.go | 76 ++++++++++ internal/transcript/peer.go | 2 +- internal/transcript/signals.go | 142 +++++++++++++++--- internal/transcript/signals_test.go | 81 ++++++++++ internal/transcript/transcript.go | 1 + 12 files changed, 426 insertions(+), 66 deletions(-) create mode 100644 app/src/lib/background-work.test.ts create mode 100644 app/src/lib/background-work.ts diff --git a/app/src/components/conversation/chat-controls.tsx b/app/src/components/conversation/chat-controls.tsx index c9af06bfe..50e0c7a75 100644 --- a/app/src/components/conversation/chat-controls.tsx +++ b/app/src/components/conversation/chat-controls.tsx @@ -31,6 +31,7 @@ import { toastManager } from "@/components/ui/toast"; import { isMock } from "@/hooks/use-berth-connection"; import { agentPresets } from "@/lib/actions"; import type { LaunchFailure } from "@/lib/api"; +import { backgroundLabel, isRunning, jobBadge, jobTook } from "@/lib/background-work"; import { type ChatJob, type ChatSignals, @@ -494,8 +495,6 @@ function Ring({ pct }: { pct: number }) { // --- Background work --- -const RECENT = 5 * 60_000; - function BackgroundChip({ box, session, jobs, who }: { box: string; session: string; jobs?: ChatJob[]; who: string }) { const [now, setNow] = useState(Date.now()); useEffect(() => { @@ -503,19 +502,15 @@ function BackgroundChip({ box, session, jobs, who }: { box: string; session: str return () => window.clearInterval(t); }, []); const list = jobs ?? []; - const running = list.filter((j) => j.state === "running" || j.state === "starting"); - const recent = list.filter((j) => j.until && now - j.until < RECENT); - if (!running.length && !recent.length) return null; - const shells = running.filter((j) => j.kind === "shell").length; - const label = running.length - ? `${running.length} ${shells === running.length ? (running.length === 1 ? "shell" : "shells") : running.length === 1 ? "task" : "tasks"} running` - : `${recent.length} finished`; + const label = backgroundLabel(list, now); + if (!label) return null; + const running = list.some(isRunning); return ( - }> + }> - {running.length > 0 && } - + {running && } + {label} @@ -535,7 +530,7 @@ function JobRow({ box, session, job, now }: { box: string; session: string; job: const [open, setOpen] = useState(false); const [detail, setDetail] = useState(); const [error, setError] = useState(); - const live = job.state === "running" || job.state === "starting"; + const live = isRunning(job); useEffect(() => { if (!open) return; let alive = true; @@ -555,7 +550,7 @@ function JobRow({ box, session, job, now }: { box: string; session: string; job: if (pre.current) pre.current.scrollTop = pre.current.scrollHeight; }, [detail?.output]); const Icon = job.kind === "monitor" ? EyeIcon : TerminalIcon; - const took = fmtDur((job.until ?? now) - job.since); + const took = jobTook(job, now); return (
{open && ( @@ -588,29 +585,19 @@ function JobRow({ box, session, job, now }: { box: string; session: string; job: ); } -function State({ job }: { job: ChatJob }) { - switch (job.state) { - case "running": - case "starting": - return Running; - case "done": - return ( - - - Done - - ); - case "failed": - return Failed; - default: - return Stopped; - } -} +const badge = { + running: "rounded-full bg-success/12 px-1.5 py-px text-success-foreground", + done: "flex items-center gap-1 rounded-full bg-muted px-1.5 py-px text-muted-foreground", + failed: "rounded-full bg-destructive/12 px-1.5 py-px text-destructive-foreground", + stopped: "text-muted-foreground", +}; -function fmtDur(ms: number) { - const s = Math.max(0, Math.round(ms / 1000)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - if (m < 60) return `${m}m`; - return `${Math.floor(m / 60)}h ${m % 60}m`; +function State({ job }: { job: ChatJob }) { + const { text, tone } = jobBadge(job); + return ( + + {tone === "done" && } + {text} + + ); } diff --git a/app/src/lib/background-work.test.ts b/app/src/lib/background-work.test.ts new file mode 100644 index 000000000..81b67d500 --- /dev/null +++ b/app/src/lib/background-work.test.ts @@ -0,0 +1,40 @@ +// node --experimental-strip-types --test src/lib/background-work.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { backgroundLabel, jobBadge, jobTook, type Job } from "./background-work.ts"; + +const now = 1_000_000_000; +const shell = (state: Job["state"], extra: Partial = {}): Job => ({ kind: "shell", state, since: now - 90_000, ...extra }); + +test("the chip counts only what is still running", () => { + assert.equal(backgroundLabel([shell("running"), shell("done", { until: now - 1000, exit: 0 })], now), "1 shell running"); + assert.equal(backgroundLabel([shell("running"), shell("starting"), shell("failed", { until: now - 1000, exit: 3 })], now), "2 shells running"); + assert.equal(backgroundLabel([shell("running"), { kind: "monitor", state: "running", since: now }], now), "2 tasks running"); +}); + +test("with nothing running the chip no longer says running, and goes once the ends are old", () => { + const ended = [shell("done", { until: now - 1000, exit: 0 }), shell("failed", { until: now - 2000, exit: 3 })]; + assert.equal(backgroundLabel(ended, now), "2 finished"); + assert.doesNotMatch(backgroundLabel(ended, now) ?? "", /running/); + assert.equal(backgroundLabel([shell("done", { until: now - 10 * 60_000 })], now), null); + assert.equal(backgroundLabel([], now), null); +}); + +test("a finished shell's badge names its exit code", () => { + assert.deepEqual(jobBadge(shell("done", { exit: 0 })), { text: "Done · exit 0", tone: "done" }); + assert.deepEqual(jobBadge(shell("failed", { exit: 3 })), { text: "Failed · exit 3", tone: "failed" }); + assert.deepEqual(jobBadge(shell("stopped")), { text: "Stopped", tone: "stopped" }); + assert.deepEqual(jobBadge(shell("running")), { text: "Running", tone: "running" }); + // A box that doesn't say the code yet. + assert.deepEqual(jobBadge(shell("done")), { text: "Done", tone: "done" }); + assert.deepEqual(jobBadge(shell("failed")), { text: "Failed", tone: "failed" }); +}); + +test("a finished shell's duration is frozen at its end", () => { + const done = shell("done", { until: now - 30_000, exit: 0 }); + assert.equal(jobTook(done, now), "1m"); + assert.equal(jobTook(done, now + 60 * 60_000), "1m"); + assert.equal(jobTook(shell("running"), now), "1m"); + assert.equal(jobTook(shell("running"), now + 60 * 60_000), "1h 1m"); +}); diff --git a/app/src/lib/background-work.ts b/app/src/lib/background-work.ts new file mode 100644 index 000000000..ebe2ebdc4 --- /dev/null +++ b/app/src/lib/background-work.ts @@ -0,0 +1,54 @@ +// The work an agent left running in the background, as the chat's chip and +// its panel show it (components/conversation/chat-controls.tsx): what the +// chip says, each row's badge, and how long each took. + +import type { ChatJob } from "./chat-controls.ts"; + +export type Job = Pick; + +// Work that ended this long ago still shows, so its end is seen. +const RECENT = 5 * 60_000; + +export const isRunning = (j: Job) => j.state === "running" || j.state === "starting"; + +// backgroundLabel is what the chip says: only what is still running is +// counted; with none, how many ended a moment ago; null hides it. +export function backgroundLabel(jobs: Job[], now: number): string | null { + const running = jobs.filter(isRunning); + if (running.length) { + const n = running.length; + const shells = running.filter((j) => j.kind === "shell").length === n; + return `${n} ${shells ? (n === 1 ? "shell" : "shells") : n === 1 ? "task" : "tasks"} running`; + } + const recent = jobs.filter((j) => j.until && now - j.until < RECENT).length; + return recent ? `${recent} finished` : null; +} + +export function jobBadge(job: Job): { text: string; tone: "running" | "done" | "failed" | "stopped" } { + const code = job.exit === undefined ? "" : ` · exit ${job.exit}`; + switch (job.state) { + case "running": + case "starting": + return { text: "Running", tone: "running" }; + case "done": + return { text: `Done${code}`, tone: "done" }; + case "failed": + return { text: `Failed${code}`, tone: "failed" }; + default: + return { text: "Stopped", tone: "stopped" }; + } +} + +// jobTook is how long a job has run, or ran: frozen once it ended. +export function jobTook(job: Job, now: number): string { + const end = job.until ?? (isRunning(job) ? now : job.since); + return fmtDur(end - job.since); +} + +function fmtDur(ms: number) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h ${m % 60}m`; +} diff --git a/app/src/lib/chat-controls.ts b/app/src/lib/chat-controls.ts index f6aa9e339..1e154c3b3 100644 --- a/app/src/lib/chat-controls.ts +++ b/app/src/lib/chat-controls.ts @@ -31,6 +31,8 @@ export interface ChatJob { state: "starting" | "running" | "done" | "failed" | "stopped"; since: number; until?: number; + // A shell's exit code, once it has ended and the box says. + exit?: number; } export interface ChatSignals { @@ -229,7 +231,8 @@ function mockSignals(session: string): ChatSignals { busy && !st.stopped[session] ? [ { tool: "mock-bg-1", task: "b7k2", kind: "shell", command: "pnpm dev --port 3000", label: "Start the dev server", state: "running", since: t - 7 * 60_000 }, - { tool: "mock-bg-2", task: "b7k3", kind: "shell", command: "pnpm test --watch payments", label: "Watch the payment tests", state: "done", since: t - 4 * 60_000, until: t - 60_000 }, + { tool: "mock-bg-2", task: "b7k3", kind: "shell", command: "pnpm test --watch payments", label: "Watch the payment tests", state: "done", since: t - 4 * 60_000, until: t - 60_000, exit: 0 }, + { tool: "mock-bg-3", task: "b7k4", kind: "shell", command: "pnpm lint", label: "Lint the checkout package", state: "failed", since: t - 3 * 60_000, until: t - 150_000, exit: 3 }, ] : [], }; diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cc4ef4e5a..bbacf37f1 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -10,6 +10,12 @@ upgrades with `berth upgrade BOX`, or by running the again. ## Unreleased +- **Background shells end in the chat.** A shell Claude left running in + the background stayed "Running" in the chat's panel, its timer growing, + long after it had exited, and the chip kept counting it. A chat agent's + shells were never marked ended at all. Now a shell shows `Done · exit 0` + or `Failed · exit 3` within seconds of exiting, with how long it took, + and the chip counts only what is still running. - **Quiet projects are grayed out.** On the rail and in the two-column sidebar, a project with no agent or shell open in it is grayed out, so the ones you're using stand out. Switching to one of those opens New diff --git a/internal/transcript/claude.go b/internal/transcript/claude.go index e4e9c7296..d042f71ed 100644 --- a/internal/transcript/claude.go +++ b/internal/transcript/claude.go @@ -167,7 +167,7 @@ func (claudeParser) line(c *conv, b []byte) { return } if l.Type == "queue-operation" && l.Operation == "enqueue" { - helperDone(c, l.Content, at) + c.taskNotified(l.Content, at) return } if l.Type == "attachment" && len(l.Attachment) > 0 { @@ -433,6 +433,13 @@ var agentIDRe = regexp.MustCompile(`agentId: ([a-z0-9]+)`) var taskNote = regexp.MustCompile(`(?s).*?([^<]+).*?([^<]+)`) +// taskNotified reads a , in whichever shape Claude Code +// wrote it: a helper is back, or a background shell ended. +func (c *conv) taskNotified(s string, at int64) { + helperDone(c, s, at) + c.jobNote(s, at) +} + // helperDone reads a background helper's notification, " // … X completed", and marks // that helper back. diff --git a/internal/transcript/host.go b/internal/transcript/host.go index 966ac6ff7..1301b9c98 100644 --- a/internal/transcript/host.go +++ b/internal/transcript/host.go @@ -30,7 +30,9 @@ type hostHead struct { // A task_notification's: the call that started the helper, and how it // ended. ToolUseID string `json:"tool_use_id"` + TaskID string `json:"task_id"` Status string `json:"status"` + Summary string `json:"summary"` } // unwrapHost is the transcript line a log entry carries: one of the @@ -51,13 +53,14 @@ func unwrapHost(b []byte) ([]byte, bool) { case h.Type == "user", h.Type == "assistant": case h.Type == "system" && h.Subtype == "compact_boundary": case h.Type == "system" && h.Subtype == "task_notification": - // A helper left working in the background is back. The stream says - // so in a line of its own, where the transcript queues Claude a - // : read as that, so the crew shows it back. + // A helper or a shell left working in the background is back. The + // stream says so in a line of its own, where the transcript queues + // Claude a : read as that, so the crew shows it + // back and the shell ended, with its exit code. if h.ToolUseID == "" || h.Status == "" { return nil, false } - note := "\n" + h.ToolUseID + "\n" + h.Status + "\n" + note := "\n" + h.TaskID + "\n" + h.ToolUseID + "\n" + h.Status + "\n" + h.Summary + "\n" line, _ := json.Marshal(map[string]string{ "type": "queue-operation", "operation": "enqueue", "content": note, "timestamp": time.UnixMilli(e.At).UTC().Format(time.RFC3339Nano), diff --git a/internal/transcript/host_test.go b/internal/transcript/host_test.go index 870634f44..9a4d0ec00 100644 --- a/internal/transcript/host_test.go +++ b/internal/transcript/host_test.go @@ -1,6 +1,7 @@ package transcript import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -144,6 +145,50 @@ func TestAHelperIsBackWhenTheStreamSaysSo(t *testing.T) { } } +// A structured session's stream says a background shell ended only in a +// task_notification line of its own: Claude is told in a prompt only if it +// is idle then, so most shells never get one in the log. +func TestABackgroundShellEndsWhenTheStreamSaysSo(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "log.jsonl") + out := filepath.Join(dir, "gone", "tasks") + shell := func(seq int, at int64, id, task, cmd string) []string { + return []string{ + fmt.Sprintf(`{"seq":%d,"at":%d,"dir":"out","data":{"type":"assistant","message":{"id":"m%d","role":"assistant","content":[{"type":"tool_use","id":"%s","name":"Bash","input":{"command":"%s","description":"%s","run_in_background":true}}]}}}`, seq, at, seq, id, cmd, cmd), + fmt.Sprintf(`{"seq":%d,"at":%d,"dir":"out","data":{"type":"system","subtype":"task_started","task_id":"%s","tool_use_id":"%s","is_backgrounded":true,"task_type":"local_bash"}}`, seq+1, at, task, id), + fmt.Sprintf(`{"seq":%d,"at":%d,"dir":"out","data":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"%s","type":"tool_result","content":"Command running in background with ID: %s. Output is being written to: %s/%s.output. You will be notified when it completes."}]}}}`, seq+2, at+100, id, task, out, task), + } + } + lines := []string{`{"seq":1,"at":1792200000000,"dir":"out","data":{"type":"user","message":{"role":"user","content":"Build it, and lint it"}}}`} + lines = append(lines, shell(2, 1792200001000, "toolu_build", "bq1build", "pnpm build")...) + lines = append(lines, shell(5, 1792200002000, "toolu_lint", "bq2lint", "pnpm lint")...) + lines = append(lines, + `{"seq":8,"at":1792200030000,"dir":"out","data":{"type":"system","subtype":"task_updated","task_id":"bq1build","patch":{"status":"completed","end_time":1792200030000}}}`, + `{"seq":9,"at":1792200030000,"dir":"out","data":{"type":"system","subtype":"task_notification","task_id":"bq1build","tool_use_id":"toolu_build","status":"completed","output_file":"`+out+`/bq1build.output","summary":"Background command \"pnpm build\" completed (exit code 0)"}}`, + `{"seq":10,"at":1792200040000,"dir":"out","data":{"type":"system","subtype":"task_notification","task_id":"bq2lint","tool_use_id":"toolu_lint","status":"failed","output_file":"`+out+`/bq2lint.output","summary":"Background command \"pnpm lint\" failed with exit code 3"}}`, + ) + jobs := func(n int) []Job { + t.Helper() + os.WriteFile(path, []byte(strings.Join(lines[:n], "\n")+"\n"), 0o644) + res, err := NewReader().Read(SourceClaudeHost, path, "/home/dev/acme", 0) + if err != nil { + t.Fatal(err) + } + return res.Signals.Background + } + if bg := jobs(7); len(bg) != 2 || bg[0].State != "running" || bg[1].State != "running" { + t.Fatalf("while they run: %+v", bg) + } + bg := jobs(9) + if bg[0].State != "done" || bg[0].Until != 1792200030000 || bg[0].Exit == nil || *bg[0].Exit != 0 || bg[1].State != "running" { + t.Fatalf("once the stream says the build ended: %+v", bg) + } + bg = jobs(10) + if bg[1].State != "failed" || bg[1].Until != 1792200040000 || bg[1].Exit == nil || *bg[1].Exit != 3 { + t.Fatalf("once the stream says the lint failed: %+v", bg) + } +} + func TestAnEditInAStructuredSessionsLogKeepsItsLineNumbers(t *testing.T) { path := filepath.Join(t.TempDir(), "events.jsonl") hunk := []Hunk{{OldStart: 12, OldLines: 1, NewStart: 12, NewLines: 1, Lines: []string{"-old", "+new"}}} @@ -239,3 +284,34 @@ func TestAQuestionStaysOpenWhileItsHelpersWorkOn(t *testing.T) { } open("read afresh", afresh.Items) } + +// berthd writes the stream's lines with Go's JSON, which escapes "<" (as +// backslash-u003c): a notification Claude is told as a prompt reads as one +// in that log too. +func TestAShellsNotificationInTheLogsOwnEscapingEndsIt(t *testing.T) { + path := filepath.Join(t.TempDir(), "log.jsonl") + note := "\nbw1\ntoolu_wait\ncompleted\nBackground command \"sleep 60\" completed (exit code 0)\n" + entries := []m{ + {"seq": 1, "at": 1792200000000, "dir": "out", "data": m{"type": "user", "message": m{"role": "user", "content": "Wait a bit"}}}, + {"seq": 2, "at": 1792200001000, "dir": "out", "data": m{"type": "assistant", "message": m{"id": "m1", "role": "assistant", "content": []m{tool("toolu_wait", "Bash", m{"command": "sleep 60", "run_in_background": true})}}}}, + {"seq": 3, "at": 1792200001100, "dir": "out", "data": m{"type": "user", "message": m{"role": "user", "content": []m{{"tool_use_id": "toolu_wait", "type": "tool_result", "content": "Command running in background with ID: bw1. Output is being written to: /gone/tasks/bw1.output."}}}}}, + {"seq": 4, "at": 1792200061000, "dir": "out", "data": m{"type": "user", "message": m{"role": "user", "content": note}, "parent_tool_use_id": nil, "timestamp": "2026-10-04T12:01:01Z", "isReplay": true, "origin": m{"kind": "task-notification", "producer": "session-task"}}}, + } + var lines []string + for _, e := range entries { + b, _ := json.Marshal(e) + lines = append(lines, string(b)) + } + if strings.Contains(lines[3], "<") { + t.Fatal("json.Marshal no longer escapes <, as berthd's log does") + } + os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) + res, err := NewReader().Read(SourceClaudeHost, path, "/home/dev/acme", 0) + if err != nil { + t.Fatal(err) + } + bg := res.Signals.Background + if len(bg) != 1 || bg[0].State != "done" || bg[0].Exit == nil || *bg[0].Exit != 0 { + t.Fatalf("background = %+v", bg) + } +} diff --git a/internal/transcript/peer.go b/internal/transcript/peer.go index 73f9978af..43e103cd5 100644 --- a/internal/transcript/peer.go +++ b/internal/transcript/peer.go @@ -352,7 +352,7 @@ const ( // become messages. The same message delivered twice (a queued_command, then // its line) shows once. func (c *conv) userTurn(s string, o *Origin, how int, at int64) { - helperDone(c, s, at) + c.taskNotified(s, at) for _, k := range classify(c, s, o) { switch k.kind { case "user": diff --git a/internal/transcript/signals.go b/internal/transcript/signals.go index 456bb097a..7a466639c 100644 --- a/internal/transcript/signals.go +++ b/internal/transcript/signals.go @@ -65,6 +65,13 @@ type Job struct { State string `json:"state"` Since int64 `json:"since"` Until int64 `json:"until,omitempty"` + // Exit is a shell's exit code, once it has ended and says. + Exit *int `json:"exit,omitempty"` + + // out is a shell's output file; fromFile is set while its end is only + // what that file's last line said (a notification has the last word). + out string + fromFile bool } type Retry struct { @@ -196,8 +203,10 @@ type claudeExtra struct { // synthetic reply). func claudeSignals(c *conv, typ string, x *claudeExtra, b []byte, at int64) bool { // A tool's result or a prompt is read only when it holds what the - // signals want: most are long and say nothing about the agent. - if typ == "user" && !bytes.Contains(b, []byte(`"permissionMode"`)) && !bytes.Contains(b, []byte("[Request interrupted by user")) && !bytes.Contains(b, []byte("")) { + // signals want: most are long and say nothing about the agent. The + // notification's tag is looked for without its "<", which a structured + // session's log (berthd agent-host, Go's JSON) holds escaped. + if typ == "user" && !bytes.Contains(b, []byte(`"permissionMode"`)) && !bytes.Contains(b, []byte("[Request interrupted by user")) && !bytes.Contains(b, []byte("task-notification")) { return false } if x == nil { @@ -322,8 +331,6 @@ func claudeAttachment(c *conv, raw json.RawMessage, at int64) { c.notice("hook", "warning", msg, 0) case "hook_stopped_continuation": c.notice("hook", "warning", firstNonEmpty(a.HookName, "A hook")+" hook stopped the turn: "+strings.TrimSpace(contentText(a.Content)), 0) - case "queued_command": - c.jobNote(a.Prompt, at) } } @@ -414,7 +421,7 @@ func claudeToolSignal(c *conv, bl claudeBlock, at int64) bool { id := firstNonEmpty(str("shell_id"), str("task_id"), str("bash_id"), str("id")) for _, j := range c.sig.Background { if j.Task == id && j.State == "running" { - c.jobEnd(j.Tool, "stopped", at) + c.jobEnd(j.Tool, "stopped", at, nil) } } return false @@ -474,6 +481,9 @@ func claudeResultSignal(c *conv, toolID, text string, at int64) { if j := c.job(toolID); j != nil && j.State == "starting" { if m := startedJob.FindStringSubmatch(text); m != nil { j.Task, j.State = m[1], "running" + if o := outputFile.FindStringSubmatch(text); o != nil && j.Kind == "shell" { + j.out = o[1] + } } else { // It never started: an error, or a refusal. j.State, j.Until = "failed", at @@ -544,13 +554,41 @@ func (c *conv) jobStart(j Job) { } } -func (c *conv) jobEnd(tool, state string, at int64) { - if j := c.job(tool); j != nil && (j.State == "running" || j.State == "starting") { - j.State, j.Until = state, at +// jobEnd ends a job, with its exit code when known: else a shell's output +// file says it, if its last line does by now. +func (c *conv) jobEnd(tool, state string, at int64, exit *int) { + j := c.job(tool) + if j == nil || !(j.State == "running" || j.State == "starting" || j.fromFile) { + return + } + if !j.fromFile || state != j.State { + j.Until = at + } + j.State, j.fromFile = state, false + if exit == nil && j.out != "" && state != "stopped" { + if code, _, ok := exited(j.out); ok { + exit = &code + } + } + if exit != nil { + j.Exit = exit } } -var jobNoteRe = regexp.MustCompile(`(?s)(.*?)`) +// endState is how a shell that exited with code ended. +func endState(code int) string { + if code != 0 { + return "failed" + } + return "done" +} + +var ( + jobNoteRe = regexp.MustCompile(`(?s)(.*?)`) + // "Background command "X" completed (exit code 0)", "… failed with + // exit code 3". + exitCodeRe = regexp.MustCompile(`exit code (-?\d+)`) +) // jobNote reads a background shell's notification: "… // … completed". @@ -578,10 +616,16 @@ func (c *conv) jobNote(s string, at int64) { case "stopped", "killed", "cancelled": state = "stopped" } + var exit *int + if e := exitCodeRe.FindStringSubmatch(tag(m[1], "summary")); e != nil && state != "stopped" { + if n, err := strconv.Atoi(e[1]); err == nil { + exit, state = &n, endState(n) + } + } tool, task := tag(m[1], "tool-use-id"), tag(m[1], "task-id") for _, j := range c.sig.Background { if (tool != "" && j.Tool == tool) || (task != "" && j.Task == task) { - c.jobEnd(j.Tool, state, at) + c.jobEnd(j.Tool, state, at, exit) } } } @@ -691,6 +735,72 @@ func codexPlan(c *conv, args string) { var outputFile = regexp.MustCompile(`Output is being written to: (\S+?\.output)\b`) +// settle reads the end of each running shell's output file: Claude Code +// ends it with "[exited with code N]" when the shell exits, which can be +// long before a notification says so (a busy agent is told only when its +// turn ends). +func (s *signals) settle() { + for i := range s.Background { + j := &s.Background[i] + if j.State != "running" || j.out == "" { + continue + } + if code, at, ok := exited(j.out); ok { + j.State, j.Until, j.Exit, j.fromFile = endState(code), at, &code, true + } + } +} + +var exitedLine = regexp.MustCompile(`^\[exited with code (-?\d+)\]$`) + +// exited reads a shell's output file for its last line: its exit code and +// when it was written, if that line says it exited. +func exited(path string) (code int, at int64, ok bool) { + f, st := taskFile(path) + if f == nil { + return 0, 0, false + } + defer f.Close() + const tail = 256 + start := max(st.Size()-tail, 0) + b := make([]byte, st.Size()-start) + if _, err := f.ReadAt(b, start); err != nil && err != io.EOF { + return 0, 0, false + } + text := strings.TrimRight(string(b), " \t\r\n") + if i := strings.LastIndexByte(text, '\n'); i >= 0 { + text = text[i+1:] + } + m := exitedLine.FindStringSubmatch(strings.TrimSpace(text)) + if m == nil { + return 0, 0, false + } + code, err := strconv.Atoi(m[1]) + if err != nil { + return 0, 0, false + } + return code, st.ModTime().UnixMilli(), true +} + +// taskFile opens a background task's output file: only a regular .output +// file in a tasks folder is read. +func taskFile(path string) (*os.File, os.FileInfo) { + p := filepath.Clean(path) + if !filepath.IsAbs(p) || filepath.Ext(p) != ".output" || filepath.Base(filepath.Dir(p)) != "tasks" { + return nil, nil + } + f, err := os.Open(p) + if err != nil { + return nil, nil + } + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() { + f.Close() + return nil, nil + } + return f, st +} + // liveOutput reads a background shell's output file, named in its tool // result, so its details show what it has printed so far rather than "Command // running in background". Only a .output file in a tasks folder is read, @@ -700,19 +810,11 @@ func liveOutput(d *ToolDetail) { if m == nil { return } - p := filepath.Clean(m[1]) - if !filepath.IsAbs(p) || filepath.Base(filepath.Dir(p)) != "tasks" { - return - } - f, err := os.Open(p) - if err != nil { + f, st := taskFile(m[1]) + if f == nil { return } defer f.Close() - st, err := f.Stat() - if err != nil || !st.Mode().IsRegular() { - return - } const tail = 16 << 10 start := max(st.Size()-tail, 0) if _, err := f.Seek(start, io.SeekStart); err != nil { diff --git a/internal/transcript/signals_test.go b/internal/transcript/signals_test.go index 4e06df822..f5b78fb5b 100644 --- a/internal/transcript/signals_test.go +++ b/internal/transcript/signals_test.go @@ -3,8 +3,10 @@ package transcript import ( "os" "path/filepath" + "strconv" "strings" "testing" + "time" ) // The shapes below are Claude Code's and Codex's own, as they write them @@ -204,6 +206,85 @@ func TestClaudeBackground(t *testing.T) { } } +// A shell's output file ends with "[exited with code N]" when it ends: the +// shell reads as ended from that, though no notification says so yet. +func TestAShellEndsWhenItsOutputSaysItExited(t *testing.T) { + dir := t.TempDir() + tasks := filepath.Join(dir, "tasks") + os.MkdirAll(tasks, 0o755) + p := filepath.Join(dir, "s.jsonl") + bg := func(id, task string) []any { + return []any{ + assistant(tool(id, "Bash", m{"command": "make " + id, "description": "Make " + id, "run_in_background": true})), + user([]m{{"type": "tool_result", "tool_use_id": id, "content": "Command running in background with ID: " + task + ". Output is being written to: " + filepath.Join(tasks, task+".output") + ". You will be notified when it completes."}}), + } + } + var lines []any + lines = append(lines, user("Make them all")) + for _, s := range [][2]string{{"ok", "bok"}, {"bad", "bbad"}, {"mid", "bmid"}, {"none", "bnone"}, {"told", "btold"}} { + lines = append(lines, bg(s[0], s[1])...) + } + // A monitor whose output reads the same is a monitor still. + lines = append(lines, + assistant(tool("mon", "Monitor", m{"command": "tail -f x.log", "description": "watch the log"})), + user([]m{{"type": "tool_result", "tool_use_id": "mon", "content": "Monitor started (task bmon). Output is being written to: " + filepath.Join(tasks, "bmon.output")}}), + ) + write(t, p, lines...) + ended := time.Now().Add(-time.Minute).Truncate(time.Millisecond) + put := func(task, text string) { + f := filepath.Join(tasks, task+".output") + os.WriteFile(f, []byte(text), 0o644) + os.Chtimes(f, ended, ended) + } + put("bok", "built\n\n[exited with code 0]\n") + put("bbad", "lint: 3 problems\n[exited with code 3]\n\n") + put("bmid", "it printed [exited with code 0]\n[exited with code 0]\nand went on\n") + put("btold", "done\n[exited with code 0]\n") + put("bmon", "[exited with code 0]\n") + // "bnone" has no output file. + r := NewReader() + res, err := r.Read("claude", p, "/w", 0) + if err != nil { + t.Fatal(err) + } + got := map[string]Job{} + for _, j := range res.Signals.Background { + got[j.Tool] = j + } + exit := func(j Job) string { + if j.Exit == nil { + return "-" + } + return strconv.Itoa(*j.Exit) + } + for _, c := range []struct{ tool, state, exit string }{{"ok", "done", "0"}, {"bad", "failed", "3"}, {"mid", "running", "-"}, {"none", "running", "-"}, {"mon", "running", "-"}} { + if j := got[c.tool]; j.State != c.state || exit(j) != c.exit { + t.Errorf("%s = %s exit %s, want %s exit %s", c.tool, j.State, exit(j), c.state, c.exit) + } + } + if j := got["ok"]; j.Until != ended.UnixMilli() { + t.Errorf("ok ended at %d, want the file's time %d", j.Until, ended.UnixMilli()) + } + // A notification, when one comes, has the last word. + write(t, p, m{"type": "attachment", "attachment": m{"type": "queued_command", "prompt": "\nbtold\ntold\nkilled\nBackground command \"Make told\" was stopped\n"}}) + // The same reader, followed: "mid" ends later, with no new line in the + // transcript. + put("bmid", "it printed [exited with code 0]\nand ended\n[exited with code 0]\n") + res, err = r.Read("claude", p, "/w", 0) + if err != nil { + t.Fatal(err) + } + for _, j := range res.Signals.Background { + got[j.Tool] = j + } + if j := got["told"]; j.State != "stopped" { + t.Errorf("told = %s after its notification, want stopped", j.State) + } + if j := got["mid"]; j.State != "done" || exit(j) != "0" { + t.Errorf("mid = %s exit %s once its output says it exited, want done exit 0", j.State, exit(j)) + } +} + func TestLiveOutputOnlyReadsTaskFiles(t *testing.T) { d := ToolDetail{Output: "Output is being written to: /etc/passwd.output. x"} liveOutput(&d) diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 9e09cd277..6c0bbd38e 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -549,6 +549,7 @@ func (r *Reader) Follow(source, path, dir string, since int, gen string) (Result out.Items = append(c.artifactsSince(from, since), c.items[from-c.base:]...) out.Artifacts = c.artifacts() out.Crew = append([]CrewMember{}, c.crew...) + c.sig.settle() out.Signals = c.sig.snapshot() return out, nil } From 9f78f32dbb584d517c3c4039ba7c7a7c9ea2cd5f Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 2 Nov 2026 15:25:28 +0000 Subject: [PATCH 2/3] test(verify): background shells in the chat, on the mock and a real box Co-Authored-By: Claude Opus 5.5 --- .../skills/verify-berthd/features/README.md | 1 + .../features/chat-background-shells.md | 45 ++++++++ .../scenarios/chat-background-shells-live.mjs | 100 ++++++++++++++++++ .../scenarios/chat-background-shells.mjs | 41 +++++++ 4 files changed, 187 insertions(+) create mode 100644 .claude/skills/verify-berthd/features/chat-background-shells.md create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/chat-background-shells-live.mjs create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/chat-background-shells.mjs diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..781430905 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -23,6 +23,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box | | The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it | | Worktrees changed with git outside Shipyard: added, committed in, switched, locked, removed; a new remote; a quiet box runs no git for them | [worktree-git.md](worktree-git.md) | `worktree-git-live` (a real box, `--agent`); `TestListingLocationsAgainRunsNoGitUntilSomethingChanges` | +| Chat's background work: the chip counts only what still runs; the panel shows each shell's `Done · exit 0`, `Failed · exit N` or `Stopped` and a duration that stops growing, with its output | [chat-background-shells.md](chat-background-shells.md) | `chat-background-shells`; `chat-background-shells-live` (a real box, `--agent`); `app/src/lib/background-work.test.ts`, `TestABackgroundShellEndsWhenTheStreamSaysSo` | | Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider | | Upgrading a box: sessions keep running, and nothing the old build started stays a zombie under berthd | [box-upgrade.md](box-upgrade.md) | `../scripts/upgrade-zombies.sh FROM TO` (a real box, Linux); `go test ./internal/childreap` | diff --git a/.claude/skills/verify-berthd/features/chat-background-shells.md b/.claude/skills/verify-berthd/features/chat-background-shells.md new file mode 100644 index 000000000..160f0a6b6 --- /dev/null +++ b/.claude/skills/verify-berthd/features/chat-background-shells.md @@ -0,0 +1,45 @@ +# Chat: work left running in the background + +Under the reply box, a chip says what the agent left running in the background (shells started with `run_in_background`, monitors): "1 shell running", "2 tasks running". It counts only what still runs. With nothing running it says how many ended in the last five minutes ("2 finished", a grey dot), then goes. Clicking it opens **Left running in the background by Claude**: a row per job with its label and command, a badge and how long it ran, newest first. Opening a row shows its output (the shell's `tasks/.output`), followed while it runs. + +The badge is green **Running**, grey **Done · exit 0**, red **Failed · exit N** or **Stopped** (TaskStop, KillShell). Once a job ended, its duration stops growing. (`components/conversation/chat-controls.tsx` `BackgroundChip`, `JobRow`, `State`; `lib/background-work.ts`.) + +The box decides when a shell ended (`internal/transcript/signals.go`): + +- Claude's notification for it, in any of its shapes: a `queue-operation`, a `queued_command` attachment, a prompt (`……… (exit code N)`). A structured (chat) agent's log (berthd agent-host) says it in a `system`/`task_notification` line of its own, read as that notification (`host.go` `unwrapHost`). That log holds lines as Go's JSON writes them, with `<` escaped, so a check for the literal `` misses its prompts (`escapedNote`). +- Else the shell's output file: Claude ends it with `[exited with code N]` as the last line when the shell exits. Each read of the conversation reads the end of each running shell's file (`settle`), so it shows within one poll (a few seconds) even when no transcript line comes. The time is the file's. + +A notification, when one comes, has the last word (a shell the file said ended can still read Stopped). A monitor is never read from a file. + +## Sub-features + +- `chip-counts-running`: the chip's count is of running jobs only; with none, "N finished" for five minutes. +- `exit-badge`: `Done · exit 0`, `Failed · exit N`, `Stopped`; plain `Done`/`Failed` from a box too old to send `exit`. +- `frozen-duration`: an ended job's duration stays put while the panel's clock ticks (every 15 s). +- `structured-end`: a chat agent's shells end (they never did before: the stream's `task_notification` only reached the crew). +- `output-file-end`: a shell ends from its output file's last line, before or without a notification. + +## How to get to it (user POV) + +- Open a worktree's agent in **Conversation** and ask it to run something in the background. +- On the mock: `gpu/judge-v2` has the dev server running, the payment tests done (exit 0) and a lint that failed (exit 3). + +## Driving it with drive.mjs + +- On the mock: `scenarios/chat-background-shells.mjs` opens `gpu/judge-v2`, asserts the chip says "1 shell running", the three badges and durations, then waits past a clock tick and asserts the ended ones' durations didn't move. +- On a real box: `scenarios/chat-background-shells-live.mjs` (`export const live = true`) on a dev box whose agent runs structured. Claude starts `sleep 20; echo hi` and `sleep 6; exit 3` in the background. It asserts Running, then `Failed · exit 3`, then `Done · exit 0` within ~45 s of the chip first showing both, the output `hi`, durations that stay put for 20 s, and "N finished" on the chip. Second view: the box's `transcript` signals (`state`, `exit`, `until`) through the agent (`call`). About 1.5 minutes. + +```sh +VERIFY_BOX_DIR=/tmp/vb-$$ VERIFY_DRIVER=claude .claude/skills/verify-berthd/scripts/dev-box.sh up +eval "$(VERIFY_BOX_DIR=/tmp/vb-$$ .claude/skills/verify-berthd/scripts/dev-box.sh env)" +node .claude/skills/verify-berthd/scripts/drive.mjs chat-background-shells-live --port $PORT --agent http://127.0.0.1:9378 +VERIFY_BOX_DIR=/tmp/vb-$$ .claude/skills/verify-berthd/scripts/dev-box.sh down +``` + +For a before, `dev-box.sh restart /bin/berthd` on a fresh dev box, then the same run: it fails at `Failed · exit 3` with both rows still Running. + +## Gotchas + +- Run each live pass on a fresh dev box (`down`, then `up`): the scenario finds rows by label, and a second pass in the same session has two of each. +- A new structured agent opens on its first-task box ("What should Claude Code do?"), not the chat; the scenario types there. +- From inside an agent session, unset every `BERTH_*` before `dev-box.sh`, or the session's own driver leaks into the dev box's agents. diff --git a/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells-live.mjs new file mode 100644 index 000000000..ee905d97f --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells-live.mjs @@ -0,0 +1,100 @@ +// chat-background-shells-live: the chat's background-work chip and panel on +// a real box (features/chat-background-shells.md). Claude Code starts two +// shells in the background, one that says hi after 20 seconds and one that +// exits with code 3. The panel shows the first Running, the second +// `Failed · exit 3`, then the first `Done · exit 0` within seconds of its +// exit, each with a duration that no longer grows, and the chip stops +// saying "running". The box's own transcript signals say the same. +// +// On a dev box of this checkout, its agent structured (the way the app +// starts agents, and where the bug was): +// +// VERIFY_DRIVER=claude ../dev-box.sh up +// eval "$(../dev-box.sh env)" +// node drive.mjs chat-background-shells-live --port $PORT --agent http://127.0.0.1:9378 +// +// Without VERIFY_DRIVER it drives Claude in its terminal, which reads its +// own transcript file instead: the same assertions hold. + +export const live = true; +export const params = { view: "conversation", sidebar: "list", labs: "0" }; + +const WORKTREE = process.env.VERIFY_WORKTREE ?? "devq/acme-shop"; +const BOX = WORKTREE.split("/")[0]; +const SESSION = process.env.VERIFY_SESSION ?? "ask"; +const HI = "Say hi after 20 seconds"; +const BAD = "Exit with code 3"; + +export async function run({ page, shot, step, expect, call }) { + const signals = async () => (await call(`/v1/boxes/${BOX}/api/sessions/${SESSION}/transcript`)).signals?.background ?? []; + const job = async (label) => (await signals()).find((j) => j.label === label) ?? {}; + const pane = page.locator("[data-testid=pane]:visible").first(); + + step("open the agent's chat"); + await page.locator(`[data-testid=worktree-row][data-worktree="${WORKTREE}"]`).click({ position: { x: 32, y: 12 } }); + // A new agent opens on its first-task box; one with a conversation, on + // its chat. + let reply = pane.getByRole("textbox", { name: "What should Claude Code do?", exact: true }); + try { + await expect(reply).toBeEditable({ timeout: 30_000 }); + } catch { + reply = pane.getByTestId("composer").getByRole("textbox", { name: "Reply" }); + } + + step("ask for two shells in the background: one that says hi after 20 seconds, one that exits with code 3"); + await reply.fill( + "In one message, call the Bash tool twice, both with run_in_background true: " + + `first command \`sleep 20; echo hi\` with description "${HI}", then command \`sleep 6; exit 3\` with description "${BAD}". ` + + "Don't wait for them and don't read their output: reply with the single word started and end your turn. When one finishes later, reply with one word.", + ); + await reply.press("Enter"); + + step("the chip says both run"); + const chip = pane.getByRole("button", { name: /^Background work: / }); + await expect(chip).toHaveAccessibleName("Background work: 2 shells running", { timeout: 120_000 }); + const started = Date.now(); + await chip.click(); + const row = (label) => page.getByRole("button", { name: new RegExp(label) }); + const state = (label) => row(label).getByTestId("job-state"); + const took = (label) => row(label).getByTestId("job-took"); + await expect(state(HI)).toHaveText("Running"); + await shot("1-both-running"); + + step("the one that exits with code 3 shows Failed · exit 3, the other still runs"); + await expect(state(BAD)).toHaveText("Failed · exit 3", { timeout: 30_000 }); + await expect(state(HI)).toHaveText("Running"); + await expect(chip).toHaveAccessibleName("Background work: 1 shell running"); + await shot("2-failed-exit-3"); + + step("within seconds of its exit, the other shows Done · exit 0"); + await expect(state(HI)).toHaveText("Done · exit 0", { timeout: 60_000 }); + const late = (Date.now() - started) / 1000; + step(`Done showed ${late.toFixed(0)}s after the chip first said both ran (the shell sleeps 20s)`); + if (late > 45) throw new Error(`Done came ${late.toFixed(0)}s after the shells started: more than ~10s after the exit`); + await expect(chip).toHaveAccessibleName(/^Background work: \d+ finished$/); + await shot("3-done-exit-0"); + + step("its output stays readable"); + await row(HI).click(); + await expect(page.locator("pre").filter({ hasText: /\bhi\b/ })).toBeVisible({ timeout: 15_000 }); + await shot("4-done-output"); + + step("the durations no longer grow"); + const before = [await took(HI).innerText(), await took(BAD).innerText()]; + await page.waitForTimeout(20_000); // the chip's clock ticks every 15s + await expect(took(HI)).toHaveText(before[0]); + await expect(took(BAD)).toHaveText(before[1]); + step(`durations stay ${before.join(" and ")}`); + + step("the box's transcript says the same (a second view)"); + const hi = await job(HI); + const bad = await job(BAD); + if (hi.state !== "done" || hi.exit !== 0 || !hi.until) throw new Error(`box signals for "${HI}": ${JSON.stringify(hi)}`); + if (bad.state !== "failed" || bad.exit !== 3 || !bad.until) throw new Error(`box signals for "${BAD}": ${JSON.stringify(bad)}`); + step(`box: ${HI} ${hi.state} exit ${hi.exit}, ${BAD} ${bad.state} exit ${bad.exit}`); + + step("closed, the chip no longer says running"); + await page.keyboard.press("Escape"); + await expect(chip).not.toHaveAccessibleName(/running/); + await shot("5-chip-finished"); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells.mjs b/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells.mjs new file mode 100644 index 000000000..e319985e9 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/chat-background-shells.mjs @@ -0,0 +1,41 @@ +// The chat's background-work chip and its panel on the mock +// (features/chat-background-shells.md): +// +// node drive.mjs chat-background-shells --port $PORT +// +// gpu/judge-v2's agent left three shells: the dev server still running, +// the payment tests done (exit 0) and a lint that failed (exit 3). The chip +// counts only the running one; the panel shows each ended shell's exit code +// and a duration that stays put. + +export async function run({ page, shot, step, expect }) { + await page.goto(page.url().replace(/\?.*/, "?mock=1&sidebar=list&view=conversation")); + await expect(page.getByTestId("nav-home")).toBeVisible(); + const pane = page.locator("[data-testid=pane]:visible").first(); + + step("open gpu/judge-v2's chat"); + await page.locator('[data-testid=worktree-row][data-worktree="gpu/judge-v2"]').click({ position: { x: 32, y: 12 } }); + await expect(pane.getByTestId("chat")).toBeVisible(); + + step("the chip counts only the shell still running"); + const chip = pane.getByRole("button", { name: /^Background work: / }); + await expect(chip).toHaveAccessibleName("Background work: 1 shell running"); + await chip.click(); + const row = (label) => page.getByRole("button", { name: new RegExp(label) }); + const ended = [ + ["Watch the payment tests", "Done · exit 0", "3m"], + ["Lint the checkout package", "Failed · exit 3", "30s"], + ]; + const durations = async () => { + for (const [label, , took] of ended) await expect(row(label).getByTestId("job-took")).toHaveText(took); + }; + await expect(row("Start the dev server").getByTestId("job-state")).toHaveText("Running"); + for (const [label, state] of ended) await expect(row(label).getByTestId("job-state")).toHaveText(state); + await durations(); + await shot("panel"); + + step("the ended ones' durations stay put while the clock ticks"); + await page.waitForTimeout(16_000); // the chip's clock ticks every 15s + await durations(); + await shot("panel-later"); +} From da4e93e5cc3ffd3a7d3a2c2ea43504a9664bc79c Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 2 Nov 2026 15:28:01 +0000 Subject: [PATCH 3/3] fix: a shell's exit code is its notification's own, and its output file never holds up the reader A command whose text reads "exit code 3" no longer reads as exiting 3; a pipe where a shell's output file should be is never opened; and a shell that printed "[exited with code N]" itself and went on runs again. Co-Authored-By: Claude Opus 5.5 --- internal/transcript/signals.go | 21 +++++++--- internal/transcript/signals_test.go | 64 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/internal/transcript/signals.go b/internal/transcript/signals.go index 7a466639c..a76ef97f4 100644 --- a/internal/transcript/signals.go +++ b/internal/transcript/signals.go @@ -586,8 +586,8 @@ func endState(code int) string { var ( jobNoteRe = regexp.MustCompile(`(?s)(.*?)`) // "Background command "X" completed (exit code 0)", "… failed with - // exit code 3". - exitCodeRe = regexp.MustCompile(`exit code (-?\d+)`) + // exit code 3": the code it ends with, not text in the command. + exitCodeRe = regexp.MustCompile(`exit code (-?\d+)\)?\s*$`) ) // jobNote reads a background shell's notification: "… @@ -738,15 +738,20 @@ var outputFile = regexp.MustCompile(`Output is being written to: (\S+?\.output)\ // settle reads the end of each running shell's output file: Claude Code // ends it with "[exited with code N]" when the shell exits, which can be // long before a notification says so (a busy agent is told only when its -// turn ends). +// turn ends). Until one does, the end is the file's to take back: a shell +// that printed the line itself and went on runs again. func (s *signals) settle() { for i := range s.Background { j := &s.Background[i] - if j.State != "running" || j.out == "" { + if j.out == "" || (j.State != "running" && !j.fromFile) { continue } - if code, at, ok := exited(j.out); ok { + code, at, ok := exited(j.out) + switch { + case ok: j.State, j.Until, j.Exit, j.fromFile = endState(code), at, &code, true + case j.fromFile: + j.State, j.Until, j.Exit, j.fromFile = "running", 0, nil, false } } } @@ -783,12 +788,16 @@ func exited(path string) (code int, at int64, ok bool) { } // taskFile opens a background task's output file: only a regular .output -// file in a tasks folder is read. +// file in a tasks folder is read. It is looked at before it is opened, as +// opening a pipe would wait for a writer, and the reader with it. func taskFile(path string) (*os.File, os.FileInfo) { p := filepath.Clean(path) if !filepath.IsAbs(p) || filepath.Ext(p) != ".output" || filepath.Base(filepath.Dir(p)) != "tasks" { return nil, nil } + if st, err := os.Stat(p); err != nil || !st.Mode().IsRegular() { + return nil, nil + } f, err := os.Open(p) if err != nil { return nil, nil diff --git a/internal/transcript/signals_test.go b/internal/transcript/signals_test.go index f5b78fb5b..f6f772bed 100644 --- a/internal/transcript/signals_test.go +++ b/internal/transcript/signals_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "testing" "time" ) @@ -313,3 +314,66 @@ func TestCodexSignals(t *testing.T) { t.Fatalf("notices = %v (items %s)", got, kinds(r.Items)) } } + +// A shell's exit code is the one its notification ends with, not text in +// its command that reads like one. +func TestAShellsExitCodeIsTheNotificationsOwn(t *testing.T) { + p := filepath.Join(t.TempDir(), "s.jsonl") + write(t, p, + assistant(tool("e1", "Bash", m{"command": "echo 'exit code 3'", "description": "Say exit code 3", "run_in_background": true})), + user([]m{{"type": "tool_result", "tool_use_id": "e1", "content": "Command running in background with ID: be1. Output is being written to: /gone/tasks/be1.output."}}), + m{"type": "attachment", "attachment": m{"type": "queued_command", "prompt": "\nbe1\ne1\ncompleted\nBackground command \"echo 'exit code 3'\" completed (exit code 0)\n"}}, + ) + bg := read(t, "claude", p).Signals.Background + if len(bg) != 1 || bg[0].State != "done" || bg[0].Exit == nil || *bg[0].Exit != 0 { + t.Fatalf("background = %+v", bg) + } +} + +// A shell that prints the line itself and goes on is running again once it +// prints more; and a pipe where its output file should be is never opened, +// so it can't hold up the reader. +func TestAShellsOutputFileOnlyEndsItWhileItSaysSo(t *testing.T) { + dir := t.TempDir() + tasks := filepath.Join(dir, "tasks") + os.MkdirAll(tasks, 0o755) + p := filepath.Join(dir, "s.jsonl") + for _, s := range [][2]string{{"liar", "bliar"}, {"pipe", "bpipe"}} { + write(t, p, + assistant(tool(s[0], "Bash", m{"command": "x", "description": s[0], "run_in_background": true})), + user([]m{{"type": "tool_result", "tool_use_id": s[0], "content": "Command running in background with ID: " + s[1] + ". Output is being written to: " + filepath.Join(tasks, s[1]+".output") + "."}}), + ) + } + if err := syscall.Mkfifo(filepath.Join(tasks, "bpipe.output"), 0o600); err != nil { + t.Fatal(err) + } + liar := filepath.Join(tasks, "bliar.output") + os.WriteFile(liar, []byte("[exited with code 0]\n"), 0o644) + r := NewReader() + states := func() map[string]string { + t.Helper() + done := make(chan Result, 1) + go func() { + res, _ := r.Read("claude", p, "/w", 0) + done <- res + }() + select { + case res := <-done: + out := map[string]string{} + for _, j := range res.Signals.Background { + out[j.Tool] = j.State + } + return out + case <-time.After(5 * time.Second): + t.Fatal("reading the conversation waited on a pipe") + return nil + } + } + if s := states(); s["liar"] != "done" || s["pipe"] != "running" { + t.Fatalf("while its file ends with the line: %v", s) + } + os.WriteFile(liar, []byte("[exited with code 0]\nand on it goes\n"), 0o644) + if s := states(); s["liar"] != "running" { + t.Fatalf("once it printed more: %v", s) + } +}