@songsid/agend 2.1.7-beta.3 → 2.1.7-beta.5

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 (92) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli-instructions.md +5 -0
  4. package/dist/agent-cli.js +13 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +48 -5
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/codex-session-lookup.d.ts +59 -0
  10. package/dist/backend/codex-session-lookup.js +139 -0
  11. package/dist/backend/codex-session-lookup.js.map +1 -0
  12. package/dist/backend/codex.d.ts +29 -0
  13. package/dist/backend/codex.js +183 -10
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/types.d.ts +21 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/channel/mcp-operation-response.d.ts +3 -0
  18. package/dist/channel/mcp-operation-response.js +21 -0
  19. package/dist/channel/mcp-operation-response.js.map +1 -0
  20. package/dist/channel/mcp-operation-response.test.d.ts +1 -0
  21. package/dist/channel/mcp-operation-response.test.js +23 -0
  22. package/dist/channel/mcp-operation-response.test.js.map +1 -0
  23. package/dist/channel/mcp-server.js +20 -5
  24. package/dist/channel/mcp-server.js.map +1 -1
  25. package/dist/channel/mcp-tools.js +2 -0
  26. package/dist/channel/mcp-tools.js.map +1 -1
  27. package/dist/cli.js +68 -2
  28. package/dist/cli.js.map +1 -1
  29. package/dist/daemon.d.ts +34 -0
  30. package/dist/daemon.js +415 -41
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +174 -0
  33. package/dist/delivery-outbox.js +824 -0
  34. package/dist/delivery-outbox.js.map +1 -0
  35. package/dist/delivery-outbox.test.d.ts +1 -0
  36. package/dist/delivery-outbox.test.js +597 -0
  37. package/dist/delivery-outbox.test.js.map +1 -0
  38. package/dist/delivery-queue-evidence.d.ts +15 -0
  39. package/dist/delivery-queue-evidence.js +35 -0
  40. package/dist/delivery-queue-evidence.js.map +1 -0
  41. package/dist/delivery-queue-evidence.test.d.ts +1 -0
  42. package/dist/delivery-queue-evidence.test.js +23 -0
  43. package/dist/delivery-queue-evidence.test.js.map +1 -0
  44. package/dist/delivery-reconciliation.d.ts +49 -0
  45. package/dist/delivery-reconciliation.js +296 -0
  46. package/dist/delivery-reconciliation.js.map +1 -0
  47. package/dist/delivery-reconciliation.test.d.ts +1 -0
  48. package/dist/delivery-reconciliation.test.js +411 -0
  49. package/dist/delivery-reconciliation.test.js.map +1 -0
  50. package/dist/fleet-manager.d.ts +39 -1
  51. package/dist/fleet-manager.js +345 -50
  52. package/dist/fleet-manager.js.map +1 -1
  53. package/dist/instance-lifecycle.d.ts +18 -0
  54. package/dist/instance-lifecycle.js +116 -3
  55. package/dist/instance-lifecycle.js.map +1 -1
  56. package/dist/instructions.js +3 -0
  57. package/dist/instructions.js.map +1 -1
  58. package/dist/locale.js +6 -0
  59. package/dist/locale.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +25 -1
  61. package/dist/outbound-handlers.js +119 -8
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +7 -0
  64. package/dist/outbound-schemas.js +14 -0
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/pane-write-lock.d.ts +2 -0
  67. package/dist/pane-write-lock.js +19 -0
  68. package/dist/pane-write-lock.js.map +1 -1
  69. package/dist/remote-credential-check.d.ts +39 -0
  70. package/dist/remote-credential-check.js +81 -0
  71. package/dist/remote-credential-check.js.map +1 -0
  72. package/dist/scheduler/scheduler.d.ts +5 -1
  73. package/dist/scheduler/scheduler.js +60 -20
  74. package/dist/scheduler/scheduler.js.map +1 -1
  75. package/dist/scheduler/scheduler.test.js +41 -2
  76. package/dist/scheduler/scheduler.test.js.map +1 -1
  77. package/dist/scheduler/types.d.ts +1 -1
  78. package/dist/tmux-manager.d.ts +3 -3
  79. package/dist/tmux-manager.js +6 -6
  80. package/dist/tmux-manager.js.map +1 -1
  81. package/dist/tool-permissions.js +6 -6
  82. package/dist/tool-permissions.js.map +1 -1
  83. package/dist/transcript-monitor.d.ts +3 -1
  84. package/dist/transcript-monitor.js +15 -0
  85. package/dist/transcript-monitor.js.map +1 -1
  86. package/dist/transcript-sources.d.ts +9 -0
  87. package/dist/transcript-sources.js +14 -0
  88. package/dist/transcript-sources.js.map +1 -1
  89. package/dist/usage/providers.d.ts +11 -6
  90. package/dist/usage/providers.js +17 -10
  91. package/dist/usage/providers.js.map +1 -1
  92. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
2
- import { randomBytes } from "node:crypto";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { freemem, totalmem, cpus } from "node:os";
5
5
  import { createServer } from "node:http";
@@ -54,6 +54,12 @@ import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } f
54
54
  import { TopicArchiver } from "./topic-archiver.js";
55
55
  import { StatuslineWatcher } from "./statusline-watcher.js";
56
56
  import { outboundHandlers } from "./outbound-handlers.js";
57
+ import { DeliveryOutbox } from "./delivery-outbox.js";
58
+ // The target Daemon can legitimately wait 30 minutes for a busy pane before
59
+ // deciding to defer before submission. This is an alert threshold only: the
60
+ // active manager/target generation pair owns the lane until the daemon reports
61
+ // a state transition or that generation is replaced.
62
+ export const DURABLE_DELIVERY_LANE_ALERT_MS = 35 * 60_000;
57
63
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
58
64
  import { handleViewRequest, isViewPath } from "./view-api.js";
59
65
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
@@ -290,6 +296,14 @@ export class FleetManager {
290
296
  spawnGate;
291
297
  /** Fleet-level backend reachability memory (fed by pty_error / startup panes). */
292
298
  backendOutage = new BackendOutageTracker();
299
+ /** Stable for this FleetManager OS-process lifetime; Daemon objects have their own boot IDs. */
300
+ managerBootId = randomUUID();
301
+ deliveryOutbox = null;
302
+ deliveryOutboxRecovered = false;
303
+ deliveryPumpScheduled = false;
304
+ deliveryPumpRunning = false;
305
+ deliveryPumpTimer = null;
306
+ activeDurableTargets = new Set();
293
307
  /** Live view of lifecycle.daemons — used throughout; not deprecated. */
294
308
  get daemons() { return this.lifecycle.daemons; }
295
309
  fleetConfig = null;
@@ -558,6 +572,243 @@ export class FleetManager {
558
572
  this.topicArchiver = new TopicArchiver(this);
559
573
  this.statuslineWatcher = new StatuslineWatcher(this);
560
574
  }
575
+ ensureDeliveryOutbox() {
576
+ if (this.deliveryOutbox)
577
+ return;
578
+ const outbox = new DeliveryOutbox(join(this.dataDir, "delivery-outbox.db"), this.managerBootId);
579
+ this.deliveryOutbox = outbox;
580
+ if (!this.deliveryOutboxRecovered) {
581
+ const recovered = outbox.recoverForBoot(this.managerBootId);
582
+ this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
583
+ this.deliveryOutboxRecovered = true;
584
+ }
585
+ outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
586
+ outbox.on("state", (event) => {
587
+ this.scheduleDeliveryOutboxPump();
588
+ if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
589
+ const row = outbox.get(event.deliveryId);
590
+ if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
591
+ const notice = `Durable delivery ${row.state}: operation_id=${row.operationId}; target=${row.targetInstance}; delivery_id=${row.deliveryId}.`;
592
+ this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
593
+ this.notifyFleetError(notice);
594
+ }
595
+ }
596
+ });
597
+ outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
598
+ outbox.on("expired", (event) => {
599
+ this.scheduleDeliveryOutboxPump();
600
+ if (event.count)
601
+ this.notifyFleetError(`${event.count} durable delivery row(s) reached the 24-hour outbox bound; ${event.uncertain ?? 0} unresolved submissions are uncertain and sender notices were queued.`);
602
+ });
603
+ this.scheduleDeliveryOutboxPump();
604
+ }
605
+ /** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
606
+ admitDurableDelivery(input) {
607
+ const outbox = this.deliveryOutbox;
608
+ if (!outbox)
609
+ throw new Error("Durable delivery store is unavailable");
610
+ const source = this.daemons.get(input.sourceInstance);
611
+ const sourceDaemonBootId = input.sourceDaemonBootId ?? source?.bootId;
612
+ if (!source || !sourceDaemonBootId || source.bootId !== sourceDaemonBootId) {
613
+ throw new Error("Source daemon generation is no longer current; outcome is unknown");
614
+ }
615
+ const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
616
+ const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
617
+ if (admitted.inserted)
618
+ this.logger.info({
619
+ deliveryId: admitted.delivery.deliveryId,
620
+ operationId: input.operationId,
621
+ source: input.sourceInstance,
622
+ target: input.targetInstance,
623
+ kind: input.kind,
624
+ }, "Durable cross-instance delivery accepted");
625
+ this.scheduleDeliveryOutboxPump();
626
+ return {
627
+ deliveryId: admitted.delivery.deliveryId,
628
+ state: admitted.delivery.state,
629
+ duplicate: !admitted.inserted,
630
+ };
631
+ }
632
+ queryDurableDeliveryStatus(callerInstance, selector) {
633
+ this.ensureDeliveryOutbox();
634
+ return this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
635
+ }
636
+ getDaemonBootId(instanceName) {
637
+ return this.daemons.get(instanceName)?.bootId;
638
+ }
639
+ markDurableResponseDelivered(sourceInstance, operationId) {
640
+ this.deliveryOutbox?.markResponseDelivered(sourceInstance, operationId);
641
+ }
642
+ /** Called after a replacement Daemon object is installed in lifecycle.daemons. */
643
+ onDaemonReady(name, daemonBootId) {
644
+ const outbox = this.deliveryOutbox;
645
+ if (!outbox)
646
+ return;
647
+ for (const prior of outbox.getUnansweredAccepted(name, daemonBootId)) {
648
+ try {
649
+ outbox.admitPostRestartOutcomeNotice(prior, daemonBootId);
650
+ }
651
+ catch (err) {
652
+ this.logger.error({ err, source: name, operationId: prior.operationId }, "Could not persist post-restart delivery outcome notice");
653
+ }
654
+ }
655
+ this.scheduleDeliveryOutboxPump();
656
+ }
657
+ scheduleDeliveryOutboxPump(delayMs = 0) {
658
+ if (!this.deliveryOutbox || this.shuttingDown)
659
+ return;
660
+ if (this.deliveryPumpTimer) {
661
+ if (delayMs > 0)
662
+ return;
663
+ clearTimeout(this.deliveryPumpTimer);
664
+ this.deliveryPumpTimer = null;
665
+ }
666
+ if (this.deliveryPumpRunning) {
667
+ this.deliveryPumpScheduled = true;
668
+ return;
669
+ }
670
+ this.deliveryPumpTimer = setTimeout(() => {
671
+ this.deliveryPumpTimer = null;
672
+ void this.runDeliveryOutboxPump();
673
+ }, Math.max(0, delayMs));
674
+ this.deliveryPumpTimer.unref?.();
675
+ }
676
+ async runDeliveryOutboxPump() {
677
+ const outbox = this.deliveryOutbox;
678
+ if (!outbox || this.shuttingDown || this.deliveryPumpRunning)
679
+ return;
680
+ this.deliveryPumpRunning = true;
681
+ this.deliveryPumpScheduled = false;
682
+ try {
683
+ outbox.expireStale();
684
+ while (this.activeDurableTargets.size < 8) {
685
+ const claimed = outbox.claimNext(this.managerBootId, target => this.isInstanceRestarting(target) ? null : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
686
+ if (!claimed)
687
+ break;
688
+ this.activeDurableTargets.add(claimed.targetInstance);
689
+ void this.dispatchDurableDelivery(claimed).finally(() => {
690
+ this.activeDurableTargets.delete(claimed.targetInstance);
691
+ this.scheduleDeliveryOutboxPump();
692
+ });
693
+ }
694
+ }
695
+ catch (err) {
696
+ this.logger.error({ err }, "Durable delivery dispatcher failed; committed rows remain in SQLite");
697
+ this.scheduleDeliveryOutboxPump(5_000);
698
+ }
699
+ finally {
700
+ this.deliveryPumpRunning = false;
701
+ if (this.deliveryPumpScheduled)
702
+ this.scheduleDeliveryOutboxPump();
703
+ const nextRetryAt = outbox.nextRetryAt();
704
+ if (nextRetryAt && !this.deliveryPumpTimer) {
705
+ this.scheduleDeliveryOutboxPump(Math.max(1, Date.parse(nextRetryAt) - Date.now()));
706
+ }
707
+ const nextExpiryAt = outbox.nextExpiryAt();
708
+ if (nextExpiryAt && !this.deliveryPumpTimer) {
709
+ this.scheduleDeliveryOutboxPump(Math.max(1, Date.parse(nextExpiryAt) - Date.now()));
710
+ }
711
+ }
712
+ }
713
+ async dispatchDurableDelivery(claimed) {
714
+ const outbox = this.deliveryOutbox;
715
+ if (!outbox)
716
+ return;
717
+ const target = claimed.targetInstance;
718
+ const attempt = claimed.attemptNo;
719
+ const payload = { ...claimed.payload };
720
+ const laneReleased = this.waitForDurableLaneRelease(claimed);
721
+ const rawMeta = payload.meta && typeof payload.meta === "object"
722
+ ? payload.meta
723
+ : {};
724
+ // Arm the visible liveness alert as soon as this generation owns the lane,
725
+ // including time spent waiting for a handoff/idle gate to return.
726
+ try {
727
+ let sent;
728
+ if (claimed.kind === "raw_paste") {
729
+ if (payload.type !== "raw_paste" || typeof payload.content !== "string") {
730
+ throw new Error("Durable raw_paste row has an invalid payload");
731
+ }
732
+ // Preserve the schedule's exact bytes and raw route. The target daemon
733
+ // owns readiness, paste, one Enter, and the fenced outbox transition.
734
+ sent = await this.deliverToInstance(target, {
735
+ type: "raw_paste",
736
+ content: payload.content,
737
+ delivery_id: claimed.deliveryId,
738
+ delivery_attempt: String(claimed.attemptNo),
739
+ }, { waitForIdle: false });
740
+ }
741
+ else {
742
+ payload.meta = {
743
+ ...rawMeta,
744
+ delivery_id: claimed.deliveryId,
745
+ delivery_attempt: String(claimed.attemptNo),
746
+ from_instance: typeof rawMeta.from_instance === "string" && rawMeta.from_instance
747
+ ? rawMeta.from_instance
748
+ : claimed.sourceInstance,
749
+ correlation_id: claimed.correlationId ?? String(rawMeta.correlation_id ?? ""),
750
+ };
751
+ sent = await this.deliverToInstance(target, payload, {
752
+ isCrossInstance: true,
753
+ waitForIdle: claimed.kind !== "steer",
754
+ });
755
+ }
756
+ if (!sent) {
757
+ const current = outbox.get(claimed.deliveryId);
758
+ if (current?.state === "delivering") {
759
+ outbox.retryBeforeBegin(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "delivery was cancelled before target IPC handoff", Math.min(60_000, 1_000 * 2 ** Math.min(attempt - 1, 6)));
760
+ }
761
+ }
762
+ }
763
+ catch (err) {
764
+ const latest = outbox.get(claimed.deliveryId);
765
+ const reason = err instanceof Error ? err.message : String(err);
766
+ if (latest?.state === "submission_started") {
767
+ outbox.complete(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "uncertain", reason);
768
+ }
769
+ else {
770
+ outbox.retryBeforeBegin(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, reason, Math.min(60_000, 1_000 * 2 ** Math.min(attempt - 1, 6)));
771
+ }
772
+ this.logger.warn({ deliveryId: claimed.deliveryId, target, attempt, err: reason }, "Durable delivery dispatch deferred");
773
+ }
774
+ await laneReleased;
775
+ }
776
+ async waitForDurableLaneRelease(claimed) {
777
+ const outbox = this.deliveryOutbox;
778
+ if (!outbox)
779
+ return;
780
+ const deliveryId = claimed.deliveryId;
781
+ const released = (row) => !row
782
+ || row.state === "queued" || row.state === "retry_wait"
783
+ || row.state === "delivered" || row.state === "failed"
784
+ || row.state === "uncertain" || row.state === "cancelled";
785
+ if (released(outbox.get(deliveryId)))
786
+ return;
787
+ await new Promise(resolve => {
788
+ const finish = () => {
789
+ clearTimeout(alertTimer);
790
+ outbox.off("state", check);
791
+ outbox.off("generation_recovered", check);
792
+ resolve();
793
+ };
794
+ const check = () => {
795
+ if (released(outbox.get(deliveryId)))
796
+ finish();
797
+ };
798
+ const alertTimer = setTimeout(() => {
799
+ const current = outbox.get(deliveryId);
800
+ if ((current?.state === "delivering" || current?.state === "submission_started")
801
+ && current.targetDaemonBootId === claimed.targetDaemonBootId
802
+ && current.managerBootId === claimed.managerBootId) {
803
+ this.notifyFleetError(`Durable delivery to ${claimed.targetInstance} is still waiting after ${Math.round(DURABLE_DELIVERY_LANE_ALERT_MS / 60_000)} minutes; the same daemon generation retains delivery ${deliveryId}'s lane until it reports or is replaced.`);
804
+ }
805
+ }, DURABLE_DELIVERY_LANE_ALERT_MS);
806
+ alertTimer.unref?.();
807
+ outbox.on("state", check);
808
+ outbox.on("generation_recovered", check);
809
+ check();
810
+ });
811
+ }
561
812
  spawnConcurrency() {
562
813
  const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
563
814
  if (explicit != null)
@@ -1509,16 +1760,16 @@ export class FleetManager {
1509
1760
  }
1510
1761
  async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1511
1762
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1512
- return;
1763
+ return false;
1513
1764
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
1514
1765
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1515
- return;
1766
+ return false;
1516
1767
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
1517
1768
  if (this.lifecycle.isPaused(instanceName)) {
1518
1769
  const wakeStartedAt = Date.now();
1519
1770
  await this.lifecycle.wake(instanceName, 30_000);
1520
1771
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1521
- return;
1772
+ return false;
1522
1773
  // Waking added one to the warm count — make room by evicting a different
1523
1774
  // LRU idle instance (never this one; it's about to work).
1524
1775
  this.enforceWarmCap(instanceName);
@@ -1528,20 +1779,23 @@ export class FleetManager {
1528
1779
  const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
1529
1780
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1530
1781
  this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
1531
- return;
1782
+ return false;
1532
1783
  }
1533
1784
  // A server crash can land while waitForInstanceIdle is pending. Re-check
1534
1785
  // immediately before the old timeout path would force text into a boot UI.
1535
1786
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
1536
1787
  if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1537
- return;
1788
+ return false;
1538
1789
  if (!idle) {
1539
1790
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
1540
1791
  }
1541
- await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1542
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1543
- return;
1544
- this.lastDeliveryAt.set(instanceName, Date.now());
1792
+ const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1793
+ if (!sent)
1794
+ return false;
1795
+ if (this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1796
+ this.lastDeliveryAt.set(instanceName, Date.now());
1797
+ }
1798
+ return true;
1545
1799
  }
1546
1800
  async holdDeliveryForStorm(instanceName, deliveryEpoch) {
1547
1801
  if (!this.stormWindow.isDeliveryHeld(instanceName))
@@ -1567,26 +1821,26 @@ export class FleetManager {
1567
1821
  */
1568
1822
  async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1569
1823
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1570
- return;
1824
+ return false;
1571
1825
  const queued = this.ipcWaitTails.get(instanceName);
1572
1826
  if (!queued) {
1573
1827
  const ipc = this.instanceIpcClients.get(instanceName);
1574
1828
  if (ipc?.connected && ipc.send(payload))
1575
- return;
1829
+ return true;
1576
1830
  }
1577
1831
  const attempt = (queued ?? Promise.resolve())
1578
1832
  .catch(() => { })
1579
1833
  .then(() => {
1580
1834
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1581
- return;
1835
+ return false;
1582
1836
  return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
1583
1837
  });
1584
1838
  // The chain stores a settled-either-way promise so one failed delivery cannot
1585
1839
  // wedge every later one, and so `queued` above is safe to await unguarded.
1586
- const tail = attempt.catch(() => { });
1840
+ const tail = attempt.then(() => { }, () => { });
1587
1841
  this.ipcWaitTails.set(instanceName, tail);
1588
1842
  try {
1589
- await attempt;
1843
+ return await attempt;
1590
1844
  }
1591
1845
  finally {
1592
1846
  // Only the last waiter clears the chain; while a queue is still draining the
@@ -1602,12 +1856,12 @@ export class FleetManager {
1602
1856
  let warned = false;
1603
1857
  for (;;) {
1604
1858
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1605
- return;
1859
+ return false;
1606
1860
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
1607
1861
  // so a cached reference would stay dead forever.
1608
1862
  const ipc = this.instanceIpcClients.get(instanceName);
1609
1863
  if (ipc?.connected && ipc.send(payload))
1610
- return;
1864
+ return true;
1611
1865
  if (Date.now() >= deadline) {
1612
1866
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
1613
1867
  }
@@ -1638,22 +1892,24 @@ export class FleetManager {
1638
1892
  if (this.lifecycle.isPaused(instanceName)) {
1639
1893
  await this.lifecycle.wake(instanceName, 30_000);
1640
1894
  if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1641
- return;
1895
+ return false;
1642
1896
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
1643
1897
  }
1644
- await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1645
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1646
- return;
1898
+ const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1899
+ if (!sent)
1900
+ return false;
1647
1901
  // A cross-instance item arriving before the daemon observes this turn as
1648
1902
  // working must not trust the stale idle snapshot from before the send.
1649
- this.lastDeliveryAt.set(instanceName, Date.now());
1650
- return;
1903
+ if (this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1904
+ this.lastDeliveryAt.set(instanceName, Date.now());
1905
+ }
1906
+ return true;
1651
1907
  }
1652
1908
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1653
1909
  const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
1654
1910
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1655
1911
  try {
1656
- await delivery;
1912
+ return await delivery;
1657
1913
  }
1658
1914
  finally {
1659
1915
  if (this.idleGatedDeliveryTails.get(instanceName) === delivery) {
@@ -2279,10 +2535,16 @@ export class FleetManager {
2279
2535
  workingDirectory,
2280
2536
  reason: "restart",
2281
2537
  }, () => this.doRestartSingleInstance(name, opts))
2282
- .finally(() => this.restartsInFlight.delete(name));
2538
+ .finally(() => {
2539
+ this.restartsInFlight.delete(name);
2540
+ this.scheduleDeliveryOutboxPump();
2541
+ });
2283
2542
  this.restartsInFlight.set(name, run);
2284
2543
  return run;
2285
2544
  }
2545
+ isInstanceRestarting(name) {
2546
+ return this.restartsInFlight.has(name);
2547
+ }
2286
2548
  async doRestartSingleInstance(name, opts) {
2287
2549
  if (this.configPath) {
2288
2550
  this.loadConfig(this.configPath);
@@ -2575,6 +2837,7 @@ export class FleetManager {
2575
2837
  this.shuttingDown = false;
2576
2838
  this.configPath = configPath;
2577
2839
  this.loadEnvFile();
2840
+ this.ensureDeliveryOutbox();
2578
2841
  // Rotate fleet.log if oversized (before any logging)
2579
2842
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
2580
2843
  const fleet = this.loadConfig(configPath);
@@ -2797,7 +3060,7 @@ export class FleetManager {
2797
3060
  ...DEFAULT_SCHEDULER_CONFIG,
2798
3061
  ...this.fleetConfig?.defaults.scheduler,
2799
3062
  };
2800
- this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
3063
+ this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId) => this.handleScheduleTrigger(schedule, runId), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
2801
3064
  this.scheduler.init();
2802
3065
  this.logger.info("Scheduler initialized");
2803
3066
  // Tips share the daily-report clock but remain an independent internal
@@ -5197,6 +5460,8 @@ export class FleetManager {
5197
5460
  const requestId = msg.requestId;
5198
5461
  const fleetRequestId = msg.fleetRequestId;
5199
5462
  const senderSessionName = msg.senderSessionName;
5463
+ const operationId = typeof msg.operationId === "string" ? msg.operationId : undefined;
5464
+ const sourceDaemonBootId = typeof msg.sourceDaemonBootId === "string" ? msg.sourceDaemonBootId : undefined;
5200
5465
  const respond = (result, error) => {
5201
5466
  const ipc = this.instanceIpcClients.get(instanceName);
5202
5467
  let sent = false;
@@ -5314,7 +5579,9 @@ export class FleetManager {
5314
5579
  // Dispatch fleet-specific tools via handler map
5315
5580
  const handler = outboundHandlers.get(tool);
5316
5581
  if (handler) {
5317
- await handler(this, args, respond, { instanceName, requestId, fleetRequestId, senderSessionName });
5582
+ await handler(this, args, respond, {
5583
+ instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
5584
+ });
5318
5585
  }
5319
5586
  else {
5320
5587
  respond(null, `Unknown tool: ${tool}`);
@@ -5350,7 +5617,7 @@ export class FleetManager {
5350
5617
  }
5351
5618
  }
5352
5619
  // ===================== Scheduler =====================
5353
- async handleScheduleTrigger(schedule) {
5620
+ async handleScheduleTrigger(schedule, stableRunId = randomUUID()) {
5354
5621
  const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
5355
5622
  const RATE_LIMIT_DEFER_THRESHOLD = 85;
5356
5623
  const rl = this.statuslineWatcher.getRateLimits(target);
@@ -5368,20 +5635,32 @@ export class FleetManager {
5368
5635
  }
5369
5636
  // Silent mode: paste directly to tmux pane — no channel message.
5370
5637
  if (silent) {
5371
- const ipc = this.instanceIpcClients.get(target);
5372
- if (ipc) {
5373
- ipc.send({
5638
+ // The outbox commit is the schedule's durable acceptance point. The run
5639
+ // key is the scheduled fire instant (or an invocation UUID for a manual
5640
+ // trigger), so restart/catch-up reuses the same raw paste row.
5641
+ this.ensureDeliveryOutbox();
5642
+ const runKey = encodeURIComponent(stableRunId);
5643
+ const sourceKey = `schedule:${encodeURIComponent(id)}:${runKey}:${encodeURIComponent(target)}:raw_paste`;
5644
+ const operationId = `schedule:${id}:${stableRunId}`;
5645
+ const sourceBootId = this.daemons.get(source)?.bootId ?? this.managerBootId;
5646
+ const admitted = this.deliveryOutbox.admit({
5647
+ operationId,
5648
+ sourceKey,
5649
+ sourceInstance: source,
5650
+ sourceDaemonBootId: sourceBootId,
5651
+ targetInstance: target,
5652
+ kind: "raw_paste",
5653
+ correlationId: operationId,
5654
+ payload: {
5374
5655
  type: "raw_paste",
5375
5656
  content: message,
5376
- delivery_epoch: this.getDeliveryEpoch(target),
5377
- });
5378
- this.scheduler.recordRun(id, "delivered");
5379
- this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
5380
- }
5381
- else {
5382
- this.scheduler.recordRun(id, "instance_offline", "IPC not connected");
5383
- this.logger.warn({ target, scheduleId: id }, "Silent schedule: IPC not connected, skipping");
5384
- }
5657
+ schedule_id: id,
5658
+ schedule_run_id: stableRunId,
5659
+ },
5660
+ });
5661
+ this.scheduler.recordRun(id, "queued", `durable raw_paste delivery_id=${admitted.delivery.deliveryId}`);
5662
+ this.logger.info({ target, scheduleId: id, runId: stableRunId, deliveryId: admitted.delivery.deliveryId, duplicate: !admitted.inserted }, "Silent schedule durably admitted as raw_paste");
5663
+ this.scheduleDeliveryOutboxPump();
5385
5664
  return;
5386
5665
  }
5387
5666
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
@@ -6528,6 +6807,8 @@ export class FleetManager {
6528
6807
  * the chain drains, so idle messages hold no memory.
6529
6808
  */
6530
6809
  deliveryStatusChains = new Map();
6810
+ /** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
6811
+ pendingCliEnvProbes = new Map();
6531
6812
  reactMessageStatus(instanceName, chatId, messageId, emoji, threadId) {
6532
6813
  // React via the adapter BOUND to this instance — NOT the first discord world.
6533
6814
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -6584,9 +6865,10 @@ export class FleetManager {
6584
6865
  return;
6585
6866
  // Thread-aware adapters (Discord) react where the thread is; Telegram
6586
6867
  // addresses the supergroup chat and ignores the thread part.
6587
- // Discord needs the old reaction removed before the new one is added.
6588
- // Telegram's setMessageReaction replaces the bot's one current reaction.
6589
- if (prev && prev !== emoji && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
6868
+ // #972: add-only by default — only remove ❌ when leaving the failed
6869
+ // state. 👀/⏳/✅ stacking together is harmless; leaving ❌ visible
6870
+ // after a recovery would be misleading.
6871
+ if (prev === "❌" && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
6590
6872
  // Best effort: a failed removal must not block the new status.
6591
6873
  await adapter.unreact(target, messageId, prev, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
6592
6874
  }
@@ -9901,6 +10183,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9901
10183
  * previous list, never stall the command that asked for it.
9902
10184
  */
9903
10185
  async probeBackendBounded(backend, opts = {}) {
10186
+ // #725: single-flight per backend — if a probe for this backend is already
10187
+ // in flight, return the same promise instead of starting a duplicate.
10188
+ // Refresh probes (refreshVendorCatalog=true) are always started fresh since
10189
+ // the caller explicitly asked for a new vendor fetch.
10190
+ if (!opts.refreshVendorCatalog) {
10191
+ const existing = this.pendingCliEnvProbes.get(backend);
10192
+ if (existing)
10193
+ return existing;
10194
+ }
9904
10195
  const work = this.probeBackend(backend, opts);
9905
10196
  work.catch(() => { });
9906
10197
  let timer;
@@ -9910,12 +10201,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9910
10201
  resolve(null);
9911
10202
  }, CLI_ENV_PROBE_DEADLINE_MS);
9912
10203
  });
9913
- try {
9914
- return await Promise.race([work, deadline]);
9915
- }
9916
- finally {
10204
+ const bounded = Promise.race([work, deadline]).finally(() => {
9917
10205
  clearTimeout(timer);
9918
- }
10206
+ if (this.pendingCliEnvProbes.get(backend) === bounded)
10207
+ this.pendingCliEnvProbes.delete(backend);
10208
+ });
10209
+ if (!opts.refreshVendorCatalog)
10210
+ this.pendingCliEnvProbes.set(backend, bounded);
10211
+ return bounded;
9919
10212
  }
9920
10213
  /**
9921
10214
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
@@ -10025,8 +10318,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10025
10318
  backends.add(ch.backend);
10026
10319
  if (backends.size === 0)
10027
10320
  backends.add("claude-code");
10321
+ // #724: use the bounded variant so background startup probes cannot hang
10322
+ // indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
10028
10323
  for (const b of backends)
10029
- void this.probeBackend(b);
10324
+ void this.probeBackendBounded(b);
10030
10325
  }
10031
10326
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
10032
10327
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
@@ -10448,7 +10743,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10448
10743
  const cached = this.readCliEnv("claude-code");
10449
10744
  if (cached?.apiModels?.length)
10450
10745
  return cached.apiModels;
10451
- const env = await this.probeBackend("claude-code");
10746
+ const env = await this.probeBackendBounded("claude-code");
10452
10747
  return env?.apiModels ?? [];
10453
10748
  }
10454
10749
  /** Replace a consumed "/model" menu with the full account catalog tier. */