switchroom 0.20.6 → 0.20.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * represent-delivery-guard.ts — the DELIVERY-time half of the duplicate-represent
3
+ * defence (fix/represent-double-send-delivery-recheck).
4
+ *
5
+ * Background — the race this closes (verified from history.db, 2026):
6
+ * 1. A ~5-min silence poke fires the framework fallback, which clears the
7
+ * machine turn (`turnInFlightForGate()` → false) while the CLI session is
8
+ * STILL producing the answer (it lands ~17s later). stream-render's live-turn
9
+ * mirror (`currentTurn`/`parkedTurnStarts`) still knows the session is busy.
10
+ * 2. With the machine turn cleared, `obligationSweep` runs; `shouldSuppressRepresent`
11
+ * correctly returns false (nothing has been sent YET) and the represent is
12
+ * buffered; the idle-drain flushes it into the live CLI queue.
13
+ * 3. ~13s later the real answer is delivered + recorded (ledger closed by the
14
+ * normal path). Then the model consumes the already-queued represent and
15
+ * answers a SECOND time → the duplicate.
16
+ *
17
+ * The represent guard never FAILED — it was consulted BEFORE the reply existed,
18
+ * and there was no re-check between decision → delivery → model consumption. This
19
+ * module adds that missing re-check (F1) plus the bounded busy-defer for the
20
+ * idle-drain gate (F2). Layer F2 (obligation-wiring's sweep + this drain defer)
21
+ * keeps the represent BUFFERED while the session is busy so that, by the time a
22
+ * drain actually hands it to the bridge, the real answer has landed and been
23
+ * recorded — at which point F1 retracts the now-stale represent. `recordOutbound`
24
+ * is a synchronous SQLite write that completes before the reply tool result
25
+ * returns, so a post-reply drain observes the row.
26
+ *
27
+ * Both halves are PURE-ish factories (no Telegram, no SQLite) so the whole
28
+ * decision is executable in a unit test; the gateway injects the ledger accessor,
29
+ * the outbound-history predicate, and the logger.
30
+ */
31
+
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+ import { shouldSuppressRepresent, type RepresentGuardObligation } from './represent-guard.js'
34
+
35
+ /** The minimal ledger surface the delivery re-check needs. */
36
+ export interface RepresentDeliveryLedger {
37
+ /** The CURRENTLY-open obligation for `originTurnId`, or undefined if none is
38
+ * open (already closed — answered or cancelled — since the represent was
39
+ * buffered). */
40
+ get(originTurnId: string): RepresentGuardObligation | undefined
41
+ /** Close the obligation. Idempotent; returns whether an entry was removed. */
42
+ close(originTurnId: string | null | undefined): boolean
43
+ }
44
+
45
+ export interface RepresentRedeliveryGuardDeps {
46
+ /** OBLIGATION_LEDGER_ENABLED — when off, never retract (nothing is tracked). */
47
+ enabled: boolean
48
+ /** HISTORY_ENABLED — forwarded to the pure guard (no history ⇒ never suppress). */
49
+ historyEnabled: boolean
50
+ ledger: RepresentDeliveryLedger
51
+ /** history.hasOutboundDeliveredSince, already curried with the chat query. */
52
+ hasOutboundDeliveredSince: (
53
+ chatId: string,
54
+ sinceMs: number,
55
+ threadId?: number,
56
+ minChars?: number,
57
+ ) => boolean
58
+ /** The represent guard's OWN low reply-length threshold (a terse-but-real reply
59
+ * must still suppress the duplicate — #2472/#2474). */
60
+ minReplyChars: number
61
+ log: (line: string) => void
62
+ }
63
+
64
+ /**
65
+ * Build the `beforeRedeliver` predicate the pending-inbound buffer consults for
66
+ * EVERY message it is about to hand to the CLI bridge. Returns TRUE to proceed
67
+ * with delivery, FALSE to RETRACT (drop) the message.
68
+ *
69
+ * Only `obligation_represent` inbounds are ever retracted; everything else always
70
+ * proceeds. A represent is retracted when, AT DELIVERY TIME:
71
+ * - the obligation is no longer open (a reply landed and the normal close path
72
+ * fired, or it was cancelled) — re-delivering would duplicate / re-ask a
73
+ * resolved question; OR
74
+ * - it IS still open but `shouldSuppressRepresent` now returns true against the
75
+ * obligation's CURRENT cutoff (a reply landed since decision, but the normal
76
+ * close path missed it because its routing did not resolve back to the origin
77
+ * — the exact #2472 gap, now caught at delivery instead of only at decision).
78
+ *
79
+ * The cutoff is the obligation's own (`openedAt` for the first represent,
80
+ * `lastRepresentedAt` thereafter) — encoded inside `shouldSuppressRepresent`, so
81
+ * an OLD reply to a DIFFERENT earlier question does not suppress a legitimate new
82
+ * represent (#2472 second-represent semantics). The genuine plain-text-no-reply
83
+ * case (#2788) records NO outbound row, so the predicate reports false at BOTH
84
+ * decision and here ⇒ the represent still fires exactly once.
85
+ */
86
+ export function makeRepresentRedeliveryGuard(
87
+ deps: RepresentRedeliveryGuardDeps,
88
+ ): (msg: InboundMessage) => boolean {
89
+ return (msg) => {
90
+ if (!deps.enabled) return true
91
+ if (msg.meta?.source !== 'obligation_represent') return true
92
+ const originTurnId = msg.meta?.origin_turn_id
93
+ if (originTurnId == null) return true
94
+
95
+ const o = deps.ledger.get(originTurnId)
96
+ if (o == null) {
97
+ // Closed since this represent was buffered — a reply landed (normal close)
98
+ // or the turn was cancelled/interrupted. Either way the buffered represent
99
+ // is stale: drop it rather than re-ask an already-resolved obligation.
100
+ deps.log(
101
+ `telegram gateway: represent retracted at delivery — obligation already ` +
102
+ `closed since decision (no re-fire) origin=${originTurnId}\n`,
103
+ )
104
+ return false
105
+ }
106
+
107
+ const suppress = shouldSuppressRepresent(o, {
108
+ historyEnabled: deps.historyEnabled,
109
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
110
+ deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars),
111
+ })
112
+ if (suppress) {
113
+ // A reply landed since this obligation's cutoff but the normal close path
114
+ // missed it. Close the ledger entry ourselves and drop the represent.
115
+ deps.ledger.close(originTurnId)
116
+ deps.log(
117
+ `telegram gateway: represent retracted at delivery — reply landed since ` +
118
+ `decision (no re-fire) origin=${originTurnId}\n`,
119
+ )
120
+ return false
121
+ }
122
+ return true
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the BOUNDED busy-defer predicate for the idle-drain gate (F2, drain
128
+ * half). Returns TRUE while the drain should be DEFERRED because the session is
129
+ * busy (stream-render's live-turn mirror), FALSE once it is safe to drain.
130
+ *
131
+ * Why bounded: the drain must not hand a buffered represent to a session that is
132
+ * mid-answer (that re-queues it BEHIND the real answer → the duplicate). But a
133
+ * session that is WEDGED busy forever must not silence a buffered represent
134
+ * forever (red-team #2). So the deferral is capped: once the session has been
135
+ * continuously busy for longer than `boundMs`, the drain proceeds regardless. The
136
+ * clock resets to "not deferring" the moment the session reads idle, so an
137
+ * ordinary busy turn (which ends well within the bound) defers cleanly and never
138
+ * consumes the wedge budget.
139
+ *
140
+ * `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
141
+ * background-work grace being disabled).
142
+ */
143
+ export function makeSessionBusyDrainDeferral(
144
+ boundMs: number,
145
+ ): (busy: boolean, now: number) => boolean {
146
+ let deferringSince: number | null = null
147
+ return (busy, now) => {
148
+ if (!busy || boundMs <= 0) {
149
+ deferringSince = null
150
+ return false
151
+ }
152
+ if (deferringSince == null) deferringSince = now
153
+ // Bounded: stop deferring once we have been busy past the ceiling, so a
154
+ // wedged/hung session still eventually drains the buffered represent.
155
+ return now - deferringSince < boundMs
156
+ }
157
+ }
@@ -279,6 +279,19 @@ export function __resetParkedTurnStartsForTest(): void {
279
279
  export function __parkedTurnStartCountForTest(): number {
280
280
  return parkedTurnStarts.length
281
281
  }
282
+ /**
283
+ * Production accessor for the parked-turn-start count — the second half of the
284
+ * stream-render "session busy" signal (the first being a live `currentTurn`
285
+ * whose `endedAt == null`). A parked turn-start means the CLI has an enqueued
286
+ * message it is about to turn on, so the session is NOT idle even when the
287
+ * gateway's machine-turn gate (`turnInFlightForGate`) reads clear — the exact
288
+ * disagreement a ~5-min silence poke opens (it clears the machine turn while the
289
+ * CLI is still producing the answer). The obligation sweep and the idle-drain
290
+ * consult this so they do not treat a poke-cleared-but-busy session as idle.
291
+ */
292
+ export function parkedTurnStartCount(): number {
293
+ return parkedTurnStarts.length
294
+ }
282
295
 
283
296
  /**
284
297
  * Silence-fallback unwedge for the parked store (the two-state desync fix).
@@ -9,6 +9,8 @@
9
9
  import { describe, it, expect } from 'vitest'
10
10
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, planBufferedRedelivery, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
+ import { ObligationLedger } from '../gateway/obligation-ledger.js'
13
+ import { makeRepresentRedeliveryGuard } from '../gateway/represent-delivery-guard.js'
12
14
 
13
15
  function inbound(source: string, ts = Date.now()): InboundMessage {
14
16
  return {
@@ -223,7 +225,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
223
225
  seen.push(m.messageId as number)
224
226
  return true
225
227
  })
226
- expect(r).toEqual({ drained: 2, redelivered: 2, rebuffered: 0 })
228
+ expect(r).toEqual({ drained: 2, redelivered: 2, rebuffered: 0, retracted: 0 })
227
229
  expect(seen).toEqual([1, 2]) // FIFO preserved
228
230
  expect(buf.depth('klanker')).toBe(0)
229
231
  })
@@ -233,7 +235,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
233
235
  buf.push('klanker', inbound('user', 1))
234
236
  buf.push('klanker', inbound('cron', 2))
235
237
  const r = redeliverBufferedInbound(buf, 'klanker', () => false)
236
- expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
238
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2, retracted: 0 })
237
239
  expect(buf.depth('klanker')).toBe(2) // still there, nothing lost
238
240
  expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['user', 'cron'])
239
241
  })
@@ -244,7 +246,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
244
246
  const r = redeliverBufferedInbound(buf, 'klanker', () => {
245
247
  throw new Error('bridge write failed')
246
248
  })
247
- expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
249
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1, retracted: 0 })
248
250
  expect(buf.depth('klanker')).toBe(1)
249
251
  })
250
252
 
@@ -258,7 +260,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
258
260
  n++
259
261
  return n !== 2 // 2nd send fails
260
262
  })
261
- expect(r).toEqual({ drained: 3, redelivered: 2, rebuffered: 1 })
263
+ expect(r).toEqual({ drained: 3, redelivered: 2, rebuffered: 1, retracted: 0 })
262
264
  expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['b'])
263
265
  })
264
266
 
@@ -269,7 +271,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
269
271
  calls++
270
272
  return true
271
273
  })
272
- expect(r).toEqual({ drained: 0, redelivered: 0, rebuffered: 0 })
274
+ expect(r).toEqual({ drained: 0, redelivered: 0, rebuffered: 0, retracted: 0 })
273
275
  expect(calls).toBe(0)
274
276
  })
275
277
 
@@ -334,7 +336,7 @@ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)'
334
336
  buf.push('finn', inbound('user', 2013)) // the orphaned "verify with mff-query.py" class
335
337
  const seen: number[] = []
336
338
  const r = idleDrainTick(buf, 'finn', () => true, (m) => { seen.push(m.messageId as number); return true })
337
- expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0 })
339
+ expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0, retracted: 0 })
338
340
  expect(seen).toEqual([2013])
339
341
  expect(buf.depth('finn')).toBe(0)
340
342
  })
@@ -343,7 +345,7 @@ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)'
343
345
  const buf = createPendingInboundBuffer({ log: () => {} })
344
346
  buf.push('finn', inbound('user', 1))
345
347
  const r = idleDrainTick(buf, 'finn', () => true, () => false)
346
- expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
348
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1, retracted: 0 })
347
349
  expect(buf.depth('finn')).toBe(1) // nothing lost
348
350
  expect(idleDrainTick(buf, '', () => true, () => true)).toBeNull() // empty agent guard
349
351
  })
@@ -419,6 +421,7 @@ describe('durable-spool integration (finn/carrie lost-on-restart fix)', () => {
419
421
  drained: 1,
420
422
  redelivered: 1,
421
423
  rebuffered: 0,
424
+ retracted: 0,
422
425
  })
423
426
  })
424
427
  })
@@ -589,7 +592,7 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
589
592
  return true
590
593
  })
591
594
  expect(sent).toEqual(['part 1\npart 2\npart 3']) // ONE turn, not three
592
- expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0 })
595
+ expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0, retracted: 0 })
593
596
  expect(buf.depth('ziggy')).toBe(0)
594
597
  })
595
598
 
@@ -598,7 +601,7 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
598
601
  buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
599
602
  buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
600
603
  const r = redeliverBufferedInbound(buf, 'ziggy', () => false)
601
- expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
604
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2, retracted: 0 })
602
605
  expect(buf.depth('ziggy')).toBe(2) // both originals back, nothing lost
603
606
  expect(buf.drain('ziggy').map((m) => m.text)).toEqual(['part 1', 'part 2'])
604
607
  })
@@ -713,3 +716,136 @@ describe('planBufferedRedelivery — seeded fuzz over random burst schedules', (
713
716
  }
714
717
  })
715
718
  })
719
+
720
+ /**
721
+ * F1 end-to-end: the delivery-time represent re-check wired into
722
+ * redeliverBufferedInbound via `beforeRedeliver`. These exercise the WHOLE
723
+ * drain path (buffer → plan → guard → send) against a real ObligationLedger,
724
+ * asserting OUTCOMES: a stale represent is dropped and the ledger closed; a
725
+ * truly-unanswered obligation fires exactly one represent.
726
+ */
727
+ describe('redeliverBufferedInbound + F1 delivery-time represent re-check', () => {
728
+ const CHAT = 'c1'
729
+ const ORIGIN = 'turn-abc'
730
+ const OPENED_AT = 1_000
731
+
732
+ function representInbound(): InboundMessage {
733
+ return {
734
+ type: 'inbound',
735
+ chatId: CHAT,
736
+ messageId: 5_000,
737
+ user: 'obligation-ledger',
738
+ userId: 0,
739
+ ts: 5_000,
740
+ text: 'You asked earlier and I want to make sure I did not miss it…',
741
+ meta: { source: 'obligation_represent', origin_turn_id: ORIGIN },
742
+ }
743
+ }
744
+
745
+ function openLedger(): ObligationLedger {
746
+ const l = new ObligationLedger()
747
+ l.openIfAbsent({
748
+ originTurnId: ORIGIN,
749
+ chatId: CHAT,
750
+ messageId: 42,
751
+ text: 'original question',
752
+ openedAt: OPENED_AT,
753
+ })
754
+ return l
755
+ }
756
+
757
+ function guardFor(
758
+ ledger: ObligationLedger,
759
+ hasOutboundDeliveredSince: (chatId: string, sinceMs: number) => boolean,
760
+ ) {
761
+ return makeRepresentRedeliveryGuard({
762
+ enabled: true,
763
+ historyEnabled: true,
764
+ ledger,
765
+ hasOutboundDeliveredSince: (chatId, sinceMs) => hasOutboundDeliveredSince(chatId, sinceMs),
766
+ minReplyChars: 1,
767
+ log: () => {},
768
+ })
769
+ }
770
+
771
+ it('drops a buffered represent whose reply landed since decision, and closes the ledger', () => {
772
+ const ledger = openLedger()
773
+ // A real answer was delivered AT/AFTER the obligation's cutoff (openedAt).
774
+ const buf = createPendingInboundBuffer({
775
+ log: () => {},
776
+ beforeRedeliver: guardFor(ledger, (_chat, sinceMs) => sinceMs <= 2_000),
777
+ })
778
+ buf.push('a', representInbound())
779
+
780
+ let sends = 0
781
+ const r = redeliverBufferedInbound(buf, 'a', () => {
782
+ sends++
783
+ return true
784
+ })
785
+
786
+ expect(sends).toBe(0) // never handed to the CLI bridge
787
+ expect(r.drained).toBe(1)
788
+ expect(r.retracted).toBe(1)
789
+ expect(r.redelivered).toBe(0)
790
+ expect(r.rebuffered).toBe(0)
791
+ expect(ledger.isOpen(ORIGIN)).toBe(false) // F1 closed the stale obligation
792
+ expect(buf.depth('a')).toBe(0) // dropped, not re-buffered
793
+ })
794
+
795
+ it('delivers exactly one represent for a truly-unanswered obligation (no outbound row)', () => {
796
+ const ledger = openLedger()
797
+ // Plain-text-no-reply / genuinely unanswered: no outbound recorded since cutoff.
798
+ const buf = createPendingInboundBuffer({
799
+ log: () => {},
800
+ beforeRedeliver: guardFor(ledger, () => false),
801
+ })
802
+ buf.push('a', representInbound())
803
+
804
+ let sends = 0
805
+ const r = redeliverBufferedInbound(buf, 'a', () => {
806
+ sends++
807
+ return true
808
+ })
809
+
810
+ expect(sends).toBe(1)
811
+ expect(r.drained).toBe(1)
812
+ expect(r.redelivered).toBe(1)
813
+ expect(r.retracted).toBe(0)
814
+ expect(r.rebuffered).toBe(0)
815
+ expect(ledger.isOpen(ORIGIN)).toBe(true) // still open — the represent will be answered
816
+ })
817
+ })
818
+
819
+ /**
820
+ * FAIL-OPEN safety: a `beforeRedeliver` predicate that THROWS must never silence
821
+ * or lose a real message, nor abort the drain loop. The throw is swallowed, the
822
+ * message is treated as "deliver", and every following buffered message in the
823
+ * same drain is still delivered. This test fails if the try/catch around the
824
+ * predicate is removed (the throw would propagate out of redeliverBufferedInbound).
825
+ */
826
+ describe('redeliverBufferedInbound — beforeRedeliver fail-open on throw', () => {
827
+ it('delivers the message and the rest of the drain when the predicate throws', () => {
828
+ const buf = createPendingInboundBuffer({
829
+ log: () => {},
830
+ beforeRedeliver: () => {
831
+ throw new Error('guard boom')
832
+ },
833
+ })
834
+ buf.push('a', inbound('cron', 1))
835
+ buf.push('a', inbound('vault_grant_approved', 2))
836
+
837
+ const seen: number[] = []
838
+ // Must not throw — the loop completes past a throwing predicate.
839
+ const r = redeliverBufferedInbound(buf, 'a', (m) => {
840
+ seen.push(m.messageId as number)
841
+ return true
842
+ })
843
+
844
+ expect(seen).toEqual([1, 2]) // both delivered, loop not aborted
845
+ expect(r.drained).toBe(2)
846
+ expect(r.redelivered).toBe(2) // failed open → delivered, not dropped
847
+ expect(r.retracted).toBe(0) // a throw is NOT a retract
848
+ expect(r.rebuffered).toBe(0)
849
+ expect(buf.depth('a')).toBe(0) // nothing stranded
850
+ })
851
+ })
@@ -4,6 +4,12 @@ import {
4
4
  type RepresentGuardObligation,
5
5
  } from "../gateway/represent-guard.js";
6
6
  import { ObligationLedger } from "../gateway/obligation-ledger.js";
7
+ import {
8
+ makeRepresentRedeliveryGuard,
9
+ makeSessionBusyDrainDeferral,
10
+ } from "../gateway/represent-delivery-guard.js";
11
+ import { createObligationWiring } from "../gateway/obligation-wiring.js";
12
+ import type { InboundMessage } from "../gateway/ipc-protocol.js";
7
13
 
8
14
  // Executable verification of the #2472 fix: obligation_represent must NOT re-fire
9
15
  // for an origin_turn_id that has already been answered by a reply since the last
@@ -171,6 +177,250 @@ describe("represent guard — terse genuine reply suppresses the duplicate (#247
171
177
  });
172
178
  });
173
179
 
180
+ // F1 (fix/represent-double-send-delivery-recheck) — the DELIVERY-time re-check.
181
+ // The decision-time guard (shouldSuppressRepresent) is consulted when the sweep
182
+ // buffers a represent, which can be BEFORE the reply exists. This layer re-runs it
183
+ // at the moment the buffered represent is handed to the CLI bridge, catching the
184
+ // reply that landed in between (the double-send race).
185
+ describe("makeRepresentRedeliveryGuard — F1 delivery-time represent re-check", () => {
186
+ const MSG_ID = 42;
187
+
188
+ function representInbound(originTurnId: string, chatId: string): InboundMessage {
189
+ return {
190
+ type: "inbound",
191
+ chatId,
192
+ messageId: MSG_ID,
193
+ user: "switchroom",
194
+ userId: 0,
195
+ ts: 1,
196
+ text: "you have an earlier message…",
197
+ meta: { source: "obligation_represent", origin_turn_id: originTurnId, chat_id: chatId },
198
+ };
199
+ }
200
+
201
+ function openRepresented(ledger: ObligationLedger, representedAt: number, openedAt = 1000): void {
202
+ ledger.openIfAbsent({ originTurnId: ORIGIN, chatId: CHAT, messageId: MSG_ID, text: "q", openedAt });
203
+ ledger.markRepresented(ORIGIN, representedAt); // mirror the sweep stamping lastRepresentedAt
204
+ }
205
+
206
+ it("RETRACTS (drops + closes) a represent whose reply landed since the decision", () => {
207
+ const ledger = new ObligationLedger();
208
+ openRepresented(ledger, 2000); // sweep decided this represent at t=2000
209
+ // The real reply was recorded at t=3000 (AFTER the decision) but its routing
210
+ // missed the normal close path — the exact double-send race.
211
+ const guard = makeRepresentRedeliveryGuard({
212
+ enabled: true,
213
+ historyEnabled: true,
214
+ ledger,
215
+ // Chat-scoped: only the obligation's OWN (resolved) chat id has the row —
216
+ // outbound was recorded under the fallback-resolved id, which is o.chatId.
217
+ hasOutboundDeliveredSince: (chat, sinceMs) => chat === CHAT && 3000 >= sinceMs,
218
+ minReplyChars: 1,
219
+ log: () => {},
220
+ });
221
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(false); // do NOT deliver
222
+ expect(ledger.isOpen(ORIGIN)).toBe(false); // ledger closed by the retract
223
+ });
224
+
225
+ it("RETRACTS a represent whose obligation was already closed since it was buffered", () => {
226
+ const ledger = new ObligationLedger();
227
+ openRepresented(ledger, 2000);
228
+ ledger.close(ORIGIN); // the normal close path DID fire after buffering
229
+ const guard = makeRepresentRedeliveryGuard({
230
+ enabled: true,
231
+ historyEnabled: true,
232
+ ledger,
233
+ hasOutboundDeliveredSince: () => false, // irrelevant — obligation is gone
234
+ minReplyChars: 1,
235
+ log: () => {},
236
+ });
237
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(false); // stale → drop
238
+ });
239
+
240
+ it("PROCEEDS (delivers once) when NO outbound row exists — plain-text-no-reply (#2788)", () => {
241
+ const ledger = new ObligationLedger();
242
+ openRepresented(ledger, 2000);
243
+ const guard = makeRepresentRedeliveryGuard({
244
+ enabled: true,
245
+ historyEnabled: true,
246
+ ledger,
247
+ hasOutboundDeliveredSince: () => false, // the agent never called the reply tool
248
+ minReplyChars: 1,
249
+ log: () => {},
250
+ });
251
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // still fires exactly once
252
+ expect(ledger.isOpen(ORIGIN)).toBe(true); // guard did NOT close it
253
+ });
254
+
255
+ it("PROCEEDS when the only reply PREDATES lastRepresentedAt (#2472 second-represent cutoff)", () => {
256
+ const ledger = new ObligationLedger();
257
+ openRepresented(ledger, 5000); // second represent decided at t=5000
258
+ // A reply exists but only at t=3000 — it answered an EARLIER question, not
259
+ // this represent. The delivery re-check must use the obligation's own cutoff
260
+ // (lastRepresentedAt=5000), so this old reply does NOT suppress.
261
+ const guard = makeRepresentRedeliveryGuard({
262
+ enabled: true,
263
+ historyEnabled: true,
264
+ ledger,
265
+ hasOutboundDeliveredSince: (_chat, sinceMs) => 3000 >= sinceMs,
266
+ minReplyChars: 1,
267
+ log: () => {},
268
+ });
269
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // legitimate new represent fires
270
+ expect(ledger.isOpen(ORIGIN)).toBe(true);
271
+ });
272
+
273
+ it("passes NON-represent inbounds through untouched", () => {
274
+ const ledger = new ObligationLedger();
275
+ openRepresented(ledger, 2000);
276
+ const guard = makeRepresentRedeliveryGuard({
277
+ enabled: true,
278
+ historyEnabled: true,
279
+ ledger,
280
+ hasOutboundDeliveredSince: () => true, // would suppress a represent — but this isn't one
281
+ minReplyChars: 1,
282
+ log: () => {},
283
+ });
284
+ const userMsg: InboundMessage = {
285
+ type: "inbound",
286
+ chatId: CHAT,
287
+ messageId: 7,
288
+ user: "ken",
289
+ userId: 1,
290
+ ts: 1,
291
+ text: "hello",
292
+ };
293
+ expect(guard(userMsg)).toBe(true);
294
+ expect(ledger.isOpen(ORIGIN)).toBe(true);
295
+ });
296
+
297
+ it("is inert when the obligation ledger is disabled", () => {
298
+ const ledger = new ObligationLedger();
299
+ openRepresented(ledger, 2000);
300
+ const guard = makeRepresentRedeliveryGuard({
301
+ enabled: false,
302
+ historyEnabled: true,
303
+ ledger,
304
+ hasOutboundDeliveredSince: () => true,
305
+ minReplyChars: 1,
306
+ log: () => {},
307
+ });
308
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // never retracts when off
309
+ });
310
+ });
311
+
312
+ // F2 (fix/represent-double-send-delivery-recheck) — a poke-cleared session that is
313
+ // STILL busy must not be treated as idle. Two halves: the bounded drain-defer
314
+ // (drain half) and the sweep decision folding session-busy into the bounded
315
+ // background-work grace (decision half).
316
+ describe("makeSessionBusyDrainDeferral — F2 bounded busy-defer for the idle drain", () => {
317
+ it("defers while busy, then STOPS deferring once the bound elapses (red-team #2: no forever-silence)", () => {
318
+ const defer = makeSessionBusyDrainDeferral(1000);
319
+ expect(defer(true, 0)).toBe(true); // busy → defer the drain
320
+ expect(defer(true, 500)).toBe(true); // still within the bound
321
+ expect(defer(true, 999)).toBe(true);
322
+ expect(defer(true, 1000)).toBe(false); // bound reached → drain anyway (bounded)
323
+ });
324
+
325
+ it("resets the deferral clock whenever the session reads idle", () => {
326
+ const defer = makeSessionBusyDrainDeferral(1000);
327
+ expect(defer(true, 0)).toBe(true);
328
+ expect(defer(false, 400)).toBe(false); // idle → reset
329
+ expect(defer(true, 500)).toBe(true); // fresh window opens at 500
330
+ expect(defer(true, 1499)).toBe(true); // 999ms into the new window
331
+ expect(defer(true, 1500)).toBe(false); // new bound elapsed
332
+ });
333
+
334
+ it("is disabled when the bound is <= 0 (kill switch)", () => {
335
+ const off = makeSessionBusyDrainDeferral(0);
336
+ expect(off(true, 0)).toBe(false);
337
+ expect(off(true, 10_000)).toBe(false);
338
+ });
339
+ });
340
+
341
+ describe("obligationSweep — F2 decision half: a poke-cleared-but-busy session defers, then re-asks (bounded)", () => {
342
+ const GRACE = 20 * 60_000;
343
+ const SWEEP_ORIGIN = "12345:_#77001";
344
+
345
+ function makeWiring(opts: { ledger: ObligationLedger; pushed: InboundMessage[]; sessionBusy: boolean }) {
346
+ const liveTurn = opts.sessionBusy ? ({ turnId: "live", endedAt: null } as any) : null;
347
+ const deps = {
348
+ OBLIGATION_LEDGER_ENABLED: true,
349
+ HISTORY_ENABLED: true,
350
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS: GRACE,
351
+ OBLIGATION_ESCALATE_GRACE_MS: 0,
352
+ OBLIGATION_REPRESENT_GRACE_MS: 0,
353
+ OBLIGATION_REPRESENT_MAX: 2,
354
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS: 1,
355
+ OBLIGATION_ESCALATE_MAX: 3,
356
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS: 10_000,
357
+ obligationLedger: opts.ledger,
358
+ obligationEscalateInFlight: new Set<string>(),
359
+ pendingCrossTurnGate: { set: () => {} },
360
+ pendingInboundBuffer: {
361
+ depth: () => 0,
362
+ push: (_agent: string, m: InboundMessage) => {
363
+ opts.pushed.push(m);
364
+ return true;
365
+ },
366
+ },
367
+ capturedResume: { dispatch: () => {} },
368
+ getCurrentTurn: () => liveTurn,
369
+ // The ~5-min silence poke has ALREADY cleared the machine turn.
370
+ turnInFlightForGate: () => false,
371
+ agentHasInFlightBackgroundWork: () => false,
372
+ // No reply has been delivered — a genuinely unanswered turn.
373
+ hasOutboundDeliveredSince: () => false,
374
+ findTurnByOriginId: () => undefined,
375
+ bridgeAlive: () => true,
376
+ sendEscalationNudge: () => {},
377
+ };
378
+ // The wiring's dep type is derived from the gateway; the shape above matches
379
+ // the fields the sweep reads.
380
+ return createObligationWiring(deps as unknown as Parameters<typeof createObligationWiring>[0]);
381
+ }
382
+
383
+ function open(ledger: ObligationLedger, openedAt: number): void {
384
+ ledger.openIfAbsent({
385
+ originTurnId: SWEEP_ORIGIN,
386
+ chatId: CHAT,
387
+ messageId: 77001,
388
+ text: "an unanswered question",
389
+ openedAt,
390
+ });
391
+ }
392
+
393
+ it("DEFERS the represent while the session is busy and the obligation is younger than the grace", () => {
394
+ const ledger = new ObligationLedger(2);
395
+ const pushed: InboundMessage[] = [];
396
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
397
+ open(ledger, Date.now()); // age ~0 < GRACE
398
+ wiring.obligationSweep();
399
+ expect(pushed).toHaveLength(0); // deferred — no represent buffered
400
+ expect(ledger.isOpen(SWEEP_ORIGIN)).toBe(true);
401
+ });
402
+
403
+ it("RE-ASKS once the bound expires even though the session is still busy (bounded, not silent)", () => {
404
+ const ledger = new ObligationLedger(2);
405
+ const pushed: InboundMessage[] = [];
406
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
407
+ open(ledger, Date.now() - (GRACE + 60_000)); // age > GRACE → bound expired
408
+ wiring.obligationSweep();
409
+ expect(pushed).toHaveLength(1); // represent fires despite the still-busy session
410
+ expect(pushed[0]?.meta?.source).toBe("obligation_represent");
411
+ });
412
+
413
+ it("does NOT defer when the session is idle (represent fires immediately)", () => {
414
+ const ledger = new ObligationLedger(2);
415
+ const pushed: InboundMessage[] = [];
416
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: false });
417
+ open(ledger, Date.now()); // young, but session is genuinely idle
418
+ wiring.obligationSweep();
419
+ expect(pushed).toHaveLength(1);
420
+ expect(pushed[0]?.meta?.source).toBe("obligation_represent");
421
+ });
422
+ });
423
+
174
424
  describe("represent_count cap is honored by the ledger — a misdetected obligation cannot loop", () => {
175
425
  it("escalates (stops re-presenting) once representCount reaches maxRepresents", () => {
176
426
  const L = new ObligationLedger(2); // maxRepresents = 2