switchroom 0.19.5 → 0.19.7
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 +156 -13
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/gateway.ts +36 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +91 -12
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +40 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +221 -0
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +435 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +142 -5
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +165 -0
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.19.
|
|
2123
|
+
var VERSION = "0.19.7", COMMIT_SHA = "2ddec4ae";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26663
26663
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26664
26664
|
|
|
26665
26665
|
// src/build-info.ts
|
|
26666
|
-
var VERSION = "0.19.
|
|
26666
|
+
var VERSION = "0.19.7";
|
|
26667
26667
|
|
|
26668
26668
|
// src/cli/resolve-version.ts
|
|
26669
26669
|
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.19.
|
|
4
|
+
"version": "0.19.7",
|
|
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": {
|
|
@@ -40426,7 +40426,7 @@ function decideSupersede(record, args) {
|
|
|
40426
40426
|
if (!sameTurn) {
|
|
40427
40427
|
return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
|
|
40428
40428
|
}
|
|
40429
|
-
if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
|
|
40429
|
+
if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
|
|
40430
40430
|
return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
|
|
40431
40431
|
}
|
|
40432
40432
|
return {
|
|
@@ -40469,6 +40469,7 @@ class FlushedTurnSupersedeRegistry {
|
|
|
40469
40469
|
return decideSupersede(rec, {
|
|
40470
40470
|
liveTurnId: args.liveTurnId,
|
|
40471
40471
|
replyText: args.replyText,
|
|
40472
|
+
positiveAttribution: args.positiveAttribution,
|
|
40472
40473
|
now: args.now,
|
|
40473
40474
|
ttlMs: this.ttlMs
|
|
40474
40475
|
});
|
|
@@ -73954,7 +73955,7 @@ function decideSupersede2(record, args) {
|
|
|
73954
73955
|
if (!sameTurn) {
|
|
73955
73956
|
return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
|
|
73956
73957
|
}
|
|
73957
|
-
if (args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
|
|
73958
|
+
if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
|
|
73958
73959
|
return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
|
|
73959
73960
|
}
|
|
73960
73961
|
return {
|
|
@@ -74004,6 +74005,7 @@ class FlushedTurnSupersedeRegistry2 {
|
|
|
74004
74005
|
return decideSupersede2(rec, {
|
|
74005
74006
|
liveTurnId: args.liveTurnId,
|
|
74006
74007
|
replyText: args.replyText,
|
|
74008
|
+
positiveAttribution: args.positiveAttribution,
|
|
74007
74009
|
now: args.now,
|
|
74008
74010
|
ttlMs: this.ttlMs
|
|
74009
74011
|
});
|
|
@@ -74583,6 +74585,7 @@ async function sendReply(deps, req) {
|
|
|
74583
74585
|
resolveAnswerThreadWithLog,
|
|
74584
74586
|
resolveThreadId,
|
|
74585
74587
|
getLatestInboundMessageId: getLatestInboundMessageId2,
|
|
74588
|
+
getLastSubagentHandbackAt,
|
|
74586
74589
|
recordOutbound: recordOutbound2,
|
|
74587
74590
|
emissionAuthorityFor,
|
|
74588
74591
|
clearActivitySummary,
|
|
@@ -74656,9 +74659,15 @@ async function sendReply(deps, req) {
|
|
|
74656
74659
|
let supersedeFlushIds = [];
|
|
74657
74660
|
{
|
|
74658
74661
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
74659
|
-
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args);
|
|
74662
|
+
const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
|
|
74660
74663
|
const resolvedTurnId = ownerTurn?.turnId ?? null;
|
|
74661
|
-
const
|
|
74664
|
+
const ownerEndedAt = ownerTurn?.endedAt ?? null;
|
|
74665
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
|
|
74666
|
+
const handbackAt = getLastSubagentHandbackAt(chat_id);
|
|
74667
|
+
const now = Date.now();
|
|
74668
|
+
const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
|
|
74669
|
+
const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
|
|
74670
|
+
const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
|
|
74662
74671
|
if (decision.supersede) {
|
|
74663
74672
|
process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
|
|
74664
74673
|
`);
|
|
@@ -78142,8 +78151,61 @@ function latestEndedAccepted(candidates) {
|
|
|
78142
78151
|
return true;
|
|
78143
78152
|
return age <= ttl;
|
|
78144
78153
|
}
|
|
78154
|
+
function resolveReplyOwnerTier(candidates) {
|
|
78155
|
+
if (candidates.liveTurnId != null)
|
|
78156
|
+
return "live";
|
|
78157
|
+
if (candidates.originTurnId != null)
|
|
78158
|
+
return "origin";
|
|
78159
|
+
if (candidates.quotedTurnId != null)
|
|
78160
|
+
return "quoted";
|
|
78161
|
+
if (latestEndedAccepted(candidates))
|
|
78162
|
+
return "latest-ended";
|
|
78163
|
+
return "none";
|
|
78164
|
+
}
|
|
78145
78165
|
function resolveReplyOwnerTurnId(candidates) {
|
|
78146
|
-
|
|
78166
|
+
switch (resolveReplyOwnerTier(candidates)) {
|
|
78167
|
+
case "live":
|
|
78168
|
+
return candidates.liveTurnId;
|
|
78169
|
+
case "origin":
|
|
78170
|
+
return candidates.originTurnId;
|
|
78171
|
+
case "quoted":
|
|
78172
|
+
return candidates.quotedTurnId;
|
|
78173
|
+
case "latest-ended":
|
|
78174
|
+
return candidates.latestEndedTurnId;
|
|
78175
|
+
case "none":
|
|
78176
|
+
return null;
|
|
78177
|
+
}
|
|
78178
|
+
}
|
|
78179
|
+
|
|
78180
|
+
// gateway/subagent-handback-marker.ts
|
|
78181
|
+
var MAIN_THREAD_KEY = "<main>";
|
|
78182
|
+
|
|
78183
|
+
class SubagentHandbackMarker {
|
|
78184
|
+
byChat = new Map;
|
|
78185
|
+
threadKey(threadId) {
|
|
78186
|
+
return threadId == null ? MAIN_THREAD_KEY : String(threadId);
|
|
78187
|
+
}
|
|
78188
|
+
record(chatId, threadId, now) {
|
|
78189
|
+
let inner = this.byChat.get(chatId);
|
|
78190
|
+
if (inner == null) {
|
|
78191
|
+
inner = new Map;
|
|
78192
|
+
this.byChat.set(chatId, inner);
|
|
78193
|
+
}
|
|
78194
|
+
inner.set(this.threadKey(threadId), now);
|
|
78195
|
+
}
|
|
78196
|
+
lastAt(chatId, threadId) {
|
|
78197
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
78198
|
+
}
|
|
78199
|
+
lastAtInChat(chatId) {
|
|
78200
|
+
const inner = this.byChat.get(chatId);
|
|
78201
|
+
if (inner == null || inner.size === 0)
|
|
78202
|
+
return null;
|
|
78203
|
+
let max = -Infinity;
|
|
78204
|
+
for (const ts of inner.values())
|
|
78205
|
+
if (ts > max)
|
|
78206
|
+
max = ts;
|
|
78207
|
+
return max === -Infinity ? null : max;
|
|
78208
|
+
}
|
|
78147
78209
|
}
|
|
78148
78210
|
|
|
78149
78211
|
// answer-ready-flush.ts
|
|
@@ -84622,6 +84684,76 @@ function buildDiffPreviewCard(input) {
|
|
|
84622
84684
|
return { text: text4, reply_markup: kb };
|
|
84623
84685
|
}
|
|
84624
84686
|
|
|
84687
|
+
// gateway/subagent-handback-marker.ts
|
|
84688
|
+
var INBOUND_SOURCE_CLASSIFICATION = {
|
|
84689
|
+
subagent_handback: { decoupledCompletion: true },
|
|
84690
|
+
cron: { decoupledCompletion: false },
|
|
84691
|
+
reaction: { decoupledCompletion: false },
|
|
84692
|
+
subagent_progress: { decoupledCompletion: false },
|
|
84693
|
+
resume_interrupted: { decoupledCompletion: false },
|
|
84694
|
+
resume_deferred: { decoupledCompletion: false },
|
|
84695
|
+
resume_watchdog_timeout: { decoupledCompletion: false },
|
|
84696
|
+
vault_grant_approved: { decoupledCompletion: false },
|
|
84697
|
+
vault_grant_denied: { decoupledCompletion: false },
|
|
84698
|
+
vault_grant_timeout: { decoupledCompletion: false },
|
|
84699
|
+
vault_save_completed: { decoupledCompletion: false },
|
|
84700
|
+
vault_save_discarded: { decoupledCompletion: false },
|
|
84701
|
+
vault_save_failed: { decoupledCompletion: false },
|
|
84702
|
+
vault_save_timeout: { decoupledCompletion: false },
|
|
84703
|
+
secret_provided: { decoupledCompletion: false },
|
|
84704
|
+
secret_declined: { decoupledCompletion: false },
|
|
84705
|
+
secret_provide_failed: { decoupledCompletion: false },
|
|
84706
|
+
secret_request_timeout: { decoupledCompletion: false },
|
|
84707
|
+
mental_model_propose_timeout: { decoupledCompletion: false },
|
|
84708
|
+
bridge_dead_restart: { decoupledCompletion: false },
|
|
84709
|
+
obligation_represent: { decoupledCompletion: false },
|
|
84710
|
+
missed_approval_retry: { decoupledCompletion: false },
|
|
84711
|
+
skill_proposal_apply: { decoupledCompletion: false },
|
|
84712
|
+
warmup: { decoupledCompletion: false },
|
|
84713
|
+
mental_model_proposal_applied: { decoupledCompletion: false },
|
|
84714
|
+
mental_model_proposal_denied: { decoupledCompletion: false },
|
|
84715
|
+
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
84716
|
+
webhook: { decoupledCompletion: false },
|
|
84717
|
+
linear: { decoupledCompletion: false }
|
|
84718
|
+
};
|
|
84719
|
+
function stampsHandbackMarker(source) {
|
|
84720
|
+
if (source == null)
|
|
84721
|
+
return false;
|
|
84722
|
+
const known = INBOUND_SOURCE_CLASSIFICATION[source];
|
|
84723
|
+
if (known == null)
|
|
84724
|
+
return true;
|
|
84725
|
+
return known.decoupledCompletion;
|
|
84726
|
+
}
|
|
84727
|
+
var MAIN_THREAD_KEY2 = "<main>";
|
|
84728
|
+
|
|
84729
|
+
class SubagentHandbackMarker2 {
|
|
84730
|
+
byChat = new Map;
|
|
84731
|
+
threadKey(threadId) {
|
|
84732
|
+
return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
|
|
84733
|
+
}
|
|
84734
|
+
record(chatId, threadId, now) {
|
|
84735
|
+
let inner = this.byChat.get(chatId);
|
|
84736
|
+
if (inner == null) {
|
|
84737
|
+
inner = new Map;
|
|
84738
|
+
this.byChat.set(chatId, inner);
|
|
84739
|
+
}
|
|
84740
|
+
inner.set(this.threadKey(threadId), now);
|
|
84741
|
+
}
|
|
84742
|
+
lastAt(chatId, threadId) {
|
|
84743
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
84744
|
+
}
|
|
84745
|
+
lastAtInChat(chatId) {
|
|
84746
|
+
const inner = this.byChat.get(chatId);
|
|
84747
|
+
if (inner == null || inner.size === 0)
|
|
84748
|
+
return null;
|
|
84749
|
+
let max = -Infinity;
|
|
84750
|
+
for (const ts of inner.values())
|
|
84751
|
+
if (ts > max)
|
|
84752
|
+
max = ts;
|
|
84753
|
+
return max === -Infinity ? null : max;
|
|
84754
|
+
}
|
|
84755
|
+
}
|
|
84756
|
+
|
|
84625
84757
|
// gateway/pending-inbound-buffer.ts
|
|
84626
84758
|
var DEFAULT_PENDING_INBOUND_CAP = 32;
|
|
84627
84759
|
function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
@@ -84736,6 +84868,11 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
84736
84868
|
}
|
|
84737
84869
|
}
|
|
84738
84870
|
q.push(msg);
|
|
84871
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
84872
|
+
try {
|
|
84873
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
|
|
84874
|
+
} catch {}
|
|
84875
|
+
}
|
|
84739
84876
|
spool?.put(agent, msg);
|
|
84740
84877
|
log(`pending-inbound-buffer: agent=${agent} buffered source=${msg.meta?.source ?? "-"} ` + `depth_after=${q.length} evicted=${evicted}
|
|
84741
84878
|
`);
|
|
@@ -93414,10 +93551,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
|
|
|
93414
93551
|
}
|
|
93415
93552
|
|
|
93416
93553
|
// ../src/build-info.ts
|
|
93417
|
-
var VERSION = "0.19.
|
|
93418
|
-
var COMMIT_SHA = "
|
|
93419
|
-
var COMMIT_DATE = "2026-07-
|
|
93420
|
-
var LATEST_PR =
|
|
93554
|
+
var VERSION = "0.19.7";
|
|
93555
|
+
var COMMIT_SHA = "2ddec4ae";
|
|
93556
|
+
var COMMIT_DATE = "2026-07-20T19:59:43Z";
|
|
93557
|
+
var LATEST_PR = 3478;
|
|
93421
93558
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93422
93559
|
|
|
93423
93560
|
// gateway/boot-version.ts
|
|
@@ -96335,6 +96472,8 @@ function cardDrainGate(turn, ea, run4) {
|
|
|
96335
96472
|
var RECENT_TURNS_MAX = 32;
|
|
96336
96473
|
var recentTurnsById = new Map;
|
|
96337
96474
|
var recentTurnIdBySourceMessageId = new Map;
|
|
96475
|
+
var subagentHandbackMarker = new SubagentHandbackMarker;
|
|
96476
|
+
var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
|
|
96338
96477
|
function rememberRecentTurn(turn) {
|
|
96339
96478
|
recentTurnsById.set(turn.turnId, turn);
|
|
96340
96479
|
if (turn.sourceMessageId != null) {
|
|
@@ -96401,15 +96540,17 @@ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
|
|
|
96401
96540
|
byId.set(t.turnId, t);
|
|
96402
96541
|
}
|
|
96403
96542
|
const latestEndedAgeMs = latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null;
|
|
96404
|
-
const
|
|
96543
|
+
const candidates = {
|
|
96405
96544
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
96406
96545
|
originTurnId: origin?.turnId ?? null,
|
|
96407
96546
|
quotedTurnId: quoted?.turnId ?? null,
|
|
96408
96547
|
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
96409
96548
|
latestEndedAgeMs,
|
|
96410
96549
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS
|
|
96411
|
-
}
|
|
96412
|
-
|
|
96550
|
+
};
|
|
96551
|
+
const tier = resolveReplyOwnerTier(candidates);
|
|
96552
|
+
const winnerId = resolveReplyOwnerTurnId(candidates);
|
|
96553
|
+
return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier };
|
|
96413
96554
|
}
|
|
96414
96555
|
function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
|
|
96415
96556
|
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
|
|
@@ -99121,7 +99262,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
99121
99262
|
evictNoticeByChat.set(key, nowMs3);
|
|
99122
99263
|
const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
|
|
99123
99264
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
|
|
99124
|
-
}
|
|
99265
|
+
},
|
|
99266
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
|
|
99125
99267
|
});
|
|
99126
99268
|
function agentHasInFlightBackgroundWork(now) {
|
|
99127
99269
|
if (countRunningWorkers() > 0)
|
|
@@ -100499,6 +100641,7 @@ function gatewaySendReplyDeps() {
|
|
|
100499
100641
|
resolveAnswerThreadWithLog,
|
|
100500
100642
|
resolveThreadId,
|
|
100501
100643
|
getLatestInboundMessageId,
|
|
100644
|
+
getLastSubagentHandbackAt,
|
|
100502
100645
|
recordOutbound,
|
|
100503
100646
|
emissionAuthorityFor,
|
|
100504
100647
|
clearActivitySummary,
|
|
@@ -181,7 +181,13 @@ export function flushedAnswerMatchesReply(flushedText: string, replyText: string
|
|
|
181
181
|
*/
|
|
182
182
|
export function decideSupersede(
|
|
183
183
|
record: FlushedTurnRecord | undefined,
|
|
184
|
-
args: {
|
|
184
|
+
args: {
|
|
185
|
+
liveTurnId: string | null
|
|
186
|
+
replyText?: string | null
|
|
187
|
+
positiveAttribution?: boolean
|
|
188
|
+
now: number
|
|
189
|
+
ttlMs?: number
|
|
190
|
+
},
|
|
185
191
|
): SupersedeDecision {
|
|
186
192
|
const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
|
|
187
193
|
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
@@ -195,10 +201,29 @@ export function decideSupersede(
|
|
|
195
201
|
if (!sameTurn) {
|
|
196
202
|
return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
|
|
197
203
|
}
|
|
198
|
-
// #3429 —
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
204
|
+
// #3429 content gate — but ONLY when the caller is UNSURE the reply is this
|
|
205
|
+
// turn's own answer. `positiveAttribution === true` means the gateway
|
|
206
|
+
// established the reply IS this turn's OWN late answer — either by a positive
|
|
207
|
+
// owner-resolution tier (live atom / `origin_turn_id` echo / quoted message
|
|
208
|
+
// id) OR because no background `subagent_handback` could own it (none enqueued
|
|
209
|
+
// for the chat after the flushed turn ended within the supersede TTL; see
|
|
210
|
+
// outbound-send-path). Then supersede the flushed provisional draft REGARDLESS
|
|
211
|
+
// of text, so a model that RE-WORDED the answer between the narration that
|
|
212
|
+
// flushed and the `reply` tool call still collapses to ONE message (the
|
|
213
|
+
// duplicate-reply regression #3429 introduced on the reworded same-turn path).
|
|
214
|
+
//
|
|
215
|
+
// The content gate remains when `positiveAttribution` is falsey — the
|
|
216
|
+
// ambiguous residual (latest-ended tier AND a handback in flight) where
|
|
217
|
+
// identity alone cannot tell the turn's own late reply from a background
|
|
218
|
+
// sub-agent handback that resolved this flush-delivered ended turn as its
|
|
219
|
+
// owner. There, genuinely different content is a handback and must send FRESH
|
|
220
|
+
// (both messages surface), never edit/delete the flushed answer. Legacy
|
|
221
|
+
// identity-only callers (no `replyText`) keep the pre-#3429 behaviour.
|
|
222
|
+
if (
|
|
223
|
+
!args.positiveAttribution &&
|
|
224
|
+
args.replyText != null &&
|
|
225
|
+
!flushedAnswerMatchesReply(record.text, args.replyText)
|
|
226
|
+
) {
|
|
202
227
|
return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
|
|
203
228
|
}
|
|
204
229
|
return {
|
|
@@ -326,12 +351,18 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
326
351
|
peek(
|
|
327
352
|
chatId: string,
|
|
328
353
|
threadId: number | undefined,
|
|
329
|
-
args: {
|
|
354
|
+
args: {
|
|
355
|
+
liveTurnId: string | null
|
|
356
|
+
replyText?: string | null
|
|
357
|
+
positiveAttribution?: boolean
|
|
358
|
+
now: number
|
|
359
|
+
},
|
|
330
360
|
): SupersedeDecision {
|
|
331
361
|
const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
|
|
332
362
|
return decideSupersede(rec, {
|
|
333
363
|
liveTurnId: args.liveTurnId,
|
|
334
364
|
replyText: args.replyText,
|
|
365
|
+
positiveAttribution: args.positiveAttribution,
|
|
335
366
|
now: args.now,
|
|
336
367
|
ttlMs: this.ttlMs,
|
|
337
368
|
})
|
|
@@ -345,7 +376,12 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
345
376
|
take(
|
|
346
377
|
chatId: string,
|
|
347
378
|
threadId: number | undefined,
|
|
348
|
-
args: {
|
|
379
|
+
args: {
|
|
380
|
+
liveTurnId: string | null
|
|
381
|
+
replyText?: string | null
|
|
382
|
+
positiveAttribution?: boolean
|
|
383
|
+
now: number
|
|
384
|
+
},
|
|
349
385
|
): SupersedeDecision {
|
|
350
386
|
const lane = makeKey(chatId, threadId)
|
|
351
387
|
const decision = this.peek(chatId, threadId, args)
|
|
@@ -485,8 +485,11 @@ import {
|
|
|
485
485
|
} from '../turn-flush-safety.js'
|
|
486
486
|
import {
|
|
487
487
|
resolveReplyOwnerTurnId,
|
|
488
|
+
resolveReplyOwnerTier,
|
|
489
|
+
type ReplyOwnerTier,
|
|
488
490
|
type AnswerDeliveredLatch,
|
|
489
491
|
} from '../reply-owner-resolve.js'
|
|
492
|
+
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
490
493
|
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
491
494
|
import {
|
|
492
495
|
AnswerReadyFlushController,
|
|
@@ -3885,6 +3888,19 @@ const recentTurnsById = new Map<string, CurrentTurn>()
|
|
|
3885
3888
|
// real message_id the framework stamped, never a model-asserted thread.
|
|
3886
3889
|
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
3887
3890
|
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
3891
|
+
|
|
3892
|
+
// fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
|
|
3893
|
+
// gateway-synthesized `subagent_handback` enqueue (logic in
|
|
3894
|
+
// subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
|
|
3895
|
+
// The record is thread-resolved (retains the originating topic), but the
|
|
3896
|
+
// content-gate READ is CHAT-WIDE (dup-audit MUST-FIX 2): the latest-ended owner
|
|
3897
|
+
// tier is chat-wide, so a thread-specific gate read was steerable by the reply's
|
|
3898
|
+
// own `message_thread_id` → silent edit-over. `lastAtInChat` makes the gate
|
|
3899
|
+
// un-steerable (any in-window handback in the chat keeps the content gate).
|
|
3900
|
+
const subagentHandbackMarker = new SubagentHandbackMarker()
|
|
3901
|
+
const getLastSubagentHandbackAt = (chatId: string): number | null =>
|
|
3902
|
+
subagentHandbackMarker.lastAtInChat(chatId)
|
|
3903
|
+
|
|
3888
3904
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
3889
3905
|
recentTurnsById.set(turn.turnId, turn)
|
|
3890
3906
|
if (turn.sourceMessageId != null) {
|
|
@@ -4009,7 +4025,7 @@ function resolveReplyOwnerTurn(
|
|
|
4009
4025
|
liveTurn: CurrentTurn | null,
|
|
4010
4026
|
chatId: string,
|
|
4011
4027
|
args: Record<string, unknown>,
|
|
4012
|
-
): CurrentTurn | null {
|
|
4028
|
+
): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
|
|
4013
4029
|
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
4014
4030
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
4015
4031
|
const latestEnded = findLatestEndedTurnForChat(chatId)
|
|
@@ -4026,15 +4042,23 @@ function resolveReplyOwnerTurn(
|
|
|
4026
4042
|
// fabricate one.
|
|
4027
4043
|
const latestEndedAgeMs =
|
|
4028
4044
|
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4029
|
-
const
|
|
4045
|
+
const candidates = {
|
|
4030
4046
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
4031
4047
|
originTurnId: origin?.turnId ?? null,
|
|
4032
4048
|
quotedTurnId: quoted?.turnId ?? null,
|
|
4033
4049
|
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
4034
4050
|
latestEndedAgeMs,
|
|
4035
4051
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4036
|
-
}
|
|
4037
|
-
|
|
4052
|
+
}
|
|
4053
|
+
// #3429 — the WINNING tier travels with the turn. A positive tier
|
|
4054
|
+
// (live/origin/quoted) means the reply is this turn's own answer and the
|
|
4055
|
+
// supersede fires regardless of text; the ambiguous `latest-ended` fallback
|
|
4056
|
+
// keeps the content gate (it cannot tell a late own-reply from an async
|
|
4057
|
+
// sub-agent handback). Both derive from the SAME candidates, so the id and the
|
|
4058
|
+
// tier can never disagree.
|
|
4059
|
+
const tier = resolveReplyOwnerTier(candidates)
|
|
4060
|
+
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4061
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
|
|
4038
4062
|
}
|
|
4039
4063
|
|
|
4040
4064
|
/**
|
|
@@ -9962,6 +9986,10 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9962
9986
|
{ chat_id: chat, verb: 'inbound-buffer-eviction' },
|
|
9963
9987
|
)
|
|
9964
9988
|
},
|
|
9989
|
+
// fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
|
|
9990
|
+
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9991
|
+
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9992
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
9965
9993
|
})
|
|
9966
9994
|
|
|
9967
9995
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -12567,6 +12595,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
|
12567
12595
|
resolveAnswerThreadWithLog,
|
|
12568
12596
|
resolveThreadId,
|
|
12569
12597
|
getLatestInboundMessageId,
|
|
12598
|
+
getLastSubagentHandbackAt,
|
|
12570
12599
|
recordOutbound,
|
|
12571
12600
|
emissionAuthorityFor,
|
|
12572
12601
|
clearActivitySummary,
|
|
@@ -24458,6 +24487,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24458
24487
|
// The drain only releases at an idle prompt (no active
|
|
24459
24488
|
// turn), so the handback always lands as a clean fresh
|
|
24460
24489
|
// turn and never races a turn-in-flight composer (#1556).
|
|
24490
|
+
// The handback marker is stamped inside pendingInboundBuffer.push
|
|
24491
|
+
// (the enqueue chokepoint) so BOTH this live synthesis push and
|
|
24492
|
+
// the boot-replay re-push populate it — see MUST-FIX 2.
|
|
24461
24493
|
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
|
|
24462
24494
|
process.stderr.write(
|
|
24463
24495
|
`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
|
|
@@ -56,9 +56,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
|
56
56
|
import {
|
|
57
57
|
decideSupersedeCorrection,
|
|
58
58
|
flushedAnswerMatchesReply,
|
|
59
|
+
DEFAULT_SUPERSEDE_TTL_MS,
|
|
59
60
|
type FlushedTurnSupersedeRegistry,
|
|
60
61
|
} from '../flushed-turn-supersede.js'
|
|
61
|
-
import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
|
|
62
|
+
import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
62
63
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
63
64
|
import {
|
|
64
65
|
mintVoiceOnDemandToken,
|
|
@@ -658,7 +659,7 @@ export interface SendReplyGatewayDeps {
|
|
|
658
659
|
assertSendable(f: string): void
|
|
659
660
|
statusKey(chatId: string, threadId?: number | null): string
|
|
660
661
|
streamKey(chatId: string, threadId?: number | null): string
|
|
661
|
-
resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): CurrentTurn | null
|
|
662
|
+
resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier }
|
|
662
663
|
findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
|
|
663
664
|
findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
|
|
664
665
|
resolveAnswerThreadWithLog(
|
|
@@ -671,6 +672,7 @@ export interface SendReplyGatewayDeps {
|
|
|
671
672
|
): number | undefined
|
|
672
673
|
resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
|
|
673
674
|
getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
|
|
675
|
+
getLastSubagentHandbackAt(chatId: string): number | null
|
|
674
676
|
recordOutbound(rec: {
|
|
675
677
|
chat_id: string
|
|
676
678
|
thread_id: number | null
|
|
@@ -735,7 +737,7 @@ export async function sendReply(
|
|
|
735
737
|
statusKey, streamKey,
|
|
736
738
|
resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
|
|
737
739
|
resolveAnswerThreadWithLog, resolveThreadId,
|
|
738
|
-
getLatestInboundMessageId, recordOutbound,
|
|
740
|
+
getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
|
|
739
741
|
emissionAuthorityFor, clearActivitySummary,
|
|
740
742
|
startTypingLoop, stopTypingLoop, logOutbound,
|
|
741
743
|
closeObligationOnSubstantiveReply, finalizeStatusReaction,
|
|
@@ -874,18 +876,95 @@ export async function sendReply(
|
|
|
874
876
|
// double-send). The quoted / latest-ended recoveries are precisely what the
|
|
875
877
|
// router already did for the same reply, so unifying here makes the two
|
|
876
878
|
// resolvers agree and the late-reply supersede fires by identity.
|
|
877
|
-
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
879
|
+
const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
878
880
|
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
879
|
-
// #3429 — pass the (normalized) reply text so the registry
|
|
880
|
-
// new-content gate: identity match + TTL alone also fits
|
|
881
|
-
// that merely resolved this flush-delivered ENDED turn as
|
|
882
|
-
// the latest-ended tier. Editing the flushed message
|
|
883
|
-
// handback's text
|
|
884
|
-
//
|
|
881
|
+
// #3429 — pass the (normalized) reply text so the registry CAN apply the
|
|
882
|
+
// new-content gate: identity match + TTL alone also fits a background
|
|
883
|
+
// sub-agent handback that merely resolved this flush-delivered ENDED turn as
|
|
884
|
+
// its owner via the latest-ended tier. Editing/deleting the flushed message
|
|
885
|
+
// for that handback's unrelated text is the #3429 silent client-side drop
|
|
886
|
+
// (msgs 10482/10486). But that gate ALSO declines the turn's OWN reworded
|
|
887
|
+
// late reply (model narrated → flushed → fired `reply` with a paraphrase),
|
|
888
|
+
// which is the dominant real duplicate (agent:marko 2026-07-20: 11/11
|
|
889
|
+
// declines were own-replies, turns #1177/#1182/#1201 double-sent). So we
|
|
890
|
+
// BYPASS the content gate ONLY when the reply is confidently the flushed
|
|
891
|
+
// turn's OWN answer. The primary, deterministic signal is the absence of a
|
|
892
|
+
// background sub-agent handback that could own this reply: the gateway
|
|
893
|
+
// records when it synthesizes a `subagent_handback` inbound per chat, and if
|
|
894
|
+
// NONE was enqueued for this chat AFTER the flushed turn ended within the
|
|
895
|
+
// supersede TTL, the reply is that turn's own answer → supersede regardless
|
|
896
|
+
// of a model rewording (closes the DM default-reply duplicate, where every
|
|
897
|
+
// own-reply resolves via the latest-ended tier).
|
|
898
|
+
//
|
|
899
|
+
// MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
|
|
900
|
+
// tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
|
|
901
|
+
// `args.origin_turn_id`), so a reply can STEER itself — pass
|
|
902
|
+
// `reply_to = <another ended turn's source msg>` and it resolves THAT turn via
|
|
903
|
+
// `quoted`. Marker-absence proves "own answer" ONLY for the framework-derived
|
|
904
|
+
// `latest-ended` tier; a model-steered `quoted`/`origin` attribution with no
|
|
905
|
+
// marker is NOT evidence of ownership, so bypassing the content gate there
|
|
906
|
+
// silently edits over a different ended turn's delivered answer (the #3429
|
|
907
|
+
// double-loss, executed by Fable 2026-07-21). The `replyIsOwnAnswer`
|
|
908
|
+
// computation below therefore restricts the bypass to `live` + `latest-ended`;
|
|
909
|
+
// `quoted`/`origin` ALWAYS traverse the content gate. See that comment.
|
|
910
|
+
//
|
|
911
|
+
// The content gate is therefore kept whenever a decoupled completion WAS
|
|
912
|
+
// enqueued in the window (on the latest-ended tier) OR the reply resolved via
|
|
913
|
+
// a model-steerable tier: the late reply might carry foreign content, so it
|
|
914
|
+
// sends fresh (two messages, #3429 preserved). Concurrency note: a case-A own
|
|
915
|
+
// reply coinciding with an unrelated background handback in the same ≤TTL
|
|
916
|
+
// window degrades to two messages — safe (never a silent drop/edit).
|
|
917
|
+
const ownerEndedAt = ownerTurn?.endedAt ?? null
|
|
918
|
+
// MUST-FIX 2 (dup-audit / Fable 2026-07-21) — key BOTH the supersede lane and
|
|
919
|
+
// the marker-gate read on the FRAMEWORK-resolved thread (the owner turn's
|
|
920
|
+
// `sessionThreadId` — the lane the flush recorded on), NOT the raw model arg
|
|
921
|
+
// `args.message_thread_id` (`replyThreadId`). The flush records on
|
|
922
|
+
// `turn.sessionThreadId`; the owner turn resolved here IS that turn, so its
|
|
923
|
+
// thread is where its record and its handback marker live. Reading the gate on
|
|
924
|
+
// the raw arg let a reply carry `message_thread_id=<other topic>` to dodge a
|
|
925
|
+
// handback marker stamped on the real topic → silent edit-over (the regression
|
|
926
|
+
// F2's raw-arg keying introduced). The owner turn's thread is not model-
|
|
927
|
+
// derived, so it cannot be steered. Falls back to the raw arg only when no
|
|
928
|
+
// owner turn resolved (no record to clobber on the collapse path).
|
|
929
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
|
|
930
|
+
// MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
|
|
931
|
+
// lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
|
|
932
|
+
// handback in topic A can supersede topic B's ended turn; a thread-keyed gate
|
|
933
|
+
// read (the F2 regression) let a reply dodge that handback by carrying a
|
|
934
|
+
// different `message_thread_id`. Chat-wide makes the gate un-steerable — any
|
|
935
|
+
// in-window handback in the chat keeps the content gate (accepting the F2
|
|
936
|
+
// visible-dup in the overlap window; a self-healing dup beats a silent loss).
|
|
937
|
+
// The supersede `take()` LANE below stays thread-resolved (`gateThreadId` =
|
|
938
|
+
// the owner turn's framework thread, not the raw arg), so a handback's
|
|
939
|
+
// correction only ever touches ITS OWN topic's record.
|
|
940
|
+
const handbackAt = getLastSubagentHandbackAt(chat_id)
|
|
941
|
+
const now = Date.now()
|
|
942
|
+
const handbackCouldOwnReply =
|
|
943
|
+
handbackAt != null &&
|
|
944
|
+
ownerEndedAt != null &&
|
|
945
|
+
handbackAt > ownerEndedAt &&
|
|
946
|
+
now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
|
|
947
|
+
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
|
|
948
|
+
// content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
|
|
949
|
+
// - `live` — the framework-owned live `currentTurn` (not model-derived,
|
|
950
|
+
// and `decideSupersede`'s same-turnId check bars it from an
|
|
951
|
+
// ended turn's record). Bypasses even a handback window.
|
|
952
|
+
// - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
|
|
953
|
+
// actually needs; bypass ONLY when no decoupled completion is
|
|
954
|
+
// in the window (marker-absence ⇒ own answer).
|
|
955
|
+
// The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
|
|
956
|
+
// (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
|
|
957
|
+
// a DIFFERENT ended turn's record — with marker-absence they used to bypass
|
|
958
|
+
// the content gate and silently edit-over that turn's delivered answer (the
|
|
959
|
+
// #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
|
|
960
|
+
// they always go through the content gate, so foreign content sends fresh and
|
|
961
|
+
// only a genuine same-answer reply collapses.
|
|
962
|
+
const replyIsOwnAnswer =
|
|
963
|
+
ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
|
|
885
964
|
const decision = flushedTurnSupersede.take(
|
|
886
965
|
chat_id,
|
|
887
|
-
|
|
888
|
-
{ liveTurnId: resolvedTurnId, replyText: text,
|
|
966
|
+
gateThreadId,
|
|
967
|
+
{ liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
|
|
889
968
|
)
|
|
890
969
|
if (decision.supersede) {
|
|
891
970
|
process.stderr.write(
|