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

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.
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
7
7
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
8
8
  import { readFleetMemory } from "./process-memory.js";
9
+ import { ReplyDeduper } from "./reply-dedup.js";
9
10
  import { isScalar, parseDocument } from "yaml";
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
@@ -85,6 +86,32 @@ const CANCEL_BTN_MAX_RETRIES = 3;
85
86
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
86
87
  * reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
87
88
  const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
89
+ /**
90
+ * How long after a reply an instance gets to resume working before its cancel
91
+ * button is retired. A short turn ends with a reply and never works again → the
92
+ * button disappears ~2 minutes after the answer. A multi-step run replies
93
+ * mid-flight and keeps going → the grace check sees "working" and leaves the
94
+ * button alone (the idle edge retires it when the run really ends).
95
+ */
96
+ const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
97
+ /**
98
+ * The daemon only broadcasts execution state on TRANSITIONS, so a long
99
+ * single-state run sends nothing for hours. The idle backstop therefore pokes a
100
+ * query each tick; a live daemon answers within milliseconds and refreshes the
101
+ * cache. When nothing has refreshed it for this long despite those pokes, the
102
+ * reporting chain (daemon, IPC, or state monitor) is dead and a "working" state
103
+ * from 30 minutes ago proves nothing — the button may be retired.
104
+ */
105
+ const STATE_REPORT_STALE_MS = 30 * 60_000;
106
+ /**
107
+ * Unconditional ceiling on a cancel button's life. Deliberately far beyond any
108
+ * legitimate run (multi-hour tasks are normal on this fleet): everything below
109
+ * this is decided by real state; a button that somehow survives a full day is
110
+ * wreckage, stuck or not.
111
+ */
112
+ const CANCEL_BTN_MAX_LIFETIME_MS = 24 * 60 * 60_000;
113
+ /** Orphaned-button ledger, swept at startup. Lives in the fleet data dir. */
114
+ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
88
115
  /**
89
116
  * How often the cancel button's text is refreshed with elapsed working time.
90
117
  *
@@ -184,6 +211,8 @@ export class FleetManager {
184
211
  // reply, on cancel, or when a newer button supersedes it for the same
185
212
  // instance. Per-button tracking means a failed delete never strands a button.
186
213
  cancelButtons = new Map();
214
+ /** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
215
+ replyDeduper = new ReplyDeduper();
187
216
  /** instanceName → what it is doing right now, when the backend can tell us. */
188
217
  instanceActivity = new Map();
189
218
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
@@ -273,12 +302,35 @@ export class FleetManager {
273
302
  this.startupComplete = true;
274
303
  if (this.reloadPending)
275
304
  this.scheduleReconcile();
305
+ void this.sweepOrphanedCancelButtons();
276
306
  }
277
307
  // ── ArchiverContext bridge ────────────────────────────────────────────
278
308
  lastActivityMs(name) {
279
309
  return this.lastActivity.get(name) ?? 0;
280
310
  }
311
+ /**
312
+ * Is the instance between turns?
313
+ *
314
+ * Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
315
+ * the control client's raw 2-second output-silence heuristic. The raw heuristic
316
+ * reads every >2s output lull as idle — and long silent tools (a build, a test
317
+ * run) or an LLM pause produce those constantly mid-turn. That misreading is
318
+ * what retired cancel buttons in the middle of long work (the 5-minute backstop
319
+ * fired during a lull) and froze their progress text (ticker skipped "idle"
320
+ * ticks). The silence heuristic remains only as the fallback for instances
321
+ * whose daemon has not reported a state yet.
322
+ */
281
323
  getInstanceIdle(name) {
324
+ // A daemon that is not running cannot be mid-turn. This is what a stale
325
+ // "working" cache after a hard daemon kill (SIGKILL/OOM — no IPC crash
326
+ // report ever arrives) must not override.
327
+ if (this.getInstanceStatus(name) !== "running")
328
+ return true;
329
+ const state = this.getInstanceExecutionState(name);
330
+ if (state === "working" || state === "stuck")
331
+ return false;
332
+ if (state === "idle")
333
+ return true;
282
334
  try {
283
335
  const widFile = join(this.getInstanceDir(name), "window-id");
284
336
  if (!existsSync(widFile))
@@ -290,6 +342,18 @@ export class FleetManager {
290
342
  return true;
291
343
  }
292
344
  }
345
+ /**
346
+ * True when the instance claims working/stuck but nothing has refreshed that
347
+ * claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
348
+ * Measures the CACHE's age, not the button's — a healthy multi-hour run
349
+ * answers every query and never trips this.
350
+ */
351
+ stateReportDead(name) {
352
+ const cached = this.instanceStateCache.get(name);
353
+ if (!cached)
354
+ return false; // no claim to distrust — getInstanceIdle owns this case
355
+ return Date.now() - cached.receivedAt > STATE_REPORT_STALE_MS;
356
+ }
293
357
  // ── LifecycleContext bridge methods ──────────────────────────────────────
294
358
  webhookEmit(event, name, data) {
295
359
  this.webhookEmitter?.emit(event, name, data);
@@ -518,6 +582,9 @@ export class FleetManager {
518
582
  unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
519
583
  observedAt: numberOr(msg.observedAt, now),
520
584
  stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
585
+ // Fleet-manager receipt time, NOT the daemon's observation time: staleness
586
+ // asks "is anyone still reporting", which only the receiver can date.
587
+ receivedAt: now,
521
588
  });
522
589
  for (const check of this.instanceIdleWaiters.get(name) ?? [])
523
590
  check();
@@ -3013,35 +3080,33 @@ export class FleetManager {
3013
3080
  delete args.thread_id;
3014
3081
  threadId = undefined;
3015
3082
  }
3083
+ // Reply dedup: retries land here when the agent was told a send failed
3084
+ // (daemon budget elapsed, shell tool killed) while the adapter send was
3085
+ // still in flight and about to succeed. One real send, everyone gets its
3086
+ // outcome; a genuinely failed send clears the entry so a retry passes.
3087
+ if (tool === "reply") {
3088
+ const ticket = this.replyDeduper.begin(instanceName, String(args.text ?? ""), Array.isArray(args.files) ? args.files : []);
3089
+ if (ticket.duplicate) {
3090
+ this.logger.info({ instanceName }, "Duplicate reply suppressed — replaying the original send's outcome");
3091
+ ticket.subscribe(respond);
3092
+ return;
3093
+ }
3094
+ const original = respond;
3095
+ const respondAndRecord = (result, error) => {
3096
+ ticket.complete(result, error);
3097
+ original(result, error);
3098
+ };
3099
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
3100
+ this.afterReplyRouted(instanceName, args, senderSessionName);
3101
+ return;
3102
+ }
3103
+ // routeToolCall knows "reply"; not handling it means the world changed.
3104
+ ticket.complete(null, "reply not handled");
3105
+ original(null, "reply not handled");
3106
+ return;
3107
+ }
3016
3108
  // Route standard channel tools (reply, react, edit_message, download_attachment)
3017
3109
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
3018
- 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.
3025
- if (this.getInstanceIdle(instanceName)) {
3026
- this.clearCancelButton(instanceName);
3027
- }
3028
- else {
3029
- void this.sendCancelButton(instanceName);
3030
- }
3031
- this.reactDone(instanceName);
3032
- const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
3033
- this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
3034
- this.emitSseEvent("message", {
3035
- instance: instanceName, sender: senderSessionName ?? instanceName,
3036
- text: (args.text ?? "").slice(0, 2000),
3037
- ts: new Date().toISOString(),
3038
- });
3039
- // Log bot reply to classic instance chat-log
3040
- const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
3041
- if (isClassic) {
3042
- ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
3043
- }
3044
- }
3045
3110
  return;
3046
3111
  }
3047
3112
  // Log tool calls for activity visualization
@@ -3056,6 +3121,35 @@ export class FleetManager {
3056
3121
  respond(null, `Unknown tool: ${tool}`);
3057
3122
  }
3058
3123
  }
3124
+ /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
3125
+ afterReplyRouted(instanceName, args, senderSessionName) {
3126
+ // A reply is NOT proof the turn is over (#410) — but it is not proof of
3127
+ // more work either. Split the difference: an instance that is clearly
3128
+ // idle loses the button now; one that looks busy keeps it (re-posted
3129
+ // below the reply so it stays last in the channel), with a 2-minute
3130
+ // grace check — if it has NOT resumed working by then, the reply was the
3131
+ // end of the turn and the button goes. A multi-step run that keeps
3132
+ // working sails through the check and keeps its button.
3133
+ if (this.getInstanceIdle(instanceName)) {
3134
+ this.clearCancelButton(instanceName);
3135
+ }
3136
+ else {
3137
+ void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
3138
+ }
3139
+ this.reactDone(instanceName);
3140
+ const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
3141
+ this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
3142
+ this.emitSseEvent("message", {
3143
+ instance: instanceName, sender: senderSessionName ?? instanceName,
3144
+ text: (args.text ?? "").slice(0, 2000),
3145
+ ts: new Date().toISOString(),
3146
+ });
3147
+ // Log bot reply to classic instance chat-log
3148
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
3149
+ if (isClassic) {
3150
+ ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
3151
+ }
3152
+ }
3059
3153
  /** Handle tool status update from a daemon instance */
3060
3154
  handleToolStatusFromInstance(instanceName, msg) {
3061
3155
  const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
@@ -4019,8 +4113,13 @@ export class FleetManager {
4019
4113
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4020
4114
  if (!adapter)
4021
4115
  return;
4116
+ // Resolve the group through the world fallback (first world when unbound),
4117
+ // NOT through getChannelConfig(binding)?.group_id: on a fleet configured with
4118
+ // `channels:` worlds the primary `channel:` block is empty, so an instance
4119
+ // with no world binding yet (fresh restart, cross-instance delegation)
4120
+ // resolved group_id to undefined and the button silently never appeared.
4022
4121
  const adapterId = this.instanceWorldBinding.get(instanceName);
4023
- const groupId = this.getChannelConfig(adapterId)?.group_id;
4122
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
4024
4123
  const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
4025
4124
  let chatId;
4026
4125
  let threadId;
@@ -4036,8 +4135,12 @@ export class FleetManager {
4036
4135
  if (!chatId && groupId)
4037
4136
  chatId = String(groupId);
4038
4137
  }
4039
- if (!chatId)
4138
+ if (!chatId) {
4139
+ // A button that cannot be addressed must say so — this exact silence is how
4140
+ // "the cancel button sometimes never appears" stayed unreported-in-logs.
4141
+ this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
4040
4142
  return;
4143
+ }
4041
4144
  try {
4042
4145
  const sent = await adapter.notifyAlert(chatId, {
4043
4146
  type: "cancel",
@@ -4064,6 +4167,10 @@ export class FleetManager {
4064
4167
  // when the work was handed over — not from the pane's working transition,
4065
4168
  // which resets if the CLI blips idle mid-turn.
4066
4169
  startedAt: Date.now(),
4170
+ // Matches the text notifyAlert just posted, so the first 60s tick does
4171
+ // not re-edit identical text — which put a "(edited)" mark on Discord
4172
+ // with nothing visibly changed.
4173
+ lastProgressText: "👀 處理中…",
4067
4174
  };
4068
4175
  this.startProgressTicker(entry);
4069
4176
  // Idle-check backstop: every 5min, if the instance is idle, retire the
@@ -4074,12 +4181,24 @@ export class FleetManager {
4074
4181
  clearInterval(entry.idleCheckTimer);
4075
4182
  return;
4076
4183
  }
4077
- if (this.getInstanceIdle(instanceName)) {
4078
- this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button idle backstop retiring");
4184
+ const reason = this.getInstanceIdle(instanceName) ? "idle"
4185
+ : this.stateReportDead(instanceName) ? "state reports stopped"
4186
+ : Date.now() - (entry.startedAt ?? 0) > CANCEL_BTN_MAX_LIFETIME_MS ? "24h ceiling"
4187
+ : null;
4188
+ if (reason) {
4189
+ this.logger.info({ instanceName, messageId: entry.messageId, reason }, "Cancel button backstop retiring");
4079
4190
  this.retireButton(entry);
4191
+ return;
4080
4192
  }
4193
+ // Still looks busy. The daemon only broadcasts on transitions, so ask for
4194
+ // a fresh snapshot — a live daemon's answer refreshes receivedAt and keeps
4195
+ // the staleness check honest; a dead one's silence is the evidence.
4196
+ this.instanceIpcClients.get(instanceName)?.send({
4197
+ type: "query_instance_state", requestId: `cancel-btn-${Date.now()}`,
4198
+ });
4081
4199
  }, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
4082
4200
  this.cancelButtons.set(sent.messageId, entry);
4201
+ this.persistCancelButtons();
4083
4202
  this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
4084
4203
  }
4085
4204
  catch (e) {
@@ -4152,9 +4271,6 @@ export class FleetManager {
4152
4271
  clearInterval(entry.progressTimer);
4153
4272
  return;
4154
4273
  }
4155
- // Idle means the turn ended; the idle-edge handler retires the button.
4156
- if (this.getInstanceIdle(entry.instanceName))
4157
- return;
4158
4274
  const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
4159
4275
  if (text === entry.lastProgressText)
4160
4276
  return; // nothing changed — skip the API call
@@ -4177,6 +4293,29 @@ export class FleetManager {
4177
4293
  }, PROGRESS_UPDATE_INTERVAL_MS);
4178
4294
  entry.progressTimer.unref?.();
4179
4295
  }
4296
+ /**
4297
+ * After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
4298
+ * it has not, retire its button. Re-arming replaces the previous timer, so a
4299
+ * burst of replies ends with exactly one pending check.
4300
+ */
4301
+ armReplyGrace(instanceName) {
4302
+ for (const entry of this.cancelButtons.values()) {
4303
+ if (entry.instanceName !== instanceName)
4304
+ continue;
4305
+ if (entry.replyGraceTimer)
4306
+ clearTimeout(entry.replyGraceTimer);
4307
+ entry.replyGraceTimer = setTimeout(() => {
4308
+ entry.replyGraceTimer = undefined;
4309
+ if (!this.cancelButtons.has(entry.messageId))
4310
+ return;
4311
+ if (!this.getInstanceIdle(instanceName))
4312
+ return; // resumed — a long run keeps its button
4313
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
4314
+ this.retireButton(entry);
4315
+ }, REPLY_RETIRE_GRACE_MS);
4316
+ entry.replyGraceTimer.unref?.();
4317
+ }
4318
+ }
4180
4319
  /** Retire (delete) every cancel button belonging to an instance. */
4181
4320
  retireInstanceButtons(instanceName) {
4182
4321
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -4210,7 +4349,68 @@ export class FleetManager {
4210
4349
  clearInterval(entry.idleCheckTimer);
4211
4350
  if (entry.progressTimer)
4212
4351
  clearInterval(entry.progressTimer);
4352
+ if (entry.replyGraceTimer)
4353
+ clearTimeout(entry.replyGraceTimer);
4213
4354
  this.cancelButtons.delete(entry.messageId);
4355
+ this.persistCancelButtons();
4356
+ }
4357
+ /**
4358
+ * Mirror the live buttons to disk. The map is memory-only, so before this a
4359
+ * fleet restart orphaned every button on screen: frozen "處理中…" text and a
4360
+ * click that did nothing, forever. The ledger is tiny (a handful of rows) and
4361
+ * written on every add/remove — no debounce needed at that rate.
4362
+ */
4363
+ persistCancelButtons() {
4364
+ try {
4365
+ const rows = [...this.cancelButtons.values()].map(e => ({
4366
+ instanceName: e.instanceName,
4367
+ adapterId: e.adapterId,
4368
+ chatId: e.chatId,
4369
+ messageId: e.messageId,
4370
+ threadId: e.threadId,
4371
+ }));
4372
+ writeFileSync(join(this.dataDir, CANCEL_BTN_LEDGER_FILE), JSON.stringify(rows));
4373
+ }
4374
+ catch (err) {
4375
+ this.logger.debug({ err }, "Cancel button ledger write failed");
4376
+ }
4377
+ }
4378
+ /**
4379
+ * Delete the previous process's buttons. Runs once adapters are up: nothing
4380
+ * from a previous fleet process can still be mid-turn from this process's
4381
+ * point of view, so every ledger row is an orphan by definition.
4382
+ */
4383
+ async sweepOrphanedCancelButtons() {
4384
+ const ledgerPath = join(this.dataDir, CANCEL_BTN_LEDGER_FILE);
4385
+ let rows;
4386
+ try {
4387
+ if (!existsSync(ledgerPath))
4388
+ return;
4389
+ rows = JSON.parse(readFileSync(ledgerPath, "utf-8"));
4390
+ }
4391
+ catch {
4392
+ try {
4393
+ unlinkSync(ledgerPath);
4394
+ }
4395
+ catch { /* corrupt ledger — drop it */ }
4396
+ return;
4397
+ }
4398
+ for (const row of rows) {
4399
+ const adapter = (row.adapterId ? this.worlds.get(row.adapterId)?.adapter : undefined)
4400
+ ?? this.getAdapterForInstance?.(row.instanceName) ?? this.adapter;
4401
+ if (!adapter?.deleteMessage)
4402
+ continue;
4403
+ try {
4404
+ await adapter.deleteMessage(row.chatId, row.messageId, row.threadId);
4405
+ this.logger.info({ instanceName: row.instanceName, messageId: row.messageId }, "Swept orphaned cancel button from previous run");
4406
+ }
4407
+ catch (err) {
4408
+ // Best effort: the message may already be gone, or too old to delete.
4409
+ this.logger.debug({ err, messageId: row.messageId }, "Orphaned cancel button sweep failed");
4410
+ }
4411
+ }
4412
+ // The current process owns the ledger from here on.
4413
+ this.persistCancelButtons();
4214
4414
  }
4215
4415
  /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
4216
4416
  scheduleButtonRetry(entry, err) {