Stop a delegated child's own subprocesses, and keep the fleet rows honest

815018943809 · Devin AI · · parent 70be9da3af70

Stop a delegated child's own subprocesses, and keep the fleet rows honest

Cancelling a child killed opencode and left whatever it had shelled out
to running under pid 1, and Ctrl+C on the headless command left the whole
fan-out spending. A stop now walks the child's process tree before it
signals, and the command stops its fleet on the way out.

The interface no longer prints a bare 0 on a row whose child has not
reported usage yet, keeps the stop hint ahead of the hints that get
truncated, and clips the composer to the row so a long line cannot wrap
and leave text nothing erases.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • modified packages/openagents-cli/src/coder-fleet.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-delegate.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

6 files changed, +179 -10

packages/openagents-cli/src/cli.ts modified +16

@@ -1723,6 +1723,19 @@ const delegateCommand = Command.make(

1723 1723
              }
1724 1724
            });
1725 1725
1726
        // Ctrl+C has to reach the children. Without this the command exits and
1727
        // leaves every child agent running, spending, with nothing left
1728
        // holding a handle on them. The handler is prepended and repeated on
1729
        // exit because the runtime installs a signal handler of its own that
1730
        // tears the process down, and whichever ends the process first must not
1731
        // be the one that skips the children.
1732
        const onSignal = () => {
1733
          registry.stopAll();
1734
        };
1735
        process.prependListener("SIGINT", onSignal);
1736
        process.prependListener("SIGTERM", onSignal);
1737
        process.prependListener("exit", onSignal);
1738
1726 1739
        try {
1727 1740
          return await Promise.all(
1728 1741
            Array.from({ length: count }, () =>

@@ -1730,6 +1743,9 @@ const delegateCommand = Command.make(

1730 1743
            ),
1731 1744
          );
1732 1745
        } finally {
1746
          process.off("SIGINT", onSignal);
1747
          process.off("SIGTERM", onSignal);
1748
          process.off("exit", onSignal);
1733 1749
          unsubscribe();
1734 1750
        }
1735 1751
      });
packages/openagents-cli/src/coder-delegate.ts modified +90 -2

@@ -29,7 +29,8 @@

29 29
 * shape then breaks one small tested function rather than the scheduler.
30 30
 */
31 31
32
import { spawn } from "node:child_process";
32
import type { ChildProcess } from "node:child_process";
33
import { execFileSync, spawn } from "node:child_process";
33 34
import { createWriteStream, mkdirSync } from "node:fs";
34 35
import { tmpdir } from "node:os";
35 36
import { join } from "node:path";

@@ -248,6 +249,83 @@ export interface OpencodeHarnessOptions {

248 249
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
249 250
}
250 251
252
/** How long a stopped child has to leave on its own before it is killed. */
253
const KILL_GRACE_MS = 3_000;
254
255
/**
256
 * Signal a child and everything it started.
257
 *
258
 * The negative pid is the process group, which is why the child is spawned
259
 * detached. It falls back to the child alone when the group is already gone,
260
 * because a group whose last member exited between the two calls raises rather
261
 * than reporting nothing to do.
262
 */
263
function killGroup(child: ChildProcess, signal: "SIGTERM" | "SIGKILL"): void {
264
  const pid = child.pid;
265
  if (pid === undefined) return;
266
  try {
267
    process.kill(-pid, signal);
268
  } catch {
269
    try {
270
      child.kill(signal);
271
    } catch {
272
      // Already gone, which is the outcome that was wanted.
273
    }
274
  }
275
}
276
277
/**
278
 * Every process descended from `root`, deepest last.
279
 *
280
 * A harness is free to put its own tool processes in groups of their own, and
281
 * opencode does: signalling the group takes out opencode and leaves whatever a
282
 * bash tool started running under pid 1. The tree has to be read while the
283
 * parent is still alive, because reparenting erases the link.
284
 */
285
function descendants(root: number): ReadonlyArray<number> {
286
  let listing: string;
287
  try {
288
    listing = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
289
  } catch {
290
    return [];
291
  }
292
293
  const byParent = new Map<number, Array<number>>();
294
  for (const line of listing.split("\n")) {
295
    const [pid, parent] = line.trim().split(/\s+/).map(Number);
296
    if (pid === undefined || parent === undefined) continue;
297
    if (!Number.isInteger(pid) || !Number.isInteger(parent)) continue;
298
    const siblings = byParent.get(parent);
299
    if (siblings === undefined) byParent.set(parent, [pid]);
300
    else siblings.push(pid);
301
  }
302
303
  const found: Array<number> = [];
304
  const walk = (pid: number): void => {
305
    for (const child of byParent.get(pid) ?? []) {
306
      if (found.includes(child)) continue;
307
      found.push(child);
308
      walk(child);
309
    }
310
  };
311
  walk(root);
312
  return found;
313
}
314
315
/** Signal a child, its group, and every process either of them started. */
316
function killTree(child: ChildProcess, signal: "SIGTERM" | "SIGKILL"): void {
317
  const pid = child.pid;
318
  const tree = pid === undefined ? [] : descendants(pid);
319
  killGroup(child, signal);
320
  for (const descendant of tree) {
321
    try {
322
      process.kill(descendant, signal);
323
    } catch {
324
      // Already gone, which is the outcome that was wanted.
325
    }
326
  }
327
}
328
251 329
/** Runs children as `opencode run --format json` subprocesses. */
252 330
export class OpencodeHarness implements DelegateHarness {
253 331
  readonly agent = "opencode";

@@ -276,6 +354,11 @@ export class OpencodeHarness implements DelegateHarness {

276 354
          : { OPENCODE_CONFIG: this.options.configPath }),
277 355
      },
278 356
      stdio: ["ignore", "pipe", "pipe"],
357
      // Its own process group, so stopping a child stops what the child
358
      // started. A coding agent shells out, and killing only the agent leaves
359
      // its build or its `sleep` running with nothing left to stop it — with a
360
      // fan-out of fifteen, every cancelled fleet would leave a pile behind.
361
      detached: true,
279 362
    });
280 363
281 364
    // The transcript is written as the events arrive, not at the end, so a

@@ -294,7 +377,12 @@ export class OpencodeHarness implements DelegateHarness {

294 377
    let failure: string | undefined;
295 378
296 379
    const onAbort = () => {
297
      child.kill("SIGTERM");
380
      killTree(child, "SIGTERM");
381
      // A harness that ignores the term, or a tool that will not stop, still
382
      // has to go: the reader asked for the child to end, not to be asked.
383
      const grace = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS);
384
      grace.unref();
385
      child.once("close", () => clearTimeout(grace));
298 386
    };
299 387
    signal.addEventListener("abort", onAbort, { once: true });
300 388
packages/openagents-cli/src/coder-fleet.ts modified +7 -2

@@ -102,8 +102,13 @@ export function taskActivity(task: CoderTask): string {

102 102
export function taskCounters(task: CoderTask): string {
103 103
  if (isTerminal(task.status)) return "";
104 104
  const tools = task.progress.toolUseCount;
105
  if (tools === 0 && task.progress.tokenCount === 0) return "";
106
  return `${String(tools)} ${tools === 1 ? "tool" : "tools"} · ${formatTokens(task.progress.tokenCount)}`;
105
  const tokens = task.progress.tokenCount;
106
  const parts: Array<string> = [];
107
  if (tools > 0) parts.push(`${String(tools)} ${tools === 1 ? "tool" : "tools"}`);
108
  // Usage is omitted until the harness has reported some, because a bare `0`
109
  // in a counter column reads as a missing number rather than as "not yet".
110
  if (tokens > 0) parts.push(`${formatTokens(tokens)} tokens`);
111
  return parts.join(" · ");
107 112
}
108 113
109 114
/**
packages/openagents-cli/src/coder-ui.ts modified +18 -5

@@ -436,7 +436,17 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

436 436
      }
437 437
      rows.push(`  ${justify(activity, where, inner)}`);
438 438
      rows.push(rule);
439
      rows.push(`  › ${composer}`);
439
      // The composer shows its tail, never more characters than the row holds.
440
      // A row written past the last column makes the terminal wrap it, which
441
      // pushes the rule and the hints down a line and leaves the text they used
442
      // to occupy on screen with nothing to erase it.
443
      const composerRoom = Math.max(4, width - 4);
444
      const typed = [...composer];
445
      const visible =
446
        typed.length > composerRoom
447
          ? `…${typed.slice(typed.length - composerRoom + 1).join("")}`
448
          : composer;
449
      rows.push(`  › ${visible}`);
440 450
      rows.push(rule);
441 451
442 452
      // Every key named here does something in the state it is named in, and

@@ -451,14 +461,17 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

451 461
        if (composer.length > 0) keys.push("esc to clear");
452 462
        else keys.push("ctrl+d to quit");
453 463
      }
464
      // Stopping the fleet comes before the conveniences, because the row is
465
      // clipped from the end and this hint only appears while children are
466
      // spending. Offered last, it was dropped exactly when it applied.
467
      if (snapshot.tasks.some((task) => task.status === "running")) {
468
        keys.push("ctrl+x to stop agents");
469
      }
454 470
      // Only when there is another model to switch to, and only while nothing
455 471
      // is running: a turn already accepted keeps the backend it named.
456 472
      if (session.canCycleBackend && !snapshot.running) keys.push("tab to switch model");
457 473
      if (lines.length > transcriptRows) keys.push("pgup/pgdn to scroll");
458 474
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
459
      if (snapshot.tasks.some((task) => task.status === "running")) {
460
        keys.push("ctrl+x to stop agents");
461
      }
462 475
463 476
      // `this run` is not decoration. The count is this process's, and a
464 477
      // source that is not the thread — the stand-in behind `--offline` — has

@@ -473,7 +486,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

473 486
            : `${DIM}${replies}${RESET}`;
474 487
      rows.push(`  ${hints(keys, counter, inner)}`);
475 488
476
      paint(rows, transcriptRows + fleet.length + 3, 4 + composer.length + 1);
489
      paint(rows, transcriptRows + fleet.length + 3, 4 + [...visible].length + 1);
477 490
    };
478 491
479 492
    /**
packages/openagents-cli/test/coder-delegate.test.ts modified +23 -1

@@ -325,7 +325,7 @@ describe("fleet rendering", () => {

325 325
    expect(rows[0]?.branch).toBe("└─");
326 326
    expect(rows[0]?.mark).toBe("◐");
327 327
    expect(rows[0]?.text).toContain("bash(pnpm test)");
328
    expect(rows[0]?.text).toContain("8.2k");
328
    expect(rows[0]?.text).toContain("8.2k tokens");
329 329
  });
330 330
331 331
  it("replaces the counters with a total once the child is done", () => {

@@ -337,6 +337,28 @@ describe("fleet rendering", () => {

337 337
    expect(fleetPhrase([done])).toBe("1 done · 1 unread");
338 338
  });
339 339
340
  it("leaves usage out of a row until the harness has reported some", () => {
341
    const fresh = new CoderTaskRegistry();
342
    const started = fresh.register(
343
      {
344
        id: "d2",
345
        description: "read a file",
346
        prompt: "read it",
347
        agent: "opencode",
348
        model: "fake/model",
349
        cwd: "/tmp",
350
        background: true,
351
      },
352
      0,
353
    );
354
    fresh.start(started.id, new AbortController());
355
    fresh.recordToolUse(started.id, { toolName: "read", target: "x.ts" });
356
357
    const row = fleetRows(fresh.list(), 80)[0];
358
    expect(row?.text).toContain("1 tool");
359
    expect(row?.text).not.toContain(" 0");
360
  });
361
340 362
  it("shortens token counts", () => {
341 363
    expect(formatTokens(999)).toBe("999");
342 364
    expect(formatTokens(8214)).toBe("8.2k");
packages/openagents-cli/test/coder-ui.test.ts modified +25

@@ -310,4 +310,29 @@ describe("runCoderUi", () => {

310 310
    expect(status).not.toContain("a-long-repository-name");
311 311
    expect(status).toContain("$2.00");
312 312
  });
313
314
  it("keeps a long typed line inside the row, showing its tail", async () => {
315
    const stdin = new FakeIn();
316
    const stdout = new FakeOut();
317
    const session = new CoderSession(source([]), "repo", "main");
318
    const running = runCoderUi(session, {
319
      stdin: stdin as unknown as NodeJS.ReadStream,
320
      stdout: stdout as unknown as NodeJS.WriteStream,
321
    });
322
323
    // A row written past the last column is wrapped by the terminal, which
324
    // shifts every row below it and leaves text nothing will erase.
325
    stdin.emit("data", `${"a".repeat(120)}END`);
326
    const rows = screen(stdout.written);
327
    // Ctrl+D quits only from an empty composer, so clear it first.
328
    stdin.emit("data", "\x1b");
329
    await new Promise((resolve) => setTimeout(resolve, 60));
330
    stdin.emit("data", "\x04");
331
    await running;
332
333
    const composer = rows.find((row) => row.startsWith("  ›")) ?? "";
334
    expect([...composer].length).toBeLessThanOrEqual(stdout.columns);
335
    expect(composer.endsWith("END")).toBe(true);
336
    expect(composer).toContain("…");
337
  });
313 338
});

This page updates live while a promote is in flight · changelog