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.
- 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 +130 -23
- package/telegram-plugin/gateway/gateway.ts +23 -25
- 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/stream-render.ts +13 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/represent-guard.test.ts +250 -0
|
@@ -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
|