@bridge4dev/runner 0.59.1 → 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.
- package/dist/adapters/claude.js +125 -8
- package/dist/agent-binary.d.ts +45 -4
- package/dist/agent-binary.js +53 -4
- package/dist/attachments.d.ts +6 -0
- package/dist/attachments.js +162 -11
- package/dist/commit-message.js +8 -1
- package/dist/index.js +59 -2
- package/dist/self-update.d.ts +22 -0
- package/dist/self-update.js +22 -0
- package/dist/supervisor.d.ts +36 -0
- package/dist/supervisor.js +105 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
|
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
|
*
|
|
@@ -2226,6 +2237,25 @@ class ClaudeSession {
|
|
|
2226
2237
|
async consume() {
|
|
2227
2238
|
try {
|
|
2228
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
|
+
}
|
|
2229
2259
|
// Ticket #126: where we are in the transcript, remembered BEFORE the
|
|
2230
2260
|
// message is interpreted.
|
|
2231
2261
|
//
|
|
@@ -2426,7 +2456,50 @@ class ClaudeSession {
|
|
|
2426
2456
|
// once, for exactly one result.
|
|
2427
2457
|
const aborted = this.aborting;
|
|
2428
2458
|
this.aborting = false;
|
|
2429
|
-
|
|
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);
|
|
2430
2503
|
// #373, plan stage D. What the incident could not answer: the second
|
|
2431
2504
|
// result's origin was never recorded, so «is this the same turn
|
|
2432
2505
|
// twice or a turn the CLI started by itself» had no evidence either
|
|
@@ -2452,10 +2525,13 @@ class ClaudeSession {
|
|
|
2452
2525
|
code: 'rewind_failed',
|
|
2453
2526
|
});
|
|
2454
2527
|
}
|
|
2455
|
-
else if (msg.subtype === 'success'
|
|
2456
|
-
//
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
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.
|
|
2459
2535
|
this.emit({
|
|
2460
2536
|
type: 'turn_end',
|
|
2461
2537
|
ok: true,
|
|
@@ -2487,7 +2563,17 @@ class ClaudeSession {
|
|
|
2487
2563
|
// the closed enum rides on the assistant message, this `result`
|
|
2488
2564
|
// has no such field. Handed over so the supervisor can decide
|
|
2489
2565
|
// from a code rather than from the sentence in `errorMessage`.
|
|
2490
|
-
|
|
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
|
+
: {}),
|
|
2491
2577
|
...(this.turnFailureStatus !== null
|
|
2492
2578
|
? { failureStatus: this.turnFailureStatus }
|
|
2493
2579
|
: {}),
|
|
@@ -2496,6 +2582,11 @@ class ClaudeSession {
|
|
|
2496
2582
|
});
|
|
2497
2583
|
this.refreshUsage();
|
|
2498
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;
|
|
2499
2590
|
break;
|
|
2500
2591
|
}
|
|
2501
2592
|
default:
|
|
@@ -2671,6 +2762,15 @@ function stringifyContent(content) {
|
|
|
2671
2762
|
}
|
|
2672
2763
|
return content === undefined ? '' : JSON.stringify(content);
|
|
2673
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';
|
|
2674
2774
|
function classifyError(subtype, errors) {
|
|
2675
2775
|
// Optional on purpose despite the SDK's type: a `result` without `errors`
|
|
2676
2776
|
// threw from inside the event pump, which the loop's catch turned into a
|
|
@@ -2745,6 +2845,23 @@ export class ClaudeAdapter {
|
|
|
2745
2845
|
this.queryFn = queryFn;
|
|
2746
2846
|
}
|
|
2747
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();
|
|
2748
2865
|
return new ClaudeSession(spec, this.queryFn);
|
|
2749
2866
|
}
|
|
2750
2867
|
}
|
package/dist/agent-binary.d.ts
CHANGED
|
@@ -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,
|
|
69
|
-
* is exactly
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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;
|
package/dist/agent-binary.js
CHANGED
|
@@ -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,
|
|
144
|
-
* is exactly
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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)
|
package/dist/attachments.d.ts
CHANGED
|
@@ -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[];
|
package/dist/attachments.js
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/commit-message.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/self-update.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/self-update.js
CHANGED
|
@@ -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
|
*
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -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
|
|
@@ -305,6 +319,13 @@ export declare class Supervisor {
|
|
|
305
319
|
* which of the two is running.
|
|
306
320
|
*/
|
|
307
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;
|
|
308
329
|
/** A restore-point collection is running; a second reconnect must not start another (#388). */
|
|
309
330
|
private checkpointGcInFlight;
|
|
310
331
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
@@ -322,6 +343,21 @@ export declare class Supervisor {
|
|
|
322
343
|
* sent — the next `hello_ack` or the hourly tick carries the same fact, and
|
|
323
344
|
* the measurement behind it is cached, so retrying is nearly free.
|
|
324
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;
|
|
325
361
|
/**
|
|
326
362
|
* Why an install cannot start right now, in words for the person who pressed.
|
|
327
363
|
*
|
package/dist/supervisor.js
CHANGED
|
@@ -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';
|
|
@@ -168,6 +168,13 @@ export class Supervisor {
|
|
|
168
168
|
* which of the two is running.
|
|
169
169
|
*/
|
|
170
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;
|
|
171
178
|
/** A restore-point collection is running; a second reconnect must not start another (#388). */
|
|
172
179
|
checkpointGcInFlight = false;
|
|
173
180
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
@@ -314,12 +321,41 @@ export class Supervisor {
|
|
|
314
321
|
* sent — the next `hello_ack` or the hourly tick carries the same fact, and
|
|
315
322
|
* the measurement behind it is cached, so retrying is nearly free.
|
|
316
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
|
+
}
|
|
317
347
|
/**
|
|
318
348
|
* Why an install cannot start right now, in words for the person who pressed.
|
|
319
349
|
*
|
|
320
350
|
* `null` means the way is clear.
|
|
321
351
|
*/
|
|
322
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';
|
|
323
359
|
if (this.installInFlight === 'self_update')
|
|
324
360
|
return 'An update is already running';
|
|
325
361
|
if (this.installInFlight === 'agent_install') {
|
|
@@ -1823,7 +1859,7 @@ export class Supervisor {
|
|
|
1823
1859
|
});
|
|
1824
1860
|
return;
|
|
1825
1861
|
}
|
|
1826
|
-
if (!this.
|
|
1862
|
+
if (!this.adapterFor(descriptor.agent)) {
|
|
1827
1863
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
1828
1864
|
errorMessage: `${AGENT_LABELS[descriptor.agent] ?? descriptor.agent} is not installed on this server`,
|
|
1829
1865
|
});
|
|
@@ -2073,7 +2109,7 @@ export class Supervisor {
|
|
|
2073
2109
|
*/
|
|
2074
2110
|
launchAgent(running, prompt, resumeId) {
|
|
2075
2111
|
const { descriptor } = running;
|
|
2076
|
-
const adapter = this.
|
|
2112
|
+
const adapter = this.adapterFor(descriptor.agent);
|
|
2077
2113
|
if (!adapter || !running.worktreePath || !running.branch)
|
|
2078
2114
|
return LAUNCH_REFUSED;
|
|
2079
2115
|
/**
|
|
@@ -3414,6 +3450,28 @@ export class Supervisor {
|
|
|
3414
3450
|
// when it finally succeeds or finally gives up.
|
|
3415
3451
|
if (!event.ok && this.armApiRetry(running, descriptor, event))
|
|
3416
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);
|
|
3417
3475
|
this.completeTurn(running, descriptor, event);
|
|
3418
3476
|
return;
|
|
3419
3477
|
}
|
|
@@ -6516,6 +6574,50 @@ export class Supervisor {
|
|
|
6516
6574
|
this.opts.onRestartRequested?.(outcome);
|
|
6517
6575
|
return;
|
|
6518
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
|
+
}
|
|
6519
6621
|
case 'agent_install': {
|
|
6520
6622
|
// The veto is enforced here as well as withheld from `hello`: an API
|
|
6521
6623
|
// that has not noticed still must not install anything on a machine
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.60.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED