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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +123 -16
- package/telegram-plugin/gateway/gateway.ts +210 -13
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/worker-activity-feed.ts +118 -10
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.18.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
40854
|
-
|
|
40855
|
-
|
|
40856
|
-
|
|
40857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
82914
|
-
var COMMIT_SHA = "
|
|
82915
|
-
var COMMIT_DATE = "2026-07-
|
|
82916
|
-
var LATEST_PR =
|
|
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
|
|
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
|
|
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
|
-
//
|
|
12929
|
-
//
|
|
12930
|
-
//
|
|
12931
|
-
//
|
|
12932
|
-
//
|
|
12933
|
-
//
|
|
12934
|
-
//
|
|
12935
|
-
//
|
|
12936
|
-
//
|
|
12937
|
-
|
|
12938
|
-
|
|
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
|
-
|
|
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
|