@bridge4dev/runner 0.46.1 → 0.47.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/README.md +44 -2
- package/dist/adapters/claude.js +6 -2
- package/dist/agent-auto-update.d.ts +75 -0
- package/dist/agent-auto-update.js +134 -0
- package/dist/agent-binary.d.ts +48 -0
- package/dist/agent-binary.js +55 -0
- package/dist/agent-cleanup.d.ts +78 -0
- package/dist/agent-cleanup.js +184 -0
- package/dist/agent-install.d.ts +140 -0
- package/dist/agent-install.js +475 -0
- package/dist/agent-registry.d.ts +223 -0
- package/dist/agent-registry.js +131 -0
- package/dist/agent-versions.d.ts +93 -0
- package/dist/agent-versions.js +157 -0
- package/dist/auth-relay.d.ts +9 -1
- package/dist/auth-relay.js +3 -1
- package/dist/commit-message.js +5 -0
- package/dist/config.d.ts +44 -4
- package/dist/config.js +43 -0
- package/dist/index.js +46 -12
- package/dist/protocol.d.ts +155 -28
- package/dist/protocol.js +33 -1
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/self-update.d.ts +14 -0
- package/dist/self-update.js +45 -13
- package/dist/supervisor.d.ts +155 -2
- package/dist/supervisor.js +393 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -17,6 +17,11 @@ import { fsView } from './fsview.js';
|
|
|
17
17
|
import { publishFile } from './file-publish.js';
|
|
18
18
|
import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
|
|
19
19
|
import { selfUpdate } from './self-update.js';
|
|
20
|
+
import { installAgent } from './agent-install.js';
|
|
21
|
+
import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
|
|
22
|
+
import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
23
|
+
import { agentByDbValue } from './agent-registry.js';
|
|
24
|
+
import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
|
|
20
25
|
import { rememberWorkspacePath } from './environment.js';
|
|
21
26
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
22
27
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
@@ -108,8 +113,20 @@ export class Supervisor {
|
|
|
108
113
|
* than having no restore point for that one message.
|
|
109
114
|
*/
|
|
110
115
|
repoLockDepth = new Map();
|
|
111
|
-
/**
|
|
112
|
-
|
|
116
|
+
/**
|
|
117
|
+
* One install at a time on this machine, whoever asked for it.
|
|
118
|
+
*
|
|
119
|
+
* `self_update` and `agent_install` both end in `npm install -g` into the
|
|
120
|
+
* SAME npm prefix under the dedicated-user layout, so two of them at once is
|
|
121
|
+
* a real conflict rather than a theoretical one. The API takes a Redis lock
|
|
122
|
+
* before pressing either button, but that lock cannot cover an install the
|
|
123
|
+
* runner starts by itself (stage D) — and the runner has no Redis. This is
|
|
124
|
+
* the only lock that sees both.
|
|
125
|
+
*
|
|
126
|
+
* Holds the holder's name rather than a boolean, so the refusal can say
|
|
127
|
+
* which of the two is running.
|
|
128
|
+
*/
|
|
129
|
+
installInFlight = null;
|
|
113
130
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
114
131
|
verify;
|
|
115
132
|
verifyReports = new VerifyReportQueue();
|
|
@@ -148,6 +165,293 @@ export class Supervisor {
|
|
|
148
165
|
*/
|
|
149
166
|
this.slotsTimer = setInterval(() => this.publishSlots(), Supervisor.SLOTS_REPORT_INTERVAL_MS);
|
|
150
167
|
this.slotsTimer.unref?.();
|
|
168
|
+
/**
|
|
169
|
+
* Agent versions on a heartbeat too, for a different reason than seats.
|
|
170
|
+
*
|
|
171
|
+
* Nothing on this machine tells us when somebody updates Codex by hand, and
|
|
172
|
+
* the measurement behind the frame is cached for an hour anyway — so the
|
|
173
|
+
* tick costs a cached read most of the time and still notices a hand-made
|
|
174
|
+
* change within the hour. What makes a freshly connected card correct
|
|
175
|
+
* immediately is the same frame sent on every `hello_ack`.
|
|
176
|
+
*/
|
|
177
|
+
this.agentVersionsTimer = setInterval(() => void this.publishAgentVersions('measured'), Supervisor.AGENT_VERSIONS_INTERVAL_MS);
|
|
178
|
+
this.agentVersionsTimer.unref?.();
|
|
179
|
+
/**
|
|
180
|
+
* Р12: the old Claude version files, swept once a day.
|
|
181
|
+
*
|
|
182
|
+
* Two timers rather than one, and the delayed first sweep is the load-bearing
|
|
183
|
+
* half. The gate below is «this runner is tracking no sessions», and at the
|
|
184
|
+
* moment a daemon starts that is trivially true — the map fills from the
|
|
185
|
+
* `hello_ack` reconciliation seconds later. Sweeping in that window would be
|
|
186
|
+
* sweeping exactly when the answer is least trustworthy, on the one machine
|
|
187
|
+
* shape (restarted often) where it would happen every time.
|
|
188
|
+
*/
|
|
189
|
+
const cleanupFirst = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_FIRST_DELAY_MS;
|
|
190
|
+
const cleanupEvery = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_INTERVAL_MS;
|
|
191
|
+
this.agentCleanupFirstTimer = setTimeout(() => this.sweepOldAgentVersions(), cleanupFirst);
|
|
192
|
+
this.agentCleanupFirstTimer.unref?.();
|
|
193
|
+
this.agentCleanupTimer = setInterval(() => this.sweepOldAgentVersions(), cleanupEvery);
|
|
194
|
+
this.agentCleanupTimer.unref?.();
|
|
195
|
+
}
|
|
196
|
+
/** How often the agent versions are re-derived. See the constructor. */
|
|
197
|
+
static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
|
|
198
|
+
agentVersionsTimer;
|
|
199
|
+
/**
|
|
200
|
+
* Tell the API which agents this machine has and at which versions.
|
|
201
|
+
*
|
|
202
|
+
* Never throws and never blocks its caller: a machine where a probe hangs
|
|
203
|
+
* must still start sessions. A frame that could not be written is simply not
|
|
204
|
+
* sent — the next `hello_ack` or the hourly tick carries the same fact, and
|
|
205
|
+
* the measurement behind it is cached, so retrying is nearly free.
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* Why an install cannot start right now, in words for the person who pressed.
|
|
209
|
+
*
|
|
210
|
+
* `null` means the way is clear.
|
|
211
|
+
*/
|
|
212
|
+
installBusyReason() {
|
|
213
|
+
if (this.installInFlight === 'self_update')
|
|
214
|
+
return 'An update is already running';
|
|
215
|
+
if (this.installInFlight === 'agent_install') {
|
|
216
|
+
return 'An agent is already being installed on this server';
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A version change that has happened but has not reached the API yet.
|
|
222
|
+
*
|
|
223
|
+
* `ws.send` returns false when the socket is down, and an install finishing
|
|
224
|
+
* during a reconnect is not a rare case — it is a 300 MB download that takes
|
|
225
|
+
* minutes. The VERSION recovers on its own (the hourly tick and every
|
|
226
|
+
* `hello_ack` re-send the measurement), but `reason` and `changed` are the
|
|
227
|
+
* audit line itself: dropped, «why is my Codex suddenly different» has no
|
|
228
|
+
* answer anywhere, which is precisely what Р14 exists to prevent.
|
|
229
|
+
*
|
|
230
|
+
* Kept in memory rather than on disk on purpose. A daemon that died mid-install
|
|
231
|
+
* has a bigger hole than one lost line, and persisting it would risk filing an
|
|
232
|
+
* install that a rollback then undid.
|
|
233
|
+
*/
|
|
234
|
+
pendingVersionChanges = [];
|
|
235
|
+
/**
|
|
236
|
+
* Two installs cannot overlap — the local lock sees to that — but two can
|
|
237
|
+
* both FINISH inside one outage, and each is a line the log owes. A queue
|
|
238
|
+
* rather than a slot, because the second would otherwise overwrite the first
|
|
239
|
+
* and the older install would be the one that vanished.
|
|
240
|
+
*
|
|
241
|
+
* Bounded: an outage long enough to strand nine installs is one where the
|
|
242
|
+
* missing audit lines are not the problem worth solving.
|
|
243
|
+
*/
|
|
244
|
+
static MAX_PENDING_VERSION_CHANGES = 8;
|
|
245
|
+
async publishAgentVersions(reason, changed) {
|
|
246
|
+
try {
|
|
247
|
+
const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
|
|
248
|
+
// After an install — ours or the auto one — the cached number is a lie,
|
|
249
|
+
// and a stale `at` with it would be dropped by the API's ordering guard
|
|
250
|
+
// together with the audit line the install owes. Forcing it here means a
|
|
251
|
+
// caller cannot forget to invalidate the cache first.
|
|
252
|
+
const measurement = await measure({ force: reason !== 'measured' });
|
|
253
|
+
// A plain tick carries an earlier install's news rather than replacing it.
|
|
254
|
+
// The API keys the audit line by the measurement's `at`, so the same
|
|
255
|
+
// change arriving under a later `at` is one line, not two.
|
|
256
|
+
//
|
|
257
|
+
// `changed` is only ever handed over by an install, and an install always
|
|
258
|
+
// calls this with `manual` or `auto` — never `measured`.
|
|
259
|
+
if (changed && reason !== 'measured') {
|
|
260
|
+
this.queueVersionChange({ reason, changed });
|
|
261
|
+
}
|
|
262
|
+
const carried = this.pendingVersionChanges[0];
|
|
263
|
+
const sent = this.ws.send({
|
|
264
|
+
type: 'agent_versions',
|
|
265
|
+
at: measurement.at,
|
|
266
|
+
reason: carried?.reason ?? reason,
|
|
267
|
+
agents: measurement.agents,
|
|
268
|
+
...(carried ? { changed: carried.changed } : {}),
|
|
269
|
+
});
|
|
270
|
+
// Dropped only once a frame is genuinely written.
|
|
271
|
+
if (sent && carried) {
|
|
272
|
+
this.pendingVersionChanges.shift();
|
|
273
|
+
// One line per frame, so a backlog needs one frame each. Recursing only
|
|
274
|
+
// after a SUCCESSFUL send is what makes this terminate: the queue is
|
|
275
|
+
// shorter every time, and a failed write leaves the loop instead.
|
|
276
|
+
if (this.pendingVersionChanges.length > 0) {
|
|
277
|
+
void this.publishAgentVersions('measured');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (changed && reason !== 'measured')
|
|
283
|
+
this.queueVersionChange({ reason, changed });
|
|
284
|
+
log.warn('supervisor: could not report agent versions', { error: String(error) });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
queueVersionChange(entry) {
|
|
288
|
+
if (this.pendingVersionChanges.length >= Supervisor.MAX_PENDING_VERSION_CHANGES) {
|
|
289
|
+
log.warn('supervisor: dropping the oldest unreported version change', {
|
|
290
|
+
agent: this.pendingVersionChanges[0]?.changed.agent,
|
|
291
|
+
});
|
|
292
|
+
this.pendingVersionChanges.shift();
|
|
293
|
+
}
|
|
294
|
+
this.pendingVersionChanges.push(entry);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Р13: the agent of a starting session, moved forward in the background.
|
|
298
|
+
*
|
|
299
|
+
* The whole mechanism hangs off ONE fact — whether the API put a version in
|
|
300
|
+
* the descriptor. It does that only for machines whose «Auto-update agents»
|
|
301
|
+
* switch is on, so there is no second copy of the setting here to disagree
|
|
302
|
+
* with it, and a runner that is told nothing does nothing.
|
|
303
|
+
*
|
|
304
|
+
* Never throws and never reports anything into the session feed. A person who
|
|
305
|
+
* pressed «start» asked for a session; a failed background download is not
|
|
306
|
+
* their business, and a scary red line about one would be worse than the
|
|
307
|
+
* silence. Where it IS visible is the server card: the `agent_versions` frame
|
|
308
|
+
* at the end goes out after every outcome, so a machine that ended up without
|
|
309
|
+
* a working agent says so within seconds rather than at the next hourly tick.
|
|
310
|
+
*/
|
|
311
|
+
async maybeAutoUpdateAgent(descriptor) {
|
|
312
|
+
const latest = descriptor.agentLatestVersion;
|
|
313
|
+
// The switch. Absent = off, and that covers «the card says no», «we never
|
|
314
|
+
// managed to ask a registry» and «the API predates this release» alike.
|
|
315
|
+
if (!latest)
|
|
316
|
+
return;
|
|
317
|
+
// The machine owner's veto over installing agents at all covers this too:
|
|
318
|
+
// it is the same download onto the same disk, asked for by us instead of by
|
|
319
|
+
// a person. Refusing the button and allowing the timer would be a hole in a
|
|
320
|
+
// control whose entire point is that the server cannot talk past it.
|
|
321
|
+
if (this.opts.agentInstallEnabled === false)
|
|
322
|
+
return;
|
|
323
|
+
const runtime = agentByDbValue(descriptor.agent);
|
|
324
|
+
if (!runtime)
|
|
325
|
+
return;
|
|
326
|
+
try {
|
|
327
|
+
// The CACHED measurement, not a fresh probe.
|
|
328
|
+
//
|
|
329
|
+
// This runs on every session start, and a fresh probe is two child
|
|
330
|
+
// processes per start — `claude --version` plus `claude doctor` — on the
|
|
331
|
+
// machine somebody is trying to start work on. The cache is an hour old at
|
|
332
|
+
// worst and the throttle below is a day, so staleness cannot cost a missed
|
|
333
|
+
// update; and being wrong the other way costs nothing either, because
|
|
334
|
+
// `installAgent` measures again for real and answers «already at that
|
|
335
|
+
// version» without touching anything.
|
|
336
|
+
const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
|
|
337
|
+
const snapshot = await measure();
|
|
338
|
+
const measured = snapshot.agents[runtime.wireKey] ?? {
|
|
339
|
+
version: null,
|
|
340
|
+
managedBy: 'unknown',
|
|
341
|
+
};
|
|
342
|
+
const refusal = autoUpdateRefusal({ runtime, measured, latest });
|
|
343
|
+
if (refusal) {
|
|
344
|
+
log.debug('agent-auto-update: leaving it alone', {
|
|
345
|
+
agent: runtime.wireKey,
|
|
346
|
+
reason: refusal,
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// The lock is read BEFORE the day is claimed, and the order matters: three
|
|
351
|
+
// sessions starting at once are the ordinary case here, and spending the
|
|
352
|
+
// claim on an attempt that the lock then refuses would cost the agent its
|
|
353
|
+
// whole day for an install that never began.
|
|
354
|
+
//
|
|
355
|
+
// Still exactly one winner: `installBusyReason`, `claimAgentAutoUpdate`
|
|
356
|
+
// and the assignment below are all synchronous, with no await between
|
|
357
|
+
// them, so no second coroutine can interleave into the gap.
|
|
358
|
+
if (this.installBusyReason() !== null)
|
|
359
|
+
return;
|
|
360
|
+
// Claimed before the install and written to disk: a machine that fails for
|
|
361
|
+
// a reason it cannot fix (no space, no network) must go quiet for the day
|
|
362
|
+
// instead of retrying at every session start.
|
|
363
|
+
if (!claimAgentAutoUpdate(runtime.wireKey))
|
|
364
|
+
return;
|
|
365
|
+
this.installInFlight = 'agent_install';
|
|
366
|
+
let outcome;
|
|
367
|
+
try {
|
|
368
|
+
const run = this.opts.installAgent ?? installAgent;
|
|
369
|
+
outcome = await run({ agent: runtime.wireKey, version: latest });
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
this.installInFlight = null;
|
|
373
|
+
}
|
|
374
|
+
if (outcome.ok) {
|
|
375
|
+
log.info('agent-auto-update: installed', {
|
|
376
|
+
agent: runtime.wireKey,
|
|
377
|
+
from: outcome.fromVersion,
|
|
378
|
+
to: outcome.toVersion,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
log.warn('agent-auto-update: install failed', {
|
|
383
|
+
agent: runtime.wireKey,
|
|
384
|
+
version: latest,
|
|
385
|
+
error: outcome.detail,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
// After EVERY outcome, for the reason the button's handler gives: a failed
|
|
389
|
+
// install is exactly when the card is most likely to be wrong, because npm
|
|
390
|
+
// can unlink the old package before dying. `reason: 'auto'` is what earns
|
|
391
|
+
// the `dev.server.agent_auto_updated` audit line on the other side (Р14),
|
|
392
|
+
// and `changed` is its payload — omitted when nothing actually moved.
|
|
393
|
+
invalidateAgentVersions();
|
|
394
|
+
const moved = outcome.ok && outcome.toVersion !== undefined && outcome.toVersion !== outcome.fromVersion
|
|
395
|
+
? { agent: outcome.agent, from: outcome.fromVersion, to: outcome.toVersion }
|
|
396
|
+
: undefined;
|
|
397
|
+
await this.publishAgentVersions('auto', moved);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
// `installAgent` never throws, but `measureAgent` spawns processes and the
|
|
401
|
+
// whole point of this path is that nothing it does can reach the session.
|
|
402
|
+
log.warn('agent-auto-update: gave up', {
|
|
403
|
+
agent: runtime.wireKey,
|
|
404
|
+
error: String(error instanceof Error ? error.message : error),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Once a day, per Р12 — the vendor adds at most one file a day. */
|
|
409
|
+
static AGENT_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
|
410
|
+
/** Long enough for `hello_ack` to have told us which sessions are live. */
|
|
411
|
+
static AGENT_CLEANUP_FIRST_DELAY_MS = 60 * 60 * 1_000;
|
|
412
|
+
agentCleanupTimer;
|
|
413
|
+
agentCleanupFirstTimer;
|
|
414
|
+
/**
|
|
415
|
+
* Throw away the Claude version files nobody will run again (Р12, §4.7).
|
|
416
|
+
*
|
|
417
|
+
* Gated on NO LIVE AGENT PROCESS — `liveSessionCount`, not `sessions.size`.
|
|
418
|
+
*
|
|
419
|
+
* The stricter reading («nothing tracked at all») was the first version of
|
|
420
|
+
* this gate and it made the feature dead: parked sessions sit in that map
|
|
421
|
+
* until somebody closes them, so on any machine actually in use the sweep
|
|
422
|
+
* would never once have run, and Р12 would have shipped as a no-op nobody
|
|
423
|
+
* noticed. A parked session is not a risk to a version file either — it has
|
|
424
|
+
* no process, and when it wakes it relaunches through the launcher, which is
|
|
425
|
+
* the one file this sweep never touches.
|
|
426
|
+
*
|
|
427
|
+
* What guards a version somebody IS running is the per-file check in
|
|
428
|
+
* `agent-cleanup.ts` (`/proc/<pid>/exe`), which is the plan's own mechanism —
|
|
429
|
+
* «проверка по списку процессов». This gate is the cheap belt beside it: it
|
|
430
|
+
* keeps the sweep away from the minutes when Claude is most likely to be
|
|
431
|
+
* mid-spawn, where the process check has a window it cannot see.
|
|
432
|
+
*
|
|
433
|
+
* Also skipped while an install holds the lock: `claude install <version>` is
|
|
434
|
+
* rewriting the launcher right then, and «which file is current» has no stable
|
|
435
|
+
* answer until it finishes.
|
|
436
|
+
*/
|
|
437
|
+
sweepOldAgentVersions() {
|
|
438
|
+
// The empty string matches no session id, so this is «any live process».
|
|
439
|
+
if (this.liveSessionCount('') > 0)
|
|
440
|
+
return;
|
|
441
|
+
if (this.installBusyReason() !== null)
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
const swept = (this.opts.pruneAgentVersions ?? pruneNativeClaudeVersions)();
|
|
445
|
+
if (swept.removed.length > 0) {
|
|
446
|
+
log.info('agent-cleanup: removed old Claude versions', {
|
|
447
|
+
removed: swept.removed,
|
|
448
|
+
freedMb: Math.round(swept.freedBytes / 1024 / 1024),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
log.warn('agent-cleanup: sweep failed', { error: String(error) });
|
|
454
|
+
}
|
|
151
455
|
}
|
|
152
456
|
/** How often the seat report re-derives the truth. See the constructor. */
|
|
153
457
|
static SLOTS_REPORT_INTERVAL_MS = 15_000;
|
|
@@ -253,6 +557,10 @@ export class Supervisor {
|
|
|
253
557
|
// A build that finished while the socket was down has its verdict
|
|
254
558
|
// sitting on disk. This is the moment it can be delivered.
|
|
255
559
|
this.flushVerifyReports();
|
|
560
|
+
// The card of a machine that just came back must not show yesterday's
|
|
561
|
+
// agent versions. Deliberately not awaited: this same frame carries the
|
|
562
|
+
// session reconciliation, and a slow `codex doctor` must not hold it up.
|
|
563
|
+
void this.publishAgentVersions('measured');
|
|
256
564
|
break;
|
|
257
565
|
case 'verify_report_ack':
|
|
258
566
|
this.verifyReports.ack(frame.runId);
|
|
@@ -417,6 +725,17 @@ export class Supervisor {
|
|
|
417
725
|
});
|
|
418
726
|
return;
|
|
419
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Р13: move THIS session's agent forward, behind this session's back.
|
|
730
|
+
*
|
|
731
|
+
* Deliberately not awaited, and started before the workspace is prepared:
|
|
732
|
+
* the person asked for a session, and a 320 MB download must not be
|
|
733
|
+
* something they wait through. This session then finishes on the file it
|
|
734
|
+
* has already opened — replacing a binary does not disturb a process that
|
|
735
|
+
* holds it — and the new version takes effect for the next one. That is
|
|
736
|
+
* exactly the promise the switch's caption makes on the card.
|
|
737
|
+
*/
|
|
738
|
+
void this.maybeAutoUpdateAgent(descriptor);
|
|
420
739
|
const running = {
|
|
421
740
|
descriptor,
|
|
422
741
|
journal: this.journals.open(descriptor.id),
|
|
@@ -4109,10 +4428,10 @@ export class Supervisor {
|
|
|
4109
4428
|
// Second line of defence behind the API's per-server lock: two
|
|
4110
4429
|
// overlapping `npm install -g` into the same prefix is not something
|
|
4111
4430
|
// to leave to chance on someone else's machine.
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
this.
|
|
4431
|
+
const busy = this.installBusyReason();
|
|
4432
|
+
if (busy)
|
|
4433
|
+
return void reply({ ok: false, error: busy });
|
|
4434
|
+
this.installInFlight = 'self_update';
|
|
4116
4435
|
const run = this.opts.selfUpdate ?? selfUpdate;
|
|
4117
4436
|
let outcome;
|
|
4118
4437
|
try {
|
|
@@ -4120,7 +4439,7 @@ export class Supervisor {
|
|
|
4120
4439
|
}
|
|
4121
4440
|
catch (error) {
|
|
4122
4441
|
// A failed update must be retryable without restarting the daemon.
|
|
4123
|
-
this.
|
|
4442
|
+
this.installInFlight = null;
|
|
4124
4443
|
throw error;
|
|
4125
4444
|
}
|
|
4126
4445
|
// Deliberately NOT cleared on success: the process only exits ~1.5s
|
|
@@ -4128,7 +4447,7 @@ export class Supervisor {
|
|
|
4128
4447
|
// API releases its own lock the moment the reply lands. Clearing here
|
|
4129
4448
|
// left a window in which a second press started an `npm install -g`
|
|
4130
4449
|
// that systemd then killed mid-flight (QA-103 MINOR-8).
|
|
4131
|
-
this.
|
|
4450
|
+
this.installInFlight = outcome.ok && outcome.restart ? 'self_update' : null;
|
|
4132
4451
|
reply({
|
|
4133
4452
|
ok: outcome.ok,
|
|
4134
4453
|
result: outcome,
|
|
@@ -4138,6 +4457,69 @@ export class Supervisor {
|
|
|
4138
4457
|
this.opts.onRestartRequested?.(outcome);
|
|
4139
4458
|
return;
|
|
4140
4459
|
}
|
|
4460
|
+
case 'agent_install': {
|
|
4461
|
+
// The veto is enforced here as well as withheld from `hello`: an API
|
|
4462
|
+
// that has not noticed still must not install anything on a machine
|
|
4463
|
+
// whose owner said no.
|
|
4464
|
+
if (this.opts.agentInstallEnabled === false) {
|
|
4465
|
+
return void reply({
|
|
4466
|
+
ok: false,
|
|
4467
|
+
error: 'Installing agents is switched off on this server ([agents] install_enabled = false)',
|
|
4468
|
+
});
|
|
4469
|
+
}
|
|
4470
|
+
// Only these three ever cross the wire. The package name, the
|
|
4471
|
+
// installer URL and the command come from the registry compiled into
|
|
4472
|
+
// this build — a server that could name them would be a server that
|
|
4473
|
+
// could run anything here (§6 of the plan).
|
|
4474
|
+
const agent = str(frame.args?.['agent']);
|
|
4475
|
+
if (!agent)
|
|
4476
|
+
return void reply({ ok: false, error: 'agent is required' });
|
|
4477
|
+
const version = str(frame.args?.['version']);
|
|
4478
|
+
if (!version)
|
|
4479
|
+
return void reply({ ok: false, error: 'version is required' });
|
|
4480
|
+
const allowDowngrade = frame.args?.['allowDowngrade'] === true;
|
|
4481
|
+
const installBusy = this.installBusyReason();
|
|
4482
|
+
if (installBusy)
|
|
4483
|
+
return void reply({ ok: false, error: installBusy });
|
|
4484
|
+
this.installInFlight = 'agent_install';
|
|
4485
|
+
let installed;
|
|
4486
|
+
try {
|
|
4487
|
+
const runInstall = this.opts.installAgent ?? installAgent;
|
|
4488
|
+
installed = await runInstall({
|
|
4489
|
+
agent,
|
|
4490
|
+
version,
|
|
4491
|
+
...(allowDowngrade ? { allowDowngrade: true } : {}),
|
|
4492
|
+
});
|
|
4493
|
+
}
|
|
4494
|
+
finally {
|
|
4495
|
+
// Always cleared, unlike `self_update`: an agent install does not
|
|
4496
|
+
// restart the daemon, so a holder left behind would block every
|
|
4497
|
+
// later install for the life of the process.
|
|
4498
|
+
this.installInFlight = null;
|
|
4499
|
+
}
|
|
4500
|
+
reply({
|
|
4501
|
+
ok: installed.ok,
|
|
4502
|
+
result: installed,
|
|
4503
|
+
...(installed.ok ? {} : { error: installed.detail ?? 'Install failed' }),
|
|
4504
|
+
});
|
|
4505
|
+
// Re-measure after EVERY terminal outcome, not only after a success.
|
|
4506
|
+
// A failed install is exactly when the card is most likely to be
|
|
4507
|
+
// wrong: npm can unlink the old global package before dying, and a
|
|
4508
|
+
// rollback can fail too — and the hour-long measurement cache would
|
|
4509
|
+
// then keep re-publishing «0.150.0, up to date» about a machine that
|
|
4510
|
+
// no longer has the agent at all, on every reconnect.
|
|
4511
|
+
//
|
|
4512
|
+
// `changed` stays conditional: it is the audit payload (Р14), and
|
|
4513
|
+
// «updated from 1.2.3 to 1.2.3» would be a line about nothing.
|
|
4514
|
+
invalidateAgentVersions();
|
|
4515
|
+
const moved = installed.ok &&
|
|
4516
|
+
installed.toVersion !== undefined &&
|
|
4517
|
+
installed.toVersion !== installed.fromVersion
|
|
4518
|
+
? { agent: installed.agent, from: installed.fromVersion, to: installed.toVersion }
|
|
4519
|
+
: undefined;
|
|
4520
|
+
void this.publishAgentVersions('manual', moved);
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4141
4523
|
/**
|
|
4142
4524
|
* «Would this file reach the agent, and how big is it» (ticket #192).
|
|
4143
4525
|
*
|
|
@@ -4522,6 +4904,9 @@ export class Supervisor {
|
|
|
4522
4904
|
/** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
|
|
4523
4905
|
shutdown() {
|
|
4524
4906
|
clearInterval(this.slotsTimer);
|
|
4907
|
+
clearInterval(this.agentVersionsTimer);
|
|
4908
|
+
clearTimeout(this.agentCleanupFirstTimer);
|
|
4909
|
+
clearInterval(this.agentCleanupTimer);
|
|
4525
4910
|
this.authRelay.cancel();
|
|
4526
4911
|
for (const running of this.sessions.values()) {
|
|
4527
4912
|
this.clearBudgetTimers(running);
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.47.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED