@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.
Files changed (59) hide show
  1. package/bundle/manifest.json +11 -11
  2. package/bundle/parall-browser-pod.js +384 -32
  3. package/bundle/parall-claude-agent.js +426 -51
  4. package/bundle/parall-codex-agent.js +425 -49
  5. package/bundle/parall-daemon.js +33336 -31520
  6. package/dist/browser-pod.d.ts +7 -0
  7. package/dist/browser-pod.d.ts.map +1 -1
  8. package/dist/browser-pod.js +73 -3
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +2 -2
  11. package/dist/clip-runtime/browser-daemon-env.d.ts +29 -0
  12. package/dist/clip-runtime/browser-daemon-env.d.ts.map +1 -0
  13. package/dist/clip-runtime/browser-daemon-env.js +70 -0
  14. package/dist/clip-runtime/browser-profile-manager.d.ts +34 -5
  15. package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
  16. package/dist/clip-runtime/browser-profile-manager.js +166 -24
  17. package/dist/clip-runtime/browser-profile-pool.d.ts +250 -0
  18. package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -0
  19. package/dist/clip-runtime/browser-profile-pool.js +581 -0
  20. package/dist/clip-runtime/index.d.ts +2 -1
  21. package/dist/clip-runtime/index.d.ts.map +1 -1
  22. package/dist/clip-runtime/index.js +2 -1
  23. package/dist/clip-runtime/process-manager.d.ts +5 -3
  24. package/dist/clip-runtime/process-manager.d.ts.map +1 -1
  25. package/dist/clip-runtime/subprocess.d.ts +14 -0
  26. package/dist/clip-runtime/subprocess.d.ts.map +1 -1
  27. package/dist/clip-runtime/subprocess.js +49 -0
  28. package/dist/config.d.ts +14 -15
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +8 -26
  31. package/dist/daemon-main.d.ts +8 -0
  32. package/dist/daemon-main.d.ts.map +1 -0
  33. package/dist/daemon-main.js +165 -0
  34. package/dist/daemon-paths.d.ts +14 -0
  35. package/dist/daemon-paths.d.ts.map +1 -0
  36. package/dist/daemon-paths.js +26 -0
  37. package/dist/daemon-update-mode.d.ts +8 -0
  38. package/dist/daemon-update-mode.d.ts.map +1 -0
  39. package/dist/daemon-update-mode.js +18 -0
  40. package/dist/index.js +41 -167
  41. package/dist/runtime-bin-resolver.d.ts +4 -0
  42. package/dist/runtime-bin-resolver.d.ts.map +1 -1
  43. package/dist/runtime-bin-resolver.js +40 -7
  44. package/dist/runtime-detector.d.ts +30 -0
  45. package/dist/runtime-detector.d.ts.map +1 -0
  46. package/dist/runtime-detector.js +100 -0
  47. package/dist/supervisor.d.ts +64 -2
  48. package/dist/supervisor.d.ts.map +1 -1
  49. package/dist/supervisor.js +629 -67
  50. package/dist/update-health-gate.d.ts +66 -0
  51. package/dist/update-health-gate.d.ts.map +1 -0
  52. package/dist/update-health-gate.js +93 -0
  53. package/dist/updater-manifest.d.ts +2 -1
  54. package/dist/updater-manifest.d.ts.map +1 -1
  55. package/dist/updater-manifest.js +38 -7
  56. package/dist/updater.d.ts +13 -2
  57. package/dist/updater.d.ts.map +1 -1
  58. package/dist/updater.js +126 -17
  59. package/package.json +6 -6
@@ -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 { BrowserProfileManager, ClipProcessManager, ClipProvider, HubClient, } from './clip-runtime/index.js';
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
- healthConfirmed = false;
86
- browserProfileManager = null;
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.browserProfileManager = new BrowserProfileManager({
129
- homeDir: path.join(this.config.rootStateDir, 'bb-browser'),
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.browserProfileManager,
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 via heartbeat (best-effort).
153
- // Capability tracks whether a service manager supervises us: the updater is
154
- // created only in that case (see isSelfUpdateManaged in index.ts). A bare
155
- // `npx` foreground run reports false so the server/UI won't offer a manual
156
- // update it would silently ignore.
157
- this.client
158
- .postMachineHeartbeat({
159
- daemonVersion: this.updater?.getLocalVersion(),
160
- selfUpdateCapable: this.updater != null,
161
- })
162
- .catch((err) => this.log.warn(`daemon heartbeat report failed: ${String(err)}`));
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
- if (!this.healthConfirmed && this.updater) {
172
- try {
173
- this.updater.confirmVersion();
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.browserProfileManager) {
291
- exits.push(this.browserProfileManager.stop());
292
- this.browserProfileManager = null;
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 if (!existing.child && !existing.restartTimer && !existing.shuttingDown) {
369
- await this.restartChildNow(existing, 'reconcile found no live child');
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
- if (!this.browserProfileManager)
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
- // The daemon supervises only machine-bound (byoc) profiles; hosted
404
- // profiles (null machine_id) run on the platform pool and are never owned
405
- // by a daemon machine.
406
- if (!profile.machine_id)
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 machineId = profile.machine_id;
409
- try {
410
- if (profile.status === 'pending') {
411
- await this.enqueueBrowserProfileLifecycle({
412
- machine_id: machineId,
413
- profile_id: profile.id,
414
- action: 'open',
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
- await this.browserProfileManager.ensureRuntime(profile.id);
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
- catch (err) {
422
- this.log.warn(`browser profile reconcile failed for ${profile.id}: ${String(err)}`);
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.browserProfileManager) {
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
- await this.client.reportBrowserProfileStatus(data.profile_id, status, error).catch((err) => {
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
- await this.browserProfileManager.openProfile(data.profile_id, data.start_url);
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.browserProfileManager.stopProfile(data.profile_id);
879
+ await this.browserProfilePool.stopProfile(data.profile_id, data.generation);
569
880
  return;
570
881
  case 'reset':
571
- await this.browserProfileManager.resetProfile(data.profile_id);
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
- return this.enqueueBrowserProfileOp(data.profile_id, () => this.handleBrowserProfileLifecycle(data));
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
- void next.finally(() => {
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.browserProfileManager) {
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.browserProfileManager.handleViewerCommand(data.profile_id, data.session_id, data.command, data.input, data.turn);
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
- const states = [...this.children.values()];
1000
- for (const state of states) {
1001
- await this.terminateChild(state);
1002
- }
1003
- for (const state of states) {
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.restartChildNow(state, 'llm_source changed');
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
- child.once('close', (code, signal) => settleChild('close', code, signal));
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)