Retry a child whose provider dropped, and show a budget only where there is one

2f9cb16d76e1 · AtlantisPleb · · parent f1eae73635e5

Retry a child whose provider dropped, and show a budget only where there is one

Two things a delegated child got wrong when OpenCode Zen went away mid-run.

It gave up. `APIError: Error from provider (Console): Upstream request failed:
Endpoint is unavailable.` arrived after six and a half minutes and twenty-five
tool calls, and all of it was thrown away. Nothing about the request was wrong.

It now retries, three attempts, backing off two seconds then four. Only for a
provider that went away — `transientProviderFailure/1` matches the shapes seen
in practice and refuses anything naming the request itself, because a false
positive re-runs a child that was never going to succeed and charges minutes
and money for the same answer.

The retry **resumes** rather than restarts. The event parser already read a
session id out of opencode's JSON and nothing consumed it; the fleet keeps it
and passes it back as `--session <id> --continue`, so the twenty-five tools
already run are not run again. That distinction is the whole safety argument: a
child that has edited files and is restarted from the prompt applies its work
twice. Where there is no session to resume and the child has already run a
tool, the retry is refused and the failure stands — losing the run is the
better of two bad outcomes.

Separately, the status line said `0 calls · 0 tok` once a thread's grant
stopped carrying ceilings (openagents.com e149874). `null` from the server
means unbounded and was being collapsed to zero by `number()`, so a session
with no limit read as one with nothing left — opposite facts printed the same
way. An absent ceiling is carried as `undefined`, is not decremented, and is
simply not shown: a thread with no ceilings now reads `$99.69` and nothing
else.

667 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.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/coder-delegate.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/test/coder-delegate.test.ts

Diff

3 files changed, +355 -47

packages/openagents-cli/src/coder-delegate.ts modified +163 -38

@@ -140,7 +140,20 @@ export interface DelegateHarness {

140 140
   * permissions, provider credentials — is the harness's business.
141 141
   */
142 142
  run(
143
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
143
    input: {
144
      readonly prompt: string;
145
      readonly cwd: string;
146
      readonly transcriptPath: string;
147
      /**
148
       * A session to continue rather than start.
149
       *
150
       * Set only on a retry, and only when the first attempt got far enough to
151
       * report one. Resuming is what makes a retry safe: a child that ran
152
       * twenty-five tools before its provider dropped has already edited files,
153
       * and starting it again from the prompt would redo all of it.
154
       */
155
      readonly resumeSessionId?: string | undefined;
156
    },
144 157
    signal: AbortSignal,
145 158
  ): AsyncIterable<DelegateEvent>;
146 159
}

@@ -396,7 +409,12 @@ export class DevinHarness implements DelegateHarness {

396 409
  }
397 410
398 411
  async *run(
399
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
412
    input: {
413
      readonly prompt: string;
414
      readonly cwd: string;
415
      readonly transcriptPath: string;
416
      readonly resumeSessionId?: string | undefined;
417
    },
400 418
    signal: AbortSignal,
401 419
  ): AsyncIterable<DelegateEvent> {
402 420
    const command = this.options.command ?? "devin";

@@ -662,7 +680,12 @@ export class OpencodeHarness implements DelegateHarness {

662 680
  }
663 681
664 682
  async *run(
665
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
683
    input: {
684
      readonly prompt: string;
685
      readonly cwd: string;
686
      readonly transcriptPath: string;
687
      readonly resumeSessionId?: string | undefined;
688
    },
666 689
    signal: AbortSignal,
667 690
  ): AsyncIterable<DelegateEvent> {
668 691
    const command = this.options.command ?? "opencode";

@@ -675,7 +698,19 @@ export class OpencodeHarness implements DelegateHarness {

675 698
676 699
    const args = ["run", "--format", "json", "--model", this.model, "--dir", input.cwd];
677 700
    if (this.options.autoApprove === true) args.push("--auto");
678
    args.push(input.prompt);
701
702
    if (input.resumeSessionId === undefined) {
703
      args.push(input.prompt);
704
    } else {
705
      // The work already done stays done. `--continue` picks the session back
706
      // up with its whole transcript, so the child carries on from where its
707
      // provider dropped rather than re-reading and re-editing everything.
708
      args.push("--session", input.resumeSessionId, "--continue");
709
      args.push(
710
        "The previous attempt stopped when the model provider became unavailable. " +
711
          "Continue from where you left off and finish the task.",
712
      );
713
    }
679 714
680 715
    const child = spawn(command, args, {
681 716
      cwd: input.cwd,

@@ -941,51 +976,102 @@ export class DelegateFleet {

941 976
942 977
    /** Tool calls already counted. A harness reports one call several times. */
943 978
    const counted = new Set<string>();
979
    /** The child's session, once it reports one, so a retry can resume it. */
980
    let sessionId: string | undefined;
944 981
    let text = "";
945
    let reported: string | undefined;
946 982
947
    try {
948
      for await (const event of this.harness.run(
949
        { prompt: request.prompt, cwd, transcriptPath },
950
        controller.signal,
951
      )) {
952
        if (controller.signal.aborted) break;
953
        if (event.type === "tool") {
954
          if (counted.has(event.callId)) continue;
955
          counted.add(event.callId);
956
          const activity: CoderToolActivity = { toolName: event.name, target: event.target };
957
          this.registry.recordToolUse(id, activity);
958
        } else if (event.type === "tokens") {
959
          this.registry.recordTokens(id, { input: event.input, output: event.output });
960
        } else if (event.type === "text") {
961
          // Only the final assistant text is the child's answer, and a harness
962
          // emits one text part per step, so the last one wins.
963
          text = event.value;
964
        } else if (event.type === "error") {
965
          reported = event.message;
983
    for (let attempt = 1; ; attempt += 1) {
984
      let reported: string | undefined;
985
      let thrown: string | undefined;
986
987
      try {
988
        for await (const event of this.harness.run(
989
          {
990
            prompt: request.prompt,
991
            cwd,
992
            transcriptPath,
993
            ...(sessionId === undefined ? {} : { resumeSessionId: sessionId }),
994
          },
995
          controller.signal,
996
        )) {
997
          if (controller.signal.aborted) break;
998
          if (event.type === "session") {
999
            sessionId = event.sessionId;
1000
          } else if (event.type === "tool") {
1001
            if (counted.has(event.callId)) continue;
1002
            counted.add(event.callId);
1003
            const activity: CoderToolActivity = { toolName: event.name, target: event.target };
1004
            this.registry.recordToolUse(id, activity);
1005
          } else if (event.type === "tokens") {
1006
            this.registry.recordTokens(id, { input: event.input, output: event.output });
1007
          } else if (event.type === "text") {
1008
            // Only the final assistant text is the child's answer, and a
1009
            // harness emits one text part per step, so the last one wins.
1010
            text = event.value;
1011
          } else if (event.type === "error") {
1012
            reported = event.message;
1013
          }
966 1014
        }
1015
      } catch (cause) {
1016
        thrown = cause instanceof Error ? cause.message : String(cause);
967 1017
      }
968 1018
969
      if (controller.signal.aborted) {
970
        return { status: "stopped", taskId: id };
971
      }
972
      if (reported !== undefined) {
973
        this.registry.fail(id, reported);
974
        return { status: "failed", taskId: id, error: reported };
1019
      if (controller.signal.aborted) return { status: "stopped", taskId: id };
1020
1021
      const failure = reported ?? thrown;
1022
      if (failure === undefined) {
1023
        this.registry.complete(id, text);
1024
        return { status: "completed", taskId: id, result: text };
975 1025
      }
976 1026
977
      this.registry.complete(id, text);
978
      return { status: "completed", taskId: id, result: text };
979
    } catch (cause) {
980
      if (controller.signal.aborted) {
981
        return { status: "stopped", taskId: id };
1027
      const retry = this.retryDelay(failure, attempt, sessionId, counted.size);
1028
      if (retry === undefined) {
1029
        this.registry.fail(id, failure);
1030
        return { status: "failed", taskId: id, error: failure };
982 1031
      }
983
      const message = cause instanceof Error ? cause.message : String(cause);
984
      this.registry.fail(id, message);
985
      return { status: "failed", taskId: id, error: message };
1032
1033
      this.registry.recordToolUse(id, {
1034
        toolName: "retry",
1035
        target: `provider unavailable, attempt ${String(attempt + 1)}`,
1036
      });
1037
1038
      await new Promise((wake) => setTimeout(wake, retry));
1039
      if (controller.signal.aborted) return { status: "stopped", taskId: id };
986 1040
    }
987 1041
  }
988 1042
1043
  /**
1044
   * How long to wait before running this child again, or `undefined` to stop.
1045
   *
1046
   * A provider that went away is the one failure worth retrying: nothing about
1047
   * the request was wrong, and the six minutes of work the child had already
1048
   * done are thrown away with it. Everything else — a refused permission, a
1049
   * missing model, a prompt the child could not carry out — recurs on the
1050
   * second attempt exactly as it did on the first.
1051
   *
1052
   * A retry that cannot resume the child's session is refused once the child
1053
   * has run a tool. That child has edited files, and starting it again from
1054
   * the prompt would apply its work twice; losing the run is the better of two
1055
   * bad outcomes. With a session to continue, the work already done stays
1056
   * done and the retry is safe.
1057
   */
1058
  private retryDelay(
1059
    failure: string,
1060
    attempt: number,
1061
    sessionId: string | undefined,
1062
    toolsRun: number,
1063
  ): number | undefined {
1064
    const attempts = 3;
1065
    if (attempt >= attempts) return undefined;
1066
    if (!transientProviderFailure(failure)) return undefined;
1067
    if (sessionId === undefined && toolsRun > 0) return undefined;
1068
1069
    // Exponential, from two seconds. A provider that has just dropped is
1070
    // usually back within a few, and a child is not a keystroke — waiting eight
1071
    // seconds to save six minutes of work is not a wait anyone notices.
1072
    return 2_000 * 2 ** (attempt - 1);
1073
  }
1074
989 1075
  private mintId(): CoderTaskId {
990 1076
    // Time first so ids sort in launch order, then a counter so two children
991 1077
    // launched in the same millisecond cannot collide.

@@ -1007,3 +1093,42 @@ function numberField(record: Record<string, unknown>, key: string): number | und

1007 1093
  const value = record[key];
1008 1094
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1009 1095
}
1096
1097
/**
1098
 * Whether this failure is the provider going away rather than the work being
1099
 * wrong.
1100
 *
1101
 * Matched on the message because that is all a harness gives: `opencode`
1102
 * reports its provider's error as text, and the shape seen in practice is
1103
 * `APIError: Error from provider (Console): Upstream request failed: Endpoint
1104
 * is unavailable.` The list is deliberately narrow. A false positive re-runs a
1105
 * child that was never going to succeed, which costs minutes and money for the
1106
 * same answer.
1107
 */
1108
export function transientProviderFailure(message: string): boolean {
1109
  const text = message.toLowerCase();
1110
1111
  // A refusal that names the request is not the provider being away, however
1112
  // much of the surrounding wording matches.
1113
  if (/\b(invalid|unauthorized|forbidden|not found|unsupported|quota)\b/.test(text)) return false;
1114
1115
  return [
1116
    "endpoint is unavailable",
1117
    "upstream request failed",
1118
    "service unavailable",
1119
    "temporarily unavailable",
1120
    "overloaded",
1121
    "rate limit",
1122
    "too many requests",
1123
    "connection reset",
1124
    "connection refused",
1125
    "socket hang up",
1126
    "econnreset",
1127
    "etimedout",
1128
    "gateway timeout",
1129
    "bad gateway",
1130
    "502",
1131
    "503",
1132
    "504",
1133
  ].some((needle) => text.includes(needle));
1134
}
packages/openagents-cli/src/coder-thread.ts modified +33 -9

@@ -112,9 +112,10 @@ export const boundedResult = (output: string): string => bounded(output, TOOL_RE

112 112
113 113
/** What the thread may still spend, as the server last reported it. */
114 114
export interface ThreadBudget {
115
  readonly calls: number;
116
  readonly totalTokens: number;
117
  readonly costMicrousd: number;
115
  /** `undefined` where the server set no ceiling: there is nothing counting down. */
116
  readonly calls: number | undefined;
117
  readonly totalTokens: number | undefined;
118
  readonly costMicrousd: number | undefined;
118 119
}
119 120
120 121
export interface ThreadOptions {

@@ -876,9 +877,15 @@ export class ThreadReplySource implements ReplySource {

876 877
   */
877 878
  private spend(usage: Record<string, unknown>): void {
878 879
    const total = number(usage["total_tokens"]);
880
    // A ceiling that was never set has no remainder to decrement. Counting one
881
    // down from `undefined` would have invented a limit the server does not
882
    // hold, and shown it running out.
879 883
    this.remaining = {
880
      calls: Math.max(0, this.remaining.calls - 1),
881
      totalTokens: Math.max(0, this.remaining.totalTokens - total),
884
      calls: this.remaining.calls === undefined ? undefined : Math.max(0, this.remaining.calls - 1),
885
      totalTokens:
886
        this.remaining.totalTokens === undefined
887
          ? undefined
888
          : Math.max(0, this.remaining.totalTokens - total),
882 889
      costMicrousd: this.remaining.costMicrousd,
883 890
    };
884 891
    // The same report feeds the turn's own tally, which `turn.assistant`

@@ -1032,10 +1039,14 @@ function budgetOf(

1032 1039
  remaining: Record<string, unknown>,
1033 1040
  limits: Record<string, unknown>,
1034 1041
): ThreadBudget {
1042
  // `null` from the server means unbounded, and is carried as `undefined`
1043
  // rather than collapsed to zero. A ceiling of nothing left and a ceiling that
1044
  // was never set are opposite facts, and `number()` would have printed both as
1045
  // `0 calls` — a session with no limit reading as one with none remaining.
1035 1046
  return {
1036
    calls: number(remaining["calls"] ?? limits["max_calls"]),
1037
    totalTokens: number(remaining["total_tokens"] ?? limits["max_total_tokens"]),
1038
    costMicrousd: number(remaining["cost_microusd"] ?? limits["max_cost_microusd"]),
1047
    calls: optional(remaining["calls"] ?? limits["max_calls"]),
1048
    totalTokens: optional(remaining["total_tokens"] ?? limits["max_total_tokens"]),
1049
    costMicrousd: optional(remaining["cost_microusd"] ?? limits["max_cost_microusd"]),
1039 1050
  };
1040 1051
}
1041 1052

@@ -1048,7 +1059,20 @@ function budgetOf(

1048 1059
 * expensive model runs into instead.
1049 1060
 */
1050 1061
export function formatBudget(budget: ThreadBudget): string {
1051
  return `${budget.calls} calls · ${compact(budget.totalTokens)} tok · ${dollars(budget.costMicrousd)}`;
1062
  // Only the ceilings that exist. A thread with none shows nothing here, which
1063
  // is the honest reading of a session nothing is counting down.
1064
  const parts = [
1065
    budget.calls === undefined ? undefined : `${String(budget.calls)} calls`,
1066
    budget.totalTokens === undefined ? undefined : `${compact(budget.totalTokens)} tok`,
1067
    budget.costMicrousd === undefined ? undefined : dollars(budget.costMicrousd),
1068
  ].filter((part): part is string => part !== undefined);
1069
1070
  return parts.join(" · ");
1071
}
1072
1073
/** A number the server gave, or `undefined` where it gave `null` for "no limit". */
1074
function optional(value: unknown): number | undefined {
1075
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1052 1076
}
1053 1077
1054 1078
function compact(tokens: number): string {
packages/openagents-cli/test/coder-delegate.test.ts modified +159

@@ -7,6 +7,7 @@ import {

7 7
  describePrompt,
8 8
  parseDelegateCommand,
9 9
  parseOpencodeEvent,
10
  transientProviderFailure,
10 11
} from "../src/coder-delegate.js";
11 12
import {
12 13
  activityPhrase,

@@ -442,3 +443,161 @@ describe("latestActivities", () => {

442 443
    ]);
443 444
  });
444 445
});
446
447
describe("classifying a child's failure", () => {
448
  it("recognises the provider going away, in the shape opencode reports it", () => {
449
    // The message seen in practice, verbatim.
450
    expect(
451
      transientProviderFailure(
452
        "APIError: Error from provider (Console): Upstream request failed: Endpoint is unavailable.",
453
      ),
454
    ).toBe(true);
455
  });
456
457
  it("recognises the other ways a provider drops", () => {
458
    for (const message of [
459
      "503 Service Unavailable",
460
      "Provider overloaded, try again",
461
      "Rate limit exceeded",
462
      "socket hang up",
463
      "ECONNRESET",
464
      "502 Bad Gateway",
465
    ]) {
466
      expect(transientProviderFailure(message)).toBe(true);
467
    }
468
  });
469
470
  it("does not retry a failure that will recur", () => {
471
    for (const message of [
472
      "Permission denied by the user",
473
      "Model not found: nonsense/model",
474
      "The prompt could not be carried out",
475
      "Unauthorized: invalid API key",
476
      // Wording that overlaps a transient shape but names the request.
477
      "Quota exceeded: too many requests this month",
478
    ]) {
479
      expect(transientProviderFailure(message)).toBe(false);
480
    }
481
  });
482
});
483
484
describe("retrying a child whose provider dropped", () => {
485
  /** A harness that fails the first `failures` runs, then succeeds. */
486
  const flaky = (
487
    failures: number,
488
    message: string,
489
    options: { readonly session?: string; readonly toolsBeforeFailing?: number } = {},
490
  ) => {
491
    const runs: Array<string | undefined> = [];
492
    let seen = 0;
493
494
    const built: DelegateHarness = {
495
      agent: "fake",
496
      model: "fake/model",
497
      async *run(input) {
498
        runs.push(input.resumeSessionId);
499
        seen += 1;
500
        if (options.session !== undefined) {
501
          yield { type: "session", sessionId: options.session };
502
        }
503
        for (let index = 0; index < (options.toolsBeforeFailing ?? 0); index += 1) {
504
          yield { type: "tool", callId: `c${String(seen)}-${String(index)}`, name: "read", target: "f" };
505
        }
506
        if (seen <= failures) {
507
          yield { type: "error", message };
508
          return;
509
        }
510
        yield { type: "text", value: "finished" };
511
      },
512
    };
513
514
    return { harness: built, runs };
515
  };
516
517
  const fleetFor = (built: DelegateHarness) =>
518
    new DelegateFleet(new CoderTaskRegistry(), built, {
519
      maxConcurrent: 1,
520
      cwd: mkdtempSync(join(tmpdir(), "oa-retry-")),
521
    });
522
523
  it("resumes the session rather than starting the work again", async () => {
524
    const { harness: built, runs } = flaky(1, "Endpoint is unavailable", {
525
      session: "ses_abc",
526
      toolsBeforeFailing: 3,
527
    });
528
529
    const outcome = await fleetFor(built).submit({
530
      description: "flaky",
531
      prompt: "do the thing",
532
      cwd: ".",
533
      background: false,
534
    });
535
536
    expect(outcome.status).toBe("completed");
537
    // The retry carried the session, so the three tools already run are not
538
    // run again from the prompt.
539
    expect(runs).toEqual([undefined, "ses_abc"]);
540
  }, 20_000);
541
542
  it("gives up after a bounded number of attempts", async () => {
543
    const { harness: built, runs } = flaky(9, "Endpoint is unavailable", { session: "ses_abc" });
544
545
    const outcome = await fleetFor(built).submit({
546
      description: "always failing",
547
      prompt: "do the thing",
548
      cwd: ".",
549
      background: false,
550
    });
551
552
    expect(outcome.status).toBe("failed");
553
    expect(runs).toHaveLength(3);
554
  }, 20_000);
555
556
  it("does not retry a failure that will recur", async () => {
557
    const { harness: built, runs } = flaky(1, "Permission denied by the user", {
558
      session: "ses_abc",
559
    });
560
561
    const outcome = await fleetFor(built).submit({
562
      description: "refused",
563
      prompt: "do the thing",
564
      cwd: ".",
565
      background: false,
566
    });
567
568
    expect(outcome.status).toBe("failed");
569
    expect(runs).toHaveLength(1);
570
  });
571
572
  it("refuses to redo work it cannot resume", async () => {
573
    // No session reported, and the child already ran tools: re-running from
574
    // the prompt would apply its edits a second time.
575
    const { harness: built, runs } = flaky(1, "Endpoint is unavailable", {
576
      toolsBeforeFailing: 2,
577
    });
578
579
    const outcome = await fleetFor(built).submit({
580
      description: "unresumable",
581
      prompt: "do the thing",
582
      cwd: ".",
583
      background: false,
584
    });
585
586
    expect(outcome.status).toBe("failed");
587
    expect(runs).toHaveLength(1);
588
  });
589
590
  it("retries a child that had done nothing yet, session or not", async () => {
591
    const { harness: built, runs } = flaky(1, "Endpoint is unavailable");
592
593
    const outcome = await fleetFor(built).submit({
594
      description: "nothing done",
595
      prompt: "do the thing",
596
      cwd: ".",
597
      background: false,
598
    });
599
600
    expect(outcome.status).toBe("completed");
601
    expect(runs).toHaveLength(2);
602
  }, 20_000);
603
});

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