Run children on free models, and refresh a grant that outlived its session

16e901594594 · AtlantisPleb · · parent da174dc06e7a

Run children on free models, and refresh a grant that outlived its session

Four children came back together with `grant_expired`, and the first sign of it
was four failed children rather than anything about the grant. A thread grant
lives 3,600 seconds — `thread_grant_ttl_seconds` — and the console mints one at
startup and never again. A session open longer than an hour dispatches onto a
credential minted at breakfast.

Two changes, and the first one is the one that matters.

**Children default to a free model with no grant at all.** The harness's own
catalog costs nothing and needs no credential from us, so `buildDelegation` now
picks the first of `opencode/big-pickle`, then the Gemini flash models,
resolved against what `opencode models` actually lists. A name that goes away
falls through to the next rather than failing a fan-out, and a listing that
cannot be read says nothing rather than guessing.

**The grant is the fallback, and it refreshes.** Where it is still used, the
child gateway mints another when the proxy refuses one as expired and sends the
same request again, once — a second expiry on a grant minted moments ago is a
refusal about something else, and retrying forever would hide it. A caller with
no way to mint another gets the refusal as before.

Worth recording against the intent: `ox-alpha` cannot be reached without a
grant. The server maps it to `stealth/ox-alpha` through its own provider, and
the harness lists no stealth or openrouter route among its 101 models, so the
proxy is the only path to it and the proxy is what requires the grant.
`opencode/big-pickle` is the harness's own stealth entry and is what the
preference list reaches first; whether it is the same model is not something
this repository can assert.

415 tests pass.

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/src/cli.ts
  • modified packages/openagents-cli/src/coder-child-gateway.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/test/coder-child-gateway-grant.test.ts

Diff

6 files changed, +239 -10

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": 2437,
7
    "filesScanned": 2438,
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:8b8a0cd69a97c37f05aad3a00b188183c4aaf167865ab050878a3ea987f5da5d",
4
  "sourceDigest": "sha256:154444d74279069de014797ee8c9943bc18f625702640bc4dbdb6bd1d5e82f18",
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 (37 tracked test files)"
1879
          "ref": "packages/openagents-cli (38 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +50 -2

@@ -19,7 +19,13 @@ 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, DevinHarness, describePrompt, OpencodeHarness } from "./coder-delegate.js";
22
import {
23
  DelegateFleet,
24
  DevinHarness,
25
  describePrompt,
26
  firstAvailableChildModel,
27
  OpencodeHarness,
28
} from "./coder-delegate.js";
23 29
import { fleetPlainLines } from "./coder-fleet.js";
24 30
import { runCoderPlain } from "./coder-plain.js";
25 31
import type { CoderDelegation } from "./coder-session.js";

@@ -1577,6 +1583,8 @@ async function buildDelegation(options: {

1577 1583
  readonly cwd: string;
1578 1584
  /** The session's grant, when it has one. Children spend it by default. */
1579 1585
  readonly grant: ChildGrant | undefined;
1586
  /** Mint a fresh grant, for when the one in hand has expired. */
1587
  readonly refreshGrant?: (() => Promise<ChildGrant | undefined>) | undefined;
1580 1588
}): Promise<DelegationSetup | undefined> {
1581 1589
  const named = options.model ?? process.env["OPENAGENTS_DELEGATE_MODEL"];
1582 1590
  const command = options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"];

@@ -1604,12 +1612,39 @@ async function buildDelegation(options: {

1604 1612
  let configPath: string | undefined;
1605 1613
  let close: () => Promise<void>;
1606 1614
1615
  // Free and grant-free first. A thread grant lives an hour, has to be minted,
1616
  // and expires under a console that outlives it; the harness's own catalog
1617
  // costs nothing and needs no credential from us. The grant stays as the
1618
  // fallback for a machine whose harness lists none of them.
1619
  const free =
1620
    named === undefined || named.trim().length === 0
1621
      ? await firstAvailableChildModel(command ?? "opencode")
1622
      : undefined;
1623
1624
  if (free !== undefined) {
1625
    const harness = new OpencodeHarness({
1626
      model: free,
1627
      ...(command === undefined ? {} : { command }),
1628
      ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1629
      autoApprove: options.autoApprove,
1630
    });
1631
    const registry = new CoderTaskRegistry();
1632
    const fleet = new DelegateFleet(registry, harness, {
1633
      maxConcurrent: Math.max(1, options.concurrency),
1634
      cwd: options.cwd,
1635
    });
1636
    return {
1637
      delegation: { registry, fleet, label: `${harness.agent} (${free})` },
1638
      close: () => Promise.resolve(),
1639
    };
1640
  }
1641
1607 1642
  if (named !== undefined && named.trim().length > 0) {
1608 1643
    model = named;
1609 1644
    configPath = namedConfig;
1610 1645
    close = () => Promise.resolve();
1611 1646
  } else if (options.grant !== undefined) {
1612
    const gateway = await startChildGateway(options.grant);
1647
    const gateway = await startChildGateway(options.grant, options.refreshGrant);
1613 1648
    const harnessConfig = writeChildHarnessConfig({
1614 1649
      baseUrl: gateway.baseUrl,
1615 1650
      model: options.grant.model,

@@ -1794,6 +1829,19 @@ const coderCommand = Command.make(

1794 1829
          concurrency,
1795 1830
          cwd: process.cwd(),
1796 1831
          grant: childGrant,
1832
          // A thread grant lives an hour; a console does not stop at one. When
1833
          // the grant in hand has expired, another thread is opened and its
1834
          // grant used, so a fan-out started in the afternoon does not fail on
1835
          // a credential minted at breakfast.
1836
          refreshGrant: async () => {
1837
            if (Option.isNone(stored)) return undefined;
1838
            const reopened = await openChildThread({
1839
              origin: endpoint.origin,
1840
              token: Redacted.value(stored.value.token),
1841
              objective: `delegated children of openagents coder in ${workspace.repository}`,
1842
            }).catch(() => undefined);
1843
            return reopened?.kind === "opened" ? reopened.thread.childGrant : undefined;
1844
          },
1797 1845
        }),
1798 1846
      );
1799 1847
packages/openagents-cli/src/coder-child-gateway.ts modified +42 -5

@@ -152,12 +152,43 @@ export const CHILD_PROVIDER = "openagents";

152 152
 * Resolves once it is listening, because a child launched against a port that
153 153
 * is not up yet fails on its first call and reports it as a provider error.
154 154
 */
155
export async function startChildGateway(grant: ChildGrant): Promise<ChildGateway> {
155
export async function startChildGateway(
156
  grant: ChildGrant,
157
  /**
158
   * Mint a fresh grant, when the one in hand has expired.
159
   *
160
   * A thread grant lives an hour and the session that minted it does not. A
161
   * console open longer than that dispatched four children onto a grant minted
162
   * at breakfast, and all four came back `grant_expired` — the first sign of
163
   * which was four failed children rather than anything about the grant.
164
   *
165
   * Left out by a caller that has no way to mint another, and the refusal then
166
   * reaches the child as it did before.
167
   */
168
  refresh?: () => Promise<ChildGrant | undefined>,
169
): Promise<ChildGateway> {
170
  // The grant in hand, replaced rather than reused once it has expired.
171
  let current = grant;
172
156 173
  const server = createServer((request, response) => {
157 174
    const chunks: Buffer[] = [];
158 175
    request.on("data", (chunk: Buffer) => chunks.push(chunk));
159 176
    request.on("end", () => {
160
      void forward(Buffer.concat(chunks).toString("utf8"), grant, response);
177
      void (async () => {
178
        const body = Buffer.concat(chunks).toString("utf8");
179
        const first = await forward(body, current, response, refresh !== undefined);
180
        if (first !== "grant_expired" || refresh === undefined) return;
181
182
        // Once. A second expiry on a grant minted moments ago is a refusal
183
        // about something else, and retrying it forever would hide that.
184
        const minted = await refresh().catch(() => undefined);
185
        if (minted === undefined) {
186
          await forward(body, current, response, false);
187
          return;
188
        }
189
        current = minted;
190
        await forward(body, current, response, false);
191
      })();
161 192
    });
162 193
  });
163 194

@@ -185,7 +216,9 @@ async function forward(

185 216
  body: string,
186 217
  grant: ChildGrant,
187 218
  response: import("node:http").ServerResponse,
188
): Promise<void> {
219
  /** When set, an expired grant is reported to the caller instead of the child. */
220
  reportExpiry: boolean,
221
): Promise<"grant_expired" | undefined> {
189 222
  let payload: Record<string, unknown> = {};
190 223
  try {
191 224
    const parsed: unknown = JSON.parse(body === "" ? "{}" : body);

@@ -232,6 +265,9 @@ async function forward(

232 265
    // message is the difference between a child that says
233 266
    // `budget_exhausted` and three children that say `exited with code 1`.
234 267
    const detail = (await upstream.text().catch(() => "")).slice(0, 400);
268
    if (reportExpiry && upstream.status === 403 && detail.includes("grant_expired")) {
269
      return "grant_expired";
270
    }
235 271
    response.writeHead(upstream.status, { "content-type": "application/json" });
236 272
    response.end(
237 273
      JSON.stringify({

@@ -243,7 +279,7 @@ async function forward(

243 279
        },
244 280
      }),
245 281
    );
246
    return;
282
    return undefined;
247 283
  }
248 284
249 285
  response.writeHead(upstream.status, {

@@ -252,7 +288,7 @@ async function forward(

252 288
253 289
  if (upstream.body === null) {
254 290
    response.end();
255
    return;
291
    return undefined;
256 292
  }
257 293
258 294
  const reader = upstream.body.getReader();

@@ -264,4 +300,5 @@ async function forward(

264 300
    response.write(Buffer.from(value));
265 301
  }
266 302
  response.end();
303
  return undefined;
267 304
}
packages/openagents-cli/src/coder-delegate.ts modified +44

@@ -492,6 +492,50 @@ export class DevinHarness implements DelegateHarness {

492 492
  }
493 493
}
494 494
495
/**
496
 * The models a child is given, in the order they are preferred.
497
 *
498
 * Free and grant-free, both on purpose. A thread grant lives an hour, has to be
499
 * minted, and expires under a console that outlives it — four children once
500
 * came back `grant_expired` together — while the harness's own catalog costs
501
 * nothing and needs no credential from us at all.
502
 *
503
 * Resolved against what the harness actually lists, so a name that goes away
504
 * falls through to the next rather than failing a fan-out.
505
 */
506
export const FREE_CHILD_MODELS: ReadonlyArray<string> = [
507
  "opencode/big-pickle",
508
  "opencode/gemini-3.7-flash",
509
  "opencode/gemini-3.6-flash",
510
  "opencode/gemini-3.5-flash",
511
];
512
513
/**
514
 * The first preferred model the harness offers, or undefined when it lists none.
515
 *
516
 * A listing that cannot be read says nothing rather than guessing, for the same
517
 * reason the preflight does: a harness whose subcommand differs must not be
518
 * able to block a fan-out that would have worked.
519
 */
520
export async function firstAvailableChildModel(
521
  command = "opencode",
522
  preferred: ReadonlyArray<string> = FREE_CHILD_MODELS,
523
): Promise<string | undefined> {
524
  const listed = await new Promise<string>((resolve) => {
525
    const probe = spawn(command, ["models"], { stdio: ["ignore", "pipe", "ignore"] });
526
    let out = "";
527
    probe.stdout.setEncoding("utf8");
528
    probe.stdout.on("data", (chunk: string) => {
529
      out += chunk;
530
    });
531
    probe.on("error", () => resolve(""));
532
    probe.on("close", () => resolve(out));
533
  });
534
  if (listed.trim().length === 0) return undefined;
535
  const names = new Set(listed.split("\n").map((line) => line.trim()));
536
  return preferred.find((candidate) => names.has(candidate));
537
}
538
495 539
export class OpencodeHarness implements DelegateHarness {
496 540
  readonly agent = "opencode";
497 541
  readonly model: string;
packages/openagents-cli/test/coder-child-gateway-grant.test.ts added +100

@@ -0,0 +1,100 @@

1
import { request as httpRequest } from "node:http";
2
3
import { Redacted } from "effect";
4
import { describe, expect, it, vi, afterEach } from "vitest";
5
6
import { startChildGateway, type ChildGrant } from "../src/coder-child-gateway.js";
7
8
const grant = (token: string): ChildGrant => ({
9
  proxyUrl: "https://openagents.test/api/inference/proxy",
10
  token: Redacted.make(token),
11
  model: "ox-alpha",
12
});
13
14
/**
15
 * Call the gateway over raw HTTP.
16
 *
17
 * Not `fetch`: these tests replace `fetch` to stand in for the proxy upstream,
18
 * and a client that also used it would be answered by the stand-in without the
19
 * gateway ever running.
20
 */
21
const call = async (baseUrl: string): Promise<number> => {
22
  const url = new URL(`${baseUrl}/chat/completions`);
23
  return await new Promise<number>((resolve, reject) => {
24
    const request = httpRequest(
25
      { hostname: url.hostname, port: url.port, path: url.pathname, method: "POST" },
26
      (response) => {
27
        response.resume();
28
        response.on("end", () => resolve(response.statusCode ?? 0));
29
      },
30
    );
31
    request.on("error", reject);
32
    request.end(JSON.stringify({ messages: [{ role: "user", content: "hi" }] }));
33
  });
34
};
35
36
afterEach(() => {
37
  vi.restoreAllMocks();
38
});
39
40
describe("a child gateway whose grant has expired", () => {
41
  it("mints another and sends the same request on it", async () => {
42
    const sentWith: string[] = [];
43
    vi.spyOn(globalThis, "fetch").mockImplementation(
44
      // eslint-disable-next-line @typescript-eslint/require-await -- a stand-in upstream
45
      async (_input, init) => {
46
        const auth = String((init?.headers as Record<string, string>)?.["authorization"] ?? "");
47
        sentWith.push(auth);
48
        // A grant minted an hour ago, then one minted moments ago.
49
        return auth.includes("stale")
50
          ? new Response(JSON.stringify({ error: { code: "grant_expired" } }), { status: 403 })
51
          : new Response(JSON.stringify({ ok: true }), { status: 200 });
52
      },
53
    );
54
55
    const gateway = await startChildGateway(grant("stale"), async () =>
56
      Promise.resolve(grant("fresh")),
57
    );
58
59
    const status = await call(gateway.baseUrl);
60
61
    // The child sees the answer, not the refusal: four children failing on a
62
    // credential minted at breakfast is the failure this exists to stop.
63
    expect(status).toBe(200);
64
    expect(sentWith.some((auth) => auth.includes("stale"))).toBe(true);
65
    expect(sentWith.some((auth) => auth.includes("fresh"))).toBe(true);
66
67
    await gateway.close();
68
  });
69
70
  it("reports the refusal when no fresh grant can be had", async () => {
71
    vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
72
      Promise.resolve(
73
        new Response(JSON.stringify({ error: { code: "grant_expired" } }), { status: 403 }),
74
      ),
75
    );
76
77
    const gateway = await startChildGateway(grant("stale"), async () => Promise.resolve(undefined));
78
79
    // Reported rather than retried forever.
80
    expect(await call(gateway.baseUrl)).toBe(403);
81
82
    await gateway.close();
83
  });
84
85
  it("does not retry a caller that cannot mint one", async () => {
86
    let calls = 0;
87
    vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
88
      calls += 1;
89
      return await Promise.resolve(
90
        new Response(JSON.stringify({ error: { code: "grant_expired" } }), { status: 403 }),
91
      );
92
    });
93
94
    const gateway = await startChildGateway(grant("stale"));
95
    await call(gateway.baseUrl);
96
97
    expect(calls).toBe(1);
98
    await gateway.close();
99
  });
100
});

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