@bridge4dev/runner 0.42.0 → 0.44.1

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.
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { log } from './log.js';
4
4
  import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
+ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryDelayMs, } from './adapters/error-policy.js';
5
6
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
6
7
  import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
7
8
  import { JournalStore } from './journal.js';
@@ -480,6 +481,10 @@ export class Supervisor {
480
481
  return LAUNCH_REFUSED;
481
482
  }
482
483
  running.lastPrompt = prompt;
484
+ // #252: a launch is also the start of a turn, so the retry path needs it too.
485
+ // Both are set here and only here they agree; `deliverMessage` moves the
486
+ // second one on its own afterwards.
487
+ running.lastTurnPrompt = prompt;
483
488
  // Facts about this session only, plus the one file the project named. The
484
489
  // rest of the project's documentation is read by each agent itself — see
485
490
  // `composeWorkspaceContext`.
@@ -897,6 +902,7 @@ export class Supervisor {
897
902
  // full disk, a daemon already shutting down) must not take the exit path
898
903
  // with it, because everything below it is cleanup.
899
904
  try {
905
+ this.clearApiRetry(running);
900
906
  this.withdrawOpenQuestions(running, 'session_stopped');
901
907
  }
902
908
  catch (error) {
@@ -1328,6 +1334,13 @@ export class Supervisor {
1328
1334
  });
1329
1335
  return;
1330
1336
  case 'turn_end': {
1337
+ // #252: decided BEFORE the frame goes out. A `turn_end{ok:false}` files
1338
+ // the session as FAILED, and FAILED is terminal — a session we intend to
1339
+ // retry must never be told it has ended. So a turn that is going to be
1340
+ // retried emits no `turn_end` at all; exactly one leaves this session,
1341
+ // when it finally succeeds or finally gives up.
1342
+ if (!event.ok && this.armApiRetry(running, descriptor, event))
1343
+ return;
1331
1344
  this.sendEvent(running, 'turn_end', {
1332
1345
  ok: event.ok,
1333
1346
  errorMessage: event.errorMessage,
@@ -1813,7 +1826,138 @@ export class Supervisor {
1813
1826
  * resolves them and a refusal puts exactly those records back in the queue —
1814
1827
  * the message is retired from disk only once an agent has it.
1815
1828
  */
1829
+ /**
1830
+ * Should this failed turn be retried by itself, and if so, arm it (#252, #257).
1831
+ *
1832
+ * Returns `true` when a retry is armed, and the caller must then emit NOTHING —
1833
+ * a `turn_end{ok:false}` files the session as FAILED, which is terminal, and a
1834
+ * session we intend to retry cannot be told it has ended.
1835
+ *
1836
+ * The owner's standing priority governs every branch here: «лучше, чтобы ретрай
1837
+ * не сработал, чем сработал там, где не нужно». So this reads as a list of
1838
+ * reasons to decline, and the permission is the last thing it reaches.
1839
+ */
1840
+ armApiRetry(running, descriptor, event) {
1841
+ // A refusal by the plan is not a failure to repeat — it is a pause measured
1842
+ // in hours, and the API has already armed a clock for it (#258). Retrying
1843
+ // into a spent limit changes nothing and would fight the pause.
1844
+ if (event.limitBlocked === true)
1845
+ return false;
1846
+ // The session is on its way out with a meaning of its own.
1847
+ if (running.stopRequested || running.budgetSpent || running.parkRequested)
1848
+ return false;
1849
+ if (Supervisor.isPaused(running))
1850
+ return false;
1851
+ // An open card means the agent asked the person something. Retrying would
1852
+ // orphan the card, and — worse — the suppressed `turn_end` is what clears the
1853
+ // API's open-ask counter, so the session would sit unanswerable.
1854
+ if (running.openQuestions.size > 0)
1855
+ return false;
1856
+ // The ceiling that does not depend on the table being right.
1857
+ if ((running.apiRetriesUsed ?? 0) >= MAX_RETRIES_PER_SESSION)
1858
+ return false;
1859
+ const decision = classifyFailure({
1860
+ provider: descriptor.agent === 'CODEX' ? 'codex' : 'claude',
1861
+ code: typeof event['failureCode'] === 'string' ? event['failureCode'] : null,
1862
+ status: typeof event['failureStatus'] === 'number' ? event['failureStatus'] : null,
1863
+ text: event.errorMessage ?? null,
1864
+ produced: event['produced'] === true,
1865
+ irreversible: event['irreversible'] === true,
1866
+ });
1867
+ if (decision.bucket === 'stop')
1868
+ return false;
1869
+ // Pinned to a local: the narrowing above does not survive into the timer's
1870
+ // closure, and widening it back to `RetryBucket` there would let a `stop`
1871
+ // through as if it were a permission.
1872
+ const bucket = decision.bucket;
1873
+ // The same failure came straight back. Spending the rest of the allowance on
1874
+ // it proves nothing — it is a deterministic fault wearing a transient face.
1875
+ const previous = running.apiRetry?.ruleId ?? null;
1876
+ const attempt = (running.apiRetry?.attempt ?? 0) + 1;
1877
+ if (attempt > 1 && isRepeatOfSameFailure(previous, decision)) {
1878
+ this.clearApiRetry(running);
1879
+ this.sendEvent(running, 'system_note', {
1880
+ code: 'agent_retry',
1881
+ text: 'The same failure came back after an automatic retry — stopping so a person can look.',
1882
+ });
1883
+ return false;
1884
+ }
1885
+ if (attempt > decision.attempts) {
1886
+ this.clearApiRetry(running);
1887
+ return false;
1888
+ }
1889
+ const delay = retryDelayMs(decision.backoff, attempt);
1890
+ const at = new Date(Date.now() + delay);
1891
+ const timer = setTimeout(() => {
1892
+ this.runApiRetry(running, bucket);
1893
+ }, delay);
1894
+ timer.unref();
1895
+ running.apiRetry = { timer, attempt, ruleId: decision.ruleId };
1896
+ running.apiRetriesUsed = (running.apiRetriesUsed ?? 0) + 1;
1897
+ // Supervisor-minted rather than an adapter notice: adapter notices are
1898
+ // de-duplicated for the session's whole life, so «attempt 2» would appear
1899
+ // once and never again (gotcha #148).
1900
+ this.sendEvent(running, 'system_note', {
1901
+ code: 'agent_retry',
1902
+ text: `${bucket === 'continue' ? 'The answer was cut off part-way' : 'The provider could not be reached'} — ` +
1903
+ `attempt ${attempt} of ${decision.attempts}, next at ${at.toISOString().slice(11, 16)} UTC. ` +
1904
+ 'This is a fault on the provider’s side, not in the task.',
1905
+ });
1906
+ this.reportStatus(descriptor.id, 'RUNNING', {});
1907
+ return true;
1908
+ }
1909
+ /** Fire an armed retry — re-checking, at FIRE time, everything that could have changed. */
1910
+ runApiRetry(running, bucket) {
1911
+ const armed = running.apiRetry;
1912
+ if (!armed)
1913
+ return;
1914
+ // Everything below changed while we waited, and every one of them means the
1915
+ // retry must not happen. Checked here rather than only at arm time: the wait
1916
+ // is minutes long, which is plenty of time for a person to press Stop, for a
1917
+ // budget to run out, or for a limit pause to start.
1918
+ if (running.stopRequested ||
1919
+ running.budgetSpent ||
1920
+ running.parkRequested ||
1921
+ Supervisor.isPaused(running) ||
1922
+ running.openQuestions.size > 0) {
1923
+ this.clearApiRetry(running);
1924
+ return;
1925
+ }
1926
+ if (!running.session) {
1927
+ // The process died while we waited. Relaunching is `launchAgent`'s job and
1928
+ // it needs a prompt; without one there is nothing honest to do here.
1929
+ this.clearApiRetry(running);
1930
+ this.reportStatus(running.descriptor.id, 'FAILED', {
1931
+ errorMessage: 'The agent process ended while waiting to retry',
1932
+ });
1933
+ return;
1934
+ }
1935
+ // Never a re-send of the prompt when work was already done: `continue` asks
1936
+ // the agent to look at what it did and carry on, which is exactly what the
1937
+ // owner used to type by hand. `auto-resume.ts` records the cost of getting
1938
+ // this backwards — a re-sent prompt ran a fifteen-minute command twice.
1939
+ const text = bucket === 'continue'
1940
+ ? 'The connection to the model dropped part-way through your previous answer — not by ' +
1941
+ 'anything you did, and nothing is wrong with the work. Check what you had already ' +
1942
+ 'finished before redoing any of it, then continue from where you stopped.'
1943
+ : (running.lastTurnPrompt ?? 'Continue.');
1944
+ running.session.send(text);
1945
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
1946
+ }
1947
+ /** Disarm a pending retry — always through here, so no timer is ever orphaned. */
1948
+ clearApiRetry(running) {
1949
+ if (!running.apiRetry)
1950
+ return;
1951
+ clearTimeout(running.apiRetry.timer);
1952
+ delete running.apiRetry;
1953
+ }
1816
1954
  deliverMessage(running, text, held = [], originSeq) {
1955
+ // #252: a person typing is the clearest possible evidence that the work is
1956
+ // back on track — the same reasoning `clearAutoResume` is built on. Their
1957
+ // words also supersede whatever we were about to retry, so any armed timer
1958
+ // is disarmed rather than left to fire into a conversation that has moved on.
1959
+ this.clearApiRetry(running);
1960
+ running.apiRetriesUsed = 0;
1817
1961
  // Older instructions are already waiting: send them together, in order.
1818
1962
  // Without this a message that CAN go now jumps the queue — and the ones it
1819
1963
  // jumped are stranded, because the drain only runs when a process exits
@@ -1844,6 +1988,11 @@ export class Supervisor {
1844
1988
  }
1845
1989
  };
1846
1990
  if (running.session && !running.parkRequested) {
1991
+ // #252: the words this turn is actually running. `lastPrompt` deliberately
1992
+ // stays put — three older latches relaunch a PROCESS with it, and handing
1993
+ // them a follow-up line instead of the task would start a fresh
1994
+ // conversation with «продолжай».
1995
+ running.lastTurnPrompt = text;
1847
1996
  running.session.send(text);
1848
1997
  settle();
1849
1998
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
@@ -2281,6 +2430,7 @@ export class Supervisor {
2281
2430
  running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
2282
2431
  }
2283
2432
  else {
2433
+ this.clearApiRetry(running);
2284
2434
  this.withdrawOpenQuestions(running, 'session_stopped');
2285
2435
  this.reportStatus(sessionId, 'STOPPED', { activeMs: running.activeMs });
2286
2436
  this.sessions.delete(sessionId);
@@ -2318,6 +2468,7 @@ export class Supervisor {
2318
2468
  running.session.stop('session_stopped'); // pumpEvents finishes the cleanup
2319
2469
  }
2320
2470
  else {
2471
+ this.clearApiRetry(running);
2321
2472
  this.withdrawOpenQuestions(running, 'session_stopped');
2322
2473
  this.sessions.delete(sessionId);
2323
2474
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.42.0";
1
+ export declare const RUNNER_VERSION = "0.44.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.42.0';
2
+ export const RUNNER_VERSION = '0.44.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.42.0",
3
+ "version": "0.44.1",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",