@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.
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/dist/agent-cli-instructions.md +5 -0
- package/dist/agent-cli.js +13 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +48 -5
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/codex-session-lookup.d.ts +59 -0
- package/dist/backend/codex-session-lookup.js +139 -0
- package/dist/backend/codex-session-lookup.js.map +1 -0
- package/dist/backend/codex.d.ts +29 -0
- package/dist/backend/codex.js +183 -10
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +21 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/mcp-operation-response.d.ts +3 -0
- package/dist/channel/mcp-operation-response.js +21 -0
- package/dist/channel/mcp-operation-response.js.map +1 -0
- package/dist/channel/mcp-operation-response.test.d.ts +1 -0
- package/dist/channel/mcp-operation-response.test.js +23 -0
- package/dist/channel/mcp-operation-response.test.js.map +1 -0
- package/dist/channel/mcp-server.js +20 -5
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +68 -2
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +34 -0
- package/dist/daemon.js +415 -41
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +174 -0
- package/dist/delivery-outbox.js +824 -0
- package/dist/delivery-outbox.js.map +1 -0
- package/dist/delivery-outbox.test.d.ts +1 -0
- package/dist/delivery-outbox.test.js +597 -0
- package/dist/delivery-outbox.test.js.map +1 -0
- package/dist/delivery-queue-evidence.d.ts +15 -0
- package/dist/delivery-queue-evidence.js +35 -0
- package/dist/delivery-queue-evidence.js.map +1 -0
- package/dist/delivery-queue-evidence.test.d.ts +1 -0
- package/dist/delivery-queue-evidence.test.js +23 -0
- package/dist/delivery-queue-evidence.test.js.map +1 -0
- package/dist/delivery-reconciliation.d.ts +49 -0
- package/dist/delivery-reconciliation.js +296 -0
- package/dist/delivery-reconciliation.js.map +1 -0
- package/dist/delivery-reconciliation.test.d.ts +1 -0
- package/dist/delivery-reconciliation.test.js +411 -0
- package/dist/delivery-reconciliation.test.js.map +1 -0
- package/dist/fleet-manager.d.ts +39 -1
- package/dist/fleet-manager.js +345 -50
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +18 -0
- package/dist/instance-lifecycle.js +116 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +3 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +25 -1
- package/dist/outbound-handlers.js +119 -8
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -0
- package/dist/outbound-schemas.js +14 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-write-lock.d.ts +2 -0
- package/dist/pane-write-lock.js +19 -0
- package/dist/pane-write-lock.js.map +1 -1
- package/dist/remote-credential-check.d.ts +39 -0
- package/dist/remote-credential-check.js +81 -0
- package/dist/remote-credential-check.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +5 -1
- package/dist/scheduler/scheduler.js +60 -20
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/scheduler.test.js +41 -2
- package/dist/scheduler/scheduler.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +1 -1
- package/dist/tmux-manager.d.ts +3 -3
- package/dist/tmux-manager.js +6 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.js +6 -6
- package/dist/tool-permissions.js.map +1 -1
- package/dist/transcript-monitor.d.ts +3 -1
- package/dist/transcript-monitor.js +15 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +9 -0
- package/dist/transcript-sources.js +14 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/usage/providers.d.ts +11 -6
- package/dist/usage/providers.js +17 -10
- package/dist/usage/providers.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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 (!
|
|
1543
|
-
return;
|
|
1544
|
-
this.
|
|
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.
|
|
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 (!
|
|
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.
|
|
1650
|
-
|
|
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(() =>
|
|
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, {
|
|
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
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
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
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
}
|
|
5381
|
-
|
|
5382
|
-
|
|
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
|
-
//
|
|
6588
|
-
//
|
|
6589
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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. */
|