switchroom 0.13.20 → 0.13.22
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 +33 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +7 -6
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +87 -25
- package/telegram-plugin/gateway/disconnect-flush.ts +37 -0
- package/telegram-plugin/gateway/gateway.ts +100 -7
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +37 -4
- package/telegram-plugin/handoff-continuity.ts +8 -2
- package/telegram-plugin/recent-outbound-dedup.ts +51 -5
- package/telegram-plugin/runtime-metrics.ts +5 -1
- package/telegram-plugin/subagent-watcher.ts +25 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +114 -0
- package/telegram-plugin/tests/handoff-continuity.test.ts +15 -2
- package/telegram-plugin/tests/inbound-delivery-gate.test.ts +77 -4
- package/telegram-plugin/tests/recent-outbound-dedup.test.ts +72 -0
- package/telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts +152 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +72 -45
- package/vendor/hindsight-memory/scripts/recall.py +18 -2
|
@@ -3084,6 +3084,30 @@ silencePoke.startTimer({
|
|
|
3084
3084
|
emitRuntimeMetric(event)
|
|
3085
3085
|
},
|
|
3086
3086
|
onFrameworkFallback: async (ctx) => {
|
|
3087
|
+
// Late-fire short-circuit (2026-05-23 audit finding). The fallback
|
|
3088
|
+
// can race a clean turn-end: the model's actual reply lands inside
|
|
3089
|
+
// the silence window's final ~50ms, the canonical turn-end path
|
|
3090
|
+
// clears `activeTurnStartedAt` and nulls `currentTurn`, and then
|
|
3091
|
+
// this handler fires anyway. Without this check we emit a noisy
|
|
3092
|
+
// "still working…" ping to the user (right after they got their
|
|
3093
|
+
// real reply) AND a misleading "ended wedged turn ... currentTurn_
|
|
3094
|
+
// nulled=false drained_buffered=0/0" log line. The 7-day audit
|
|
3095
|
+
// showed this race accounts for ~90% of all framework_fallback log
|
|
3096
|
+
// events (124 of 138 `currentTurn_nulled=false` cases). Distinct
|
|
3097
|
+
// log line so observability still tracks the fact that the silence
|
|
3098
|
+
// crossed threshold; the wedge counter is no longer polluted.
|
|
3099
|
+
if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
|
|
3100
|
+
process.stderr.write(
|
|
3101
|
+
`telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
|
|
3102
|
+
`turn ended cleanly during silence window ` +
|
|
3103
|
+
`chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
|
|
3104
|
+
)
|
|
3105
|
+
// Tell silence-poke this chat-thread is finished so the next
|
|
3106
|
+
// arming doesn't carry stale state.
|
|
3107
|
+
silencePoke.endTurn(ctx.key)
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3087
3111
|
// Deterministic in-flight update status (klanker incident). If this
|
|
3088
3112
|
// gateway dispatched an update_apply that's still running, the
|
|
3089
3113
|
// recurring framework fallback carries hostd's REAL phase + elapsed
|
|
@@ -3579,6 +3603,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3579
3603
|
// scripts/check-plugin-references.mjs (TS2722).
|
|
3580
3604
|
progressDriver?.dispose?.({ preservePending: true })
|
|
3581
3605
|
},
|
|
3606
|
+
// When dangling activeTurnStartedAt keys were swept (setDone raced
|
|
3607
|
+
// disconnect), the module-scope `currentTurn` may also point at the
|
|
3608
|
+
// dead bridge's turn. Null it so the next inbound starts a fresh
|
|
3609
|
+
// turn instead of inheriting a ghost.
|
|
3610
|
+
onDanglingTurnsSwept: () => {
|
|
3611
|
+
if (currentTurn != null) {
|
|
3612
|
+
process.stderr.write(
|
|
3613
|
+
`telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)\n`,
|
|
3614
|
+
)
|
|
3615
|
+
currentTurn = null
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3582
3618
|
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
3583
3619
|
})
|
|
3584
3620
|
},
|
|
@@ -4227,7 +4263,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4227
4263
|
// late-replies with different content sail through.
|
|
4228
4264
|
{
|
|
4229
4265
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4230
|
-
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now())
|
|
4266
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4231
4267
|
if (dup != null) {
|
|
4232
4268
|
process.stderr.write(
|
|
4233
4269
|
`telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
|
|
@@ -4561,6 +4597,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4561
4597
|
threadId,
|
|
4562
4598
|
decision.mergedText,
|
|
4563
4599
|
Date.now(),
|
|
4600
|
+
turn?.registryKey ?? null,
|
|
4564
4601
|
)
|
|
4565
4602
|
|
|
4566
4603
|
silentAnchorEditDone = true
|
|
@@ -4885,7 +4922,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4885
4922
|
// calls with this same content within DEFAULT_DEDUP_TTL_MS will
|
|
4886
4923
|
// be suppressed.
|
|
4887
4924
|
if (sentIds.length > 0) {
|
|
4888
|
-
outboundDedup.record(chat_id, threadId, text, Date.now())
|
|
4925
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4889
4926
|
}
|
|
4890
4927
|
return { content: [{ type: 'text', text: result }] }
|
|
4891
4928
|
}
|
|
@@ -4896,6 +4933,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4896
4933
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
4897
4934
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
4898
4935
|
|
|
4936
|
+
// Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
|
|
4937
|
+
// uses the answer-stream / draft-stream path for multi-paragraph
|
|
4938
|
+
// replies — the model emits via stream_reply and the original
|
|
4939
|
+
// PR #1683 scrub site (executeReply) never sees the text. klanker's
|
|
4940
|
+
// 2026-05-24 log showed model output with em-dashes routed via
|
|
4941
|
+
// stream_reply done=true, materializing as sendMessage with no
|
|
4942
|
+
// scrub. Mirror the executeReply pattern here: scrub BEFORE the
|
|
4943
|
+
// outbound-dedup check (so retries see the scrubbed key) and
|
|
4944
|
+
// mutate args.text so all downstream consumers (the stream-
|
|
4945
|
+
// controller, dedup record, history record) see the scrubbed
|
|
4946
|
+
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
4947
|
+
{
|
|
4948
|
+
const scrub = scrubVoice(args.text as string)
|
|
4949
|
+
if (scrub.replaced > 0) {
|
|
4950
|
+
args.text = scrub.scrubbed
|
|
4951
|
+
emitRuntimeMetric({
|
|
4952
|
+
kind: 'voice_scrub_applied',
|
|
4953
|
+
chatKey: statusKey(args.chat_id as string, args.message_thread_id != null
|
|
4954
|
+
? Number(args.message_thread_id) : undefined),
|
|
4955
|
+
replaced: scrub.replaced,
|
|
4956
|
+
site: 'stream_reply',
|
|
4957
|
+
})
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4899
4961
|
// #546 dedup check: stream_reply done=true is the most-common
|
|
4900
4962
|
// retry shape — claude-code re-emits the final-text call when
|
|
4901
4963
|
// the previous bridge missed the ack. If turn-flush already sent
|
|
@@ -4906,7 +4968,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4906
4968
|
const sChatId = args.chat_id as string
|
|
4907
4969
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4908
4970
|
const sText = args.text as string
|
|
4909
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now())
|
|
4971
|
+
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
|
|
4910
4972
|
if (dup != null) {
|
|
4911
4973
|
process.stderr.write(
|
|
4912
4974
|
`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
|
|
@@ -5070,7 +5132,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5070
5132
|
if (args.done === true && result.messageId != null) {
|
|
5071
5133
|
const sChatId = args.chat_id as string
|
|
5072
5134
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
5073
|
-
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
|
|
5135
|
+
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
|
|
5074
5136
|
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
5075
5137
|
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
5076
5138
|
// that follows. Capture it so if this turn ends with a pending
|
|
@@ -6382,10 +6444,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6382
6444
|
// threadId come from the captured `turn` snapshot, stable for
|
|
6383
6445
|
// the lifetime of the stream.
|
|
6384
6446
|
checkDedup: (text: string) => {
|
|
6385
|
-
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null
|
|
6447
|
+
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
|
|
6386
6448
|
},
|
|
6387
6449
|
recordDedup: (text: string) => {
|
|
6388
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now())
|
|
6450
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
|
|
6389
6451
|
},
|
|
6390
6452
|
// #648 — write answer-stream materializations into the SQLite
|
|
6391
6453
|
// history buffer so get_recent_messages can surface them. Guard
|
|
@@ -6546,6 +6608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6546
6608
|
turn.sessionThreadId,
|
|
6547
6609
|
streamedFinalText,
|
|
6548
6610
|
Date.now(),
|
|
6611
|
+
turn.registryKey ?? null,
|
|
6549
6612
|
)
|
|
6550
6613
|
} catch { /* best-effort */ }
|
|
6551
6614
|
if (HISTORY_ENABLED) {
|
|
@@ -6715,11 +6778,31 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6715
6778
|
}
|
|
6716
6779
|
|
|
6717
6780
|
if (flushDecision.kind === 'flush') {
|
|
6718
|
-
|
|
6781
|
+
let capturedText = flushDecision.text
|
|
6719
6782
|
const backstopChatId = chatId
|
|
6720
6783
|
const backstopThreadId = threadId
|
|
6721
6784
|
const backstopCtrl = ctrl
|
|
6722
6785
|
|
|
6786
|
+
// Voice scrub (PR #1683 follow-up). Turn-flush is the path
|
|
6787
|
+
// that fires when the model emits raw transcript text WITHOUT
|
|
6788
|
+
// calling reply / stream_reply. That captured text bypasses
|
|
6789
|
+
// PR #1683's executeReply scrub site entirely and is delivered
|
|
6790
|
+
// via sendMessage / editMessageText directly. Scrub the
|
|
6791
|
+
// capturedText before markdownToHtml so em-dashes never reach
|
|
6792
|
+
// the wire. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
6793
|
+
{
|
|
6794
|
+
const scrub = scrubVoice(capturedText)
|
|
6795
|
+
if (scrub.replaced > 0) {
|
|
6796
|
+
capturedText = scrub.scrubbed
|
|
6797
|
+
emitRuntimeMetric({
|
|
6798
|
+
kind: 'voice_scrub_applied',
|
|
6799
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
6800
|
+
replaced: scrub.replaced,
|
|
6801
|
+
site: 'turn_flush',
|
|
6802
|
+
})
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6723
6806
|
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
6724
6807
|
// returns 'reply-called' otherwise). It legitimately delivers the
|
|
6725
6808
|
// model's terminal text as the answer, so the turn IS answered.
|
|
@@ -6911,6 +6994,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6911
6994
|
backstopThreadId,
|
|
6912
6995
|
capturedText,
|
|
6913
6996
|
Date.now(),
|
|
6997
|
+
currentTurn?.registryKey ?? null,
|
|
6914
6998
|
)
|
|
6915
6999
|
if (backstopCtrl) backstopCtrl.setDone()
|
|
6916
7000
|
// Unpin the card. completeTurn cleans up pinMgr's per-turn
|
|
@@ -8455,6 +8539,15 @@ async function handleInbound(
|
|
|
8455
8539
|
decideInboundDelivery({
|
|
8456
8540
|
turnInFlight: turnInFlightAtReceipt,
|
|
8457
8541
|
isSteering,
|
|
8542
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
8543
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
8544
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
8545
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
8546
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
8547
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
8548
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
8549
|
+
// the freshly-killed bridge.
|
|
8550
|
+
isInterrupt: interrupt.isInterrupt,
|
|
8458
8551
|
}) === 'buffer-until-idle'
|
|
8459
8552
|
) {
|
|
8460
8553
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
@@ -53,6 +53,27 @@
|
|
|
53
53
|
* mid-turn — that is the whole point of the steering feature (redirect
|
|
54
54
|
* the agent while it works). Steering messages keep immediate delivery.
|
|
55
55
|
* The wedge only ever affected the queued-mid-turn default path.
|
|
56
|
+
*
|
|
57
|
+
* ## Interrupt-marker is also exempt (2026-05-24 fix)
|
|
58
|
+
*
|
|
59
|
+
* An inbound prefixed with `!` invokes the interrupt path
|
|
60
|
+
* (`gateway.ts:handleInbound` parse + `tmux send-keys C-c` to the
|
|
61
|
+
* bridge). The SIGINT kills the in-flight turn at the SDK level — but
|
|
62
|
+
* the killed turn does NOT always emit `turn_complete`. Without that
|
|
63
|
+
* event, the turn-complete buffer-flush never fires, and the
|
|
64
|
+
* post-SIGINT inbound body (the `!` replacement instruction) rots in
|
|
65
|
+
* `pendingInboundBuffer` indefinitely.
|
|
66
|
+
*
|
|
67
|
+
* 2026-05-24 live UAT trace: user fires `! actually reply hello`,
|
|
68
|
+
* SIGINT delivered, killed turn never emits `turn_complete`, buffer
|
|
69
|
+
* stays full, user sees no response. The Phase-3 audit had this UAT
|
|
70
|
+
* `describe.skip`'d as "real interrupt-marker wedge or prompt-shape
|
|
71
|
+
* issue" — confirmed real.
|
|
72
|
+
*
|
|
73
|
+
* Resolution: bypass the gate for interrupt inbounds. The interrupt
|
|
74
|
+
* carve-out is a peer of `isSteering` — both are "intentional
|
|
75
|
+
* mid-turn delivery" cases. Caller passes the interrupt flag from the
|
|
76
|
+
* inbound parse; the gate returns `'deliver'` immediately.
|
|
56
77
|
*/
|
|
57
78
|
|
|
58
79
|
export interface InboundDeliveryGateInput {
|
|
@@ -63,6 +84,14 @@ export interface InboundDeliveryGateInput {
|
|
|
63
84
|
/** This inbound carried an explicit `/steer` (`/s`) prefix and is an
|
|
64
85
|
* intentional mid-turn redirect. */
|
|
65
86
|
isSteering: boolean
|
|
87
|
+
/** This inbound was parsed by `parseInterruptMarker` as a `!`-prefixed
|
|
88
|
+
* interrupt request. The gateway has already (or is about to) deliver
|
|
89
|
+
* the SIGINT to claude via tmux send-keys; the body of the message
|
|
90
|
+
* (post-`!`) is the user's replacement instruction. Without this
|
|
91
|
+
* carve-out, the body rots in pendingInboundBuffer because the
|
|
92
|
+
* SIGINT'd turn doesn't reliably emit turn_complete to drain the
|
|
93
|
+
* buffer. Optional + defaults false for backward compat. */
|
|
94
|
+
isInterrupt?: boolean
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
export type InboundDeliveryDecision =
|
|
@@ -73,13 +102,17 @@ export type InboundDeliveryDecision =
|
|
|
73
102
|
| 'buffer-until-idle'
|
|
74
103
|
|
|
75
104
|
/**
|
|
76
|
-
* Pure.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
105
|
+
* Pure. Defers delivery ONLY when a turn is in flight AND this inbound
|
|
106
|
+
* is neither steering nor an interrupt. Idle → deliver. Steering → deliver
|
|
107
|
+
* (intentional mid-turn redirect). Interrupt → deliver (the `!`
|
|
108
|
+
* carve-out — see header doc; the killed turn may never drain the
|
|
109
|
+
* buffer, so we must not buffer in the first place).
|
|
79
110
|
*/
|
|
80
111
|
export function decideInboundDelivery(
|
|
81
112
|
input: InboundDeliveryGateInput,
|
|
82
113
|
): InboundDeliveryDecision {
|
|
83
|
-
if (input.
|
|
114
|
+
if (input.isSteering) return 'deliver'
|
|
115
|
+
if (input.isInterrupt === true) return 'deliver'
|
|
116
|
+
if (input.turnInFlight) return 'buffer-until-idle'
|
|
84
117
|
return 'deliver'
|
|
85
118
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (written by the summarizer Stop hook). On the FIRST assistant reply
|
|
6
6
|
* of the new session the plugin prepends a subtle one-liner:
|
|
7
7
|
*
|
|
8
|
-
* ↩️ Picked up where we left off
|
|
8
|
+
* ↩️ Picked up where we left off, <topic>
|
|
9
9
|
*
|
|
10
10
|
* The sidecar is consumed (read + deleted) so the line only fires once.
|
|
11
11
|
* All helpers here are filesystem-only or env-only — no Telegram side
|
|
@@ -175,7 +175,13 @@ export function formatHandoffLine(
|
|
|
175
175
|
topic: string,
|
|
176
176
|
format: HandoffFormat,
|
|
177
177
|
): string {
|
|
178
|
-
|
|
178
|
+
// Comma instead of em-dash: the framework-emitted prefix is
|
|
179
|
+
// concatenated AFTER scrubVoice runs on the model body (gateway.ts
|
|
180
|
+
// executeReply), so any em-dash here bypasses the v0.13.20 voice
|
|
181
|
+
// scrub. Replacing at the template source is one mechanical change
|
|
182
|
+
// that closes the dominant residual em-dash leak (16 of 17 dashed
|
|
183
|
+
// messages on test-harness were this template per 2026-05-24 audit).
|
|
184
|
+
const prefix = "↩️ Picked up where we left off, ";
|
|
179
185
|
if (format === "html") {
|
|
180
186
|
return `<i>${prefix}${escapeHtml(topic)}</i>\n\n`;
|
|
181
187
|
}
|
|
@@ -57,6 +57,16 @@ interface DedupEntry {
|
|
|
57
57
|
/** First 80 chars of the original (un-normalized) text — for
|
|
58
58
|
* operator-facing log lines that show what got deduped. */
|
|
59
59
|
preview: string
|
|
60
|
+
/** The `currentTurn.registryKey` at record time, or `null` if the
|
|
61
|
+
* recording site had no turn context. Threaded through so check()
|
|
62
|
+
* can distinguish within-turn retries (#546 bug class — keep
|
|
63
|
+
* protecting) from cross-turn coincidences (2026-05-23 audit found
|
|
64
|
+
* identical mid-turn + final replies across two turns ~30s apart
|
|
65
|
+
* silently swallowing the second turn's answer; the user gets
|
|
66
|
+
* no response to their second question). Null on either side
|
|
67
|
+
* matches as before, preserving the boot-time / edge-case behaviour
|
|
68
|
+
* the original tests pin. */
|
|
69
|
+
turnKey: string | null
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/**
|
|
@@ -75,8 +85,21 @@ export class OutboundDedupCache {
|
|
|
75
85
|
/** Record an outbound message. Caller should invoke this after a
|
|
76
86
|
* successful send, regardless of which path sent it (turn-flush,
|
|
77
87
|
* executeReply, executeStreamReply, etc.). Short content is not
|
|
78
|
-
* recorded — see DEDUP_MIN_CONTENT_LEN.
|
|
79
|
-
|
|
88
|
+
* recorded — see DEDUP_MIN_CONTENT_LEN.
|
|
89
|
+
*
|
|
90
|
+
* `turnKey` lets check() tell within-turn retries (the #546 race
|
|
91
|
+
* this module exists to catch) apart from cross-turn coincidences
|
|
92
|
+
* (a user asking similar questions in different turns). Pass
|
|
93
|
+
* `null` if the recording site has no turn context — that matches
|
|
94
|
+
* legacy behaviour and is what the early-boot / fallback callers
|
|
95
|
+
* pass. */
|
|
96
|
+
record(
|
|
97
|
+
chatId: string,
|
|
98
|
+
threadId: number | undefined,
|
|
99
|
+
text: string,
|
|
100
|
+
now: number,
|
|
101
|
+
turnKey: string | null = null,
|
|
102
|
+
): void {
|
|
80
103
|
if (text.length < DEDUP_MIN_CONTENT_LEN) return
|
|
81
104
|
const key = makeKey(chatId, threadId)
|
|
82
105
|
const list = this.entries.get(key) ?? []
|
|
@@ -85,6 +108,7 @@ export class OutboundDedupCache {
|
|
|
85
108
|
hash: normalizeForDedup(text),
|
|
86
109
|
ts: now,
|
|
87
110
|
preview: text.slice(0, 80),
|
|
111
|
+
turnKey,
|
|
88
112
|
})
|
|
89
113
|
this.entries.set(key, list)
|
|
90
114
|
}
|
|
@@ -92,12 +116,24 @@ export class OutboundDedupCache {
|
|
|
92
116
|
/** Check whether the given text was already sent recently to the
|
|
93
117
|
* same chat. Returns the matched entry's preview + age on hit, or
|
|
94
118
|
* null on miss. Caller decides what to do with the answer
|
|
95
|
-
* (skip-send, log, etc.).
|
|
119
|
+
* (skip-send, log, etc.).
|
|
120
|
+
*
|
|
121
|
+
* Cross-turn carve-out (2026-05-23 fix): when both sides of a hash
|
|
122
|
+
* match carry non-null `turnKey` AND those keys differ, treat as
|
|
123
|
+
* miss. The duplicate-reply race this module was built for (#546)
|
|
124
|
+
* is strictly within-turn (the same turn's buffered text replays
|
|
125
|
+
* via a stream_reply retry), so within-turn retries continue to
|
|
126
|
+
* hit. A user typing two similar prompts back-to-back used to lose
|
|
127
|
+
* the second turn's reply because the hashes collided across
|
|
128
|
+
* turns; that no longer happens. Null on EITHER side (legacy /
|
|
129
|
+
* no-turn-context callers) still matches — preserves backward
|
|
130
|
+
* compatibility with the original test suite + early-boot paths. */
|
|
96
131
|
check(
|
|
97
132
|
chatId: string,
|
|
98
133
|
threadId: number | undefined,
|
|
99
134
|
text: string,
|
|
100
135
|
now: number,
|
|
136
|
+
turnKey: string | null = null,
|
|
101
137
|
): { matched: true; preview: string; ageMs: number } | null {
|
|
102
138
|
if (text.length < DEDUP_MIN_CONTENT_LEN) return null
|
|
103
139
|
const key = makeKey(chatId, threadId)
|
|
@@ -106,9 +142,19 @@ export class OutboundDedupCache {
|
|
|
106
142
|
this.evict(list, now)
|
|
107
143
|
const candidateHash = normalizeForDedup(text)
|
|
108
144
|
for (const entry of list) {
|
|
109
|
-
if (entry.hash
|
|
110
|
-
|
|
145
|
+
if (entry.hash !== candidateHash) continue
|
|
146
|
+
// Cross-turn carve-out: distinct, non-null turnKeys on both
|
|
147
|
+
// sides ⇒ different turns ⇒ not a #546 retry. Skip past this
|
|
148
|
+
// entry and keep scanning (a same-turn match later in the list
|
|
149
|
+
// should still hit).
|
|
150
|
+
if (
|
|
151
|
+
turnKey != null
|
|
152
|
+
&& entry.turnKey != null
|
|
153
|
+
&& entry.turnKey !== turnKey
|
|
154
|
+
) {
|
|
155
|
+
continue
|
|
111
156
|
}
|
|
157
|
+
return { matched: true, preview: entry.preview, ageMs: now - entry.ts }
|
|
112
158
|
}
|
|
113
159
|
return null
|
|
114
160
|
}
|
|
@@ -158,7 +158,11 @@ export type RuntimeMetricEvent =
|
|
|
158
158
|
kind: 'voice_scrub_applied'
|
|
159
159
|
chatKey: string
|
|
160
160
|
replaced: number
|
|
161
|
-
|
|
161
|
+
// `stream_reply` and `turn_flush` added in v0.13.21 — modern
|
|
162
|
+
// Claude routes most multi-paragraph replies through the
|
|
163
|
+
// answer-stream / draft-stream path, bypassing the v0.13.20
|
|
164
|
+
// executeReply scrub site. The two new sites close that gap.
|
|
165
|
+
site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
/**
|
|
@@ -459,7 +459,10 @@ function backfillJsonlAgentId(
|
|
|
459
459
|
log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
-
|
|
462
|
+
// Exported for unit-testing the ENOENT/EACCES deregister path
|
|
463
|
+
// (telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts).
|
|
464
|
+
// Not intended for consumption by other modules.
|
|
465
|
+
export function readSubTail(
|
|
463
466
|
entry: WorkerEntry,
|
|
464
467
|
tail: SubTail,
|
|
465
468
|
now: number,
|
|
@@ -472,6 +475,14 @@ function readSubTail(
|
|
|
472
475
|
* previously-stalled entry. Closes the resume edge the schema doc
|
|
473
476
|
* has always promised. */
|
|
474
477
|
onUnstall?: (agentId: string, description: string) => void,
|
|
478
|
+
/** Fires when the JSONL file is no longer accessible (ENOENT — file
|
|
479
|
+
* reaped by Claude Code when the parent session ends; EACCES —
|
|
480
|
+
* permission change mid-poll). The caller deregisters the entry so
|
|
481
|
+
* the 1s poll loop stops re-statting a dead path. Without this
|
|
482
|
+
* callback, every poll re-emits the error log line — on 2026-05-23
|
|
483
|
+
* the clerk agent logged 540k ENOENT lines in 3 days (30/sec
|
|
484
|
+
* sustained) AND leaked one fs.watch FD per stranded entry. */
|
|
485
|
+
onFileVanished?: (agentId: string, code: 'ENOENT' | 'EACCES') => void,
|
|
475
486
|
): void {
|
|
476
487
|
try {
|
|
477
488
|
const stat = fs.statSync(entry.filePath)
|
|
@@ -639,6 +650,17 @@ function readSubTail(
|
|
|
639
650
|
}
|
|
640
651
|
tail.hasEmittedStart = startState.hasEmittedStart
|
|
641
652
|
} catch (err) {
|
|
653
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
654
|
+
if (code === 'ENOENT' || code === 'EACCES') {
|
|
655
|
+
// JSONL is gone (Claude Code reaped the parent session's
|
|
656
|
+
// subagents/ dir) or permission flipped under us. Deregister the
|
|
657
|
+
// entry so the periodic poll stops re-emitting this same line
|
|
658
|
+
// forever. Logged ONCE per agent — operators can still audit
|
|
659
|
+
// which entries got reaped without 30 lines/sec of noise.
|
|
660
|
+
log?.(`subagent-watcher: JSONL vanished for ${entry.agentId} (${code}) — deregistering`)
|
|
661
|
+
onFileVanished?.(entry.agentId, code)
|
|
662
|
+
return
|
|
663
|
+
}
|
|
642
664
|
log?.(`subagent-watcher: read error ${entry.agentId}: ${(err as Error).message}`)
|
|
643
665
|
}
|
|
644
666
|
}
|
|
@@ -841,7 +863,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
841
863
|
if (!entry || !t) return
|
|
842
864
|
readSubTail(entry, t, nowFn(), (desc) => {
|
|
843
865
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
844
|
-
}, fs, log, db, parentStateDir, config.onUnstall)
|
|
866
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
845
867
|
maybySendStateTransition(agentId)
|
|
846
868
|
})
|
|
847
869
|
} catch (err) {
|
|
@@ -1179,7 +1201,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1179
1201
|
if (!tail) continue
|
|
1180
1202
|
readSubTail(entry, tail, n, (desc) => {
|
|
1181
1203
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1182
|
-
}, fs, log, db, parentStateDir, config.onUnstall)
|
|
1204
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
1183
1205
|
maybySendStateTransition(agentId)
|
|
1184
1206
|
}
|
|
1185
1207
|
|
|
@@ -142,3 +142,117 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
|
|
|
142
142
|
expect(deps.activeDraftParseModes.size).toBe(0)
|
|
143
143
|
})
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)', () => {
|
|
147
|
+
// The race that motivates this: the canonical reply path fires
|
|
148
|
+
// `setDone()` on the StatusReactionController BEFORE purgeReactionTracking
|
|
149
|
+
// runs `activeTurnStartedAt.delete(key)`. If the bridge crashes between
|
|
150
|
+
// those two steps, the controller loop sees an EMPTY activeStatusReactions
|
|
151
|
+
// (already cleared by setDone) but activeTurnStartedAt still has the key.
|
|
152
|
+
// Without the sweep, that key orphans and the next inbound is "held mid-
|
|
153
|
+
// turn" against a ghost.
|
|
154
|
+
|
|
155
|
+
it('sweeps activeTurnStartedAt keys the controller loop missed', () => {
|
|
156
|
+
// Construct the exact race: activeStatusReactions is EMPTY (setDone
|
|
157
|
+
// already cleared it on the reply path) but activeTurnStartedAt still
|
|
158
|
+
// has an entry.
|
|
159
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
160
|
+
const clearActiveReactions = vi.fn()
|
|
161
|
+
const disposeProgressDriver = vi.fn()
|
|
162
|
+
const log = vi.fn()
|
|
163
|
+
const deps = {
|
|
164
|
+
agentName: 'clerk',
|
|
165
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
166
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>([
|
|
167
|
+
['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
|
|
168
|
+
]),
|
|
169
|
+
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
170
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
171
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
172
|
+
clearActiveReactions,
|
|
173
|
+
disposeProgressDriver,
|
|
174
|
+
onDanglingTurnsSwept,
|
|
175
|
+
log,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
flushOnAgentDisconnect(deps)
|
|
179
|
+
|
|
180
|
+
// The sweep fired and cleared the dangling entry.
|
|
181
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
182
|
+
expect(deps.activeReactionMsgIds.size).toBe(0)
|
|
183
|
+
expect(onDanglingTurnsSwept).toHaveBeenCalledTimes(1)
|
|
184
|
+
expect(onDanglingTurnsSwept.mock.calls[0][0]).toEqual(['ghost:thr:msg'])
|
|
185
|
+
// The log line names what happened so the operator can audit.
|
|
186
|
+
expect(
|
|
187
|
+
log.mock.calls.some((c: unknown[]) =>
|
|
188
|
+
typeof c[0] === 'string' && /swept .* dangling turn/.test(c[0]),
|
|
189
|
+
),
|
|
190
|
+
).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not fire the sweep when the controller loop already cleaned up everything', () => {
|
|
194
|
+
// Normal-path disconnect: activeStatusReactions had entries, the
|
|
195
|
+
// controller loop ran setDone + delete on each, activeTurnStartedAt
|
|
196
|
+
// is already empty by the end of the loop. No dangling to sweep.
|
|
197
|
+
const { spies, deps } = makeDeps('clerk')
|
|
198
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
199
|
+
const depsWithCallback = { ...deps, onDanglingTurnsSwept }
|
|
200
|
+
|
|
201
|
+
flushOnAgentDisconnect(depsWithCallback)
|
|
202
|
+
|
|
203
|
+
// Controller loop already cleaned both entries.
|
|
204
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
205
|
+
// Callback NOT fired — nothing left to sweep after the loop.
|
|
206
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
207
|
+
// Regression: the existing setDone path still works.
|
|
208
|
+
expect(spies.setDoneA).toHaveBeenCalledTimes(1)
|
|
209
|
+
expect(spies.setDoneB).toHaveBeenCalledTimes(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('does NOT sweep for anonymous disconnects (no agent registered)', () => {
|
|
213
|
+
// Critical regression guard: the sweep MUST be gated by the
|
|
214
|
+
// agentName-null early-return. Anonymous one-shot IPC clients
|
|
215
|
+
// (recall.py, etc.) disconnect constantly and must never touch
|
|
216
|
+
// turn state.
|
|
217
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
218
|
+
const deps = {
|
|
219
|
+
agentName: null,
|
|
220
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
221
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
222
|
+
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
223
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
224
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
225
|
+
clearActiveReactions: vi.fn(),
|
|
226
|
+
disposeProgressDriver: vi.fn(),
|
|
227
|
+
onDanglingTurnsSwept,
|
|
228
|
+
log: vi.fn(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
flushOnAgentDisconnect(deps)
|
|
232
|
+
|
|
233
|
+
// Anonymous disconnect: turn state preserved, sweep callback not fired.
|
|
234
|
+
expect(deps.activeTurnStartedAt.size).toBe(1)
|
|
235
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
|
|
239
|
+
// Backward-compat guard — existing callers that don't pass the new
|
|
240
|
+
// callback still work without runtime error.
|
|
241
|
+
const deps = {
|
|
242
|
+
agentName: 'clerk',
|
|
243
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
244
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
245
|
+
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
246
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
247
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
248
|
+
clearActiveReactions: vi.fn(),
|
|
249
|
+
disposeProgressDriver: vi.fn(),
|
|
250
|
+
// onDanglingTurnsSwept intentionally omitted.
|
|
251
|
+
log: vi.fn(),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
expect(() => flushOnAgentDisconnect(deps)).not.toThrow()
|
|
255
|
+
// The sweep still happens, just without the callback observation.
|
|
256
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -219,7 +219,7 @@ describe("shouldShowHandoffLine", () => {
|
|
|
219
219
|
describe("formatHandoffLine", () => {
|
|
220
220
|
it("wraps the topic in italic HTML with the return emoji", () => {
|
|
221
221
|
const line = formatHandoffLine("fixing the bug", "html");
|
|
222
|
-
expect(line).toBe("<i>↩️ Picked up where we left off
|
|
222
|
+
expect(line).toBe("<i>↩️ Picked up where we left off, fixing the bug</i>\n\n");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it("escapes HTML-unsafe chars in the topic", () => {
|
|
@@ -238,7 +238,7 @@ describe("formatHandoffLine", () => {
|
|
|
238
238
|
|
|
239
239
|
it("produces plain text for 'text' format", () => {
|
|
240
240
|
const line = formatHandoffLine("simple", "text");
|
|
241
|
-
expect(line).toBe("↩️ Picked up where we left off
|
|
241
|
+
expect(line).toBe("↩️ Picked up where we left off, simple\n\n");
|
|
242
242
|
});
|
|
243
243
|
|
|
244
244
|
it("always ends with a blank-line separator", () => {
|
|
@@ -246,4 +246,17 @@ describe("formatHandoffLine", () => {
|
|
|
246
246
|
expect(formatHandoffLine("t", fmt).endsWith("\n\n")).toBe(true);
|
|
247
247
|
}
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
// Regression guard: the handoff prefix was an em-dash bypass for the
|
|
251
|
+
// v0.13.20 voice scrubber (the framework prefix is concatenated AFTER
|
|
252
|
+
// scrubVoice runs in executeReply). Replacing the em-dash with a
|
|
253
|
+
// comma at the template source closes that leak. Pin it so a future
|
|
254
|
+
// operator who "fixes typography" doesn't re-introduce the dash.
|
|
255
|
+
it("does NOT contain an em-dash or en-dash in any format (voice-scrub guard)", () => {
|
|
256
|
+
for (const fmt of ["html", "markdownv2", "text"] as const) {
|
|
257
|
+
const line = formatHandoffLine("anything goes here", fmt);
|
|
258
|
+
expect(line).not.toContain("—");
|
|
259
|
+
expect(line).not.toContain("–");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
249
262
|
});
|