@bridge4dev/runner 0.46.1 → 0.48.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/adapters/codex.js +7 -4
- 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/levels.d.ts +49 -0
- package/dist/levels.js +51 -0
- 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 +186 -2
- package/dist/supervisor.js +503 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { log } from './log.js';
|
|
4
|
+
import { CONTEXT_USAGE_MIN_DELTA_RATIO, CONTEXT_USAGE_MIN_DELTA_TOKENS, RATE_LIMITS_RESEND_INTERVAL_MS, } from './levels.js';
|
|
4
5
|
import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
|
|
5
6
|
import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryDelayMs, } from './adapters/error-policy.js';
|
|
6
7
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
@@ -17,6 +18,11 @@ import { fsView } from './fsview.js';
|
|
|
17
18
|
import { publishFile } from './file-publish.js';
|
|
18
19
|
import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
|
|
19
20
|
import { selfUpdate } from './self-update.js';
|
|
21
|
+
import { installAgent } from './agent-install.js';
|
|
22
|
+
import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
|
|
23
|
+
import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
24
|
+
import { agentByDbValue } from './agent-registry.js';
|
|
25
|
+
import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
|
|
20
26
|
import { rememberWorkspacePath } from './environment.js';
|
|
21
27
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
22
28
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
@@ -53,6 +59,16 @@ function gitPolicyOf(descriptor) {
|
|
|
53
59
|
: {}),
|
|
54
60
|
};
|
|
55
61
|
}
|
|
62
|
+
function freshLevels() {
|
|
63
|
+
return {
|
|
64
|
+
contextSent: null,
|
|
65
|
+
contextHeld: null,
|
|
66
|
+
contextPassNext: false,
|
|
67
|
+
limitsSent: null,
|
|
68
|
+
sent: 0,
|
|
69
|
+
dropped: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
56
72
|
const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
|
|
57
73
|
export class Supervisor {
|
|
58
74
|
ws;
|
|
@@ -80,6 +96,7 @@ export class Supervisor {
|
|
|
80
96
|
static EMPTY_TURN_SETTLE_MS = 25_000;
|
|
81
97
|
/** The window actually used — the constant, or a test's own shorter one. */
|
|
82
98
|
emptyTurnSettleMs;
|
|
99
|
+
rateLimitsResendMs;
|
|
83
100
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
84
101
|
static JOURNAL_TTL_MS = 72 * 3_600_000;
|
|
85
102
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -108,8 +125,20 @@ export class Supervisor {
|
|
|
108
125
|
* than having no restore point for that one message.
|
|
109
126
|
*/
|
|
110
127
|
repoLockDepth = new Map();
|
|
111
|
-
/**
|
|
112
|
-
|
|
128
|
+
/**
|
|
129
|
+
* One install at a time on this machine, whoever asked for it.
|
|
130
|
+
*
|
|
131
|
+
* `self_update` and `agent_install` both end in `npm install -g` into the
|
|
132
|
+
* SAME npm prefix under the dedicated-user layout, so two of them at once is
|
|
133
|
+
* a real conflict rather than a theoretical one. The API takes a Redis lock
|
|
134
|
+
* before pressing either button, but that lock cannot cover an install the
|
|
135
|
+
* runner starts by itself (stage D) — and the runner has no Redis. This is
|
|
136
|
+
* the only lock that sees both.
|
|
137
|
+
*
|
|
138
|
+
* Holds the holder's name rather than a boolean, so the refusal can say
|
|
139
|
+
* which of the two is running.
|
|
140
|
+
*/
|
|
141
|
+
installInFlight = null;
|
|
113
142
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
114
143
|
verify;
|
|
115
144
|
verifyReports = new VerifyReportQueue();
|
|
@@ -118,6 +147,7 @@ export class Supervisor {
|
|
|
118
147
|
this.opts = opts;
|
|
119
148
|
this.journals = opts.journals ?? new JournalStore();
|
|
120
149
|
this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
|
|
150
|
+
this.rateLimitsResendMs = opts.rateLimitsResendMs ?? RATE_LIMITS_RESEND_INTERVAL_MS;
|
|
121
151
|
this.verify = new VerifyRunner({
|
|
122
152
|
enabled: opts.verifyEnabled !== false,
|
|
123
153
|
onReport: (report) => {
|
|
@@ -148,6 +178,293 @@ export class Supervisor {
|
|
|
148
178
|
*/
|
|
149
179
|
this.slotsTimer = setInterval(() => this.publishSlots(), Supervisor.SLOTS_REPORT_INTERVAL_MS);
|
|
150
180
|
this.slotsTimer.unref?.();
|
|
181
|
+
/**
|
|
182
|
+
* Agent versions on a heartbeat too, for a different reason than seats.
|
|
183
|
+
*
|
|
184
|
+
* Nothing on this machine tells us when somebody updates Codex by hand, and
|
|
185
|
+
* the measurement behind the frame is cached for an hour anyway — so the
|
|
186
|
+
* tick costs a cached read most of the time and still notices a hand-made
|
|
187
|
+
* change within the hour. What makes a freshly connected card correct
|
|
188
|
+
* immediately is the same frame sent on every `hello_ack`.
|
|
189
|
+
*/
|
|
190
|
+
this.agentVersionsTimer = setInterval(() => void this.publishAgentVersions('measured'), Supervisor.AGENT_VERSIONS_INTERVAL_MS);
|
|
191
|
+
this.agentVersionsTimer.unref?.();
|
|
192
|
+
/**
|
|
193
|
+
* Р12: the old Claude version files, swept once a day.
|
|
194
|
+
*
|
|
195
|
+
* Two timers rather than one, and the delayed first sweep is the load-bearing
|
|
196
|
+
* half. The gate below is «this runner is tracking no sessions», and at the
|
|
197
|
+
* moment a daemon starts that is trivially true — the map fills from the
|
|
198
|
+
* `hello_ack` reconciliation seconds later. Sweeping in that window would be
|
|
199
|
+
* sweeping exactly when the answer is least trustworthy, on the one machine
|
|
200
|
+
* shape (restarted often) where it would happen every time.
|
|
201
|
+
*/
|
|
202
|
+
const cleanupFirst = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_FIRST_DELAY_MS;
|
|
203
|
+
const cleanupEvery = opts.agentCleanupMs ?? Supervisor.AGENT_CLEANUP_INTERVAL_MS;
|
|
204
|
+
this.agentCleanupFirstTimer = setTimeout(() => this.sweepOldAgentVersions(), cleanupFirst);
|
|
205
|
+
this.agentCleanupFirstTimer.unref?.();
|
|
206
|
+
this.agentCleanupTimer = setInterval(() => this.sweepOldAgentVersions(), cleanupEvery);
|
|
207
|
+
this.agentCleanupTimer.unref?.();
|
|
208
|
+
}
|
|
209
|
+
/** How often the agent versions are re-derived. See the constructor. */
|
|
210
|
+
static AGENT_VERSIONS_INTERVAL_MS = 60 * 60 * 1_000;
|
|
211
|
+
agentVersionsTimer;
|
|
212
|
+
/**
|
|
213
|
+
* Tell the API which agents this machine has and at which versions.
|
|
214
|
+
*
|
|
215
|
+
* Never throws and never blocks its caller: a machine where a probe hangs
|
|
216
|
+
* must still start sessions. A frame that could not be written is simply not
|
|
217
|
+
* sent — the next `hello_ack` or the hourly tick carries the same fact, and
|
|
218
|
+
* the measurement behind it is cached, so retrying is nearly free.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Why an install cannot start right now, in words for the person who pressed.
|
|
222
|
+
*
|
|
223
|
+
* `null` means the way is clear.
|
|
224
|
+
*/
|
|
225
|
+
installBusyReason() {
|
|
226
|
+
if (this.installInFlight === 'self_update')
|
|
227
|
+
return 'An update is already running';
|
|
228
|
+
if (this.installInFlight === 'agent_install') {
|
|
229
|
+
return 'An agent is already being installed on this server';
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* A version change that has happened but has not reached the API yet.
|
|
235
|
+
*
|
|
236
|
+
* `ws.send` returns false when the socket is down, and an install finishing
|
|
237
|
+
* during a reconnect is not a rare case — it is a 300 MB download that takes
|
|
238
|
+
* minutes. The VERSION recovers on its own (the hourly tick and every
|
|
239
|
+
* `hello_ack` re-send the measurement), but `reason` and `changed` are the
|
|
240
|
+
* audit line itself: dropped, «why is my Codex suddenly different» has no
|
|
241
|
+
* answer anywhere, which is precisely what Р14 exists to prevent.
|
|
242
|
+
*
|
|
243
|
+
* Kept in memory rather than on disk on purpose. A daemon that died mid-install
|
|
244
|
+
* has a bigger hole than one lost line, and persisting it would risk filing an
|
|
245
|
+
* install that a rollback then undid.
|
|
246
|
+
*/
|
|
247
|
+
pendingVersionChanges = [];
|
|
248
|
+
/**
|
|
249
|
+
* Two installs cannot overlap — the local lock sees to that — but two can
|
|
250
|
+
* both FINISH inside one outage, and each is a line the log owes. A queue
|
|
251
|
+
* rather than a slot, because the second would otherwise overwrite the first
|
|
252
|
+
* and the older install would be the one that vanished.
|
|
253
|
+
*
|
|
254
|
+
* Bounded: an outage long enough to strand nine installs is one where the
|
|
255
|
+
* missing audit lines are not the problem worth solving.
|
|
256
|
+
*/
|
|
257
|
+
static MAX_PENDING_VERSION_CHANGES = 8;
|
|
258
|
+
async publishAgentVersions(reason, changed) {
|
|
259
|
+
try {
|
|
260
|
+
const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
|
|
261
|
+
// After an install — ours or the auto one — the cached number is a lie,
|
|
262
|
+
// and a stale `at` with it would be dropped by the API's ordering guard
|
|
263
|
+
// together with the audit line the install owes. Forcing it here means a
|
|
264
|
+
// caller cannot forget to invalidate the cache first.
|
|
265
|
+
const measurement = await measure({ force: reason !== 'measured' });
|
|
266
|
+
// A plain tick carries an earlier install's news rather than replacing it.
|
|
267
|
+
// The API keys the audit line by the measurement's `at`, so the same
|
|
268
|
+
// change arriving under a later `at` is one line, not two.
|
|
269
|
+
//
|
|
270
|
+
// `changed` is only ever handed over by an install, and an install always
|
|
271
|
+
// calls this with `manual` or `auto` — never `measured`.
|
|
272
|
+
if (changed && reason !== 'measured') {
|
|
273
|
+
this.queueVersionChange({ reason, changed });
|
|
274
|
+
}
|
|
275
|
+
const carried = this.pendingVersionChanges[0];
|
|
276
|
+
const sent = this.ws.send({
|
|
277
|
+
type: 'agent_versions',
|
|
278
|
+
at: measurement.at,
|
|
279
|
+
reason: carried?.reason ?? reason,
|
|
280
|
+
agents: measurement.agents,
|
|
281
|
+
...(carried ? { changed: carried.changed } : {}),
|
|
282
|
+
});
|
|
283
|
+
// Dropped only once a frame is genuinely written.
|
|
284
|
+
if (sent && carried) {
|
|
285
|
+
this.pendingVersionChanges.shift();
|
|
286
|
+
// One line per frame, so a backlog needs one frame each. Recursing only
|
|
287
|
+
// after a SUCCESSFUL send is what makes this terminate: the queue is
|
|
288
|
+
// shorter every time, and a failed write leaves the loop instead.
|
|
289
|
+
if (this.pendingVersionChanges.length > 0) {
|
|
290
|
+
void this.publishAgentVersions('measured');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (changed && reason !== 'measured')
|
|
296
|
+
this.queueVersionChange({ reason, changed });
|
|
297
|
+
log.warn('supervisor: could not report agent versions', { error: String(error) });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
queueVersionChange(entry) {
|
|
301
|
+
if (this.pendingVersionChanges.length >= Supervisor.MAX_PENDING_VERSION_CHANGES) {
|
|
302
|
+
log.warn('supervisor: dropping the oldest unreported version change', {
|
|
303
|
+
agent: this.pendingVersionChanges[0]?.changed.agent,
|
|
304
|
+
});
|
|
305
|
+
this.pendingVersionChanges.shift();
|
|
306
|
+
}
|
|
307
|
+
this.pendingVersionChanges.push(entry);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Р13: the agent of a starting session, moved forward in the background.
|
|
311
|
+
*
|
|
312
|
+
* The whole mechanism hangs off ONE fact — whether the API put a version in
|
|
313
|
+
* the descriptor. It does that only for machines whose «Auto-update agents»
|
|
314
|
+
* switch is on, so there is no second copy of the setting here to disagree
|
|
315
|
+
* with it, and a runner that is told nothing does nothing.
|
|
316
|
+
*
|
|
317
|
+
* Never throws and never reports anything into the session feed. A person who
|
|
318
|
+
* pressed «start» asked for a session; a failed background download is not
|
|
319
|
+
* their business, and a scary red line about one would be worse than the
|
|
320
|
+
* silence. Where it IS visible is the server card: the `agent_versions` frame
|
|
321
|
+
* at the end goes out after every outcome, so a machine that ended up without
|
|
322
|
+
* a working agent says so within seconds rather than at the next hourly tick.
|
|
323
|
+
*/
|
|
324
|
+
async maybeAutoUpdateAgent(descriptor) {
|
|
325
|
+
const latest = descriptor.agentLatestVersion;
|
|
326
|
+
// The switch. Absent = off, and that covers «the card says no», «we never
|
|
327
|
+
// managed to ask a registry» and «the API predates this release» alike.
|
|
328
|
+
if (!latest)
|
|
329
|
+
return;
|
|
330
|
+
// The machine owner's veto over installing agents at all covers this too:
|
|
331
|
+
// it is the same download onto the same disk, asked for by us instead of by
|
|
332
|
+
// a person. Refusing the button and allowing the timer would be a hole in a
|
|
333
|
+
// control whose entire point is that the server cannot talk past it.
|
|
334
|
+
if (this.opts.agentInstallEnabled === false)
|
|
335
|
+
return;
|
|
336
|
+
const runtime = agentByDbValue(descriptor.agent);
|
|
337
|
+
if (!runtime)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
// The CACHED measurement, not a fresh probe.
|
|
341
|
+
//
|
|
342
|
+
// This runs on every session start, and a fresh probe is two child
|
|
343
|
+
// processes per start — `claude --version` plus `claude doctor` — on the
|
|
344
|
+
// machine somebody is trying to start work on. The cache is an hour old at
|
|
345
|
+
// worst and the throttle below is a day, so staleness cannot cost a missed
|
|
346
|
+
// update; and being wrong the other way costs nothing either, because
|
|
347
|
+
// `installAgent` measures again for real and answers «already at that
|
|
348
|
+
// version» without touching anything.
|
|
349
|
+
const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
|
|
350
|
+
const snapshot = await measure();
|
|
351
|
+
const measured = snapshot.agents[runtime.wireKey] ?? {
|
|
352
|
+
version: null,
|
|
353
|
+
managedBy: 'unknown',
|
|
354
|
+
};
|
|
355
|
+
const refusal = autoUpdateRefusal({ runtime, measured, latest });
|
|
356
|
+
if (refusal) {
|
|
357
|
+
log.debug('agent-auto-update: leaving it alone', {
|
|
358
|
+
agent: runtime.wireKey,
|
|
359
|
+
reason: refusal,
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// The lock is read BEFORE the day is claimed, and the order matters: three
|
|
364
|
+
// sessions starting at once are the ordinary case here, and spending the
|
|
365
|
+
// claim on an attempt that the lock then refuses would cost the agent its
|
|
366
|
+
// whole day for an install that never began.
|
|
367
|
+
//
|
|
368
|
+
// Still exactly one winner: `installBusyReason`, `claimAgentAutoUpdate`
|
|
369
|
+
// and the assignment below are all synchronous, with no await between
|
|
370
|
+
// them, so no second coroutine can interleave into the gap.
|
|
371
|
+
if (this.installBusyReason() !== null)
|
|
372
|
+
return;
|
|
373
|
+
// Claimed before the install and written to disk: a machine that fails for
|
|
374
|
+
// a reason it cannot fix (no space, no network) must go quiet for the day
|
|
375
|
+
// instead of retrying at every session start.
|
|
376
|
+
if (!claimAgentAutoUpdate(runtime.wireKey))
|
|
377
|
+
return;
|
|
378
|
+
this.installInFlight = 'agent_install';
|
|
379
|
+
let outcome;
|
|
380
|
+
try {
|
|
381
|
+
const run = this.opts.installAgent ?? installAgent;
|
|
382
|
+
outcome = await run({ agent: runtime.wireKey, version: latest });
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
this.installInFlight = null;
|
|
386
|
+
}
|
|
387
|
+
if (outcome.ok) {
|
|
388
|
+
log.info('agent-auto-update: installed', {
|
|
389
|
+
agent: runtime.wireKey,
|
|
390
|
+
from: outcome.fromVersion,
|
|
391
|
+
to: outcome.toVersion,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
log.warn('agent-auto-update: install failed', {
|
|
396
|
+
agent: runtime.wireKey,
|
|
397
|
+
version: latest,
|
|
398
|
+
error: outcome.detail,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// After EVERY outcome, for the reason the button's handler gives: a failed
|
|
402
|
+
// install is exactly when the card is most likely to be wrong, because npm
|
|
403
|
+
// can unlink the old package before dying. `reason: 'auto'` is what earns
|
|
404
|
+
// the `dev.server.agent_auto_updated` audit line on the other side (Р14),
|
|
405
|
+
// and `changed` is its payload — omitted when nothing actually moved.
|
|
406
|
+
invalidateAgentVersions();
|
|
407
|
+
const moved = outcome.ok && outcome.toVersion !== undefined && outcome.toVersion !== outcome.fromVersion
|
|
408
|
+
? { agent: outcome.agent, from: outcome.fromVersion, to: outcome.toVersion }
|
|
409
|
+
: undefined;
|
|
410
|
+
await this.publishAgentVersions('auto', moved);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
// `installAgent` never throws, but `measureAgent` spawns processes and the
|
|
414
|
+
// whole point of this path is that nothing it does can reach the session.
|
|
415
|
+
log.warn('agent-auto-update: gave up', {
|
|
416
|
+
agent: runtime.wireKey,
|
|
417
|
+
error: String(error instanceof Error ? error.message : error),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Once a day, per Р12 — the vendor adds at most one file a day. */
|
|
422
|
+
static AGENT_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
|
|
423
|
+
/** Long enough for `hello_ack` to have told us which sessions are live. */
|
|
424
|
+
static AGENT_CLEANUP_FIRST_DELAY_MS = 60 * 60 * 1_000;
|
|
425
|
+
agentCleanupTimer;
|
|
426
|
+
agentCleanupFirstTimer;
|
|
427
|
+
/**
|
|
428
|
+
* Throw away the Claude version files nobody will run again (Р12, §4.7).
|
|
429
|
+
*
|
|
430
|
+
* Gated on NO LIVE AGENT PROCESS — `liveSessionCount`, not `sessions.size`.
|
|
431
|
+
*
|
|
432
|
+
* The stricter reading («nothing tracked at all») was the first version of
|
|
433
|
+
* this gate and it made the feature dead: parked sessions sit in that map
|
|
434
|
+
* until somebody closes them, so on any machine actually in use the sweep
|
|
435
|
+
* would never once have run, and Р12 would have shipped as a no-op nobody
|
|
436
|
+
* noticed. A parked session is not a risk to a version file either — it has
|
|
437
|
+
* no process, and when it wakes it relaunches through the launcher, which is
|
|
438
|
+
* the one file this sweep never touches.
|
|
439
|
+
*
|
|
440
|
+
* What guards a version somebody IS running is the per-file check in
|
|
441
|
+
* `agent-cleanup.ts` (`/proc/<pid>/exe`), which is the plan's own mechanism —
|
|
442
|
+
* «проверка по списку процессов». This gate is the cheap belt beside it: it
|
|
443
|
+
* keeps the sweep away from the minutes when Claude is most likely to be
|
|
444
|
+
* mid-spawn, where the process check has a window it cannot see.
|
|
445
|
+
*
|
|
446
|
+
* Also skipped while an install holds the lock: `claude install <version>` is
|
|
447
|
+
* rewriting the launcher right then, and «which file is current» has no stable
|
|
448
|
+
* answer until it finishes.
|
|
449
|
+
*/
|
|
450
|
+
sweepOldAgentVersions() {
|
|
451
|
+
// The empty string matches no session id, so this is «any live process».
|
|
452
|
+
if (this.liveSessionCount('') > 0)
|
|
453
|
+
return;
|
|
454
|
+
if (this.installBusyReason() !== null)
|
|
455
|
+
return;
|
|
456
|
+
try {
|
|
457
|
+
const swept = (this.opts.pruneAgentVersions ?? pruneNativeClaudeVersions)();
|
|
458
|
+
if (swept.removed.length > 0) {
|
|
459
|
+
log.info('agent-cleanup: removed old Claude versions', {
|
|
460
|
+
removed: swept.removed,
|
|
461
|
+
freedMb: Math.round(swept.freedBytes / 1024 / 1024),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
log.warn('agent-cleanup: sweep failed', { error: String(error) });
|
|
467
|
+
}
|
|
151
468
|
}
|
|
152
469
|
/** How often the seat report re-derives the truth. See the constructor. */
|
|
153
470
|
static SLOTS_REPORT_INTERVAL_MS = 15_000;
|
|
@@ -253,6 +570,10 @@ export class Supervisor {
|
|
|
253
570
|
// A build that finished while the socket was down has its verdict
|
|
254
571
|
// sitting on disk. This is the moment it can be delivered.
|
|
255
572
|
this.flushVerifyReports();
|
|
573
|
+
// The card of a machine that just came back must not show yesterday's
|
|
574
|
+
// agent versions. Deliberately not awaited: this same frame carries the
|
|
575
|
+
// session reconciliation, and a slow `codex doctor` must not hold it up.
|
|
576
|
+
void this.publishAgentVersions('measured');
|
|
256
577
|
break;
|
|
257
578
|
case 'verify_report_ack':
|
|
258
579
|
this.verifyReports.ack(frame.runId);
|
|
@@ -417,6 +738,17 @@ export class Supervisor {
|
|
|
417
738
|
});
|
|
418
739
|
return;
|
|
419
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Р13: move THIS session's agent forward, behind this session's back.
|
|
743
|
+
*
|
|
744
|
+
* Deliberately not awaited, and started before the workspace is prepared:
|
|
745
|
+
* the person asked for a session, and a 320 MB download must not be
|
|
746
|
+
* something they wait through. This session then finishes on the file it
|
|
747
|
+
* has already opened — replacing a binary does not disturb a process that
|
|
748
|
+
* holds it — and the new version takes effect for the next one. That is
|
|
749
|
+
* exactly the promise the switch's caption makes on the card.
|
|
750
|
+
*/
|
|
751
|
+
void this.maybeAutoUpdateAgent(descriptor);
|
|
420
752
|
const running = {
|
|
421
753
|
descriptor,
|
|
422
754
|
journal: this.journals.open(descriptor.id),
|
|
@@ -424,6 +756,7 @@ export class Supervisor {
|
|
|
424
756
|
lastReported: descriptor.status,
|
|
425
757
|
costUsd: descriptor.costUsd,
|
|
426
758
|
costBaseUsd: descriptor.costUsd,
|
|
759
|
+
levels: freshLevels(),
|
|
427
760
|
stopRequested: false,
|
|
428
761
|
parkRequested: false,
|
|
429
762
|
pendingMessages: [],
|
|
@@ -1378,12 +1711,101 @@ export class Supervisor {
|
|
|
1378
1711
|
* its timer runs out, and it must end in exactly the way it would have
|
|
1379
1712
|
* ended immediately. A copy would be two behaviours one edit apart.
|
|
1380
1713
|
*/
|
|
1714
|
+
/**
|
|
1715
|
+
* The level gate for the context meter (#366).
|
|
1716
|
+
*
|
|
1717
|
+
* While a turn is open, a frame goes out only when the meter moved by at
|
|
1718
|
+
* least one of the shared thresholds or the window itself changed; the
|
|
1719
|
+
* newest held-back value is flushed right before `turn_end`, so the ring at
|
|
1720
|
+
* rest is exact. Outside a turn every frame passes – there are only a handful
|
|
1721
|
+
* (process start, a model change, and the Claude adapter's own measurement,
|
|
1722
|
+
* which resolves AFTER `turn_end`), and each one is news.
|
|
1723
|
+
*/
|
|
1724
|
+
forwardContextUsage(running, usedTokens, maxTokens) {
|
|
1725
|
+
const levels = running.levels;
|
|
1726
|
+
const frame = { usedTokens, maxTokens };
|
|
1727
|
+
const last = levels.contextSent;
|
|
1728
|
+
const turnOpen = running.lastReported === 'RUNNING';
|
|
1729
|
+
const moved = last === null ? Number.POSITIVE_INFINITY : Math.abs(usedTokens - last.usedTokens);
|
|
1730
|
+
const pass = last === null ||
|
|
1731
|
+
last.maxTokens !== maxTokens ||
|
|
1732
|
+
levels.contextPassNext ||
|
|
1733
|
+
!turnOpen ||
|
|
1734
|
+
moved >= CONTEXT_USAGE_MIN_DELTA_TOKENS ||
|
|
1735
|
+
moved / maxTokens >= CONTEXT_USAGE_MIN_DELTA_RATIO;
|
|
1736
|
+
// Only the newest value is worth keeping; whatever it replaces is gone.
|
|
1737
|
+
if (levels.contextHeld !== null)
|
|
1738
|
+
levels.dropped += 1;
|
|
1739
|
+
if (!pass) {
|
|
1740
|
+
levels.contextHeld = frame;
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
levels.contextHeld = null;
|
|
1744
|
+
levels.contextPassNext = false;
|
|
1745
|
+
levels.contextSent = frame;
|
|
1746
|
+
levels.sent += 1;
|
|
1747
|
+
this.sendEvent(running, 'context_usage', frame);
|
|
1748
|
+
}
|
|
1749
|
+
/** Send the value the gate was holding, if any – strictly before `turn_end`. */
|
|
1750
|
+
flushHeldContextUsage(running) {
|
|
1751
|
+
const levels = running.levels;
|
|
1752
|
+
const held = levels.contextHeld;
|
|
1753
|
+
if (held === null)
|
|
1754
|
+
return;
|
|
1755
|
+
levels.contextHeld = null;
|
|
1756
|
+
// A held value identical to the last one sent is not news – flushing it
|
|
1757
|
+
// would add a frame to every turn and give back half the saving.
|
|
1758
|
+
const sent = levels.contextSent;
|
|
1759
|
+
if (sent !== null && sent.usedTokens === held.usedTokens && sent.maxTokens === held.maxTokens) {
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
levels.contextSent = held;
|
|
1763
|
+
levels.sent += 1;
|
|
1764
|
+
this.sendEvent(running, 'context_usage', held);
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* The level gate for plan usage (#366).
|
|
1768
|
+
*
|
|
1769
|
+
* A snapshot goes out when it is the first, when anything but `measuredAt`
|
|
1770
|
+
* changed, when it carries a refusal (#258 – always, even twice in a row), or
|
|
1771
|
+
* when the one last sent is older than the resend floor. The fingerprint is
|
|
1772
|
+
* the whole payload minus the clock, so a field an adapter adds tomorrow is
|
|
1773
|
+
* part of it without anybody remembering to list it here.
|
|
1774
|
+
*/
|
|
1775
|
+
forwardRateLimits(running, limits) {
|
|
1776
|
+
const levels = running.levels;
|
|
1777
|
+
const print = JSON.stringify({ ...limits, measuredAt: undefined });
|
|
1778
|
+
const now = Date.now();
|
|
1779
|
+
const last = levels.limitsSent;
|
|
1780
|
+
const pass = last === null ||
|
|
1781
|
+
Boolean(limits.blocked) ||
|
|
1782
|
+
last.print !== print ||
|
|
1783
|
+
now - last.at >= this.rateLimitsResendMs;
|
|
1784
|
+
if (!pass) {
|
|
1785
|
+
levels.dropped += 1;
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
levels.limitsSent = { print, at: now };
|
|
1789
|
+
levels.sent += 1;
|
|
1790
|
+
this.sendEvent(running, 'rate_limits', { ...limits });
|
|
1791
|
+
}
|
|
1381
1792
|
completeTurn(running, descriptor, event) {
|
|
1793
|
+
// #366: the ring at rest must be exact – the value the gate held back
|
|
1794
|
+
// during the turn goes out first, so it is older than `turn_end` by seq.
|
|
1795
|
+
this.flushHeldContextUsage(running);
|
|
1382
1796
|
this.sendEvent(running, 'turn_end', {
|
|
1383
1797
|
ok: event.ok,
|
|
1384
1798
|
errorMessage: event.errorMessage,
|
|
1385
1799
|
...(event.aborted ? { aborted: true } : {}),
|
|
1386
1800
|
});
|
|
1801
|
+
// …and whatever the adapter measures once the turn is over (Claude does,
|
|
1802
|
+
// asynchronously) is news, not a step of the same turn.
|
|
1803
|
+
running.levels.contextPassNext = true;
|
|
1804
|
+
log.info('supervisor: levels', {
|
|
1805
|
+
sessionId: descriptor.id,
|
|
1806
|
+
sent: running.levels.sent,
|
|
1807
|
+
dropped: running.levels.dropped,
|
|
1808
|
+
});
|
|
1387
1809
|
// The session is already on its way out with a status that MEANS
|
|
1388
1810
|
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
1389
1811
|
// that window is a consequence of it, and letting the line below
|
|
@@ -1902,17 +2324,17 @@ export class Supervisor {
|
|
|
1902
2324
|
});
|
|
1903
2325
|
return;
|
|
1904
2326
|
case 'context_usage':
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
});
|
|
2327
|
+
// #366. A LEVEL: only a value that moved is worth a frame. The gate
|
|
2328
|
+
// decides before `sendEvent`, so a dropped frame never spends a seq.
|
|
2329
|
+
this.forwardContextUsage(running, event.usedTokens, event.maxTokens);
|
|
1909
2330
|
return;
|
|
1910
2331
|
// #279. A LEVEL signal like `agent_tasks`: every frame carries the whole
|
|
1911
|
-
// snapshot, so the API
|
|
2332
|
+
// snapshot, so the API keeps what arrived rather than merging, and a
|
|
1912
2333
|
// dropped frame costs freshness, never correctness. Account-wide, not
|
|
1913
2334
|
// session-wide — the API files it under the SERVER, not this session.
|
|
2335
|
+
// #366: gated the same way as the context meter, see `forwardRateLimits`.
|
|
1914
2336
|
case 'rate_limits':
|
|
1915
|
-
this.
|
|
2337
|
+
this.forwardRateLimits(running, event.limits);
|
|
1916
2338
|
return;
|
|
1917
2339
|
case 'agent_tasks': {
|
|
1918
2340
|
// Ticket #113. A LEVEL signal: every frame carries the whole live set,
|
|
@@ -3125,6 +3547,7 @@ export class Supervisor {
|
|
|
3125
3547
|
lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
|
|
3126
3548
|
costUsd: descriptor.costUsd,
|
|
3127
3549
|
costBaseUsd: descriptor.costUsd,
|
|
3550
|
+
levels: freshLevels(),
|
|
3128
3551
|
stopRequested: false,
|
|
3129
3552
|
parkRequested: false,
|
|
3130
3553
|
pendingMessages: [],
|
|
@@ -4109,10 +4532,10 @@ export class Supervisor {
|
|
|
4109
4532
|
// Second line of defence behind the API's per-server lock: two
|
|
4110
4533
|
// overlapping `npm install -g` into the same prefix is not something
|
|
4111
4534
|
// to leave to chance on someone else's machine.
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
this.
|
|
4535
|
+
const busy = this.installBusyReason();
|
|
4536
|
+
if (busy)
|
|
4537
|
+
return void reply({ ok: false, error: busy });
|
|
4538
|
+
this.installInFlight = 'self_update';
|
|
4116
4539
|
const run = this.opts.selfUpdate ?? selfUpdate;
|
|
4117
4540
|
let outcome;
|
|
4118
4541
|
try {
|
|
@@ -4120,7 +4543,7 @@ export class Supervisor {
|
|
|
4120
4543
|
}
|
|
4121
4544
|
catch (error) {
|
|
4122
4545
|
// A failed update must be retryable without restarting the daemon.
|
|
4123
|
-
this.
|
|
4546
|
+
this.installInFlight = null;
|
|
4124
4547
|
throw error;
|
|
4125
4548
|
}
|
|
4126
4549
|
// Deliberately NOT cleared on success: the process only exits ~1.5s
|
|
@@ -4128,7 +4551,7 @@ export class Supervisor {
|
|
|
4128
4551
|
// API releases its own lock the moment the reply lands. Clearing here
|
|
4129
4552
|
// left a window in which a second press started an `npm install -g`
|
|
4130
4553
|
// that systemd then killed mid-flight (QA-103 MINOR-8).
|
|
4131
|
-
this.
|
|
4554
|
+
this.installInFlight = outcome.ok && outcome.restart ? 'self_update' : null;
|
|
4132
4555
|
reply({
|
|
4133
4556
|
ok: outcome.ok,
|
|
4134
4557
|
result: outcome,
|
|
@@ -4138,6 +4561,69 @@ export class Supervisor {
|
|
|
4138
4561
|
this.opts.onRestartRequested?.(outcome);
|
|
4139
4562
|
return;
|
|
4140
4563
|
}
|
|
4564
|
+
case 'agent_install': {
|
|
4565
|
+
// The veto is enforced here as well as withheld from `hello`: an API
|
|
4566
|
+
// that has not noticed still must not install anything on a machine
|
|
4567
|
+
// whose owner said no.
|
|
4568
|
+
if (this.opts.agentInstallEnabled === false) {
|
|
4569
|
+
return void reply({
|
|
4570
|
+
ok: false,
|
|
4571
|
+
error: 'Installing agents is switched off on this server ([agents] install_enabled = false)',
|
|
4572
|
+
});
|
|
4573
|
+
}
|
|
4574
|
+
// Only these three ever cross the wire. The package name, the
|
|
4575
|
+
// installer URL and the command come from the registry compiled into
|
|
4576
|
+
// this build — a server that could name them would be a server that
|
|
4577
|
+
// could run anything here (§6 of the plan).
|
|
4578
|
+
const agent = str(frame.args?.['agent']);
|
|
4579
|
+
if (!agent)
|
|
4580
|
+
return void reply({ ok: false, error: 'agent is required' });
|
|
4581
|
+
const version = str(frame.args?.['version']);
|
|
4582
|
+
if (!version)
|
|
4583
|
+
return void reply({ ok: false, error: 'version is required' });
|
|
4584
|
+
const allowDowngrade = frame.args?.['allowDowngrade'] === true;
|
|
4585
|
+
const installBusy = this.installBusyReason();
|
|
4586
|
+
if (installBusy)
|
|
4587
|
+
return void reply({ ok: false, error: installBusy });
|
|
4588
|
+
this.installInFlight = 'agent_install';
|
|
4589
|
+
let installed;
|
|
4590
|
+
try {
|
|
4591
|
+
const runInstall = this.opts.installAgent ?? installAgent;
|
|
4592
|
+
installed = await runInstall({
|
|
4593
|
+
agent,
|
|
4594
|
+
version,
|
|
4595
|
+
...(allowDowngrade ? { allowDowngrade: true } : {}),
|
|
4596
|
+
});
|
|
4597
|
+
}
|
|
4598
|
+
finally {
|
|
4599
|
+
// Always cleared, unlike `self_update`: an agent install does not
|
|
4600
|
+
// restart the daemon, so a holder left behind would block every
|
|
4601
|
+
// later install for the life of the process.
|
|
4602
|
+
this.installInFlight = null;
|
|
4603
|
+
}
|
|
4604
|
+
reply({
|
|
4605
|
+
ok: installed.ok,
|
|
4606
|
+
result: installed,
|
|
4607
|
+
...(installed.ok ? {} : { error: installed.detail ?? 'Install failed' }),
|
|
4608
|
+
});
|
|
4609
|
+
// Re-measure after EVERY terminal outcome, not only after a success.
|
|
4610
|
+
// A failed install is exactly when the card is most likely to be
|
|
4611
|
+
// wrong: npm can unlink the old global package before dying, and a
|
|
4612
|
+
// rollback can fail too — and the hour-long measurement cache would
|
|
4613
|
+
// then keep re-publishing «0.150.0, up to date» about a machine that
|
|
4614
|
+
// no longer has the agent at all, on every reconnect.
|
|
4615
|
+
//
|
|
4616
|
+
// `changed` stays conditional: it is the audit payload (Р14), and
|
|
4617
|
+
// «updated from 1.2.3 to 1.2.3» would be a line about nothing.
|
|
4618
|
+
invalidateAgentVersions();
|
|
4619
|
+
const moved = installed.ok &&
|
|
4620
|
+
installed.toVersion !== undefined &&
|
|
4621
|
+
installed.toVersion !== installed.fromVersion
|
|
4622
|
+
? { agent: installed.agent, from: installed.fromVersion, to: installed.toVersion }
|
|
4623
|
+
: undefined;
|
|
4624
|
+
void this.publishAgentVersions('manual', moved);
|
|
4625
|
+
return;
|
|
4626
|
+
}
|
|
4141
4627
|
/**
|
|
4142
4628
|
* «Would this file reach the agent, and how big is it» (ticket #192).
|
|
4143
4629
|
*
|
|
@@ -4522,6 +5008,9 @@ export class Supervisor {
|
|
|
4522
5008
|
/** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
|
|
4523
5009
|
shutdown() {
|
|
4524
5010
|
clearInterval(this.slotsTimer);
|
|
5011
|
+
clearInterval(this.agentVersionsTimer);
|
|
5012
|
+
clearTimeout(this.agentCleanupFirstTimer);
|
|
5013
|
+
clearInterval(this.agentCleanupTimer);
|
|
4525
5014
|
this.authRelay.cancel();
|
|
4526
5015
|
for (const running of this.sessions.values()) {
|
|
4527
5016
|
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.48.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED