@songsid/agend 2.1.2-beta.42 → 2.1.2-beta.44

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.
@@ -50,6 +50,7 @@ import { validateFleetConfig } from "./config-validator.js";
50
50
  import { readLastInboundAt } from "./daemon.js";
51
51
  import { clearPausedMarker } from "./pause-marker.js";
52
52
  import { releaseProcessFleetLock } from "./fleet-lock.js";
53
+ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
53
54
  import { getTmuxSession } from "./config.js";
54
55
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
55
56
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -587,7 +588,25 @@ export class FleetManager {
587
588
  getInstanceStatus(name) {
588
589
  if (this.lifecycle.isPaused(name))
589
590
  return "paused";
591
+ const daemon = this.lifecycle.daemons.get(name);
590
592
  const processStatus = this.instanceProcessStatus.get(name);
593
+ // IPC can be disconnected during a respawn, so the event which announces
594
+ // the new live pane may be missed. The in-process daemon is authoritative
595
+ // in that case; do not leave a stale `crashed` cache masking an instance
596
+ // that has already recovered and can answer messages.
597
+ const daemonStatus = daemon?.getProcessStatus?.();
598
+ if (daemonStatus === "running") {
599
+ if (processStatus)
600
+ this.instanceProcessStatus.delete(name);
601
+ // A recovered in-process daemon is also the authority for clearing a
602
+ // marker left by a crash-loop/reconnect race. This keeps standalone
603
+ // `agend ls` from seeing the old marker after the next API outage.
604
+ try {
605
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
606
+ }
607
+ catch { /* absent */ }
608
+ return "running";
609
+ }
591
610
  if (processStatus)
592
611
  return processStatus;
593
612
  const pidPath = join(this.getInstanceDir(name), "daemon.pid");
@@ -669,6 +688,13 @@ export class FleetManager {
669
688
  cacheInstanceProcessStatus(name, status) {
670
689
  if (status === "running") {
671
690
  this.instanceProcessStatus.delete(name);
691
+ // A prior crash-loop marker is one-shot. Successful respawn is the
692
+ // authoritative recovery signal even when the marker outlived an IPC
693
+ // disconnect and was not consumed by a new Daemon constructor.
694
+ try {
695
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
696
+ }
697
+ catch { /* absent */ }
672
698
  return;
673
699
  }
674
700
  if (status !== "crashed" && status !== "stopped")
@@ -704,7 +730,7 @@ export class FleetManager {
704
730
  const victims = selectLruEvictions(warm, cap, {
705
731
  exclude,
706
732
  isEvicting: name => this.warmCapEvicting.has(name),
707
- isGeneral: name => this.fleetConfig?.instances[name]?.general_topic === true,
733
+ isGeneral: name => isGeneralInstance(this.fleetConfig, name),
708
734
  isIdle: name => this.getInstanceExecutionState(name) === "idle",
709
735
  lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
710
736
  });
@@ -878,6 +904,9 @@ export class FleetManager {
878
904
  this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
879
905
  return "awake";
880
906
  }
907
+ if (isGeneralInstance(this.fleetConfig, name)) {
908
+ throw new Error(GENERAL_PAUSE_ERROR);
909
+ }
881
910
  await this.lifecycle.pause(name);
882
911
  return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
883
912
  }
@@ -897,11 +926,41 @@ export class FleetManager {
897
926
  await new Promise(resolve => setTimeout(resolve, 250));
898
927
  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));
899
928
  }
900
- async startInstance(name, config, topicMode, kind = "fleet-topic") {
929
+ async startInstance(name, config, topicMode, kind = "fleet-topic",
930
+ /**
931
+ * Explicit starts (CLI/API) may resume a paused or failed daemon. Startup
932
+ * and reconcile calls leave this false so a persisted pause remains paused
933
+ * across a fleet restart.
934
+ */
935
+ resumePaused = false) {
936
+ if (resumePaused && this.lifecycle.isPaused(name)) {
937
+ await this.lifecycle.wake(name, 30_000);
938
+ // A successful wake clears the persisted pause marker and produces a
939
+ // fresh instance_state snapshot. Drop any stale process error left by a
940
+ // pre-pause crash so /api/fleet and `agend ls` converge on running.
941
+ this.instanceProcessStatus.delete(name);
942
+ return;
943
+ }
901
944
  if (this.lifecycle.isPaused(name)) {
902
945
  this.logger.info({ name }, "Persisted paused instance — skipping startup");
903
946
  return;
904
947
  }
948
+ if (this.lifecycle.daemons.has(name)) {
949
+ // A crash-loop daemon remains in the lifecycle map so its health monitor
950
+ // can expose the failure. The old start path treated that object as
951
+ // already running and merely deleted the process-status cache, leaving a
952
+ // dead pane (and crash marker) behind. An explicit start is a recovery
953
+ // request: tear down the failed daemon and build a fresh one.
954
+ if (resumePaused) {
955
+ const status = this.getInstanceStatus(name);
956
+ if (status === "crashed" || status === "stopped") {
957
+ await this.restartSingleInstance(name);
958
+ return;
959
+ }
960
+ }
961
+ this.logger.info({ name }, "Instance already running, skipping");
962
+ return;
963
+ }
905
964
  if (config.general_topic) {
906
965
  // antigravity (agy) does not read MCP instructions — fleet context and
907
966
  // routing instructions are not injected, so it cannot act as a dispatcher.
@@ -912,12 +971,19 @@ export class FleetManager {
912
971
  }
913
972
  this.ensureGeneralInstructions(config.working_directory, config.backend);
914
973
  }
915
- this.instanceProcessStatus.delete(name);
916
974
  await this.lifecycle.start(name, config, topicMode, {
917
975
  kind,
918
976
  backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
919
977
  model: this.resolveInstanceModel(name).display,
920
978
  });
979
+ // Only clear a stale process status after a real start succeeded. Clearing
980
+ // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
981
+ // a falsely running instance when lifecycle.start() returns early.
982
+ this.instanceProcessStatus.delete(name);
983
+ try {
984
+ unlinkSync(join(this.getInstanceDir(name), "crash-state.json"));
985
+ }
986
+ catch { /* consumed or absent */ }
921
987
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
922
988
  await this.connectIpcToInstance(name);
923
989
  }
@@ -6858,7 +6924,7 @@ When users create specialized instances, suggest these configurations:
6858
6924
  (async () => {
6859
6925
  try {
6860
6926
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
6861
- await this.startInstance(name, config, topicMode ?? false);
6927
+ await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
6862
6928
  this.emitSseEvent("status", this.getUiStatus());
6863
6929
  res.writeHead(200);
6864
6930
  res.end(JSON.stringify({ ok: true }));