@bridge4dev/runner 0.58.2 → 0.60.0

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.
@@ -11,7 +11,7 @@ import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
13
13
  import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
14
- import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
14
+ import { assertClaudeInstalled, claudeExecutableOption, sessionClaudePath, } from '../agent-binary.js';
15
15
  /** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
16
16
  const STDERR_TAIL_LIMIT = 2048;
17
17
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
@@ -340,10 +340,21 @@ class ClaudeSession {
340
340
  * has no such field. Without stashing it, the supervisor would be left with a
341
341
  * sentence — and a decision made from a sentence can be made by prose (#252).
342
342
  *
343
- * Reset per turn in `endTaskTurn`, which already owns the turn epoch.
343
+ * Reset when a turn BEGINS — `beginTurnFacts`, reached from `resumingTurn`
344
+ * for every turn this runner starts and from the boundary guard in the read
345
+ * loop for one the CLI started by itself (#406). The docblock used to say
346
+ * `endTaskTurn` owns the epoch; it never has, and reading it that way is what
347
+ * made «a code from this turn» and «a code from some turn» look identical.
344
348
  */
345
349
  turnFailureCode = null;
346
350
  turnFailureStatus = null;
351
+ /**
352
+ * A `result` has gone out and no new message has arrived since (#406).
353
+ *
354
+ * The marker the read loop uses to tell a turn the CLI opened by itself from
355
+ * a second `result` for the turn that just ended.
356
+ */
357
+ turnClosed = false;
347
358
  /**
348
359
  * Did this turn put ANYTHING on the wire — text, thinking, or a tool call?
349
360
  *
@@ -1798,10 +1809,17 @@ class ClaudeSession {
1798
1809
  // as work. The id is forgotten when that task settles instead, which is the
1799
1810
  // moment it stops being able to come back.
1800
1811
  this.turnEpoch += 1;
1801
- // Straight through `flushTasks` rather than an empty frame of its own: the
1802
- // counters changed, so the fingerprint differs and it will publish — and
1803
- // what it publishes is the truth about what is still running.
1812
+ // Straight through `flushTasks` rather than an empty frame of its own, and
1813
+ // with the de-duplication disarmed for this one frame (plan
1814
+ // `workflow-mode-fixes` S1 p.10). The turn's counters usually changed, but
1815
+ // not always: a task started in an earlier turn and still running, and a
1816
+ // turn that started nothing new, produce exactly the frame the last turn
1817
+ // ended on — and the end of a turn is the moment the API and the tray most
1818
+ // need to hear what is still running, whether or not it is news. The
1819
+ // supervisor de-duplicates the COUNT on its own side; this frame is the
1820
+ // tray's freshness, not the database's.
1804
1821
  this.taskPublishedAt = 0;
1822
+ this.lastTaskFingerprint = '';
1805
1823
  this.flushTasks();
1806
1824
  }
1807
1825
  /**
@@ -2219,6 +2237,25 @@ class ClaudeSession {
2219
2237
  async consume() {
2220
2238
  try {
2221
2239
  for await (const msg of this.q) {
2240
+ /**
2241
+ * A turn the CLI started by itself begins here (#406).
2242
+ *
2243
+ * `beginTurnFacts` has exactly one other caller, `resumingTurn`, and it
2244
+ * covers every turn THIS runner opens. The CLI opens turns too — a
2245
+ * background subagent finishing wakes one, and its `result` carries
2246
+ * `origin.kind: 'task-notification'` — and those used to inherit the
2247
+ * previous turn's `turnFailureCode`, `turnProduced` and
2248
+ * `turnIrreversible`, which is how a long-healed stream cut could still
2249
+ * be the reason attached to a turn an hour later.
2250
+ *
2251
+ * The guard is «a message that is not another `result`», not «the turn
2252
+ * ended»: one Stop can produce two results (see the stop-cycle tests),
2253
+ * and the second one has to see the same facts as the first.
2254
+ */
2255
+ if (this.turnClosed && msg.type !== 'result') {
2256
+ this.turnClosed = false;
2257
+ this.beginTurnFacts();
2258
+ }
2222
2259
  // Ticket #126: where we are in the transcript, remembered BEFORE the
2223
2260
  // message is interpreted.
2224
2261
  //
@@ -2419,7 +2456,50 @@ class ClaudeSession {
2419
2456
  // once, for exactly one result.
2420
2457
  const aborted = this.aborting;
2421
2458
  this.aborting = false;
2422
- const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
2459
+ /**
2460
+ * The CLI's own word for why it stopped — and the one signal that
2461
+ * it gave up (#406).
2462
+ *
2463
+ * `subtype` alone is not that signal: on 12.09.2026 Claude Code
2464
+ * wrote «API Error: Connection lost mid-response», ended the turn,
2465
+ * and stamped the result `subtype: "success"` with
2466
+ * `terminal_reason: "api_error"` beside it. The runner read the
2467
+ * first field, reported a clean turn, and the session stood for
2468
+ * 5 h 17 min with «your turn» on it.
2469
+ *
2470
+ * Deliberately NOT the accumulated `turnFailureCode`: Claude Code
2471
+ * survives most stream cuts by itself, and a code left over from
2472
+ * one it survived would fail three healthy turns an hour (the
2473
+ * correction on §492, ticket #406's second comment).
2474
+ */
2475
+ const terminalReason = typeof msg.terminal_reason === 'string'
2476
+ ? msg.terminal_reason
2477
+ : null;
2478
+ /**
2479
+ * The other half of the turn boundary above (#406).
2480
+ *
2481
+ * The guard in the read loop forgets the last turn on the first
2482
+ * message that is not a `result` — which a turn the CLI woke and
2483
+ * that said NOTHING never sends. Its only message is its own
2484
+ * closing `result`, and `origin.kind` is the field the SDK marks it
2485
+ * with; #373 already treats that field as the one thing that tells a
2486
+ * genuinely new internal turn from a second ending of the last one.
2487
+ * Without this, a silent internal turn would report the `produced`
2488
+ * and `irreversible` of whatever ran before it.
2489
+ */
2490
+ const originKind = typeof msg.origin?.kind === 'string'
2491
+ ? msg.origin.kind
2492
+ : null;
2493
+ if (this.turnClosed && originKind === 'task-notification') {
2494
+ this.turnClosed = false;
2495
+ this.beginTurnFacts();
2496
+ }
2497
+ const gaveUp = msg.subtype === 'success' && terminalReason === 'api_error';
2498
+ const failure = gaveUp
2499
+ ? API_ERROR_TURN_MESSAGE
2500
+ : msg.subtype === 'success'
2501
+ ? ''
2502
+ : classifyError(msg.subtype, msg.errors);
2423
2503
  // #373, plan stage D. What the incident could not answer: the second
2424
2504
  // result's origin was never recorded, so «is this the same turn
2425
2505
  // twice or a turn the CLI started by itself» had no evidence either
@@ -2445,10 +2525,13 @@ class ClaudeSession {
2445
2525
  code: 'rewind_failed',
2446
2526
  });
2447
2527
  }
2448
- else if (msg.subtype === 'success' || aborted) {
2449
- // A turn the user stopped is not a failed turn. Reporting it as
2450
- // one moved the session to FAILED, which is terminal — pressing
2451
- // Stop cost people the session they meant to keep.
2528
+ else if (aborted || (msg.subtype === 'success' && !gaveUp)) {
2529
+ // `aborted` is tested FIRST, and stays ahead of `gaveUp`: a turn
2530
+ // the user stopped is not a failed turn whatever the CLI writes
2531
+ // beside it, and an abort can land on top of an API error.
2532
+ // Reporting a stop as a failure moved the session to FAILED,
2533
+ // which is terminal — pressing Stop cost people the session they
2534
+ // meant to keep.
2452
2535
  this.emit({
2453
2536
  type: 'turn_end',
2454
2537
  ok: true,
@@ -2480,7 +2563,17 @@ class ClaudeSession {
2480
2563
  // the closed enum rides on the assistant message, this `result`
2481
2564
  // has no such field. Handed over so the supervisor can decide
2482
2565
  // from a code rather than from the sentence in `errorMessage`.
2483
- ...(this.turnFailureCode !== null ? { failureCode: this.turnFailureCode } : {}),
2566
+ //
2567
+ // #406: a turn the CLI gave up on always carries a code, even
2568
+ // when nothing in it named one — `server_error` is what «their
2569
+ // side, try again» is spelled as, and without it `classifyFailure`
2570
+ // would have only the sentence to go on, which is the thing #252
2571
+ // took away from it.
2572
+ ...(this.turnFailureCode !== null
2573
+ ? { failureCode: this.turnFailureCode }
2574
+ : gaveUp
2575
+ ? { failureCode: 'server_error' }
2576
+ : {}),
2484
2577
  ...(this.turnFailureStatus !== null
2485
2578
  ? { failureStatus: this.turnFailureStatus }
2486
2579
  : {}),
@@ -2489,6 +2582,11 @@ class ClaudeSession {
2489
2582
  });
2490
2583
  this.refreshUsage();
2491
2584
  }
2585
+ // #406: the facts above have been reported, so the next message —
2586
+ // whoever starts the turn it belongs to — may forget them. Armed
2587
+ // here rather than cleared here, because a second `result` for this
2588
+ // same ending has to read exactly what the first one did.
2589
+ this.turnClosed = true;
2492
2590
  break;
2493
2591
  }
2494
2592
  default:
@@ -2664,6 +2762,15 @@ function stringifyContent(content) {
2664
2762
  }
2665
2763
  return content === undefined ? '' : JSON.stringify(content);
2666
2764
  }
2765
+ /**
2766
+ * What the feed says about a turn Claude Code gave up on (#406).
2767
+ *
2768
+ * Deliberately free of «mid-response» and «may be incomplete»: `refine()` in
2769
+ * `error-policy.ts` matches those two phrases and narrows a retry to a single
2770
+ * «carry on» attempt. Here the narrowing must come from `produced`, which is a
2771
+ * fact about the turn, and not from the wording of a sentence.
2772
+ */
2773
+ const API_ERROR_TURN_MESSAGE = 'The connection to the model failed and Claude Code ended the turn on an API error';
2667
2774
  function classifyError(subtype, errors) {
2668
2775
  // Optional on purpose despite the SDK's type: a `result` without `errors`
2669
2776
  // threw from inside the event pump, which the loop's catch turned into a
@@ -2738,6 +2845,23 @@ export class ClaudeAdapter {
2738
2845
  this.queryFn = queryFn;
2739
2846
  }
2740
2847
  startSession(spec) {
2848
+ /**
2849
+ * «There is no Claude here» is answered once, and here (#395).
2850
+ *
2851
+ * BEFORE the session object exists, and that placement is the point: the
2852
+ * constructor writes the MCP config — a 0600 file holding this workspace's
2853
+ * live key — before it builds `Options`, and nothing removes that file for
2854
+ * a constructor that threw. The supervisor turns this throw into one red
2855
+ * line in the feed (`launchCrashed`) and a FAILED session, which is the
2856
+ * honest answer when the agent is not on the machine.
2857
+ *
2858
+ * Only for the real SDK. An adapter built on an injected `query` is a test
2859
+ * or a probe: it spawns nothing, so the fallback this guards against cannot
2860
+ * happen — and asking anyway would fail the whole suite on every machine
2861
+ * without the CLI, CI included. That is how the 0.56.0 release broke.
2862
+ */
2863
+ if (this.queryFn === query)
2864
+ assertClaudeInstalled();
2741
2865
  return new ClaudeSession(spec, this.queryFn);
2742
2866
  }
2743
2867
  }
@@ -62,13 +62,54 @@ export declare const USE_BUNDLED_CLAUDE = false;
62
62
  * the tests also need the real behaviour of; production always takes the default.
63
63
  */
64
64
  export declare function sessionClaudePath(useBundled?: boolean): string | null;
65
+ /**
66
+ * What every caller says when Claude Code is not on this machine (#395).
67
+ *
68
+ * Word for word the API's own refusal (`assertAgentAvailable` in
69
+ * `dev-sessions.service.ts`): the two halves answer the same question about the
70
+ * same binary, and a person who meets both — the API when it can see the
71
+ * measurement, this one when it cannot — should not have to work out whether
72
+ * they are being told two different things.
73
+ */
74
+ export declare const CLAUDE_NOT_INSTALLED = "Claude Code is not installed on this server \u2014 install it there, or pick another agent";
75
+ /** Thrown where a run would otherwise start on a binary nobody chose. */
76
+ export declare class ClaudeNotInstalledError extends Error {
77
+ constructor();
78
+ }
79
+ /**
80
+ * Refuse, in words, when a real Claude run has nothing legitimate to start.
81
+ *
82
+ * ## The hole this closes (#395, decision D14)
83
+ *
84
+ * With the bundled binary switched off and no system `claude`,
85
+ * `claudeExecutableOption` has nothing to pin and returns `{}`. To the SDK an
86
+ * absent `pathToClaudeCodeExecutable` does not mean «there is no Claude» — it
87
+ * means «resolve your own». So the machine either ran sessions on a SECOND
88
+ * Claude of a different version while its card said «not installed» (five
89
+ * sessions on `vmi3024903`, one of them $93), or — on a machine installed the
90
+ * standard way, where `--omit=optional` means the bundled package is not there
91
+ * at all — died inside the SDK with `Native CLI binary for linux-x64 not found`
92
+ * before its first word.
93
+ *
94
+ * ## Why it is a separate call and not a throw from `claudeExecutableOption`
95
+ *
96
+ * Both places that build `Options` do so eagerly, including in tests, where the
97
+ * SDK is replaced by a fake `query` and no binary is ever spawned. A throw from
98
+ * the option builder would therefore fail the whole suite on any machine
99
+ * without the CLI — CI among them, which is exactly how the 0.56.0 release
100
+ * broke. The callers below ask this question only on the path that reaches the
101
+ * REAL SDK.
102
+ */
103
+ export declare function assertClaudeInstalled(useBundled?: boolean,
104
+ /** Where the binary is looked up. Production always takes the default. */
105
+ resolve?: (useBundled: boolean) => string | null): void;
65
106
  /**
66
107
  * The SDK option that pins the executable, for both places that build `Options`.
67
108
  *
68
- * Empty while the bundled binary is in use: the SDK then resolves its own, which
69
- * is exactly today's behaviour and keeps `main` neutral. Empty also when the
70
- * system binary is missing — passing a path we know is not there would turn a
71
- * clear «Claude is not installed» into an SDK spawn error.
109
+ * Empty while the bundled binary is in use: the SDK then resolves its own,
110
+ * which is exactly what `USE_BUNDLED_CLAUDE = true` asks for. Empty also when
111
+ * there is no system binary — and that emptiness is no longer allowed to reach
112
+ * the real SDK: `assertClaudeInstalled` is asked first, on both paths.
72
113
  */
73
114
  export declare function claudeExecutableOption(useBundled?: boolean): {
74
115
  pathToClaudeCodeExecutable?: string;
@@ -137,13 +137,62 @@ export const USE_BUNDLED_CLAUDE = false;
137
137
  export function sessionClaudePath(useBundled = USE_BUNDLED_CLAUDE) {
138
138
  return useBundled ? claudeCliPath() : whichExecutable(AGENT_RUNTIMES.claude.bin);
139
139
  }
140
+ /**
141
+ * What every caller says when Claude Code is not on this machine (#395).
142
+ *
143
+ * Word for word the API's own refusal (`assertAgentAvailable` in
144
+ * `dev-sessions.service.ts`): the two halves answer the same question about the
145
+ * same binary, and a person who meets both — the API when it can see the
146
+ * measurement, this one when it cannot — should not have to work out whether
147
+ * they are being told two different things.
148
+ */
149
+ export const CLAUDE_NOT_INSTALLED = 'Claude Code is not installed on this server — install it there, or pick another agent';
150
+ /** Thrown where a run would otherwise start on a binary nobody chose. */
151
+ export class ClaudeNotInstalledError extends Error {
152
+ constructor() {
153
+ super(CLAUDE_NOT_INSTALLED);
154
+ this.name = 'ClaudeNotInstalledError';
155
+ }
156
+ }
157
+ /**
158
+ * Refuse, in words, when a real Claude run has nothing legitimate to start.
159
+ *
160
+ * ## The hole this closes (#395, decision D14)
161
+ *
162
+ * With the bundled binary switched off and no system `claude`,
163
+ * `claudeExecutableOption` has nothing to pin and returns `{}`. To the SDK an
164
+ * absent `pathToClaudeCodeExecutable` does not mean «there is no Claude» — it
165
+ * means «resolve your own». So the machine either ran sessions on a SECOND
166
+ * Claude of a different version while its card said «not installed» (five
167
+ * sessions on `vmi3024903`, one of them $93), or — on a machine installed the
168
+ * standard way, where `--omit=optional` means the bundled package is not there
169
+ * at all — died inside the SDK with `Native CLI binary for linux-x64 not found`
170
+ * before its first word.
171
+ *
172
+ * ## Why it is a separate call and not a throw from `claudeExecutableOption`
173
+ *
174
+ * Both places that build `Options` do so eagerly, including in tests, where the
175
+ * SDK is replaced by a fake `query` and no binary is ever spawned. A throw from
176
+ * the option builder would therefore fail the whole suite on any machine
177
+ * without the CLI — CI among them, which is exactly how the 0.56.0 release
178
+ * broke. The callers below ask this question only on the path that reaches the
179
+ * REAL SDK.
180
+ */
181
+ export function assertClaudeInstalled(useBundled = USE_BUNDLED_CLAUDE,
182
+ /** Where the binary is looked up. Production always takes the default. */
183
+ resolve = sessionClaudePath) {
184
+ if (useBundled)
185
+ return;
186
+ if (!resolve(useBundled))
187
+ throw new ClaudeNotInstalledError();
188
+ }
140
189
  /**
141
190
  * The SDK option that pins the executable, for both places that build `Options`.
142
191
  *
143
- * Empty while the bundled binary is in use: the SDK then resolves its own, which
144
- * is exactly today's behaviour and keeps `main` neutral. Empty also when the
145
- * system binary is missing — passing a path we know is not there would turn a
146
- * clear «Claude is not installed» into an SDK spawn error.
192
+ * Empty while the bundled binary is in use: the SDK then resolves its own,
193
+ * which is exactly what `USE_BUNDLED_CLAUDE = true` asks for. Empty also when
194
+ * there is no system binary — and that emptiness is no longer allowed to reach
195
+ * the real SDK: `assertClaudeInstalled` is asked first, on both paths.
147
196
  */
148
197
  export function claudeExecutableOption(useBundled = USE_BUNDLED_CLAUDE) {
149
198
  if (useBundled)
@@ -56,6 +56,10 @@ export declare function ensureGitExclude(worktreePath: string): Promise<void>;
56
56
  * Returns what actually landed — a file that could not be fetched is reported
57
57
  * and skipped rather than failing the whole message, because the text the user
58
58
  * typed is usually still worth delivering.
59
+ *
60
+ * Each file gets up to `DOWNLOAD_ATTEMPTS` goes inside ONE `DOWNLOAD_TIMEOUT_MS`
61
+ * budget (#407): the ceiling on how long a person waits for their own message is
62
+ * unchanged, and the attempts share it rather than each getting their own.
59
63
  */
60
64
  export declare function saveAttachments(input: {
61
65
  worktreePath: string;
@@ -63,6 +67,8 @@ export declare function saveAttachments(input: {
63
67
  token: string;
64
68
  attachments: RemoteAttachment[];
65
69
  fetchImpl?: typeof fetch;
70
+ /** Test seam: the pauses between attempts, so a suite does not sit through them. */
71
+ sleepImpl?: (ms: number) => Promise<void>;
66
72
  }): Promise<{
67
73
  saved: SavedAttachment[];
68
74
  failed: string[];
@@ -44,6 +44,105 @@ const MAX_ATTACHMENT_BYTES = 51 * 1024 * 1024;
44
44
  * generous is nil: a stalled download fails the same way, just later.
45
45
  */
46
46
  const DOWNLOAD_TIMEOUT_MS = 180_000;
47
+ /**
48
+ * How many times one file is fetched before the message goes without it (#407).
49
+ *
50
+ * One attempt was the whole defect: on 13.09.2026 a 656 KB screenshot died on
51
+ * `ECONNRESET` half-way through the body — the API had answered 200 and the
52
+ * proxy had written all 656 604 bytes — and the person got a line in the feed
53
+ * instead of their screenshot. Re-fetching the same file from the same machine
54
+ * reproduced it about 5 times in 65, so the second attempt is very nearly free
55
+ * and the third is the one that covers a bad minute.
56
+ *
57
+ * Three and not more because of the far end: `RATE_LIMIT.DEV_RUNNER_FILE` is 60
58
+ * requests a minute per runner and a message carries at most
59
+ * `DEV_SESSION_MESSAGE_ATTACHMENT_CAP` = 5 files, so the worst message on this
60
+ * path costs 15 — a quarter of the budget, with the rest left for the other
61
+ * sessions on the machine.
62
+ */
63
+ const DOWNLOAD_ATTEMPTS = 3;
64
+ /**
65
+ * What to wait before attempt 2 and before attempt 3.
66
+ *
67
+ * Short on purpose: a person is watching a message they just sent, and the
68
+ * fault this retries is a dropped connection rather than a busy server — the
69
+ * one case that genuinely needs a long wait (429) brings its own number below.
70
+ */
71
+ const RETRY_PAUSES_MS = [1_000, 3_000];
72
+ /**
73
+ * The longest a `Retry-After` may hold up one file.
74
+ *
75
+ * The header is honoured because a 429 is the far end asking for room, and
76
+ * capped because it is allowed to say «600» — which would spend the whole
77
+ * budget below on waiting and deliver the message without the file anyway.
78
+ */
79
+ const MAX_RETRY_AFTER_MS = 10_000;
80
+ /** `Retry-After` in seconds or as an HTTP date, clamped — or nothing usable. */
81
+ function retryAfterMs(response, now) {
82
+ const header = response.headers.get('retry-after');
83
+ if (!header)
84
+ return undefined;
85
+ const seconds = Number(header.trim());
86
+ const ms = Number.isFinite(seconds)
87
+ ? seconds * 1_000
88
+ : Number.isNaN(Date.parse(header))
89
+ ? NaN
90
+ : Date.parse(header) - now;
91
+ if (!Number.isFinite(ms) || ms <= 0)
92
+ return undefined;
93
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
94
+ }
95
+ /**
96
+ * Fetch one attachment once, and say whether the failure is worth repeating.
97
+ *
98
+ * Retried: anything `fetch` throws (a refused connection, a reset, a timeout)
99
+ * and anything thrown while reading the body — the incident was in the body, not
100
+ * in the response — plus 5xx and 429, which are the far end saying «not now».
101
+ *
102
+ * Not retried: every other 4xx (the file is gone, or this token may not have
103
+ * it), and a file over the ceiling, which will be over it again.
104
+ */
105
+ async function fetchAttachmentOnce(doFetch, url, token, budgetMs) {
106
+ let response;
107
+ try {
108
+ response = await doFetch(url, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ signal: AbortSignal.timeout(budgetMs),
111
+ });
112
+ }
113
+ catch (error) {
114
+ return { ok: false, retry: true, reason: String(error) };
115
+ }
116
+ if (!response.ok) {
117
+ const retry = response.status >= 500 || response.status === 429;
118
+ return {
119
+ ok: false,
120
+ retry,
121
+ reason: `HTTP ${response.status}`,
122
+ ...(response.status === 429
123
+ ? (() => {
124
+ const after = retryAfterMs(response, Date.now());
125
+ return after === undefined ? {} : { retryAfterMs: after };
126
+ })()
127
+ : {}),
128
+ };
129
+ }
130
+ let buffer;
131
+ try {
132
+ buffer = Buffer.from(await response.arrayBuffer());
133
+ }
134
+ catch (error) {
135
+ return { ok: false, retry: true, reason: String(error) };
136
+ }
137
+ if (buffer.length > MAX_ATTACHMENT_BYTES) {
138
+ return {
139
+ ok: false,
140
+ retry: false,
141
+ reason: `file is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB`,
142
+ };
143
+ }
144
+ return { ok: true, buffer };
145
+ }
47
146
  /**
48
147
  * A file name that is safe to write and unambiguous to read.
49
148
  *
@@ -134,9 +233,18 @@ export async function ensureGitExclude(worktreePath) {
134
233
  * Returns what actually landed — a file that could not be fetched is reported
135
234
  * and skipped rather than failing the whole message, because the text the user
136
235
  * typed is usually still worth delivering.
236
+ *
237
+ * Each file gets up to `DOWNLOAD_ATTEMPTS` goes inside ONE `DOWNLOAD_TIMEOUT_MS`
238
+ * budget (#407): the ceiling on how long a person waits for their own message is
239
+ * unchanged, and the attempts share it rather than each getting their own.
137
240
  */
138
241
  export async function saveAttachments(input) {
139
242
  const doFetch = input.fetchImpl ?? fetch;
243
+ const sleep = input.sleepImpl ??
244
+ ((ms) => new Promise((resolve) => {
245
+ const timer = setTimeout(resolve, ms);
246
+ timer.unref?.();
247
+ }));
140
248
  const dir = path.join(input.worktreePath, ATTACHMENT_DIR);
141
249
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
142
250
  await ensureGitExclude(input.worktreePath);
@@ -144,18 +252,59 @@ export async function saveAttachments(input) {
144
252
  const failed = [];
145
253
  const base = input.apiUrl.replace(/\/$/, '');
146
254
  for (const attachment of input.attachments) {
147
- try {
148
- const response = await doFetch(`${base}/api/v1/dev/runner/attachments/${encodeURIComponent(attachment.id)}`, {
149
- headers: { Authorization: `Bearer ${input.token}` },
150
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
151
- });
152
- if (!response.ok) {
153
- throw new Error(`HTTP ${response.status}`);
255
+ const url = `${base}/api/v1/dev/runner/attachments/${encodeURIComponent(attachment.id)}`;
256
+ const deadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
257
+ let buffer = null;
258
+ let lastReason = 'unknown';
259
+ /** What actually happened, for the summary line — not the ceiling. */
260
+ let made = 0;
261
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
262
+ const budget = deadline - Date.now();
263
+ if (budget <= 0) {
264
+ lastReason = 'the time allowed for this file ran out';
265
+ break;
154
266
  }
155
- const buffer = Buffer.from(await response.arrayBuffer());
156
- if (buffer.length > MAX_ATTACHMENT_BYTES) {
157
- throw new Error(`file is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB`);
267
+ made += 1;
268
+ const outcome = await fetchAttachmentOnce(doFetch, url, input.token, budget);
269
+ if (outcome.ok) {
270
+ buffer = outcome.buffer;
271
+ break;
158
272
  }
273
+ lastReason = outcome.reason;
274
+ // Every attempt is in the log with its number, so «it worked the second
275
+ // time» is visible afterwards rather than being a silent success.
276
+ log.warn('attachments: download attempt failed', {
277
+ attachmentId: attachment.id,
278
+ attempt,
279
+ of: DOWNLOAD_ATTEMPTS,
280
+ willRetry: outcome.retry && attempt < DOWNLOAD_ATTEMPTS,
281
+ error: outcome.reason,
282
+ });
283
+ if (!outcome.retry || attempt === DOWNLOAD_ATTEMPTS)
284
+ break;
285
+ const pause = Math.min(outcome.retryAfterMs ?? RETRY_PAUSES_MS[attempt - 1] ?? 0,
286
+ // Never wait past the budget: the wait would be the whole of what is
287
+ // left and the attempt it buys would have no time to run.
288
+ Math.max(0, deadline - Date.now()));
289
+ if (pause > 0)
290
+ await sleep(pause);
291
+ }
292
+ if (buffer === null) {
293
+ // One line per FILE at the end, next to the line the session feed gets.
294
+ log.warn('attachments: download failed', {
295
+ attachmentId: attachment.id,
296
+ // What was actually tried, and the ceiling beside it. Printing the
297
+ // ceiling alone said «3 attempts» for a 404 that was asked once — and
298
+ // «did the retry run on this file» is the one question this whole
299
+ // change exists to let somebody answer from the log.
300
+ attempts: made,
301
+ of: DOWNLOAD_ATTEMPTS,
302
+ error: lastReason,
303
+ });
304
+ failed.push(attachment.fileName);
305
+ continue;
306
+ }
307
+ try {
159
308
  const name = safeAttachmentName(attachment.id, attachment.fileName);
160
309
  fs.writeFileSync(path.join(dir, name), buffer, { mode: 0o600 });
161
310
  saved.push({
@@ -166,7 +315,9 @@ export async function saveAttachments(input) {
166
315
  });
167
316
  }
168
317
  catch (error) {
169
- log.warn('attachments: download failed', {
318
+ // The bytes are here and the disk refused them — not something a repeat
319
+ // of the download would mend.
320
+ log.warn('attachments: could not write the file', {
170
321
  attachmentId: attachment.id,
171
322
  error: String(error),
172
323
  });
@@ -1,5 +1,5 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { claudeExecutableOption } from './agent-binary.js';
2
+ import { assertClaudeInstalled, claudeExecutableOption } from './agent-binary.js';
3
3
  import { scrubbedEnv } from './adapters/claude.js';
4
4
  import { gitBranchDiff } from './gitops.js';
5
5
  import { isSecretPath, maskString } from './policy.js';
@@ -149,6 +149,13 @@ export async function proposeCommitMessage(input, queryFn = query) {
149
149
  }
150
150
  let text = '';
151
151
  try {
152
+ // The second place a real Claude run starts (#395). Same question as
153
+ // `ClaudeAdapter.startSession`, asked for the same reason and only of the
154
+ // real SDK: without it this run would reach for whatever binary the SDK
155
+ // resolves by itself. Inside the `try`, so the answer is the ordinary
156
+ // `{ ok: false, error }` this function already returns.
157
+ if (queryFn === query)
158
+ assertClaudeInstalled();
152
159
  const run = queryFn({
153
160
  prompt: buildPrompt(input, diff),
154
161
  options: {
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { claimCageAuthority, runSystemctl } from './cage-authority.js';
13
13
  import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
14
14
  import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
15
15
  import { log } from './log.js';
16
- import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
16
+ import { installIsWritable, installPrefixFor, isSupervisedProcess, restartCapability, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
17
17
  import { applyStoredClaudeToken } from './agent-auth.js';
18
18
  import { Supervisor } from './supervisor.js';
19
19
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
@@ -175,6 +175,21 @@ function runnerCapabilities(apiUrlOverride) {
175
175
  ...(selfUpdatable()
176
176
  ? { selfUpdate: true }
177
177
  : { selfUpdateBlocked: selfUpdateBlockedReason() ?? 'unsupervised' }),
178
+ /**
179
+ * 0.60.0: can be told to restart itself (#396), the same contract shape as
180
+ * the pair above.
181
+ *
182
+ * The condition is `isSupervisedProcess()` ALONE, and deliberately not
183
+ * `selfUpdatable()`: replacing the package needs an installed, writable npm
184
+ * package, but restarting needs only something that will start us again —
185
+ * a source checkout under systemd (this dogfood box) restarts perfectly
186
+ * well and must get the button.
187
+ *
188
+ * Announced rather than inferred from the version, because an older runner
189
+ * drops a command it cannot parse WITHOUT answering, and the dashboard
190
+ * would then offer a button that hangs until the gateway gives up.
191
+ */
192
+ ...restartCapability(),
178
193
  /**
179
194
  * Which OS user this daemon runs as (0.24.0).
180
195
  *
@@ -526,6 +541,12 @@ function runnerCapabilities(apiUrlOverride) {
526
541
  // это чтение, а не установка, и машину, чей владелец запретил ставить из
527
542
  // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
528
543
  'agent_versions_refresh',
544
+ // #396. Listed unconditionally, unlike the `restart` flag above: the
545
+ // flag is what the dashboard draws a button from, and this list is what
546
+ // `runCommand` dispatches on. A machine that cannot restart still
547
+ // ANSWERS the command, with a sentence saying why — which is a better
548
+ // outcome than a frame nobody replies to.
549
+ 'runner_restart',
529
550
  ],
530
551
  };
531
552
  }
@@ -911,6 +932,20 @@ async function cmdDaemon() {
911
932
  CLAUDE: new ClaudeAdapter(),
912
933
  ...(codex ? { CODEX: codex } : {}),
913
934
  },
935
+ /**
936
+ * #395: an agent installed from the dashboard is usable without a restart.
937
+ *
938
+ * The map above is built once, from what was on PATH when this process
939
+ * started — the same frozen snapshot `capabilities.agents` was. Claude does
940
+ * not need this (its adapter is unconditional and asks for the binary at
941
+ * session start), Codex does: its adapter is only built when `codex` was
942
+ * already there, so pressing «Install Codex» used to leave a machine that
943
+ * the card called ready and every session on which died at once.
944
+ *
945
+ * `hasExecutable` is asked HERE, at the moment of the question, which is
946
+ * the whole point.
947
+ */
948
+ makeAdapter: (agent) => agent === 'CODEX' && hasExecutable('codex') ? bootstrapCodex(config) : null,
914
949
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
915
950
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
916
951
  /**
@@ -961,6 +996,22 @@ async function cmdDaemon() {
961
996
  }, RESTART_DELAY_MS);
962
997
  timer.unref();
963
998
  },
999
+ // #396: the same exit, asked for directly rather than as the tail of an
1000
+ // update. The delay is the same and for the same reason — the answer has
1001
+ // to leave the socket before the process does, or the card shows a timeout
1002
+ // for a restart that is happening.
1003
+ onRestartCommanded: (note) => {
1004
+ log.info('daemon: restarting on a command from the dashboard', {
1005
+ sessions: supervisor.activeSessionIds.length,
1006
+ told: Boolean(note),
1007
+ });
1008
+ const timer = setTimeout(() => {
1009
+ supervisor.shutdown();
1010
+ ws.stop();
1011
+ process.exit(0);
1012
+ }, RESTART_DELAY_MS);
1013
+ timer.unref();
1014
+ },
964
1015
  });
965
1016
  const updateStatus = () => writeStatusFile({
966
1017
  pid: process.pid,
@@ -1276,7 +1327,13 @@ async function runnerChecks() {
1276
1327
  // /root/.local — a directory the daemon's user cannot write — and
1277
1328
  // the install died naming a home nobody had chosen. `sudo -iu` hands
1278
1329
  // the string to the TARGET user's login shell, so `$HOME` is theirs.
1279
- fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
1330
+ // `--omit=optional` is not cosmetic here (#395): without it npm pulls
1331
+ // the SDK's bundled Claude — 215 MB and a SECOND Claude of a
1332
+ // different version on the machine — and every other install path in
1333
+ // the product omits it (`dev-runner-install.sh`, `self-update.ts`).
1334
+ // This one line was how a hand-repaired machine ended up with a
1335
+ // binary no card could account for.
1336
+ fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --omit=optional --loglevel=error @bridge4dev/runner'`,
1280
1337
  fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
1281
1338
  }),
1282
1339
  });
@@ -116,6 +116,28 @@ export declare function installIsWritable(packageDir?: string | null): boolean;
116
116
  * that would work is recoverable, disappearing from the user's server is not.
117
117
  */
118
118
  export declare function isSupervisedProcess(env?: NodeJS.ProcessEnv): boolean;
119
+ /**
120
+ * The `hello` pair that decides whether the card draws a «Restart runner»
121
+ * button (#396, plan `workflow-stage-restart` R13).
122
+ *
123
+ * The same shape as `selfUpdate` / `selfUpdateBlocked`, and for the same
124
+ * reason: an older runner drops a command it cannot parse without answering, so
125
+ * the dashboard must gate on an announced capability rather than on a version.
126
+ *
127
+ * Only `isSupervisedProcess()`, and NOT `selfUpdatable()`: replacing the
128
+ * package needs an installed, writable npm package, restarting needs only
129
+ * something that will start us again. A daemon run from a source checkout under
130
+ * systemd — the dogfood box — restarts perfectly well and must get the button.
131
+ *
132
+ * Lives here rather than beside the other capabilities in `index.ts` because
133
+ * that file exports nothing and is the CLI entry point: a key spelled wrong
134
+ * there is caught by nobody, and `capabilities` is an untyped bag on both ends.
135
+ */
136
+ export declare function restartCapability(env?: NodeJS.ProcessEnv): {
137
+ restart: true;
138
+ } | {
139
+ restartBlocked: 'unsupervised';
140
+ };
119
141
  /**
120
142
  * Same origin as the API we are paired with, and it really is a tarball.
121
143
  *
@@ -148,6 +148,28 @@ export function installIsWritable(packageDir = resolveInstalledPackageDir()) {
148
148
  export function isSupervisedProcess(env = process.env) {
149
149
  return typeof env['INVOCATION_ID'] === 'string' && env['INVOCATION_ID'].length > 0;
150
150
  }
151
+ /**
152
+ * The `hello` pair that decides whether the card draws a «Restart runner»
153
+ * button (#396, plan `workflow-stage-restart` R13).
154
+ *
155
+ * The same shape as `selfUpdate` / `selfUpdateBlocked`, and for the same
156
+ * reason: an older runner drops a command it cannot parse without answering, so
157
+ * the dashboard must gate on an announced capability rather than on a version.
158
+ *
159
+ * Only `isSupervisedProcess()`, and NOT `selfUpdatable()`: replacing the
160
+ * package needs an installed, writable npm package, restarting needs only
161
+ * something that will start us again. A daemon run from a source checkout under
162
+ * systemd — the dogfood box — restarts perfectly well and must get the button.
163
+ *
164
+ * Lives here rather than beside the other capabilities in `index.ts` because
165
+ * that file exports nothing and is the CLI entry point: a key spelled wrong
166
+ * there is caught by nobody, and `capabilities` is an untyped bag on both ends.
167
+ */
168
+ export function restartCapability(env = process.env) {
169
+ // The literal string matters: the dashboard renders the explanatory sentence
170
+ // for exactly this value and silently draws nothing for any other.
171
+ return isSupervisedProcess(env) ? { restart: true } : { restartBlocked: 'unsupervised' };
172
+ }
151
173
  /**
152
174
  * Same origin as the API we are paired with, and it really is a tarball.
153
175
  *
@@ -39,6 +39,20 @@ export interface SupervisorOptions {
39
39
  * reports the new version only after the next manual restart.
40
40
  */
41
41
  onRestartRequested?: (outcome: SelfUpdateOutcome) => void;
42
+ /**
43
+ * Called after a `runner_restart` the machine agreed to, once the reply is on
44
+ * the wire (#396). Separate from `onRestartRequested` rather than folded into
45
+ * it: that one is typed on `SelfUpdateOutcome` and its handler logs the
46
+ * version it is restarting INTO, which a plain restart has no answer for.
47
+ */
48
+ onRestartCommanded?: (note: string | null) => void;
49
+ /**
50
+ * Build an adapter for an agent that was not on PATH when the daemon started
51
+ * (#395). Returning `null` means «still not here», and the session is refused
52
+ * in the same words as before. Only the entry point can supply this: it owns
53
+ * the config the adapters are built from.
54
+ */
55
+ makeAdapter?: (agent: 'CLAUDE' | 'CODEX') => AgentAdapter | null;
42
56
  /**
43
57
  * Local ceiling on concurrent agent processes, from the runner's own config
44
58
  * (layer 1). When set it wins over the API's number if it is stricter — the
@@ -210,7 +224,30 @@ export interface SupervisorOptions {
210
224
  readScopeHold?: (unit: string) => ScopeHold | null;
211
225
  /** #398 S4: the heartbeat for the memory frame. Real one is a minute. */
212
226
  sessionLimitsHeartbeatMs?: number;
227
+ /**
228
+ * S1 of plan `workflow-mode-fixes`: how often a resting session re-asserts
229
+ * a background count it still holds — a test seam over
230
+ * `BACKGROUND_TASKS_HEARTBEAT_MS`. Real one is five minutes.
231
+ */
232
+ backgroundTasksHeartbeatMs?: number;
213
233
  }
234
+ /**
235
+ * How often a resting session says «I still hold N background tasks» — the
236
+ * same `session_status` frame, the same status, sent again (plan
237
+ * `workflow-mode-fixes` S1 p.9).
238
+ *
239
+ * The count is a level the API stamps on arrival, and every reader over there
240
+ * — the workflow gate, its reminder, the «Agents» badge, the Inbox — believes
241
+ * it for an hour from that stamp. A task that simply runs (a forty-minute
242
+ * build) changes nothing in the tray, so nothing else would ever send the
243
+ * count again, and the platform would stop believing a live task after an
244
+ * hour. This is the frame that keeps it believed for as long as it is true.
245
+ *
246
+ * Mirrored by value in `@devbridge/shared` (`constants/dev-sessions.ts`),
247
+ * which this package cannot import; `supervisor.test.ts` holds the two copies
248
+ * together and keeps this well inside the API's trust window.
249
+ */
250
+ export declare const BACKGROUND_TASKS_HEARTBEAT_MS: number;
214
251
  export declare class Supervisor {
215
252
  private readonly ws;
216
253
  private readonly opts;
@@ -282,6 +319,13 @@ export declare class Supervisor {
282
319
  * which of the two is running.
283
320
  */
284
321
  private installInFlight;
322
+ /**
323
+ * A `runner_restart` has been agreed to and the exit is coming (#396).
324
+ *
325
+ * Never cleared: the process has ~1.5 s left, and everything it must refuse in
326
+ * that window it must refuse for good.
327
+ */
328
+ private restarting;
285
329
  /** A restore-point collection is running; a second reconnect must not start another (#388). */
286
330
  private checkpointGcInFlight;
287
331
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
@@ -299,6 +343,21 @@ export declare class Supervisor {
299
343
  * sent — the next `hello_ack` or the hourly tick carries the same fact, and
300
344
  * the measurement behind it is cached, so retrying is nearly free.
301
345
  */
346
+ /**
347
+ * The adapter for this agent — built now if it was not there at boot (#395).
348
+ *
349
+ * `opts.adapters` is assembled once, when the daemon starts, from what was on
350
+ * PATH at that moment. That is the SAME frozen snapshot `capabilities.agents`
351
+ * was, one layer down: press «Install Codex» on a running machine and the
352
+ * measurement updates, the card lights up, the API lets the session through —
353
+ * and this map still has no CODEX in it, so the session is created and dies
354
+ * at once. Asking the factory closes the last of the three lists.
355
+ *
356
+ * Built at most once per process and remembered: preparing Codex's isolated
357
+ * home clones ~90 MB on a machine that has never run it, and that is a price
358
+ * to pay on the first session, not on every one.
359
+ */
360
+ private adapterFor;
302
361
  /**
303
362
  * Why an install cannot start right now, in words for the person who pressed.
304
363
  *
@@ -438,6 +497,14 @@ export declare class Supervisor {
438
497
  private publishSlots;
439
498
  /** The heartbeat window actually used — the constant, or a test's own. */
440
499
  private readonly hostLoadHeartbeatMs;
500
+ /** S1 p.9: how often a held background count is said again. */
501
+ private readonly backgroundTasksHeartbeatMs;
502
+ /**
503
+ * The API has answered this socket's `hello` (S1 p.9). Frames sent before
504
+ * that are closed with 4002; the reconnect path re-sends every status in
505
+ * `reconcile` anyway, so the heartbeat has nothing to say until then.
506
+ */
507
+ private helloAcked;
441
508
  private readonly hostLoadTimer;
442
509
  /** #398 S2: the stall detector's clock, and the deadline it enforces. */
443
510
  private readonly stallTimer;
@@ -655,6 +722,28 @@ export declare class Supervisor {
655
722
  */
656
723
  private idleSessionCeiling;
657
724
  private publishSessionLimits;
725
+ /**
726
+ * Re-assert the background count of every resting session that still holds
727
+ * one (S1 p.9) — the same `session_status`, the same status, sent again.
728
+ *
729
+ * Only at rest, and only in the two statuses somebody over there reads the
730
+ * age of the number in: `REVIEW` and `WAITING_INPUT`. NOT in
731
+ * `WAITING_PERMISSION` — after a denied card `lastReported` stays there
732
+ * until the turn ends while the API has already healed its row to `RUNNING`,
733
+ * and repeating the old status would put the row back to «waiting for
734
+ * permission» with no card behind it, a fresh `statusChangedAt` and a
735
+ * needless nudge of the workflow engine. Not mid-turn either: a turn ends
736
+ * with a status frame of its own.
737
+ *
738
+ * Straight through `reportStatus`, past the equality check in
739
+ * `setBackgroundTasks`: that one exists to stay quiet when nothing changed,
740
+ * and «nothing changed» is exactly what this frame is for. Measured from the
741
+ * last status frame of ANY kind — every one carries the count — so a session
742
+ * that just reported is not reported twice, and a reconnect (which re-sends
743
+ * every tracked session's status in `reconcile`) restarts the five minutes
744
+ * by itself.
745
+ */
746
+ private publishBackgroundHeartbeats;
658
747
  private publishHostLoad;
659
748
  private onFrame;
660
749
  private startSession;
@@ -19,7 +19,7 @@ import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs
19
19
  import { fsView } from './fsview.js';
20
20
  import { publishFile } from './file-publish.js';
21
21
  import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
22
- import { selfUpdate } from './self-update.js';
22
+ import { isSupervisedProcess, selfUpdate } from './self-update.js';
23
23
  import { installAgent } from './agent-install.js';
24
24
  import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
25
25
  import { pruneNativeClaudeVersions } from './agent-cleanup.js';
@@ -80,6 +80,23 @@ function freshLevels() {
80
80
  };
81
81
  }
82
82
  const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
83
+ /**
84
+ * How often a resting session says «I still hold N background tasks» — the
85
+ * same `session_status` frame, the same status, sent again (plan
86
+ * `workflow-mode-fixes` S1 p.9).
87
+ *
88
+ * The count is a level the API stamps on arrival, and every reader over there
89
+ * — the workflow gate, its reminder, the «Agents» badge, the Inbox — believes
90
+ * it for an hour from that stamp. A task that simply runs (a forty-minute
91
+ * build) changes nothing in the tray, so nothing else would ever send the
92
+ * count again, and the platform would stop believing a live task after an
93
+ * hour. This is the frame that keeps it believed for as long as it is true.
94
+ *
95
+ * Mirrored by value in `@devbridge/shared` (`constants/dev-sessions.ts`),
96
+ * which this package cannot import; `supervisor.test.ts` holds the two copies
97
+ * together and keeps this well inside the API's trust window.
98
+ */
99
+ export const BACKGROUND_TASKS_HEARTBEAT_MS = 5 * 60 * 1000;
83
100
  export class Supervisor {
84
101
  ws;
85
102
  opts;
@@ -151,6 +168,13 @@ export class Supervisor {
151
168
  * which of the two is running.
152
169
  */
153
170
  installInFlight = null;
171
+ /**
172
+ * A `runner_restart` has been agreed to and the exit is coming (#396).
173
+ *
174
+ * Never cleared: the process has ~1.5 s left, and everything it must refuse in
175
+ * that window it must refuse for good.
176
+ */
177
+ restarting = false;
154
178
  /** A restore-point collection is running; a second reconnect must not start another (#388). */
155
179
  checkpointGcInFlight = false;
156
180
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
@@ -177,6 +201,9 @@ export class Supervisor {
177
201
  ws.on('frame', (frame) => {
178
202
  void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
179
203
  });
204
+ ws.on('close', () => {
205
+ this.helloAcked = false;
206
+ });
180
207
  /**
181
208
  * The seat report is on a heartbeat, not only on the events that change it.
182
209
  *
@@ -233,8 +260,14 @@ export class Supervisor {
233
260
  * actually moved (or the heartbeat came due).
234
261
  */
235
262
  this.hostLoadHeartbeatMs = opts.hostLoadHeartbeatMs ?? HOST_LOAD_HEARTBEAT_MS;
263
+ this.backgroundTasksHeartbeatMs =
264
+ opts.backgroundTasksHeartbeatMs ?? BACKGROUND_TASKS_HEARTBEAT_MS;
236
265
  this.hostLoadTimer = setInterval(() => {
237
266
  this.publishHostLoad();
267
+ // The fourth thing only a tick can notice (S1 p.9): a background task
268
+ // that is simply still running produces no event, and the API's belief
269
+ // in the count runs out an hour after it was last said.
270
+ this.publishBackgroundHeartbeats();
238
271
  // Same cadence, same reason: what a session's cgroup is going through is
239
272
  // something that happens to it, and only a tick can notice (#387).
240
273
  this.watchSessionCages();
@@ -288,12 +321,41 @@ export class Supervisor {
288
321
  * sent — the next `hello_ack` or the hourly tick carries the same fact, and
289
322
  * the measurement behind it is cached, so retrying is nearly free.
290
323
  */
324
+ /**
325
+ * The adapter for this agent — built now if it was not there at boot (#395).
326
+ *
327
+ * `opts.adapters` is assembled once, when the daemon starts, from what was on
328
+ * PATH at that moment. That is the SAME frozen snapshot `capabilities.agents`
329
+ * was, one layer down: press «Install Codex» on a running machine and the
330
+ * measurement updates, the card lights up, the API lets the session through —
331
+ * and this map still has no CODEX in it, so the session is created and dies
332
+ * at once. Asking the factory closes the last of the three lists.
333
+ *
334
+ * Built at most once per process and remembered: preparing Codex's isolated
335
+ * home clones ~90 MB on a machine that has never run it, and that is a price
336
+ * to pay on the first session, not on every one.
337
+ */
338
+ adapterFor(agent) {
339
+ const known = this.opts.adapters[agent];
340
+ if (known)
341
+ return known;
342
+ const built = this.opts.makeAdapter?.(agent) ?? null;
343
+ if (built)
344
+ this.opts.adapters[agent] = built;
345
+ return built ?? undefined;
346
+ }
291
347
  /**
292
348
  * Why an install cannot start right now, in words for the person who pressed.
293
349
  *
294
350
  * `null` means the way is clear.
295
351
  */
296
352
  installBusyReason() {
353
+ // #396: the same window `self_update` documents below, for a plain restart.
354
+ // The process only exits ~1.5 s after the reply, and the API frees its own
355
+ // lock the moment the reply lands — so an install that slipped into that
356
+ // window would be an `npm install -g` the exit kills half-written.
357
+ if (this.restarting)
358
+ return 'This runner is restarting';
297
359
  if (this.installInFlight === 'self_update')
298
360
  return 'An update is already running';
299
361
  if (this.installInFlight === 'agent_install') {
@@ -641,6 +703,14 @@ export class Supervisor {
641
703
  }
642
704
  /** The heartbeat window actually used — the constant, or a test's own. */
643
705
  hostLoadHeartbeatMs;
706
+ /** S1 p.9: how often a held background count is said again. */
707
+ backgroundTasksHeartbeatMs;
708
+ /**
709
+ * The API has answered this socket's `hello` (S1 p.9). Frames sent before
710
+ * that are closed with 4002; the reconnect path re-sends every status in
711
+ * `reconcile` anyway, so the heartbeat has nothing to say until then.
712
+ */
713
+ helloAcked = false;
644
714
  hostLoadTimer;
645
715
  /** #398 S2: the stall detector's clock, and the deadline it enforces. */
646
716
  stallTimer;
@@ -1531,6 +1601,58 @@ export class Supervisor {
1531
1601
  this.lastLimitsSentAt = measuredAt;
1532
1602
  }
1533
1603
  }
1604
+ /**
1605
+ * Re-assert the background count of every resting session that still holds
1606
+ * one (S1 p.9) — the same `session_status`, the same status, sent again.
1607
+ *
1608
+ * Only at rest, and only in the two statuses somebody over there reads the
1609
+ * age of the number in: `REVIEW` and `WAITING_INPUT`. NOT in
1610
+ * `WAITING_PERMISSION` — after a denied card `lastReported` stays there
1611
+ * until the turn ends while the API has already healed its row to `RUNNING`,
1612
+ * and repeating the old status would put the row back to «waiting for
1613
+ * permission» with no card behind it, a fresh `statusChangedAt` and a
1614
+ * needless nudge of the workflow engine. Not mid-turn either: a turn ends
1615
+ * with a status frame of its own.
1616
+ *
1617
+ * Straight through `reportStatus`, past the equality check in
1618
+ * `setBackgroundTasks`: that one exists to stay quiet when nothing changed,
1619
+ * and «nothing changed» is exactly what this frame is for. Measured from the
1620
+ * last status frame of ANY kind — every one carries the count — so a session
1621
+ * that just reported is not reported twice, and a reconnect (which re-sends
1622
+ * every tracked session's status in `reconcile`) restarts the five minutes
1623
+ * by itself.
1624
+ */
1625
+ publishBackgroundHeartbeats() {
1626
+ // `helloAcked` and not merely `connected`: the socket is open from the
1627
+ // `open` event, but the gateway answers any frame that reaches it before
1628
+ // its own `hello` bookkeeping is done with a close (4002) — a window of
1629
+ // tens of milliseconds on every reconnect that a tick can land in.
1630
+ if (!this.ws.connected || !this.helloAcked)
1631
+ return;
1632
+ const now = Date.now();
1633
+ for (const running of this.sessions.values()) {
1634
+ if (running.backgroundTasks <= 0)
1635
+ continue;
1636
+ // Only a session with a process behind it. The count is zeroed when the
1637
+ // process is found gone, but not on every path that loses one: a
1638
+ // relaunch that crashed leaves the entry with `session = null` and the
1639
+ // count it was holding, and re-asserting that would be the platform
1640
+ // confirming work nobody is doing.
1641
+ if (!running.session)
1642
+ continue;
1643
+ if (running.lastReported !== 'REVIEW' && running.lastReported !== 'WAITING_INPUT')
1644
+ continue;
1645
+ const sinceLastSent = now - (running.statusSentAt ?? 0);
1646
+ // A backwards clock step is «due», not «early» — same allowance as the
1647
+ // host-load heartbeat, for the same reason.
1648
+ if (sinceLastSent < this.backgroundTasksHeartbeatMs && sinceLastSent >= 0)
1649
+ continue;
1650
+ this.reportStatus(running.descriptor.id, running.lastReported, {
1651
+ costUsd: running.costUsd,
1652
+ activeMs: Supervisor.spentMs(running),
1653
+ });
1654
+ }
1655
+ }
1534
1656
  publishHostLoad() {
1535
1657
  const sample = (this.opts.readHostLoad ?? readHostLoad)();
1536
1658
  if (!sample)
@@ -1549,6 +1671,7 @@ export class Supervisor {
1549
1671
  async onFrame(frame) {
1550
1672
  switch (frame.type) {
1551
1673
  case 'hello_ack':
1674
+ this.helloAcked = true;
1552
1675
  // A new connection knows nothing about the seats we reported to the
1553
1676
  // last one — the API keeps that beside the socket, not in the database,
1554
1677
  // because it is only true while the socket is. Forget what we told the
@@ -1736,7 +1859,7 @@ export class Supervisor {
1736
1859
  });
1737
1860
  return;
1738
1861
  }
1739
- if (!this.opts.adapters[descriptor.agent]) {
1862
+ if (!this.adapterFor(descriptor.agent)) {
1740
1863
  this.reportStatus(descriptor.id, 'FAILED', {
1741
1864
  errorMessage: `${AGENT_LABELS[descriptor.agent] ?? descriptor.agent} is not installed on this server`,
1742
1865
  });
@@ -1986,7 +2109,7 @@ export class Supervisor {
1986
2109
  */
1987
2110
  launchAgent(running, prompt, resumeId) {
1988
2111
  const { descriptor } = running;
1989
- const adapter = this.opts.adapters[descriptor.agent];
2112
+ const adapter = this.adapterFor(descriptor.agent);
1990
2113
  if (!adapter || !running.worktreePath || !running.branch)
1991
2114
  return LAUNCH_REFUSED;
1992
2115
  /**
@@ -3327,6 +3450,28 @@ export class Supervisor {
3327
3450
  // when it finally succeeds or finally gives up.
3328
3451
  if (!event.ok && this.armApiRetry(running, descriptor, event))
3329
3452
  return;
3453
+ /**
3454
+ * A turn that ended well closes the retry EPISODE (#406).
3455
+ *
3456
+ * `running.apiRetry` records «attempt N of rule R», and `armApiRetry`
3457
+ * reads it to answer two questions: have we run out of attempts, and
3458
+ * did the same failure come straight back. Both are questions about ONE
3459
+ * episode. Until now the record was only ever cleared when a person
3460
+ * typed, a session stopped, or a session was torn down — so a session
3461
+ * that recovered kept «attempt 1 of claude.server_error» for the rest of
3462
+ * its life, and the NEXT api_error hours later read as «the same failure
3463
+ * came back after an automatic retry», got no retry, and ended the
3464
+ * session with a note that was not true.
3465
+ *
3466
+ * Nobody met that before this release: an `api_error` turn was reported
3467
+ * as a SUCCESS and never reached this code at all. With #406 it does,
3468
+ * and a long unattended run meets two network hiccups more often than
3469
+ * one. `apiRetriesUsed` — the ceiling that does not depend on the table
3470
+ * being right — is deliberately NOT reset here: it is a fact about the
3471
+ * session, and only a person typing clears it.
3472
+ */
3473
+ if (event.ok)
3474
+ this.clearApiRetry(running);
3330
3475
  this.completeTurn(running, descriptor, event);
3331
3476
  return;
3332
3477
  }
@@ -6429,6 +6574,50 @@ export class Supervisor {
6429
6574
  this.opts.onRestartRequested?.(outcome);
6430
6575
  return;
6431
6576
  }
6577
+ /**
6578
+ * «Restart the runner» from the server card (#396, plan R13).
6579
+ *
6580
+ * The whole errand on this side: answer, tell every live session why it
6581
+ * is about to go quiet, and exit for the service to start us again. The
6582
+ * API cannot do the middle part — the feeds belong to sessions only this
6583
+ * process knows about — and it does not try to.
6584
+ *
6585
+ * Refused, in words, when nothing would bring the daemon back: a runner
6586
+ * started by hand that exits here is a machine that has lost its runner
6587
+ * until somebody with SSH notices. Same condition and the same shape of
6588
+ * sentence as `self_update`'s (`self-update.ts`), and the same reason.
6589
+ */
6590
+ case 'runner_restart': {
6591
+ if (!isSupervisedProcess()) {
6592
+ const detail = 'The runner is not running as a service on this machine, so nothing would start it ' +
6593
+ 'again. Install it with `devbridge-runner install-service`, or restart it by hand.';
6594
+ return void reply({ ok: false, result: { ok: false, detail }, error: detail });
6595
+ }
6596
+ // Second line of defence behind the API's per-server lock, exactly as
6597
+ // `self_update` has: exiting in the middle of an `npm install -g`
6598
+ // leaves a half-written package for the service to start.
6599
+ const busy = this.installBusyReason();
6600
+ if (busy)
6601
+ return void reply({ ok: false, result: { ok: false, detail: busy }, error: busy });
6602
+ const note = str(frame.args?.['note']);
6603
+ // BEFORE `shutdown()`, which clears the session map — and before the
6604
+ // exit, because everything on this path is journaled synchronously
6605
+ // for the reason spelled out on `shutdown` itself. A person whose
6606
+ // session goes quiet did not press the button; the line is how they
6607
+ // find out it was pressed at all.
6608
+ if (note) {
6609
+ for (const running of this.sessions.values()) {
6610
+ this.sendEvent(running, 'system_note', { code: 'runner_restart', text: note });
6611
+ }
6612
+ }
6613
+ // Set BEFORE the reply: the API releases its per-server lock the
6614
+ // moment the reply lands, and the next errand must meet a machine
6615
+ // that already knows it is leaving.
6616
+ this.restarting = true;
6617
+ reply({ ok: true, result: { ok: true } });
6618
+ this.opts.onRestartCommanded?.(note);
6619
+ return;
6620
+ }
6432
6621
  case 'agent_install': {
6433
6622
  // The veto is enforced here as well as withheld from `hello`: an API
6434
6623
  // that has not noticed still must not install anything on a machine
@@ -6978,6 +7167,10 @@ export class Supervisor {
6978
7167
  // would mean it goes on claiming background work forever.
6979
7168
  ...(running ? { backgroundTasks: running.backgroundTasks } : {}),
6980
7169
  });
7170
+ // …and the count's age starts here (S1 p.9): the background heartbeat is
7171
+ // due five minutes after the last frame that carried it, whichever it was.
7172
+ if (running)
7173
+ running.statusSentAt = Date.now();
6981
7174
  // Every seat change is accompanied by a status report — a session starting,
6982
7175
  // parking, ending. Hooking the seat report here rather than at each of those
6983
7176
  // is the whole point: «remember to also tell the API» is the rule that had
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.58.2";
1
+ export declare const RUNNER_VERSION = "0.60.0";
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.58.2';
2
+ export const RUNNER_VERSION = '0.60.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.58.2",
3
+ "version": "0.60.0",
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",