Run delegated children on the Devin CLI

779a77692f09 · AtlantisPleb · · parent 2bb070155aa2

Run delegated children on the Devin CLI

`--child-model devin` runs a fan-out on the Devin CLI instead of on opencode.
`devin:<mode>` picks a permission mode other than the default `dangerous`.

A harness rather than a shell command, because that is the whole difference. A
Devin child started this way is a fleet child: it reports through the registry
the interface renders, it shows pending, running and finished with its elapsed
time, it stops with `ctrl+x`, it does not block the turn that started it, and
several run at once under the same concurrency cap. Run through `shell`, the
same work is one opaque call that freezes the session and shows nothing until it
ends. Nothing about a blocking spawn of an arbitrary command can be detected and
drawn, which is why this belongs behind the tool rather than in a skill telling
the model to craft a command.

A Devin child brings its own credentials and its own model rather than spending
this session's thread grant, so it needs neither the grant nor the child
gateway, and it is a different trust and billing boundary from an opencode
child. The fleet names the agent for that reason. Its print mode has no
structured output, so a child reports its answer once at the end rather than
streaming tool calls the way `opencode --format json` does; the fleet still
shows it start, run and finish, which is the part a reader waits on.

`--respect-workspace-trust false` is passed, because print mode cannot show the
trust prompt and a child in a directory nobody has opened Devin in would exit
before doing anything.

A correction with it: the skill said `respect_workspace_trust` was a config key
and not a flag. It is a flag, and `devin --help` says so. A session had reached
for it and been told by this skill that it did not exist.

Verified against the real binary: one child answers, three run concurrently in
6s where they would take 16s in turn, and through the `delegate` tool each is
told its own number and answers with it. Eight tests use a stand-in binary, so
they cost nothing and never call out.

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 docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/skills/delegating-work/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/test/coder-delegate-devin.test.ts

Diff

6 files changed, +297 -13

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2435,
7
    "filesScanned": 2436,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:df1ae247646ce2032b886c6ad9a2641425b70af96750dec46faa193b2ac1cba6",
4
  "sourceDigest": "sha256:213d97f4fd62f412e9feabce849ebb61ba854f8faad1fd31941e0f8ec982cc63",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (35 tracked test files)"
1879
          "ref": "packages/openagents-cli (36 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/skills/delegating-work/SKILL.md modified +27 -9

@@ -29,7 +29,27 @@ questions, so the prompt has to carry everything. Every child gets the same

29 29
prompt and is told its own number separately — write for whichever child is
30 30
reading, rather than naming one.
31 31
32
## The Devin CLI, for a whole task
32
## `delegate --child-model devin`, for a Devin fan-out
33
34
If `devin` is on `PATH`, `delegate` can run its children on it instead of on
35
opencode: pass `--child-model devin`, or `devin:<mode>` for a permission mode
36
other than the default `dangerous`.
37
38
Prefer this over running `devin` yourself through `shell`. A child started this
39
way is a fleet child like any other — it reports through the registry the
40
interface renders, it can be stopped with `ctrl+x`, it does not block the turn
41
that started it, and several run at once. Run through `shell`, the same work is
42
one call that freezes the session and shows nothing until it ends.
43
44
A Devin child brings its own credentials and its own model rather than spending
45
this session's grant, which is a different trust and billing boundary from an
46
opencode child. That is why the fleet names the agent.
47
48
Its print mode has no structured output, so a Devin child reports its answer
49
once at the end rather than streaming its tool calls the way an opencode child
50
does.
51
52
## The Devin CLI directly, for one task you will wait on
33 53
34 54
If `devin` is on `PATH`, it is another coding agent on this machine, and it can
35 55
take a task end to end rather than one prompt in parallel. Check with

@@ -64,14 +84,12 @@ calls the equivalent mode "bypass". Passing `--permission-mode bypass` is not

64 84
rejected — it is accepted and ignored, so the session silently falls back to
65 85
prompting, and a prompt nobody can answer is a task that does nothing.
66 86
67
**It refuses a workspace it does not trust**, but only in a directory nobody has
68
opened it in. You will know because it exits at once, before doing anything,
69
with the words `Refusing to run in an untrusted workspace`. Only that message
70
means this; do not read any other failure as a trust problem. Trust is granted
71
by starting `devin` interactively there once, which only the person at the
72
keyboard can do, so say so and name the directory rather than working around it.
73
`respect_workspace_trust` is a config-file key, not a command-line flag, and
74
passing it as one will not do anything.
87
**It refuses a workspace nobody has opened it in**, exiting at once with
88
`Refusing to run in an untrusted workspace`. Print mode cannot show the trust
89
prompt, so it fails rather than asking. Pass `--respect-workspace-trust false`
90
to skip the check, which is what print mode is for; the alternative is someone
91
starting `devin` interactively in that directory once. Only that exact message
92
means trust — do not read other failures as a trust problem.
75 93
76 94
**`dangerous` auto-approves every tool it has.** That is the point of using it
77 95
unattended, and it is the reason to say what you are handing over before you
packages/openagents-cli/src/cli.ts modified +19 -1

@@ -19,7 +19,7 @@ import type { ChildGrant } from "./coder-child-gateway.js";

19 19
import { startChildGateway } from "./coder-child-gateway.js";
20 20
import { writeChildHarnessConfig } from "./coder-child-config.js";
21 21
import type { DelegationOutcome } from "./coder-delegate.js";
22
import { DelegateFleet, describePrompt, OpencodeHarness } from "./coder-delegate.js";
22
import { DelegateFleet, DevinHarness, describePrompt, OpencodeHarness } from "./coder-delegate.js";
23 23
import { fleetPlainLines } from "./coder-fleet.js";
24 24
import { runCoderPlain } from "./coder-plain.js";
25 25
import type { CoderDelegation } from "./coder-session.js";

@@ -1581,6 +1581,24 @@ async function buildDelegation(options: {

1581 1581
  const command = options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"];
1582 1582
  const namedConfig = options.configPath ?? process.env["OPENAGENTS_DELEGATE_CONFIG"];
1583 1583
1584
  // `--child-model devin` runs children on the Devin CLI instead. It brings its
1585
  // own credentials and its own model, so it needs neither this session's grant
1586
  // nor a gateway, and it is refused up front when it is not installed rather
1587
  // than once per child.
1588
  if (named !== undefined && /^devin(:(.+))?$/.test(named.trim())) {
1589
    const mode = /^devin:(.+)$/.exec(named.trim())?.[1];
1590
    const harness = new DevinHarness(mode === undefined ? {} : { permissionMode: mode });
1591
    const registry = new CoderTaskRegistry();
1592
    const fleet = new DelegateFleet(registry, harness, {
1593
      maxConcurrent: Math.max(1, options.concurrency),
1594
      cwd: options.cwd,
1595
    });
1596
    return {
1597
      delegation: { registry, fleet, label: `${harness.agent} (${harness.model})` },
1598
      close: () => Promise.resolve(),
1599
    };
1600
  }
1601
1584 1602
  let model: string;
1585 1603
  let configPath: string | undefined;
1586 1604
  let close: () => Promise<void>;
packages/openagents-cli/src/coder-delegate.ts modified +141

@@ -351,6 +351,147 @@ function killTree(child: ChildProcess, signal: "SIGTERM" | "SIGKILL"): void {

351 351
}
352 352
353 353
/** Runs children as `opencode run --format json` subprocesses. */
354
/** How a Devin child is run. */
355
export interface DevinHarnessOptions {
356
  /** The binary, so a test can point at a stand-in. Defaults to `devin`. */
357
  readonly command?: string | undefined;
358
  /**
359
   * The permission mode passed through.
360
   *
361
   * `dangerous` is this build's name for the unattended mode -- the published
362
   * documentation calls it "bypass", and passing that is accepted and ignored,
363
   * so a child would silently fall back to prompting where nobody can answer.
364
   */
365
  readonly permissionMode?: string | undefined;
366
  /** Extra environment for the child. */
367
  readonly env?: Record<string, string> | undefined;
368
}
369
370
/**
371
 * Children run by the Devin CLI.
372
 *
373
 * A second harness rather than a shell command, so a Devin fan-out is a fleet
374
 * like any other: it reports through the registry the renderer reads, it can be
375
 * stopped with the rest, and it does not block the turn that started it. Run
376
 * through `shell` instead, the same work is one opaque call that freezes the
377
 * session and shows nothing while it runs.
378
 *
379
 * Devin's print mode has no structured output, so a child reports its answer
380
 * once at the end rather than streaming tool calls the way `opencode --format
381
 * json` does. The fleet still shows it start, run, and finish, which is the
382
 * part the reader is waiting on.
383
 *
384
 * Its own credentials are used, not this session's grant. That is a different
385
 * trust and billing boundary from an `opencode` child, and it is the reason the
386
 * agent is named in the fleet rather than left implicit.
387
 */
388
export class DevinHarness implements DelegateHarness {
389
  readonly agent = "devin";
390
  readonly model: string;
391
392
  constructor(private readonly options: DevinHarnessOptions = {}) {
393
    // Devin picks its own model from its own configuration, and print mode does
394
    // not report which. Naming one here would be inventing it.
395
    this.model = options.permissionMode ?? "dangerous";
396
  }
397
398
  async *run(
399
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
400
    signal: AbortSignal,
401
  ): AsyncIterable<DelegateEvent> {
402
    const command = this.options.command ?? "devin";
403
    const mode = this.options.permissionMode ?? "dangerous";
404
405
    const child = spawn(
406
      command,
407
      [
408
        "-p",
409
        input.prompt,
410
        "--permission-mode",
411
        mode,
412
        // Print mode cannot show the trust prompt, so without this a child in a
413
        // directory nobody has opened Devin in exits before doing anything.
414
        "--respect-workspace-trust",
415
        "false",
416
      ],
417
      {
418
        cwd: input.cwd,
419
        env: { ...process.env, ...this.options.env },
420
        // No terminal: a child that would prompt gets end-of-file and stops
421
        // rather than waiting where the fleet shows it as still working.
422
        stdio: ["ignore", "pipe", "pipe"],
423
      },
424
    );
425
426
    const events: DelegateEvent[] = [];
427
    let resolveNext: (() => void) | undefined;
428
    const wake = () => {
429
      resolveNext?.();
430
      resolveNext = undefined;
431
    };
432
433
    let answer = "";
434
    let failure = "";
435
    let done = false;
436
    let startFailure: Error | undefined;
437
438
    child.stdout.setEncoding("utf8");
439
    child.stdout.on("data", (chunk: string) => {
440
      answer += chunk;
441
    });
442
    child.stderr.setEncoding("utf8");
443
    child.stderr.on("data", (chunk: string) => {
444
      failure += chunk;
445
    });
446
447
    const onAbort = () => child.kill("SIGKILL");
448
    signal.addEventListener("abort", onAbort, { once: true });
449
450
    child.on("error", (cause: Error) => {
451
      startFailure =
452
        (cause as NodeJS.ErrnoException).code === "ENOENT"
453
          ? new Error(`The \`${command}\` command is not on PATH.`)
454
          : cause;
455
      done = true;
456
      wake();
457
    });
458
    child.on("close", (code: number | null) => {
459
      const text = answer.trim();
460
      if (code === 0) {
461
        if (text.length > 0) events.push({ type: "text", value: text });
462
      } else {
463
        const said = `${failure.trim()}\n${text}`.trim();
464
        events.push({
465
          type: "error",
466
          message:
467
            said.length > 0
468
              ? said
469
              : `The \`${command}\` child exited with code ${String(code ?? -1)}.`,
470
        });
471
      }
472
      done = true;
473
      wake();
474
    });
475
476
    try {
477
      for (;;) {
478
        while (events.length > 0) {
479
          const next = events.shift();
480
          if (next !== undefined) yield next;
481
        }
482
        if (done) break;
483
        await new Promise<void>((resolve) => {
484
          resolveNext = resolve;
485
        });
486
      }
487
    } finally {
488
      signal.removeEventListener("abort", onAbort);
489
    }
490
491
    if (startFailure !== undefined) throw startFailure;
492
  }
493
}
494
354 495
export class OpencodeHarness implements DelegateHarness {
355 496
  readonly agent = "opencode";
356 497
  readonly model: string;
packages/openagents-cli/test/coder-delegate-devin.test.ts added +107

@@ -0,0 +1,107 @@

1
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { DevinHarness, type DelegateEvent } from "../src/coder-delegate.js";
7
8
/** A stand-in for the binary, so the tests cost nothing and never call out. */
9
const fake = (script: string): string => {
10
  const directory = mkdtempSync(join(tmpdir(), "devin-harness-"));
11
  const path = join(directory, "devin-stub");
12
  writeFileSync(path, `#!/bin/sh\n${script}\n`);
13
  chmodSync(path, 0o755);
14
  return path;
15
};
16
17
const collect = async (
18
  harness: DevinHarness,
19
  cwd = process.cwd(),
20
): Promise<ReadonlyArray<DelegateEvent>> => {
21
  const events: DelegateEvent[] = [];
22
  for await (const event of harness.run(
23
    { prompt: "do the thing", cwd, transcriptPath: join(tmpdir(), "unused.jsonl") },
24
    new AbortController().signal,
25
  )) {
26
    events.push(event);
27
  }
28
  return events;
29
};
30
31
describe("running children on the Devin CLI", () => {
32
  it("names itself in the fleet, so a Devin child is not mistaken for an opencode one", () => {
33
    // Its credentials and its billing are not this session's, which is why the
34
    // agent is named rather than left implicit.
35
    expect(new DevinHarness().agent).toBe("devin");
36
  });
37
38
  it("reports the child's answer as text", async () => {
39
    const events = await collect(new DevinHarness({ command: fake('echo "PONG"') }));
40
41
    expect(events).toEqual([{ type: "text", value: "PONG" }]);
42
  });
43
44
  it("passes the prompt, the unattended mode, and the trust flag", async () => {
45
    // Print mode cannot show the trust prompt, so without the flag a child in a
46
    // directory nobody has opened Devin in exits before doing anything.
47
    const events = await collect(new DevinHarness({ command: fake('echo "$@"') }));
48
49
    const said = (events[0] as { value: string }).value;
50
    expect(said).toContain("-p do the thing");
51
    expect(said).toContain("--permission-mode dangerous");
52
    expect(said).toContain("--respect-workspace-trust false");
53
  });
54
55
  it("takes a different permission mode when one is asked for", async () => {
56
    const harness = new DevinHarness({ command: fake('echo "$@"'), permissionMode: "auto" });
57
58
    const said = ((await collect(harness))[0] as { value: string }).value;
59
    expect(said).toContain("--permission-mode auto");
60
    // The mode is what the fleet shows beside the agent, because print mode
61
    // does not report which model answered.
62
    expect(harness.model).toBe("auto");
63
  });
64
65
  it("reports a failing child as an error, with what it said", async () => {
66
    const events = await collect(
67
      new DevinHarness({ command: fake('echo "went wrong" >&2; exit 2') }),
68
    );
69
70
    expect(events).toEqual([{ type: "error", message: "went wrong" }]);
71
  });
72
73
  it("says so when a failing child said nothing at all", async () => {
74
    const events = await collect(new DevinHarness({ command: fake("exit 3") }));
75
76
    expect((events[0] as { message: string }).message).toContain("exited with code 3");
77
  });
78
79
  it("throws when the binary is not on PATH, rather than reporting an empty child", async () => {
80
    // Refused once for the fleet, not once per child.
81
    await expect(collect(new DevinHarness({ command: "devin-does-not-exist" }))).rejects.toThrow(
82
      "not on PATH",
83
    );
84
  });
85
86
  it("stops when the fleet is stopped", async () => {
87
    const harness = new DevinHarness({ command: fake("sleep 30") });
88
    const controller = new AbortController();
89
    const events: DelegateEvent[] = [];
90
91
    const running = (async () => {
92
      for await (const event of harness.run(
93
        { prompt: "x", cwd: process.cwd(), transcriptPath: "/tmp/x" },
94
        controller.signal,
95
      )) {
96
        events.push(event);
97
      }
98
    })();
99
100
    controller.abort();
101
    await running;
102
103
    // Killed rather than left running: a child holds a process, and a console
104
    // that exits while children keep spending leaves nothing to stop them with.
105
    expect(events.every((event) => event.type !== "text")).toBe(true);
106
  });
107
});

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