@parall/daemon 1.36.1 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bundle/manifest.json +11 -11
  2. package/bundle/parall-browser-pod.js +307 -27
  3. package/bundle/parall-claude-agent.js +363 -50
  4. package/bundle/parall-codex-agent.js +362 -48
  5. package/bundle/parall-daemon.js +33473 -31706
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/clip-runtime/browser-daemon-env.d.ts +29 -0
  9. package/dist/clip-runtime/browser-daemon-env.d.ts.map +1 -0
  10. package/dist/clip-runtime/browser-daemon-env.js +70 -0
  11. package/dist/clip-runtime/browser-profile-manager.d.ts +18 -5
  12. package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
  13. package/dist/clip-runtime/browser-profile-manager.js +149 -24
  14. package/dist/clip-runtime/browser-profile-pool.d.ts +250 -0
  15. package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -0
  16. package/dist/clip-runtime/browser-profile-pool.js +581 -0
  17. package/dist/clip-runtime/index.d.ts +2 -2
  18. package/dist/clip-runtime/index.d.ts.map +1 -1
  19. package/dist/clip-runtime/index.js +2 -1
  20. package/dist/clip-runtime/process-manager.d.ts +5 -3
  21. package/dist/clip-runtime/process-manager.d.ts.map +1 -1
  22. package/dist/clip-runtime/subprocess.d.ts +14 -0
  23. package/dist/clip-runtime/subprocess.d.ts.map +1 -1
  24. package/dist/clip-runtime/subprocess.js +49 -0
  25. package/dist/config.d.ts +14 -15
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +8 -26
  28. package/dist/daemon-main.d.ts +8 -0
  29. package/dist/daemon-main.d.ts.map +1 -0
  30. package/dist/daemon-main.js +165 -0
  31. package/dist/daemon-paths.d.ts +14 -0
  32. package/dist/daemon-paths.d.ts.map +1 -0
  33. package/dist/daemon-paths.js +26 -0
  34. package/dist/daemon-update-mode.d.ts +8 -0
  35. package/dist/daemon-update-mode.d.ts.map +1 -0
  36. package/dist/daemon-update-mode.js +18 -0
  37. package/dist/index.js +41 -167
  38. package/dist/runtime-bin-resolver.d.ts +4 -0
  39. package/dist/runtime-bin-resolver.d.ts.map +1 -1
  40. package/dist/runtime-bin-resolver.js +40 -7
  41. package/dist/runtime-detector.d.ts +30 -0
  42. package/dist/runtime-detector.d.ts.map +1 -0
  43. package/dist/runtime-detector.js +100 -0
  44. package/dist/supervisor.d.ts +53 -2
  45. package/dist/supervisor.d.ts.map +1 -1
  46. package/dist/supervisor.js +604 -67
  47. package/dist/update-health-gate.d.ts +66 -0
  48. package/dist/update-health-gate.d.ts.map +1 -0
  49. package/dist/update-health-gate.js +93 -0
  50. package/dist/updater-manifest.d.ts +2 -1
  51. package/dist/updater-manifest.d.ts.map +1 -1
  52. package/dist/updater-manifest.js +38 -7
  53. package/dist/updater.d.ts +13 -2
  54. package/dist/updater.d.ts.map +1 -1
  55. package/dist/updater.js +126 -17
  56. 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,12 +167,12 @@ export class DaemonSupervisor {
125
167
  this.migrateFlatLayout();
126
168
  // Initialize Clip runtime manager (optional, for Pinix Clip subprocess management)
127
169
  if (process.env.PRLL_CLIP_RUNTIME_ENABLED === 'true') {
128
- this.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
  },
136
178
  resolveProxy: (profileId) => this.resolveBrowserProfileProxy(profileId),
@@ -138,29 +180,49 @@ export class DaemonSupervisor {
138
180
  this.clipManager = new ClipProcessManager({
139
181
  clipsDir: path.join(this.config.rootStateDir, 'clips'),
140
182
  dataDir: path.join(this.config.rootStateDir, 'clip-data'),
141
- browserProfileManager: this.browserProfileManager,
183
+ browserProfileManager: this.browserProfilePool,
142
184
  // Execution side: nested browser dependency invokes resolve their
143
185
  // binding and route through the hub (no local shortcut).
144
186
  hubClient: () => this.getHubClient(),
145
187
  ensureInstalled: (config) => this.ensureClipInstalled(config),
146
188
  });
147
189
  await this.reconcileMachineClips();
190
+ // Pre-fence every locally-known browser profile BEFORE registering as a hub
191
+ // Provider (which exposes hub browser invokes). Until the reconnect reconcile
192
+ // (fullReconcile below) has applied any reset/stop/delete missed while offline, a
193
+ // hub invoke must not run on a profile — it could revive the stale cookies a
194
+ // missed reset meant to clear (reset-before-open must hold across a reconnect).
195
+ // Fencing is synchronous and cheap (no bb-browser opens, so provider registration
196
+ // is never blocked on a slow/stuck Chromium); the reconcile un-fences the desired
197
+ // profiles after wiping. An invoke in the gap is rejected and the hub retries — no
198
+ // stale-disk execution.
199
+ const poolToFence = this.browserProfilePool;
200
+ if (poolToFence) {
201
+ for (const profileId of new Set([
202
+ ...poolToFence.activeProfileIds(),
203
+ ...poolToFence.localStateProfileIds(),
204
+ ])) {
205
+ poolToFence.fence(profileId);
206
+ }
207
+ }
148
208
  // Register as a hub Provider unless the machine opted out
149
209
  // (provider_enabled=false). See applyClipProviderState.
150
210
  await this.applyClipProviderState();
151
211
  this.startClipReconcileTimer();
152
212
  }
153
- // Report daemon version + self-update capability via heartbeat (best-effort).
154
- // Capability tracks whether a service manager supervises us: the updater is
155
- // created only in that case (see isSelfUpdateManaged in index.ts). A bare
156
- // `npx` foreground run reports false so the server/UI won't offer a manual
157
- // update it would silently ignore.
158
- this.client
159
- .postMachineHeartbeat({
160
- daemonVersion: this.updater?.getLocalVersion(),
161
- selfUpdateCapable: this.updater != null,
162
- })
163
- .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?.();
164
226
  await this.fullReconcile();
165
227
  this.ws = new ParallWs({
166
228
  getTicket: () => this.client.getMachineWsTicket(),
@@ -169,15 +231,9 @@ export class DaemonSupervisor {
169
231
  });
170
232
  this.ws.on('machine.hello', (_data) => {
171
233
  this.log.info('machine WS connected (machine.hello)');
172
- if (!this.healthConfirmed && this.updater) {
173
- try {
174
- this.updater.confirmVersion();
175
- this.healthConfirmed = true;
176
- }
177
- catch (err) {
178
- this.log.warn(`confirmVersion failed: ${String(err)}`);
179
- }
180
- }
234
+ // Control-plane handshake fact — the health gate (process-level) owns the
235
+ // confirm / stability-window policy; the supervisor just reports the event.
236
+ this.healthGate?.onMachineHello();
181
237
  void (async () => {
182
238
  await this.refreshMachineConfig();
183
239
  await this.fullReconcile();
@@ -222,6 +278,10 @@ export class DaemonSupervisor {
222
278
  void this.applyClipProviderState();
223
279
  }
224
280
  });
281
+ this.ws.on('machine.agent_config.updated', (data) => {
282
+ this.log.info(`WS: agent ${data.agent_id} provider config updated (effective llm_source=${data.llm_source})`);
283
+ void this.handleAgentConfigUpdated(data.agent_id);
284
+ });
225
285
  this.ws.on('machine.workspace.setup.requested', (data) => {
226
286
  this.log.info(`WS: workspace setup requested for agent ${data.agent_id}`);
227
287
  void this.handleWorkspaceSetupRequested(data.agent_id);
@@ -262,6 +322,13 @@ export class DaemonSupervisor {
262
322
  if (!this.running)
263
323
  return;
264
324
  this.running = false;
325
+ // This supervisor instance is ending (clean shutdown OR a runForever-caught
326
+ // soft crash). Reset the update health window before any cleanup that could
327
+ // throw, so a stale confirm timer can't fire across supervisor instances.
328
+ // Soft crash resets the timer but does NOT increment boot_count — rollback
329
+ // failures come only from process-level unclean exits (the daemon-running
330
+ // marker in index.ts).
331
+ this.healthGate?.onSupervisorEnded();
265
332
  if (this.ws) {
266
333
  this.ws.disconnect();
267
334
  this.ws = null;
@@ -271,7 +338,15 @@ export class DaemonSupervisor {
271
338
  clearTimeout(timer);
272
339
  }
273
340
  this.workspaceSetupRetryTimers.clear();
341
+ for (const timer of this.agentConfigRefreshRetryTimers.values()) {
342
+ clearTimeout(timer);
343
+ }
344
+ this.agentConfigRefreshRetryTimers.clear();
274
345
  this.stopClipReconcileTimer();
346
+ if (this.runtimeDetectTimer) {
347
+ clearInterval(this.runtimeDetectTimer);
348
+ this.runtimeDetectTimer = null;
349
+ }
275
350
  for (const state of this.children.values()) {
276
351
  state.shuttingDown = true;
277
352
  if (state.restartTimer) {
@@ -288,9 +363,9 @@ export class DaemonSupervisor {
288
363
  exits.push(this.clipManager.stopAll());
289
364
  this.clipManager = null;
290
365
  }
291
- if (this.browserProfileManager) {
292
- exits.push(this.browserProfileManager.stop());
293
- this.browserProfileManager = null;
366
+ if (this.browserProfilePool) {
367
+ exits.push(this.browserProfilePool.stop());
368
+ this.browserProfilePool = null;
294
369
  }
295
370
  await Promise.allSettled(exits);
296
371
  this.children.clear();
@@ -366,8 +441,21 @@ export class DaemonSupervisor {
366
441
  }
367
442
  await this.spawnAgent(userId, orgId, a);
368
443
  }
369
- else if (!existing.child && !existing.restartTimer && !existing.shuttingDown) {
370
- 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
+ }
371
459
  }
372
460
  }
373
461
  for (const [userId, state] of this.children) {
@@ -387,9 +475,46 @@ export class DaemonSupervisor {
387
475
  await this.reconcileMachineClips();
388
476
  await this.reconcileBrowserProfiles();
389
477
  }
478
+ // Wipe a missed reset before a revive, reporting `error` if the wipe fails. A failed
479
+ // wipe must NOT silently leave the row at `pending`: report `error` under the lifecycle
480
+ // generation (visible), keep the profile fenced (applyResetWipe fenced it) and the
481
+ // sidecar unwritten, then re-throw so the caller skips the open. The next reconcile's
482
+ // negative path retries the wipe because the SSOT reset_generation is still unapplied.
483
+ // Shared by the reconnect reconcile and the live `open` lifecycle path.
484
+ async wipeBeforeRevive(pool, profileId, resetGen, generation) {
485
+ try {
486
+ await pool.applyResetWipe(profileId, resetGen);
487
+ }
488
+ catch (err) {
489
+ const message = err instanceof Error ? err.message : String(err);
490
+ this.client
491
+ .reportBrowserProfileStatus(profileId, 'error', message, generation)
492
+ .catch((e) => this.log.warn(`browser profile wipe-error report failed: ${String(e)}`));
493
+ throw err;
494
+ }
495
+ }
390
496
  async reconcileBrowserProfiles() {
391
- if (!this.browserProfileManager)
497
+ const pool = this.browserProfilePool;
498
+ if (!pool)
392
499
  return;
500
+ // Snapshot the locally-live set BEFORE the server fetch. A profile opened via a
501
+ // WS lifecycle event DURING the fetch is NOT in this snapshot, so the negative
502
+ // convergence below (which is stale w.r.t. that open) can never release it.
503
+ // There is no periodic browser reconcile to re-open a wrongly-released profile —
504
+ // it only runs on startup / WS-hello — so a stale release would strand it until
505
+ // the next reconnect.
506
+ const activeBeforeList = new Set(pool.activeProfileIds());
507
+ // Also snapshot revives (open/ensure) queued but not yet a live manager: such a
508
+ // stale open is invisible to activeProfileIds() and would otherwise drain after a
509
+ // missed offline stop/delete and start a runtime the server no longer wants.
510
+ const pendingReviveBeforeList = new Set(this.browserProfilePendingRevives.keys());
511
+ // And ALL local profile state — per-profile homes AND legacy flat account files
512
+ // (the pool owns that storage layout). State with NO server row is a
513
+ // deleted/reassigned profile whose cookies/storage must be wiped from this host;
514
+ // a pre-upgrade profile never reopened after migration exists only as a legacy flat
515
+ // file, so home-only enumeration would miss it. Pre-fetch so state created during
516
+ // the fetch is left alone.
517
+ const localStateBeforeList = new Set(pool.localStateProfileIds());
393
518
  let profiles;
394
519
  try {
395
520
  profiles = await this.client.listMachineBrowserProfiles();
@@ -398,29 +523,163 @@ export class DaemonSupervisor {
398
523
  this.log.warn(`browser profile reconcile: list failed: ${String(err)}`);
399
524
  return;
400
525
  }
526
+ // The pool must still be the same instance after the await — stop() may have
527
+ // nulled/replaced it during a concurrent shutdown; bail rather than act on a
528
+ // torn-down pool (also avoids NPE-ing through Promise.allSettled into warnings).
529
+ if (this.browserProfilePool !== pool)
530
+ return;
531
+ // Converge local runtimes to the server's desired-live set (the server is the
532
+ // SSOT). The supervisor is the desired-state coordinator; the pool is the
533
+ // per-profile runtime registry. Every op here goes through
534
+ // enqueueBrowserProfileOp so all supervisor-originated profile work shares one
535
+ // ordering layer (the pool's own queue is the final per-profile mutex).
536
+ const desiredLive = new Set();
537
+ const serverIds = new Set(); // every byoc profile the server still lists (any status)
538
+ const serverResetGen = new Map();
539
+ const ops = [];
540
+ // reset_generation (server) is the SSOT for reset intent; the daemon's per-profile
541
+ // sidecar (read via pool.appliedResetGeneration) is only a local ack of "already
542
+ // wiped up to this generation". A missed reset (server newer than the local ack) must
543
+ // wipe local state BEFORE any open/ensure, so a reopen starts clean and never
544
+ // resurrects the cookies the reset cleared. Returns the server reset_generation to
545
+ // apply, or null if nothing is missed. Pure (no side effects).
546
+ const missedResetGen = (profileId) => {
547
+ const gen = serverResetGen.get(profileId) ?? 0;
548
+ return gen > pool.appliedResetGeneration(profileId) ? gen : null;
549
+ };
550
+ // Negative-path helper (a stopped profile with a missed reset): fence synchronously
551
+ // then enqueue a STANDALONE wipe. No revive follows, so a wipe failure simply leaves
552
+ // the profile fenced and the sidecar unwritten → the next reconcile retries (the
553
+ // SSOT reset_generation is still unapplied locally). Fail-closed by construction.
554
+ const enqueueResetWipeIfMissed = (profileId) => {
555
+ const gen = missedResetGen(profileId);
556
+ if (gen === null)
557
+ return false;
558
+ // Fence SYNCHRONOUSLY before the async wipe — same contract as the WS stop/reset
559
+ // path. On a machine-WS reconnect whose clip-provider stream stays live, a hub
560
+ // invoke could otherwise run on the stale runtime in the gap before the wipe op
561
+ // starts. (In the negative loop this re-asserts an already-set fence within the same
562
+ // synchronous iteration — atomic, no revive can observe an intermediate generation.)
563
+ pool.fence(profileId);
564
+ ops.push(this.enqueueBrowserProfileOp(profileId, () => pool.applyResetWipe(profileId, gen)));
565
+ return true;
566
+ };
401
567
  for (const profile of profiles) {
568
+ // The daemon supervises only machine-bound (byoc) profiles; hosted profiles
569
+ // (null machine_id) run on the platform pool and are never owned by a daemon.
570
+ if (!profile.machine_id)
571
+ continue;
572
+ serverIds.add(profile.id);
573
+ serverResetGen.set(profile.id, profile.reset_generation ?? 0);
402
574
  if (profile.status !== 'running' && profile.status !== 'pending')
403
575
  continue;
404
- // The daemon supervises only machine-bound (byoc) profiles; hosted
405
- // profiles (null machine_id) run on the platform pool and are never owned
406
- // by a daemon machine.
407
- 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))
408
623
  continue;
409
- const machineId = profile.machine_id;
410
- try {
411
- if (profile.status === 'pending') {
412
- await this.enqueueBrowserProfileLifecycle({
413
- machine_id: machineId,
414
- profile_id: profile.id,
415
- action: 'open',
416
- });
624
+ const isActive = activeBeforeList.has(profileId);
625
+ const hasLocalState = localStateBeforeList.has(profileId);
626
+ // Pending-only revive (queued open/ensure, no runtime, no on-disk state at the
627
+ // pre-fetch snapshot): the synchronous fence alone neutralizes the stale open (it
628
+ // bumps stopSeq, so the queued revive skips as stale). Do NOT AWAIT a teardown — it
629
+ // would block behind the possibly-stuck queue and stall the whole reconcile (the
630
+ // queued-stale-open deadlock).
631
+ if (!isActive && !hasLocalState) {
632
+ pool.fence(profileId);
633
+ // Race: a pending-only revive can COMPLETE during the list fetch (create a manager
634
+ // + on-disk state) BEFORE this fence — the snapshot is then stale, so the fence
635
+ // alone leaves an orphaned runtime/disk the negative loop never converged. Converge
636
+ // it FIRE-AND-FORGET (NOT in `ops`: a still-stuck revive queue must not hang the
637
+ // reconcile; it runs once the queue drains — after the fenced open skips or
638
+ // completes — and is a no-op if nothing was created):
639
+ // - deleted/reassigned → wipeAbsent: the old host must keep no session data.
640
+ // - present + missed reset → applyResetWipe: the completed open may have applied
641
+ // only its event's (older) reset_generation, not the current one.
642
+ // - present + stopped → releaseRuntime: free the orphaned Chromium; a plain
643
+ // stop keeps its cookies on disk.
644
+ let cleanup;
645
+ if (!serverIds.has(profileId)) {
646
+ cleanup = pool.wipeAbsent(profileId);
417
647
  }
418
648
  else {
419
- 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);
420
654
  }
655
+ void cleanup.catch((err) => this.log.warn(`browser profile pending-revive cleanup failed for ${profileId}: ${String(err)}`));
656
+ continue;
421
657
  }
422
- catch (err) {
423
- 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)}`);
424
683
  }
425
684
  }
426
685
  }
@@ -533,7 +792,9 @@ export class DaemonSupervisor {
533
792
  }
534
793
  async handleAgentDetached(agentId) {
535
794
  this.pendingWorkspaceSetup.delete(agentId);
795
+ this.pendingConfigRefresh.delete(agentId);
536
796
  this.clearWorkspaceSetupRetry(agentId);
797
+ this.clearAgentConfigRefreshRetry(agentId);
537
798
  if (this.spawningAgents.has(agentId)) {
538
799
  this.cancelledSpawns.add(agentId);
539
800
  }
@@ -574,26 +835,53 @@ export class DaemonSupervisor {
574
835
  }
575
836
  }
576
837
  }
577
- async handleBrowserProfileLifecycle(data) {
578
- if (!this.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;
579
847
  this.log.warn('browser profile lifecycle event received but clip runtime is disabled');
580
848
  const status = data.action === 'stop' ? 'stopped' : 'error';
581
849
  const error = data.action === 'stop' ? undefined : 'Browser profile runtime is disabled on this daemon';
582
- 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) => {
583
856
  this.log.warn(`browser profile lifecycle negative ACK failed for ${data.profile_id}: ${String(err)}`);
584
857
  });
585
858
  return;
586
859
  }
587
860
  try {
588
861
  switch (data.action) {
589
- case 'open':
590
- 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);
591
876
  return;
877
+ }
592
878
  case 'stop':
593
- await this.browserProfileManager.stopProfile(data.profile_id);
879
+ await this.browserProfilePool.stopProfile(data.profile_id, data.generation);
594
880
  return;
595
881
  case 'reset':
596
- 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);
597
885
  return;
598
886
  default:
599
887
  this.log.warn(`unknown browser profile lifecycle action: ${data.action}`);
@@ -604,7 +892,35 @@ export class DaemonSupervisor {
604
892
  }
605
893
  }
606
894
  enqueueBrowserProfileLifecycle(data) {
607
- 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);
608
924
  }
609
925
  enqueueBrowserProfileOp(profileId, op) {
610
926
  const previous = this.browserProfileOpQueues.get(profileId) ?? Promise.resolve();
@@ -614,13 +930,35 @@ export class DaemonSupervisor {
614
930
  })
615
931
  .then(op);
616
932
  this.browserProfileOpQueues.set(profileId, next);
617
- 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(() => {
618
941
  if (this.browserProfileOpQueues.get(profileId) === next) {
619
942
  this.browserProfileOpQueues.delete(profileId);
620
943
  }
621
944
  });
622
945
  return next;
623
946
  }
947
+ // Enqueue a REVIVE op (open / ensureRuntime), tracking it in
948
+ // browserProfilePendingRevives for the lifetime of the queued op so reconnect
949
+ // reconcile can fence a stale revive that hasn't spawned a manager yet. Registered
950
+ // synchronously at enqueue (so a snapshot taken now sees it) and cleared when the
951
+ // op settles. Use enqueueBrowserProfileOp (no tracking) for stop/reset/release/viewer.
952
+ enqueueBrowserProfileRevive(profileId, op) {
953
+ this.browserProfilePendingRevives.set(profileId, (this.browserProfilePendingRevives.get(profileId) ?? 0) + 1);
954
+ return this.enqueueBrowserProfileOp(profileId, op).finally(() => {
955
+ const remaining = (this.browserProfilePendingRevives.get(profileId) ?? 1) - 1;
956
+ if (remaining <= 0)
957
+ this.browserProfilePendingRevives.delete(profileId);
958
+ else
959
+ this.browserProfilePendingRevives.set(profileId, remaining);
960
+ });
961
+ }
624
962
  /**
625
963
  * Handle one viewer control command and reply via REST. Always answers the
626
964
  * request/reply bridge exactly once ({result} on success, {error:{message}}
@@ -628,7 +966,7 @@ export class DaemonSupervisor {
628
966
  * handleFilesystemBrowse. Serialized per profile by enqueueBrowserProfileViewer.
629
967
  */
630
968
  async handleBrowserProfileViewer(data) {
631
- if (!this.browserProfileManager) {
969
+ if (!this.browserProfilePool) {
632
970
  await this.client
633
971
  .postBrowserProfileViewerResponse(data.request_id, {
634
972
  error: { message: 'Browser profile runtime is disabled on this daemon' },
@@ -637,7 +975,7 @@ export class DaemonSupervisor {
637
975
  return;
638
976
  }
639
977
  try {
640
- const result = await this.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);
641
979
  await this.client.postBrowserProfileViewerResponse(data.request_id, { result });
642
980
  }
643
981
  catch (err) {
@@ -736,6 +1074,40 @@ export class DaemonSupervisor {
736
1074
  clearInterval(this.clipReconcileTimer);
737
1075
  this.clipReconcileTimer = null;
738
1076
  }
1077
+ /**
1078
+ * Detect runtime CLIs on this host and heartbeat the result. `initial`
1079
+ * always reports (it also carries daemon version + self-update capability,
1080
+ * replacing the old version-only startup heartbeat); periodic runs report
1081
+ * only when the detection result changed, keeping steady state write-free.
1082
+ * Serialized via `runtimeDetectInFlight` so a slow probe can't overlap the
1083
+ * next interval tick and race the change comparison.
1084
+ */
1085
+ detectAndReportRuntimes(initial) {
1086
+ if (this.runtimeDetectInFlight)
1087
+ return this.runtimeDetectInFlight;
1088
+ const run = (async () => {
1089
+ const detected = await this.detectRuntimesFn(process.env, this.log);
1090
+ const changed = this.detectedRuntimes == null || !detectedRuntimesEqual(this.detectedRuntimes, detected);
1091
+ if (!initial && !changed)
1092
+ return;
1093
+ this.log.info(`runtime detection: ${summarizeDetectedRuntimes(detected)}`);
1094
+ await this.client.postMachineHeartbeat({
1095
+ daemonVersion: this.updater?.getLocalVersion(),
1096
+ selfUpdateCapable: this.updater != null,
1097
+ detectedRuntimes: detected,
1098
+ });
1099
+ // Commit the comparison baseline only after a successful post — a failed
1100
+ // report must stay "changed" so the next tick retries it instead of
1101
+ // silently dropping the result until the CLI set happens to change again.
1102
+ this.detectedRuntimes = detected;
1103
+ })().catch((err) => {
1104
+ this.log.warn(`runtime detection report failed: ${String(err)}`);
1105
+ });
1106
+ this.runtimeDetectInFlight = run.finally(() => {
1107
+ this.runtimeDetectInFlight = null;
1108
+ });
1109
+ return this.runtimeDetectInFlight;
1110
+ }
739
1111
  machineClipToConfig(clip) {
740
1112
  const clipPath = path.join(this.config.rootStateDir, 'clips', clip.alias);
741
1113
  return {
@@ -1021,20 +1393,132 @@ export class DaemonSupervisor {
1021
1393
  }
1022
1394
  }
1023
1395
  async respawnAllChildren() {
1024
- const states = [...this.children.values()];
1025
- for (const state of states) {
1026
- await this.terminateChild(state);
1027
- }
1028
- 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()]) {
1029
1402
  if (!state.shuttingDown && this.running) {
1030
- await this.restartChildNow(state, 'llm_source changed');
1403
+ await this.respawnChildForConfig(state, 'llm_source changed');
1031
1404
  }
1032
1405
  }
1033
1406
  }
1407
+ /**
1408
+ * Per-agent counterpart of the machine.config.updated respawn: ONE agent's
1409
+ * explicit llm_source changed, so refresh that agent's cached
1410
+ * provider_config from the server and respawn only its child — every other
1411
+ * child keeps running. The event payload carries only the EFFECTIVE source;
1412
+ * the cache must hold the RAW per-agent config (see ChildState
1413
+ * .providerConfig) so startChild keeps resolving inherit-vs-override
1414
+ * against the CURRENT machine default — caching the effective value would
1415
+ * pin this agent across future machine-level default changes.
1416
+ */
1417
+ async handleAgentConfigUpdated(agentId) {
1418
+ if (this.spawningAgents.has(agentId)) {
1419
+ // A spawn in flight captured its provider_config from a pre-event fetch
1420
+ // and `children` isn't populated yet — mark the agent dirty; spawnAgent
1421
+ // replays this handler once the spawn settles (same idiom as
1422
+ // pendingWorkspaceSetup). The replay compares configs, so it no-ops
1423
+ // when the spawn already saw the change.
1424
+ this.log.info(`agent ${agentId}: provider config updated during spawn — queued re-check`);
1425
+ this.pendingConfigRefresh.add(agentId);
1426
+ return;
1427
+ }
1428
+ const state = this.children.get(agentId);
1429
+ if (!state) {
1430
+ this.clearAgentConfigRefreshRetry(agentId);
1431
+ // No child state and no spawn in flight — but handleAgentAttached may
1432
+ // still be fetching its (pre-event, possibly stale) snapshot, so mark
1433
+ // the agent dirty for the post-spawn re-check instead of dropping the
1434
+ // only live signal. handleAgentDetached clears the flag if the agent
1435
+ // leaves instead of spawning.
1436
+ this.pendingConfigRefresh.add(agentId);
1437
+ this.log.info(`agent ${agentId}: provider config updated but no running child — queued re-check for next spawn`);
1438
+ return;
1439
+ }
1440
+ const result = await this.fetchAttachedAgent(agentId);
1441
+ if (result.kind === 'retryable') {
1442
+ // Transient list failure: retry rather than dropping the only live
1443
+ // update signal (fullReconcile additionally re-syncs the cache on
1444
+ // every WS reconnect).
1445
+ this.log.warn(`agent ${agentId}: provider config refresh failed — scheduling retry`);
1446
+ this.scheduleAgentConfigRefreshRetry(agentId);
1447
+ return;
1448
+ }
1449
+ this.clearAgentConfigRefreshRetry(agentId);
1450
+ if (result.kind === 'skip') {
1451
+ // Agent gone or inactive — the detach path owns teardown.
1452
+ this.log.info(`agent ${agentId}: provider config update for a gone/inactive agent — ignoring`);
1453
+ return;
1454
+ }
1455
+ // Update the cache BEFORE the respawn so any restart path (including a
1456
+ // pending crash-backoff timer) starts the child with the fresh config.
1457
+ // Equal configs mean the change was already applied (spawn/reconcile got
1458
+ // there first, or a duplicate event) — skip the pointless respawn.
1459
+ const fresh = result.entry.provider_config;
1460
+ const changed = JSON.stringify(state.providerConfig ?? null) !== JSON.stringify(fresh ?? null);
1461
+ state.providerConfig = fresh;
1462
+ if (!changed) {
1463
+ this.log.info(`agent ${agentId}: provider config already current — no respawn needed`);
1464
+ return;
1465
+ }
1466
+ await this.respawnChildForConfig(state, 'per-agent llm_source changed');
1467
+ }
1468
+ /**
1469
+ * Intentional single-child respawn for a config change. Marks the state
1470
+ * shuttingDown around the terminate so settleChild doesn't schedule a
1471
+ * competing crash-backoff restart (which would also bump restartAttempts
1472
+ * and delay the new route), clears any pending crash timer so
1473
+ * restartChildNow isn't blocked by it, and bails if the agent detached
1474
+ * while the child was terminating.
1475
+ */
1476
+ async respawnChildForConfig(state, reason) {
1477
+ state.shuttingDown = true;
1478
+ if (state.restartTimer) {
1479
+ clearTimeout(state.restartTimer);
1480
+ state.restartTimer = null;
1481
+ }
1482
+ await this.terminateChild(state);
1483
+ if (this.children.get(state.agentId) !== state) {
1484
+ return; // detached mid-restart — the detach path owns the lifecycle
1485
+ }
1486
+ state.shuttingDown = false;
1487
+ await this.restartChildNow(state, reason);
1488
+ }
1489
+ scheduleAgentConfigRefreshRetry(agentId) {
1490
+ if (!this.running || this.agentConfigRefreshRetryTimers.has(agentId))
1491
+ return;
1492
+ const timer = setTimeout(() => {
1493
+ this.agentConfigRefreshRetryTimers.delete(agentId);
1494
+ if (this.running) {
1495
+ void this.handleAgentConfigUpdated(agentId);
1496
+ }
1497
+ }, AGENT_CONFIG_REFRESH_RETRY_DELAY_MS);
1498
+ timer.unref?.();
1499
+ this.agentConfigRefreshRetryTimers.set(agentId, timer);
1500
+ }
1501
+ clearAgentConfigRefreshRetry(agentId) {
1502
+ const timer = this.agentConfigRefreshRetryTimers.get(agentId);
1503
+ if (timer) {
1504
+ clearTimeout(timer);
1505
+ this.agentConfigRefreshRetryTimers.delete(agentId);
1506
+ }
1507
+ }
1034
1508
  async restartChildNow(state, reason) {
1035
1509
  if (!this.running || state.shuttingDown || state.child || state.restartTimer) {
1036
1510
  return;
1037
1511
  }
1512
+ // Single-flight per child state: fullReconcile and
1513
+ // handleAgentConfigUpdated can both reach here for the same agent (a WS
1514
+ // reconnect fires both), and the state.child guard above cannot see a
1515
+ // restart still awaiting its mint — a second entrant would double-spawn
1516
+ // and orphan the first child (settleChild ignores a superseded
1517
+ // state.child).
1518
+ if (this.restartingStates.has(state)) {
1519
+ return;
1520
+ }
1521
+ this.restartingStates.add(state);
1038
1522
  try {
1039
1523
  state.credential = await this.client.mintLaunchCredential(state.agentId);
1040
1524
  state.restartAttempts = 0;
@@ -1043,6 +1527,26 @@ export class DaemonSupervisor {
1043
1527
  }
1044
1528
  catch (err) {
1045
1529
  this.log.warn(`agent ${state.agentId}: restart mint failed (${reason}): ${String(err)}`);
1530
+ // The old child is already terminated and no crash-backoff timer exists
1531
+ // here — without a retry the agent stays down until the next reconcile
1532
+ // or event. Re-enter through restartChildNow (re-mints the credential)
1533
+ // on the standard backoff curve; guards mirror the settle path plus the
1534
+ // registration-identity backstop.
1535
+ if (this.running &&
1536
+ !state.shuttingDown &&
1537
+ this.children.get(state.agentId) === state &&
1538
+ !state.restartTimer) {
1539
+ const delay = Math.min(this.config.restartBackoffMs * 2 ** state.restartAttempts, this.config.restartBackoffMaxMs);
1540
+ state.restartAttempts += 1;
1541
+ this.log.warn(`agent ${state.agentId}: will retry restart in ${delay}ms`);
1542
+ state.restartTimer = setTimeout(() => {
1543
+ state.restartTimer = null;
1544
+ void this.restartChildNow(state, reason);
1545
+ }, delay);
1546
+ }
1547
+ }
1548
+ finally {
1549
+ this.restartingStates.delete(state);
1046
1550
  }
1047
1551
  }
1048
1552
  async spawnAgent(agentId, orgId, attached) {
@@ -1051,6 +1555,7 @@ export class DaemonSupervisor {
1051
1555
  }
1052
1556
  this.spawningAgents.add(agentId);
1053
1557
  let shouldReplaySetupRequest = false;
1558
+ let shouldRecheckConfig = false;
1054
1559
  try {
1055
1560
  await this.spawnAgentOnce(agentId, orgId, attached);
1056
1561
  }
@@ -1058,12 +1563,23 @@ export class DaemonSupervisor {
1058
1563
  this.spawningAgents.delete(agentId);
1059
1564
  this.cancelledSpawns.delete(agentId);
1060
1565
  shouldReplaySetupRequest = this.pendingWorkspaceSetup.delete(agentId);
1566
+ shouldRecheckConfig = this.pendingConfigRefresh.delete(agentId);
1061
1567
  }
1062
1568
  if (shouldReplaySetupRequest && this.running) {
1063
1569
  queueMicrotask(() => {
1064
1570
  void this.handleWorkspaceSetupRequested(agentId);
1065
1571
  });
1066
1572
  }
1573
+ else if (shouldRecheckConfig && this.running) {
1574
+ // A provider-config update landed while this spawn was in flight, so
1575
+ // the spawn may have committed a pre-event snapshot. Re-check now that
1576
+ // the child state exists; the handler's config comparison no-ops when
1577
+ // the spawn already picked up the fresh config. The workspace replay
1578
+ // above subsumes this (it re-fetches the attached entry and respawns).
1579
+ queueMicrotask(() => {
1580
+ void this.handleAgentConfigUpdated(agentId);
1581
+ });
1582
+ }
1067
1583
  }
1068
1584
  async spawnAgentOnce(agentId, orgId, attached) {
1069
1585
  let credential;
@@ -1131,6 +1647,14 @@ export class DaemonSupervisor {
1131
1647
  startChild(state) {
1132
1648
  if (state.shuttingDown || !this.running)
1133
1649
  return;
1650
+ // Never start a child for a state that is no longer the registered entry:
1651
+ // a detach (or detach + reattach) can land while a deliberate restart is
1652
+ // awaiting its credential mint — respawnChildForConfig restores
1653
+ // shuttingDown=false before the detach handler deletes the entry, so this
1654
+ // identity check is the backstop that keeps a stale state from spawning
1655
+ // an untracked orphan child.
1656
+ if (this.children.get(state.agentId) !== state)
1657
+ return;
1134
1658
  if (!state.credential) {
1135
1659
  this.log.error(`startChild ${state.agentId}: no credential — bug`);
1136
1660
  return;
@@ -1202,7 +1726,14 @@ export class DaemonSupervisor {
1202
1726
  }
1203
1727
  settleChild('error', null, null, err);
1204
1728
  });
1205
- 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));
1206
1737
  const stableTimer = setTimeout(() => {
1207
1738
  if (state.child === child) {
1208
1739
  state.restartAttempts = 0;
@@ -1210,6 +1741,12 @@ export class DaemonSupervisor {
1210
1741
  }, Math.max(this.config.restartBackoffMs, 30_000));
1211
1742
  stableTimer.unref?.();
1212
1743
  }
1744
+ /**
1745
+ * SIGTERM the child (SIGKILL after 10s) and resolve once it has exited.
1746
+ * settleChild's 'exit' listener is registered before this one, so
1747
+ * state.child is guaranteed cleared by the time this resolves — callers
1748
+ * may start a replacement child immediately after awaiting.
1749
+ */
1213
1750
  async terminateChild(state) {
1214
1751
  const child = state.child;
1215
1752
  if (!child)