@ouro.bot/cli 0.1.0-alpha.690 → 0.1.0-alpha.692

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.692",
6
+ "changes": [
7
+ "Retry transient managed-sense runtime credential reads with bounded backoff so enabled senses recover from vault timeouts without a daemon restart.",
8
+ "Rotate NDJSON logs immediately after a single oversized final event and make doctor ignore launchd plists for paused or unscheduled habits while avoiding prune warnings for already-rotated compressed history."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.691",
13
+ "changes": [
14
+ "Bound context-loss Sentinel per-receipt detail files while preserving append-only history, so long-lived agents cannot accumulate unbounded duplicate recovery JSON in their bundles."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.690",
6
19
  "changes": [
@@ -47,6 +47,7 @@ const flight_recorder_1 = require("../arc/flight-recorder");
47
47
  const runtime_1 = require("../nerves/runtime");
48
48
  const context_loss_gauntlet_1 = require("./context-loss-gauntlet");
49
49
  const provider_visibility_1 = require("./provider-visibility");
50
+ const CONTEXT_LOSS_SENTINEL_RECEIPT_FILE_LIMIT = 512;
50
51
  const REQUIRED_LANES = ["outward", "inner"];
51
52
  function logicalLocator(...parts) {
52
53
  return path.posix.join(...parts);
@@ -324,6 +325,43 @@ function readReceiptFile(filePath, label, issues) {
324
325
  }
325
326
  return parsed.value;
326
327
  }
328
+ function receiptIdFromFileName(fileName) {
329
+ return fileName.endsWith(".json") ? fileName.slice(0, -".json".length) : null;
330
+ }
331
+ function receiptOrderFromDetailFile(filePath, fileName) {
332
+ try {
333
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
334
+ if (isReceipt(parsed))
335
+ return orderFromReceipt(parsed);
336
+ }
337
+ catch {
338
+ // Malformed legacy detail files should not block retention cleanup.
339
+ }
340
+ const stat = fs.statSync(filePath);
341
+ return {
342
+ generatedAt: new Date(stat.mtimeMs).toISOString(),
343
+ id: receiptIdFromFileName(fileName),
344
+ };
345
+ }
346
+ function pruneReceiptDetailFiles(paths, protectedReceiptIds) {
347
+ const fileNames = fs.readdirSync(paths.receiptsDir)
348
+ .filter((entry) => receiptIdFromFileName(entry) !== null);
349
+ if (fileNames.length <= CONTEXT_LOSS_SENTINEL_RECEIPT_FILE_LIMIT)
350
+ return;
351
+ const candidates = fileNames
352
+ .map((fileName) => ({
353
+ fileName,
354
+ filePath: path.join(paths.receiptsDir, fileName),
355
+ receiptId: receiptIdFromFileName(fileName),
356
+ order: receiptOrderFromDetailFile(path.join(paths.receiptsDir, fileName), fileName),
357
+ }))
358
+ .filter((entry) => !protectedReceiptIds.has(entry.receiptId))
359
+ .sort((left, right) => compareOrder(left.order, right.order));
360
+ const removalCount = fileNames.length - CONTEXT_LOSS_SENTINEL_RECEIPT_FILE_LIMIT;
361
+ for (const entry of candidates.slice(0, removalCount)) {
362
+ fs.rmSync(entry.filePath, { force: true });
363
+ }
364
+ }
327
365
  function isNonEmptyText(value) {
328
366
  return typeof value === "string" && value.trim().length > 0;
329
367
  }
@@ -806,19 +844,24 @@ async function persistReceipt(agentRoot, receipt, lockTimeoutMs) {
806
844
  const coalesced = coalescedDaemonHealthReceipt(agentRoot, existingLatest, existingReady, receipt, watermark);
807
845
  if (coalesced)
808
846
  return coalesced;
809
- atomicWriteJson(path.join(paths.receiptsDir, `${receipt.id}.json`), receipt);
847
+ const receiptPath = path.join(paths.receiptsDir, `${receipt.id}.json`);
848
+ atomicWriteJson(receiptPath, receipt);
849
+ setReceiptFileMtime(receiptPath, receipt);
810
850
  appendHistory(paths, receipt);
851
+ let latestAfterWrite = existingLatest ?? receipt;
811
852
  if (shouldReplaceLatestReceipt(existingLatest, receipt, latestOrder)) {
812
853
  atomicWriteJson(paths.latest, receipt);
813
854
  updateWatermarkOrder(watermark, "latest", receipt);
814
855
  writeWatermark(agentRoot, watermark);
815
856
  setReceiptFileMtime(paths.latest, receipt);
857
+ latestAfterWrite = receipt;
816
858
  recordBlockedReceiptEvent(agentRoot, receipt);
817
859
  recordRecoveredReceiptEvent(agentRoot, receipt);
818
860
  }
819
861
  else if (existingLatest && nextReady === receipt) {
820
862
  const updatedLatest = syncLatestReadyState(existingLatest, nextReady);
821
863
  atomicWriteJson(paths.latest, updatedLatest);
864
+ latestAfterWrite = updatedLatest;
822
865
  }
823
866
  if (receipt.verdict === "ready" && nextReady === receipt) {
824
867
  atomicWriteJson(paths.latestReady, receipt);
@@ -826,6 +869,10 @@ async function persistReceipt(agentRoot, receipt, lockTimeoutMs) {
826
869
  writeWatermark(agentRoot, watermark);
827
870
  setReceiptFileMtime(paths.latestReady, receipt);
828
871
  }
872
+ const protectedReceiptIds = new Set([receipt.id, latestAfterWrite.id]);
873
+ if (nextReady)
874
+ protectedReceiptIds.add(nextReady.id);
875
+ pruneReceiptDetailFiles(paths, protectedReceiptIds);
829
876
  return receipt;
830
877
  });
831
878
  }
@@ -7887,6 +7887,7 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
7887
7887
  bundlesRoot: deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(),
7888
7888
  homedir: os.homedir(),
7889
7889
  envPath: process.env.PATH ?? "",
7890
+ platform: process.platform,
7890
7891
  };
7891
7892
  const doctorResult = command.category
7892
7893
  ? await (0, doctor_1.runDoctorChecks)(doctorDeps, { category: command.category })
@@ -25,6 +25,9 @@ const bluebubbles_health_diagnostics_1 = require("./bluebubbles-health-diagnosti
25
25
  const ouro_path_installer_1 = require("../versioning/ouro-path-installer");
26
26
  const runtime_credentials_1 = require("../runtime-credentials");
27
27
  const machine_identity_1 = require("../machine-identity");
28
+ const habit_parser_1 = require("../habits/habit-parser");
29
+ const cadence_1 = require("./cadence");
30
+ const nerves_1 = require("../../nerves");
28
31
  const DEFAULT_BLUEBUBBLES_REQUEST_TIMEOUT_MS = 30_000;
29
32
  // ── Category checkers ──
30
33
  function checkCliPath(deps) {
@@ -321,17 +324,73 @@ function checkHabits(deps) {
321
324
  continue;
322
325
  }
323
326
  checks.push({ label: `${agentDir} habits dir`, status: "pass", detail: habitsDir });
327
+ const activeScheduledHabits = [];
328
+ let unreadableHabits = 0;
329
+ for (const file of deps.readdirSync(habitsDir).filter((entry) => entry.endsWith(".md"))) {
330
+ const filePath = `${habitsDir}/${file}`;
331
+ try {
332
+ const habit = (0, habit_parser_1.parseHabitFile)(deps.readFileSync(filePath), filePath);
333
+ if (habit.status === "active" && habit.cadence && (0, cadence_1.parseCadenceToCron)(habit.cadence) !== null) {
334
+ activeScheduledHabits.push(habit.name);
335
+ }
336
+ }
337
+ catch {
338
+ unreadableHabits += 1;
339
+ }
340
+ }
341
+ if (unreadableHabits > 0) {
342
+ checks.push({
343
+ label: `${agentDir} habit files`,
344
+ status: "warn",
345
+ detail: `${unreadableHabits} unreadable habit file(s)`,
346
+ });
347
+ }
348
+ if (activeScheduledHabits.length === 0) {
349
+ checks.push({
350
+ label: `${agentDir} launchd plists`,
351
+ status: "pass",
352
+ detail: "no active scheduled habits require launchd",
353
+ });
354
+ continue;
355
+ }
324
356
  // Check for launchd plists on macOS
357
+ const platform = deps.platform;
358
+ if (platform !== "darwin") {
359
+ checks.push({
360
+ label: `${agentDir} launchd plists`,
361
+ status: "pass",
362
+ detail: `launchd not applicable on ${platform}; scheduled habits use the platform cron manager`,
363
+ });
364
+ continue;
365
+ }
325
366
  const launchAgentsDir = `${deps.homedir}/Library/LaunchAgents`;
326
367
  if (deps.existsSync(launchAgentsDir)) {
327
- const plists = deps.readdirSync(launchAgentsDir).filter((f) => f.startsWith(`bot.ouro.${agentName}.`) && f.endsWith(".plist"));
328
- if (plists.length > 0) {
329
- checks.push({ label: `${agentDir} launchd plists`, status: "pass", detail: `${plists.length} plist(s)` });
368
+ const plists = new Set(deps.readdirSync(launchAgentsDir));
369
+ const missing = activeScheduledHabits
370
+ .map((habitName) => `bot.ouro.${agentName}.${habitName}.plist`)
371
+ .filter((plistName) => !plists.has(plistName));
372
+ if (missing.length === 0) {
373
+ checks.push({
374
+ label: `${agentDir} launchd plists`,
375
+ status: "pass",
376
+ detail: `${activeScheduledHabits.length} active scheduled habit(s) registered`,
377
+ });
330
378
  }
331
379
  else {
332
- checks.push({ label: `${agentDir} launchd plists`, status: "fail", detail: "no matching plists in LaunchAgents" });
380
+ checks.push({
381
+ label: `${agentDir} launchd plists`,
382
+ status: "fail",
383
+ detail: `missing ${missing.join(", ")}`,
384
+ });
333
385
  }
334
386
  }
387
+ else {
388
+ checks.push({
389
+ label: `${agentDir} launchd plists`,
390
+ status: "fail",
391
+ detail: `${launchAgentsDir} not found for ${activeScheduledHabits.length} active scheduled habit(s)`,
392
+ });
393
+ }
335
394
  }
336
395
  if (checks.length === 0) {
337
396
  checks.push({ label: "habits", status: "warn", detail: "no agents found" });
@@ -630,14 +689,29 @@ function checkFriends(deps) {
630
689
  }
631
690
  function checkDisk(deps) {
632
691
  const checks = [];
692
+ const isActiveLogStream = (name) => {
693
+ if (name.endsWith(".ndjson"))
694
+ return !/\.\d+\.ndjson$/.test(name);
695
+ if (name.endsWith(".log"))
696
+ return !/\.\d+\.log$/.test(name);
697
+ return false;
698
+ };
633
699
  const addLogSizeCheck = (labelPrefix, logsDir) => {
634
700
  let totalSize = 0;
701
+ let activeSize = 0;
702
+ const oversizedActive = [];
635
703
  try {
636
704
  const files = deps.readdirSync(logsDir);
637
705
  for (const file of files) {
638
706
  try {
639
707
  const stat = deps.statSync(`${logsDir}/${file}`);
640
708
  totalSize += stat.size;
709
+ if (isActiveLogStream(file)) {
710
+ activeSize += stat.size;
711
+ if (stat.size >= nerves_1.DEFAULT_MAX_LOG_SIZE_BYTES) {
712
+ oversizedActive.push(file);
713
+ }
714
+ }
641
715
  }
642
716
  catch {
643
717
  // skip unreadable files
@@ -648,14 +722,19 @@ function checkDisk(deps) {
648
722
  // readdirSync failure handled below
649
723
  }
650
724
  const sizeMB = totalSize / (1024 * 1024);
651
- if (sizeMB > 500) {
652
- checks.push({ label: `${labelPrefix} daemon log size`, status: "fail", detail: `${sizeMB.toFixed(1)}MB — exceeds 500MB limit` });
725
+ const activeSizeMB = activeSize / (1024 * 1024);
726
+ if (activeSizeMB > 500) {
727
+ checks.push({ label: `${labelPrefix} daemon log size`, status: "fail", detail: `${activeSizeMB.toFixed(1)}MB active / ${sizeMB.toFixed(1)}MB total — active logs exceed 500MB limit` });
653
728
  }
654
- else if (sizeMB > 100) {
655
- checks.push({ label: `${labelPrefix} daemon log size`, status: "warn", detail: `${sizeMB.toFixed(1)}MB — consider pruning with \`ouro logs prune\`` });
729
+ else if (oversizedActive.length > 0) {
730
+ checks.push({
731
+ label: `${labelPrefix} daemon log size`,
732
+ status: "warn",
733
+ detail: `${activeSizeMB.toFixed(1)}MB active / ${sizeMB.toFixed(1)}MB total; active stream(s) over ${Math.round(nerves_1.DEFAULT_MAX_LOG_SIZE_BYTES / (1024 * 1024))}MB: ${oversizedActive.join(", ")} — run \`ouro logs prune\``,
734
+ });
656
735
  }
657
736
  else {
658
- checks.push({ label: `${labelPrefix} daemon log size`, status: "pass", detail: `${sizeMB.toFixed(1)}MB` });
737
+ checks.push({ label: `${labelPrefix} daemon log size`, status: "pass", detail: `${activeSizeMB.toFixed(1)}MB active / ${sizeMB.toFixed(1)}MB total` });
659
738
  }
660
739
  };
661
740
  const agents = discoverAgents(deps);
@@ -49,6 +49,7 @@ const DEFAULT_TEAMS_PORT = 3978;
49
49
  const DEFAULT_BLUEBUBBLES_PORT = 18790;
50
50
  const DEFAULT_BLUEBUBBLES_WEBHOOK_PATH = "/bluebubbles-webhook";
51
51
  const BLUEBUBBLES_RUNTIME_FRESHNESS_WINDOW_MS = 90_000;
52
+ const SENSE_CONFIG_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000];
52
53
  function defaultSenses() {
53
54
  return {
54
55
  cli: { ...identity_1.DEFAULT_AGENT_SENSES.cli },
@@ -519,6 +520,8 @@ class DaemonSenseManager {
519
520
  processManager;
520
521
  contexts;
521
522
  pendingConfigRefreshes = new Set();
523
+ configRefreshRetryTimers = new Map();
524
+ configRefreshRetryAttempts = new Map();
522
525
  bundlesRoot;
523
526
  constructor(options) {
524
527
  const bundlesRoot = options.bundlesRoot ?? path.join(os.homedir(), "AgentBundles");
@@ -580,13 +583,55 @@ class DaemonSenseManager {
580
583
  },
581
584
  });
582
585
  }
583
- scheduleSenseConfigRefresh(name, parsed) {
584
- if (this.pendingConfigRefreshes.has(name))
586
+ scheduleSenseConfigRefresh(name, parsed, delayMs = 0) {
587
+ if (this.pendingConfigRefreshes.has(name) || this.configRefreshRetryTimers.has(name))
585
588
  return;
589
+ if (delayMs > 0) {
590
+ const timer = setTimeout(() => {
591
+ this.configRefreshRetryTimers.delete(name);
592
+ this.pendingConfigRefreshes.add(name);
593
+ void this.refreshSenseConfigAndRetry(name, parsed);
594
+ }, delayMs);
595
+ this.configRefreshRetryTimers.set(name, timer);
596
+ (0, runtime_1.emitNervesEvent)({
597
+ level: "warn",
598
+ component: "channels",
599
+ event: "channel.daemon_sense_config_retry_scheduled",
600
+ message: "scheduled managed sense config retry",
601
+ meta: {
602
+ agent: parsed.agent,
603
+ sense: parsed.sense,
604
+ managedName: name,
605
+ delayMs,
606
+ attempt: this.configRefreshRetryAttempts.get(name),
607
+ },
608
+ });
609
+ return;
610
+ }
586
611
  this.pendingConfigRefreshes.add(name);
587
612
  void this.refreshSenseConfigAndRetry(name, parsed);
588
613
  }
614
+ nextConfigRetryDelayMs(name) {
615
+ const attempt = (this.configRefreshRetryAttempts.get(name) ?? 0) + 1;
616
+ this.configRefreshRetryAttempts.set(name, attempt);
617
+ return SENSE_CONFIG_RETRY_DELAYS_MS[Math.min(attempt - 1, SENSE_CONFIG_RETRY_DELAYS_MS.length - 1)];
618
+ }
619
+ clearConfigRetryState(name) {
620
+ clearTimeout(this.configRefreshRetryTimers.get(name));
621
+ this.configRefreshRetryTimers.delete(name);
622
+ this.configRefreshRetryAttempts.delete(name);
623
+ }
624
+ shouldRetryConfigRefresh(sense, runtimeConfig, machineRuntimeConfig) {
625
+ if (!runtimeConfig.ok && runtimeConfig.reason === "unavailable")
626
+ return true;
627
+ if ((sense === "bluebubbles" || sense === "voice" || sense === "a2a") &&
628
+ !machineRuntimeConfig.ok &&
629
+ machineRuntimeConfig.reason === "unavailable")
630
+ return true;
631
+ return false;
632
+ }
589
633
  async refreshSenseConfigAndRetry(name, parsed) {
634
+ let retryAfterMs = null;
590
635
  try {
591
636
  const refreshed = await (0, runtime_credentials_1.refreshRuntimeCredentialConfig)(parsed.agent, { preserveCachedOnFailure: true });
592
637
  const machineRefreshed = parsed.sense === "bluebubbles" || parsed.sense === "voice" || parsed.sense === "a2a"
@@ -597,8 +642,16 @@ class DaemonSenseManager {
597
642
  if (!context)
598
643
  return;
599
644
  context.facts = senseFactsFromRuntimeConfig(parsed.agent, context.senses, refreshed, machineRefreshed);
600
- if (!context.facts[parsed.sense].configured)
645
+ if (!context.facts[parsed.sense].configured) {
646
+ if (this.shouldRetryConfigRefresh(parsed.sense, refreshed, machineRefreshed)) {
647
+ retryAfterMs = this.nextConfigRetryDelayMs(name);
648
+ }
649
+ else {
650
+ this.clearConfigRetryState(name);
651
+ }
601
652
  return;
653
+ }
654
+ this.clearConfigRetryState(name);
602
655
  setTimeout(() => {
603
656
  void this.processManager.startAgent?.(name).catch((error) => {
604
657
  (0, runtime_1.emitNervesEvent)({
@@ -624,6 +677,9 @@ class DaemonSenseManager {
624
677
  }
625
678
  finally {
626
679
  this.pendingConfigRefreshes.delete(name);
680
+ if (retryAfterMs !== null) {
681
+ this.scheduleSenseConfigRefresh(name, parsed, retryAfterMs);
682
+ }
627
683
  }
628
684
  }
629
685
  async refreshEnabledSenseConfigs() {
@@ -676,6 +732,11 @@ class DaemonSenseManager {
676
732
  });
677
733
  }
678
734
  async stopAll() {
735
+ for (const timer of this.configRefreshRetryTimers.values()) {
736
+ clearTimeout(timer);
737
+ }
738
+ this.configRefreshRetryTimers.clear();
739
+ this.configRefreshRetryAttempts.clear();
679
740
  await this.processManager.stopAll();
680
741
  }
681
742
  /* v8 ignore start -- pid collection for orphan cleanup pidfile @preserve */
@@ -303,7 +303,9 @@ function createNdjsonFileSink(filePath, optionsOrMaxSize) {
303
303
  return;
304
304
  flushing = true;
305
305
  const line = queue.shift();
306
- if (bytesSinceCheck >= rotationCheckInterval) {
306
+ const checkBeforeAppend = bytesSinceCheck >= rotationCheckInterval;
307
+ const checkAfterAppend = checkBeforeAppend || line.length >= rotationCheckInterval;
308
+ if (checkBeforeAppend) {
307
309
  bytesSinceCheck = 0;
308
310
  try {
309
311
  rotateIfNeeded(filePath, options);
@@ -313,6 +315,14 @@ function createNdjsonFileSink(filePath, optionsOrMaxSize) {
313
315
  }
314
316
  }
315
317
  (0, fs_1.appendFile)(filePath, line, "utf8", () => {
318
+ if (checkAfterAppend) {
319
+ try {
320
+ rotateIfNeeded(filePath, options);
321
+ }
322
+ catch {
323
+ // Rotation errors are surfaced via nerves events; never block writes.
324
+ }
325
+ }
316
326
  flushing = false;
317
327
  flush();
318
328
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.690",
3
+ "version": "0.1.0-alpha.692",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",