switchroom 0.18.23 → 0.18.24

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.18.23", COMMIT_SHA = "a90cf518";
2123
+ var VERSION = "0.18.24", COMMIT_SHA = "f641363e";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -26605,7 +26605,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26605
26605
  import { dirname as dirname4, join as join7 } from "node:path";
26606
26606
 
26607
26607
  // src/build-info.ts
26608
- var VERSION = "0.18.23";
26608
+ var VERSION = "0.18.24";
26609
26609
 
26610
26610
  // src/cli/resolve-version.ts
26611
26611
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.18.23",
4
+ "version": "0.18.24",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -40569,6 +40569,7 @@ function createWorkerActivityFeed(opts) {
40569
40569
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000;
40570
40570
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8));
40571
40571
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60000));
40572
+ const absoluteRowLifetimeCapMs = Math.max(1, Math.floor(opts.absoluteRowLifetimeCapMs ?? 6 * 60 * 60000));
40572
40573
  const reconcilePinFn = opts.reconcilePin ?? (() => {});
40573
40574
  const setIntervalFn = opts.setInterval ?? ((cb, ms) => {
40574
40575
  const t = setInterval(cb, ms);
@@ -40850,22 +40851,36 @@ function createWorkerActivityFeed(opts) {
40850
40851
  const staleFinished = [];
40851
40852
  for (const g of groups.values()) {
40852
40853
  for (const row of g.workers.values()) {
40853
- if (now - row.lastUpdateAt >= staleWorkerTtlMs) {
40854
- if (row.finished)
40855
- staleFinished.push({ g, agentId: row.agentId });
40856
- else
40857
- staleAgentIds.push(row.agentId);
40858
- }
40854
+ const silent = now - row.lastUpdateAt >= staleWorkerTtlMs;
40855
+ const tooOld = now - row.createdAtMs >= absoluteRowLifetimeCapMs;
40856
+ if (!silent && !tooOld)
40857
+ continue;
40858
+ const reason = silent ? "silence" : "absolute";
40859
+ if (row.finished)
40860
+ staleFinished.push({ g, agentId: row.agentId, reason });
40861
+ else
40862
+ staleAgentIds.push({ agentId: row.agentId, reason });
40859
40863
  }
40860
40864
  }
40861
- for (const { g, agentId } of staleFinished) {
40862
- log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} \u2014 reaping leaked finished row`);
40865
+ for (const { g, agentId, reason } of staleFinished) {
40866
+ if (reason === "absolute") {
40867
+ const age = Math.floor((now - (g.workers.get(agentId)?.createdAtMs ?? now)) / 1000);
40868
+ log(`worker-feed: ABSOLUTE cap GC finished row agent=${agentId} feed=${g.feedKey} \u2014 age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); reaping immortal finished row`);
40869
+ } else {
40870
+ log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} \u2014 reaping leaked finished row`);
40871
+ }
40863
40872
  g.pendingFinalize.delete(agentId);
40864
40873
  removeWorker(g, agentId);
40865
40874
  syncPin(g);
40866
40875
  }
40867
- for (const agentId of staleAgentIds) {
40868
- log(`worker-feed: TTL reap agent=${agentId} \u2014 no update in ${Math.floor((now - (groupOfAgent(agentId)?.workers.get(agentId)?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`);
40876
+ for (const { agentId, reason } of staleAgentIds) {
40877
+ const row = groupOfAgent(agentId)?.workers.get(agentId);
40878
+ if (reason === "absolute") {
40879
+ const age = Math.floor((now - (row?.createdAtMs ?? now)) / 1000);
40880
+ log(`worker-feed: ABSOLUTE cap reap agent=${agentId} \u2014 row age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); force-terminating immortal row (survives lastUpdateAt reset)`);
40881
+ } else {
40882
+ log(`worker-feed: TTL reap agent=${agentId} \u2014 no update in ${Math.floor((now - (row?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`);
40883
+ }
40869
40884
  terminateWorker(agentId);
40870
40885
  }
40871
40886
  for (const g of [...groups.values()]) {
@@ -40962,6 +40977,7 @@ function createWorkerActivityFeed(opts) {
40962
40977
  state: "running",
40963
40978
  finished: false,
40964
40979
  lastUpdateAt: nowFn(),
40980
+ createdAtMs: nowFn(),
40965
40981
  dispatchAtMs: null,
40966
40982
  stepStartedAtMs: null
40967
40983
  };
@@ -41018,6 +41034,21 @@ function createWorkerActivityFeed(opts) {
41018
41034
  log(`worker-feed: resurrect agent=${agentId} \u2014 cleared finalized gate; card will repaint on next running cue`);
41019
41035
  }
41020
41036
  },
41037
+ purgeAllOnBoot() {
41038
+ for (const g of [...groups.values()]) {
41039
+ if (g.messageId != null) {
41040
+ reconcilePinFn({ feedKey: g.feedKey, chatId: g.chatId, threadId: g.threadId, messageId: null });
41041
+ }
41042
+ for (const agentId of [...g.workers.keys()]) {
41043
+ markFinalized(agentId);
41044
+ agentIndex.delete(agentId);
41045
+ }
41046
+ g.workers.clear();
41047
+ g.pendingFinalize.clear();
41048
+ groups.delete(g.feedKey);
41049
+ }
41050
+ log("worker-feed: purgeAllOnBoot \u2014 reconciled feed to empty and released all group pins");
41051
+ },
41021
41052
  heartbeatTick,
41022
41053
  stop() {
41023
41054
  if (heartbeatTimer != null) {
@@ -68165,6 +68196,7 @@ function endsWithSilentMarker2(text4) {
68165
68196
  return false;
68166
68197
  return isSilentFlushMarker2(lines[lines.length - 1]);
68167
68198
  }
68199
+ var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
68168
68200
  function selectFlushDeliveryText2(blocks) {
68169
68201
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
68170
68202
  if (candidates.length === 0)
@@ -68213,6 +68245,29 @@ function isTurnFlushSafetyEnabled(env = process.env) {
68213
68245
  return true;
68214
68246
  }
68215
68247
 
68248
+ // reply-owner-resolve.ts
68249
+ function latestEndedAccepted(candidates) {
68250
+ if (candidates.latestEndedTurnId == null)
68251
+ return false;
68252
+ const age = candidates.latestEndedAgeMs;
68253
+ const ttl = candidates.latestEndedTtlMs;
68254
+ if (age == null || ttl == null)
68255
+ return true;
68256
+ return age <= ttl;
68257
+ }
68258
+ function resolveReplyOwnerTurnId(candidates) {
68259
+ return candidates.liveTurnId ?? candidates.originTurnId ?? candidates.quotedTurnId ?? (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ?? null;
68260
+ }
68261
+ function decideAnswerLatchSuppression(input) {
68262
+ if (input.superseded)
68263
+ return false;
68264
+ if (!input.replySubstantive)
68265
+ return false;
68266
+ if (!input.isLateReply)
68267
+ return false;
68268
+ return input.ownerAnswerDelivered;
68269
+ }
68270
+
68216
68271
  // answer-ready-flush.ts
68217
68272
  var ANSWER_READY_FLUSH_MS = 1000;
68218
68273
  function resolveAnswerReadyFlushMs(env) {
@@ -82910,10 +82965,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
82910
82965
  }
82911
82966
 
82912
82967
  // ../src/build-info.ts
82913
- var VERSION = "0.18.23";
82914
- var COMMIT_SHA = "a90cf518";
82915
- var COMMIT_DATE = "2026-07-14T09:32:46+10:00";
82916
- var LATEST_PR = 3238;
82968
+ var VERSION = "0.18.24";
82969
+ var COMMIT_SHA = "f641363e";
82970
+ var COMMIT_DATE = "2026-07-14T15:55:33+10:00";
82971
+ var LATEST_PR = 3240;
82917
82972
  var COMMITS_AHEAD_OF_TAG = 0;
82918
82973
 
82919
82974
  // gateway/boot-version.ts
@@ -85340,6 +85395,7 @@ function resolveSubagentOriginChat(agentId) {
85340
85395
  }
85341
85396
  var WORKER_FEED_FALLBACK_LOG_CAP = 256;
85342
85397
  var WORKER_FEED_STALE_TTL_MARGIN_MS = 300000;
85398
+ var WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE = 4;
85343
85399
  var workerFeedOwnerDmFallbackLogged = new Set;
85344
85400
  function resolveWorkerFeedChat(agentId, fleetChatId) {
85345
85401
  const origin = resolveSubagentOriginChat(agentId);
@@ -85748,6 +85804,26 @@ function findLatestEndedTurnForChat(chatId) {
85748
85804
  }
85749
85805
  return latest;
85750
85806
  }
85807
+ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
85808
+ const origin = findTurnByOriginId(args.origin_turn_id);
85809
+ const quoted = findTurnByQuotedMessageId(chatId, args.reply_to);
85810
+ const latestEnded = findLatestEndedTurnForChat(chatId);
85811
+ const byId = new Map;
85812
+ for (const t of [latestEnded, quoted, origin, liveTurn]) {
85813
+ if (t != null)
85814
+ byId.set(t.turnId, t);
85815
+ }
85816
+ const latestEndedAgeMs = latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null;
85817
+ const winnerId = resolveReplyOwnerTurnId({
85818
+ liveTurnId: liveTurn?.turnId ?? null,
85819
+ originTurnId: origin?.turnId ?? null,
85820
+ quotedTurnId: quoted?.turnId ?? null,
85821
+ latestEndedTurnId: latestEnded?.turnId ?? null,
85822
+ latestEndedAgeMs,
85823
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS
85824
+ });
85825
+ return winnerId != null ? byId.get(winnerId) ?? null : null;
85826
+ }
85751
85827
  function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
85752
85828
  const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
85753
85829
  const threadId = resolveAnswerThreadId({
@@ -86268,6 +86344,7 @@ function endCurrentTurnAtomic(turn, opts) {
86268
86344
  clearAnswerReadyFlushTimeout(turn);
86269
86345
  endCurrentTurnForKey(turn, key);
86270
86346
  const turnEndedAt = Date.now();
86347
+ turn.endedAt = turnEndedAt;
86271
86348
  process.stderr.write(`telegram gateway: ${formatTurnLifecycle("clear", "turn_end", turn, turnEndedAt)}
86272
86349
  `);
86273
86350
  if (opts?.deferRecord !== true) {
@@ -90044,7 +90121,8 @@ async function executeReply(args) {
90044
90121
  }
90045
90122
  {
90046
90123
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
90047
- const resolvedTurnId = turn?.turnId ?? findTurnByOriginId(args.origin_turn_id)?.turnId ?? null;
90124
+ const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args);
90125
+ const resolvedTurnId = ownerTurn?.turnId ?? null;
90048
90126
  const decision = flushedTurnSupersede.take(chat_id, replyThreadId, { liveTurnId: resolvedTurnId, now: Date.now() });
90049
90127
  if (decision.supersede) {
90050
90128
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
@@ -90052,6 +90130,25 @@ async function executeReply(args) {
90052
90130
  for (const id of decision.deleteMessageIds) {
90053
90131
  await swallowingApiCall(() => lockedBot.api.deleteMessage(chat_id, id), { chat_id, verb: "reply.supersedeFlushed" });
90054
90132
  }
90133
+ } else {
90134
+ const replySubstantive = isSubstantiveFinalReply({
90135
+ text: rawText,
90136
+ disableNotification: args.disable_notification === true
90137
+ });
90138
+ const suppressByLatch = decideAnswerLatchSuppression({
90139
+ superseded: false,
90140
+ replySubstantive,
90141
+ isLateReply: turn == null,
90142
+ ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false
90143
+ });
90144
+ if (suppressByLatch) {
90145
+ process.stderr.write(`telegram gateway: reply: suppressed by answer-delivered latch (flush already delivered this turn's answer) chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}
90146
+ `);
90147
+ return { content: [{ type: "text", text: "sent (deduped \u2014 answer already delivered via turn-flush)" }] };
90148
+ }
90149
+ if (replySubstantive && ownerTurn != null) {
90150
+ ownerTurn.answerDelivered = true;
90151
+ }
90055
90152
  }
90056
90153
  }
90057
90154
  const files = args.files ?? [];
@@ -92201,6 +92298,8 @@ function handleSessionEvent(ev) {
92201
92298
  finalAnswerDelivered: false,
92202
92299
  finalAnswerSubstantive: false,
92203
92300
  finalAnswerEverDelivered: false,
92301
+ answerDelivered: false,
92302
+ endedAt: null,
92204
92303
  firstPingAt: null,
92205
92304
  firstPingWasSubstantive: false,
92206
92305
  silentAnchorMessageId: null,
@@ -92677,6 +92776,9 @@ function handleSessionEvent(ev) {
92677
92776
  }
92678
92777
  turn.finalAnswerDelivered = true;
92679
92778
  turn.finalAnswerSubstantive = true;
92779
+ if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
92780
+ turn.answerDelivered = true;
92781
+ }
92680
92782
  const cardTakeover = progressDriver?.takeOverCard({
92681
92783
  chatId: backstopChatId,
92682
92784
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
@@ -92789,6 +92891,7 @@ function handleSessionEvent(ev) {
92789
92891
  sendThrew = true;
92790
92892
  process.stderr.write(`telegram gateway: turn-flush send failed: ${err.message}
92791
92893
  `);
92894
+ turn.answerDelivered = false;
92792
92895
  if (backstopCtrl)
92793
92896
  backstopCtrl.finalize("error");
92794
92897
  } finally {
@@ -99338,7 +99441,10 @@ var didOneTimeSetup = false;
99338
99441
  })();
99339
99442
  const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== "0";
99340
99443
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS);
99341
- workerActivityFeed?.stop();
99444
+ if (workerActivityFeed != null) {
99445
+ workerActivityFeed.purgeAllOnBoot();
99446
+ workerActivityFeed.stop();
99447
+ }
99342
99448
  workerActivityFeed = createWorkerActivityFeed({
99343
99449
  bot: {
99344
99450
  sendMessage: async (cid, text5, sendOpts) => {
@@ -99356,6 +99462,7 @@ var didOneTimeSetup = false;
99356
99462
  floodWaitRemainingMs: probeFloodWaitRemainingMs,
99357
99463
  maxRows: workerFeedMaxRows,
99358
99464
  staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
99465
+ absoluteRowLifetimeCapMs: resolveInflightTerminalCapMs() * WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE,
99359
99466
  reconcilePin: ({ feedKey, chatId, messageId }) => {
99360
99467
  if (!PIN_STATUS_WHILE_WORKING)
99361
99468
  return;
@@ -88,7 +88,7 @@ import {
88
88
  type TelegraphAccount,
89
89
  } from '../telegraph.js'
90
90
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
91
- import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
91
+ import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
92
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
93
93
  import {
94
94
  splitCoalescedAttachments,
@@ -400,7 +400,12 @@ import {
400
400
  import {
401
401
  decideTurnFlush,
402
402
  isTurnFlushSafetyEnabled,
403
+ FLUSH_SUBSTANTIVE_MIN_CHARS,
403
404
  } from '../turn-flush-safety.js'
405
+ import {
406
+ resolveReplyOwnerTurnId,
407
+ decideAnswerLatchSuppression,
408
+ } from '../reply-owner-resolve.js'
404
409
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
405
410
  import {
406
411
  AnswerReadyFlushController,
@@ -2036,6 +2041,28 @@ const WORKER_FEED_FALLBACK_LOG_CAP = 256
2036
2041
  * slot, never a live-but-quiet worker. 5 min.
2037
2042
  */
2038
2043
  const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
2044
+ /**
2045
+ * Multiple of the watcher's in-flight terminal cap used to derive the worker-
2046
+ * feed ABSOLUTE row-lifetime cap (`absoluteRowLifetimeCapMs`). Unlike the
2047
+ * silence-keyed `staleWorkerTtlMs` backstop, the absolute cap is anchored to a
2048
+ * row's immutable creation time, so it reaps an immortal card even when the row
2049
+ * keeps receiving `update()` cues that reset `lastUpdateAt` every heartbeat
2050
+ * (the Carrie 5h zombie-pin leak, re-edited 3000+ times, that the silence sweep
2051
+ * could never match). At the 45-min default cap this yields a 3-hour absolute
2052
+ * ceiling — comfortably above any legitimate single worker's lifetime, so it
2053
+ * can only ever bite a genuine leak, while bounding a ghost card well UNDER the
2054
+ * ~5h symptom Ken hit (the initial 8x/6h was too loose). Derived from the same
2055
+ * base as `staleWorkerTtlMs` — the watcher's effective in-flight terminal cap
2056
+ * (`resolveInflightTerminalCapMs()`: the `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_
2057
+ * CAP_MS` env override, else the 45-min default) — never a bare magic number, so
2058
+ * it tracks the env-level terminal-cap override in lockstep with the silence
2059
+ * backstop. (Both this and `staleWorkerTtlMs` call `resolveInflightTerminalCapMs()`
2060
+ * with NO arg, matching the watcher's OWN effective value here: the gateway does
2061
+ * not pass a config-file `inflightTerminalCapMs` to `startSubagentWatcher`, so
2062
+ * there is no config-file override to thread through — env + default is the
2063
+ * complete override surface on this path.)
2064
+ */
2065
+ const WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE = 4
2039
2066
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2040
2067
 
2041
2068
  /**
@@ -3104,6 +3131,27 @@ type CurrentTurn = {
3104
3131
  // false ONLY at turn start, mirroring `activityEverOpened`'s sticky-true
3105
3132
  // contract.
3106
3133
  finalAnswerEverDelivered: boolean
3134
+ // 2026-07 double-reply-on-DM fix (Part 2 — race backstop). Set true
3135
+ // SYNCHRONOUSLY at turn-flush FIRE time (before the ~500 ms async send and
3136
+ // before `flushedTurnSupersede.record`) when the flush delivers a SUBSTANTIVE
3137
+ // (≥`FLUSH_SUBSTANTIVE_MIN_CHARS`) terminal answer, and also set when a
3138
+ // substantive `reply` sends. It persists on the ended turn in
3139
+ // `recentTurnsById`, so a LATE reply landing in the flush's post-fire
3140
+ // pre-record race window (where `flushedTurnSupersede` finds no record to
3141
+ // delete yet) resolves this turn via the unified owner resolver, sees the
3142
+ // latch already set, and suppresses itself — closing the residual window Part
3143
+ // 1's supersede cannot reach. Scoped to the substantive floor so an interim
3144
+ // sub-floor ack NEITHER sets nor trips it. Reset false at turn start.
3145
+ answerDelivered: boolean
3146
+ // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3147
+ // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3148
+ // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
3149
+ // authority (it drives message deletion), so `resolveReplyOwnerTurn` only
3150
+ // honours a latest-ended turn whose `endedAt` is within the supersede TTL —
3151
+ // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3152
+ // a NEWER turn sitting at the registry tail and delete that newer turn's legit
3153
+ // answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
3154
+ endedAt: number | null
3107
3155
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3108
3156
  // this turn that landed with `disable_notification: false` (a real
3109
3157
  // device ping). The conversational-pacing contract
@@ -3608,6 +3656,55 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
3608
3656
  return latest
3609
3657
  }
3610
3658
 
3659
+ /**
3660
+ * 2026-07 double-reply-on-DM fix (Part 1). Resolve the turn that OWNS a landing
3661
+ * reply using the SAME full chain the thread-router uses, so the supersede
3662
+ * resolver can never again diverge from routing (the exact bug: supersede
3663
+ * omitted the quoted-message and latest-ended recoveries, so a DM late reply —
3664
+ * no live turn, no `origin_turn_id` — resolved to a null owner and its flush
3665
+ * message was never superseded → duplicate).
3666
+ *
3667
+ * Precedence (first non-null wins), delegated to the pure
3668
+ * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
3669
+ * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
3670
+ * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
3671
+ * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
3672
+ * 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
3673
+ * Returns the CurrentTurn for the winning id (so callers can read its
3674
+ * `answerDelivered` latch), or null when every lookup missed.
3675
+ */
3676
+ function resolveReplyOwnerTurn(
3677
+ liveTurn: CurrentTurn | null,
3678
+ chatId: string,
3679
+ args: Record<string, unknown>,
3680
+ ): CurrentTurn | null {
3681
+ const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
3682
+ const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
3683
+ const latestEnded = findLatestEndedTurnForChat(chatId)
3684
+ const byId = new Map<string, CurrentTurn>()
3685
+ // Populate lowest-precedence first so a higher tier's turn wins the id slot
3686
+ // when two lookups resolve the same turn (they carry the same turnId anyway).
3687
+ for (const t of [latestEnded, quoted, origin, liveTurn]) {
3688
+ if (t != null) byId.set(t.turnId, t)
3689
+ }
3690
+ // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
3691
+ // latest-ended turn can't inherit deletion authority over a newer turn's flush
3692
+ // record. `endedAt` is null only for a turn still resolvable but not yet ended
3693
+ // (not a supersede risk); leave the age unset then (unbounded) rather than
3694
+ // fabricate one.
3695
+ const latestEndedAgeMs =
3696
+ latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
3697
+ const winnerId = resolveReplyOwnerTurnId({
3698
+ liveTurnId: liveTurn?.turnId ?? null,
3699
+ originTurnId: origin?.turnId ?? null,
3700
+ quotedTurnId: quoted?.turnId ?? null,
3701
+ latestEndedTurnId: latestEnded?.turnId ?? null,
3702
+ latestEndedAgeMs,
3703
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
3704
+ })
3705
+ return winnerId != null ? (byId.get(winnerId) ?? null) : null
3706
+ }
3707
+
3611
3708
  /**
3612
3709
  * Resolve the answer-reply thread AND emit `reply-route` telemetry. The
3613
3710
  * 2026-06-05 triage showed reply routing was the blind spot: `reply: invoked`
@@ -4842,6 +4939,12 @@ function endCurrentTurnAtomic(
4842
4939
  // the turn got), plus a DEGRADED warning when the turn did tool work but the
4843
4940
  // live feed never opened because its sends failed (the resume-400 signature).
4844
4941
  const turnEndedAt = Date.now()
4942
+ // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
4943
+ // `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
4944
+ // supersede TTL (a stale latest-ended turn must not inherit deletion
4945
+ // authority over a newer turn's flush record). Set once; idempotent on the
4946
+ // deferRecord flush path (which calls this synchronously before its send).
4947
+ turn.endedAt = turnEndedAt
4845
4948
  process.stderr.write(
4846
4949
  `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
4847
4950
  )
@@ -12925,17 +13028,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12925
13028
  // so a fresh turn's answer is never clobbered.
12926
13029
  {
12927
13030
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12928
- // Resolve the turnId this reply belongs to by IDENTITY, not just the live
12929
- // `currentTurn`. A late reply lands with `currentTurn == null` (silence poke
12930
- // cleared it — Bug D) or with a transiently-cleared currentTurn while its own
12931
- // turn is really still the owner (LOW-1); in both cases the turn is still
12932
- // resolvable from the `origin_turn_id` nonce the model echoes back (Tier 2 —
12933
- // the same last-known-turn resolver the chat-routing / obligation code uses).
12934
- // Passing this resolved turnId means supersede matches the flushed record by
12935
- // identity instead of falling back to a null-liveTurnId branch that would
12936
- // otherwise be free to delete a DIFFERENT turn's legitimate message.
12937
- const resolvedTurnId =
12938
- turn?.turnId ?? findTurnByOriginId(args.origin_turn_id as string | undefined)?.turnId ?? null
13031
+ // 2026-07 double-reply-on-DM fix (Part 1) resolve the turn this reply
13032
+ // belongs to by IDENTITY, via the SAME full chain the thread-router uses
13033
+ // (`resolveReplyOwnerTurn`): live `currentTurn`, then the model-echoed
13034
+ // `origin_turn_id`, then the framework-owned quoted message id, then the
13035
+ // chat's most-recently-ended turn. The prior chain stopped at
13036
+ // `currentTurn ?? findTurnByOriginId`, so a DM late reply `currentTurn`
13037
+ // nulled by the flush's synthetic turn_end AND no `origin_turn_id` (a
13038
+ // supergroup-only field) resolved to a null owner. `decideSupersede`
13039
+ // deliberately never lets a null live turn supersede a turnId-bearing flush
13040
+ // record, so message A survived AND the reply shipped message B (the exact
13041
+ // double-send). The quoted / latest-ended recoveries are precisely what the
13042
+ // router already did for the same reply, so unifying here makes the two
13043
+ // resolvers agree and the late-reply supersede fires by identity.
13044
+ const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
13045
+ const resolvedTurnId = ownerTurn?.turnId ?? null
12939
13046
  const decision = flushedTurnSupersede.take(
12940
13047
  chat_id,
12941
13048
  replyThreadId,
@@ -12952,6 +13059,46 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12952
13059
  { chat_id, verb: 'reply.supersedeFlushed' },
12953
13060
  )
12954
13061
  }
13062
+ } else {
13063
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
13064
+ // Supersede found no record. Either there was no flush (normal reply), or
13065
+ // the flush FIRED but has not yet recorded its message ids (the residual
13066
+ // pre-record race Part 1's supersede cannot reach). The flush sets
13067
+ // `answerDelivered = true` synchronously at fire time (before its async
13068
+ // send AND before `record`), and it persists on the ended turn — so when
13069
+ // this LATE, substantive reply resolves its owner turn and sees the latch
13070
+ // already set, the flush's message A is already on its way out and this
13071
+ // reply would ship a duplicate. Suppress it. Scoped to the substantive
13072
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
13073
+ // interim sub-floor ack, a chunked multi-part answer, or a legitimate
13074
+ // second in-turn substantive reply (live `currentTurn`) is never
13075
+ // suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
13076
+ // the `reply` path (no `done`); pass the model's original notification
13077
+ // intent to mirror the #2533 decoupling call shape.
13078
+ const replySubstantive = isSubstantiveFinalReply({
13079
+ text: rawText,
13080
+ disableNotification: args.disable_notification === true,
13081
+ })
13082
+ const suppressByLatch = decideAnswerLatchSuppression({
13083
+ superseded: false,
13084
+ replySubstantive,
13085
+ isLateReply: turn == null,
13086
+ ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
13087
+ })
13088
+ if (suppressByLatch) {
13089
+ process.stderr.write(
13090
+ `telegram gateway: reply: suppressed by answer-delivered latch ` +
13091
+ `(flush already delivered this turn's answer) chatId=${chat_id} ` +
13092
+ `ownerTurnId=${JSON.stringify(resolvedTurnId)}\n`,
13093
+ )
13094
+ return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
13095
+ }
13096
+ // A substantive answer is going out via this reply — set the latch on its
13097
+ // owner turn so a later bridge-replayed / reworded duplicate of the same
13098
+ // answer is caught by the branch above.
13099
+ if (replySubstantive && ownerTurn != null) {
13100
+ ownerTurn.answerDelivered = true
13101
+ }
12955
13102
  }
12956
13103
  }
12957
13104
 
@@ -16880,6 +17027,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16880
17027
  finalAnswerSubstantive: false,
16881
17028
  // Sticky latch — reset ONLY here (turn start), never by reopen.
16882
17029
  finalAnswerEverDelivered: false,
17030
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
17031
+ // latch, reset at turn start alongside the other answer flags.
17032
+ answerDelivered: false,
17033
+ // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
17034
+ endedAt: null,
16883
17035
  firstPingAt: null,
16884
17036
  // Notification ownership (R8 / PR-2): no slot claimed yet, so the
16885
17037
  // "claimer was substantive" flag starts false. Set atomically with
@@ -18113,6 +18265,20 @@ function handleSessionEvent(ev: SessionEvent): void {
18113
18265
  // the silent-end re-prompt. (Belt-and-braces, like the set above —
18114
18266
  // this branch returns before any further tool_label can arrive.)
18115
18267
  turn.finalAnswerSubstantive = true
18268
+ // 2026-07 double-reply-on-DM fix (Part 2) — arm the answer-delivered
18269
+ // race latch NOW, synchronously, BEFORE the ~500 ms async send below and
18270
+ // BEFORE `flushedTurnSupersede.record`. A late reply that lands in the
18271
+ // post-fire pre-record window resolves this turn (via the unified owner
18272
+ // resolver, reading the atom preserved in `recentTurnsById`) and
18273
+ // suppresses itself against this latch — closing the residual race Part
18274
+ // 1's supersede cannot reach. Scoped to a SUBSTANTIVE terminal answer
18275
+ // (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
18276
+ // recognise a real answer) so a short flush never latches against a
18277
+ // legitimate substantive reply. `capturedText` here is the selected,
18278
+ // normalized flush delivery text.
18279
+ if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
18280
+ turn.answerDelivered = true
18281
+ }
18116
18282
 
18117
18283
  // #654 deterministic double-message fix. Hand off the pinned
18118
18284
  // progress card BEFORE state reset so the driver doesn't keep
@@ -18352,6 +18518,22 @@ function handleSessionEvent(ev: SessionEvent): void {
18352
18518
  } catch (err) {
18353
18519
  sendThrew = true
18354
18520
  process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
18521
+ // 2026-07 double-reply-on-DM fix (F1) — the flush armed
18522
+ // `answerDelivered` synchronously at FIRE time, but the send just
18523
+ // FAILED. When nothing was delivered the supersede record was NOT
18524
+ // written (gated on `sentIds.length > 0` above), so Part 1 can never
18525
+ // fire for this turn — leaving the latch armed would make a genuine
18526
+ // late reply suppress itself and the user would get ZERO messages
18527
+ // (silent answer loss; on main that path still delivers the reply).
18528
+ // Reset the latch so the late reply is NOT suppressed. On a PARTIAL
18529
+ // send (sentIds > 0) the record WAS written, so Part 1's supersede
18530
+ // deletes the partial message A and the reply delivers cleanly —
18531
+ // resetting here is harmless in that case too.
18532
+ // FOLLOW-UP (coordinator to file an issue): a reply suppressed
18533
+ // synchronously DURING the in-flight send that then fails is not
18534
+ // fully closable with a boolean latch — a residual micro-window the
18535
+ // supersede+latch pair cannot eliminate. Not addressed in this PR.
18536
+ turn.answerDelivered = false
18355
18537
  // #1713: backstop send failed — finalize as error so the
18356
18538
  // turn ends cleanly with 😱 rather than leaving it open.
18357
18539
  if (backstopCtrl) backstopCtrl.finalize('error')
@@ -30102,7 +30284,16 @@ void (async () => {
30102
30284
  // or the turn ended while it kept running — extended autonomous
30103
30285
  // work) is surfaced via the worker feed instead of vanishing.
30104
30286
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
30105
- workerActivityFeed?.stop()
30287
+ // Boot/reconnect purge (Ken, PR #3239 review): every row in the
30288
+ // OUTGOING feed is a dead child sub-agent, and its `wk:group:` pins
30289
+ // would otherwise be orphaned — the replacement feed is empty and
30290
+ // never knew those groups, so it can never unpin them, and no full-
30291
+ // boot pin sweep runs on a bare bridge reconnect. Reconcile the old
30292
+ // feed to empty and release all its group pins BEFORE stopping it.
30293
+ if (workerActivityFeed != null) {
30294
+ workerActivityFeed.purgeAllOnBoot()
30295
+ workerActivityFeed.stop()
30296
+ }
30106
30297
  workerActivityFeed = createWorkerActivityFeed({
30107
30298
  // #2669: worker-feed body is raw GFM markdown — send via rich.
30108
30299
  bot: {
@@ -30167,6 +30358,12 @@ void (async () => {
30167
30358
  // force-collapsing a row the terminal signals somehow never
30168
30359
  // removed.
30169
30360
  staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
30361
+ // ABSOLUTE row-lifetime cap — anchored to a row's creation, immune
30362
+ // to the `lastUpdateAt` reset that lets an immortal-but-updating
30363
+ // row dodge `staleWorkerTtlMs` forever (Carrie 5h zombie pin).
30364
+ // Derived from the same terminal cap so it tracks operator
30365
+ // overrides; 4× → ~3h at the 45-min default.
30366
+ absoluteRowLifetimeCapMs: resolveInflightTerminalCapMs() * WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE,
30170
30367
  // #3207 review: GROUP-level status pin. Workers now coalesce into
30171
30368
  // ONE shared message, so the pin must follow the GROUP lifecycle,
30172
30369
  // not a single worker's — otherwise a sibling's finish unpins a