@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.
- package/dist/adapters/claude.js +135 -11
- 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 +89 -0
- package/dist/supervisor.js +196 -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
|
*
|
|
@@ -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
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
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
|
-
|
|
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'
|
|
2449
|
-
//
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
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
|
-
|
|
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
|
}
|
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
|
|
@@ -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;
|
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';
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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