@songsid/agend 2.1.2-beta.43 → 2.1.2-beta.45

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.
@@ -236,6 +236,8 @@ export class FleetManager {
236
236
  instanceActivity = new Map();
237
237
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
238
238
  ipcWaitTails = new Map();
239
+ /** instanceName → restart currently executing; concurrent callers join it. */
240
+ restartsInFlight = new Map();
239
241
  // Last user message delivered to each instance — used to react ✅ on completion.
240
242
  lastInboundMsg = new Map();
241
243
  topicArchiver;
@@ -588,7 +590,25 @@ export class FleetManager {
588
590
  getInstanceStatus(name) {
589
591
  if (this.lifecycle.isPaused(name))
590
592
  return "paused";
593
+ const daemon = this.lifecycle.daemons.get(name);
591
594
  const processStatus = this.instanceProcessStatus.get(name);
595
+ // IPC can be disconnected during a respawn, so the event which announces
596
+ // the new live pane may be missed. The in-process daemon is authoritative
597
+ // in that case; do not leave a stale `crashed` cache masking an instance
598
+ // that has already recovered and can answer messages.
599
+ const daemonStatus = daemon?.getProcessStatus?.();
600
+ if (daemonStatus === "running") {
601
+ if (processStatus)
602
+ this.instanceProcessStatus.delete(name);
603
+ // A recovered in-process daemon is also the authority for clearing a
604
+ // marker left by a crash-loop/reconnect race. This keeps standalone
605
+ // `agend ls` from seeing the old marker after the next API outage.
606
+ try {
607
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
608
+ }
609
+ catch { /* absent */ }
610
+ return "running";
611
+ }
592
612
  if (processStatus)
593
613
  return processStatus;
594
614
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
@@ -670,6 +690,13 @@ export class FleetManager {
670
690
  cacheInstanceProcessStatus(name, status) {
671
691
  if (status === "running") {
672
692
  this.instanceProcessStatus.delete(name);
693
+ // A prior crash-loop marker is one-shot. Successful respawn is the
694
+ // authoritative recovery signal even when the marker outlived an IPC
695
+ // disconnect and was not consumed by a new Daemon constructor.
696
+ try {
697
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
698
+ }
699
+ catch { /* absent */ }
673
700
  return;
674
701
  }
675
702
  if (status !== "crashed" && status !== "stopped")
@@ -901,11 +928,41 @@ export class FleetManager {
901
928
  await new Promise(resolve => setTimeout(resolve, 250));
902
929
  await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, 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));
903
930
  }
904
- async startInstance(name, config, topicMode, kind = "fleet-topic") {
931
+ async startInstance(name, config, topicMode, kind = "fleet-topic",
932
+ /**
933
+ * Explicit starts (CLI/API) may resume a paused or failed daemon. Startup
934
+ * and reconcile calls leave this false so a persisted pause remains paused
935
+ * across a fleet restart.
936
+ */
937
+ resumePaused = false) {
938
+ if (resumePaused && this.lifecycle.isPaused(name)) {
939
+ await this.lifecycle.wake(name, 30_000);
940
+ // A successful wake clears the persisted pause marker and produces a
941
+ // fresh instance_state snapshot. Drop any stale process error left by a
942
+ // pre-pause crash so /api/fleet and `agend ls` converge on running.
943
+ this.instanceProcessStatus.delete(name);
944
+ return;
945
+ }
905
946
  if (this.lifecycle.isPaused(name)) {
906
947
  this.logger.info({ name }, "Persisted paused instance — skipping startup");
907
948
  return;
908
949
  }
950
+ if (this.lifecycle.daemons.has(name)) {
951
+ // A crash-loop daemon remains in the lifecycle map so its health monitor
952
+ // can expose the failure. The old start path treated that object as
953
+ // already running and merely deleted the process-status cache, leaving a
954
+ // dead pane (and crash marker) behind. An explicit start is a recovery
955
+ // request: tear down the failed daemon and build a fresh one.
956
+ if (resumePaused) {
957
+ const status = this.getInstanceStatus(name);
958
+ if (status === "crashed" || status === "stopped") {
959
+ await this.restartSingleInstance(name);
960
+ return;
961
+ }
962
+ }
963
+ this.logger.info({ name }, "Instance already running, skipping");
964
+ return;
965
+ }
909
966
  if (config.general_topic) {
910
967
  // antigravity (agy) does not read MCP instructions — fleet context and
911
968
  // routing instructions are not injected, so it cannot act as a dispatcher.
@@ -916,12 +973,19 @@ export class FleetManager {
916
973
  }
917
974
  this.ensureGeneralInstructions(config.working_directory, config.backend);
918
975
  }
919
- this.instanceProcessStatus.delete(name);
920
976
  await this.lifecycle.start(name, config, topicMode, {
921
977
  kind,
922
978
  backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
923
979
  model: this.resolveInstanceModel(name).display,
924
980
  });
981
+ // Only clear a stale process status after a real start succeeded. Clearing
982
+ // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
983
+ // a falsely running instance when lifecycle.start() returns early.
984
+ this.instanceProcessStatus.delete(name);
985
+ try {
986
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
987
+ }
988
+ catch { /* consumed or absent */ }
925
989
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
926
990
  await this.connectIpcToInstance(name);
927
991
  }
@@ -1030,6 +1094,21 @@ export class FleetManager {
1030
1094
  }
1031
1095
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
1032
1096
  async restartSingleInstance(name, opts) {
1097
+ // One restart at a time per instance. Multiple sources can ask concurrently
1098
+ // (MCP revival, /restart, pty_error, model failover); a second stop/start
1099
+ // interleaved with the first tears down the window the first just created.
1100
+ // Later callers join the in-flight restart instead — its opts win.
1101
+ const inFlight = this.restartsInFlight.get(name);
1102
+ if (inFlight) {
1103
+ this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
1104
+ return inFlight;
1105
+ }
1106
+ const run = this.doRestartSingleInstance(name, opts)
1107
+ .finally(() => this.restartsInFlight.delete(name));
1108
+ this.restartsInFlight.set(name, run);
1109
+ return run;
1110
+ }
1111
+ async doRestartSingleInstance(name, opts) {
1033
1112
  if (this.configPath) {
1034
1113
  this.loadConfig(this.configPath);
1035
1114
  this.routing.rebuild(this.fleetConfig);
@@ -6862,7 +6941,7 @@ When users create specialized instances, suggest these configurations:
6862
6941
  (async () => {
6863
6942
  try {
6864
6943
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
6865
- await this.startInstance(name, config, topicMode ?? false);
6944
+ await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
6866
6945
  this.emitSseEvent("status", this.getUiStatus());
6867
6946
  res.writeHead(200);
6868
6947
  res.end(JSON.stringify({ ok: true }));