@parall/daemon 1.36.0 → 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 +384 -32
- package/bundle/parall-claude-agent.js +426 -51
- package/bundle/parall-codex-agent.js +425 -49
- package/bundle/parall-daemon.js +33336 -31520
- package/dist/browser-pod.d.ts +7 -0
- package/dist/browser-pod.d.ts.map +1 -1
- package/dist/browser-pod.js +73 -3
- 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 +34 -5
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +166 -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 -1
- 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 +64 -2
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +629 -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,41 +167,62 @@ 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
|
},
|
|
178
|
+
resolveProxy: (profileId) => this.resolveBrowserProfileProxy(profileId),
|
|
136
179
|
});
|
|
137
180
|
this.clipManager = new ClipProcessManager({
|
|
138
181
|
clipsDir: path.join(this.config.rootStateDir, 'clips'),
|
|
139
182
|
dataDir: path.join(this.config.rootStateDir, 'clip-data'),
|
|
140
|
-
browserProfileManager: this.
|
|
183
|
+
browserProfileManager: this.browserProfilePool,
|
|
141
184
|
// Execution side: nested browser dependency invokes resolve their
|
|
142
185
|
// binding and route through the hub (no local shortcut).
|
|
143
186
|
hubClient: () => this.getHubClient(),
|
|
144
187
|
ensureInstalled: (config) => this.ensureClipInstalled(config),
|
|
145
188
|
});
|
|
146
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
|
+
}
|
|
147
208
|
// Register as a hub Provider unless the machine opted out
|
|
148
209
|
// (provider_enabled=false). See applyClipProviderState.
|
|
149
210
|
await this.applyClipProviderState();
|
|
150
211
|
this.startClipReconcileTimer();
|
|
151
212
|
}
|
|
152
|
-
// Report daemon version + self-update capability
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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?.();
|
|
163
226
|
await this.fullReconcile();
|
|
164
227
|
this.ws = new ParallWs({
|
|
165
228
|
getTicket: () => this.client.getMachineWsTicket(),
|
|
@@ -168,15 +231,9 @@ export class DaemonSupervisor {
|
|
|
168
231
|
});
|
|
169
232
|
this.ws.on('machine.hello', (_data) => {
|
|
170
233
|
this.log.info('machine WS connected (machine.hello)');
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.healthConfirmed = true;
|
|
175
|
-
}
|
|
176
|
-
catch (err) {
|
|
177
|
-
this.log.warn(`confirmVersion failed: ${String(err)}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
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();
|
|
180
237
|
void (async () => {
|
|
181
238
|
await this.refreshMachineConfig();
|
|
182
239
|
await this.fullReconcile();
|
|
@@ -221,6 +278,10 @@ export class DaemonSupervisor {
|
|
|
221
278
|
void this.applyClipProviderState();
|
|
222
279
|
}
|
|
223
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
|
+
});
|
|
224
285
|
this.ws.on('machine.workspace.setup.requested', (data) => {
|
|
225
286
|
this.log.info(`WS: workspace setup requested for agent ${data.agent_id}`);
|
|
226
287
|
void this.handleWorkspaceSetupRequested(data.agent_id);
|
|
@@ -261,6 +322,13 @@ export class DaemonSupervisor {
|
|
|
261
322
|
if (!this.running)
|
|
262
323
|
return;
|
|
263
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();
|
|
264
332
|
if (this.ws) {
|
|
265
333
|
this.ws.disconnect();
|
|
266
334
|
this.ws = null;
|
|
@@ -270,7 +338,15 @@ export class DaemonSupervisor {
|
|
|
270
338
|
clearTimeout(timer);
|
|
271
339
|
}
|
|
272
340
|
this.workspaceSetupRetryTimers.clear();
|
|
341
|
+
for (const timer of this.agentConfigRefreshRetryTimers.values()) {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
}
|
|
344
|
+
this.agentConfigRefreshRetryTimers.clear();
|
|
273
345
|
this.stopClipReconcileTimer();
|
|
346
|
+
if (this.runtimeDetectTimer) {
|
|
347
|
+
clearInterval(this.runtimeDetectTimer);
|
|
348
|
+
this.runtimeDetectTimer = null;
|
|
349
|
+
}
|
|
274
350
|
for (const state of this.children.values()) {
|
|
275
351
|
state.shuttingDown = true;
|
|
276
352
|
if (state.restartTimer) {
|
|
@@ -287,9 +363,9 @@ export class DaemonSupervisor {
|
|
|
287
363
|
exits.push(this.clipManager.stopAll());
|
|
288
364
|
this.clipManager = null;
|
|
289
365
|
}
|
|
290
|
-
if (this.
|
|
291
|
-
exits.push(this.
|
|
292
|
-
this.
|
|
366
|
+
if (this.browserProfilePool) {
|
|
367
|
+
exits.push(this.browserProfilePool.stop());
|
|
368
|
+
this.browserProfilePool = null;
|
|
293
369
|
}
|
|
294
370
|
await Promise.allSettled(exits);
|
|
295
371
|
this.children.clear();
|
|
@@ -365,8 +441,21 @@ export class DaemonSupervisor {
|
|
|
365
441
|
}
|
|
366
442
|
await this.spawnAgent(userId, orgId, a);
|
|
367
443
|
}
|
|
368
|
-
else
|
|
369
|
-
|
|
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
|
+
}
|
|
370
459
|
}
|
|
371
460
|
}
|
|
372
461
|
for (const [userId, state] of this.children) {
|
|
@@ -386,9 +475,46 @@ export class DaemonSupervisor {
|
|
|
386
475
|
await this.reconcileMachineClips();
|
|
387
476
|
await this.reconcileBrowserProfiles();
|
|
388
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
|
+
}
|
|
389
496
|
async reconcileBrowserProfiles() {
|
|
390
|
-
|
|
497
|
+
const pool = this.browserProfilePool;
|
|
498
|
+
if (!pool)
|
|
391
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());
|
|
392
518
|
let profiles;
|
|
393
519
|
try {
|
|
394
520
|
profiles = await this.client.listMachineBrowserProfiles();
|
|
@@ -397,32 +523,190 @@ export class DaemonSupervisor {
|
|
|
397
523
|
this.log.warn(`browser profile reconcile: list failed: ${String(err)}`);
|
|
398
524
|
return;
|
|
399
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
|
+
};
|
|
400
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);
|
|
401
574
|
if (profile.status !== 'running' && profile.status !== 'pending')
|
|
402
575
|
continue;
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
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))
|
|
407
623
|
continue;
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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);
|
|
416
647
|
}
|
|
417
648
|
else {
|
|
418
|
-
|
|
649
|
+
const wipeGen = missedResetGen(profileId);
|
|
650
|
+
cleanup =
|
|
651
|
+
wipeGen !== null
|
|
652
|
+
? pool.applyResetWipe(profileId, wipeGen)
|
|
653
|
+
: pool.releaseRuntime(profileId);
|
|
419
654
|
}
|
|
655
|
+
void cleanup.catch((err) => this.log.warn(`browser profile pending-revive cleanup failed for ${profileId}: ${String(err)}`));
|
|
656
|
+
continue;
|
|
420
657
|
}
|
|
421
|
-
|
|
422
|
-
|
|
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)}`);
|
|
423
683
|
}
|
|
424
684
|
}
|
|
425
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Resolve a profile's outbound proxy for bb-browser account creation (BYOC).
|
|
688
|
+
* Fetched fresh — account creation is rare (once per profile until reset), so a
|
|
689
|
+
* proxy edit applies on the next account_create. The mck_-authed machine list is
|
|
690
|
+
* the only path that carries proxy_password (for replay to bb-browser).
|
|
691
|
+
*
|
|
692
|
+
* Fails closed: a fetch failure or a vanished profile THROWS, so the manager never
|
|
693
|
+
* creates an account that would egress from the host's real IP for a
|
|
694
|
+
* proxy-configured profile (the caller reports `error` and retries next tick).
|
|
695
|
+
*/
|
|
696
|
+
async resolveBrowserProfileProxy(profileId) {
|
|
697
|
+
const profiles = await this.client.listMachineBrowserProfiles();
|
|
698
|
+
const profile = profiles.find((p) => p.id === profileId);
|
|
699
|
+
if (!profile) {
|
|
700
|
+
throw new Error(`browser profile ${profileId} not found; refusing to create a bb-browser account without its proxy config`);
|
|
701
|
+
}
|
|
702
|
+
if (!profile.proxy_server)
|
|
703
|
+
return null;
|
|
704
|
+
return {
|
|
705
|
+
server: profile.proxy_server,
|
|
706
|
+
username: profile.proxy_username ?? undefined,
|
|
707
|
+
password: profile.proxy_password ?? undefined,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
426
710
|
// ---- Flat layout migration (self-hosted → daemon) ----
|
|
427
711
|
/**
|
|
428
712
|
* Detects a legacy flat state layout (no agents/ subdir) and migrates it
|
|
@@ -508,7 +792,9 @@ export class DaemonSupervisor {
|
|
|
508
792
|
}
|
|
509
793
|
async handleAgentDetached(agentId) {
|
|
510
794
|
this.pendingWorkspaceSetup.delete(agentId);
|
|
795
|
+
this.pendingConfigRefresh.delete(agentId);
|
|
511
796
|
this.clearWorkspaceSetupRetry(agentId);
|
|
797
|
+
this.clearAgentConfigRefreshRetry(agentId);
|
|
512
798
|
if (this.spawningAgents.has(agentId)) {
|
|
513
799
|
this.cancelledSpawns.add(agentId);
|
|
514
800
|
}
|
|
@@ -549,26 +835,53 @@ export class DaemonSupervisor {
|
|
|
549
835
|
}
|
|
550
836
|
}
|
|
551
837
|
}
|
|
552
|
-
async handleBrowserProfileLifecycle(data) {
|
|
553
|
-
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;
|
|
554
847
|
this.log.warn('browser profile lifecycle event received but clip runtime is disabled');
|
|
555
848
|
const status = data.action === 'stop' ? 'stopped' : 'error';
|
|
556
849
|
const error = data.action === 'stop' ? undefined : 'Browser profile runtime is disabled on this daemon';
|
|
557
|
-
|
|
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) => {
|
|
558
856
|
this.log.warn(`browser profile lifecycle negative ACK failed for ${data.profile_id}: ${String(err)}`);
|
|
559
857
|
});
|
|
560
858
|
return;
|
|
561
859
|
}
|
|
562
860
|
try {
|
|
563
861
|
switch (data.action) {
|
|
564
|
-
case 'open':
|
|
565
|
-
|
|
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);
|
|
566
876
|
return;
|
|
877
|
+
}
|
|
567
878
|
case 'stop':
|
|
568
|
-
await this.
|
|
879
|
+
await this.browserProfilePool.stopProfile(data.profile_id, data.generation);
|
|
569
880
|
return;
|
|
570
881
|
case 'reset':
|
|
571
|
-
|
|
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);
|
|
572
885
|
return;
|
|
573
886
|
default:
|
|
574
887
|
this.log.warn(`unknown browser profile lifecycle action: ${data.action}`);
|
|
@@ -579,7 +892,35 @@ export class DaemonSupervisor {
|
|
|
579
892
|
}
|
|
580
893
|
}
|
|
581
894
|
enqueueBrowserProfileLifecycle(data) {
|
|
582
|
-
|
|
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);
|
|
583
924
|
}
|
|
584
925
|
enqueueBrowserProfileOp(profileId, op) {
|
|
585
926
|
const previous = this.browserProfileOpQueues.get(profileId) ?? Promise.resolve();
|
|
@@ -589,13 +930,35 @@ export class DaemonSupervisor {
|
|
|
589
930
|
})
|
|
590
931
|
.then(op);
|
|
591
932
|
this.browserProfileOpQueues.set(profileId, next);
|
|
592
|
-
|
|
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(() => {
|
|
593
941
|
if (this.browserProfileOpQueues.get(profileId) === next) {
|
|
594
942
|
this.browserProfileOpQueues.delete(profileId);
|
|
595
943
|
}
|
|
596
944
|
});
|
|
597
945
|
return next;
|
|
598
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
|
+
}
|
|
599
962
|
/**
|
|
600
963
|
* Handle one viewer control command and reply via REST. Always answers the
|
|
601
964
|
* request/reply bridge exactly once ({result} on success, {error:{message}}
|
|
@@ -603,7 +966,7 @@ export class DaemonSupervisor {
|
|
|
603
966
|
* handleFilesystemBrowse. Serialized per profile by enqueueBrowserProfileViewer.
|
|
604
967
|
*/
|
|
605
968
|
async handleBrowserProfileViewer(data) {
|
|
606
|
-
if (!this.
|
|
969
|
+
if (!this.browserProfilePool) {
|
|
607
970
|
await this.client
|
|
608
971
|
.postBrowserProfileViewerResponse(data.request_id, {
|
|
609
972
|
error: { message: 'Browser profile runtime is disabled on this daemon' },
|
|
@@ -612,7 +975,7 @@ export class DaemonSupervisor {
|
|
|
612
975
|
return;
|
|
613
976
|
}
|
|
614
977
|
try {
|
|
615
|
-
const result = await this.
|
|
978
|
+
const result = await this.browserProfilePool.handleViewerCommand(data.profile_id, data.session_id, data.command, data.input, data.turn);
|
|
616
979
|
await this.client.postBrowserProfileViewerResponse(data.request_id, { result });
|
|
617
980
|
}
|
|
618
981
|
catch (err) {
|
|
@@ -711,6 +1074,40 @@ export class DaemonSupervisor {
|
|
|
711
1074
|
clearInterval(this.clipReconcileTimer);
|
|
712
1075
|
this.clipReconcileTimer = null;
|
|
713
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
|
+
}
|
|
714
1111
|
machineClipToConfig(clip) {
|
|
715
1112
|
const clipPath = path.join(this.config.rootStateDir, 'clips', clip.alias);
|
|
716
1113
|
return {
|
|
@@ -996,20 +1393,132 @@ export class DaemonSupervisor {
|
|
|
996
1393
|
}
|
|
997
1394
|
}
|
|
998
1395
|
async respawnAllChildren() {
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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()]) {
|
|
1004
1402
|
if (!state.shuttingDown && this.running) {
|
|
1005
|
-
await this.
|
|
1403
|
+
await this.respawnChildForConfig(state, 'llm_source changed');
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
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);
|
|
1006
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);
|
|
1007
1506
|
}
|
|
1008
1507
|
}
|
|
1009
1508
|
async restartChildNow(state, reason) {
|
|
1010
1509
|
if (!this.running || state.shuttingDown || state.child || state.restartTimer) {
|
|
1011
1510
|
return;
|
|
1012
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);
|
|
1013
1522
|
try {
|
|
1014
1523
|
state.credential = await this.client.mintLaunchCredential(state.agentId);
|
|
1015
1524
|
state.restartAttempts = 0;
|
|
@@ -1018,6 +1527,26 @@ export class DaemonSupervisor {
|
|
|
1018
1527
|
}
|
|
1019
1528
|
catch (err) {
|
|
1020
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);
|
|
1021
1550
|
}
|
|
1022
1551
|
}
|
|
1023
1552
|
async spawnAgent(agentId, orgId, attached) {
|
|
@@ -1026,6 +1555,7 @@ export class DaemonSupervisor {
|
|
|
1026
1555
|
}
|
|
1027
1556
|
this.spawningAgents.add(agentId);
|
|
1028
1557
|
let shouldReplaySetupRequest = false;
|
|
1558
|
+
let shouldRecheckConfig = false;
|
|
1029
1559
|
try {
|
|
1030
1560
|
await this.spawnAgentOnce(agentId, orgId, attached);
|
|
1031
1561
|
}
|
|
@@ -1033,12 +1563,23 @@ export class DaemonSupervisor {
|
|
|
1033
1563
|
this.spawningAgents.delete(agentId);
|
|
1034
1564
|
this.cancelledSpawns.delete(agentId);
|
|
1035
1565
|
shouldReplaySetupRequest = this.pendingWorkspaceSetup.delete(agentId);
|
|
1566
|
+
shouldRecheckConfig = this.pendingConfigRefresh.delete(agentId);
|
|
1036
1567
|
}
|
|
1037
1568
|
if (shouldReplaySetupRequest && this.running) {
|
|
1038
1569
|
queueMicrotask(() => {
|
|
1039
1570
|
void this.handleWorkspaceSetupRequested(agentId);
|
|
1040
1571
|
});
|
|
1041
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
|
+
}
|
|
1042
1583
|
}
|
|
1043
1584
|
async spawnAgentOnce(agentId, orgId, attached) {
|
|
1044
1585
|
let credential;
|
|
@@ -1106,6 +1647,14 @@ export class DaemonSupervisor {
|
|
|
1106
1647
|
startChild(state) {
|
|
1107
1648
|
if (state.shuttingDown || !this.running)
|
|
1108
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;
|
|
1109
1658
|
if (!state.credential) {
|
|
1110
1659
|
this.log.error(`startChild ${state.agentId}: no credential — bug`);
|
|
1111
1660
|
return;
|
|
@@ -1177,7 +1726,14 @@ export class DaemonSupervisor {
|
|
|
1177
1726
|
}
|
|
1178
1727
|
settleChild('error', null, null, err);
|
|
1179
1728
|
});
|
|
1180
|
-
|
|
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));
|
|
1181
1737
|
const stableTimer = setTimeout(() => {
|
|
1182
1738
|
if (state.child === child) {
|
|
1183
1739
|
state.restartAttempts = 0;
|
|
@@ -1185,6 +1741,12 @@ export class DaemonSupervisor {
|
|
|
1185
1741
|
}, Math.max(this.config.restartBackoffMs, 30_000));
|
|
1186
1742
|
stableTimer.unref?.();
|
|
1187
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
|
+
*/
|
|
1188
1750
|
async terminateChild(state) {
|
|
1189
1751
|
const child = state.child;
|
|
1190
1752
|
if (!child)
|