@songsid/agend 2.3.0 → 2.3.1

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 (86) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/muse.d.ts +20 -2
  4. package/dist/backend/muse.js +154 -16
  5. package/dist/backend/muse.js.map +1 -1
  6. package/dist/channel/adapters/telegram.d.ts +1 -1
  7. package/dist/channel/adapters/telegram.js +3 -35
  8. package/dist/channel/adapters/telegram.js.map +1 -1
  9. package/dist/cli.js +6 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config-validator.js +2 -2
  12. package/dist/config-validator.js.map +1 -1
  13. package/dist/daemon.d.ts +13 -0
  14. package/dist/daemon.js +155 -16
  15. package/dist/daemon.js.map +1 -1
  16. package/dist/delivery-outbox.d.ts +11 -0
  17. package/dist/delivery-outbox.js +47 -2
  18. package/dist/delivery-outbox.js.map +1 -1
  19. package/dist/fleet-context.d.ts +1 -1
  20. package/dist/fleet-lock.d.ts +6 -0
  21. package/dist/fleet-lock.js +19 -0
  22. package/dist/fleet-lock.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +34 -2
  24. package/dist/fleet-manager.js +264 -39
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +3 -2
  28. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -2
  29. package/dist/instance-lifecycle.d.ts +1 -0
  30. package/dist/instance-lifecycle.js +11 -0
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/locale.js +24 -2
  33. package/dist/locale.js.map +1 -1
  34. package/dist/log-service.js +1 -0
  35. package/dist/log-service.js.map +1 -1
  36. package/dist/needs-you.js +1 -0
  37. package/dist/needs-you.js.map +1 -1
  38. package/dist/outbound-handlers.d.ts +2 -2
  39. package/dist/outbound-handlers.js +5 -5
  40. package/dist/outbound-handlers.js.map +1 -1
  41. package/dist/search/search-index.d.ts +10 -2
  42. package/dist/search/search-index.js +17 -4
  43. package/dist/search/search-index.js.map +1 -1
  44. package/dist/service-installer.d.ts +23 -1
  45. package/dist/service-installer.js +40 -11
  46. package/dist/service-installer.js.map +1 -1
  47. package/dist/session-oom-stop.d.ts +45 -0
  48. package/dist/session-oom-stop.js +259 -0
  49. package/dist/session-oom-stop.js.map +1 -0
  50. package/dist/session-oom.d.ts +13 -0
  51. package/dist/session-oom.js +119 -7
  52. package/dist/session-oom.js.map +1 -1
  53. package/dist/settings-api.d.ts +1 -1
  54. package/dist/settings-api.js +1 -1
  55. package/dist/settings-api.js.map +1 -1
  56. package/dist/spawn-gate.d.ts +2 -0
  57. package/dist/spawn-gate.js +17 -4
  58. package/dist/spawn-gate.js.map +1 -1
  59. package/dist/start-attempt.d.ts +11 -1
  60. package/dist/start-attempt.js +16 -5
  61. package/dist/start-attempt.js.map +1 -1
  62. package/dist/start-failure-chat.d.ts +37 -0
  63. package/dist/start-failure-chat.js +142 -0
  64. package/dist/start-failure-chat.js.map +1 -0
  65. package/dist/start-failure.d.ts +24 -0
  66. package/dist/start-failure.js +68 -1
  67. package/dist/start-failure.js.map +1 -1
  68. package/dist/telegram-api-root.d.ts +1 -0
  69. package/dist/telegram-api-root.js +40 -0
  70. package/dist/telegram-api-root.js.map +1 -0
  71. package/dist/types.d.ts +3 -2
  72. package/dist/ui/panel-chat.js +19 -6
  73. package/dist/ui/panel-log.js +3 -3
  74. package/dist/ui/panel-settings.js +8 -1
  75. package/dist/ui/settings-strings.js +6 -0
  76. package/dist/ui/shared/app-shell.js +21 -4
  77. package/dist/ui/shared/app.css +9 -3
  78. package/dist/ui/shared/usage-dialog.js +21 -11
  79. package/dist/ui/shared/view-strings.js +2 -0
  80. package/dist/web-api.d.ts +1 -1
  81. package/dist/web-api.js +1 -1
  82. package/dist/web-api.js.map +1 -1
  83. package/dist/working-state-journal.d.ts +68 -0
  84. package/dist/working-state-journal.js +93 -0
  85. package/dist/working-state-journal.js.map +1 -0
  86. package/package.json +1 -1
@@ -217,6 +217,8 @@ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
217
217
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
218
218
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
219
219
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
220
+ import { describeSelfOom } from "./session-oom.js";
221
+ import { assertNoSessionOomStop, readSessionOomStop, captureSessionOomRetry, clearSessionOomStopForRetry, SessionOomStoppedError, sessionOomStopHint } from "./session-oom-stop.js";
220
222
  import { idleGateTimeoutLog } from "./idle-gate-log.js";
221
223
  import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
222
224
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
@@ -460,6 +462,7 @@ const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
460
462
  const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
461
463
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
462
464
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
465
+ const OOM_RETRY_CALLBACK_PREFIX = "oom-retry:";
463
466
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
464
467
  const NEEDS_PAUSE_TTL_MS = 10_000;
465
468
  const HANG_CALLBACK_PREFIX = "hang:";
@@ -471,7 +474,7 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
471
474
  * group's approval, tips, and the per-user /model and /effort menus (which are not nonce entries at all).
472
475
  */
473
476
  const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
474
- HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
477
+ HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, OOM_RETRY_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
475
478
  ]);
476
479
  /**
477
480
  * Where a reply goes on a fleet with no chat platform (web track C4): it is "sent" by being shown in the
@@ -759,6 +762,8 @@ export class FleetManager {
759
762
  * backend outage during a post-update herd). instanceName → pending retry.
760
763
  */
761
764
  startupRetries = new Map();
765
+ /** #2484: instances whose automatic startup retry is running now (between its timer and its outcome). */
766
+ startupRetriesRunning = new Set();
762
767
  /** Bumped by every explicit stop (incl. the stop half of a restart); fences the outage hand-off. */
763
768
  explicitStopGeneration = new Map();
764
769
  /** instanceName → outage hand-off currently executing; a repeat joins it. */
@@ -1792,7 +1797,7 @@ export class FleetManager {
1792
1797
  ...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
1793
1798
  ...reactions.meta,
1794
1799
  },
1795
- }, by.humanOrigin, button.label)) !== false;
1800
+ }, by.humanOrigin, button.label, this.startRetryHoldFor(set.instance, inbound))) !== false;
1796
1801
  }
1797
1802
  finally {
1798
1803
  // Both false and throw reopen the choice in the controller. Finish the
@@ -1841,8 +1846,18 @@ export class FleetManager {
1841
1846
  }
1842
1847
  this.deliveryOutbox = outbox;
1843
1848
  this.deliveryOutboxRecovered = true;
1849
+ outbox.on("working_references", (names) => {
1850
+ for (const name of names)
1851
+ this.observeWorkingState(name, "obligations");
1852
+ });
1844
1853
  outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
1845
1854
  outbox.on("state", (event) => {
1855
+ if (event.deliveryId) {
1856
+ const row = outbox.get(event.deliveryId);
1857
+ if (row)
1858
+ for (const name of new Set([row.sourceInstance, row.targetInstance]))
1859
+ this.observeWorkingState(name, "obligations");
1860
+ }
1846
1861
  this.scheduleDeliveryOutboxPump();
1847
1862
  this.wakeCoordinator?.kick();
1848
1863
  if (event.deliveryId && event.state === "delivered") {
@@ -2001,8 +2016,19 @@ export class FleetManager {
2001
2016
  : item),
2002
2017
  };
2003
2018
  }
2019
+ /** A terminal OOM stop is not an auto-wakeable pause, including after a fleet restart. */
2020
+ sessionOomStopped(name) {
2021
+ return this.daemons.get(name)?.isSessionOomStopped?.() === true || readSessionOomStop(this.getInstanceDir(name)).blocked;
2022
+ }
2023
+ sessionOomStopReason(name) {
2024
+ const state = readSessionOomStop(this.getInstanceDir(name));
2025
+ return state.blocked ? sessionOomStopHint(state)
2026
+ : this.daemons.get(name)?.notAcceptingReason?.() ?? sessionOomStopHint();
2027
+ }
2004
2028
  /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
2005
2029
  deliveryWaitingReason(target) {
2030
+ if (this.sessionOomStopped(target))
2031
+ return this.sessionOomStopReason(target);
2006
2032
  if (this.lifecycle.isPaused(target))
2007
2033
  return t("delivery.hold_state", "paused");
2008
2034
  return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
@@ -2015,6 +2041,9 @@ export class FleetManager {
2015
2041
  }
2016
2042
  /** Called after a replacement Daemon object is installed in lifecycle.daemons. */
2017
2043
  onDaemonReady(name, daemonBootId) {
2044
+ const daemon = this.daemons.get(name);
2045
+ if (daemon?.bootId === daemonBootId)
2046
+ this.listenForWorkingState(name, daemon);
2018
2047
  const outbox = this.deliveryOutbox;
2019
2048
  if (!outbox)
2020
2049
  return;
@@ -2038,6 +2067,82 @@ export class FleetManager {
2038
2067
  }
2039
2068
  this.scheduleDeliveryOutboxPump();
2040
2069
  }
2070
+ workingStateListeners;
2071
+ workingStateErrors;
2072
+ /** Attach only to the published owner. Nothing here sends or resumes work. */
2073
+ listenForWorkingState(name, daemon) {
2074
+ if (typeof daemon.getWorkingStateObservation !== "function")
2075
+ return;
2076
+ this.workingStateListeners ??= new WeakSet();
2077
+ if (this.workingStateListeners.has(daemon))
2078
+ return;
2079
+ this.workingStateListeners.add(daemon);
2080
+ daemon.on("working_state", (event) => {
2081
+ try {
2082
+ if (this.daemons.get(name) !== daemon || !sameInteractionOwner(event.observation.owner, daemon.getInteractionSnapshot().owner))
2083
+ return;
2084
+ this.observeWorkingState(name, event.reason, event.observation);
2085
+ }
2086
+ catch { /* A malformed/stale observation cannot affect the daemon. */ }
2087
+ });
2088
+ this.observeWorkingState(name, "published");
2089
+ }
2090
+ observeWorkingState(name, reason, supplied) {
2091
+ const outbox = this.deliveryOutbox, daemon = this.daemons.get(name);
2092
+ if (!outbox || !daemon || typeof daemon.getWorkingStateObservation !== "function")
2093
+ return;
2094
+ try {
2095
+ const observation = supplied ?? daemon.getWorkingStateObservation();
2096
+ if (!sameInteractionOwner(observation.owner, daemon.getInteractionSnapshot().owner))
2097
+ return;
2098
+ const references = [];
2099
+ const unavailable = [];
2100
+ let totalReferences = 0;
2101
+ const board = this.scheduler?.db.board;
2102
+ if (!board)
2103
+ unavailable.push("tasks");
2104
+ else
2105
+ try {
2106
+ const tasks = board.listCards({ assignee: name, status: ["claimed", "blocked", "review"], limit: 16 });
2107
+ totalReferences += tasks.total;
2108
+ for (const card of tasks.cards)
2109
+ references.push({ kind: "task", id: card.uuid, version: card.version,
2110
+ status: card.status });
2111
+ }
2112
+ catch {
2113
+ unavailable.push("tasks");
2114
+ }
2115
+ try {
2116
+ const obligations = outbox.workingObligations(name);
2117
+ references.push(...obligations.references);
2118
+ totalReferences += obligations.total;
2119
+ }
2120
+ catch {
2121
+ unavailable.push("obligations");
2122
+ }
2123
+ const entry = outbox.observeWorkingState(name, { version: 1, reason, observedAt: Date.now(), observation,
2124
+ lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
2125
+ if (!entry)
2126
+ return;
2127
+ const state = entry.current.observation.executionState ?? "unknown";
2128
+ const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
2129
+ owed: entry.current.references.filter(r => r.kind === "owed").length,
2130
+ waiting: entry.current.references.filter(r => r.kind === "waiting").length,
2131
+ background: entry.current.observation.backgroundWork,
2132
+ total: entry.current.totalReferences, unavailable: entry.current.unavailable };
2133
+ this.logger.info({ name, state, reason, revision: entry.revision, ...counts }, "Working-state journal observed");
2134
+ this.eventLog?.insert(name, "working_state", { state, reason, revision: entry.revision, ...counts,
2135
+ message: `Observed ${state}; tasks ${counts.tasks}, replies owed ${counts.owed}, waiting ${counts.waiting}; ${counts.total} references${counts.background === true ? "; background work running" : ""}${unavailable.length ? "; some sources unavailable" : ""}. Journal only; no recovery action.` });
2136
+ this.emitSseEvent("steps_tick", { instance: name });
2137
+ }
2138
+ catch {
2139
+ this.workingStateErrors ??= new Set();
2140
+ if (!this.workingStateErrors.has(name)) {
2141
+ this.workingStateErrors.add(name);
2142
+ this.logger.warn({ name }, "Could not persist working-state observation; delivery and restart behavior unchanged");
2143
+ }
2144
+ }
2145
+ }
2041
2146
  /**
2042
2147
  * #926: the two reply-obligation safety nets. Idle owners with an unanswered
2043
2148
  * request get one reminder (after the grace); requesters get one overdue
@@ -3725,7 +3830,7 @@ export class FleetManager {
3725
3830
  finish(false);
3726
3831
  });
3727
3832
  }
3728
- async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent) {
3833
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent, onStartRetryHold) {
3729
3834
  if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3730
3835
  return false;
3731
3836
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
@@ -3764,7 +3869,7 @@ export class FleetManager {
3764
3869
  const log = idleGateTimeoutLog(instanceName, timeoutMs, this.instanceStateCache.get(instanceName), Date.now());
3765
3870
  this.logger[log.level](log.fields, log.message);
3766
3871
  }
3767
- const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
3872
+ const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent, onStartRetryHold);
3768
3873
  if (!sent)
3769
3874
  return false;
3770
3875
  if (this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
@@ -3793,8 +3898,11 @@ export class FleetManager {
3793
3898
  * Ordering is preserved by serialising behind any waiter already queued for this
3794
3899
  * instance, *including* when the socket happens to be up: otherwise a message
3795
3900
  * arriving after the reconnect could overtake one that has been waiting for it.
3901
+ *
3902
+ * `onStartRetryHold` (#2484, a person's message): an instance in its own start-retry backoff is waited for until that
3903
+ * retry brings it up, however long the backoff, and fails only once the retries give up.
3796
3904
  */
3797
- async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
3905
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent, onStartRetryHold) {
3798
3906
  if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3799
3907
  return false;
3800
3908
  const queued = this.ipcWaitTails.get(instanceName);
@@ -3808,7 +3916,7 @@ export class FleetManager {
3808
3916
  .then(() => {
3809
3917
  if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3810
3918
  return false;
3811
- return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent);
3919
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent, onStartRetryHold);
3812
3920
  });
3813
3921
  // The chain stores a settled-either-way promise so one failed delivery cannot
3814
3922
  // wedge every later one, and so `queued` above is safe to await unguarded.
@@ -3826,9 +3934,10 @@ export class FleetManager {
3826
3934
  }
3827
3935
  }
3828
3936
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
3829
- async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
3830
- const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
3937
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent, onStartRetryHold) {
3938
+ let deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
3831
3939
  let warned = false;
3940
+ let held = false;
3832
3941
  for (;;) {
3833
3942
  if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3834
3943
  return false;
@@ -3837,6 +3946,22 @@ export class FleetManager {
3837
3946
  const ipc = this.instanceIpcClients.get(instanceName);
3838
3947
  if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
3839
3948
  return true;
3949
+ // #2484: while the instance waits for (or runs) its own start retry, the grace runs from now — so the message is
3950
+ // delivered once a retry brings it up, and fails only after the retries stop (they gave up, or a person stopped
3951
+ // or paused it) and a last grace passes without it.
3952
+ if (onStartRetryHold && this.inStartRetryBackoff(instanceName)) {
3953
+ deadline = Math.max(deadline, Date.now() + IPC_RECONNECT_GRACE_MS);
3954
+ if (!held) {
3955
+ held = true;
3956
+ this.logger.info({ instanceName, retry: this.startupRetries.get(instanceName)?.attempt }, "Instance is waiting for its start retry — holding the message until it is up");
3957
+ try {
3958
+ onStartRetryHold();
3959
+ }
3960
+ catch (err) {
3961
+ this.logger.debug({ err, instanceName }, "Start-retry hold notice failed");
3962
+ }
3963
+ }
3964
+ }
3840
3965
  if (Date.now() >= deadline) {
3841
3966
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
3842
3967
  }
@@ -4222,6 +4347,18 @@ export class FleetManager {
4222
4347
  this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
4223
4348
  }
4224
4349
  }
4350
+ /**
4351
+ * #2484: a person's message waits for its instance's own start retry (DeliveryOptions.onStartRetryHold), shown as
4352
+ * queued on the message meanwhile. A ClassicBot room message (no `msg`) has no delivery-status reactions: it only waits.
4353
+ */
4354
+ startRetryHoldFor(instanceName, msg) {
4355
+ return {
4356
+ onStartRetryHold: () => {
4357
+ if (msg?.chatId && msg.messageId)
4358
+ this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "queued", msg.threadId || undefined);
4359
+ },
4360
+ };
4361
+ }
4225
4362
  /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
4226
4363
  sendHumanControl(instanceName, payload, origin, originalBody) {
4227
4364
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -4262,7 +4399,7 @@ export class FleetManager {
4262
4399
  return false;
4263
4400
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
4264
4401
  }
4265
- const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent);
4402
+ const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent, options.onStartRetryHold);
4266
4403
  if (!sent)
4267
4404
  return false;
4268
4405
  // A cross-instance item arriving before the daemon observes this turn as
@@ -4273,7 +4410,7 @@ export class FleetManager {
4273
4410
  return true;
4274
4411
  }
4275
4412
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
4276
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent));
4413
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent, options.onStartRetryHold));
4277
4414
  this.idleGatedDeliveryTails.set(instanceName, delivery);
4278
4415
  try {
4279
4416
  return await delivery;
@@ -4730,7 +4867,7 @@ export class FleetManager {
4730
4867
  mode: target => this.deliveryWorkerMode(target),
4731
4868
  available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
4732
4869
  listPending: () => this.deliveryOutbox?.listPending() ?? [],
4733
- isPaused: target => this.lifecycle.isPaused(target),
4870
+ isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
4734
4871
  pauseReason: target => readPauseReason(this.getInstanceDir(target)),
4735
4872
  isRestarting: target => this.isInstanceRestarting(target),
4736
4873
  wake: async (target) => {
@@ -4742,7 +4879,7 @@ export class FleetManager {
4742
4879
  warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
4743
4880
  warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
4744
4881
  wokeAt: target => this.lifecycle.wokeAtFor(target),
4745
- notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
4882
+ notAcceptingReason: target => this.sessionOomStopped(target) ? this.sessionOomStopReason(target) : this.daemons.get(target)?.notAcceptingReason?.() ?? null,
4746
4883
  notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
4747
4884
  notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
4748
4885
  kickPump: () => this.scheduleDeliveryOutboxPump(),
@@ -4797,7 +4934,12 @@ export class FleetManager {
4797
4934
  * is exactly the old lifecycle wake; otherwise it goes through the shared
4798
4935
  * slot admission and joins any wake already in flight for the target.
4799
4936
  */
4800
- async explicitWake(name, timeoutMs = 30_000) {
4937
+ async explicitWake(name, timeoutMs = 30_000, retryStoppedSession = false) {
4938
+ if (this.sessionOomStopped(name)) {
4939
+ if (!retryStoppedSession)
4940
+ throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
4941
+ return this.restartSingleInstance(name, { explicit: true });
4942
+ }
4801
4943
  const token = await this.acquireWakeSlot(name);
4802
4944
  try {
4803
4945
  await this.lifecycle.wake(name, timeoutMs);
@@ -4818,7 +4960,7 @@ export class FleetManager {
4818
4960
  }
4819
4961
  async changeInstancePauseState(name, action) {
4820
4962
  if (action === "wake") {
4821
- await this.explicitWake(name, 30_000);
4963
+ await this.explicitWake(name, 30_000, true);
4822
4964
  this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
4823
4965
  return "awake";
4824
4966
  }
@@ -4994,6 +5136,11 @@ export class FleetManager {
4994
5136
  /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
4995
5137
  transition, execution) {
4996
5138
  execution?.assert();
5139
+ if (this.sessionOomStopped(name)) {
5140
+ if (resumePaused)
5141
+ return this.restartSingleInstance(name, { explicit: true });
5142
+ throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
5143
+ }
4997
5144
  // CLI single-instance cold starts bypass startAll. Start diagnostics before
4998
5145
  // any lifecycle/wake work; a shutdown must not revive the stopped sampler.
4999
5146
  if (!this.shuttingDown)
@@ -5002,7 +5149,7 @@ export class FleetManager {
5002
5149
  // into a running instance — harmless, but noisy — or race this start).
5003
5150
  this.cancelStartupRetry(name);
5004
5151
  if (resumePaused && this.lifecycle.isPaused(name)) {
5005
- await this.explicitWake(name, 30_000);
5152
+ await this.explicitWake(name, 30_000, true);
5006
5153
  // A successful wake clears the persisted pause marker and produces a
5007
5154
  // fresh instance_state snapshot. Drop any stale process error left by a
5008
5155
  // pre-pause crash so /api/fleet and `agend ls` converge on running.
@@ -5022,7 +5169,7 @@ export class FleetManager {
5022
5169
  if (resumePaused) {
5023
5170
  const status = this.getInstanceStatus(name);
5024
5171
  if (status === "crashed" || status === "stopped") {
5025
- await this.restartSingleInstance(name);
5172
+ await this.restartSingleInstance(name, { explicit: true });
5026
5173
  return;
5027
5174
  }
5028
5175
  }
@@ -5197,6 +5344,8 @@ export class FleetManager {
5197
5344
  // outcome. Retrying would start an instance the operator just stopped.
5198
5345
  if (err instanceof SupersededStartError)
5199
5346
  return false;
5347
+ if (err instanceof SessionOomStoppedError)
5348
+ await this.notifySessionOomStop(name);
5200
5349
  if (this.stopOnUnsupportedCli(name, err, what))
5201
5350
  return false;
5202
5351
  this.logger.error({ err, name }, `Failed to start ${what}`);
@@ -5217,6 +5366,11 @@ export class FleetManager {
5217
5366
  scheduleStartupRetry(name, attempt) {
5218
5367
  if (this.shuttingDown)
5219
5368
  return;
5369
+ if (this.sessionOomStopped(name)) {
5370
+ this.instanceProcessStatus.set(name, "stopped");
5371
+ this.logger.warn({ name }, this.sessionOomStopReason(name));
5372
+ return;
5373
+ }
5220
5374
  if (this.startupRetries.has(name))
5221
5375
  return;
5222
5376
  const backoff = FleetManager.STARTUP_RETRY_BACKOFF_MS;
@@ -5244,6 +5398,12 @@ export class FleetManager {
5244
5398
  this.startupRetries.delete(name);
5245
5399
  this.logger.info({ name }, "Pending automatic startup retry cancelled");
5246
5400
  }
5401
+ /** #2484: the instance's own start retry is scheduled or running, and no person paused it (a stop cancels the retry). */
5402
+ inStartRetryBackoff(name) {
5403
+ if (this.shuttingDown || this.lifecycle.isPaused(name))
5404
+ return false;
5405
+ return this.startupRetries.has(name) || this.startupRetriesRunning.has(name);
5406
+ }
5247
5407
  /** Pending automatic retry, if any (status display / tests). */
5248
5408
  pendingStartupRetry(name) {
5249
5409
  const pending = this.startupRetries.get(name);
@@ -5294,6 +5454,8 @@ export class FleetManager {
5294
5454
  this.startupRetries.delete(name);
5295
5455
  if (this.shuttingDown)
5296
5456
  return;
5457
+ if (this.sessionOomStopped(name))
5458
+ return;
5297
5459
  if (!this.isConfiguredInstance(name))
5298
5460
  return; // removed from fleet.yaml / classic channel meanwhile
5299
5461
  if (this.lifecycle.isPaused(name) || this.daemons.has(name))
@@ -5309,6 +5471,7 @@ export class FleetManager {
5309
5471
  }
5310
5472
  const topicMode = this.fleetConfig?.channel?.mode === "topic"
5311
5473
  || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
5474
+ this.startupRetriesRunning.add(name);
5312
5475
  try {
5313
5476
  await this.spawnGate.run({
5314
5477
  instanceName: name,
@@ -5330,6 +5493,9 @@ export class FleetManager {
5330
5493
  this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
5331
5494
  this.scheduleStartupRetry(name, attempt + 1);
5332
5495
  }
5496
+ finally {
5497
+ this.startupRetriesRunning.delete(name);
5498
+ }
5333
5499
  }
5334
5500
  /** Fleet-topic (fleet.yaml) or ClassicBot (classic channel) — both are retryable; anything else is gone. */
5335
5501
  isConfiguredInstance(name) {
@@ -5655,12 +5821,29 @@ export class FleetManager {
5655
5821
  }
5656
5822
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
5657
5823
  async restartSingleInstance(name, opts) {
5824
+ if (opts?.recoveryCurrent && !opts.recoveryCurrent())
5825
+ throw new Error(t("exit.expired", name));
5826
+ const retry = opts?.explicit ? opts.sessionOomRetry ?? captureSessionOomRetry(this.getInstanceDir(name)) : undefined;
5827
+ if (opts?.sessionOomRetry) {
5828
+ const current = captureSessionOomRetry(this.getInstanceDir(name));
5829
+ // An unclear-cause stop intentionally has no tombstone. Only its live
5830
+ // launch fence can authorize an absent-marker receipt; positive stops
5831
+ // still require the offered durable revision.
5832
+ if ((!retry?.revision && !opts.recoveryCurrent) || current.revision !== retry?.revision || current.legacyRevision !== retry?.legacyRevision)
5833
+ throw new SessionOomStoppedError(true);
5834
+ }
5835
+ if (!opts?.explicit && this.sessionOomStopped(name))
5836
+ throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
5658
5837
  // One restart at a time per instance. Multiple sources can ask concurrently
5659
5838
  // (MCP revival, /restart, pty_error, model failover); a second stop/start
5660
5839
  // interleaved with the first tears down the window the first just created.
5661
5840
  // Later callers join the in-flight restart instead — its opts win.
5662
5841
  const inFlight = this.restartsInFlight.get(name);
5663
5842
  if (inFlight) {
5843
+ // Do not let a late explicit caller clear a stop belonging to the
5844
+ // automatic flight it would join. That action must be retried afterwards.
5845
+ if (retry && this.sessionOomStopped(name))
5846
+ throw new SessionOomStoppedError(true);
5664
5847
  this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
5665
5848
  // #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
5666
5849
  // start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
@@ -5687,7 +5870,7 @@ export class FleetManager {
5687
5870
  workingDirectory,
5688
5871
  reason: "restart",
5689
5872
  stage: "lifecycle",
5690
- }, () => this.doRestartSingleInstance(name, opts, transition)))
5873
+ }, () => this.doRestartSingleInstance(name, opts, transition, retry)))
5691
5874
  .finally(() => {
5692
5875
  this.restartsInFlight.delete(name);
5693
5876
  if (this.restartGaps.get(name) === gap)
@@ -5728,7 +5911,7 @@ export class FleetManager {
5728
5911
  this.lifecycle.clearActivitySeedNow(name);
5729
5912
  }
5730
5913
  }
5731
- async doRestartSingleInstance(name, opts, transition) {
5914
+ async doRestartSingleInstance(name, opts, transition, retry) {
5732
5915
  if (this.configPath) {
5733
5916
  this.loadConfig(this.configPath);
5734
5917
  this.routing.rebuild(this.fleetConfig);
@@ -5746,7 +5929,7 @@ export class FleetManager {
5746
5929
  if (!wasPaused)
5747
5930
  this.restartsHoldingSlot.add(name);
5748
5931
  try {
5749
- await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
5932
+ await this.doRestartWithSlot(name, opts, transition, config, wasPaused, retry);
5750
5933
  // An explicit restart of a paused instance is an operator wake (§1.6):
5751
5934
  // its success lifts the coordinator's park/backoff (the level stays).
5752
5935
  // Automatic restarts never do.
@@ -5758,7 +5941,7 @@ export class FleetManager {
5758
5941
  this.releaseWakeSlot(name, slot);
5759
5942
  }
5760
5943
  }
5761
- async doRestartWithSlot(name, opts, transition, config, wasPaused) {
5944
+ async doRestartWithSlot(name, opts, transition, config, wasPaused, retry) {
5762
5945
  const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
5763
5946
  const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
5764
5947
  // #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
@@ -5766,6 +5949,12 @@ export class FleetManager {
5766
5949
  // Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
5767
5950
  /** `launching`: the login the start will use — read only when a switch is to be carried. */
5768
5951
  const betweenStopAndStart = async (launching) => {
5952
+ // Clearing is synchronous, after the old daemon is stopped and inside
5953
+ // this action's serialized transition; an unseen later stop is retained.
5954
+ if (retry)
5955
+ clearSessionOomStopForRetry(this.getInstanceDir(name), retry);
5956
+ else
5957
+ assertNoSessionOomStop(this.getInstanceDir(name));
5769
5958
  // #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
5770
5959
  const gap = this.restartGaps.get(name);
5771
5960
  const carry = gap?.carry ?? opts?.carry;
@@ -9702,7 +9891,7 @@ export class FleetManager {
9702
9891
  ...generalReactions.meta,
9703
9892
  ...extraMeta,
9704
9893
  },
9705
- }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
9894
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text, this.startRetryHoldFor(generalInstance, msg));
9706
9895
  generalReactions.consume();
9707
9896
  this.afterUserInboundDelivered(generalInstance, msg);
9708
9897
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
@@ -9798,7 +9987,7 @@ export class FleetManager {
9798
9987
  ...reactions.meta,
9799
9988
  ...extraMeta,
9800
9989
  },
9801
- }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
9990
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text, this.startRetryHoldFor(instanceName, msg));
9802
9991
  // Only after the message actually went out. A failed delivery keeps the
9803
9992
  // reactions queued for the retry / the next message.
9804
9993
  reactions.consume();
@@ -15021,14 +15210,42 @@ export class FleetManager {
15021
15210
  /** A clean exit is intentional from the CLI's perspective, but often not from
15022
15211
  * the operator's. Keep the instance notice passive and put the action in the
15023
15212
  * same-world General topic where an administrator can make the choice. */
15024
- async notifyNormalExit(instanceName) {
15025
- this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
15213
+ async notifySessionOomStop(instanceName, unclear = false) {
15214
+ await this.notifyNormalExit(instanceName, unclear ? "unclear" : true);
15215
+ }
15216
+ async notifyNormalExit(instanceName, oom = false) {
15217
+ const daemon = oom === "unclear" ? this.daemons.get(instanceName) : undefined;
15218
+ const owner = daemon ? { ...daemon.getInteractionSnapshot().owner } : undefined;
15219
+ const current = daemon && owner ? () => this.daemons.get(instanceName) === daemon
15220
+ && daemon.isUnclearOomStopped() && sameInteractionOwner(owner, daemon.getInteractionSnapshot().owner) : undefined;
15221
+ if (oom === "unclear" && !current?.())
15222
+ return;
15223
+ const state = oom ? readSessionOomStop(this.getInstanceDir(instanceName)) : null;
15224
+ if (oom === "unclear" && state?.blocked)
15225
+ return;
15226
+ const proof = state?.blocked ? state.record?.evidence ?? state.evidence : undefined;
15227
+ if (oom === true && !proof)
15228
+ return;
15229
+ const sessionOomRetry = oom ? captureSessionOomRetry(this.getInstanceDir(instanceName)) : undefined;
15230
+ if (oom && [...this.pendingNonceButtons.values()].some(p => p.prefix === OOM_RETRY_CALLBACK_PREFIX
15231
+ && p.instanceName === instanceName && p.sessionOomRetry?.revision === sessionOomRetry?.revision
15232
+ && (oom !== "unclear" || p.confirmationCurrent?.() === true)))
15233
+ return;
15234
+ const prefix = oom ? OOM_RETRY_CALLBACK_PREFIX : EXIT_RESTART_CALLBACK_PREFIX;
15235
+ const message = oom === "unclear" ? t("inst.oom_unclear_choice", instanceName) : oom && proof
15236
+ ? t("inst.session_oom_choice", instanceName, describeSelfOom(proof))
15237
+ : t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName));
15238
+ const choices = oom
15239
+ ? [{ action: "resume", label: t("inst.session_oom_resume") }, { action: "fresh", label: t("inst.session_oom_fresh") }]
15240
+ : [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }];
15241
+ const extra = sessionOomRetry ? { sessionOomRetry, ...(current ? { confirmationCurrent: current } : {}) } : undefined;
15242
+ this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
15026
15243
  const web = this.webOnlyPromptPlace();
15027
15244
  if (web) {
15028
15245
  await this.postNonceButtonPrompt({
15029
- prefix: EXIT_RESTART_CALLBACK_PREFIX, alertType: "exit_restart", instanceName, ...web,
15030
- message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
15031
- choices: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }],
15246
+ prefix, alertType: "exit_restart", instanceName, ...web,
15247
+ message, extra,
15248
+ choices,
15032
15249
  expiredText: t("exit.expired", instanceName),
15033
15250
  });
15034
15251
  return;
@@ -15049,37 +15266,36 @@ export class FleetManager {
15049
15266
  return;
15050
15267
  }
15051
15268
  await this.postNonceButtonPrompt({
15052
- prefix: EXIT_RESTART_CALLBACK_PREFIX,
15269
+ prefix,
15053
15270
  alertType: "exit_restart",
15054
15271
  instanceName,
15055
15272
  adapter,
15056
15273
  adapterId,
15057
15274
  chatId,
15058
15275
  threadId,
15059
- message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
15060
- choices: [
15061
- { action: "restart", label: t("exit.restart") },
15062
- { action: "ignore", label: t("exit.ignore") },
15063
- ],
15276
+ message, extra,
15277
+ choices,
15064
15278
  expiredText: t("exit.expired", instanceName),
15065
15279
  });
15066
15280
  }
15067
15281
  /** Consume a clean-exit Restart/Ignore button exactly once. */
15068
15282
  async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
15069
- const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
15283
+ const oom = data.callbackData.startsWith(OOM_RETRY_CALLBACK_PREFIX);
15284
+ const claimed = this.consumeNonceCallback(oom ? OOM_RETRY_CALLBACK_PREFIX : EXIT_RESTART_CALLBACK_PREFIX, oom ? /^oom-retry:([0-9a-f]+):(resume|fresh)$/ : /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
15070
15285
  if (claimed === null)
15071
15286
  return false;
15072
15287
  if (claimed === "consumed")
15073
15288
  return true;
15074
15289
  const { entry: pending, action } = claimed;
15075
- this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
15290
+ this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
15076
15291
  if (action === "ignore") {
15077
15292
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
15078
15293
  return true;
15079
15294
  }
15080
15295
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
15081
15296
  try {
15082
- await this.restartSingleInstance(pending.instanceName);
15297
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
15298
+ ...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
15083
15299
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
15084
15300
  }
15085
15301
  catch (err) {
@@ -16910,6 +17126,10 @@ export class FleetManager {
16910
17126
  log: (level, message, extra) => this.logger[level](extra ?? {}, message),
16911
17127
  });
16912
17128
  db.board.onChange(({ rev, ids, projects, wakes }) => {
17129
+ // Refresh both old and new owners on reassignment; wakes are not an ownership index.
17130
+ if (ids.length)
17131
+ for (const name of this.daemons.keys())
17132
+ this.observeWorkingState(name, "tasks");
16913
17133
  this.emitSseEvent("tasks", { rev, ids, projects });
16914
17134
  waker.note(wakes);
16915
17135
  // step 3: cards waiting for the person are Needs you items; #2355 review: a project's lead routes them too.
@@ -17008,7 +17228,7 @@ export class FleetManager {
17008
17228
  }
17009
17229
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
17010
17230
  try {
17011
- await this.restartSingleInstance(pending.instanceName);
17231
+ await this.restartSingleInstance(pending.instanceName, { explicit: true });
17012
17232
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
17013
17233
  }
17014
17234
  catch (err) {
@@ -17780,7 +18000,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17780
18000
  content: fullText,
17781
18001
  targetSession: instanceName,
17782
18002
  meta,
17783
- }, msg.humanOrigin, msg.humanOriginalBody ?? text);
18003
+ }, msg.humanOrigin, msg.humanOriginalBody ?? text, this.startRetryHoldFor(instanceName));
17784
18004
  reactions.consume();
17785
18005
  }
17786
18006
  catch (err) {
@@ -19738,7 +19958,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19738
19958
  // also sees the channel's messages.
19739
19959
  if (adapterId)
19740
19960
  this.bindInstanceAdapter(instanceName, adapterId);
19741
- await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
19961
+ if (this.sessionOomStopped(instanceName)) {
19962
+ await this.restartSingleInstance(instanceName, { explicit: true });
19963
+ }
19964
+ else {
19965
+ await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
19966
+ }
19742
19967
  this.reregisterClassicChannels();
19743
19968
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
19744
19969
  if (guildId && !classicChannels.isCollab(channelId, adapterId)) {