@ouro.bot/cli 0.1.0-alpha.519 → 0.1.0-alpha.520

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.
@@ -53,6 +53,7 @@ class DaemonProcessManager {
53
53
  clearTimeoutFn;
54
54
  cooldownRecoveryMs;
55
55
  maxCooldownRetries;
56
+ startupStaleAfterMs;
56
57
  existsSyncFn;
57
58
  configCheckFn;
58
59
  statusWriterFn;
@@ -98,6 +99,29 @@ class DaemonProcessManager {
98
99
  });
99
100
  }
100
101
  }
102
+ currentTimeMs() {
103
+ const value = this.now();
104
+ return Number.isFinite(value) ? value : Date.now();
105
+ }
106
+ isStartAttemptCurrent(state, attemptId) {
107
+ return state.startAttemptId === attemptId;
108
+ }
109
+ markConfigCheckFailed(state, errorReason, fixHint) {
110
+ const agent = state.config.name;
111
+ state.snapshot.status = "crashed";
112
+ state.snapshot.errorReason = errorReason;
113
+ state.snapshot.fixHint = fixHint;
114
+ (0, runtime_1.emitNervesEvent)({
115
+ level: "error",
116
+ component: "daemon",
117
+ event: "daemon.agent_config_invalid",
118
+ message: errorReason,
119
+ meta: { agent, fix: fixHint },
120
+ });
121
+ this.writeStatus(agent, `[daemon] ${agent}: ${errorReason}\n` +
122
+ (fixHint ? ` Fix: ${fixHint}\n` : ""));
123
+ this.notifySnapshotChange(state.snapshot);
124
+ }
101
125
  constructor(options) {
102
126
  this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
103
127
  this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
@@ -105,6 +129,7 @@ class DaemonProcessManager {
105
129
  this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
106
130
  this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
107
131
  this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
132
+ this.startupStaleAfterMs = options.startupStaleAfterMs ?? 45_000;
108
133
  this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
109
134
  this.now = options.now ?? (() => Date.now());
110
135
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
@@ -119,6 +144,9 @@ class DaemonProcessManager {
119
144
  this.agents.set(agent.name, {
120
145
  config: agent,
121
146
  process: null,
147
+ startInFlight: false,
148
+ startAttemptedAtMs: null,
149
+ startAttemptId: 0,
122
150
  restartTimer: null,
123
151
  crashTimestamps: [],
124
152
  stopRequested: false,
@@ -149,107 +177,193 @@ class DaemonProcessManager {
149
177
  }
150
178
  }
151
179
  }
180
+ triggerAutoStartAgents() {
181
+ for (const state of this.agents.values()) {
182
+ if (!state.config.autoStart)
183
+ continue;
184
+ void this.startAgent(state.config.name).catch((error) => {
185
+ const errorReason = error instanceof Error ? error.message : String(error);
186
+ this.markConfigCheckFailed(state, `agent startup threw before the worker could run: ${errorReason}`, "Run 'ouro doctor' for diagnostics, then retry 'ouro up'.");
187
+ });
188
+ }
189
+ }
152
190
  async startAgent(agent) {
153
191
  const state = this.requireAgent(agent);
154
- if (state.process)
192
+ if (state.process || state.startInFlight)
155
193
  return;
156
- this.clearRestartTimer(state);
157
- state.stopRequested = false;
158
- state.snapshot.status = "starting";
159
- if (this.configCheckFn) {
160
- const result = await this.configCheckFn(agent);
161
- if (result.skip) {
162
- state.snapshot.status = "stopped";
194
+ const attemptId = state.startAttemptId + 1;
195
+ state.startAttemptId = attemptId;
196
+ state.startInFlight = true;
197
+ state.startAttemptedAtMs = this.currentTimeMs();
198
+ try {
199
+ this.clearRestartTimer(state);
200
+ state.stopRequested = false;
201
+ state.snapshot.status = "starting";
202
+ if (this.configCheckFn) {
203
+ let result;
204
+ try {
205
+ result = await this.configCheckFn(agent);
206
+ }
207
+ catch (error) {
208
+ const errorReason = error instanceof Error ? error.message : String(error);
209
+ this.markConfigCheckFailed(state, `agent config validation threw: ${errorReason}`, "Run 'ouro doctor' for diagnostics, then retry 'ouro up'.");
210
+ return;
211
+ }
212
+ if (!this.isStartAttemptCurrent(state, attemptId))
213
+ return;
214
+ if (state.stopRequested) {
215
+ state.snapshot.status = "stopped";
216
+ state.snapshot.pid = null;
217
+ this.notifySnapshotChange(state.snapshot);
218
+ return;
219
+ }
220
+ if (result.skip) {
221
+ state.snapshot.status = "stopped";
222
+ state.snapshot.errorReason = null;
223
+ state.snapshot.fixHint = null;
224
+ (0, runtime_1.emitNervesEvent)({
225
+ component: "daemon",
226
+ event: "daemon.agent_config_skipped",
227
+ message: result.error ?? "agent start skipped by config check",
228
+ meta: { agent, fix: result.fix ?? null },
229
+ });
230
+ this.notifySnapshotChange(state.snapshot);
231
+ return;
232
+ }
233
+ if (!result.ok) {
234
+ this.markConfigCheckFailed(state, result.error ?? "agent config validation failed", result.fix ?? null);
235
+ return;
236
+ }
237
+ /* v8 ignore next -- defensive duplicate stale-start guard before spawn preparation @preserve */
238
+ if (!this.isStartAttemptCurrent(state, attemptId))
239
+ return;
240
+ // Config check passed — clear any prior error so the pulse stops
241
+ // reporting the broken state. This is the recovery path: the user
242
+ // fixed their secrets/config, the next startAgent attempt sees a
243
+ // valid config, and the pulse goes quiet.
163
244
  state.snapshot.errorReason = null;
164
245
  state.snapshot.fixHint = null;
165
- (0, runtime_1.emitNervesEvent)({
166
- component: "daemon",
167
- event: "daemon.agent_config_skipped",
168
- message: result.error ?? "agent start skipped by config check",
169
- meta: { agent, fix: result.fix ?? null },
170
- });
171
- this.notifySnapshotChange(state.snapshot);
172
- return;
173
246
  }
174
- if (!result.ok) {
247
+ const runCwd = (0, identity_1.getRepoRoot)();
248
+ const entryScript = path.join((0, identity_1.getRepoRoot)(), "dist", state.config.entry);
249
+ if (this.existsSyncFn && !this.existsSyncFn(entryScript)) {
175
250
  state.snapshot.status = "crashed";
176
- // Surface the error and fix to the snapshot so sibling agents can
177
- // read it via the pulse. Without this, the diagnosis stayed
178
- // trapped in the nerves event and stderr — visible to humans
179
- // running `ouro status` or grepping logs, but invisible to
180
- // peer agents trying to coordinate around the broken state.
181
- state.snapshot.errorReason = result.error ?? "agent config validation failed";
182
- state.snapshot.fixHint = result.fix ?? null;
183
251
  (0, runtime_1.emitNervesEvent)({
184
252
  level: "error",
185
253
  component: "daemon",
186
- event: "daemon.agent_config_invalid",
187
- message: result.error ?? "agent config validation failed",
188
- meta: { agent, fix: result.fix ?? null },
254
+ event: "daemon.agent_entry_missing",
255
+ message: "agent entry script does not exist — cannot spawn. Run 'ouro daemon install' from the correct location.",
256
+ meta: { agent, entryScript },
189
257
  });
190
- this.writeStatus(agent, `[daemon] ${agent}: ${result.error}\n` +
191
- (result.fix ? ` Fix: ${result.fix}\n` : ""));
192
258
  this.notifySnapshotChange(state.snapshot);
193
259
  return;
194
260
  }
195
- // Config check passed clear any prior error so the pulse stops
196
- // reporting the broken state. This is the recovery path: the user
197
- // fixed their secrets/config, the next startAgent attempt sees a
198
- // valid config, and the pulse goes quiet.
199
- state.snapshot.errorReason = null;
200
- state.snapshot.fixHint = null;
201
- }
202
- const runCwd = (0, identity_1.getRepoRoot)();
203
- const entryScript = path.join((0, identity_1.getRepoRoot)(), "dist", state.config.entry);
204
- if (this.existsSyncFn && !this.existsSyncFn(entryScript)) {
205
- state.snapshot.status = "crashed";
261
+ /* v8 ignore next -- defensive duplicate stale-start guard immediately before spawn @preserve */
262
+ if (!this.isStartAttemptCurrent(state, attemptId))
263
+ return;
264
+ if (state.stopRequested) {
265
+ state.snapshot.status = "stopped";
266
+ state.snapshot.pid = null;
267
+ this.notifySnapshotChange(state.snapshot);
268
+ return;
269
+ }
270
+ const args = [entryScript, "--agent", state.config.agentArg ?? agent, ...(state.config.args ?? [])];
271
+ const child = this.spawnFn("node", args, {
272
+ cwd: runCwd,
273
+ env: state.config.env ? { ...process.env, ...state.config.env } : process.env,
274
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
275
+ });
276
+ /* v8 ignore next 7 -- defensive: spawn should always return a ChildProcess @preserve */
277
+ if (!child) {
278
+ state.snapshot.status = "crashed";
279
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "daemon", event: "daemon.agent_spawn_failed", message: "spawn returned null", meta: { agent } });
280
+ return;
281
+ }
282
+ if (!this.isStartAttemptCurrent(state, attemptId)) {
283
+ try {
284
+ child.kill("SIGTERM");
285
+ }
286
+ catch {
287
+ (0, runtime_1.emitNervesEvent)({
288
+ level: "warn",
289
+ component: "daemon",
290
+ event: "daemon.agent_stop_error",
291
+ message: "failed to stop stale managed agent startup",
292
+ meta: { agent },
293
+ });
294
+ }
295
+ return;
296
+ }
297
+ state.process = child;
298
+ state.snapshot.status = "running";
299
+ state.snapshot.pid = child.pid ?? null;
300
+ state.snapshot.startedAt = new Date(this.currentTimeMs()).toISOString();
301
+ const bootstrap = state.config.getRuntimeCredentialBootstrap?.() ?? null;
302
+ if (bootstrap) {
303
+ const message = {
304
+ type: "ouro.runtimeCredentialBootstrap",
305
+ agentName: bootstrap.agentName,
306
+ };
307
+ if (bootstrap.runtimeConfig)
308
+ message.runtimeConfig = bootstrap.runtimeConfig;
309
+ if (bootstrap.machineRuntimeConfig)
310
+ message.machineRuntimeConfig = bootstrap.machineRuntimeConfig;
311
+ if (bootstrap.machineId)
312
+ message.machineId = bootstrap.machineId;
313
+ if (bootstrap.providerCredentialRecords)
314
+ message.providerCredentialRecords = bootstrap.providerCredentialRecords;
315
+ try {
316
+ child.send?.(message);
317
+ (0, runtime_1.emitNervesEvent)({
318
+ component: "daemon",
319
+ event: "daemon.agent_runtime_credentials_bootstrap_sent",
320
+ message: "sent runtime credential bootstrap to managed agent process",
321
+ meta: {
322
+ agent,
323
+ runtimeConfig: !!bootstrap.runtimeConfig,
324
+ machineRuntimeConfig: !!bootstrap.machineRuntimeConfig,
325
+ providerCredentialRecords: bootstrap.providerCredentialRecords?.length ?? 0,
326
+ },
327
+ });
328
+ }
329
+ catch (error) {
330
+ (0, runtime_1.emitNervesEvent)({
331
+ level: "warn",
332
+ component: "daemon",
333
+ event: "daemon.agent_ipc_send_error",
334
+ message: "failed to send runtime credential bootstrap to managed agent process",
335
+ meta: { agent, error: error instanceof Error ? error.message : String(error) },
336
+ });
337
+ }
338
+ }
206
339
  (0, runtime_1.emitNervesEvent)({
207
- level: "error",
208
340
  component: "daemon",
209
- event: "daemon.agent_entry_missing",
210
- message: "agent entry script does not exist — cannot spawn. Run 'ouro daemon install' from the correct location.",
211
- meta: { agent, entryScript },
341
+ event: "daemon.agent_started",
342
+ message: "daemon started managed agent process",
343
+ meta: { agent, pid: child.pid ?? null, cwd: runCwd },
212
344
  });
213
345
  this.notifySnapshotChange(state.snapshot);
214
- return;
346
+ /* v8 ignore start — child process error handler; requires real spawn to trigger */
347
+ child.on("error", (err) => {
348
+ (0, runtime_1.emitNervesEvent)({
349
+ level: "warn",
350
+ component: "daemon",
351
+ event: "daemon.agent_process_error",
352
+ message: "managed agent process emitted error",
353
+ meta: { agent, error: err.message },
354
+ });
355
+ });
356
+ /* v8 ignore stop */
357
+ child.once("exit", (code, signal) => {
358
+ this.onExit(state, code, signal);
359
+ });
215
360
  }
216
- const args = [entryScript, "--agent", state.config.agentArg ?? agent, ...(state.config.args ?? [])];
217
- const child = this.spawnFn("node", args, {
218
- cwd: runCwd,
219
- env: state.config.env ? { ...process.env, ...state.config.env } : process.env,
220
- stdio: ["ignore", "ignore", "ignore", "ipc"],
221
- });
222
- /* v8 ignore next 7 -- defensive: spawn should always return a ChildProcess @preserve */
223
- if (!child) {
224
- state.snapshot.status = "crashed";
225
- (0, runtime_1.emitNervesEvent)({ level: "error", component: "daemon", event: "daemon.agent_spawn_failed", message: "spawn returned null", meta: { agent } });
226
- return;
361
+ finally {
362
+ if (this.isStartAttemptCurrent(state, attemptId)) {
363
+ state.startInFlight = false;
364
+ state.startAttemptedAtMs = null;
365
+ }
227
366
  }
228
- state.process = child;
229
- state.snapshot.status = "running";
230
- state.snapshot.pid = child.pid ?? null;
231
- state.snapshot.startedAt = new Date(this.now()).toISOString();
232
- (0, runtime_1.emitNervesEvent)({
233
- component: "daemon",
234
- event: "daemon.agent_started",
235
- message: "daemon started managed agent process",
236
- meta: { agent, pid: child.pid ?? null, cwd: runCwd },
237
- });
238
- this.notifySnapshotChange(state.snapshot);
239
- /* v8 ignore start — child process error handler; requires real spawn to trigger */
240
- child.on("error", (err) => {
241
- (0, runtime_1.emitNervesEvent)({
242
- level: "warn",
243
- component: "daemon",
244
- event: "daemon.agent_process_error",
245
- message: "managed agent process emitted error",
246
- meta: { agent, error: err.message },
247
- });
248
- });
249
- /* v8 ignore stop */
250
- child.once("exit", (code, signal) => {
251
- this.onExit(state, code, signal);
252
- });
253
367
  }
254
368
  async stopAgent(agent) {
255
369
  const state = this.requireAgent(agent);
@@ -281,6 +395,31 @@ class DaemonProcessManager {
281
395
  this.notifySnapshotChange(state.snapshot);
282
396
  }
283
397
  async restartAgent(agent) {
398
+ const state = this.requireAgent(agent);
399
+ if (state.startInFlight && !state.process) {
400
+ const startedAt = state.startAttemptedAtMs;
401
+ /* v8 ignore next -- defensive: startInFlight always records a start timestamp @preserve */
402
+ const elapsedMs = startedAt === null ? 0 : this.currentTimeMs() - startedAt;
403
+ if (elapsedMs < this.startupStaleAfterMs) {
404
+ (0, runtime_1.emitNervesEvent)({
405
+ component: "daemon",
406
+ event: "daemon.agent_restart_deferred",
407
+ message: "managed agent restart skipped while startup is already in flight",
408
+ meta: { agent, elapsedMs, startupStaleAfterMs: this.startupStaleAfterMs },
409
+ });
410
+ return;
411
+ }
412
+ (0, runtime_1.emitNervesEvent)({
413
+ level: "warn",
414
+ component: "daemon",
415
+ event: "daemon.agent_startup_stale_recovered",
416
+ message: "managed agent startup was stale; clearing the pending attempt before restart",
417
+ meta: { agent, elapsedMs, startupStaleAfterMs: this.startupStaleAfterMs },
418
+ });
419
+ state.startAttemptId += 1;
420
+ state.startInFlight = false;
421
+ state.startAttemptedAtMs = null;
422
+ }
284
423
  await this.stopAgent(agent);
285
424
  await this.startAgent(agent);
286
425
  }
@@ -316,11 +455,13 @@ class DaemonProcessManager {
316
455
  if (!state.process)
317
456
  return;
318
457
  state.process = null;
458
+ state.startInFlight = false;
459
+ state.startAttemptedAtMs = null;
319
460
  state.snapshot.pid = null;
320
461
  state.snapshot.lastExitCode = code;
321
462
  state.snapshot.lastSignal = signal;
322
463
  const crashed = !state.stopRequested && code !== 0;
323
- const now = this.now();
464
+ const now = this.currentTimeMs();
324
465
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
325
466
  const runDuration = Math.max(0, now - startedAt);
326
467
  (0, runtime_1.emitNervesEvent)({
@@ -40,6 +40,7 @@ const path = __importStar(require("path"));
40
40
  const runtime_1 = require("../../nerves/runtime");
41
41
  const identity_1 = require("../identity");
42
42
  const runtime_credentials_1 = require("../runtime-credentials");
43
+ const provider_credentials_1 = require("../provider-credentials");
43
44
  const sense_truth_1 = require("../sense-truth");
44
45
  const machine_identity_1 = require("../machine-identity");
45
46
  const process_manager_1 = require("./process-manager");
@@ -223,6 +224,25 @@ function managedSenseEntry(sense) {
223
224
  return "senses/bluebubbles/entry.js";
224
225
  return "senses/mail-entry.js";
225
226
  }
227
+ function runtimeCredentialBootstrapFor(agent, sense) {
228
+ const runtime = (0, runtime_credentials_1.readRuntimeCredentialConfig)(agent);
229
+ const machineId = sense === "bluebubbles" ? currentMachineId() : undefined;
230
+ const machine = sense === "bluebubbles" ? (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(agent) : null;
231
+ const providerPool = (0, provider_credentials_1.readProviderCredentialPool)(agent);
232
+ const providerCredentialRecords = providerPool.ok
233
+ ? Object.values(providerPool.pool.providers).filter((record) => !!record)
234
+ : [];
235
+ const bootstrap = {
236
+ agentName: agent,
237
+ runtimeConfig: runtime.ok ? runtime.config : undefined,
238
+ machineRuntimeConfig: machine?.ok ? machine.config : undefined,
239
+ machineId,
240
+ providerCredentialRecords: providerCredentialRecords.length > 0 ? providerCredentialRecords : undefined,
241
+ };
242
+ if (!bootstrap.runtimeConfig && !bootstrap.machineRuntimeConfig && !bootstrap.providerCredentialRecords)
243
+ return null;
244
+ return bootstrap;
245
+ }
226
246
  function blueBubblesRuntimeStateIsFresh(lastCheckedAt, now = Date.now()) {
227
247
  if (!lastCheckedAt) {
228
248
  return false;
@@ -279,6 +299,7 @@ function readBlueBubblesRuntimeFacts(agent, bundlesRoot, snapshot) {
279
299
  class DaemonSenseManager {
280
300
  processManager;
281
301
  contexts;
302
+ pendingConfigRefreshes = new Set();
282
303
  bundlesRoot;
283
304
  constructor(options) {
284
305
  const bundlesRoot = options.bundlesRoot ?? path.join(os.homedir(), "AgentBundles");
@@ -297,6 +318,7 @@ class DaemonSenseManager {
297
318
  entry: managedSenseEntry(sense),
298
319
  channel: sense,
299
320
  autoStart: true,
321
+ getRuntimeCredentialBootstrap: () => runtimeCredentialBootstrapFor(agent, sense),
300
322
  }));
301
323
  });
302
324
  this.processManager = options.processManager ?? new process_manager_1.DaemonProcessManager({
@@ -308,14 +330,11 @@ class DaemonSenseManager {
308
330
  const context = this.contexts.get(parsed.agent);
309
331
  if (!context)
310
332
  return { ok: true };
311
- const refreshed = await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(parsed.agent, { preserveCachedOnFailure: true });
312
- const machineRefreshed = parsed.sense === "bluebubbles"
313
- ? await (0, runtime_credentials_1.refreshMachineRuntimeCredentialConfig)(parsed.agent, currentMachineId(), { preserveCachedOnFailure: true })
314
- : (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(parsed.agent);
315
- context.facts = senseFactsFromRuntimeConfig(parsed.agent, context.senses, refreshed, machineRefreshed);
333
+ context.facts = senseFactsFromRuntimeConfig(parsed.agent, context.senses, (0, runtime_credentials_1.readRuntimeCredentialConfig)(parsed.agent), (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(parsed.agent));
316
334
  const fact = context.facts[parsed.sense];
317
335
  if (fact.configured)
318
336
  return { ok: true };
337
+ this.scheduleSenseConfigRefresh(name, parsed);
319
338
  if (fact.optional) {
320
339
  return {
321
340
  ok: false,
@@ -325,6 +344,7 @@ class DaemonSenseManager {
325
344
  }
326
345
  return {
327
346
  ok: false,
347
+ skip: true,
328
348
  error: `${parsed.sense} is enabled for ${parsed.agent} but runtime credentials are not ready: ${fact.detail}`,
329
349
  fix: senseRepairHint(parsed.agent, parsed.sense),
330
350
  };
@@ -340,9 +360,70 @@ class DaemonSenseManager {
340
360
  },
341
361
  });
342
362
  }
363
+ scheduleSenseConfigRefresh(name, parsed) {
364
+ if (this.pendingConfigRefreshes.has(name))
365
+ return;
366
+ this.pendingConfigRefreshes.add(name);
367
+ void this.refreshSenseConfigAndRetry(name, parsed);
368
+ }
369
+ async refreshSenseConfigAndRetry(name, parsed) {
370
+ try {
371
+ const refreshed = await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(parsed.agent, { preserveCachedOnFailure: true });
372
+ const machineRefreshed = parsed.sense === "bluebubbles"
373
+ ? await (0, runtime_credentials_1.refreshMachineRuntimeCredentialConfig)(parsed.agent, currentMachineId(), { preserveCachedOnFailure: true })
374
+ : (0, runtime_credentials_1.readMachineRuntimeCredentialConfig)(parsed.agent);
375
+ const context = this.contexts.get(parsed.agent);
376
+ /* v8 ignore next -- defensive: config refreshes are only scheduled for known agent contexts @preserve */
377
+ if (!context)
378
+ return;
379
+ context.facts = senseFactsFromRuntimeConfig(parsed.agent, context.senses, refreshed, machineRefreshed);
380
+ if (!context.facts[parsed.sense].configured)
381
+ return;
382
+ setTimeout(() => {
383
+ void this.processManager.startAgent?.(name).catch((error) => {
384
+ (0, runtime_1.emitNervesEvent)({
385
+ level: "error",
386
+ component: "channels",
387
+ event: "channel.daemon_sense_autostart_error",
388
+ message: "sense autostart failed",
389
+ /* v8 ignore next -- defensive: process manager rejects with Error instances in normal use @preserve */
390
+ meta: { error: error instanceof Error ? error.message : String(error) },
391
+ });
392
+ });
393
+ }, 0);
394
+ }
395
+ catch (error) {
396
+ (0, runtime_1.emitNervesEvent)({
397
+ level: "error",
398
+ component: "channels",
399
+ event: "channel.daemon_sense_autostart_error",
400
+ message: "sense config refresh failed",
401
+ /* v8 ignore next -- defensive: runtime credential refresh rejects with Error instances in normal use @preserve */
402
+ meta: { error: error instanceof Error ? error.message : String(error) },
403
+ });
404
+ }
405
+ finally {
406
+ this.pendingConfigRefreshes.delete(name);
407
+ }
408
+ }
343
409
  async startAutoStartSenses() {
344
410
  await this.processManager.startAutoStartAgents();
345
411
  }
412
+ triggerAutoStartSenses() {
413
+ if (this.processManager.triggerAutoStartAgents) {
414
+ this.processManager.triggerAutoStartAgents();
415
+ return;
416
+ }
417
+ void this.processManager.startAutoStartAgents().catch((error) => {
418
+ (0, runtime_1.emitNervesEvent)({
419
+ level: "error",
420
+ component: "channels",
421
+ event: "channel.daemon_sense_autostart_error",
422
+ message: "sense autostart failed",
423
+ meta: { error: error instanceof Error ? error.message : String(error) },
424
+ });
425
+ });
426
+ }
346
427
  async stopAll() {
347
428
  await this.processManager.stopAll();
348
429
  }
@@ -22,6 +22,7 @@ const terminal_ui_1 = require("./terminal-ui");
22
22
  const SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
23
23
  const STABILITY_THRESHOLD_MS = 5_000;
24
24
  const POLL_INTERVAL_MS = 500;
25
+ const STARTUP_POLL_TIMEOUT_MS = 180_000;
25
26
  // ── ANSI helpers ──
26
27
  const RESET = "\x1b[0m";
27
28
  const BOLD = "\x1b[1m";
@@ -133,6 +134,7 @@ function renderFinalSummary(result, isTTY) {
133
134
  */
134
135
  async function pollDaemonStartup(deps) {
135
136
  const startTime = deps.now();
137
+ const maxWaitMs = deps.maxWaitMs ?? STARTUP_POLL_TIMEOUT_MS;
136
138
  let prevLineCount = 0;
137
139
  const isTTY = deps.isTTY ?? true;
138
140
  const isAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
@@ -182,8 +184,29 @@ async function pollDaemonStartup(deps) {
182
184
  degraded: [{ agent: "daemon", errorReason: errorMsg, fixHint: "check daemon logs or run `ouro doctor`" }],
183
185
  };
184
186
  }
185
- // Show what the daemon is doing from its log
186
187
  const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
188
+ if (elapsed >= maxWaitMs) {
189
+ const errorMsg = latestEvent
190
+ ? `daemon did not answer within ${(maxWaitMs / 1000).toFixed(0)}s; latest event: ${latestEvent}`
191
+ : `daemon did not answer within ${(maxWaitMs / 1000).toFixed(0)}s`;
192
+ const result = {
193
+ stable: [],
194
+ degraded: [{ agent: "daemon", errorReason: errorMsg, fixHint: "check daemon logs or run `ouro doctor`" }],
195
+ };
196
+ if (shouldRender) {
197
+ const summary = renderFinalSummary(result, isTTY);
198
+ deps.writeRaw(summary);
199
+ }
200
+ (0, runtime_1.emitNervesEvent)({
201
+ level: "error",
202
+ component: "daemon",
203
+ event: "daemon.startup_poll_timeout",
204
+ message: "daemon startup polling timed out before the daemon answered",
205
+ meta: { socketPath: deps.socketPath, daemonPid: deps.daemonPid, elapsedMs: elapsed, maxWaitMs, lastEvent: latestEvent },
206
+ });
207
+ return result;
208
+ }
209
+ // Show what the daemon is doing from its log
187
210
  reportProgress([
188
211
  "waiting for Ouro to answer",
189
212
  latestEvent ? `- latest daemon event: ${latestEvent}` : "- background service is still starting",
@@ -223,10 +246,53 @@ async function pollDaemonStartup(deps) {
223
246
  });
224
247
  return result;
225
248
  }
249
+ if (elapsed >= maxWaitMs) {
250
+ const result = buildTimedOutStartupResult(payload, assessment, maxWaitMs);
251
+ if (shouldRender) {
252
+ const summary = renderFinalSummary(result, isTTY);
253
+ deps.writeRaw(summary);
254
+ }
255
+ (0, runtime_1.emitNervesEvent)({
256
+ level: "error",
257
+ component: "daemon",
258
+ event: "daemon.startup_poll_timeout",
259
+ message: "daemon startup polling timed out with unresolved workers",
260
+ meta: {
261
+ socketPath: deps.socketPath,
262
+ daemonPid: deps.daemonPid,
263
+ elapsedMs: elapsed,
264
+ maxWaitMs,
265
+ stableCount: result.stable.length,
266
+ degradedCount: result.degraded.length,
267
+ },
268
+ });
269
+ return result;
270
+ }
226
271
  }
227
272
  await deps.sleep(POLL_INTERVAL_MS);
228
273
  }
229
274
  }
275
+ function buildTimedOutStartupResult(payload, assessment, maxWaitMs) {
276
+ const stable = [...assessment.stable];
277
+ const degraded = [...assessment.degraded];
278
+ const stableAgents = new Set(stable);
279
+ const degradedAgents = new Set();
280
+ for (const worker of degraded)
281
+ degradedAgents.add(worker.agent);
282
+ const seconds = (maxWaitMs / 1000).toFixed(0);
283
+ for (const worker of payload.workers) {
284
+ if (stableAgents.has(worker.agent) || degradedAgents.has(worker.agent))
285
+ continue;
286
+ degraded.push({
287
+ agent: worker.agent,
288
+ errorReason: `startup timed out after ${seconds}s while worker was ${worker.status}`,
289
+ fixHint: worker.errorReason
290
+ ? worker.fixHint ?? "check daemon logs"
291
+ : "run `ouro status` or `ouro doctor` for current worker details; the daemon is still answering",
292
+ });
293
+ }
294
+ return { stable, degraded };
295
+ }
230
296
  function formatStartupWorkerLine(payload) {
231
297
  const base = `- ${payload.agent}/${payload.worker}: ${payload.status}`;
232
298
  if (payload.status === "crashed" && payload.errorReason) {
@@ -255,7 +255,7 @@ function statusFromReaderFailure(reason) {
255
255
  return reason;
256
256
  }
257
257
  async function readMailView(agentName) {
258
- const resolved = (0, reader_1.resolveMailroomReader)(agentName);
258
+ const resolved = await (0, reader_1.resolveMailroomReaderWithRefresh)(agentName);
259
259
  if (!resolved.ok) {
260
260
  const status = statusFromReaderFailure(resolved.reason);
261
261
  emitMailRead(agentName, "list", status);
@@ -300,7 +300,7 @@ async function readMailView(agentName) {
300
300
  }
301
301
  }
302
302
  async function readMailMessageView(agentName, messageId) {
303
- const resolved = (0, reader_1.resolveMailroomReader)(agentName);
303
+ const resolved = await (0, reader_1.resolveMailroomReaderWithRefresh)(agentName);
304
304
  if (!resolved.ok) {
305
305
  const status = statusFromReaderFailure(resolved.reason);
306
306
  emitMailRead(agentName, "message", status);
@@ -347,7 +347,7 @@ async function readProviderCredentialRecord(agentName, provider, options = {}) {
347
347
  const cached = readCachedProviderCredentialRecord(agentName, provider);
348
348
  if (cached.ok || options.refreshIfMissing === false)
349
349
  return cached;
350
- return resultForProvider(await refreshProviderCredentialPool(agentName), provider);
350
+ return resultForProvider(await refreshProviderCredentialPool(agentName, { providers: [provider] }), provider);
351
351
  }
352
352
  async function upsertProviderCredential(input) {
353
353
  const updatedAt = (input.now ?? new Date()).toISOString();
@@ -374,6 +374,7 @@ async function upsertProviderCredential(input) {
374
374
  });
375
375
  input.onProgress?.(`refreshing local provider snapshot from ${input.agentName}'s vault...`);
376
376
  const refreshResult = await refreshProviderCredentialPool(input.agentName, {
377
+ providers: [input.provider],
377
378
  onProgress: input.onProgress,
378
379
  });
379
380
  if (!refreshResult.ok) {