@bridge4dev/runner 0.42.1 → 0.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -153,6 +153,16 @@ class CodexSession {
153
153
  threadId = null;
154
154
  threadModel = null;
155
155
  activeTurnId = null;
156
+ /**
157
+ * Did this turn put anything on the wire before it broke, and was any of it
158
+ * beyond taking back? (#252, #257)
159
+ *
160
+ * The Claude adapter counts the same two facts for the same reason: a failure
161
+ * that arrives after six rounds of tools cannot be answered by re-sending the
162
+ * turn. Reset in `beginTurnWork`, called wherever a turn starts.
163
+ */
164
+ turnProduced = false;
165
+ turnIrreversible = false;
156
166
  /**
157
167
  * Last turn this thread finished (ticket #126).
158
168
  *
@@ -1099,6 +1109,9 @@ class CodexSession {
1099
1109
  }
1100
1110
  case 'turn/started': {
1101
1111
  this.activeTurnId = str(params['turnId']) ?? str(asRecord(params['turn'])['id']) ?? null;
1112
+ // #252: Codex announces its turns, so this is the exact boundary. Any
1113
+ // later and one turn's «work was done» would veto every retry after it.
1114
+ this.beginTurnWork();
1102
1115
  return;
1103
1116
  }
1104
1117
  case 'turn/completed': {
@@ -1276,6 +1289,8 @@ class CodexSession {
1276
1289
  case 'commandExecution': {
1277
1290
  const command = str(item['command']) ?? '';
1278
1291
  if (!done) {
1292
+ // #252/#257: what separates «send the turn again» from «resume it».
1293
+ this.noteWork(command);
1279
1294
  this.emit({
1280
1295
  type: 'tool',
1281
1296
  phase: 'use',
@@ -1384,16 +1399,28 @@ class CodexSession {
1384
1399
  const usedPercent = num(window['usedPercent'] ?? window['used_percent']);
1385
1400
  if (usedPercent === undefined)
1386
1401
  continue;
1387
- const minutes = num(window['windowMinutes'] ?? window['window_minutes']) ?? null;
1388
- // Live 2026-08-15: Codex sent a percentage with NO length, so the label
1389
- // fell back to «Limit window» — true but useless. The slot itself carries
1390
- // the answer on every ChatGPT plan: `primary` is the short window,
1391
- // `secondary` the weekly one. Used only when the minutes are missing, so
1392
- // a provider that does state them still wins.
1402
+ // `windowDurationMins` is the name codex-cli 0.147.0 actually sends —
1403
+ // captured from a live `account/rateLimits/updated` on 2026-08-16:
1404
+ //
1405
+ // primary: {usedPercent: 4, windowDurationMins: 10080, resetsAt: 1787221437}
1406
+ // secondary: null
1407
+ //
1408
+ // The two older spellings are kept behind it, not in front: a Codex that
1409
+ // still sends them keeps working, and the current one stops being read as
1410
+ // «length unknown». That misreading is what made the panel label a WEEKLY
1411
+ // window «5 hours» — the number was right and the heading was not.
1412
+ const minutes = num(window['windowDurationMins'] ?? window['windowMinutes'] ?? window['window_minutes']) ??
1413
+ null;
1393
1414
  // Only when the length is ABSENT. A stated length we do not recognise is
1394
1415
  // information — 43 200 minutes is a monthly window, and calling it «5
1395
1416
  // hours» because it arrived first would be worse than admitting we have
1396
1417
  // no name for it.
1418
+ //
1419
+ // The slot fallback stays for exactly that case, but it is now the last
1420
+ // resort rather than the usual path. And note what the live payload shows:
1421
+ // `primary` is NOT reliably the short window. Here it is the weekly one
1422
+ // and `secondary` is null, so guessing by slot was wrong in the only case
1423
+ // we have ever observed.
1397
1424
  const key = minutes === null
1398
1425
  ? slot === 'primary'
1399
1426
  ? 'five_hour'
@@ -1403,7 +1430,12 @@ class CodexSession {
1403
1430
  key,
1404
1431
  windowMinutes: minutes,
1405
1432
  usedPercent: clampPercent(usedPercent),
1406
- resetsAt: str(window['resetsAt'] ?? window['resets_at']) ?? null,
1433
+ // Epoch SECONDS on the wire, not an ISO string — reading it with `str()`
1434
+ // returned undefined for every snapshot ever received, which is why no
1435
+ // Codex window has ever shown a reset time. The string form is still
1436
+ // accepted in case a future version switches. Same fact, two encodings,
1437
+ // one place that knows it — exactly as the Claude adapter already does.
1438
+ resetsAt: codexResetsAt(window['resetsAt'] ?? window['resets_at']),
1407
1439
  });
1408
1440
  }
1409
1441
  this.rateLimitWindows = windows.sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER));
@@ -1438,9 +1470,36 @@ class CodexSession {
1438
1470
  * calls a human instead. Guessing a time here is the one outcome worth
1439
1471
  * avoiding: waking early burns a retry and changes nothing.
1440
1472
  */
1473
+ /** A new turn is starting — forget what the previous one did (#252). */
1474
+ beginTurnWork() {
1475
+ this.turnProduced = false;
1476
+ this.turnIrreversible = false;
1477
+ }
1478
+ /**
1479
+ * Record a command as work that happened, and judge whether it can be undone.
1480
+ *
1481
+ * The same blunt list as the Claude adapter: writing git subcommands only.
1482
+ * Read-only git is excluded deliberately — the agent runs `git status` all day
1483
+ * and treating that as a write would suppress nearly every retry.
1484
+ */
1485
+ noteWork(command) {
1486
+ this.turnProduced = true;
1487
+ if (this.turnIrreversible)
1488
+ return;
1489
+ if (/\bgit\s+(commit|push|tag|merge|rebase|reset|revert|cherry-pick)\b/.test(command)) {
1490
+ this.turnIrreversible = true;
1491
+ }
1492
+ }
1441
1493
  rateLimitRefusal(error) {
1494
+ // The machine-readable cause FIRST. Codex 0.147.0 says `usageLimitExceeded`
1495
+ // — Exceeded, not Reached — so not one of the four substrings below ever
1496
+ // matched it, and the auto-pause never armed on this agent at all: a refused
1497
+ // turn went to FAILED instead of sleeping until the window reopened.
1498
+ // Demonstrated by test, 2026-08-16, not inferred.
1499
+ const code = str(asRecord(error)['codexErrorInfo']);
1442
1500
  const haystack = JSON.stringify(error).toLowerCase();
1443
- const refused = haystack.includes('ratelimitreached') ||
1501
+ const refused = code === 'usageLimitExceeded' ||
1502
+ haystack.includes('ratelimitreached') ||
1444
1503
  haystack.includes('rate_limit_reached') ||
1445
1504
  haystack.includes('usage_limit_reached') ||
1446
1505
  haystack.includes('usagelimitreached');
@@ -1472,6 +1531,13 @@ class CodexSession {
1472
1531
  const blocked = this.rateLimitRefusal(error);
1473
1532
  if (blocked)
1474
1533
  this.emitRateLimits(blocked);
1534
+ // #252: the machine-readable cause, beside the sentence. `codexErrorInfo`
1535
+ // is Codex's own enum (`usageLimitExceeded`, `httpConnectionFailed`,
1536
+ // `responseStreamConnectionFailed`, `contextWindowExceeded`, …) and it is
1537
+ // the ONLY thing allowed to open the door to an automatic retry — the
1538
+ // message next to it is prose, and prose can be written by agents.
1539
+ const failureCode = str(error['codexErrorInfo']);
1540
+ const failureStatus = error['httpStatusCode'];
1475
1541
  this.emit({
1476
1542
  type: 'turn_end',
1477
1543
  ok: false,
@@ -1480,6 +1546,10 @@ class CodexSession {
1480
1546
  // one line earlier would ring into a dead row and throw the person's
1481
1547
  // queued words away instead of sending them.
1482
1548
  ...(blocked ? { limitBlocked: true } : {}),
1549
+ ...(failureCode !== undefined ? { failureCode } : {}),
1550
+ ...(typeof failureStatus === 'number' ? { failureStatus } : {}),
1551
+ ...(this.turnProduced ? { produced: true } : {}),
1552
+ ...(this.turnIrreversible ? { irreversible: true } : {}),
1483
1553
  });
1484
1554
  return;
1485
1555
  }
@@ -1867,6 +1937,31 @@ function truncateRecord(value) {
1867
1937
  }
1868
1938
  return out;
1869
1939
  }
1940
+ /**
1941
+ * When a Codex window resets → ISO, or null if it did not say.
1942
+ *
1943
+ * Epoch SECONDS on the wire, captured live from codex-cli 0.147.0 on 2026-08-16:
1944
+ * `resetsAt: 1787221437` is 2026-08-20. Read as a string it was silently dropped,
1945
+ * which is why no Codex window has ever shown a reset time and why #258 had
1946
+ * nothing to schedule a wake-up from on this agent.
1947
+ *
1948
+ * The range check is not decoration. A clock built from a misread number is
1949
+ * worse than no clock at all: milliseconds mistaken for seconds would promise a
1950
+ * reset in the year 58600, and #258 would arm a timer on it. Outside the range,
1951
+ * we say we do not know — which is the honest answer and the safe one.
1952
+ */
1953
+ export function codexResetsAt(raw) {
1954
+ if (typeof raw === 'string' && raw) {
1955
+ const parsed = Date.parse(raw);
1956
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
1957
+ }
1958
+ if (typeof raw !== 'number' || !Number.isFinite(raw))
1959
+ return null;
1960
+ // 2001-09-09 … 2286-11-20 in epoch seconds.
1961
+ if (raw < 1_000_000_000 || raw > 9_999_999_999)
1962
+ return null;
1963
+ return new Date(raw * 1000).toISOString();
1964
+ }
1870
1965
  function stringifyMcpResult(item) {
1871
1966
  const errorMessage = str(asRecord(item['error'])['message']);
1872
1967
  if (errorMessage)
@@ -0,0 +1,178 @@
1
+ /**
2
+ * What a failed turn is allowed to do next — as DATA, not as branching (#252, #257).
3
+ *
4
+ * It lives beside `rate-limits.ts` and for the same reason: two CLIs describe the
5
+ * same class of accident in two vocabularies, a third provider is expected, and
6
+ * one table is easier to audit than two adapters' worth of `if`.
7
+ *
8
+ * ## How to read the table
9
+ *
10
+ * Every cause either CLI is known to report has its own row, including the ones
11
+ * that are never retried. Owner's instruction, 2026-08-16: the config should list
12
+ * them all with an explicit `retry: true | false`, so the answer to «why did the
13
+ * session not pick itself back up» is one line in one file rather than an absence.
14
+ *
15
+ * The dividing line is **whose fault it is**. Automatic retry is for failures that
16
+ * belong to the provider's own servers and have nothing to do with what the user
17
+ * asked for — a busy cluster, a 500, a dropped connection. Everything that depends
18
+ * on the account or the request — a dead key, an empty wallet, a refused policy, a
19
+ * context that no longer fits — is `retry: false` and stops the session, because
20
+ * asking again produces the same answer while burning tokens and, for policy and
21
+ * billing, sending a bad signal toward the account.
22
+ *
23
+ * ## The unit is the TURN, not the request
24
+ *
25
+ * The tempting table — «529 and 5xx are retryable» — is written for a single HTTP
26
+ * call. Ours is a turn: one turn is many calls, and by the time a 529 surfaces the
27
+ * agent may have run six rounds of tools, with a `git commit` and a `git push`
28
+ * already on disk. So a row here is a PERMISSION, never an instruction: the caller
29
+ * still has to ask «did this turn already do something» and narrow it. `refine()`
30
+ * below is that step, and it is not optional.
31
+ *
32
+ * ## Why codes and not sentences
33
+ *
34
+ * Both CLIs hand us a machine-readable cause and the runner used to throw it away.
35
+ * Sentences are not matched at all — see the note on `match`. We already have a
36
+ * scar from matching prose: `isAuthError` had to be strengthened because a bare
37
+ * `401` matched an MCP server's error and «unauthorized» contains «auth».
38
+ *
39
+ * ## Unknown means STOP
40
+ *
41
+ * A code that matches no row behaves exactly as it does today: the session stops
42
+ * and calls a human. The costs are wildly asymmetric — a wrong STOP costs one
43
+ * click, a wrong RETRY costs a second `git push`, a duplicated MCP write, tokens
44
+ * paid twice, and a transcript with duplicate tool_use ids (which is itself a 400
45
+ * that no retry can clear). It also makes an endless retry loop impossible by
46
+ * construction rather than by a counter someone might raise later.
47
+ */
48
+ /** Verified against Claude Code CLI 2.1.233 and codex-cli 0.147.0 on 2026-08-16. */
49
+ export type RetryBucket =
50
+ /** Nothing ran. The same turn may be sent again. */
51
+ 'retry'
52
+ /** Work was partly done. Resume it; never re-send the prompt. */
53
+ | 'continue'
54
+ /** A repeat cannot help. Stop and tell the person. */
55
+ | 'stop';
56
+ export type BackoffProfile = 'standard' | 'slow';
57
+ export interface ErrorRule {
58
+ /** Stable id — also the key the «same failure twice» guard compares. */
59
+ id: string;
60
+ provider: 'claude' | 'codex';
61
+ match: {
62
+ /** The provider's own machine-readable cause. */
63
+ codeIn?: readonly string[];
64
+ /** HTTP status, when the provider reported one. */
65
+ statusFrom?: number;
66
+ statusTo?: number;
67
+ };
68
+ /**
69
+ * The switch: may this cause EVER be retried automatically?
70
+ *
71
+ * `false` means today's behaviour — the session stops and asks for a person.
72
+ * Turning one on is a deliberate act with a reason written in `note`.
73
+ */
74
+ retry: boolean;
75
+ /** Read only when `retry` is true. */
76
+ bucket?: Exclude<RetryBucket, 'stop'>;
77
+ attempts?: number;
78
+ backoff?: BackoffProfile;
79
+ /**
80
+ * `false` → the row is INERT: counted, never acted on.
81
+ *
82
+ * How a provider we have not run yet gets into the table safely. Gemini and the
83
+ * Chinese providers are expected next, and several of them answer with HTTP 200
84
+ * and put the failure in the body, so their rows must not go live on a guess.
85
+ */
86
+ verified: boolean;
87
+ note: string;
88
+ }
89
+ /**
90
+ * The one place a provider is added, and the one place a retry is switched on.
91
+ *
92
+ * First match wins, so named causes precede status ranges.
93
+ */
94
+ export declare const ERROR_RULES: readonly ErrorRule[];
95
+ export interface FailureSignal {
96
+ provider: 'claude' | 'codex';
97
+ /**
98
+ * The provider's machine-readable cause — `assistant.error` for Claude,
99
+ * `codexErrorInfo` for Codex.
100
+ *
101
+ * **A decision can only start here or at `status`.** If both are absent the
102
+ * answer is `stop`, no matter what the text says.
103
+ */
104
+ code?: string | null;
105
+ /** HTTP status, when the provider reported one. */
106
+ status?: number | null;
107
+ /**
108
+ * The CLI's own sentence about the failure.
109
+ *
110
+ * Read in ONE direction: it can turn a `retry` into a `continue`, and it can
111
+ * never turn anything into a `retry`.
112
+ */
113
+ text?: string | null;
114
+ /** Did the turn emit anything at all before it broke — text, thinking, a tool call? */
115
+ produced?: boolean;
116
+ /** Did the turn run something a repeat cannot take back — git, or a write through MCP? */
117
+ irreversible?: boolean;
118
+ }
119
+ export interface RetryDecision {
120
+ bucket: RetryBucket;
121
+ attempts: number;
122
+ backoff: BackoffProfile;
123
+ /** Which row decided, or `null` when nothing matched and the default applied. */
124
+ ruleId: string | null;
125
+ }
126
+ /**
127
+ * What may this failure do next?
128
+ *
129
+ * First match wins. A row that is inert (`verified: false`) or switched off
130
+ * (`retry: false`) resolves to `stop` — but still reports its id, so the feed and
131
+ * the logs can say WHICH known cause stopped the session rather than «unknown».
132
+ */
133
+ export declare function classifyFailure(signal: FailureSignal): RetryDecision;
134
+ /**
135
+ * The ceiling no row can raise: total automatic retries in one session's life.
136
+ *
137
+ * Every other guard depends on the table being right. This one does not — it is
138
+ * arithmetic. If a code were misclassified, if a provider started answering with
139
+ * a cause we read wrongly, if two mechanisms both decided to retry, the session
140
+ * still cannot spin: after this many attempts it stops and asks for a person,
141
+ * exactly as it does today.
142
+ *
143
+ * Reset when a HUMAN sends a message, on the same reasoning as `clearAutoResume`:
144
+ * somebody typing into the session is the clearest possible evidence that the
145
+ * work is on track again.
146
+ */
147
+ export declare const MAX_RETRIES_PER_SESSION = 12;
148
+ /**
149
+ * Has this exact failure already been retried, and come straight back?
150
+ *
151
+ * Cheap insurance against a deterministic failure wearing a transient face — a
152
+ * context overflow surfacing as a 500, a malformed transcript that will fail
153
+ * identically forever. One repeat of the same row is enough to conclude the
154
+ * repeat is not helping, and spending the remaining attempts proves nothing.
155
+ */
156
+ export declare function isRepeatOfSameFailure(previousRuleId: string | null | undefined, decision: RetryDecision): boolean;
157
+ /**
158
+ * How long to wait before attempt `attempt` (1-based).
159
+ *
160
+ * standard: max(10s, U(0, min(300s, 30s · 2^(n-1))))
161
+ * slow: max(30s, U(0, min(600s, 60s · 2^(n-1))))
162
+ *
163
+ * Full jitter, with a floor. The floor matters: pure full jitter can collapse to
164
+ * nearly zero, and a zero-length wait before restarting a process is pointless —
165
+ * the process takes longer than that to come up.
166
+ *
167
+ * The base is tens of seconds rather than one, because our attempt is not an HTTP
168
+ * request: it is a process re-reading a conversation, costing seconds and input
169
+ * tokens. By the time we see the failure at all, the CLI has already run its own
170
+ * retry ladder — `api_retry` reports up to ten of them — so answering that with
171
+ * another try one second later would just be noise.
172
+ *
173
+ * Jitter is not decoration: one machine runs several sessions and there are many
174
+ * machines, all watching the same API. Without it a recovering provider gets a
175
+ * synchronised volley from the whole fleet.
176
+ */
177
+ export declare function retryDelayMs(profile: BackoffProfile, attempt: number, random?: () => number): number;
178
+ //# sourceMappingURL=error-policy.d.ts.map