switchroom 0.20.5 → 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.
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +3 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +4 -3
- package/dist/host-control/main.js +4 -3
- package/dist/vault/approvals/kernel-server.js +3 -2
- package/dist/vault/broker/server.js +3 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +188 -31
- package/telegram-plugin/gateway/boot-sweep-gate.ts +24 -0
- package/telegram-plugin/gateway/gateway.ts +31 -33
- package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
- package/telegram-plugin/gateway/represent-delivery-guard.ts +157 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +39 -2
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +162 -0
- package/telegram-plugin/gateway/stream-render.ts +13 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +15 -2
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/represent-guard.test.ts +250 -0
|
@@ -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
|