@parall/daemon 1.36.1 → 1.37.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/bundle/manifest.json +11 -11
- package/bundle/parall-browser-pod.js +307 -27
- package/bundle/parall-claude-agent.js +363 -50
- package/bundle/parall-codex-agent.js +362 -48
- package/bundle/parall-daemon.js +33473 -31706
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/clip-runtime/browser-daemon-env.d.ts +29 -0
- package/dist/clip-runtime/browser-daemon-env.d.ts.map +1 -0
- package/dist/clip-runtime/browser-daemon-env.js +70 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts +18 -5
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +149 -24
- package/dist/clip-runtime/browser-profile-pool.d.ts +250 -0
- package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -0
- package/dist/clip-runtime/browser-profile-pool.js +581 -0
- package/dist/clip-runtime/index.d.ts +2 -2
- package/dist/clip-runtime/index.d.ts.map +1 -1
- package/dist/clip-runtime/index.js +2 -1
- package/dist/clip-runtime/process-manager.d.ts +5 -3
- package/dist/clip-runtime/process-manager.d.ts.map +1 -1
- package/dist/clip-runtime/subprocess.d.ts +14 -0
- package/dist/clip-runtime/subprocess.d.ts.map +1 -1
- package/dist/clip-runtime/subprocess.js +49 -0
- package/dist/config.d.ts +14 -15
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -26
- package/dist/daemon-main.d.ts +8 -0
- package/dist/daemon-main.d.ts.map +1 -0
- package/dist/daemon-main.js +165 -0
- package/dist/daemon-paths.d.ts +14 -0
- package/dist/daemon-paths.d.ts.map +1 -0
- package/dist/daemon-paths.js +26 -0
- package/dist/daemon-update-mode.d.ts +8 -0
- package/dist/daemon-update-mode.d.ts.map +1 -0
- package/dist/daemon-update-mode.js +18 -0
- package/dist/index.js +41 -167
- package/dist/runtime-bin-resolver.d.ts +4 -0
- package/dist/runtime-bin-resolver.d.ts.map +1 -1
- package/dist/runtime-bin-resolver.js +40 -7
- package/dist/runtime-detector.d.ts +30 -0
- package/dist/runtime-detector.d.ts.map +1 -0
- package/dist/runtime-detector.js +100 -0
- package/dist/supervisor.d.ts +53 -2
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +604 -67
- package/dist/update-health-gate.d.ts +66 -0
- package/dist/update-health-gate.d.ts.map +1 -0
- package/dist/update-health-gate.js +93 -0
- package/dist/updater-manifest.d.ts +2 -1
- package/dist/updater-manifest.d.ts.map +1 -1
- package/dist/updater-manifest.js +38 -7
- package/dist/updater.d.ts +13 -2
- package/dist/updater.d.ts.map +1 -1
- package/dist/updater.js +126 -17
- package/package.json +6 -6
package/dist/supervisor.js
CHANGED
|
@@ -5,11 +5,12 @@ import { effectiveLLMSourceExplicit } from '@parall/agent-core';
|
|
|
5
5
|
import { ParallWs, } from '@parall/sdk';
|
|
6
6
|
import { installClip, parseSource } from './clip-runtime/clip-installer.js';
|
|
7
7
|
import { parseIpcCommands } from './clip-runtime/manifest.js';
|
|
8
|
-
import {
|
|
8
|
+
import { BrowserProfilePool, ClipProcessManager, ClipProvider, HubClient, } from './clip-runtime/index.js';
|
|
9
9
|
import { agentClaudeCredentialsFileFor, agentClaudeHomeFor, agentStateDirFor, agentWorkspaceDirFor, sharedClaudeCredentialsFileFor, } from './config.js';
|
|
10
10
|
import { listDirectory } from './filesystem.js';
|
|
11
11
|
import { assertAgentKey, getRuntimeAdapter } from './runtimes.js';
|
|
12
12
|
import { applyRuntimeBinaryEnv } from './runtime-bin-resolver.js';
|
|
13
|
+
import { detectedRuntimesEqual, detectRuntimes, summarizeDetectedRuntimes, } from './runtime-detector.js';
|
|
13
14
|
import { prepareWorkspace } from './workspace.js';
|
|
14
15
|
const RUNTIME_PACKAGES = {
|
|
15
16
|
'claude-code': '@parall/claude-agent',
|
|
@@ -17,7 +18,12 @@ const RUNTIME_PACKAGES = {
|
|
|
17
18
|
openclaw: '@parall/openclaw-agent',
|
|
18
19
|
};
|
|
19
20
|
const WORKSPACE_SETUP_RETRY_DELAY_MS = 5_000;
|
|
21
|
+
const AGENT_CONFIG_REFRESH_RETRY_DELAY_MS = 5_000;
|
|
20
22
|
const CLIP_RECONCILE_INTERVAL_MS = 5 * 60_000;
|
|
23
|
+
// Runtime-CLI re-detection cadence. Cheap (three --version probes at most) and
|
|
24
|
+
// only a CHANGED result posts a heartbeat, so this mainly bounds how long a
|
|
25
|
+
// freshly installed CLI takes to show up in machine pickers.
|
|
26
|
+
const RUNTIME_DETECT_INTERVAL_MS = 5 * 60_000;
|
|
21
27
|
/**
|
|
22
28
|
* Sleep that wakes early on abort. Returns true if the full delay elapsed,
|
|
23
29
|
* false if aborted. Used by bootstrap retry and the outer keepalive in
|
|
@@ -63,8 +69,28 @@ export class DaemonSupervisor {
|
|
|
63
69
|
// stream.start (the kill fires before the streamer registers and misses it,
|
|
64
70
|
// leaving a live streamer on a profile the server just marked stopped).
|
|
65
71
|
browserProfileOpQueues = new Map();
|
|
72
|
+
// Profiles with a revive (open/ensureRuntime) queued or running but not yet a live
|
|
73
|
+
// pool manager (count = in-flight revives). Reconnect reconcile snapshots this
|
|
74
|
+
// alongside the pool's active set: a stale queued open is invisible to
|
|
75
|
+
// pool.activeProfileIds() until it spawns, so without this it could drain after a
|
|
76
|
+
// missed offline stop/delete and start a runtime the server no longer wants.
|
|
77
|
+
browserProfilePendingRevives = new Map();
|
|
66
78
|
workspaceSetupRetryTimers = new Map();
|
|
79
|
+
agentConfigRefreshRetryTimers = new Map();
|
|
67
80
|
cancelledSpawns = new Set();
|
|
81
|
+
// Agents whose provider config changed while no child state existed yet
|
|
82
|
+
// (spawn in flight, or an attach fetch still in flight): spawnAgent replays
|
|
83
|
+
// handleAgentConfigUpdated after the spawn settles so an in-flight spawn
|
|
84
|
+
// cannot commit a stale provider_config snapshot and lose the event.
|
|
85
|
+
pendingConfigRefresh = new Set();
|
|
86
|
+
// Child states with a deliberate restart awaiting its credential mint. The
|
|
87
|
+
// state.child guard in restartChildNow cannot see a restart that has not
|
|
88
|
+
// spawned yet, so without this set the reconcile and live config-update
|
|
89
|
+
// paths could double-spawn the same agent. Keyed by ChildState identity,
|
|
90
|
+
// NOT agent ID: a stale in-flight restart from a detached state must not
|
|
91
|
+
// suppress the reattached agent's (new state's) restart — startChild's
|
|
92
|
+
// registration check already keeps the stale state from ever spawning.
|
|
93
|
+
restartingStates = new Set();
|
|
68
94
|
ws = null;
|
|
69
95
|
running = false;
|
|
70
96
|
machineId = null;
|
|
@@ -82,12 +108,23 @@ export class DaemonSupervisor {
|
|
|
82
108
|
connectedClipServiceUrl = null;
|
|
83
109
|
stopResolve = null;
|
|
84
110
|
updater = null;
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
// main #1671 (self-update health gate) and this PR (per-profile browser pool) added
|
|
112
|
+
// adjacent fields — keep both. healthGate replaces the old healthConfirmed flag;
|
|
113
|
+
// browserProfilePool replaces the old single browserProfileManager.
|
|
114
|
+
healthGate = null;
|
|
115
|
+
browserProfilePool = null;
|
|
87
116
|
clipManager = null;
|
|
88
117
|
clipProvider = null;
|
|
89
118
|
clipReconcileTimer = null;
|
|
90
119
|
clipReconcileInFlight = null;
|
|
120
|
+
// Last runtime-CLI detection result (null = not run yet). Re-detected
|
|
121
|
+
// periodically; only a CHANGED result re-posts the heartbeat so steady state
|
|
122
|
+
// stays write-free server-side.
|
|
123
|
+
detectedRuntimes = null;
|
|
124
|
+
runtimeDetectTimer = null;
|
|
125
|
+
runtimeDetectInFlight = null;
|
|
126
|
+
// Injectable for tests (real detection spawns --version subprocesses).
|
|
127
|
+
detectRuntimesFn = detectRuntimes;
|
|
91
128
|
// Execution-side hub client for cross-machine dependency resolution
|
|
92
129
|
// (GetBindings + Invoke). Cached by resolved endpoint so a clip_provider_url
|
|
93
130
|
// rollout transparently rebuilds it against the new grey-cloud host.
|
|
@@ -101,6 +138,11 @@ export class DaemonSupervisor {
|
|
|
101
138
|
setUpdater(updater) {
|
|
102
139
|
this.updater = updater;
|
|
103
140
|
}
|
|
141
|
+
/** Inject the process-level update health gate. The supervisor only feeds it
|
|
142
|
+
* fact signals (machine.hello, supervisor-ended); the gate owns confirm policy. */
|
|
143
|
+
setHealthGate(gate) {
|
|
144
|
+
this.healthGate = gate;
|
|
145
|
+
}
|
|
104
146
|
/** Start the supervisor. Returns a promise that resolves on `stop()`. */
|
|
105
147
|
async run(signal) {
|
|
106
148
|
if (this.running)
|
|
@@ -125,12 +167,12 @@ export class DaemonSupervisor {
|
|
|
125
167
|
this.migrateFlatLayout();
|
|
126
168
|
// Initialize Clip runtime manager (optional, for Pinix Clip subprocess management)
|
|
127
169
|
if (process.env.PRLL_CLIP_RUNTIME_ENABLED === 'true') {
|
|
128
|
-
this.
|
|
129
|
-
|
|
170
|
+
this.browserProfilePool = new BrowserProfilePool({
|
|
171
|
+
baseHomeDir: path.join(this.config.rootStateDir, 'bb-browser'),
|
|
130
172
|
log: this.log,
|
|
131
|
-
reportStatus: (profileId, status, errorMsg) => {
|
|
173
|
+
reportStatus: (profileId, status, errorMsg, generation) => {
|
|
132
174
|
this.client
|
|
133
|
-
.reportBrowserProfileStatus(profileId, status, errorMsg)
|
|
175
|
+
.reportBrowserProfileStatus(profileId, status, errorMsg, generation)
|
|
134
176
|
.catch((err) => this.log.warn(`browser profile status report failed: ${String(err)}`));
|
|
135
177
|
},
|
|
136
178
|
resolveProxy: (profileId) => this.resolveBrowserProfileProxy(profileId),
|
|
@@ -138,29 +180,49 @@ export class DaemonSupervisor {
|
|
|
138
180
|
this.clipManager = new ClipProcessManager({
|
|
139
181
|
clipsDir: path.join(this.config.rootStateDir, 'clips'),
|
|
140
182
|
dataDir: path.join(this.config.rootStateDir, 'clip-data'),
|
|
141
|
-
browserProfileManager: this.
|
|
183
|
+
browserProfileManager: this.browserProfilePool,
|
|
142
184
|
// Execution side: nested browser dependency invokes resolve their
|
|
143
185
|
// binding and route through the hub (no local shortcut).
|
|
144
186
|
hubClient: () => this.getHubClient(),
|
|
145
187
|
ensureInstalled: (config) => this.ensureClipInstalled(config),
|
|
146
188
|
});
|
|
147
189
|
await this.reconcileMachineClips();
|
|
190
|
+
// Pre-fence every locally-known browser profile BEFORE registering as a hub
|
|
191
|
+
// Provider (which exposes hub browser invokes). Until the reconnect reconcile
|
|
192
|
+
// (fullReconcile below) has applied any reset/stop/delete missed while offline, a
|
|
193
|
+
// hub invoke must not run on a profile — it could revive the stale cookies a
|
|
194
|
+
// missed reset meant to clear (reset-before-open must hold across a reconnect).
|
|
195
|
+
// Fencing is synchronous and cheap (no bb-browser opens, so provider registration
|
|
196
|
+
// is never blocked on a slow/stuck Chromium); the reconcile un-fences the desired
|
|
197
|
+
// profiles after wiping. An invoke in the gap is rejected and the hub retries — no
|
|
198
|
+
// stale-disk execution.
|
|
199
|
+
const poolToFence = this.browserProfilePool;
|
|
200
|
+
if (poolToFence) {
|
|
201
|
+
for (const profileId of new Set([
|
|
202
|
+
...poolToFence.activeProfileIds(),
|
|
203
|
+
...poolToFence.localStateProfileIds(),
|
|
204
|
+
])) {
|
|
205
|
+
poolToFence.fence(profileId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
148
208
|
// Register as a hub Provider unless the machine opted out
|
|
149
209
|
// (provider_enabled=false). See applyClipProviderState.
|
|
150
210
|
await this.applyClipProviderState();
|
|
151
211
|
this.startClipReconcileTimer();
|
|
152
212
|
}
|
|
153
|
-
// Report daemon version + self-update capability
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
213
|
+
// Report daemon version + self-update capability + detected runtime CLIs
|
|
214
|
+
// via heartbeat (best-effort, off the critical path — detection spawns
|
|
215
|
+
// `--version` probes). Capability tracks whether a service manager
|
|
216
|
+
// supervises us: the updater is created only in that case (see
|
|
217
|
+
// isSelfUpdateManaged in index.ts). A bare `npx` foreground run reports
|
|
218
|
+
// false so the server/UI won't offer a manual update it would silently
|
|
219
|
+
// ignore. Detection then re-runs periodically so installing a CLI shows
|
|
220
|
+
// up in machine pickers without a daemon restart.
|
|
221
|
+
void this.detectAndReportRuntimes(true);
|
|
222
|
+
this.runtimeDetectTimer = setInterval(() => {
|
|
223
|
+
void this.detectAndReportRuntimes(false);
|
|
224
|
+
}, RUNTIME_DETECT_INTERVAL_MS);
|
|
225
|
+
this.runtimeDetectTimer.unref?.();
|
|
164
226
|
await this.fullReconcile();
|
|
165
227
|
this.ws = new ParallWs({
|
|
166
228
|
getTicket: () => this.client.getMachineWsTicket(),
|
|
@@ -169,15 +231,9 @@ export class DaemonSupervisor {
|
|
|
169
231
|
});
|
|
170
232
|
this.ws.on('machine.hello', (_data) => {
|
|
171
233
|
this.log.info('machine WS connected (machine.hello)');
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this.healthConfirmed = true;
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
this.log.warn(`confirmVersion failed: ${String(err)}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
234
|
+
// Control-plane handshake fact — the health gate (process-level) owns the
|
|
235
|
+
// confirm / stability-window policy; the supervisor just reports the event.
|
|
236
|
+
this.healthGate?.onMachineHello();
|
|
181
237
|
void (async () => {
|
|
182
238
|
await this.refreshMachineConfig();
|
|
183
239
|
await this.fullReconcile();
|
|
@@ -222,6 +278,10 @@ export class DaemonSupervisor {
|
|
|
222
278
|
void this.applyClipProviderState();
|
|
223
279
|
}
|
|
224
280
|
});
|
|
281
|
+
this.ws.on('machine.agent_config.updated', (data) => {
|
|
282
|
+
this.log.info(`WS: agent ${data.agent_id} provider config updated (effective llm_source=${data.llm_source})`);
|
|
283
|
+
void this.handleAgentConfigUpdated(data.agent_id);
|
|
284
|
+
});
|
|
225
285
|
this.ws.on('machine.workspace.setup.requested', (data) => {
|
|
226
286
|
this.log.info(`WS: workspace setup requested for agent ${data.agent_id}`);
|
|
227
287
|
void this.handleWorkspaceSetupRequested(data.agent_id);
|
|
@@ -262,6 +322,13 @@ export class DaemonSupervisor {
|
|
|
262
322
|
if (!this.running)
|
|
263
323
|
return;
|
|
264
324
|
this.running = false;
|
|
325
|
+
// This supervisor instance is ending (clean shutdown OR a runForever-caught
|
|
326
|
+
// soft crash). Reset the update health window before any cleanup that could
|
|
327
|
+
// throw, so a stale confirm timer can't fire across supervisor instances.
|
|
328
|
+
// Soft crash resets the timer but does NOT increment boot_count — rollback
|
|
329
|
+
// failures come only from process-level unclean exits (the daemon-running
|
|
330
|
+
// marker in index.ts).
|
|
331
|
+
this.healthGate?.onSupervisorEnded();
|
|
265
332
|
if (this.ws) {
|
|
266
333
|
this.ws.disconnect();
|
|
267
334
|
this.ws = null;
|
|
@@ -271,7 +338,15 @@ export class DaemonSupervisor {
|
|
|
271
338
|
clearTimeout(timer);
|
|
272
339
|
}
|
|
273
340
|
this.workspaceSetupRetryTimers.clear();
|
|
341
|
+
for (const timer of this.agentConfigRefreshRetryTimers.values()) {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
}
|
|
344
|
+
this.agentConfigRefreshRetryTimers.clear();
|
|
274
345
|
this.stopClipReconcileTimer();
|
|
346
|
+
if (this.runtimeDetectTimer) {
|
|
347
|
+
clearInterval(this.runtimeDetectTimer);
|
|
348
|
+
this.runtimeDetectTimer = null;
|
|
349
|
+
}
|
|
275
350
|
for (const state of this.children.values()) {
|
|
276
351
|
state.shuttingDown = true;
|
|
277
352
|
if (state.restartTimer) {
|
|
@@ -288,9 +363,9 @@ export class DaemonSupervisor {
|
|
|
288
363
|
exits.push(this.clipManager.stopAll());
|
|
289
364
|
this.clipManager = null;
|
|
290
365
|
}
|
|
291
|
-
if (this.
|
|
292
|
-
exits.push(this.
|
|
293
|
-
this.
|
|
366
|
+
if (this.browserProfilePool) {
|
|
367
|
+
exits.push(this.browserProfilePool.stop());
|
|
368
|
+
this.browserProfilePool = null;
|
|
294
369
|
}
|
|
295
370
|
await Promise.allSettled(exits);
|
|
296
371
|
this.children.clear();
|
|
@@ -366,8 +441,21 @@ export class DaemonSupervisor {
|
|
|
366
441
|
}
|
|
367
442
|
await this.spawnAgent(userId, orgId, a);
|
|
368
443
|
}
|
|
369
|
-
else
|
|
370
|
-
|
|
444
|
+
else {
|
|
445
|
+
// Catch up on per-agent provider_config changes missed while the WS
|
|
446
|
+
// was down (machine.agent_config.updated is delivered live only):
|
|
447
|
+
// refresh the cache, and respawn a live child when the stored config
|
|
448
|
+
// actually changed. The serialized comparison is stable — both sides
|
|
449
|
+
// come from the same endpoint's JSON.
|
|
450
|
+
const configChanged = JSON.stringify(existing.providerConfig ?? null) !==
|
|
451
|
+
JSON.stringify(a.provider_config ?? null);
|
|
452
|
+
existing.providerConfig = a.provider_config;
|
|
453
|
+
if (!existing.child && !existing.restartTimer && !existing.shuttingDown) {
|
|
454
|
+
await this.restartChildNow(existing, 'reconcile found no live child');
|
|
455
|
+
}
|
|
456
|
+
else if (configChanged && !existing.shuttingDown) {
|
|
457
|
+
await this.respawnChildForConfig(existing, 'provider config changed while disconnected');
|
|
458
|
+
}
|
|
371
459
|
}
|
|
372
460
|
}
|
|
373
461
|
for (const [userId, state] of this.children) {
|
|
@@ -387,9 +475,46 @@ export class DaemonSupervisor {
|
|
|
387
475
|
await this.reconcileMachineClips();
|
|
388
476
|
await this.reconcileBrowserProfiles();
|
|
389
477
|
}
|
|
478
|
+
// Wipe a missed reset before a revive, reporting `error` if the wipe fails. A failed
|
|
479
|
+
// wipe must NOT silently leave the row at `pending`: report `error` under the lifecycle
|
|
480
|
+
// generation (visible), keep the profile fenced (applyResetWipe fenced it) and the
|
|
481
|
+
// sidecar unwritten, then re-throw so the caller skips the open. The next reconcile's
|
|
482
|
+
// negative path retries the wipe because the SSOT reset_generation is still unapplied.
|
|
483
|
+
// Shared by the reconnect reconcile and the live `open` lifecycle path.
|
|
484
|
+
async wipeBeforeRevive(pool, profileId, resetGen, generation) {
|
|
485
|
+
try {
|
|
486
|
+
await pool.applyResetWipe(profileId, resetGen);
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
490
|
+
this.client
|
|
491
|
+
.reportBrowserProfileStatus(profileId, 'error', message, generation)
|
|
492
|
+
.catch((e) => this.log.warn(`browser profile wipe-error report failed: ${String(e)}`));
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
390
496
|
async reconcileBrowserProfiles() {
|
|
391
|
-
|
|
497
|
+
const pool = this.browserProfilePool;
|
|
498
|
+
if (!pool)
|
|
392
499
|
return;
|
|
500
|
+
// Snapshot the locally-live set BEFORE the server fetch. A profile opened via a
|
|
501
|
+
// WS lifecycle event DURING the fetch is NOT in this snapshot, so the negative
|
|
502
|
+
// convergence below (which is stale w.r.t. that open) can never release it.
|
|
503
|
+
// There is no periodic browser reconcile to re-open a wrongly-released profile —
|
|
504
|
+
// it only runs on startup / WS-hello — so a stale release would strand it until
|
|
505
|
+
// the next reconnect.
|
|
506
|
+
const activeBeforeList = new Set(pool.activeProfileIds());
|
|
507
|
+
// Also snapshot revives (open/ensure) queued but not yet a live manager: such a
|
|
508
|
+
// stale open is invisible to activeProfileIds() and would otherwise drain after a
|
|
509
|
+
// missed offline stop/delete and start a runtime the server no longer wants.
|
|
510
|
+
const pendingReviveBeforeList = new Set(this.browserProfilePendingRevives.keys());
|
|
511
|
+
// And ALL local profile state — per-profile homes AND legacy flat account files
|
|
512
|
+
// (the pool owns that storage layout). State with NO server row is a
|
|
513
|
+
// deleted/reassigned profile whose cookies/storage must be wiped from this host;
|
|
514
|
+
// a pre-upgrade profile never reopened after migration exists only as a legacy flat
|
|
515
|
+
// file, so home-only enumeration would miss it. Pre-fetch so state created during
|
|
516
|
+
// the fetch is left alone.
|
|
517
|
+
const localStateBeforeList = new Set(pool.localStateProfileIds());
|
|
393
518
|
let profiles;
|
|
394
519
|
try {
|
|
395
520
|
profiles = await this.client.listMachineBrowserProfiles();
|
|
@@ -398,29 +523,163 @@ export class DaemonSupervisor {
|
|
|
398
523
|
this.log.warn(`browser profile reconcile: list failed: ${String(err)}`);
|
|
399
524
|
return;
|
|
400
525
|
}
|
|
526
|
+
// The pool must still be the same instance after the await — stop() may have
|
|
527
|
+
// nulled/replaced it during a concurrent shutdown; bail rather than act on a
|
|
528
|
+
// torn-down pool (also avoids NPE-ing through Promise.allSettled into warnings).
|
|
529
|
+
if (this.browserProfilePool !== pool)
|
|
530
|
+
return;
|
|
531
|
+
// Converge local runtimes to the server's desired-live set (the server is the
|
|
532
|
+
// SSOT). The supervisor is the desired-state coordinator; the pool is the
|
|
533
|
+
// per-profile runtime registry. Every op here goes through
|
|
534
|
+
// enqueueBrowserProfileOp so all supervisor-originated profile work shares one
|
|
535
|
+
// ordering layer (the pool's own queue is the final per-profile mutex).
|
|
536
|
+
const desiredLive = new Set();
|
|
537
|
+
const serverIds = new Set(); // every byoc profile the server still lists (any status)
|
|
538
|
+
const serverResetGen = new Map();
|
|
539
|
+
const ops = [];
|
|
540
|
+
// reset_generation (server) is the SSOT for reset intent; the daemon's per-profile
|
|
541
|
+
// sidecar (read via pool.appliedResetGeneration) is only a local ack of "already
|
|
542
|
+
// wiped up to this generation". A missed reset (server newer than the local ack) must
|
|
543
|
+
// wipe local state BEFORE any open/ensure, so a reopen starts clean and never
|
|
544
|
+
// resurrects the cookies the reset cleared. Returns the server reset_generation to
|
|
545
|
+
// apply, or null if nothing is missed. Pure (no side effects).
|
|
546
|
+
const missedResetGen = (profileId) => {
|
|
547
|
+
const gen = serverResetGen.get(profileId) ?? 0;
|
|
548
|
+
return gen > pool.appliedResetGeneration(profileId) ? gen : null;
|
|
549
|
+
};
|
|
550
|
+
// Negative-path helper (a stopped profile with a missed reset): fence synchronously
|
|
551
|
+
// then enqueue a STANDALONE wipe. No revive follows, so a wipe failure simply leaves
|
|
552
|
+
// the profile fenced and the sidecar unwritten → the next reconcile retries (the
|
|
553
|
+
// SSOT reset_generation is still unapplied locally). Fail-closed by construction.
|
|
554
|
+
const enqueueResetWipeIfMissed = (profileId) => {
|
|
555
|
+
const gen = missedResetGen(profileId);
|
|
556
|
+
if (gen === null)
|
|
557
|
+
return false;
|
|
558
|
+
// Fence SYNCHRONOUSLY before the async wipe — same contract as the WS stop/reset
|
|
559
|
+
// path. On a machine-WS reconnect whose clip-provider stream stays live, a hub
|
|
560
|
+
// invoke could otherwise run on the stale runtime in the gap before the wipe op
|
|
561
|
+
// starts. (In the negative loop this re-asserts an already-set fence within the same
|
|
562
|
+
// synchronous iteration — atomic, no revive can observe an intermediate generation.)
|
|
563
|
+
pool.fence(profileId);
|
|
564
|
+
ops.push(this.enqueueBrowserProfileOp(profileId, () => pool.applyResetWipe(profileId, gen)));
|
|
565
|
+
return true;
|
|
566
|
+
};
|
|
401
567
|
for (const profile of profiles) {
|
|
568
|
+
// The daemon supervises only machine-bound (byoc) profiles; hosted profiles
|
|
569
|
+
// (null machine_id) run on the platform pool and are never owned by a daemon.
|
|
570
|
+
if (!profile.machine_id)
|
|
571
|
+
continue;
|
|
572
|
+
serverIds.add(profile.id);
|
|
573
|
+
serverResetGen.set(profile.id, profile.reset_generation ?? 0);
|
|
402
574
|
if (profile.status !== 'running' && profile.status !== 'pending')
|
|
403
575
|
continue;
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
576
|
+
desiredLive.add(profile.id);
|
|
577
|
+
// A missed reset must wipe SUCCESSFULLY before this profile is revived. Fence
|
|
578
|
+
// synchronously now (reject hub invokes in the gap before the queued wipe runs).
|
|
579
|
+
const wipeGen = missedResetGen(profile.id);
|
|
580
|
+
if (wipeGen !== null)
|
|
581
|
+
pool.fence(profile.id);
|
|
582
|
+
// Capture the fence generation AFTER any fence bump so a stop arriving while the op
|
|
583
|
+
// waits makes it skip as stale; carry the lifecycle generation so the daemon's
|
|
584
|
+
// `running` report is fenced server-side. Drive desired state through the CAPTURED
|
|
585
|
+
// pool (not enqueueBrowserProfileLifecycle, which re-reads this.browserProfilePool
|
|
586
|
+
// and would negative-ACK `error` for a pending profile if stop() nulled the field).
|
|
587
|
+
const sinceSeq = pool.stopSeqOf(profile.id);
|
|
588
|
+
const generation = profile.lifecycle_generation;
|
|
589
|
+
// forceStatusReport ONLY for a `pending` row: the daemon's last report may already be
|
|
590
|
+
// `running` (Chromium survived the WS drop), so a deduped repeat would leave the
|
|
591
|
+
// server stuck at `pending`. A `running` row keeps the normal dedup.
|
|
592
|
+
const forceStatusReport = profile.status === 'pending';
|
|
593
|
+
ops.push(this.enqueueBrowserProfileRevive(profile.id, async () => {
|
|
594
|
+
// Reset-before-open, FAIL-CLOSED: a missed reset must wipe successfully BEFORE
|
|
595
|
+
// the revive un-fences and reopens. If applyResetWipe throws, the await
|
|
596
|
+
// propagates and ensureRuntime is SKIPPED — the profile stays fenced (the wipe
|
|
597
|
+
// fenced it and the applied-reset sidecar was NOT written), so the next reconcile
|
|
598
|
+
// retries because the SSOT reset_generation is still unapplied locally. This
|
|
599
|
+
// ordering is expressed locally; the enqueueBrowserProfileOp queue contract (a
|
|
600
|
+
// rejected op does not block later ops) is intentionally unchanged. A failed
|
|
601
|
+
// wipe reports `error` (visible) and re-throws here, so ensureRuntime is skipped.
|
|
602
|
+
if (wipeGen !== null)
|
|
603
|
+
await this.wipeBeforeRevive(pool, profile.id, wipeGen, generation);
|
|
604
|
+
// Reconnect recovery is liveness-only: ensure the runtime is live (account +
|
|
605
|
+
// tab) for both pending and running. The original open's `start_url` is NOT
|
|
606
|
+
// replayed (it rides only the live lifecycle event, not persisted); a `pending`
|
|
607
|
+
// profile whose open event the daemon missed comes up at the default page rather
|
|
608
|
+
// than a targetless `about:blank`. Persisting start_url is a separate follow-up.
|
|
609
|
+
await pool.ensureRuntime(profile.id, sinceSeq, generation, { forceStatusReport });
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
// Negative convergence over everything locally known but NOT desired — active
|
|
613
|
+
// runtimes, queued revives (open/ensure not yet a manager), and local state (homes +
|
|
614
|
+
// legacy flat files) — all from the PRE-FETCH snapshots, so a profile that appeared
|
|
615
|
+
// DURING the fetch is left alone (no periodic reconcile would undo a wrong teardown).
|
|
616
|
+
const candidates = new Set([
|
|
617
|
+
...activeBeforeList,
|
|
618
|
+
...pendingReviveBeforeList,
|
|
619
|
+
...localStateBeforeList,
|
|
620
|
+
]);
|
|
621
|
+
for (const profileId of candidates) {
|
|
622
|
+
if (desiredLive.has(profileId))
|
|
408
623
|
continue;
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
624
|
+
const isActive = activeBeforeList.has(profileId);
|
|
625
|
+
const hasLocalState = localStateBeforeList.has(profileId);
|
|
626
|
+
// Pending-only revive (queued open/ensure, no runtime, no on-disk state at the
|
|
627
|
+
// pre-fetch snapshot): the synchronous fence alone neutralizes the stale open (it
|
|
628
|
+
// bumps stopSeq, so the queued revive skips as stale). Do NOT AWAIT a teardown — it
|
|
629
|
+
// would block behind the possibly-stuck queue and stall the whole reconcile (the
|
|
630
|
+
// queued-stale-open deadlock).
|
|
631
|
+
if (!isActive && !hasLocalState) {
|
|
632
|
+
pool.fence(profileId);
|
|
633
|
+
// Race: a pending-only revive can COMPLETE during the list fetch (create a manager
|
|
634
|
+
// + on-disk state) BEFORE this fence — the snapshot is then stale, so the fence
|
|
635
|
+
// alone leaves an orphaned runtime/disk the negative loop never converged. Converge
|
|
636
|
+
// it FIRE-AND-FORGET (NOT in `ops`: a still-stuck revive queue must not hang the
|
|
637
|
+
// reconcile; it runs once the queue drains — after the fenced open skips or
|
|
638
|
+
// completes — and is a no-op if nothing was created):
|
|
639
|
+
// - deleted/reassigned → wipeAbsent: the old host must keep no session data.
|
|
640
|
+
// - present + missed reset → applyResetWipe: the completed open may have applied
|
|
641
|
+
// only its event's (older) reset_generation, not the current one.
|
|
642
|
+
// - present + stopped → releaseRuntime: free the orphaned Chromium; a plain
|
|
643
|
+
// stop keeps its cookies on disk.
|
|
644
|
+
let cleanup;
|
|
645
|
+
if (!serverIds.has(profileId)) {
|
|
646
|
+
cleanup = pool.wipeAbsent(profileId);
|
|
417
647
|
}
|
|
418
648
|
else {
|
|
419
|
-
|
|
649
|
+
const wipeGen = missedResetGen(profileId);
|
|
650
|
+
cleanup =
|
|
651
|
+
wipeGen !== null
|
|
652
|
+
? pool.applyResetWipe(profileId, wipeGen)
|
|
653
|
+
: pool.releaseRuntime(profileId);
|
|
420
654
|
}
|
|
655
|
+
void cleanup.catch((err) => this.log.warn(`browser profile pending-revive cleanup failed for ${profileId}: ${String(err)}`));
|
|
656
|
+
continue;
|
|
421
657
|
}
|
|
422
|
-
|
|
423
|
-
|
|
658
|
+
if (!serverIds.has(profileId)) {
|
|
659
|
+
// Absent from the server list → deleted/reassigned → fence + wipe local state (the
|
|
660
|
+
// old host must not keep cookies/storage). No status report — server is the SSOT.
|
|
661
|
+
pool.fence(profileId);
|
|
662
|
+
ops.push(this.enqueueBrowserProfileOp(profileId, () => pool.wipeAbsent(profileId)));
|
|
663
|
+
}
|
|
664
|
+
else if (enqueueResetWipeIfMissed(profileId)) {
|
|
665
|
+
// Present (stopped) with a missed reset → fenced synchronously + wipe enqueued.
|
|
666
|
+
}
|
|
667
|
+
else if (isActive) {
|
|
668
|
+
// Present (stopped) but the runtime is still live → fence + release the runtime
|
|
669
|
+
// only; a plain stop keeps its cookies on disk.
|
|
670
|
+
pool.fence(profileId);
|
|
671
|
+
ops.push(this.enqueueBrowserProfileOp(profileId, () => pool.releaseRuntime(profileId)));
|
|
672
|
+
}
|
|
673
|
+
// else: present + stopped + on-disk state + no runtime + no missed reset → LEAVE
|
|
674
|
+
// ALONE (do NOT fence). Its stopped-fence is already held — the start-sequence
|
|
675
|
+
// pre-fence on a fresh daemon, or the persisted pool state on a WS reconnect — so a
|
|
676
|
+
// hub invoke is still rejected. Re-fencing here would bump stopSeq and drop an `open`
|
|
677
|
+
// that arrived during the list fetch (it captured an older sinceSeq, and the stale
|
|
678
|
+
// `stopped` snapshot doesn't reflect it yet), stranding the server at `pending`.
|
|
679
|
+
}
|
|
680
|
+
for (const result of await Promise.allSettled(ops)) {
|
|
681
|
+
if (result.status === 'rejected') {
|
|
682
|
+
this.log.warn(`browser profile reconcile op failed: ${String(result.reason)}`);
|
|
424
683
|
}
|
|
425
684
|
}
|
|
426
685
|
}
|
|
@@ -533,7 +792,9 @@ export class DaemonSupervisor {
|
|
|
533
792
|
}
|
|
534
793
|
async handleAgentDetached(agentId) {
|
|
535
794
|
this.pendingWorkspaceSetup.delete(agentId);
|
|
795
|
+
this.pendingConfigRefresh.delete(agentId);
|
|
536
796
|
this.clearWorkspaceSetupRetry(agentId);
|
|
797
|
+
this.clearAgentConfigRefreshRetry(agentId);
|
|
537
798
|
if (this.spawningAgents.has(agentId)) {
|
|
538
799
|
this.cancelledSpawns.add(agentId);
|
|
539
800
|
}
|
|
@@ -574,26 +835,53 @@ export class DaemonSupervisor {
|
|
|
574
835
|
}
|
|
575
836
|
}
|
|
576
837
|
}
|
|
577
|
-
async handleBrowserProfileLifecycle(data) {
|
|
578
|
-
if (!this.
|
|
838
|
+
async handleBrowserProfileLifecycle(data, sinceSeq) {
|
|
839
|
+
if (!this.browserProfilePool) {
|
|
840
|
+
// Shutting down: stop() disconnects WS + nulls the pool, but a lifecycle op
|
|
841
|
+
// queued before that still runs. Bail silently rather than negative-ACK
|
|
842
|
+
// `error` for a profile that was merely mid-open during shutdown — a spurious
|
|
843
|
+
// error would strand it (reconcile only auto-heals pending/running). A genuine
|
|
844
|
+
// clip-runtime-disabled daemon is still running, so it falls through and ACKs.
|
|
845
|
+
if (!this.running)
|
|
846
|
+
return;
|
|
579
847
|
this.log.warn('browser profile lifecycle event received but clip runtime is disabled');
|
|
580
848
|
const status = data.action === 'stop' ? 'stopped' : 'error';
|
|
581
849
|
const error = data.action === 'stop' ? undefined : 'Browser profile runtime is disabled on this daemon';
|
|
582
|
-
|
|
850
|
+
// Carry the lifecycle generation so the report applies under the generation fence —
|
|
851
|
+
// without it a nil-gen `error` on an already-stopped row (the state a `reset` leaves)
|
|
852
|
+
// is silently dropped by the safe-state rule, hiding the failure.
|
|
853
|
+
await this.client
|
|
854
|
+
.reportBrowserProfileStatus(data.profile_id, status, error, data.generation)
|
|
855
|
+
.catch((err) => {
|
|
583
856
|
this.log.warn(`browser profile lifecycle negative ACK failed for ${data.profile_id}: ${String(err)}`);
|
|
584
857
|
});
|
|
585
858
|
return;
|
|
586
859
|
}
|
|
587
860
|
try {
|
|
588
861
|
switch (data.action) {
|
|
589
|
-
case 'open':
|
|
590
|
-
|
|
862
|
+
case 'open': {
|
|
863
|
+
// Reset-before-open, FAIL-CLOSED (symmetric with reconnect reconcile): if this
|
|
864
|
+
// open carries a reset the daemon hasn't applied locally, wipe FIRST and open
|
|
865
|
+
// ONLY on success. A failed wipe throws to the catch below — openProfile is
|
|
866
|
+
// skipped, the profile stays fenced (the wipe fenced it + the receipt pre-fence),
|
|
867
|
+
// the applied-reset sidecar is NOT written, and the next reconnect reconcile
|
|
868
|
+
// retries because the SSOT reset_generation is still unapplied. server
|
|
869
|
+
// reset_generation is the SSOT; the sidecar is only the local applied-ack.
|
|
870
|
+
const resetGen = data.reset_generation ?? 0;
|
|
871
|
+
if (resetGen > this.browserProfilePool.appliedResetGeneration(data.profile_id)) {
|
|
872
|
+
// Reports `error` + re-throws on failure (to the catch below), skipping the open.
|
|
873
|
+
await this.wipeBeforeRevive(this.browserProfilePool, data.profile_id, resetGen, data.generation);
|
|
874
|
+
}
|
|
875
|
+
await this.browserProfilePool.openProfile(data.profile_id, data.start_url, sinceSeq, data.generation);
|
|
591
876
|
return;
|
|
877
|
+
}
|
|
592
878
|
case 'stop':
|
|
593
|
-
await this.
|
|
879
|
+
await this.browserProfilePool.stopProfile(data.profile_id, data.generation);
|
|
594
880
|
return;
|
|
595
881
|
case 'reset':
|
|
596
|
-
|
|
882
|
+
// Carry BOTH generations: lifecycle for the status report, reset for the
|
|
883
|
+
// daemon's applied-reset sidecar (so a later reconnect won't re-wipe).
|
|
884
|
+
await this.browserProfilePool.resetProfile(data.profile_id, data.generation, data.reset_generation);
|
|
597
885
|
return;
|
|
598
886
|
default:
|
|
599
887
|
this.log.warn(`unknown browser profile lifecycle action: ${data.action}`);
|
|
@@ -604,7 +892,35 @@ export class DaemonSupervisor {
|
|
|
604
892
|
}
|
|
605
893
|
}
|
|
606
894
|
enqueueBrowserProfileLifecycle(data) {
|
|
607
|
-
|
|
895
|
+
// Pre-fence at event receipt (synchronously, before the op queues): a hub
|
|
896
|
+
// invoke bypasses this queue and hits the pool directly, so one authorized
|
|
897
|
+
// just before the server flipped the row to stopped could arrive while this
|
|
898
|
+
// stop/reset waits behind another op and spawn a transient Chromium the op
|
|
899
|
+
// then tears down. Fencing now rejects it immediately; open un-fences when it
|
|
900
|
+
// runs. (Only the WS stop/reset path reaches here with these actions; the
|
|
901
|
+
// reconcile open path passes action='open'.)
|
|
902
|
+
if (data.action === 'stop' || data.action === 'reset') {
|
|
903
|
+
this.browserProfilePool?.fence(data.profile_id);
|
|
904
|
+
}
|
|
905
|
+
else if (data.action === 'open' &&
|
|
906
|
+
this.browserProfilePool &&
|
|
907
|
+
(data.reset_generation ?? 0) > this.browserProfilePool.appliedResetGeneration(data.profile_id)) {
|
|
908
|
+
// reset-before-open on the LIVE open path too: if this open carries a reset the
|
|
909
|
+
// daemon hasn't applied (a prior wipe failed, or the reset event was missed), fence
|
|
910
|
+
// SYNCHRONOUSLY at receipt — a hub invoke (which bypasses this queue) must not hit
|
|
911
|
+
// the un-wiped home while the open waits in the queue. The handler wipes first and
|
|
912
|
+
// only opens on success (see handleBrowserProfileLifecycle).
|
|
913
|
+
this.browserProfilePool.fence(data.profile_id);
|
|
914
|
+
}
|
|
915
|
+
// Capture the fence generation NOW (at receipt, AFTER any pre-fence above) for an open,
|
|
916
|
+
// so a stop arriving while this open waits in the queue makes it skip as a stale revive.
|
|
917
|
+
const sinceSeq = data.action === 'open' ? this.browserProfilePool?.stopSeqOf(data.profile_id) : undefined;
|
|
918
|
+
const op = () => this.handleBrowserProfileLifecycle(data, sinceSeq);
|
|
919
|
+
// `open` is a revive — track it so reconnect reconcile can fence it even before
|
|
920
|
+
// it spawns a manager. stop/reset are not revives.
|
|
921
|
+
return data.action === 'open'
|
|
922
|
+
? this.enqueueBrowserProfileRevive(data.profile_id, op)
|
|
923
|
+
: this.enqueueBrowserProfileOp(data.profile_id, op);
|
|
608
924
|
}
|
|
609
925
|
enqueueBrowserProfileOp(profileId, op) {
|
|
610
926
|
const previous = this.browserProfileOpQueues.get(profileId) ?? Promise.resolve();
|
|
@@ -614,13 +930,35 @@ export class DaemonSupervisor {
|
|
|
614
930
|
})
|
|
615
931
|
.then(op);
|
|
616
932
|
this.browserProfileOpQueues.set(profileId, next);
|
|
617
|
-
|
|
933
|
+
// The caller's returned `next` still surfaces a rejected op (the reconcile's
|
|
934
|
+
// allSettled handles it). This separate cleanup chain only deletes the queue-map
|
|
935
|
+
// entry, so it must swallow the rejection first — otherwise `.finally` re-throws it
|
|
936
|
+
// into a voided promise and a failing op (e.g. a reconnect reset wipe that throws)
|
|
937
|
+
// leaks an unhandledRejection. Mirrors BrowserProfilePool.enqueue's cleanup.
|
|
938
|
+
void next
|
|
939
|
+
.catch(() => { })
|
|
940
|
+
.finally(() => {
|
|
618
941
|
if (this.browserProfileOpQueues.get(profileId) === next) {
|
|
619
942
|
this.browserProfileOpQueues.delete(profileId);
|
|
620
943
|
}
|
|
621
944
|
});
|
|
622
945
|
return next;
|
|
623
946
|
}
|
|
947
|
+
// Enqueue a REVIVE op (open / ensureRuntime), tracking it in
|
|
948
|
+
// browserProfilePendingRevives for the lifetime of the queued op so reconnect
|
|
949
|
+
// reconcile can fence a stale revive that hasn't spawned a manager yet. Registered
|
|
950
|
+
// synchronously at enqueue (so a snapshot taken now sees it) and cleared when the
|
|
951
|
+
// op settles. Use enqueueBrowserProfileOp (no tracking) for stop/reset/release/viewer.
|
|
952
|
+
enqueueBrowserProfileRevive(profileId, op) {
|
|
953
|
+
this.browserProfilePendingRevives.set(profileId, (this.browserProfilePendingRevives.get(profileId) ?? 0) + 1);
|
|
954
|
+
return this.enqueueBrowserProfileOp(profileId, op).finally(() => {
|
|
955
|
+
const remaining = (this.browserProfilePendingRevives.get(profileId) ?? 1) - 1;
|
|
956
|
+
if (remaining <= 0)
|
|
957
|
+
this.browserProfilePendingRevives.delete(profileId);
|
|
958
|
+
else
|
|
959
|
+
this.browserProfilePendingRevives.set(profileId, remaining);
|
|
960
|
+
});
|
|
961
|
+
}
|
|
624
962
|
/**
|
|
625
963
|
* Handle one viewer control command and reply via REST. Always answers the
|
|
626
964
|
* request/reply bridge exactly once ({result} on success, {error:{message}}
|
|
@@ -628,7 +966,7 @@ export class DaemonSupervisor {
|
|
|
628
966
|
* handleFilesystemBrowse. Serialized per profile by enqueueBrowserProfileViewer.
|
|
629
967
|
*/
|
|
630
968
|
async handleBrowserProfileViewer(data) {
|
|
631
|
-
if (!this.
|
|
969
|
+
if (!this.browserProfilePool) {
|
|
632
970
|
await this.client
|
|
633
971
|
.postBrowserProfileViewerResponse(data.request_id, {
|
|
634
972
|
error: { message: 'Browser profile runtime is disabled on this daemon' },
|
|
@@ -637,7 +975,7 @@ export class DaemonSupervisor {
|
|
|
637
975
|
return;
|
|
638
976
|
}
|
|
639
977
|
try {
|
|
640
|
-
const result = await this.
|
|
978
|
+
const result = await this.browserProfilePool.handleViewerCommand(data.profile_id, data.session_id, data.command, data.input, data.turn);
|
|
641
979
|
await this.client.postBrowserProfileViewerResponse(data.request_id, { result });
|
|
642
980
|
}
|
|
643
981
|
catch (err) {
|
|
@@ -736,6 +1074,40 @@ export class DaemonSupervisor {
|
|
|
736
1074
|
clearInterval(this.clipReconcileTimer);
|
|
737
1075
|
this.clipReconcileTimer = null;
|
|
738
1076
|
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Detect runtime CLIs on this host and heartbeat the result. `initial`
|
|
1079
|
+
* always reports (it also carries daemon version + self-update capability,
|
|
1080
|
+
* replacing the old version-only startup heartbeat); periodic runs report
|
|
1081
|
+
* only when the detection result changed, keeping steady state write-free.
|
|
1082
|
+
* Serialized via `runtimeDetectInFlight` so a slow probe can't overlap the
|
|
1083
|
+
* next interval tick and race the change comparison.
|
|
1084
|
+
*/
|
|
1085
|
+
detectAndReportRuntimes(initial) {
|
|
1086
|
+
if (this.runtimeDetectInFlight)
|
|
1087
|
+
return this.runtimeDetectInFlight;
|
|
1088
|
+
const run = (async () => {
|
|
1089
|
+
const detected = await this.detectRuntimesFn(process.env, this.log);
|
|
1090
|
+
const changed = this.detectedRuntimes == null || !detectedRuntimesEqual(this.detectedRuntimes, detected);
|
|
1091
|
+
if (!initial && !changed)
|
|
1092
|
+
return;
|
|
1093
|
+
this.log.info(`runtime detection: ${summarizeDetectedRuntimes(detected)}`);
|
|
1094
|
+
await this.client.postMachineHeartbeat({
|
|
1095
|
+
daemonVersion: this.updater?.getLocalVersion(),
|
|
1096
|
+
selfUpdateCapable: this.updater != null,
|
|
1097
|
+
detectedRuntimes: detected,
|
|
1098
|
+
});
|
|
1099
|
+
// Commit the comparison baseline only after a successful post — a failed
|
|
1100
|
+
// report must stay "changed" so the next tick retries it instead of
|
|
1101
|
+
// silently dropping the result until the CLI set happens to change again.
|
|
1102
|
+
this.detectedRuntimes = detected;
|
|
1103
|
+
})().catch((err) => {
|
|
1104
|
+
this.log.warn(`runtime detection report failed: ${String(err)}`);
|
|
1105
|
+
});
|
|
1106
|
+
this.runtimeDetectInFlight = run.finally(() => {
|
|
1107
|
+
this.runtimeDetectInFlight = null;
|
|
1108
|
+
});
|
|
1109
|
+
return this.runtimeDetectInFlight;
|
|
1110
|
+
}
|
|
739
1111
|
machineClipToConfig(clip) {
|
|
740
1112
|
const clipPath = path.join(this.config.rootStateDir, 'clips', clip.alias);
|
|
741
1113
|
return {
|
|
@@ -1021,20 +1393,132 @@ export class DaemonSupervisor {
|
|
|
1021
1393
|
}
|
|
1022
1394
|
}
|
|
1023
1395
|
async respawnAllChildren() {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1396
|
+
// Same deliberate-restart choreography as the per-agent path: a bare
|
|
1397
|
+
// terminateChild leaves the settle('exit') callback thinking the child
|
|
1398
|
+
// crashed, which schedules a backoff timer that then blocks the immediate
|
|
1399
|
+
// restart — the machine-level switch would wait out the backoff.
|
|
1400
|
+
// respawnChildForConfig clears the timer and single-flights the restart.
|
|
1401
|
+
for (const state of [...this.children.values()]) {
|
|
1029
1402
|
if (!state.shuttingDown && this.running) {
|
|
1030
|
-
await this.
|
|
1403
|
+
await this.respawnChildForConfig(state, 'llm_source changed');
|
|
1031
1404
|
}
|
|
1032
1405
|
}
|
|
1033
1406
|
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Per-agent counterpart of the machine.config.updated respawn: ONE agent's
|
|
1409
|
+
* explicit llm_source changed, so refresh that agent's cached
|
|
1410
|
+
* provider_config from the server and respawn only its child — every other
|
|
1411
|
+
* child keeps running. The event payload carries only the EFFECTIVE source;
|
|
1412
|
+
* the cache must hold the RAW per-agent config (see ChildState
|
|
1413
|
+
* .providerConfig) so startChild keeps resolving inherit-vs-override
|
|
1414
|
+
* against the CURRENT machine default — caching the effective value would
|
|
1415
|
+
* pin this agent across future machine-level default changes.
|
|
1416
|
+
*/
|
|
1417
|
+
async handleAgentConfigUpdated(agentId) {
|
|
1418
|
+
if (this.spawningAgents.has(agentId)) {
|
|
1419
|
+
// A spawn in flight captured its provider_config from a pre-event fetch
|
|
1420
|
+
// and `children` isn't populated yet — mark the agent dirty; spawnAgent
|
|
1421
|
+
// replays this handler once the spawn settles (same idiom as
|
|
1422
|
+
// pendingWorkspaceSetup). The replay compares configs, so it no-ops
|
|
1423
|
+
// when the spawn already saw the change.
|
|
1424
|
+
this.log.info(`agent ${agentId}: provider config updated during spawn — queued re-check`);
|
|
1425
|
+
this.pendingConfigRefresh.add(agentId);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const state = this.children.get(agentId);
|
|
1429
|
+
if (!state) {
|
|
1430
|
+
this.clearAgentConfigRefreshRetry(agentId);
|
|
1431
|
+
// No child state and no spawn in flight — but handleAgentAttached may
|
|
1432
|
+
// still be fetching its (pre-event, possibly stale) snapshot, so mark
|
|
1433
|
+
// the agent dirty for the post-spawn re-check instead of dropping the
|
|
1434
|
+
// only live signal. handleAgentDetached clears the flag if the agent
|
|
1435
|
+
// leaves instead of spawning.
|
|
1436
|
+
this.pendingConfigRefresh.add(agentId);
|
|
1437
|
+
this.log.info(`agent ${agentId}: provider config updated but no running child — queued re-check for next spawn`);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const result = await this.fetchAttachedAgent(agentId);
|
|
1441
|
+
if (result.kind === 'retryable') {
|
|
1442
|
+
// Transient list failure: retry rather than dropping the only live
|
|
1443
|
+
// update signal (fullReconcile additionally re-syncs the cache on
|
|
1444
|
+
// every WS reconnect).
|
|
1445
|
+
this.log.warn(`agent ${agentId}: provider config refresh failed — scheduling retry`);
|
|
1446
|
+
this.scheduleAgentConfigRefreshRetry(agentId);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
this.clearAgentConfigRefreshRetry(agentId);
|
|
1450
|
+
if (result.kind === 'skip') {
|
|
1451
|
+
// Agent gone or inactive — the detach path owns teardown.
|
|
1452
|
+
this.log.info(`agent ${agentId}: provider config update for a gone/inactive agent — ignoring`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
// Update the cache BEFORE the respawn so any restart path (including a
|
|
1456
|
+
// pending crash-backoff timer) starts the child with the fresh config.
|
|
1457
|
+
// Equal configs mean the change was already applied (spawn/reconcile got
|
|
1458
|
+
// there first, or a duplicate event) — skip the pointless respawn.
|
|
1459
|
+
const fresh = result.entry.provider_config;
|
|
1460
|
+
const changed = JSON.stringify(state.providerConfig ?? null) !== JSON.stringify(fresh ?? null);
|
|
1461
|
+
state.providerConfig = fresh;
|
|
1462
|
+
if (!changed) {
|
|
1463
|
+
this.log.info(`agent ${agentId}: provider config already current — no respawn needed`);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
await this.respawnChildForConfig(state, 'per-agent llm_source changed');
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Intentional single-child respawn for a config change. Marks the state
|
|
1470
|
+
* shuttingDown around the terminate so settleChild doesn't schedule a
|
|
1471
|
+
* competing crash-backoff restart (which would also bump restartAttempts
|
|
1472
|
+
* and delay the new route), clears any pending crash timer so
|
|
1473
|
+
* restartChildNow isn't blocked by it, and bails if the agent detached
|
|
1474
|
+
* while the child was terminating.
|
|
1475
|
+
*/
|
|
1476
|
+
async respawnChildForConfig(state, reason) {
|
|
1477
|
+
state.shuttingDown = true;
|
|
1478
|
+
if (state.restartTimer) {
|
|
1479
|
+
clearTimeout(state.restartTimer);
|
|
1480
|
+
state.restartTimer = null;
|
|
1481
|
+
}
|
|
1482
|
+
await this.terminateChild(state);
|
|
1483
|
+
if (this.children.get(state.agentId) !== state) {
|
|
1484
|
+
return; // detached mid-restart — the detach path owns the lifecycle
|
|
1485
|
+
}
|
|
1486
|
+
state.shuttingDown = false;
|
|
1487
|
+
await this.restartChildNow(state, reason);
|
|
1488
|
+
}
|
|
1489
|
+
scheduleAgentConfigRefreshRetry(agentId) {
|
|
1490
|
+
if (!this.running || this.agentConfigRefreshRetryTimers.has(agentId))
|
|
1491
|
+
return;
|
|
1492
|
+
const timer = setTimeout(() => {
|
|
1493
|
+
this.agentConfigRefreshRetryTimers.delete(agentId);
|
|
1494
|
+
if (this.running) {
|
|
1495
|
+
void this.handleAgentConfigUpdated(agentId);
|
|
1496
|
+
}
|
|
1497
|
+
}, AGENT_CONFIG_REFRESH_RETRY_DELAY_MS);
|
|
1498
|
+
timer.unref?.();
|
|
1499
|
+
this.agentConfigRefreshRetryTimers.set(agentId, timer);
|
|
1500
|
+
}
|
|
1501
|
+
clearAgentConfigRefreshRetry(agentId) {
|
|
1502
|
+
const timer = this.agentConfigRefreshRetryTimers.get(agentId);
|
|
1503
|
+
if (timer) {
|
|
1504
|
+
clearTimeout(timer);
|
|
1505
|
+
this.agentConfigRefreshRetryTimers.delete(agentId);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1034
1508
|
async restartChildNow(state, reason) {
|
|
1035
1509
|
if (!this.running || state.shuttingDown || state.child || state.restartTimer) {
|
|
1036
1510
|
return;
|
|
1037
1511
|
}
|
|
1512
|
+
// Single-flight per child state: fullReconcile and
|
|
1513
|
+
// handleAgentConfigUpdated can both reach here for the same agent (a WS
|
|
1514
|
+
// reconnect fires both), and the state.child guard above cannot see a
|
|
1515
|
+
// restart still awaiting its mint — a second entrant would double-spawn
|
|
1516
|
+
// and orphan the first child (settleChild ignores a superseded
|
|
1517
|
+
// state.child).
|
|
1518
|
+
if (this.restartingStates.has(state)) {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
this.restartingStates.add(state);
|
|
1038
1522
|
try {
|
|
1039
1523
|
state.credential = await this.client.mintLaunchCredential(state.agentId);
|
|
1040
1524
|
state.restartAttempts = 0;
|
|
@@ -1043,6 +1527,26 @@ export class DaemonSupervisor {
|
|
|
1043
1527
|
}
|
|
1044
1528
|
catch (err) {
|
|
1045
1529
|
this.log.warn(`agent ${state.agentId}: restart mint failed (${reason}): ${String(err)}`);
|
|
1530
|
+
// The old child is already terminated and no crash-backoff timer exists
|
|
1531
|
+
// here — without a retry the agent stays down until the next reconcile
|
|
1532
|
+
// or event. Re-enter through restartChildNow (re-mints the credential)
|
|
1533
|
+
// on the standard backoff curve; guards mirror the settle path plus the
|
|
1534
|
+
// registration-identity backstop.
|
|
1535
|
+
if (this.running &&
|
|
1536
|
+
!state.shuttingDown &&
|
|
1537
|
+
this.children.get(state.agentId) === state &&
|
|
1538
|
+
!state.restartTimer) {
|
|
1539
|
+
const delay = Math.min(this.config.restartBackoffMs * 2 ** state.restartAttempts, this.config.restartBackoffMaxMs);
|
|
1540
|
+
state.restartAttempts += 1;
|
|
1541
|
+
this.log.warn(`agent ${state.agentId}: will retry restart in ${delay}ms`);
|
|
1542
|
+
state.restartTimer = setTimeout(() => {
|
|
1543
|
+
state.restartTimer = null;
|
|
1544
|
+
void this.restartChildNow(state, reason);
|
|
1545
|
+
}, delay);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
finally {
|
|
1549
|
+
this.restartingStates.delete(state);
|
|
1046
1550
|
}
|
|
1047
1551
|
}
|
|
1048
1552
|
async spawnAgent(agentId, orgId, attached) {
|
|
@@ -1051,6 +1555,7 @@ export class DaemonSupervisor {
|
|
|
1051
1555
|
}
|
|
1052
1556
|
this.spawningAgents.add(agentId);
|
|
1053
1557
|
let shouldReplaySetupRequest = false;
|
|
1558
|
+
let shouldRecheckConfig = false;
|
|
1054
1559
|
try {
|
|
1055
1560
|
await this.spawnAgentOnce(agentId, orgId, attached);
|
|
1056
1561
|
}
|
|
@@ -1058,12 +1563,23 @@ export class DaemonSupervisor {
|
|
|
1058
1563
|
this.spawningAgents.delete(agentId);
|
|
1059
1564
|
this.cancelledSpawns.delete(agentId);
|
|
1060
1565
|
shouldReplaySetupRequest = this.pendingWorkspaceSetup.delete(agentId);
|
|
1566
|
+
shouldRecheckConfig = this.pendingConfigRefresh.delete(agentId);
|
|
1061
1567
|
}
|
|
1062
1568
|
if (shouldReplaySetupRequest && this.running) {
|
|
1063
1569
|
queueMicrotask(() => {
|
|
1064
1570
|
void this.handleWorkspaceSetupRequested(agentId);
|
|
1065
1571
|
});
|
|
1066
1572
|
}
|
|
1573
|
+
else if (shouldRecheckConfig && this.running) {
|
|
1574
|
+
// A provider-config update landed while this spawn was in flight, so
|
|
1575
|
+
// the spawn may have committed a pre-event snapshot. Re-check now that
|
|
1576
|
+
// the child state exists; the handler's config comparison no-ops when
|
|
1577
|
+
// the spawn already picked up the fresh config. The workspace replay
|
|
1578
|
+
// above subsumes this (it re-fetches the attached entry and respawns).
|
|
1579
|
+
queueMicrotask(() => {
|
|
1580
|
+
void this.handleAgentConfigUpdated(agentId);
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1067
1583
|
}
|
|
1068
1584
|
async spawnAgentOnce(agentId, orgId, attached) {
|
|
1069
1585
|
let credential;
|
|
@@ -1131,6 +1647,14 @@ export class DaemonSupervisor {
|
|
|
1131
1647
|
startChild(state) {
|
|
1132
1648
|
if (state.shuttingDown || !this.running)
|
|
1133
1649
|
return;
|
|
1650
|
+
// Never start a child for a state that is no longer the registered entry:
|
|
1651
|
+
// a detach (or detach + reattach) can land while a deliberate restart is
|
|
1652
|
+
// awaiting its credential mint — respawnChildForConfig restores
|
|
1653
|
+
// shuttingDown=false before the detach handler deletes the entry, so this
|
|
1654
|
+
// identity check is the backstop that keeps a stale state from spawning
|
|
1655
|
+
// an untracked orphan child.
|
|
1656
|
+
if (this.children.get(state.agentId) !== state)
|
|
1657
|
+
return;
|
|
1134
1658
|
if (!state.credential) {
|
|
1135
1659
|
this.log.error(`startChild ${state.agentId}: no credential — bug`);
|
|
1136
1660
|
return;
|
|
@@ -1202,7 +1726,14 @@ export class DaemonSupervisor {
|
|
|
1202
1726
|
}
|
|
1203
1727
|
settleChild('error', null, null, err);
|
|
1204
1728
|
});
|
|
1205
|
-
|
|
1729
|
+
// Settle on 'exit', not 'close': with stdio ignore/inherit/inherit the
|
|
1730
|
+
// parent holds no pipes to flush, and Node does not guarantee 'close'
|
|
1731
|
+
// lands in the same tick as 'exit'. Deliberate respawn paths await
|
|
1732
|
+
// terminateChild — an 'exit' listener registered AFTER this one — so
|
|
1733
|
+
// settling here guarantees state.child is already cleared when
|
|
1734
|
+
// terminateChild resolves and restartChildNow can run immediately
|
|
1735
|
+
// instead of silently deferring to the crash-backoff timer.
|
|
1736
|
+
child.once('exit', (code, signal) => settleChild('exit', code, signal));
|
|
1206
1737
|
const stableTimer = setTimeout(() => {
|
|
1207
1738
|
if (state.child === child) {
|
|
1208
1739
|
state.restartAttempts = 0;
|
|
@@ -1210,6 +1741,12 @@ export class DaemonSupervisor {
|
|
|
1210
1741
|
}, Math.max(this.config.restartBackoffMs, 30_000));
|
|
1211
1742
|
stableTimer.unref?.();
|
|
1212
1743
|
}
|
|
1744
|
+
/**
|
|
1745
|
+
* SIGTERM the child (SIGKILL after 10s) and resolve once it has exited.
|
|
1746
|
+
* settleChild's 'exit' listener is registered before this one, so
|
|
1747
|
+
* state.child is guaranteed cleared by the time this resolves — callers
|
|
1748
|
+
* may start a replacement child immediately after awaiting.
|
|
1749
|
+
*/
|
|
1213
1750
|
async terminateChild(state) {
|
|
1214
1751
|
const child = state.child;
|
|
1215
1752
|
if (!child)
|