@songsid/agend 2.1.8-beta.9 → 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.
Files changed (56) hide show
  1. package/dist/backend/claude-code.d.ts +35 -5
  2. package/dist/backend/claude-code.js +288 -17
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/channel/mcp-tools.js +1 -1
  5. package/dist/channel/mcp-tools.js.map +1 -1
  6. package/dist/cli.js +16 -6
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config-validator.js +11 -0
  9. package/dist/config-validator.js.map +1 -1
  10. package/dist/daemon.d.ts +47 -1
  11. package/dist/daemon.js +136 -17
  12. package/dist/daemon.js.map +1 -1
  13. package/dist/delivery-outbox.d.ts +9 -1
  14. package/dist/delivery-outbox.js +25 -4
  15. package/dist/delivery-outbox.js.map +1 -1
  16. package/dist/fleet-manager.d.ts +89 -4
  17. package/dist/fleet-manager.js +361 -53
  18. package/dist/fleet-manager.js.map +1 -1
  19. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +5 -2
  20. package/dist/instance-lifecycle.d.ts +78 -5
  21. package/dist/instance-lifecycle.js +187 -14
  22. package/dist/instance-lifecycle.js.map +1 -1
  23. package/dist/login-flows.d.ts +15 -0
  24. package/dist/login-flows.js +4 -1
  25. package/dist/login-flows.js.map +1 -1
  26. package/dist/outbound-handlers.d.ts +3 -0
  27. package/dist/outbound-handlers.js +3 -3
  28. package/dist/outbound-handlers.js.map +1 -1
  29. package/dist/outbound-schemas.d.ts +2 -0
  30. package/dist/outbound-schemas.js +1 -1
  31. package/dist/outbound-schemas.js.map +1 -1
  32. package/dist/pause-marker.d.ts +10 -1
  33. package/dist/pause-marker.js +26 -2
  34. package/dist/pause-marker.js.map +1 -1
  35. package/dist/settings-api.d.ts +2 -0
  36. package/dist/settings-api.js +3 -1
  37. package/dist/settings-api.js.map +1 -1
  38. package/dist/status-emojis.d.ts +9 -1
  39. package/dist/status-emojis.js +11 -1
  40. package/dist/status-emojis.js.map +1 -1
  41. package/dist/target-queue-worker.d.ts +67 -0
  42. package/dist/target-queue-worker.js +76 -0
  43. package/dist/target-queue-worker.js.map +1 -0
  44. package/dist/tool-permissions.js +7 -2
  45. package/dist/tool-permissions.js.map +1 -1
  46. package/dist/types.d.ts +26 -1
  47. package/dist/types.js +9 -1
  48. package/dist/types.js.map +1 -1
  49. package/dist/ui/settings.html +2 -2
  50. package/dist/wake-coordinator.d.ts +143 -0
  51. package/dist/wake-coordinator.js +321 -0
  52. package/dist/wake-coordinator.js.map +1 -0
  53. package/dist/web-api.d.ts +3 -1
  54. package/dist/web-api.js +1 -1
  55. package/dist/web-api.js.map +1 -1
  56. package/package.json +1 -1
@@ -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
- if (!outbox || this.shuttingDown || this.deliveryPumpRunning)
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, target => this.isInstanceRestarting(target) ? null : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
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
- async dispatchDurableDelivery(claimed) {
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
- isIdle: name => this.getInstanceExecutionState(name) === "idle",
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.lifecycle.wake(instanceName, 30_000);
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
- await this.lifecycle.wake(instanceName, 30_000);
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.lifecycle.wake(name, 30_000);
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.lifecycle.wake(name, 30_000);
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
- const run = this.spawnGate.run({
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
- async doRestartSingleInstance(name, opts) {
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
  }
@@ -6975,19 +7269,26 @@ export class FleetManager {
6975
7269
  * Settings picker (#1005/#1021), so an agent and an operator see one truth.
6976
7270
  */
6977
7271
  async listEmojisFor(instanceName, refresh = false) {
6978
- const self = this.fleetConfig?.instances[instanceName];
6979
- if (!self)
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)
6980
7280
  return { error: this.personaEmojiMissing(instanceName) };
6981
7281
  const worldId = this.getInstanceAdapterId(instanceName);
6982
7282
  const { platform } = this.resolveStatusEmojisFor(instanceName);
6983
7283
  const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
6984
- const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
7284
+ const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
6985
7285
  const out = {
6986
7286
  platform: platform ?? null,
6987
7287
  statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
6988
7288
  standard: platform === "telegram"
6989
7289
  ? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
6990
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." } : {}),
6991
7292
  };
6992
7293
  if (platform === "discord" && worldId) {
6993
7294
  const listed = await this.listGuildEmojis(worldId, refresh);
@@ -7018,9 +7319,10 @@ export class FleetManager {
7018
7319
  * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7019
7320
  */
7020
7321
  async previewEmojis(instanceName, args) {
7021
- const self = this.fleetConfig?.instances[instanceName];
7022
- if (!self)
7322
+ // Seeing, like list_emojis: ClassicBot instances may preview too.
7323
+ if (!this.ownInstanceConfig(instanceName) && !this.isClassicPersonaTarget(instanceName)) {
7023
7324
  return { error: this.personaEmojiMissing(instanceName) };
7325
+ }
7024
7326
  const wanted = args.emojis;
7025
7327
  if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7026
7328
  return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
@@ -7179,6 +7481,10 @@ export class FleetManager {
7179
7481
  const resolved = this.resolveStatusEmojisFor(instanceName);
7180
7482
  return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
7181
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
+ }
7182
7488
  personaEmojiMissing(instanceName) {
7183
7489
  return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
7184
7490
  ? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
@@ -7240,6 +7546,16 @@ export class FleetManager {
7240
7546
  const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
7241
7547
  return reactionForm(resolved.platform, resolved.received);
7242
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
+ }
7243
7559
  reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
7244
7560
  // React via the adapter BOUND to this instance — NOT the first discord world.
7245
7561
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
@@ -10357,9 +10673,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10357
10673
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10358
10674
  const noMentionReactChatId = msg.threadId ?? msg.chatId;
10359
10675
  if (reactAdapter && noMentionReactChatId && msg.messageId) {
10360
- const emoji = msg.source === "telegram"
10361
- ? (saved.kind === "photo" ? "👌" : "👍")
10362
- : (saved.kind === "photo" ? "📸" : "📎");
10676
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10363
10677
  reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
10364
10678
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10365
10679
  }
@@ -10390,9 +10704,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10390
10704
  return;
10391
10705
  // Attachments already saved at the top of the collab block.
10392
10706
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
10393
- const emoji = msg.source === "telegram"
10394
- ? (saved.kind === "photo" ? "👌" : "👍")
10395
- : (saved.kind === "photo" ? "📸" : "📎");
10707
+ const emoji = this.savedAttachmentReactionFor(instanceName, classicAdapter, msg.adapterId, saved.kind);
10396
10708
  classicAdapter.react(collabReactChatId, msg.messageId, emoji)
10397
10709
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10398
10710
  }
@@ -10440,10 +10752,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10440
10752
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10441
10753
  const reactChatId = msg.threadId ?? msg.chatId;
10442
10754
  if (saved && reactAdapter && reactChatId && msg.messageId) {
10443
- // Telegram only supports limited emoji for reactions; use 👌 for photo, 👍 for file
10444
- const emoji = msg.source === "telegram"
10445
- ? (saved.kind === "photo" ? "👌" : "👍")
10446
- : (saved.kind === "photo" ? "📸" : "📎");
10755
+ const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
10447
10756
  reactAdapter.react(reactChatId, msg.messageId, emoji)
10448
10757
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10449
10758
  }
@@ -10489,9 +10798,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10489
10798
  classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
10490
10799
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10491
10800
  if (saved) {
10492
- const savedEmoji = msg.source === "telegram"
10493
- ? (saved.kind === "photo" ? "👌" : "👍")
10494
- : (saved.kind === "photo" ? "📸" : "📎");
10801
+ const savedEmoji = this.savedAttachmentReactionFor(instanceName, classicMsgAdapter, msg.adapterId, saved.kind);
10495
10802
  classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
10496
10803
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
10497
10804
  }
@@ -11833,7 +12140,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11833
12140
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
11834
12141
  }
11835
12142
  /** Start a classic channel instance with lightweight config */
11836
- async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
12143
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
11837
12144
  if (this.daemons.has(instanceName))
11838
12145
  return;
11839
12146
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -11860,7 +12167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11860
12167
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
11861
12168
  };
11862
12169
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
11863
- await this.startInstance(instanceName, config, topicMode, "classic");
12170
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
11864
12171
  }
11865
12172
  /** Handle /start slash command — register classic channel */
11866
12173
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -11972,6 +12279,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11972
12279
  clearInterval(this.replyObligationTimer);
11973
12280
  this.replyObligationTimer = null;
11974
12281
  }
12282
+ this.wakeCoordinator?.stop();
11975
12283
  if (this.logRotateTimer) {
11976
12284
  clearInterval(this.logRotateTimer);
11977
12285
  this.logRotateTimer = null;
@@ -14033,7 +14341,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14033
14341
  this.logger.info({ name }, "Instance restart requested via HTTP");
14034
14342
  (async () => {
14035
14343
  try {
14036
- await this.restartSingleInstance(name);
14344
+ await this.restartSingleInstance(name, { explicit: true });
14037
14345
  this.logger.info({ name }, "Instance restarted");
14038
14346
  this.emitSseEvent("status", this.getUiStatus());
14039
14347
  res.writeHead(200);