switchroom 0.20.6 → 0.20.8
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 +106 -12
- package/dist/auth-broker/index.js +71 -9
- package/dist/cli/notion-write-pretool.mjs +68 -7
- package/dist/cli/switchroom.js +387 -30
- package/dist/host-control/main.js +72 -10
- package/dist/vault/approvals/kernel-server.js +71 -9
- package/dist/vault/broker/server.js +71 -9
- package/package.json +1 -1
- package/telegram-plugin/bridge/ipc-client.ts +17 -1
- package/telegram-plugin/dist/bridge/bridge.js +5 -2
- package/telegram-plugin/dist/gateway/gateway.js +342 -120
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +50 -54
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- 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 +188 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -2
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +295 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
* Episode isolation (#4341 follow-up): `deferringSince` used to reset ONLY on a
|
|
141
|
+
* `busy=false` call, but the predicate is not consulted on every drain path. When
|
|
142
|
+
* a buffer is emptied by a bridge re-register (`onClientRegistered`) while the
|
|
143
|
+
* session is still busy, the idle-drain gate is never invoked with `busy=false`,
|
|
144
|
+
* so `deferringSince` stays pinned at the old t0. A later, UNRELATED deferral
|
|
145
|
+
* episode then computes `now - t0 >= boundMs` immediately and drains a represent
|
|
146
|
+
* into a mid-answer session — reopening the duplicate window this guard closes.
|
|
147
|
+
* Fix: a fresh deferral episode also starts the clock at `now` when the predicate
|
|
148
|
+
* has not been consulted within `staleGapMs`. The idle-drain gate polls on a
|
|
149
|
+
* fixed short interval while (and only while) the buffer is non-empty, so a gap
|
|
150
|
+
* between consecutive consultations longer than a few poll intervals means the
|
|
151
|
+
* prior episode's buffer drained via a non-idle path and this is a new episode.
|
|
152
|
+
*
|
|
153
|
+
* `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
|
|
154
|
+
* background-work grace being disabled). `staleGapMs <= 0` disables the
|
|
155
|
+
* gap-based episode reset (leaving only the `busy=false` reset).
|
|
156
|
+
*/
|
|
157
|
+
export const DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15_000
|
|
158
|
+
|
|
159
|
+
export function makeSessionBusyDrainDeferral(
|
|
160
|
+
boundMs: number,
|
|
161
|
+
staleGapMs: number = DEFAULT_DRAIN_DEFER_STALE_GAP_MS,
|
|
162
|
+
): (busy: boolean, now: number) => boolean {
|
|
163
|
+
let deferringSince: number | null = null
|
|
164
|
+
let lastCallAt: number | null = null
|
|
165
|
+
return (busy, now) => {
|
|
166
|
+
const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt
|
|
167
|
+
lastCallAt = now
|
|
168
|
+
if (!busy || boundMs <= 0) {
|
|
169
|
+
deferringSince = null
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
// Start (or restart) the clock at the top of a NEW deferral episode:
|
|
173
|
+
// - we were not deferring (deferringSince cleared by a busy=false call), OR
|
|
174
|
+
// - the predicate has not been consulted within staleGapMs, which means the
|
|
175
|
+
// prior episode's buffer drained via a path that never calls us with
|
|
176
|
+
// busy=false (bridge re-register) and left deferringSince pinned at a
|
|
177
|
+
// stale t0. Either way a fresh episode's bound must run from `now`.
|
|
178
|
+
if (
|
|
179
|
+
deferringSince == null ||
|
|
180
|
+
(staleGapMs > 0 && gapSinceLastCall != null && gapSinceLastCall > staleGapMs)
|
|
181
|
+
) {
|
|
182
|
+
deferringSince = now
|
|
183
|
+
}
|
|
184
|
+
// Bounded: stop deferring once we have been busy past the ceiling, so a
|
|
185
|
+
// wedged/hung session still eventually drains the buffered represent.
|
|
186
|
+
return now - deferringSince < boundMs
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -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).
|
|
@@ -1125,8 +1138,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1125
1138
|
}
|
|
1126
1139
|
if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
|
|
1127
1140
|
const cardChatId = ev.chatId
|
|
1128
|
-
|
|
1129
|
-
|
|
1141
|
+
// Reply-anchor ONLY to a plausible real Telegram message id. Synthetic
|
|
1142
|
+
// enqueues (subagent handback, boot resume, cron) fabricate `messageId`
|
|
1143
|
+
// from `Date.now()` (~1.78e13) — finite, so a bare `Number.isFinite`
|
|
1144
|
+
// guard passes it, but `reply_parameters.message_id` hard-rejects
|
|
1145
|
+
// anything beyond signed int32 with 400 `field "message_id" must be a
|
|
1146
|
+
// valid Number` (`allow_sending_without_reply` does NOT bypass the
|
|
1147
|
+
// range check), killing the whole card send (overlord
|
|
1148
|
+
// gateway-supervisor.log 2026-08-04, e.g. msg=1785846295635). Same bug
|
|
1149
|
+
// class as the resume-dark-feed incident — reuse its guard, don't
|
|
1150
|
+
// re-derive a weaker one. An unanchored card is fine; no card is not.
|
|
1151
|
+
const replyTo = parseSourceMessageId(ev.messageId)
|
|
1130
1152
|
void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
|
|
1131
1153
|
if (cardId == null) return
|
|
1132
1154
|
// Still parked → adopt on the next dequeue. Otherwise the entry already
|
|
@@ -164,3 +164,91 @@ describe('determineRestartReason', () => {
|
|
|
164
164
|
expect(result).toBe('crash')
|
|
165
165
|
})
|
|
166
166
|
})
|
|
167
|
+
|
|
168
|
+
// ── determineBridgeReconnectReason (fleet-audit B2) ────────────────────────
|
|
169
|
+
//
|
|
170
|
+
// Live trace this pins (kdogg gateway-supervisor.log, 2026-08-02):
|
|
171
|
+
// 06:27:43 shutdown.clean_marker_written reason="cli: restart"
|
|
172
|
+
// 06:27:46 boot.clean_shutdown_detected → boot path logs reason=graceful
|
|
173
|
+
// …and CLEARS the clean-shutdown marker (2026-05-25 GC)
|
|
174
|
+
// 06:27:55 surviving bridge re-registers → old code re-derived from the
|
|
175
|
+
// now-empty disk and logged reason=crash for a graceful restart.
|
|
176
|
+
// Fleet-wide: lawgpt 310 / reggie 179 / ziggy 175 / kdogg 174 such lines.
|
|
177
|
+
|
|
178
|
+
import { determineBridgeReconnectReason } from '../gateway/boot-reason.js'
|
|
179
|
+
|
|
180
|
+
describe('determineBridgeReconnectReason', () => {
|
|
181
|
+
it('reuses the boot-determined reason when the boot path consumed the markers (kdogg 2026-08-02 trace)', () => {
|
|
182
|
+
const result = determineBridgeReconnectReason({
|
|
183
|
+
marker: null, // cleared by the boot path
|
|
184
|
+
cleanMarker: null, // cleared by the boot path
|
|
185
|
+
sessionMarker: sessionMarker(),
|
|
186
|
+
now: NOW,
|
|
187
|
+
gatewayStartedAtMs: NOW - 10_000, // bridge re-registered 10s after boot
|
|
188
|
+
bootReason: 'graceful',
|
|
189
|
+
})
|
|
190
|
+
// Old behavior (plain determineRestartReason on the empty disk state)
|
|
191
|
+
// returned 'crash' here — the B2 mislabel.
|
|
192
|
+
expect(result).toBe('graceful')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('a still-present fresh restart marker wins over the cached boot reason', () => {
|
|
196
|
+
const result = determineBridgeReconnectReason({
|
|
197
|
+
marker: recentMarker(10_000),
|
|
198
|
+
cleanMarker: null,
|
|
199
|
+
sessionMarker: sessionMarker(),
|
|
200
|
+
now: NOW,
|
|
201
|
+
gatewayStartedAtMs: NOW - 10_000,
|
|
202
|
+
bootReason: 'graceful',
|
|
203
|
+
})
|
|
204
|
+
expect(result).toBe('planned')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('a still-present fresh clean-shutdown marker wins over the cached boot reason', () => {
|
|
208
|
+
const result = determineBridgeReconnectReason({
|
|
209
|
+
marker: null,
|
|
210
|
+
cleanMarker: recentCleanMarker(5_000),
|
|
211
|
+
sessionMarker: sessionMarker(),
|
|
212
|
+
now: NOW,
|
|
213
|
+
gatewayStartedAtMs: NOW - 10_000,
|
|
214
|
+
bootReason: 'crash',
|
|
215
|
+
})
|
|
216
|
+
expect(result).toBe('graceful')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('late re-register (gateway up past the reuse window) still classifies as crash', () => {
|
|
220
|
+
const result = determineBridgeReconnectReason({
|
|
221
|
+
marker: null,
|
|
222
|
+
cleanMarker: null,
|
|
223
|
+
sessionMarker: sessionMarker(),
|
|
224
|
+
now: NOW,
|
|
225
|
+
gatewayStartedAtMs: NOW - 6 * 60_000, // gateway alive 6 min — bridge side died
|
|
226
|
+
bootReason: 'graceful',
|
|
227
|
+
})
|
|
228
|
+
expect(result).toBe('crash')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('no recorded boot reason falls back to the plain derivation', () => {
|
|
232
|
+
const result = determineBridgeReconnectReason({
|
|
233
|
+
marker: null,
|
|
234
|
+
cleanMarker: null,
|
|
235
|
+
sessionMarker: sessionMarker(),
|
|
236
|
+
now: NOW,
|
|
237
|
+
gatewayStartedAtMs: NOW - 10_000,
|
|
238
|
+
bootReason: null,
|
|
239
|
+
})
|
|
240
|
+
expect(result).toBe('crash')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('fresh first boot (no session marker) reuses bootReason fresh', () => {
|
|
244
|
+
const result = determineBridgeReconnectReason({
|
|
245
|
+
marker: null,
|
|
246
|
+
cleanMarker: null,
|
|
247
|
+
sessionMarker: null,
|
|
248
|
+
now: NOW,
|
|
249
|
+
gatewayStartedAtMs: NOW - 3_000,
|
|
250
|
+
bootReason: 'fresh',
|
|
251
|
+
})
|
|
252
|
+
expect(result).toBe('fresh')
|
|
253
|
+
})
|
|
254
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4348 — the Tier-1 cheap-cron bridge-register drain must route through the
|
|
3
|
+
* shared `redeliverBufferedInbound` chokepoint so a delivered cron fire is
|
|
4
|
+
* `spool.ack`'d exactly once and CANNOT re-fire after a restart.
|
|
5
|
+
*
|
|
6
|
+
* The bug: `onClientRegistered`'s `<agent>-cron` branch did a raw
|
|
7
|
+
* `pendingInboundBuffer.drain()` + `client.send()` loop and returned early,
|
|
8
|
+
* never reaching `spool.ack` (which lives only inside
|
|
9
|
+
* `redeliverBufferedInbound`). A due cron tick spooled during the boot window
|
|
10
|
+
* (before the cron bridge registered) stayed live in the durable spool, so
|
|
11
|
+
* boot-replay re-pushed it on the next restart and the SAME fire was delivered
|
|
12
|
+
* a second time — a duplicate cron delivery bounded only by the 15-min
|
|
13
|
+
* escalation sweep.
|
|
14
|
+
*
|
|
15
|
+
* These tests assert the OUTCOME on the real drain seam
|
|
16
|
+
* (`drainCronBridgeOnRegister`), against a REAL spool + buffer:
|
|
17
|
+
* 1. the boot-window fire is delivered to the cron bridge, AND
|
|
18
|
+
* 2. its durable spool entry is acked (liveCount → 0), AND
|
|
19
|
+
* 3. a simulated restart's boot-replay finds nothing to re-push, so the fire
|
|
20
|
+
* does NOT re-fire.
|
|
21
|
+
*
|
|
22
|
+
* RED-before-GREEN: with the pre-#4348 raw-drain body the fire is delivered but
|
|
23
|
+
* the spool entry stays live, so assertions (2)+(3) fail. With the fix routing
|
|
24
|
+
* through `redeliverBufferedInbound` they pass.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest'
|
|
28
|
+
import {
|
|
29
|
+
createInboundSpool,
|
|
30
|
+
type InboundSpoolFsSeam,
|
|
31
|
+
type InboundSpool,
|
|
32
|
+
} from '../gateway/inbound-spool.js'
|
|
33
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
|
|
34
|
+
import { drainCronBridgeOnRegister, cronIdentity } from '../gateway/cron-session.js'
|
|
35
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
36
|
+
|
|
37
|
+
const SPOOL_PATH = '/state/agent/telegram/inbound-spool.jsonl'
|
|
38
|
+
|
|
39
|
+
/** In-memory fake fs for the spool — models append + atomic-rename compaction.
|
|
40
|
+
* Shared across "restarts" so the durable JSONL survives, exactly like the
|
|
41
|
+
* persistent per-agent volume. */
|
|
42
|
+
function fakeFs(): InboundSpoolFsSeam {
|
|
43
|
+
const files = new Map<string, string>()
|
|
44
|
+
return {
|
|
45
|
+
appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
|
|
46
|
+
readFileSync: (p) => files.get(p) ?? '',
|
|
47
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
48
|
+
renameSync: (from, to) => {
|
|
49
|
+
files.set(to, files.get(from) ?? '')
|
|
50
|
+
files.delete(from)
|
|
51
|
+
},
|
|
52
|
+
existsSync: (p) => files.has(p),
|
|
53
|
+
statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
|
|
54
|
+
fsyncFileSync: () => {},
|
|
55
|
+
fsyncDirSync: () => {},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cronFire(over: Partial<InboundMessage> = {}): InboundMessage {
|
|
60
|
+
return {
|
|
61
|
+
type: 'inbound',
|
|
62
|
+
chatId: 'c1',
|
|
63
|
+
messageId: 0, // synthetic — cron fires carry no Telegram messageId
|
|
64
|
+
user: 'system',
|
|
65
|
+
userId: 0,
|
|
66
|
+
ts: 1000,
|
|
67
|
+
text: 'Time for the daily digest',
|
|
68
|
+
meta: { source: 'cron', session: 'cron' },
|
|
69
|
+
...over,
|
|
70
|
+
} as InboundMessage
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A capturing stand-in for the just-registered cron IPC client. */
|
|
74
|
+
function fakeClient(agentName: string): {
|
|
75
|
+
agentName: string
|
|
76
|
+
send: (msg: unknown) => void
|
|
77
|
+
sent: unknown[]
|
|
78
|
+
} {
|
|
79
|
+
const sent: unknown[] = []
|
|
80
|
+
return { agentName, send: (m) => void sent.push(m), sent }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Simulate the gateway's boot-replay: re-push every live (un-acked) spool
|
|
84
|
+
* entry into a fresh in-memory buffer, exactly as gateway.ts does at boot. */
|
|
85
|
+
function bootReplayInto(spool: InboundSpool): ReturnType<typeof createPendingInboundBuffer> {
|
|
86
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
87
|
+
for (const { agent, msg } of spool.liveEntries()) buffer.push(agent, msg)
|
|
88
|
+
return buffer
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('#4348 cron-bridge drain routes through the spool-ack chokepoint', () => {
|
|
92
|
+
it('acks the boot-window cron fire and does not re-fire after restart', () => {
|
|
93
|
+
const fs = fakeFs()
|
|
94
|
+
const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
|
|
95
|
+
const cronAgent = cronIdentity('overlord') // "overlord-cron"
|
|
96
|
+
|
|
97
|
+
// Boot window: a due cron tick arrives BEFORE the cron bridge registers.
|
|
98
|
+
// It is buffered (in-memory) AND durably spooled by the same push.
|
|
99
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
100
|
+
buffer.push(cronAgent, cronFire())
|
|
101
|
+
expect(spool.liveCount()).toBe(1) // durably recorded, not yet delivered
|
|
102
|
+
|
|
103
|
+
// The cron bridge registers → the drain seam under test runs.
|
|
104
|
+
const client = fakeClient(cronAgent)
|
|
105
|
+
const result = drainCronBridgeOnRegister(client, buffer, spool)
|
|
106
|
+
|
|
107
|
+
// (1) The fire was delivered to the cron bridge.
|
|
108
|
+
expect(result.drained).toBe(1)
|
|
109
|
+
expect(result.redelivered).toBe(1)
|
|
110
|
+
const deliveredFires = client.sent.filter(
|
|
111
|
+
(m): m is InboundMessage => (m as InboundMessage).type === 'inbound',
|
|
112
|
+
)
|
|
113
|
+
expect(deliveredFires).toHaveLength(1)
|
|
114
|
+
expect(deliveredFires[0]!.text).toBe('Time for the daily digest')
|
|
115
|
+
|
|
116
|
+
// (2) The durable spool entry is tombstoned — the whole point of #4348.
|
|
117
|
+
expect(spool.liveCount()).toBe(0)
|
|
118
|
+
expect(spool.liveEntries()).toHaveLength(0)
|
|
119
|
+
|
|
120
|
+
// (3) Simulated restart: boot-replay finds nothing to re-push, so the SAME
|
|
121
|
+
// fire does NOT re-fire. (Pre-fix this replayed the un-acked entry.)
|
|
122
|
+
const afterRestart = bootReplayInto(spool)
|
|
123
|
+
expect(afterRestart.drain(cronAgent)).toHaveLength(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('a send failure re-buffers the fire and leaves the spool entry live (lossless)', () => {
|
|
127
|
+
const fs = fakeFs()
|
|
128
|
+
const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
|
|
129
|
+
const cronAgent = cronIdentity('overlord')
|
|
130
|
+
|
|
131
|
+
const buffer = createPendingInboundBuffer({ log: () => {}, spool })
|
|
132
|
+
buffer.push(cronAgent, cronFire())
|
|
133
|
+
|
|
134
|
+
// Client whose send throws for inbound fires (bridge wedged mid-drain).
|
|
135
|
+
const throwing = {
|
|
136
|
+
agentName: cronAgent,
|
|
137
|
+
send: (m: unknown) => {
|
|
138
|
+
if ((m as InboundMessage).type === 'inbound') throw new Error('socket gone')
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
const result = drainCronBridgeOnRegister(throwing, buffer, spool)
|
|
142
|
+
|
|
143
|
+
// Not delivered → re-buffered, and the spool entry stays LIVE so the next
|
|
144
|
+
// register (or boot-replay) retries it. Nothing is dropped, nothing acked.
|
|
145
|
+
expect(result.redelivered).toBe(0)
|
|
146
|
+
expect(result.rebuffered).toBe(1)
|
|
147
|
+
expect(spool.liveCount()).toBe(1)
|
|
148
|
+
expect(buffer.drain(cronAgent)).toHaveLength(1)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for fleet-audit B2 — the bridge's background reconnect
|
|
3
|
+
* loop must never surface a failed connect attempt as a process-level
|
|
4
|
+
* unhandledRejection.
|
|
5
|
+
*
|
|
6
|
+
* Live evidence (kdogg, 2026-07-17):
|
|
7
|
+
* bridge-crash.log: `unhandledRejection … Error: Failed to connect
|
|
8
|
+
* | at doConnect (dist/server.js:24188) …`
|
|
9
|
+
*
|
|
10
|
+
* Mechanism: `scheduleReconnect()`'s timer invoked `doConnect()` without a
|
|
11
|
+
* rejection handler. `doConnect` returns a promise that REJECTS whenever
|
|
12
|
+
* the `Bun.connect` attempt fails (gateway restarting out from under a
|
|
13
|
+
* long-lived bridge — the exact window every planned `docker restart`
|
|
14
|
+
* opens). The initial-connect call sites attach a `.catch`; the retry
|
|
15
|
+
* timer did not, so every failed background retry escaped as an
|
|
16
|
+
* unhandledRejection. Pre-#3033 that killed the bridge process outright
|
|
17
|
+
* (Claude Code never respawns a dead MCP server → mute agent); post-#3033
|
|
18
|
+
* it still spams `bridge-crash.log` with pseudo-crash breadcrumbs and
|
|
19
|
+
* leans on a global process handler for survival.
|
|
20
|
+
*
|
|
21
|
+
* Outcome asserted: with nothing listening on the socket path, the client
|
|
22
|
+
* runs through its initial attempt AND several background retries without
|
|
23
|
+
* a single unhandledRejection reaching the process. RED without the
|
|
24
|
+
* `.catch` in scheduleReconnect's timer, GREEN with it.
|
|
25
|
+
*
|
|
26
|
+
* Run with: bun test telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect } from "bun:test";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { createIpcClient } from "../bridge/ipc-client.js";
|
|
32
|
+
|
|
33
|
+
describe("ipc-client background reconnect", () => {
|
|
34
|
+
it("a failed reconnect attempt never escapes as a process-level unhandledRejection", async () => {
|
|
35
|
+
const captured: unknown[] = [];
|
|
36
|
+
const onUnhandled = (err: unknown) => {
|
|
37
|
+
captured.push(err);
|
|
38
|
+
};
|
|
39
|
+
process.on("unhandledRejection", onUnhandled);
|
|
40
|
+
|
|
41
|
+
// Nothing listens here — every connect attempt fails, exercising both
|
|
42
|
+
// the (already-handled) initial attempt and the retry-timer path.
|
|
43
|
+
const socketPath = join(tmpdir(), `ipc-b2-${crypto.randomUUID()}.sock`);
|
|
44
|
+
|
|
45
|
+
const handle = await createIpcClient({
|
|
46
|
+
socketPath,
|
|
47
|
+
agentName: "b2-test",
|
|
48
|
+
onInbound: () => {},
|
|
49
|
+
onPermission: () => {},
|
|
50
|
+
onStatus: () => {},
|
|
51
|
+
log: () => {},
|
|
52
|
+
reconnectDelayMs: 15,
|
|
53
|
+
maxReconnectDelayMs: 30,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Long enough for several background retries (15ms, 30ms, 30ms, …)
|
|
58
|
+
// plus the macrotask on which unhandledRejection is delivered.
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
60
|
+
} finally {
|
|
61
|
+
handle.close();
|
|
62
|
+
// Give any in-flight rejection its delivery tick before detaching.
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
64
|
+
process.off("unhandledRejection", onUnhandled);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(handle.isConnected()).toBe(false);
|
|
68
|
+
expect(captured).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -282,6 +282,92 @@ describe('narrative-lane golden — clearActivitySummary finalize', () => {
|
|
|
282
282
|
})
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
+
// ── #4348 — silent-sentinel activity card is SUPPRESSED (deleted, not finalized) ─
|
|
286
|
+
// A turn whose entire user-facing outcome is a bare NO_REPLY / HEARTBEAT_OK
|
|
287
|
+
// (the sentinel-reply-guard dropped the sentinel-only reply, or the flush net
|
|
288
|
+
// classified the captured text as silent) must leave NO visible telemetry card
|
|
289
|
+
// in the chat — most visibly on the forced-synthesis handback turns a
|
|
290
|
+
// background sub-agent injects. A real reply's card must still finalize.
|
|
291
|
+
describe('narrative-lane golden — silent-sentinel card suppression (#4348)', () => {
|
|
292
|
+
// Open the feed card on a fresh (working) turn FIRST — the card-open gate
|
|
293
|
+
// (mayOpenActivityCard) won't open one once the answer is already delivered —
|
|
294
|
+
// then stamp the turn's terminal outcome, exactly as the real turn does:
|
|
295
|
+
// the card opens mid-work, and lastReplyText / finalAnswerEverDelivered are
|
|
296
|
+
// only known at turn end.
|
|
297
|
+
async function openThenClear(over: Partial<CurrentTurn>) {
|
|
298
|
+
const { lane, calls } = makeLane()
|
|
299
|
+
const turn = makeLaneTurn(lane)
|
|
300
|
+
lane.showNarrativeStep(turn, 'Doing the work now')
|
|
301
|
+
await turn.activityInFlight
|
|
302
|
+
const cardId = turn.activityMessageId
|
|
303
|
+
expect(cardId).not.toBeNull()
|
|
304
|
+
Object.assign(turn, over)
|
|
305
|
+
lane.clearActivitySummary(turn)
|
|
306
|
+
await settle()
|
|
307
|
+
return { calls, cardId, turn }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it('reply("NO_REPLY") turn: DELETES the card, never edits a done record', async () => {
|
|
311
|
+
// The guard drops the sentinel-only reply before chat, but the blocked
|
|
312
|
+
// tool_use still stamps lastReplyText — the exact `✓ NO_REPLY` noise card.
|
|
313
|
+
const { calls, cardId, turn } = await openThenClear({
|
|
314
|
+
replyCalled: true,
|
|
315
|
+
lastReplyText: 'NO_REPLY',
|
|
316
|
+
finalAnswerEverDelivered: false,
|
|
317
|
+
})
|
|
318
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
319
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
320
|
+
expect(turn.activityMessageId).toBeNull()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('HEARTBEAT_OK. (trailing punctuation, case-insensitive) is also suppressed', async () => {
|
|
324
|
+
const { calls, cardId } = await openThenClear({
|
|
325
|
+
replyCalled: true,
|
|
326
|
+
lastReplyText: 'heartbeat_ok.',
|
|
327
|
+
finalAnswerEverDelivered: false,
|
|
328
|
+
})
|
|
329
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
330
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is suppressed', async () => {
|
|
334
|
+
const { calls, cardId } = await openThenClear({
|
|
335
|
+
replyCalled: false,
|
|
336
|
+
lastReplyText: '',
|
|
337
|
+
capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
|
|
338
|
+
finalAnswerEverDelivered: false,
|
|
339
|
+
})
|
|
340
|
+
expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
|
|
341
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('normal reply turn: still FINALIZES the card (edit, no delete)', async () => {
|
|
345
|
+
// The guarantee the fix must not break: a real answer keeps its record.
|
|
346
|
+
const { calls, cardId, turn } = await openThenClear({
|
|
347
|
+
replyCalled: true,
|
|
348
|
+
lastReplyText: 'The fix is deployed and the tests are green.',
|
|
349
|
+
finalAnswerEverDelivered: true,
|
|
350
|
+
})
|
|
351
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
352
|
+
.toBeGreaterThanOrEqual(1)
|
|
353
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
354
|
+
expect(turn.activityMessageId).toBeNull()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('reply "prose\\nNO_REPLY" that WAS delivered (finalAnswerEverDelivered) keeps its card', async () => {
|
|
358
|
+
// The guard does NOT drop a reply with non-marker content, so its prose
|
|
359
|
+
// reached chat — endsWithSilentMarker must NOT suppress a delivered reply.
|
|
360
|
+
const { calls, cardId } = await openThenClear({
|
|
361
|
+
replyCalled: true,
|
|
362
|
+
lastReplyText: 'Here is the full answer to your question.\nNO_REPLY',
|
|
363
|
+
finalAnswerEverDelivered: true,
|
|
364
|
+
})
|
|
365
|
+
expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
|
|
366
|
+
.toBeGreaterThanOrEqual(1)
|
|
367
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
285
371
|
// ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
|
|
286
372
|
// The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
|
|
287
373
|
// into its deps, records the delivered answer into ONE OutboundDedupCache;
|