switchroom 0.14.40 → 0.14.41
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 +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +52 -14
- package/telegram-plugin/gateway/gateway.ts +66 -6
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +69 -5
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +71 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +8 -1
package/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.41";
|
|
49466
|
+
var COMMIT_SHA = "747ab2f1";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -47263,11 +47263,17 @@ function decideInboundDelivery(input) {
|
|
|
47263
47263
|
function createDeliveryQueue() {
|
|
47264
47264
|
return { pending: new Map };
|
|
47265
47265
|
}
|
|
47266
|
-
function trackDelivery(q, key, inbound, now) {
|
|
47267
|
-
q.pending.set(key, { key, inbound, lastAttemptAt: now });
|
|
47266
|
+
function trackDelivery(q, key, inbound, now, messageId = null) {
|
|
47267
|
+
q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now });
|
|
47268
47268
|
}
|
|
47269
|
-
function ackDelivery(q, key) {
|
|
47270
|
-
|
|
47269
|
+
function ackDelivery(q, key, enqueueMessageId = null) {
|
|
47270
|
+
const entry = q.pending.get(key);
|
|
47271
|
+
if (!entry)
|
|
47272
|
+
return false;
|
|
47273
|
+
if (entry.messageId != null && entry.messageId !== enqueueMessageId)
|
|
47274
|
+
return false;
|
|
47275
|
+
q.pending.delete(key);
|
|
47276
|
+
return true;
|
|
47271
47277
|
}
|
|
47272
47278
|
function sweep(q, now, timeoutMs) {
|
|
47273
47279
|
const redeliver = [];
|
|
@@ -47282,6 +47288,15 @@ function sweep(q, now, timeoutMs) {
|
|
|
47282
47288
|
function forgetDelivery(q, key) {
|
|
47283
47289
|
q.pending.delete(key);
|
|
47284
47290
|
}
|
|
47291
|
+
function shouldTrackDelivery(input) {
|
|
47292
|
+
if (input.isSteering || input.isInterrupt)
|
|
47293
|
+
return false;
|
|
47294
|
+
if (input.hasSource)
|
|
47295
|
+
return false;
|
|
47296
|
+
if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0)
|
|
47297
|
+
return false;
|
|
47298
|
+
return true;
|
|
47299
|
+
}
|
|
47285
47300
|
|
|
47286
47301
|
// gateway/pending-permission-decisions.ts
|
|
47287
47302
|
var DEFAULT_PENDING_PERMISSION_CAP = 32;
|
|
@@ -51850,10 +51865,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51850
51865
|
}
|
|
51851
51866
|
|
|
51852
51867
|
// ../src/build-info.ts
|
|
51853
|
-
var VERSION = "0.14.
|
|
51854
|
-
var COMMIT_SHA = "
|
|
51855
|
-
var COMMIT_DATE = "2026-06-
|
|
51856
|
-
var LATEST_PR =
|
|
51868
|
+
var VERSION = "0.14.41";
|
|
51869
|
+
var COMMIT_SHA = "747ab2f1";
|
|
51870
|
+
var COMMIT_DATE = "2026-06-02T11:03:39Z";
|
|
51871
|
+
var LATEST_PR = 2095;
|
|
51857
51872
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51858
51873
|
|
|
51859
51874
|
// gateway/boot-version.ts
|
|
@@ -53028,7 +53043,9 @@ function markClaudeBusyForInbound(m) {
|
|
|
53028
53043
|
return key;
|
|
53029
53044
|
}
|
|
53030
53045
|
var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
|
|
53031
|
-
var
|
|
53046
|
+
var _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS;
|
|
53047
|
+
var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== "" ? Number(_deliveryTimeoutRaw) : 15000;
|
|
53048
|
+
var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
|
|
53032
53049
|
var DELIVERY_CONFIRM_SWEEP_MS = 5000;
|
|
53033
53050
|
var deliveryQueue = createDeliveryQueue();
|
|
53034
53051
|
function turnInFlightForGate() {
|
|
@@ -54160,7 +54177,12 @@ async function redeliverStrandedInbound(p) {
|
|
|
54160
54177
|
clearAgentComposer2({ agentName: selfAgent });
|
|
54161
54178
|
} catch {}
|
|
54162
54179
|
const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
|
|
54163
|
-
if (
|
|
54180
|
+
if (ok) {
|
|
54181
|
+
markClaudeBusyForInbound(p.inbound);
|
|
54182
|
+
if (!deliveryQueue.pending.has(p.key)) {
|
|
54183
|
+
trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId);
|
|
54184
|
+
}
|
|
54185
|
+
} else {
|
|
54164
54186
|
pendingInboundBuffer.push(selfAgent, p.inbound);
|
|
54165
54187
|
forgetDelivery(deliveryQueue, p.key);
|
|
54166
54188
|
}
|
|
@@ -54168,6 +54190,10 @@ async function redeliverStrandedInbound(p) {
|
|
|
54168
54190
|
var _deliveryConfirmSweep = setInterval(() => {
|
|
54169
54191
|
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54170
54192
|
return;
|
|
54193
|
+
if (currentTurn != null)
|
|
54194
|
+
return;
|
|
54195
|
+
if (pendingPermissions.size > 0 || pendingAskUser.size > 0)
|
|
54196
|
+
return;
|
|
54171
54197
|
for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
54172
54198
|
redeliverStrandedInbound(p);
|
|
54173
54199
|
}
|
|
@@ -56564,7 +56590,7 @@ function handleSessionEvent(ev) {
|
|
|
56564
56590
|
};
|
|
56565
56591
|
currentTurn = next;
|
|
56566
56592
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
56567
|
-
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
|
|
56593
|
+
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
|
|
56568
56594
|
}
|
|
56569
56595
|
shadowEmit({
|
|
56570
56596
|
kind: "turnStart",
|
|
@@ -58000,12 +58026,24 @@ ${preBlock(write.output)}`;
|
|
|
58000
58026
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
|
|
58001
58027
|
if (delivered) {
|
|
58002
58028
|
const busyKey = markClaudeBusyForInbound(inboundMsg);
|
|
58003
|
-
if (DELIVERY_CONFIRM_ENABLED
|
|
58004
|
-
|
|
58029
|
+
if (DELIVERY_CONFIRM_ENABLED && shouldTrackDelivery({
|
|
58030
|
+
isSteering,
|
|
58031
|
+
isInterrupt: interrupt.isInterrupt,
|
|
58032
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
58033
|
+
effectiveText
|
|
58034
|
+
})) {
|
|
58035
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId));
|
|
58005
58036
|
}
|
|
58006
58037
|
}
|
|
58007
58038
|
if (!delivered) {
|
|
58008
|
-
|
|
58039
|
+
if (shouldTrackDelivery({
|
|
58040
|
+
isSteering,
|
|
58041
|
+
isInterrupt: interrupt.isInterrupt,
|
|
58042
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
58043
|
+
effectiveText
|
|
58044
|
+
})) {
|
|
58045
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
58046
|
+
}
|
|
58009
58047
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
|
|
58010
58048
|
swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u23F3 Agent is restarting \u2014 your message is queued and will be processed when it reconnects.", { ...threadOpts }), {
|
|
58011
58049
|
chat_id,
|
|
@@ -286,6 +286,7 @@ import {
|
|
|
286
286
|
ackDelivery,
|
|
287
287
|
sweep as sweepDeliveryQueue,
|
|
288
288
|
forgetDelivery,
|
|
289
|
+
shouldTrackDelivery,
|
|
289
290
|
type PendingDelivery,
|
|
290
291
|
} from './inbound-delivery-confirm.js'
|
|
291
292
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
@@ -1342,7 +1343,15 @@ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM
|
|
|
1342
1343
|
// a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
|
|
1343
1344
|
// (env) for tests/forensics; a too-low value re-delivers healthy slow turns
|
|
1344
1345
|
// (duplicate turn), which is why the default is comfortably above ack latency.
|
|
1345
|
-
const
|
|
1346
|
+
const _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS
|
|
1347
|
+
const _deliveryTimeoutParsed =
|
|
1348
|
+
_deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== '' ? Number(_deliveryTimeoutRaw) : 15_000
|
|
1349
|
+
// Clamp to a positive, finite value: a negative / zero / NaN env override would
|
|
1350
|
+
// make the sweep treat every tracked entry as stranded and re-deliver every
|
|
1351
|
+
// cycle forever (a self-inflicted re-delivery loop). To disable the feature,
|
|
1352
|
+
// use SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0, not a degenerate timeout.
|
|
1353
|
+
const DELIVERY_CONFIRM_TIMEOUT_MS =
|
|
1354
|
+
Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15_000
|
|
1346
1355
|
const DELIVERY_CONFIRM_SWEEP_MS = 5_000
|
|
1347
1356
|
const deliveryQueue = createDeliveryQueue<InboundMessage>()
|
|
1348
1357
|
|
|
@@ -4106,7 +4115,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
|
|
|
4106
4115
|
if (selfAgent) clearAgentComposer({ agentName: selfAgent })
|
|
4107
4116
|
} catch { /* best-effort; re-deliver regardless */ }
|
|
4108
4117
|
const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
|
|
4109
|
-
if (
|
|
4118
|
+
if (ok) {
|
|
4119
|
+
// Keep the #1556 gate coherent with the re-sent delivery, and survive an
|
|
4120
|
+
// ack that raced the `await import` above: only `enqueue` clears tracking,
|
|
4121
|
+
// so if a concurrent ack removed the entry, re-affirm it — never drop.
|
|
4122
|
+
// Both ops are idempotent.
|
|
4123
|
+
markClaudeBusyForInbound(p.inbound)
|
|
4124
|
+
if (!deliveryQueue.pending.has(p.key)) {
|
|
4125
|
+
trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
|
|
4126
|
+
}
|
|
4127
|
+
} else {
|
|
4110
4128
|
// Bridge offline between attempts — hand off to the offline buffer
|
|
4111
4129
|
// (bridgeUp drains it) and stop tracking here; the spool owns it now.
|
|
4112
4130
|
pendingInboundBuffer.push(selfAgent, p.inbound)
|
|
@@ -4115,6 +4133,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
|
|
|
4115
4133
|
}
|
|
4116
4134
|
const _deliveryConfirmSweep = setInterval(() => {
|
|
4117
4135
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4136
|
+
// Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
|
|
4137
|
+
// by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
|
|
4138
|
+
// means a real turn is in flight — re-clearing the composer + re-sending now
|
|
4139
|
+
// would clobber it (the exact mid-turn wedge this queue exists to prevent). A
|
|
4140
|
+
// pending permission / ask_user prompt is likewise a live interaction. Defer:
|
|
4141
|
+
// leave the entry pending (it isn't acked) so the next idle sweep retries.
|
|
4142
|
+
// NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
|
|
4143
|
+
// stays set through a strand, so it is NOT a usable "idle" signal here.
|
|
4144
|
+
if (currentTurn != null) return
|
|
4145
|
+
if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
|
|
4118
4146
|
for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
4119
4147
|
void redeliverStrandedInbound(p)
|
|
4120
4148
|
}
|
|
@@ -7945,9 +7973,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7945
7973
|
// re-delivery. `enqueue` carries the same chat/thread the inbound was
|
|
7946
7974
|
// keyed on, so the key matches.
|
|
7947
7975
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
7976
|
+
// Match on the source message id: `enqueue` fires for EVERY turn
|
|
7977
|
+
// start (cron / subagent-handback / vault-resume / restart-marker
|
|
7978
|
+
// too — see comment below), so a key-only ack would let a synthetic
|
|
7979
|
+
// turn clear a real user message still waiting under the same key.
|
|
7948
7980
|
ackDelivery(
|
|
7949
7981
|
deliveryQueue,
|
|
7950
7982
|
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
7983
|
+
ev.messageId,
|
|
7951
7984
|
)
|
|
7952
7985
|
}
|
|
7953
7986
|
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
@@ -10670,13 +10703,40 @@ async function handleInbound(
|
|
|
10670
10703
|
const busyKey = markClaudeBusyForInbound(inboundMsg)
|
|
10671
10704
|
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
10672
10705
|
// lands, the message stranded in the composer and the sweep re-delivers
|
|
10673
|
-
// it.
|
|
10674
|
-
|
|
10675
|
-
|
|
10706
|
+
// it. Track ONLY messages that produce an `enqueue` to ack against —
|
|
10707
|
+
// shouldTrackDelivery excludes steering / `!` interrupt (amend the running
|
|
10708
|
+
// turn), synthetic (meta.source) inbounds, and empty bodies, all of which
|
|
10709
|
+
// never enqueue and would re-deliver forever. The tracked messageId lets
|
|
10710
|
+
// the ack match only THIS message's enqueue (not a synthetic turn sharing
|
|
10711
|
+
// the key). See shouldTrackDelivery / ackDelivery (inbound-delivery-confirm.ts).
|
|
10712
|
+
if (
|
|
10713
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
10714
|
+
shouldTrackDelivery({
|
|
10715
|
+
isSteering,
|
|
10716
|
+
isInterrupt: interrupt.isInterrupt,
|
|
10717
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
10718
|
+
effectiveText,
|
|
10719
|
+
})
|
|
10720
|
+
) {
|
|
10721
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
|
|
10676
10722
|
}
|
|
10677
10723
|
}
|
|
10678
10724
|
if (!delivered) {
|
|
10679
|
-
|
|
10725
|
+
// Only persist fresh user turns to the durable spool. Steering / `!`
|
|
10726
|
+
// interrupt / empty bodies are mid-turn amendments or no-ops that would
|
|
10727
|
+
// arrive orphaned if replayed as a fresh turn after a restart — drop them
|
|
10728
|
+
// (the restart notice below tells the user to re-send). Mirrors the
|
|
10729
|
+
// tracking + #1556-gate carve-outs.
|
|
10730
|
+
if (
|
|
10731
|
+
shouldTrackDelivery({
|
|
10732
|
+
isSteering,
|
|
10733
|
+
isInterrupt: interrupt.isInterrupt,
|
|
10734
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
10735
|
+
effectiveText,
|
|
10736
|
+
})
|
|
10737
|
+
) {
|
|
10738
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
10739
|
+
}
|
|
10680
10740
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
|
|
10681
10741
|
// #1075: thread-id-bearing — swallow via robustApiCall so a deleted
|
|
10682
10742
|
// topic doesn't crash the gateway. Fire-and-forget; the inbound is
|
|
@@ -35,6 +35,14 @@ export interface PendingDelivery<M> {
|
|
|
35
35
|
readonly key: string
|
|
36
36
|
/** The exact inbound to re-send until claude acks it. */
|
|
37
37
|
readonly inbound: M
|
|
38
|
+
/**
|
|
39
|
+
* Source message id of the tracked inbound (stringified Telegram
|
|
40
|
+
* `message_id`), or null if unknown. The `enqueue` ack matches on THIS so a
|
|
41
|
+
* synthetic-source turn (cron / resume / vault / reaction) that shares the
|
|
42
|
+
* chatKey can't false-ack — and silently drop — a real user message still
|
|
43
|
+
* waiting to land. See ackDelivery.
|
|
44
|
+
*/
|
|
45
|
+
readonly messageId: string | null
|
|
38
46
|
/** When the latest delivery attempt was made (unix-ms). */
|
|
39
47
|
lastAttemptAt: number
|
|
40
48
|
}
|
|
@@ -51,22 +59,44 @@ export function createDeliveryQueue<M>(): DeliveryQueue<M> {
|
|
|
51
59
|
* Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
|
|
52
60
|
* Overwrites any prior pending for the key — the #1556 gate serialises per
|
|
53
61
|
* key, so a later inbound supersedes an earlier un-acked one for that key.
|
|
62
|
+
* `messageId` (stringified Telegram message_id) lets the ack match only the
|
|
63
|
+
* enqueue that belongs to THIS message; pass null when unknown.
|
|
54
64
|
*/
|
|
55
65
|
export function trackDelivery<M>(
|
|
56
66
|
q: DeliveryQueue<M>,
|
|
57
67
|
key: string,
|
|
58
68
|
inbound: M,
|
|
59
69
|
now: number,
|
|
70
|
+
messageId: string | null = null,
|
|
60
71
|
): void {
|
|
61
|
-
q.pending.set(key, { key, inbound, lastAttemptAt: now })
|
|
72
|
+
q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
65
|
-
* Ack a delivery — call from the `enqueue` session-event (claude started
|
|
66
|
-
* turn
|
|
76
|
+
* Ack a delivery — call from the `enqueue` session-event (claude started a
|
|
77
|
+
* turn). `enqueue` fires for EVERY turn start regardless of source (user
|
|
78
|
+
* inbound, cron, subagent-handback, vault-resume, restart-marker), so acking
|
|
79
|
+
* purely by chatKey would let a synthetic-source turn clear — and thus
|
|
80
|
+
* silently drop — a real user message still waiting under the same key. So
|
|
81
|
+
* ack ONLY when the enqueue's source message id matches the tracked one.
|
|
82
|
+
*
|
|
83
|
+
* Matching rule: if we recorded a messageId for the pending entry, require the
|
|
84
|
+
* enqueue's `enqueueMessageId` to equal it. If we never recorded one (legacy /
|
|
85
|
+
* defensive null), fall back to key-only ack. Returns true if an entry was
|
|
86
|
+
* cleared.
|
|
67
87
|
*/
|
|
68
|
-
export function ackDelivery<M>(
|
|
69
|
-
|
|
88
|
+
export function ackDelivery<M>(
|
|
89
|
+
q: DeliveryQueue<M>,
|
|
90
|
+
key: string,
|
|
91
|
+
enqueueMessageId: string | null = null,
|
|
92
|
+
): boolean {
|
|
93
|
+
const entry = q.pending.get(key)
|
|
94
|
+
if (!entry) return false
|
|
95
|
+
// A different message started this turn — don't ack ours (it may still be
|
|
96
|
+
// waiting to land; the sweep will re-deliver it if it stranded).
|
|
97
|
+
if (entry.messageId != null && entry.messageId !== enqueueMessageId) return false
|
|
98
|
+
q.pending.delete(key)
|
|
99
|
+
return true
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
/**
|
|
@@ -94,3 +124,37 @@ export function sweep<M>(
|
|
|
94
124
|
export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
|
|
95
125
|
q.pending.delete(key)
|
|
96
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Should this delivered inbound be tracked for ack/re-delivery?
|
|
130
|
+
*
|
|
131
|
+
* Track a delivery iff it is a fresh user turn that will produce exactly one
|
|
132
|
+
* `enqueue` to ack against. Everything that does NOT enqueue must be excluded,
|
|
133
|
+
* or the sweep re-delivers it forever (re-clearing the composer every cycle):
|
|
134
|
+
*
|
|
135
|
+
* - `isSteering` / `isInterrupt` — the #1556 gate's carve-outs: delivered
|
|
136
|
+
* mid-turn to AMEND the running turn, so they never start a fresh turn and
|
|
137
|
+
* never emit `enqueue`.
|
|
138
|
+
* - `hasSource` — synthetic inbounds (cron / vault-resume / subagent-handback
|
|
139
|
+
* / reaction) carry a `meta.source`; they enqueue under their own semantics
|
|
140
|
+
* and must never be tracked as if they were a queued user turn.
|
|
141
|
+
* - empty `effectiveText` — an empty body (e.g. `/queue` with no text) is
|
|
142
|
+
* silently dropped by claude's auto-submit and never enqueues, so tracking
|
|
143
|
+
* it is a pure re-delivery loop (a self-inflicted DoS on the queue).
|
|
144
|
+
*
|
|
145
|
+
* Mirror the gate's carve-outs here so tracking is exactly the set of messages
|
|
146
|
+
* that produce an `enqueue`.
|
|
147
|
+
*/
|
|
148
|
+
export function shouldTrackDelivery(input: {
|
|
149
|
+
isSteering: boolean
|
|
150
|
+
isInterrupt: boolean
|
|
151
|
+
hasSource?: boolean
|
|
152
|
+
effectiveText?: string
|
|
153
|
+
}): boolean {
|
|
154
|
+
if (input.isSteering || input.isInterrupt) return false
|
|
155
|
+
if (input.hasSource) return false
|
|
156
|
+
// Gate on empty text only when the caller actually provided it (undefined =
|
|
157
|
+
// "not supplied", left untracked-gated so existing callers keep their behaviour).
|
|
158
|
+
if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0) return false
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ackDelivery,
|
|
5
5
|
createDeliveryQueue,
|
|
6
6
|
forgetDelivery,
|
|
7
|
+
shouldTrackDelivery,
|
|
7
8
|
sweep,
|
|
8
9
|
trackDelivery,
|
|
9
10
|
type DeliveryQueue,
|
|
@@ -107,3 +108,73 @@ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () =>
|
|
|
107
108
|
expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
|
|
108
109
|
})
|
|
109
110
|
})
|
|
111
|
+
|
|
112
|
+
// Regression for the cross-source ACK collision (silent drop): `enqueue` fires
|
|
113
|
+
// for EVERY turn start regardless of source. A synthetic-source turn (cron /
|
|
114
|
+
// resume / vault / reaction) that shares the chatKey of a real user message
|
|
115
|
+
// still waiting to land would, with a key-only ack, clear — and silently drop
|
|
116
|
+
// — that user message. The ack must match on the tracked message id.
|
|
117
|
+
describe('ackDelivery — message-id-matched (cross-source false-ack guard)', () => {
|
|
118
|
+
it('acks when the enqueue message id matches the tracked one', () => {
|
|
119
|
+
const q = fresh()
|
|
120
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
121
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
122
|
+
expect(q.pending.size).toBe(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does NOT ack when a different (synthetic-source) turn enqueues under the same key', () => {
|
|
126
|
+
const q = fresh()
|
|
127
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
128
|
+
// a cron / resume turn for the same chat enqueues first, with its own id
|
|
129
|
+
expect(ackDelivery(q, 'chat:_', '1716123456789')).toBe(false)
|
|
130
|
+
// the user message is still tracked — it strands → gets re-delivered, not dropped
|
|
131
|
+
expect(q.pending.size).toBe(1)
|
|
132
|
+
expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1)
|
|
133
|
+
// and its own enqueue (matching id) later acks it cleanly
|
|
134
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
135
|
+
expect(q.pending.size).toBe(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does NOT ack when the enqueue carries no message id but the tracked one has one', () => {
|
|
139
|
+
const q = fresh()
|
|
140
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
141
|
+
expect(ackDelivery(q, 'chat:_', null)).toBe(false)
|
|
142
|
+
expect(q.pending.size).toBe(1)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('falls back to key-only ack when no message id was recorded (legacy/defensive)', () => {
|
|
146
|
+
const q = fresh()
|
|
147
|
+
trackDelivery(q, 'chat:_', { text: 'x' }, 0) // no messageId
|
|
148
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
149
|
+
expect(q.pending.size).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Regression for the steer/interrupt re-delivery loop: steering and `!`
|
|
154
|
+
// interrupt inbounds amend the running turn and never emit `enqueue`, so they
|
|
155
|
+
// must NOT be tracked (else the sweep re-delivers them forever). Only
|
|
156
|
+
// fresh-turn messages — which DO enqueue — are tracked.
|
|
157
|
+
describe('shouldTrackDelivery — only fresh-turn messages are tracked', () => {
|
|
158
|
+
it('tracks a normal (non-steering, non-interrupt) message', () => {
|
|
159
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false })).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
it('does NOT track a /steer message (amends the turn — never acks)', () => {
|
|
162
|
+
expect(shouldTrackDelivery({ isSteering: true, isInterrupt: false })).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
it('does NOT track a ! interrupt message (amends the turn — never acks)', () => {
|
|
165
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: true })).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
it('does NOT track when both flags set (defensive)', () => {
|
|
168
|
+
expect(shouldTrackDelivery({ isSteering: true, isInterrupt: true })).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
it('does NOT track a synthetic (meta.source) inbound — cron/resume/vault/reaction enqueue under their own semantics', () => {
|
|
171
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, hasSource: true })).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
it('does NOT track an empty-body message (e.g. `/queue` with no text) — never enqueues, would re-deliver forever', () => {
|
|
174
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: '' })).toBe(false)
|
|
175
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: ' ' })).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
it('tracks a normal message when effectiveText is provided and non-empty', () => {
|
|
178
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: 'draft the email' })).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -55,8 +55,15 @@ describe("uat: rapid follow-ups — steering vs queued classification", () => {
|
|
|
55
55
|
(m) => {
|
|
56
56
|
const txt = m.text;
|
|
57
57
|
const mentionsMd5 = /\bmd5\b/i.test(txt);
|
|
58
|
+
// Steer narration: the agent acknowledges amending the in-flight
|
|
59
|
+
// task. Accept the phrasings the model actually uses — including
|
|
60
|
+
// "Switched to MD5 per your update/follow-up" (the 2026-06-02
|
|
61
|
+
// canary reply that the old regex wrongly rejected). Anchored on
|
|
62
|
+
// "per your <qualifier>" / continuation language so it stays
|
|
63
|
+
// distinct from the QUEUED path (a fresh answer with no such
|
|
64
|
+
// course-correction narration).
|
|
58
65
|
const narratesSteer =
|
|
59
|
-
/↪️|\bsteer(ing)?\b|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
|
|
66
|
+
/↪️|\bsteer(ing)?\b|switch(?:ed|ing)? to \w+ per your (?:update|follow-?up|guidance|request|steer)|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
|
|
60
67
|
txt,
|
|
61
68
|
);
|
|
62
69
|
return mentionsMd5 && narratesSteer;
|