@songsid/agend 2.1.8 → 2.1.9
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/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +7 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +89 -3
- package/dist/daemon.js +301 -39
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +99 -5
- package/dist/fleet-manager.js +405 -65
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +14 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +2 -0
- package/dist/settings-api.js +3 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +9 -1
- package/dist/status-emojis.js +11 -1
- package/dist/status-emojis.js.map +1 -1
- package/dist/target-queue-worker.d.ts +67 -0
- package/dist/target-queue-worker.js +76 -0
- package/dist/target-queue-worker.js.map +1 -0
- package/dist/tool-permissions.js +7 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +23 -19
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +30 -0
- package/dist/transcript-sources.js +78 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +2 -2
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
package/dist/fleet-manager.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
2
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";
|
|
6
6
|
import { Worker } from "node:worker_threads";
|
|
7
|
-
import { join, dirname, basename } from "node:path";
|
|
7
|
+
import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
10
10
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
@@ -41,7 +41,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
|
|
|
41
41
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
42
42
|
import { safeHandler } from "./safe-async.js";
|
|
43
43
|
import { RoutingEngine } from "./routing-engine.js";
|
|
44
|
-
import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
44
|
+
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
45
45
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
46
46
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
47
47
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
@@ -66,7 +66,10 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
|
|
|
66
66
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
67
67
|
import { validateFleetConfig } from "./config-validator.js";
|
|
68
68
|
import { readLastInboundAt } from "./daemon.js";
|
|
69
|
-
import { clearPausedMarker } from "./pause-marker.js";
|
|
69
|
+
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
70
|
+
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
71
|
+
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
72
|
+
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
70
73
|
import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
|
|
71
74
|
import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
72
75
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
@@ -286,6 +289,14 @@ export class FleetManager {
|
|
|
286
289
|
managerBootId = randomUUID();
|
|
287
290
|
deliveryOutbox = null;
|
|
288
291
|
deliveryOutboxRecovered = false;
|
|
292
|
+
/** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
|
|
293
|
+
wakeCoordinator = null;
|
|
294
|
+
/**
|
|
295
|
+
* Phase 2c: targets whose durable lane a TargetQueueWorker owns
|
|
296
|
+
* (delivery_worker: on). Granted and released only in the pump's
|
|
297
|
+
* synchronous section and only while the lane is empty; the pump skips them.
|
|
298
|
+
*/
|
|
299
|
+
queueWorkers = new Map();
|
|
289
300
|
deliveryPumpScheduled = false;
|
|
290
301
|
deliveryPumpRunning = false;
|
|
291
302
|
deliveryPumpTimer = null;
|
|
@@ -570,9 +581,15 @@ export class FleetManager {
|
|
|
570
581
|
this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
|
|
571
582
|
this.deliveryOutboxRecovered = true;
|
|
572
583
|
}
|
|
573
|
-
outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
|
|
584
|
+
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
574
585
|
outbox.on("state", (event) => {
|
|
575
586
|
this.scheduleDeliveryOutboxPump();
|
|
587
|
+
this.wakeCoordinator?.kick();
|
|
588
|
+
if (event.deliveryId && event.state === "delivered") {
|
|
589
|
+
const target = outbox.get(event.deliveryId)?.targetInstance;
|
|
590
|
+
if (target)
|
|
591
|
+
this.wakeCoordinator?.noteDelivered(target);
|
|
592
|
+
}
|
|
576
593
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
577
594
|
const row = outbox.get(event.deliveryId);
|
|
578
595
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
@@ -591,8 +608,11 @@ export class FleetManager {
|
|
|
591
608
|
outbox.on("content_digest_mismatch", (event) => {
|
|
592
609
|
this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
|
|
593
610
|
});
|
|
611
|
+
this.wakeCoordinator = this.createWakeCoordinator();
|
|
612
|
+
this.wakeCoordinator.start();
|
|
594
613
|
outbox.on("expired", (event) => {
|
|
595
614
|
this.scheduleDeliveryOutboxPump();
|
|
615
|
+
this.wakeCoordinator?.kick();
|
|
596
616
|
if (event.count)
|
|
597
617
|
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.`);
|
|
598
618
|
});
|
|
@@ -619,6 +639,9 @@ export class FleetManager {
|
|
|
619
639
|
kind: input.kind,
|
|
620
640
|
}, "Durable cross-instance delivery accepted");
|
|
621
641
|
this.scheduleDeliveryOutboxPump();
|
|
642
|
+
// Phase 2b: a paused wake_only target is woken by the coordinator, never by
|
|
643
|
+
// the pump; admission must tell it, or the work waits for the watchdog.
|
|
644
|
+
this.wakeCoordinator?.kick();
|
|
622
645
|
return {
|
|
623
646
|
deliveryId: admitted.delivery.deliveryId,
|
|
624
647
|
state: admitted.delivery.state,
|
|
@@ -681,7 +704,7 @@ export class FleetManager {
|
|
|
681
704
|
}
|
|
682
705
|
}
|
|
683
706
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
684
|
-
if (!this.deliveryOutbox || this.shuttingDown)
|
|
707
|
+
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
685
708
|
return;
|
|
686
709
|
if (this.deliveryPumpTimer) {
|
|
687
710
|
if (delayMs > 0)
|
|
@@ -701,14 +724,27 @@ export class FleetManager {
|
|
|
701
724
|
}
|
|
702
725
|
async runDeliveryOutboxPump() {
|
|
703
726
|
const outbox = this.deliveryOutbox;
|
|
704
|
-
|
|
727
|
+
// A closed database is never queried: this runs from timers, where a throw
|
|
728
|
+
// is an unhandled rejection. Checking here covers the finally block too:
|
|
729
|
+
// the body below never awaits, so the database cannot close in between.
|
|
730
|
+
if (!outbox || this.shuttingDown || this.deliveryPumpRunning || !outbox.isOpen)
|
|
705
731
|
return;
|
|
706
732
|
this.deliveryPumpRunning = true;
|
|
707
733
|
this.deliveryPumpScheduled = false;
|
|
708
734
|
try {
|
|
709
|
-
outbox.expireStale(
|
|
735
|
+
outbox.expireStale(undefined, undefined, target => {
|
|
736
|
+
const failure = this.wakeCoordinator?.wakeFailure(target);
|
|
737
|
+
return failure ? `target could not be woken (${failure})` : undefined;
|
|
738
|
+
});
|
|
739
|
+
this.reconcileQueueWorkers(outbox);
|
|
710
740
|
while (this.activeDurableTargets.size < 8) {
|
|
711
|
-
const claimed = outbox.claimNext(this.managerBootId,
|
|
741
|
+
const claimed = outbox.claimNext(this.managerBootId,
|
|
742
|
+
// Phase 2b: a wake_only target is claimed only once it is awake; the
|
|
743
|
+
// wake coordinator, not this pump, wakes it (no claim, no attempt).
|
|
744
|
+
// Phase 2c: a lane a queue worker owns is never claimed here.
|
|
745
|
+
target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
|
|
746
|
+
? null
|
|
747
|
+
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
|
|
712
748
|
if (!claimed)
|
|
713
749
|
break;
|
|
714
750
|
this.activeDurableTargets.add(claimed.targetInstance);
|
|
@@ -736,7 +772,67 @@ export class FleetManager {
|
|
|
736
772
|
}
|
|
737
773
|
}
|
|
738
774
|
}
|
|
739
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Phase 2c ownership (design §1.5-A), called synchronously from the pump
|
|
777
|
+
* before its claim loop, so a grant and the pump's own claims cannot
|
|
778
|
+
* interleave. A worker is granted a target's lane only when the target is
|
|
779
|
+
* `on` and its lane is empty: no row in flight in the outbox and no pump
|
|
780
|
+
* dispatch holding it. It gives the lane back only when it is idle and the
|
|
781
|
+
* target is no longer `on` (or has nothing pending). A flag change never
|
|
782
|
+
* takes a lane mid-drain.
|
|
783
|
+
*/
|
|
784
|
+
reconcileQueueWorkers(outbox) {
|
|
785
|
+
const pendingTargets = new Set(outbox.listPending().map(row => row.targetInstance));
|
|
786
|
+
for (const [target, worker] of this.queueWorkers) {
|
|
787
|
+
const keep = this.deliveryWorkerMode(target) === "on" && pendingTargets.has(target);
|
|
788
|
+
if (!keep && !worker.busy && !worker.inFlight)
|
|
789
|
+
this.queueWorkers.delete(target);
|
|
790
|
+
}
|
|
791
|
+
for (const target of pendingTargets) {
|
|
792
|
+
if (this.queueWorkers.has(target) || this.deliveryWorkerMode(target) !== "on")
|
|
793
|
+
continue;
|
|
794
|
+
const laneEmpty = !this.activeDurableTargets.has(target)
|
|
795
|
+
&& outbox.countForTarget(target, ["delivering", "submission_started"]) === 0;
|
|
796
|
+
if (laneEmpty)
|
|
797
|
+
this.queueWorkers.set(target, this.createQueueWorker(target));
|
|
798
|
+
}
|
|
799
|
+
for (const worker of this.queueWorkers.values())
|
|
800
|
+
void worker.drain();
|
|
801
|
+
}
|
|
802
|
+
createQueueWorker(target) {
|
|
803
|
+
const worker = new TargetQueueWorker(target, {
|
|
804
|
+
owns: () => this.queueWorkers.get(target) === worker,
|
|
805
|
+
wanted: () => this.deliveryWorkerMode(target) === "on",
|
|
806
|
+
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
807
|
+
blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
|
|
808
|
+
daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
|
|
809
|
+
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
|
|
810
|
+
tryAcquireBudget: () => {
|
|
811
|
+
if (this.activeDurableTargets.size >= 8)
|
|
812
|
+
return false;
|
|
813
|
+
this.activeDurableTargets.add(target);
|
|
814
|
+
return true;
|
|
815
|
+
},
|
|
816
|
+
releaseBudget: () => { this.activeDurableTargets.delete(target); },
|
|
817
|
+
dispatch: async (claimed) => {
|
|
818
|
+
try {
|
|
819
|
+
await this.dispatchDurableDelivery(claimed, { holdLaneOnTransportError: true });
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
// The row's state event can fire before the hand-off settles; the
|
|
823
|
+
// pump pass it starts then still sees this worker busy and keeps it
|
|
824
|
+
// as owner. One kick after the dispatch has really settled lets the
|
|
825
|
+
// pump release an owner that is no longer wanted (or claim the next
|
|
826
|
+
// row). The no-claim path never kicks, so no retry hot loop.
|
|
827
|
+
this.scheduleDeliveryOutboxPump();
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
kickCoordinator: () => this.wakeCoordinator?.kick(),
|
|
831
|
+
logger: this.logger,
|
|
832
|
+
});
|
|
833
|
+
return worker;
|
|
834
|
+
}
|
|
835
|
+
async dispatchDurableDelivery(claimed, opts = {}) {
|
|
740
836
|
const outbox = this.deliveryOutbox;
|
|
741
837
|
if (!outbox)
|
|
742
838
|
return;
|
|
@@ -762,7 +858,7 @@ export class FleetManager {
|
|
|
762
858
|
content: payload.content,
|
|
763
859
|
delivery_id: claimed.deliveryId,
|
|
764
860
|
delivery_attempt: String(claimed.attemptNo),
|
|
765
|
-
}, { waitForIdle: false });
|
|
861
|
+
}, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
|
|
766
862
|
}
|
|
767
863
|
else {
|
|
768
864
|
payload.meta = {
|
|
@@ -777,6 +873,7 @@ export class FleetManager {
|
|
|
777
873
|
sent = await this.deliverToInstance(target, payload, {
|
|
778
874
|
isCrossInstance: true,
|
|
779
875
|
waitForIdle: claimed.kind !== "steer",
|
|
876
|
+
noInlineWake: this.deliveryWorkerMode(target) !== "off",
|
|
780
877
|
});
|
|
781
878
|
}
|
|
782
879
|
if (!sent) {
|
|
@@ -789,7 +886,10 @@ export class FleetManager {
|
|
|
789
886
|
catch (err) {
|
|
790
887
|
const latest = outbox.get(claimed.deliveryId);
|
|
791
888
|
const reason = err instanceof Error ? err.message : String(err);
|
|
792
|
-
if (latest?.state === "submission_started") {
|
|
889
|
+
if (opts.holdLaneOnTransportError && (latest?.state === "submission_started" || latest?.state === "reconciliation_pending")) {
|
|
890
|
+
this.logger.warn({ deliveryId: claimed.deliveryId, target, attempt, err: reason }, "Transport failed after the submission began; holding the lane for the daemon's verdict or its generation's reconciliation");
|
|
891
|
+
}
|
|
892
|
+
else if (latest?.state === "submission_started") {
|
|
793
893
|
outbox.complete(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "uncertain", reason);
|
|
794
894
|
}
|
|
795
895
|
else {
|
|
@@ -1688,13 +1788,15 @@ export class FleetManager {
|
|
|
1688
1788
|
exclude,
|
|
1689
1789
|
isEvicting: name => this.warmCapEvicting.has(name),
|
|
1690
1790
|
isGeneral: name => isGeneralInstance(this.fleetConfig, name),
|
|
1691
|
-
|
|
1791
|
+
// A work lease (Phase 2a) keeps a just-woken or mid-submission instance
|
|
1792
|
+
// out of the eviction candidates; it is not idle in the sense that matters.
|
|
1793
|
+
isIdle: name => this.getInstanceExecutionState(name) === "idle" && !this.lifecycle.hasWorkLease(name),
|
|
1692
1794
|
lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
|
|
1693
1795
|
});
|
|
1694
1796
|
for (const victim of victims) {
|
|
1695
1797
|
this.warmCapEvicting.add(victim);
|
|
1696
1798
|
this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
|
|
1697
|
-
this.lifecycle.pause(victim)
|
|
1799
|
+
this.lifecycle.pause(victim, "warm_cap")
|
|
1698
1800
|
.catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
|
|
1699
1801
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
1700
1802
|
}
|
|
@@ -1787,16 +1889,22 @@ export class FleetManager {
|
|
|
1787
1889
|
finish(false);
|
|
1788
1890
|
});
|
|
1789
1891
|
}
|
|
1790
|
-
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1892
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
|
|
1791
1893
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1792
1894
|
return false;
|
|
1793
1895
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
1794
1896
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1795
1897
|
return false;
|
|
1796
1898
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
1899
|
+
if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
|
|
1900
|
+
// Paused after its row was claimed (an operator or auth pause): hand the
|
|
1901
|
+
// row back; the coordinator decides whether and when to wake it.
|
|
1902
|
+
this.wakeCoordinator?.kick();
|
|
1903
|
+
return false;
|
|
1904
|
+
}
|
|
1797
1905
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1798
1906
|
const wakeStartedAt = Date.now();
|
|
1799
|
-
await this.
|
|
1907
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1800
1908
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1801
1909
|
return false;
|
|
1802
1910
|
// Waking added one to the warm count — make room by evicting a different
|
|
@@ -1919,7 +2027,11 @@ export class FleetManager {
|
|
|
1919
2027
|
?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
|
|
1920
2028
|
if (!waitForIdle) {
|
|
1921
2029
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1922
|
-
|
|
2030
|
+
if (options.noInlineWake) {
|
|
2031
|
+
this.wakeCoordinator?.kick();
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1923
2035
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1924
2036
|
return false;
|
|
1925
2037
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
@@ -1935,7 +2047,7 @@ export class FleetManager {
|
|
|
1935
2047
|
return true;
|
|
1936
2048
|
}
|
|
1937
2049
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
1938
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
2050
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
|
|
1939
2051
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
1940
2052
|
try {
|
|
1941
2053
|
return await delivery;
|
|
@@ -1951,9 +2063,117 @@ export class FleetManager {
|
|
|
1951
2063
|
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
1952
2064
|
return allowed.some(entry => String(entry) === String(userId));
|
|
1953
2065
|
}
|
|
2066
|
+
/** Phase 2: delivery_worker for a target (instance override → fleet default → off). */
|
|
2067
|
+
deliveryWorkerMode(target) {
|
|
2068
|
+
return resolveDeliveryWorkerMode(this.fleetConfig, target);
|
|
2069
|
+
}
|
|
2070
|
+
/** Active instances mid-restart: they keep their warm slot through the stop → replacement gap. */
|
|
2071
|
+
restartsHoldingSlot = new Set();
|
|
2072
|
+
/**
|
|
2073
|
+
* Instances counted against the wake coordinator's hard cap: running
|
|
2074
|
+
* daemons, plus active instances whose restart is between stopping the old
|
|
2075
|
+
* daemon and publishing the replacement.
|
|
2076
|
+
*/
|
|
2077
|
+
warmInstanceNames() {
|
|
2078
|
+
const names = new Set([...this.daemons.keys()].filter(name => this.getInstanceStatus(name) === "running"));
|
|
2079
|
+
for (const name of this.restartsHoldingSlot)
|
|
2080
|
+
names.add(name);
|
|
2081
|
+
return [...names];
|
|
2082
|
+
}
|
|
2083
|
+
createWakeCoordinator() {
|
|
2084
|
+
return new WakeCoordinator({
|
|
2085
|
+
mode: target => this.deliveryWorkerMode(target),
|
|
2086
|
+
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
2087
|
+
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
2088
|
+
isPaused: target => this.lifecycle.isPaused(target),
|
|
2089
|
+
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
2090
|
+
isRestarting: target => this.isInstanceRestarting(target),
|
|
2091
|
+
wake: async (target) => {
|
|
2092
|
+
await this.lifecycle.wake(target, 30_000, undefined, { source: "coordinator" });
|
|
2093
|
+
// The soft cap still applies: evict a different, unleased idle instance.
|
|
2094
|
+
this.enforceWarmCap(target);
|
|
2095
|
+
},
|
|
2096
|
+
residentNames: () => this.warmInstanceNames(),
|
|
2097
|
+
warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
|
|
2098
|
+
warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
|
|
2099
|
+
wokeAt: target => this.lifecycle.wokeAtFor(target),
|
|
2100
|
+
notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
|
|
2101
|
+
notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
|
|
2102
|
+
notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
|
|
2103
|
+
kickPump: () => this.scheduleDeliveryOutboxPump(),
|
|
2104
|
+
now: () => Date.now(),
|
|
2105
|
+
logger: this.logger,
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/** LifecycleContext: an operator or user wake clears the coordinator's park/backoff for it. */
|
|
2109
|
+
onExternalWake(name) {
|
|
2110
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Phase 2b shared warm-slot admission (design §1.3, §1.6) for every wake
|
|
2114
|
+
* that is not the coordinator's own: an operator's wake, a user's message,
|
|
2115
|
+
* an explicit start or a restart of a paused instance. With delivery_worker
|
|
2116
|
+
* off, or for a target that is not paused (an active restart is already
|
|
2117
|
+
* counted), nothing is reserved. A target already being woken is joined —
|
|
2118
|
+
* its flight holds the slot — rather than reserved twice. Otherwise a slot
|
|
2119
|
+
* is taken under the hard cap, an unleased idle instance is paused to make
|
|
2120
|
+
* one, and if none can be, the wake is refused instead of exceeding the cap.
|
|
2121
|
+
*/
|
|
2122
|
+
async acquireWakeSlot(name) {
|
|
2123
|
+
const coordinator = this.wakeCoordinator;
|
|
2124
|
+
if (!coordinator || this.deliveryWorkerMode(name) === "off" || !this.lifecycle.isPaused(name))
|
|
2125
|
+
return null;
|
|
2126
|
+
if (coordinator.isWaking(name))
|
|
2127
|
+
return null;
|
|
2128
|
+
let token = coordinator.tryReserve(name);
|
|
2129
|
+
if (!token) {
|
|
2130
|
+
const victim = this.lruUnleasedIdle(name);
|
|
2131
|
+
if (victim) {
|
|
2132
|
+
await this.lifecycle.pause(victim, "warm_cap");
|
|
2133
|
+
if (coordinator.isWaking(name))
|
|
2134
|
+
return null;
|
|
2135
|
+
token = coordinator.tryReserve(name);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (!token) {
|
|
2139
|
+
throw new Error(`No warm slot is free for '${name}' (warm_cap + overflow reached and no idle instance can be paused); try again when one goes idle`);
|
|
2140
|
+
}
|
|
2141
|
+
return token;
|
|
2142
|
+
}
|
|
2143
|
+
releaseWakeSlot(name, token) {
|
|
2144
|
+
if (!token || !this.wakeCoordinator)
|
|
2145
|
+
return;
|
|
2146
|
+
this.wakeCoordinator.release(name, token);
|
|
2147
|
+
this.wakeCoordinator.kick();
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* An operator's or user's wake (/wake, wake_instance, start_instance on a
|
|
2151
|
+
* paused instance, Settings, a channel message). With delivery_worker off it
|
|
2152
|
+
* is exactly the old lifecycle wake; otherwise it goes through the shared
|
|
2153
|
+
* slot admission and joins any wake already in flight for the target.
|
|
2154
|
+
*/
|
|
2155
|
+
async explicitWake(name, timeoutMs = 30_000) {
|
|
2156
|
+
const token = await this.acquireWakeSlot(name);
|
|
2157
|
+
try {
|
|
2158
|
+
await this.lifecycle.wake(name, timeoutMs);
|
|
2159
|
+
}
|
|
2160
|
+
finally {
|
|
2161
|
+
this.releaseWakeSlot(name, token);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
/** The least-recently-active idle, unleased, non-general running instance other than `exclude`. */
|
|
2165
|
+
lruUnleasedIdle(exclude) {
|
|
2166
|
+
return this.warmInstanceNames()
|
|
2167
|
+
.filter(name => name !== exclude
|
|
2168
|
+
&& !this.warmCapEvicting.has(name)
|
|
2169
|
+
&& !isGeneralInstance(this.fleetConfig, name)
|
|
2170
|
+
&& this.getInstanceExecutionState(name) === "idle"
|
|
2171
|
+
&& !this.lifecycle.hasWorkLease(name))
|
|
2172
|
+
.sort((a, b) => (readLastInboundAt(this.getInstanceDir(a)) ?? 0) - (readLastInboundAt(this.getInstanceDir(b)) ?? 0))[0];
|
|
2173
|
+
}
|
|
1954
2174
|
async changeInstancePauseState(name, action) {
|
|
1955
2175
|
if (action === "wake") {
|
|
1956
|
-
await this.
|
|
2176
|
+
await this.explicitWake(name, 30_000);
|
|
1957
2177
|
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
1958
2178
|
return "awake";
|
|
1959
2179
|
}
|
|
@@ -2072,12 +2292,14 @@ export class FleetManager {
|
|
|
2072
2292
|
* and reconcile calls leave this false so a persisted pause remains paused
|
|
2073
2293
|
* across a fleet restart.
|
|
2074
2294
|
*/
|
|
2075
|
-
resumePaused = false
|
|
2295
|
+
resumePaused = false,
|
|
2296
|
+
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2297
|
+
transition) {
|
|
2076
2298
|
// Any start supersedes a pending automatic retry (it would otherwise fire
|
|
2077
2299
|
// into a running instance — harmless, but noisy — or race this start).
|
|
2078
2300
|
this.cancelStartupRetry(name);
|
|
2079
2301
|
if (resumePaused && this.lifecycle.isPaused(name)) {
|
|
2080
|
-
await this.
|
|
2302
|
+
await this.explicitWake(name, 30_000);
|
|
2081
2303
|
// A successful wake clears the persisted pause marker and produces a
|
|
2082
2304
|
// fresh instance_state snapshot. Drop any stale process error left by a
|
|
2083
2305
|
// pre-pause crash so /api/fleet and `agend ls` converge on running.
|
|
@@ -2104,6 +2326,10 @@ export class FleetManager {
|
|
|
2104
2326
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
2105
2327
|
return;
|
|
2106
2328
|
}
|
|
2329
|
+
// An explicit start (CLI/API) begins a fresh idle window; boot and
|
|
2330
|
+
// reconcile starts keep the persisted last-inbound seed (Phase 2a).
|
|
2331
|
+
if (resumePaused)
|
|
2332
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2107
2333
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2108
2334
|
if (config.general_topic) {
|
|
2109
2335
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
@@ -2127,7 +2353,7 @@ export class FleetManager {
|
|
|
2127
2353
|
kind,
|
|
2128
2354
|
backend,
|
|
2129
2355
|
model: this.resolveInstanceModel(name).display,
|
|
2130
|
-
});
|
|
2356
|
+
}, transition);
|
|
2131
2357
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
2132
2358
|
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
2133
2359
|
// a falsely running instance when lifecycle.start() returns early.
|
|
@@ -2141,18 +2367,18 @@ export class FleetManager {
|
|
|
2141
2367
|
this.requestDiscordUsagePresenceRefresh();
|
|
2142
2368
|
}
|
|
2143
2369
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
2144
|
-
async startPersistedPausedInstance(name) {
|
|
2370
|
+
async startPersistedPausedInstance(name, transition) {
|
|
2145
2371
|
const topicMode = this.fleetConfig?.channel?.mode === "topic"
|
|
2146
2372
|
|| !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
|
|
2147
2373
|
const fleetConfig = this.fleetConfig?.instances[name];
|
|
2148
2374
|
if (fleetConfig) {
|
|
2149
|
-
await this.startInstance(name, fleetConfig, topicMode);
|
|
2375
|
+
await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
|
|
2150
2376
|
return;
|
|
2151
2377
|
}
|
|
2152
2378
|
const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
|
|
2153
2379
|
if (!channel || !this.classicChannels)
|
|
2154
2380
|
throw new Error(`Paused instance '${name}' is no longer configured`);
|
|
2155
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2381
|
+
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition);
|
|
2156
2382
|
}
|
|
2157
2383
|
/**
|
|
2158
2384
|
* Start instances with configurable concurrency and stagger delay.
|
|
@@ -2201,6 +2427,10 @@ export class FleetManager {
|
|
|
2201
2427
|
return this.daemons.has(name);
|
|
2202
2428
|
}
|
|
2203
2429
|
catch (err) {
|
|
2430
|
+
// Superseded by a stop/restart (Phase 2a): that transition owns the
|
|
2431
|
+
// outcome. Retrying would start an instance the operator just stopped.
|
|
2432
|
+
if (err instanceof SupersededStartError)
|
|
2433
|
+
return false;
|
|
2204
2434
|
this.logger.error({ err, name }, `Failed to start ${what}`);
|
|
2205
2435
|
this.scheduleStartupRetry(name, 0);
|
|
2206
2436
|
return false;
|
|
@@ -2322,6 +2552,9 @@ export class FleetManager {
|
|
|
2322
2552
|
}
|
|
2323
2553
|
}
|
|
2324
2554
|
catch (err) {
|
|
2555
|
+
// Superseded by a stop/restart: that transition owns the outcome.
|
|
2556
|
+
if (err instanceof SupersededStartError)
|
|
2557
|
+
return;
|
|
2325
2558
|
this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
|
|
2326
2559
|
this.scheduleStartupRetry(name, attempt + 1);
|
|
2327
2560
|
}
|
|
@@ -2362,6 +2595,8 @@ export class FleetManager {
|
|
|
2362
2595
|
return this.daemons.has(ch.instanceName);
|
|
2363
2596
|
}
|
|
2364
2597
|
catch (err) {
|
|
2598
|
+
if (err instanceof SupersededStartError)
|
|
2599
|
+
return false;
|
|
2365
2600
|
this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
|
|
2366
2601
|
this.scheduleStartupRetry(ch.instanceName, 0);
|
|
2367
2602
|
return false;
|
|
@@ -2518,7 +2753,7 @@ export class FleetManager {
|
|
|
2518
2753
|
}
|
|
2519
2754
|
return false;
|
|
2520
2755
|
}
|
|
2521
|
-
async stopInstance(name) {
|
|
2756
|
+
async stopInstance(name, transition) {
|
|
2522
2757
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
2523
2758
|
this.cancelStartupRetry(name);
|
|
2524
2759
|
this.failoverActive.delete(name);
|
|
@@ -2537,7 +2772,7 @@ export class FleetManager {
|
|
|
2537
2772
|
// the hand-off snapshotted it).
|
|
2538
2773
|
const run = (async () => {
|
|
2539
2774
|
try {
|
|
2540
|
-
await this.lifecycle.stop(name);
|
|
2775
|
+
await this.lifecycle.stop(name, transition);
|
|
2541
2776
|
}
|
|
2542
2777
|
finally {
|
|
2543
2778
|
this.clearNoncePromptsForInstance(name);
|
|
@@ -2559,11 +2794,16 @@ export class FleetManager {
|
|
|
2559
2794
|
return inFlight;
|
|
2560
2795
|
}
|
|
2561
2796
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
2562
|
-
|
|
2797
|
+
// Phase 2a: invalidate synchronously so a start still spawning does not
|
|
2798
|
+
// publish, then queue behind it as this instance's next transition. The
|
|
2799
|
+
// spawn gate is taken inside the transition, never the other way round, so
|
|
2800
|
+
// the start being waited for can still get its own gate slot.
|
|
2801
|
+
this.lifecycle.invalidate(name);
|
|
2802
|
+
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
2563
2803
|
instanceName: name,
|
|
2564
2804
|
workingDirectory,
|
|
2565
2805
|
reason: "restart",
|
|
2566
|
-
}, () => this.doRestartSingleInstance(name, opts))
|
|
2806
|
+
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
2567
2807
|
.finally(() => {
|
|
2568
2808
|
this.restartsInFlight.delete(name);
|
|
2569
2809
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -2574,19 +2814,73 @@ export class FleetManager {
|
|
|
2574
2814
|
isInstanceRestarting(name) {
|
|
2575
2815
|
return this.restartsInFlight.has(name);
|
|
2576
2816
|
}
|
|
2577
|
-
|
|
2817
|
+
/** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
|
|
2818
|
+
restartInFlight(name) {
|
|
2819
|
+
return this.restartsInFlight.get(name);
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Phase 2a: restarting a paused instance is a wake. The stop leaves the
|
|
2823
|
+
* pause marker in place, and the start used to skip on it ("Persisted
|
|
2824
|
+
* paused instance"), turning a resident pause into a marker-only one that
|
|
2825
|
+
* nothing would wake. Clear it before the start; if the start fails, put it
|
|
2826
|
+
* back so the instance stays wakeable instead of becoming plain stopped.
|
|
2827
|
+
*/
|
|
2828
|
+
async startAfterRestart(name, wasPaused, pausedAt, reason, start) {
|
|
2829
|
+
const instanceDir = this.getInstanceDir(name);
|
|
2830
|
+
if (wasPaused)
|
|
2831
|
+
clearPausedMarker(instanceDir);
|
|
2832
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2833
|
+
try {
|
|
2834
|
+
await start();
|
|
2835
|
+
}
|
|
2836
|
+
catch (err) {
|
|
2837
|
+
if (wasPaused && !this.daemons.has(name))
|
|
2838
|
+
writePausedMarker(instanceDir, pausedAt ?? Date.now(), reason);
|
|
2839
|
+
throw err;
|
|
2840
|
+
}
|
|
2841
|
+
finally {
|
|
2842
|
+
this.lifecycle.clearActivitySeedNow(name);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
async doRestartSingleInstance(name, opts, transition) {
|
|
2578
2846
|
if (this.configPath) {
|
|
2579
2847
|
this.loadConfig(this.configPath);
|
|
2580
2848
|
this.routing.rebuild(this.fleetConfig);
|
|
2581
2849
|
this.reregisterClassicChannels();
|
|
2582
2850
|
}
|
|
2583
2851
|
const config = this.fleetConfig?.instances[name];
|
|
2852
|
+
const wasPaused = this.lifecycle.isPaused(name);
|
|
2853
|
+
// A paused instance's restart is a wake: it takes a warm slot like any other
|
|
2854
|
+
// (refused before anything is stopped when none can be had). An active
|
|
2855
|
+
// restart is already counted and reserves nothing.
|
|
2856
|
+
const slot = await this.acquireWakeSlot(name);
|
|
2857
|
+
// An active instance keeps the slot it already occupies across its own
|
|
2858
|
+
// stop → replacement gap: counted as warm until the replacement is
|
|
2859
|
+
// published or the restart settles, so no other target can take it.
|
|
2860
|
+
if (!wasPaused)
|
|
2861
|
+
this.restartsHoldingSlot.add(name);
|
|
2862
|
+
try {
|
|
2863
|
+
await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
|
|
2864
|
+
// An explicit restart of a paused instance is an operator wake (§1.6):
|
|
2865
|
+
// its success lifts the coordinator's park/backoff (the level stays).
|
|
2866
|
+
// Automatic restarts never do.
|
|
2867
|
+
if (wasPaused && opts?.explicit && this.daemons.has(name))
|
|
2868
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2869
|
+
}
|
|
2870
|
+
finally {
|
|
2871
|
+
this.restartsHoldingSlot.delete(name);
|
|
2872
|
+
this.releaseWakeSlot(name, slot);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
2876
|
+
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
2877
|
+
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
2584
2878
|
if (config) {
|
|
2585
|
-
await this.stopInstance(name);
|
|
2879
|
+
await this.stopInstance(name, transition);
|
|
2586
2880
|
if (opts?.freshStart)
|
|
2587
2881
|
this.writeFreshStartMarker(name);
|
|
2588
2882
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2589
|
-
await this.startInstance(name, config, topicMode ?? false);
|
|
2883
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
2590
2884
|
this.stormWindow.markRecovered(name);
|
|
2591
2885
|
return;
|
|
2592
2886
|
}
|
|
@@ -2595,11 +2889,11 @@ export class FleetManager {
|
|
|
2595
2889
|
if (channelId) {
|
|
2596
2890
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
2597
2891
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
2598
|
-
await this.stopInstance(name);
|
|
2892
|
+
await this.stopInstance(name, transition);
|
|
2599
2893
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
2600
2894
|
if (opts?.freshStart)
|
|
2601
2895
|
this.writeFreshStartMarker(name);
|
|
2602
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2896
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
2603
2897
|
this.stormWindow.markRecovered(name);
|
|
2604
2898
|
return;
|
|
2605
2899
|
}
|
|
@@ -6002,10 +6296,15 @@ export class FleetManager {
|
|
|
6002
6296
|
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
6003
6297
|
?? instanceName;
|
|
6004
6298
|
}
|
|
6299
|
+
/** Inherited keys such as __proto__ must never resolve to a mutable instance. */
|
|
6300
|
+
ownInstanceConfig(instanceName) {
|
|
6301
|
+
const instances = this.fleetConfig?.instances;
|
|
6302
|
+
return instances && Object.hasOwn(instances, instanceName) ? instances[instanceName] : undefined;
|
|
6303
|
+
}
|
|
6005
6304
|
/** Persist identity to the instance's actual config store. Classic instances
|
|
6006
6305
|
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
6007
6306
|
setInstanceDisplayName(instanceName, displayName) {
|
|
6008
|
-
const fleetInstance = this.
|
|
6307
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
6009
6308
|
if (fleetInstance) {
|
|
6010
6309
|
fleetInstance.display_name = displayName;
|
|
6011
6310
|
this.saveFleetConfig();
|
|
@@ -6014,7 +6313,7 @@ export class FleetManager {
|
|
|
6014
6313
|
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
6015
6314
|
}
|
|
6016
6315
|
setInstanceDescription(instanceName, description) {
|
|
6017
|
-
const fleetInstance = this.
|
|
6316
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
6018
6317
|
if (fleetInstance) {
|
|
6019
6318
|
fleetInstance.description = description;
|
|
6020
6319
|
this.saveFleetConfig();
|
|
@@ -6970,19 +7269,26 @@ export class FleetManager {
|
|
|
6970
7269
|
* Settings picker (#1005/#1021), so an agent and an operator see one truth.
|
|
6971
7270
|
*/
|
|
6972
7271
|
async listEmojisFor(instanceName, refresh = false) {
|
|
6973
|
-
|
|
6974
|
-
|
|
7272
|
+
// Seeing is not setting: a ClassicBot instance has no per-instance stamp,
|
|
7273
|
+
// but it reacts in its own channel like any bot and must be able to see
|
|
7274
|
+
// the emojis it can use (only set_persona_emoji refuses it).
|
|
7275
|
+
// Own entries only: an inherited name (constructor, __proto__, toString…)
|
|
7276
|
+
// is not a fleet instance, as set_persona_emoji already holds.
|
|
7277
|
+
const self = this.ownInstanceConfig(instanceName);
|
|
7278
|
+
const classic = !self && this.isClassicPersonaTarget(instanceName);
|
|
7279
|
+
if (!self && !classic)
|
|
6975
7280
|
return { error: this.personaEmojiMissing(instanceName) };
|
|
6976
7281
|
const worldId = this.getInstanceAdapterId(instanceName);
|
|
6977
7282
|
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
6978
7283
|
const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
|
|
6979
|
-
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self
|
|
7284
|
+
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
|
|
6980
7285
|
const out = {
|
|
6981
7286
|
platform: platform ?? null,
|
|
6982
7287
|
statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
|
|
6983
7288
|
standard: platform === "telegram"
|
|
6984
7289
|
? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
|
|
6985
7290
|
: { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
|
|
7291
|
+
...(classic ? { note: "ClassicBot instances have no per-instance status emojis: the statuses above are the connection's, which an operator sets in Settings. You can still use any emoji listed here in your own reactions." } : {}),
|
|
6986
7292
|
};
|
|
6987
7293
|
if (platform === "discord" && worldId) {
|
|
6988
7294
|
const listed = await this.listGuildEmojis(worldId, refresh);
|
|
@@ -7013,9 +7319,10 @@ export class FleetManager {
|
|
|
7013
7319
|
* from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
|
|
7014
7320
|
*/
|
|
7015
7321
|
async previewEmojis(instanceName, args) {
|
|
7016
|
-
|
|
7017
|
-
if (!
|
|
7322
|
+
// Seeing, like list_emojis: ClassicBot instances may preview too.
|
|
7323
|
+
if (!this.ownInstanceConfig(instanceName) && !this.isClassicPersonaTarget(instanceName)) {
|
|
7018
7324
|
return { error: this.personaEmojiMissing(instanceName) };
|
|
7325
|
+
}
|
|
7019
7326
|
const wanted = args.emojis;
|
|
7020
7327
|
if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
|
|
7021
7328
|
return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
|
|
@@ -7110,7 +7417,7 @@ export class FleetManager {
|
|
|
7110
7417
|
* An empty emoji removes that status's override.
|
|
7111
7418
|
*/
|
|
7112
7419
|
async setPersonaEmoji(instanceName, args) {
|
|
7113
|
-
const self = this.
|
|
7420
|
+
const self = this.ownInstanceConfig(instanceName);
|
|
7114
7421
|
if (!self)
|
|
7115
7422
|
return { error: this.personaEmojiMissing(instanceName) };
|
|
7116
7423
|
const status = (args.status ?? "delivered");
|
|
@@ -7155,8 +7462,9 @@ export class FleetManager {
|
|
|
7155
7462
|
// Re-read after the validation above, which may have waited on Discord:
|
|
7156
7463
|
// another set_persona_emoji or a Settings save can have changed another
|
|
7157
7464
|
// status, or replaced this instance's config object, meanwhile. Change
|
|
7158
|
-
// only this one key on what is there now.
|
|
7159
|
-
|
|
7465
|
+
// only this one key on what is there now. Ownership must still hold:
|
|
7466
|
+
// removal during the await must not fall through to an inherited entry.
|
|
7467
|
+
const current = this.ownInstanceConfig(instanceName);
|
|
7160
7468
|
if (!current)
|
|
7161
7469
|
return { error: this.personaEmojiMissing(instanceName) };
|
|
7162
7470
|
const map = { ...(current.status_emojis ?? {}) };
|
|
@@ -7173,6 +7481,10 @@ export class FleetManager {
|
|
|
7173
7481
|
const resolved = this.resolveStatusEmojisFor(instanceName);
|
|
7174
7482
|
return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
|
|
7175
7483
|
}
|
|
7484
|
+
/** A ClassicBot instance: it may see (list/preview) emojis, never set a per-instance stamp. */
|
|
7485
|
+
isClassicPersonaTarget(instanceName) {
|
|
7486
|
+
return this.classicChannels?.getAll().some(c => c.instanceName === instanceName) ?? false;
|
|
7487
|
+
}
|
|
7176
7488
|
personaEmojiMissing(instanceName) {
|
|
7177
7489
|
return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
|
|
7178
7490
|
? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
|
|
@@ -7234,6 +7546,16 @@ export class FleetManager {
|
|
|
7234
7546
|
const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
|
|
7235
7547
|
return reactionForm(resolved.platform, resolved.received);
|
|
7236
7548
|
}
|
|
7549
|
+
/**
|
|
7550
|
+
* The stamp for an inbound photo / file a classic bot saved (#1080): the
|
|
7551
|
+
* instance's `status_emojis.photo|attachment`, then its connection's, then
|
|
7552
|
+
* the built-in (📸/📎, or 👌/👍 on Telegram — what these paths always stamped).
|
|
7553
|
+
* Anything else a saved attachment can be counts as a file.
|
|
7554
|
+
*/
|
|
7555
|
+
savedAttachmentReactionFor(instanceName, adapter, adapterId, kind) {
|
|
7556
|
+
const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
|
|
7557
|
+
return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
|
|
7558
|
+
}
|
|
7237
7559
|
reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
|
|
7238
7560
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
7239
7561
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
@@ -9683,10 +10005,16 @@ export class FleetManager {
|
|
|
9683
10005
|
}
|
|
9684
10006
|
// The installer may only have added the binary to a profile PATH; a
|
|
9685
10007
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
9686
|
-
|
|
10008
|
+
const installedAt = this.locateBinaryOnLoginShell(info.binary);
|
|
10009
|
+
if (!installedAt) {
|
|
9687
10010
|
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
9688
10011
|
return;
|
|
9689
10012
|
}
|
|
10013
|
+
// #1059: make it reachable from this process too. `/login` lists what
|
|
10014
|
+
// `which` finds on the fleet's own PATH, and instances resolve their
|
|
10015
|
+
// binary the same way, so a binary only a login shell could see was
|
|
10016
|
+
// installed, reported as verified, and then offered nowhere.
|
|
10017
|
+
this.adoptBinaryDirectory(installedAt, backend);
|
|
9690
10018
|
if (!LOGIN_FLOWS[backend]) {
|
|
9691
10019
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
9692
10020
|
return;
|
|
@@ -9746,15 +10074,35 @@ export class FleetManager {
|
|
|
9746
10074
|
return t("install.cancelled", backend);
|
|
9747
10075
|
}
|
|
9748
10076
|
/** `command -v` on a login shell, so PATH additions from rc files count. */
|
|
9749
|
-
|
|
10077
|
+
/**
|
|
10078
|
+
* The absolute path a fresh login shell resolves `binary` to, or null. Only
|
|
10079
|
+
* a real executable file counts: `command -v` also answers with an alias
|
|
10080
|
+
* definition or a function name, neither of which a spawn could run.
|
|
10081
|
+
*/
|
|
10082
|
+
locateBinaryOnLoginShell(binary) {
|
|
9750
10083
|
try {
|
|
9751
|
-
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
|
|
9752
|
-
|
|
10084
|
+
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
|
|
10085
|
+
if (result.status !== 0)
|
|
10086
|
+
return null;
|
|
10087
|
+
const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
|
|
10088
|
+
if (!isAbsolute(path))
|
|
10089
|
+
return null;
|
|
10090
|
+
accessSync(path, fsConstants.X_OK);
|
|
10091
|
+
return statSync(path).isFile() ? path : null;
|
|
9753
10092
|
}
|
|
9754
10093
|
catch {
|
|
9755
|
-
return
|
|
10094
|
+
return null;
|
|
9756
10095
|
}
|
|
9757
10096
|
}
|
|
10097
|
+
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
10098
|
+
adoptBinaryDirectory(binaryPath, backend) {
|
|
10099
|
+
const dir = dirname(binaryPath);
|
|
10100
|
+
const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
10101
|
+
if (entries.includes(dir))
|
|
10102
|
+
return;
|
|
10103
|
+
process.env.PATH = [dir, ...entries].join(delimiter);
|
|
10104
|
+
this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
|
|
10105
|
+
}
|
|
9758
10106
|
/** "Sign in now?" button after a successful install. */
|
|
9759
10107
|
async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
9760
10108
|
const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
|
|
@@ -10325,9 +10673,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10325
10673
|
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
10326
10674
|
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
10327
10675
|
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
10328
|
-
const emoji = msg.
|
|
10329
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10330
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10676
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
|
|
10331
10677
|
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
10332
10678
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10333
10679
|
}
|
|
@@ -10358,9 +10704,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10358
10704
|
return;
|
|
10359
10705
|
// Attachments already saved at the top of the collab block.
|
|
10360
10706
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
10361
|
-
const emoji = msg.
|
|
10362
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10363
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10707
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, classicAdapter, msg.adapterId, saved.kind);
|
|
10364
10708
|
classicAdapter.react(collabReactChatId, msg.messageId, emoji)
|
|
10365
10709
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10366
10710
|
}
|
|
@@ -10408,10 +10752,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10408
10752
|
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
10409
10753
|
const reactChatId = msg.threadId ?? msg.chatId;
|
|
10410
10754
|
if (saved && reactAdapter && reactChatId && msg.messageId) {
|
|
10411
|
-
|
|
10412
|
-
const emoji = msg.source === "telegram"
|
|
10413
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10414
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10755
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
|
|
10415
10756
|
reactAdapter.react(reactChatId, msg.messageId, emoji)
|
|
10416
10757
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10417
10758
|
}
|
|
@@ -10457,9 +10798,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10457
10798
|
classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
|
|
10458
10799
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10459
10800
|
if (saved) {
|
|
10460
|
-
const savedEmoji = msg.
|
|
10461
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10462
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10801
|
+
const savedEmoji = this.savedAttachmentReactionFor(instanceName, classicMsgAdapter, msg.adapterId, saved.kind);
|
|
10463
10802
|
classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
|
|
10464
10803
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10465
10804
|
}
|
|
@@ -11801,7 +12140,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11801
12140
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
11802
12141
|
}
|
|
11803
12142
|
/** Start a classic channel instance with lightweight config */
|
|
11804
|
-
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
|
|
12143
|
+
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
|
|
11805
12144
|
if (this.daemons.has(instanceName))
|
|
11806
12145
|
return;
|
|
11807
12146
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
@@ -11828,7 +12167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11828
12167
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
11829
12168
|
};
|
|
11830
12169
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
11831
|
-
await this.startInstance(instanceName, config, topicMode, "classic");
|
|
12170
|
+
await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
|
|
11832
12171
|
}
|
|
11833
12172
|
/** Handle /start slash command — register classic channel */
|
|
11834
12173
|
async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
|
|
@@ -11940,6 +12279,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11940
12279
|
clearInterval(this.replyObligationTimer);
|
|
11941
12280
|
this.replyObligationTimer = null;
|
|
11942
12281
|
}
|
|
12282
|
+
this.wakeCoordinator?.stop();
|
|
11943
12283
|
if (this.logRotateTimer) {
|
|
11944
12284
|
clearInterval(this.logRotateTimer);
|
|
11945
12285
|
this.logRotateTimer = null;
|
|
@@ -14001,7 +14341,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14001
14341
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
14002
14342
|
(async () => {
|
|
14003
14343
|
try {
|
|
14004
|
-
await this.restartSingleInstance(name);
|
|
14344
|
+
await this.restartSingleInstance(name, { explicit: true });
|
|
14005
14345
|
this.logger.info({ name }, "Instance restarted");
|
|
14006
14346
|
this.emitSseEvent("status", this.getUiStatus());
|
|
14007
14347
|
res.writeHead(200);
|