|
| 1 | +# Run Retry Bounds Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Bound non-quota retries for non-interactive `opencode run` to five retries and 120 seconds without changing global retry behavior. |
| 6 | + |
| 7 | +**Architecture:** Keep enforcement in `src/cli/cmd/run.ts`, where the CLI consumes `session.status.retry` and can abort a Session. Quota errors retain immediate abort and key rotation; other retry statuses use `attempt` and `next` for CLI-only bounds. |
| 8 | + |
| 9 | +**Tech Stack:** TypeScript, Bun test, Effect, OpenCode SDK v2. |
| 10 | + |
| 11 | +## Global Constraints |
| 12 | + |
| 13 | +- Apply retry bounds only to non-interactive `opencode run`; do not modify `SessionRetry.policy`. |
| 14 | +- Known quota errors abort immediately and remain eligible for key rotation. |
| 15 | +- Non-quota errors never rotate keys or write `throttle.json`. |
| 16 | +- Allow attempts 1 through 5; reject attempt 6 before it waits. |
| 17 | +- Abort a non-quota retry when `status.next` would exceed 120 seconds from retry attempt 1. |
| 18 | +- Text and JSON retry-bound errors use `OPENCODE_RETRY_LIMIT:`. |
| 19 | +- Run tests and `bun typecheck` from `packages/opencode`. |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +### Task 1: Add retry-limit process regressions |
| 24 | + |
| 25 | +**Files:** |
| 26 | +- Modify: `packages/opencode/test/cli/run/run-process-limit.test.ts` |
| 27 | + |
| 28 | +**Interfaces:** |
| 29 | +- Consumes: `opencode.run(message, { format?, timeoutMs? })` and TestLLMServer response helpers. |
| 30 | +- Produces: process assertions for retry count, retry deadline, text output, and JSON output. |
| 31 | + |
| 32 | +- [ ] **Step 1: Write failing retry-count tests** |
| 33 | + |
| 34 | +Queue five retryable 500 responses followed by text and assert exit code `0`. Queue six retryable 500 responses and assert nonzero exit, duration below 30 seconds, and `OPENCODE_RETRY_LIMIT:` in stderr. Set `retry-after-ms: 0` so the suite does not sleep. |
| 35 | + |
| 36 | +```ts |
| 37 | +const result = yield* opencode.run("say hi", { timeoutMs: 30_000 }) |
| 38 | +expect(result.exitCode).not.toBe(0) |
| 39 | +expect(result.stderr).toContain("OPENCODE_RETRY_LIMIT:") |
| 40 | +expect(result.durationMs).toBeLessThan(30_000) |
| 41 | +``` |
| 42 | + |
| 43 | +- [ ] **Step 2: Write failing deadline and JSON tests** |
| 44 | + |
| 45 | +Return a retryable response with `retry-after: 121` and assert immediate nonzero exit with `OPENCODE_RETRY_LIMIT:`. Add a JSON-format sixth-failure test that requires a prefixed `error.message` while retaining retry fields. |
| 46 | + |
| 47 | +```ts |
| 48 | +const events = opencode.parseJsonEvents(result.stdout) |
| 49 | +expect(events.find((event) => event.type === "error")?.error).toMatchObject({ |
| 50 | + type: "retry", |
| 51 | + message: expect.stringMatching(/^OPENCODE_RETRY_LIMIT:/), |
| 52 | + attempt: expect.any(Number), |
| 53 | + next: expect.any(Number), |
| 54 | +}) |
| 55 | +``` |
| 56 | + |
| 57 | +- [ ] **Step 3: Run tests and confirm red** |
| 58 | + |
| 59 | +Run: |
| 60 | + |
| 61 | +```sh |
| 62 | +bun test --test-name-pattern 'retry limit|Retry-After|format json' test/cli/run/run-process-limit.test.ts --timeout 60000 |
| 63 | +``` |
| 64 | + |
| 65 | +Expected: new marker and bounded-retry assertions fail because no retry-limit path exists. |
| 66 | + |
| 67 | +### Task 2: Bound CLI retry status handling |
| 68 | + |
| 69 | +**Files:** |
| 70 | +- Modify: `packages/opencode/src/cli/cmd/run.ts:35-55` |
| 71 | +- Modify: `packages/opencode/src/cli/cmd/run.ts:827-850` |
| 72 | + |
| 73 | +**Interfaces:** |
| 74 | +- Consumes: retry status `{ attempt, message, next }`, `SessionRetry.isQuotaOrRateLimitRetryStatus`, and `client.session.abort({ sessionID })`. |
| 75 | +- Produces: `OPENCODE_RETRY_LIMIT` text/JSON errors and bounded non-quota retries. |
| 76 | + |
| 77 | +- [ ] **Step 1: Add CLI-only constants and message helper** |
| 78 | + |
| 79 | +Add constants beside `quotaError` and use the existing error-payload pattern for both markers. |
| 80 | + |
| 81 | +```ts |
| 82 | +const RUN_RETRY_MAX_ATTEMPTS = 5 |
| 83 | +const RUN_RETRY_MAX_WAIT = 120_000 |
| 84 | + |
| 85 | +function retryLimitError(message: string) { |
| 86 | + return `OPENCODE_RETRY_LIMIT: ${message}` |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +- [ ] **Step 2: Track a non-quota retry deadline** |
| 91 | + |
| 92 | +Before `loop`, declare `let retryDeadline: number | undefined`. In a non-quota retry branch, reset it when `status.attempt === 1` to `Date.now() + RUN_RETRY_MAX_WAIT`. Abort before waiting when either condition is true: |
| 93 | + |
| 94 | +```ts |
| 95 | +status.attempt > RUN_RETRY_MAX_ATTEMPTS || status.next > retryDeadline |
| 96 | +``` |
| 97 | + |
| 98 | +On the limit, abort the session, produce `retryLimitError(status.message)`, emit the prefixed JSON error in JSON mode, print the same text in default mode, and return from the event loop. |
| 99 | + |
| 100 | +- [ ] **Step 3: Preserve quota ordering** |
| 101 | + |
| 102 | +Keep recognized quota handling before generic retry limiting. Do not set `pendingRotation` for generic retry-limit errors. |
| 103 | + |
| 104 | +```ts |
| 105 | +if (status.type === "retry" && SessionRetry.isQuotaOrRateLimitRetryStatus(status)) { |
| 106 | + // Existing abort, immediate exit, and key rotation path. |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +- [ ] **Step 4: Run focused tests and confirm green** |
| 111 | + |
| 112 | +Run: |
| 113 | + |
| 114 | +```sh |
| 115 | +bun test --test-name-pattern 'retry limit|Retry-After|format json' test/cli/run/run-process-limit.test.ts --timeout 60000 |
| 116 | +``` |
| 117 | + |
| 118 | +Expected: all new retry-limit tests pass. |
| 119 | + |
| 120 | +### Task 3: Verify quota and rotation contracts |
| 121 | + |
| 122 | +**Files:** |
| 123 | +- Verify: `packages/opencode/test/session/retry.test.ts` |
| 124 | +- Verify: `packages/opencode/test/cli/run/run-process-limit.test.ts` |
| 125 | +- Verify: `packages/opencode/test/cli/run/run-key-rotation.test.ts` |
| 126 | + |
| 127 | +**Interfaces:** |
| 128 | +- Consumes: existing quota classification, process, and key-rotation fixtures. |
| 129 | +- Produces: evidence that retry limits do not change quota exits or API-key rotation. |
| 130 | + |
| 131 | +- [ ] **Step 1: Run regression suites** |
| 132 | + |
| 133 | +Run: |
| 134 | + |
| 135 | +```sh |
| 136 | +bun test test/session/retry.test.ts --timeout 30000 |
| 137 | +bun test test/cli/run/run-process-limit.test.ts --timeout 60000 |
| 138 | +bun test test/cli/run/run-key-rotation.test.ts --timeout 60000 |
| 139 | +``` |
| 140 | + |
| 141 | +Expected: all tests pass, including quota exits, JSON quota markers, rotation, all-key exhaustion, and throttle-disabled rotation. |
| 142 | + |
| 143 | +- [ ] **Step 2: Format and typecheck** |
| 144 | + |
| 145 | +Run: |
| 146 | + |
| 147 | +```sh |
| 148 | +bunx prettier --write src/cli/cmd/run.ts test/cli/run/run-process-limit.test.ts |
| 149 | +bun typecheck |
| 150 | +git diff --check |
| 151 | +``` |
| 152 | + |
| 153 | +Expected: typecheck succeeds and whitespace check is clean. |
| 154 | + |
| 155 | +- [ ] **Step 3: Build and release verification** |
| 156 | + |
| 157 | +Run: |
| 158 | + |
| 159 | +```sh |
| 160 | +bun run script/build.ts --single --skip-install --skip-embed-web-ui |
| 161 | +PATH=/tmp/opencode-verify:$PATH timeout --signal=TERM 25s /usr/sbin/opencode-cheap run 'hi' |
| 162 | +PATH=/tmp/opencode-verify:$PATH timeout --signal=TERM 25s /usr/sbin/opencode-cheap run --format json 'hi' |
| 163 | +``` |
| 164 | + |
| 165 | +Expected: both commands return nonzero before timeout; text and JSON expose `OPENCODE_QUOTA_LIMIT:`. |
| 166 | + |
| 167 | +- [ ] **Step 4: Commit implementation** |
| 168 | + |
| 169 | +Run: |
| 170 | + |
| 171 | +```sh |
| 172 | +git add packages/opencode/src/cli/cmd/run.ts packages/opencode/test/cli/run/run-process-limit.test.ts |
| 173 | +git commit -s -S -m 'fix(cli): bound run retries' |
| 174 | +``` |
| 175 | + |
| 176 | +Expected: one signed commit containing only retry-limit implementation and tests. |
0 commit comments