@songsid/agend 2.1.2-beta.17 → 2.1.2-beta.18

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.
@@ -138,7 +138,26 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
138
138
  private scheduleReconcile;
139
139
  private finishStartup;
140
140
  lastActivityMs(name: string): number;
141
+ /**
142
+ * Is the instance between turns?
143
+ *
144
+ * Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
145
+ * the control client's raw 2-second output-silence heuristic. The raw heuristic
146
+ * reads every >2s output lull as idle — and long silent tools (a build, a test
147
+ * run) or an LLM pause produce those constantly mid-turn. That misreading is
148
+ * what retired cancel buttons in the middle of long work (the 5-minute backstop
149
+ * fired during a lull) and froze their progress text (ticker skipped "idle"
150
+ * ticks). The silence heuristic remains only as the fallback for instances
151
+ * whose daemon has not reported a state yet.
152
+ */
141
153
  private getInstanceIdle;
154
+ /**
155
+ * True when the instance claims working/stuck but nothing has refreshed that
156
+ * claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
157
+ * Measures the CACHE's age, not the button's — a healthy multi-hour run
158
+ * answers every query and never trips this.
159
+ */
160
+ private stateReportDead;
142
161
  webhookEmit(event: string, name: string, data?: Record<string, unknown>): void;
143
162
  getSysInfo(): import("./fleet-context.js").SysInfo;
144
163
  /** Load fleet.yaml and build routing table */
@@ -501,6 +520,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
501
520
  * delete the very cancel button this is trying to keep alive.
502
521
  */
503
522
  private startProgressTicker;
523
+ /**
524
+ * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
525
+ * it has not, retire its button. Re-arming replaces the previous timer, so a
526
+ * burst of replies ends with exactly one pending check.
527
+ */
528
+ private armReplyGrace;
504
529
  /** Retire (delete) every cancel button belonging to an instance. */
505
530
  private retireInstanceButtons;
506
531
  /** Begin retiring one button (delete + bounded retry on failure). Idempotent:
@@ -510,6 +535,19 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
510
535
  private attemptButtonDelete;
511
536
  /** Clear an entry's timers (retry + idle-check) and drop it from the map. */
512
537
  private discardButton;
538
+ /**
539
+ * Mirror the live buttons to disk. The map is memory-only, so before this a
540
+ * fleet restart orphaned every button on screen: frozen "處理中…" text and a
541
+ * click that did nothing, forever. The ledger is tiny (a handful of rows) and
542
+ * written on every add/remove — no debounce needed at that rate.
543
+ */
544
+ private persistCancelButtons;
545
+ /**
546
+ * Delete the previous process's buttons. Runs once adapters are up: nothing
547
+ * from a previous fleet process can still be mid-turn from this process's
548
+ * point of view, so every ledger row is an orphan by definition.
549
+ */
550
+ private sweepOrphanedCancelButtons;
513
551
  /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
514
552
  private scheduleButtonRetry;
515
553
  /** Delete one button's message via its own adapter. Resolves on success,
@@ -85,6 +85,32 @@ const CANCEL_BTN_MAX_RETRIES = 3;
85
85
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
86
86
  * reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
87
87
  const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
88
+ /**
89
+ * How long after a reply an instance gets to resume working before its cancel
90
+ * button is retired. A short turn ends with a reply and never works again → the
91
+ * button disappears ~2 minutes after the answer. A multi-step run replies
92
+ * mid-flight and keeps going → the grace check sees "working" and leaves the
93
+ * button alone (the idle edge retires it when the run really ends).
94
+ */
95
+ const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
96
+ /**
97
+ * The daemon only broadcasts execution state on TRANSITIONS, so a long
98
+ * single-state run sends nothing for hours. The idle backstop therefore pokes a
99
+ * query each tick; a live daemon answers within milliseconds and refreshes the
100
+ * cache. When nothing has refreshed it for this long despite those pokes, the
101
+ * reporting chain (daemon, IPC, or state monitor) is dead and a "working" state
102
+ * from 30 minutes ago proves nothing — the button may be retired.
103
+ */
104
+ const STATE_REPORT_STALE_MS = 30 * 60_000;
105
+ /**
106
+ * Unconditional ceiling on a cancel button's life. Deliberately far beyond any
107
+ * legitimate run (multi-hour tasks are normal on this fleet): everything below
108
+ * this is decided by real state; a button that somehow survives a full day is
109
+ * wreckage, stuck or not.
110
+ */
111
+ const CANCEL_BTN_MAX_LIFETIME_MS = 24 * 60 * 60_000;
112
+ /** Orphaned-button ledger, swept at startup. Lives in the fleet data dir. */
113
+ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
88
114
  /**
89
115
  * How often the cancel button's text is refreshed with elapsed working time.
90
116
  *
@@ -273,12 +299,35 @@ export class FleetManager {
273
299
  this.startupComplete = true;
274
300
  if (this.reloadPending)
275
301
  this.scheduleReconcile();
302
+ void this.sweepOrphanedCancelButtons();
276
303
  }
277
304
  // ── ArchiverContext bridge ────────────────────────────────────────────
278
305
  lastActivityMs(name) {
279
306
  return this.lastActivity.get(name) ?? 0;
280
307
  }
308
+ /**
309
+ * Is the instance between turns?
310
+ *
311
+ * Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
312
+ * the control client's raw 2-second output-silence heuristic. The raw heuristic
313
+ * reads every >2s output lull as idle — and long silent tools (a build, a test
314
+ * run) or an LLM pause produce those constantly mid-turn. That misreading is
315
+ * what retired cancel buttons in the middle of long work (the 5-minute backstop
316
+ * fired during a lull) and froze their progress text (ticker skipped "idle"
317
+ * ticks). The silence heuristic remains only as the fallback for instances
318
+ * whose daemon has not reported a state yet.
319
+ */
281
320
  getInstanceIdle(name) {
321
+ // A daemon that is not running cannot be mid-turn. This is what a stale
322
+ // "working" cache after a hard daemon kill (SIGKILL/OOM — no IPC crash
323
+ // report ever arrives) must not override.
324
+ if (this.getInstanceStatus(name) !== "running")
325
+ return true;
326
+ const state = this.getInstanceExecutionState(name);
327
+ if (state === "working" || state === "stuck")
328
+ return false;
329
+ if (state === "idle")
330
+ return true;
282
331
  try {
283
332
  const widFile = join(this.getInstanceDir(name), "window-id");
284
333
  if (!existsSync(widFile))
@@ -290,6 +339,18 @@ export class FleetManager {
290
339
  return true;
291
340
  }
292
341
  }
342
+ /**
343
+ * True when the instance claims working/stuck but nothing has refreshed that
344
+ * claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
345
+ * Measures the CACHE's age, not the button's — a healthy multi-hour run
346
+ * answers every query and never trips this.
347
+ */
348
+ stateReportDead(name) {
349
+ const cached = this.instanceStateCache.get(name);
350
+ if (!cached)
351
+ return false; // no claim to distrust — getInstanceIdle owns this case
352
+ return Date.now() - cached.receivedAt > STATE_REPORT_STALE_MS;
353
+ }
293
354
  // ── LifecycleContext bridge methods ──────────────────────────────────────
294
355
  webhookEmit(event, name, data) {
295
356
  this.webhookEmitter?.emit(event, name, data);
@@ -518,6 +579,9 @@ export class FleetManager {
518
579
  unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
519
580
  observedAt: numberOr(msg.observedAt, now),
520
581
  stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
582
+ // Fleet-manager receipt time, NOT the daemon's observation time: staleness
583
+ // asks "is anyone still reporting", which only the receiver can date.
584
+ receivedAt: now,
521
585
  });
522
586
  for (const check of this.instanceIdleWaiters.get(name) ?? [])
523
587
  check();
@@ -3016,17 +3080,18 @@ export class FleetManager {
3016
3080
  // Route standard channel tools (reply, react, edit_message, download_attachment)
3017
3081
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
3018
3082
  if (tool === "reply") {
3019
- // A reply is NOT proof the turn is over: on multi-step work an agent
3020
- // replies ("starting…") and keeps going for many minutes. Retiring the
3021
- // button here left the channel looking idle with no way to cancel and no
3022
- // sign anything was happening (#410). Idle state owns retirement now; if the
3023
- // instance is still working, move the button below the new reply so it stays
3024
- // the last thing in the channel.
3083
+ // A reply is NOT proof the turn is over (#410) but it is not proof of
3084
+ // more work either. Split the difference: an instance that is clearly
3085
+ // idle loses the button now; one that looks busy keeps it (re-posted
3086
+ // below the reply so it stays last in the channel), with a 2-minute
3087
+ // grace check if it has NOT resumed working by then, the reply was the
3088
+ // end of the turn and the button goes. A multi-step run that keeps
3089
+ // working sails through the check and keeps its button.
3025
3090
  if (this.getInstanceIdle(instanceName)) {
3026
3091
  this.clearCancelButton(instanceName);
3027
3092
  }
3028
3093
  else {
3029
- void this.sendCancelButton(instanceName);
3094
+ void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
3030
3095
  }
3031
3096
  this.reactDone(instanceName);
3032
3097
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
@@ -4019,8 +4084,13 @@ export class FleetManager {
4019
4084
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4020
4085
  if (!adapter)
4021
4086
  return;
4087
+ // Resolve the group through the world fallback (first world when unbound),
4088
+ // NOT through getChannelConfig(binding)?.group_id: on a fleet configured with
4089
+ // `channels:` worlds the primary `channel:` block is empty, so an instance
4090
+ // with no world binding yet (fresh restart, cross-instance delegation)
4091
+ // resolved group_id to undefined and the button silently never appeared.
4022
4092
  const adapterId = this.instanceWorldBinding.get(instanceName);
4023
- const groupId = this.getChannelConfig(adapterId)?.group_id;
4093
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
4024
4094
  const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
4025
4095
  let chatId;
4026
4096
  let threadId;
@@ -4036,8 +4106,12 @@ export class FleetManager {
4036
4106
  if (!chatId && groupId)
4037
4107
  chatId = String(groupId);
4038
4108
  }
4039
- if (!chatId)
4109
+ if (!chatId) {
4110
+ // A button that cannot be addressed must say so — this exact silence is how
4111
+ // "the cancel button sometimes never appears" stayed unreported-in-logs.
4112
+ this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
4040
4113
  return;
4114
+ }
4041
4115
  try {
4042
4116
  const sent = await adapter.notifyAlert(chatId, {
4043
4117
  type: "cancel",
@@ -4064,6 +4138,10 @@ export class FleetManager {
4064
4138
  // when the work was handed over — not from the pane's working transition,
4065
4139
  // which resets if the CLI blips idle mid-turn.
4066
4140
  startedAt: Date.now(),
4141
+ // Matches the text notifyAlert just posted, so the first 60s tick does
4142
+ // not re-edit identical text — which put a "(edited)" mark on Discord
4143
+ // with nothing visibly changed.
4144
+ lastProgressText: "👀 處理中…",
4067
4145
  };
4068
4146
  this.startProgressTicker(entry);
4069
4147
  // Idle-check backstop: every 5min, if the instance is idle, retire the
@@ -4074,12 +4152,24 @@ export class FleetManager {
4074
4152
  clearInterval(entry.idleCheckTimer);
4075
4153
  return;
4076
4154
  }
4077
- if (this.getInstanceIdle(instanceName)) {
4078
- this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button idle backstop retiring");
4155
+ const reason = this.getInstanceIdle(instanceName) ? "idle"
4156
+ : this.stateReportDead(instanceName) ? "state reports stopped"
4157
+ : Date.now() - (entry.startedAt ?? 0) > CANCEL_BTN_MAX_LIFETIME_MS ? "24h ceiling"
4158
+ : null;
4159
+ if (reason) {
4160
+ this.logger.info({ instanceName, messageId: entry.messageId, reason }, "Cancel button backstop retiring");
4079
4161
  this.retireButton(entry);
4162
+ return;
4080
4163
  }
4164
+ // Still looks busy. The daemon only broadcasts on transitions, so ask for
4165
+ // a fresh snapshot — a live daemon's answer refreshes receivedAt and keeps
4166
+ // the staleness check honest; a dead one's silence is the evidence.
4167
+ this.instanceIpcClients.get(instanceName)?.send({
4168
+ type: "query_instance_state", requestId: `cancel-btn-${Date.now()}`,
4169
+ });
4081
4170
  }, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
4082
4171
  this.cancelButtons.set(sent.messageId, entry);
4172
+ this.persistCancelButtons();
4083
4173
  this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
4084
4174
  }
4085
4175
  catch (e) {
@@ -4152,9 +4242,6 @@ export class FleetManager {
4152
4242
  clearInterval(entry.progressTimer);
4153
4243
  return;
4154
4244
  }
4155
- // Idle means the turn ended; the idle-edge handler retires the button.
4156
- if (this.getInstanceIdle(entry.instanceName))
4157
- return;
4158
4245
  const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
4159
4246
  if (text === entry.lastProgressText)
4160
4247
  return; // nothing changed — skip the API call
@@ -4177,6 +4264,29 @@ export class FleetManager {
4177
4264
  }, PROGRESS_UPDATE_INTERVAL_MS);
4178
4265
  entry.progressTimer.unref?.();
4179
4266
  }
4267
+ /**
4268
+ * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
4269
+ * it has not, retire its button. Re-arming replaces the previous timer, so a
4270
+ * burst of replies ends with exactly one pending check.
4271
+ */
4272
+ armReplyGrace(instanceName) {
4273
+ for (const entry of this.cancelButtons.values()) {
4274
+ if (entry.instanceName !== instanceName)
4275
+ continue;
4276
+ if (entry.replyGraceTimer)
4277
+ clearTimeout(entry.replyGraceTimer);
4278
+ entry.replyGraceTimer = setTimeout(() => {
4279
+ entry.replyGraceTimer = undefined;
4280
+ if (!this.cancelButtons.has(entry.messageId))
4281
+ return;
4282
+ if (!this.getInstanceIdle(instanceName))
4283
+ return; // resumed — a long run keeps its button
4284
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
4285
+ this.retireButton(entry);
4286
+ }, REPLY_RETIRE_GRACE_MS);
4287
+ entry.replyGraceTimer.unref?.();
4288
+ }
4289
+ }
4180
4290
  /** Retire (delete) every cancel button belonging to an instance. */
4181
4291
  retireInstanceButtons(instanceName) {
4182
4292
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -4210,7 +4320,68 @@ export class FleetManager {
4210
4320
  clearInterval(entry.idleCheckTimer);
4211
4321
  if (entry.progressTimer)
4212
4322
  clearInterval(entry.progressTimer);
4323
+ if (entry.replyGraceTimer)
4324
+ clearTimeout(entry.replyGraceTimer);
4213
4325
  this.cancelButtons.delete(entry.messageId);
4326
+ this.persistCancelButtons();
4327
+ }
4328
+ /**
4329
+ * Mirror the live buttons to disk. The map is memory-only, so before this a
4330
+ * fleet restart orphaned every button on screen: frozen "處理中…" text and a
4331
+ * click that did nothing, forever. The ledger is tiny (a handful of rows) and
4332
+ * written on every add/remove — no debounce needed at that rate.
4333
+ */
4334
+ persistCancelButtons() {
4335
+ try {
4336
+ const rows = [...this.cancelButtons.values()].map(e => ({
4337
+ instanceName: e.instanceName,
4338
+ adapterId: e.adapterId,
4339
+ chatId: e.chatId,
4340
+ messageId: e.messageId,
4341
+ threadId: e.threadId,
4342
+ }));
4343
+ writeFileSync(join(this.dataDir, CANCEL_BTN_LEDGER_FILE), JSON.stringify(rows));
4344
+ }
4345
+ catch (err) {
4346
+ this.logger.debug({ err }, "Cancel button ledger write failed");
4347
+ }
4348
+ }
4349
+ /**
4350
+ * Delete the previous process's buttons. Runs once adapters are up: nothing
4351
+ * from a previous fleet process can still be mid-turn from this process's
4352
+ * point of view, so every ledger row is an orphan by definition.
4353
+ */
4354
+ async sweepOrphanedCancelButtons() {
4355
+ const ledgerPath = join(this.dataDir, CANCEL_BTN_LEDGER_FILE);
4356
+ let rows;
4357
+ try {
4358
+ if (!existsSync(ledgerPath))
4359
+ return;
4360
+ rows = JSON.parse(readFileSync(ledgerPath, "utf-8"));
4361
+ }
4362
+ catch {
4363
+ try {
4364
+ unlinkSync(ledgerPath);
4365
+ }
4366
+ catch { /* corrupt ledger — drop it */ }
4367
+ return;
4368
+ }
4369
+ for (const row of rows) {
4370
+ const adapter = (row.adapterId ? this.worlds.get(row.adapterId)?.adapter : undefined)
4371
+ ?? this.getAdapterForInstance?.(row.instanceName) ?? this.adapter;
4372
+ if (!adapter?.deleteMessage)
4373
+ continue;
4374
+ try {
4375
+ await adapter.deleteMessage(row.chatId, row.messageId, row.threadId);
4376
+ this.logger.info({ instanceName: row.instanceName, messageId: row.messageId }, "Swept orphaned cancel button from previous run");
4377
+ }
4378
+ catch (err) {
4379
+ // Best effort: the message may already be gone, or too old to delete.
4380
+ this.logger.debug({ err, messageId: row.messageId }, "Orphaned cancel button sweep failed");
4381
+ }
4382
+ }
4383
+ // The current process owns the ledger from here on.
4384
+ this.persistCancelButtons();
4214
4385
  }
4215
4386
  /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
4216
4387
  scheduleButtonRetry(entry, err) {