Harness spec 2.0.0: background work is a job — commands and subagents alike, seen and steered
One registry per process, every job owned by a session: bash's background commands (and a command still running at its timeout, which now goes to the background instead of being killed) and subagents started with task background: true. job_list, job_output, job_stop (the former bash_list, bash_output, bash_kill) and the new job_send and report; a finished job wakes the owning session in every front end with its marked note. The TUI counts jobs, lists them (/jobs), opens a command's output, enters a subagent and messages it, moves a command to the background (ctrl+b) and asks before quitting with jobs running. The link gains the jobs capability. Subagents get a session row of their own, the spec's preamble and agent texts, and their errors reach the report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
75 files changed
+3614
-437
No files matched your search
+31
-9
@@ -3,7 +3,9 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { applyPatchTool } from "../src/tool/apply_patch.ts"
|
||||
import { bashKillTool, bashOutputTool, bashTool } from "../src/tool/bash.ts"
|
||||
import { bashTool } from "../src/tool/bash.ts"
|
||||
import { jobOutputTool, jobStopTool } from "../src/tool/job_tools.ts"
|
||||
import { JobRegistry } from "../src/jobs/registry.ts"
|
||||
import { editTool } from "../src/tool/edit.ts"
|
||||
import { multieditTool } from "../src/tool/multiedit.ts"
|
||||
import { formatAnswers, questionSchema, unattendedReply, type QuestionRequest } from "../src/tool/ask_user.ts"
|
||||
@@ -188,19 +190,39 @@ describe("file tools", () => {
|
||||
})
|
||||
|
||||
describe("background bash", () => {
|
||||
test("start, read new output, see it finish; kill a long one", async () => {
|
||||
const c = ctx()
|
||||
const started = await bashTool.run({ command: "echo one; sleep 0.5; echo two", background: true }, c)
|
||||
test("start, read new output, see it finish; stop a long one", async () => {
|
||||
const registry = new JobRegistry()
|
||||
const c = { ...ctx(), jobs: { registry, owner: () => ({ sessionId: "s1" }) } }
|
||||
const started = await bashTool.run({ command: "echo one; sleep 0.3; echo two; sleep 0.5; echo three", background: true }, c)
|
||||
const id = /as (job\d+)/.exec(started.output)![1]!
|
||||
expect(started.output).toContain("one")
|
||||
const later = await bashOutputTool.run({ id, wait: 3 }, c)
|
||||
expect(started.output).toContain("job_output")
|
||||
const later = await jobOutputTool.run({ id, wait: 3 }, c)
|
||||
expect(later.output).toContain("two")
|
||||
await Bun.sleep(200)
|
||||
expect((await bashOutputTool.run({ id }, c)).output).toContain("exited with 0")
|
||||
expect(later.output).toContain("still running")
|
||||
await Bun.sleep(1200)
|
||||
// Its end note took what was left (nobody held the session, so the note waits on the job), and
|
||||
// job_output does not repeat it.
|
||||
const job = registry.get("s1", id)!
|
||||
expect(job.state).toBe("done")
|
||||
expect(job.note).toContain("three")
|
||||
expect(job.note).toStartWith("[A background job you started has finished:]")
|
||||
expect((await jobOutputTool.run({ id }, c)).output).toBe("(no new output)\n\n[job1 exited with 0]")
|
||||
const long = await bashTool.run({ command: "sleep 30", background: true }, c)
|
||||
const id2 = /as (job\d+)/.exec(long.output)![1]!
|
||||
expect((await bashKillTool.run({ id: id2 }, c)).output).toContain("Stopped")
|
||||
expect((await bashOutputTool.run({ id: id2, wait: 3 }, c)).output).toMatch(/exited with (null|143|-?\d+)/)
|
||||
expect((await jobStopTool.run({ id: id2 }, c)).output).toContain("Stopped")
|
||||
// A signal's end never reads "null".
|
||||
const after = (await jobOutputTool.run({ id: id2, wait: 3 }, c)).output
|
||||
expect(after).toContain("stopped (SIGTERM)")
|
||||
expect(after).not.toContain("null")
|
||||
})
|
||||
|
||||
test("without a session to own jobs, background is refused and a timeout kills", async () => {
|
||||
const c = ctx()
|
||||
await expect(bashTool.run({ command: "sleep 1", background: true }, c)).rejects.toThrow("not available")
|
||||
const r = await bashTool.run({ command: "sleep 5", timeout: 200 }, c)
|
||||
expect(r.output).toContain("timed out after 0s")
|
||||
expect(r.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user