switchroom 0.20.7 → 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 +105 -11
- package/dist/auth-broker/index.js +68 -7
- package/dist/cli/notion-write-pretool.mjs +67 -6
- package/dist/cli/switchroom.js +384 -28
- package/dist/host-control/main.js +69 -8
- package/dist/vault/approvals/kernel-server.js +68 -7
- package/dist/vault/broker/server.js +68 -7
- 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 +237 -122
- 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 +28 -30
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
- package/telegram-plugin/gateway/stream-render.ts +11 -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/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +45 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -660,7 +660,7 @@ import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
|
660
660
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
661
661
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
662
662
|
import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
|
|
663
|
-
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
663
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
|
|
664
664
|
import {
|
|
665
665
|
ObligationLedger,
|
|
666
666
|
obligationEscalationText,
|
|
@@ -857,7 +857,7 @@ import {
|
|
|
857
857
|
resolvePersonaName, shouldSkipDuplicateBootCard,
|
|
858
858
|
type BootCardHandle, type RestartReason,
|
|
859
859
|
} from './boot-card.js'
|
|
860
|
-
import { determineRestartReason } from './boot-reason.js'
|
|
860
|
+
import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
|
|
861
861
|
import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
862
862
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
863
863
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
@@ -9163,6 +9163,8 @@ let activeBootCard: BootCardHandle | null = null
|
|
|
9163
9163
|
// dedupe checks this so it can't race against the boot path's await.
|
|
9164
9164
|
// See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
|
|
9165
9165
|
let bootCardPending = false
|
|
9166
|
+
// Boot-determined restart reason — see determineBridgeReconnectReason (B2).
|
|
9167
|
+
let bootReasonAtStartup: RestartReason | null = null
|
|
9166
9168
|
|
|
9167
9169
|
// Issues card (#428) — pinned per-agent surface listing current
|
|
9168
9170
|
// unresolved entries from the issue sink (#425). Idempotent across
|
|
@@ -10366,17 +10368,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10366
10368
|
|
|
10367
10369
|
onClientRegistered(client: IpcClient) {
|
|
10368
10370
|
process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
|
|
10369
|
-
// Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
|
|
10370
|
-
//
|
|
10371
|
-
//
|
|
10372
|
-
//
|
|
10373
|
-
//
|
|
10371
|
+
// Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap,
|
|
10372
|
+
// STATUS-SILENT session — it must NOT drive the gateway's singleton
|
|
10373
|
+
// machinery. Drain any boot-window cron fire to it, then return early.
|
|
10374
|
+
// #4348: the drain routes through `redeliverBufferedInbound` (the shared
|
|
10375
|
+
// ack chokepoint) so a spooled fire is acked and never re-fires on restart.
|
|
10374
10376
|
if (isCronIdentity(client.agentName)) {
|
|
10375
|
-
client
|
|
10376
|
-
const pending = pendingInboundBuffer.drain(client.agentName ?? '')
|
|
10377
|
-
for (const m of pending) {
|
|
10378
|
-
try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
|
|
10379
|
-
}
|
|
10377
|
+
drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
|
|
10380
10378
|
return
|
|
10381
10379
|
}
|
|
10382
10380
|
// Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
|
|
@@ -10450,15 +10448,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10450
10448
|
})
|
|
10451
10449
|
}
|
|
10452
10450
|
|
|
10453
|
-
// If the agent reconnected after a
|
|
10454
|
-
//
|
|
10455
|
-
//
|
|
10456
|
-
//
|
|
10457
|
-
//
|
|
10458
|
-
//
|
|
10459
|
-
// 2026-05-01). The original dedupe (msgId 2245+2248, 2026-04-26) only
|
|
10460
|
-
// covered the post-resolution case; bootCardPending closes the in-flight
|
|
10461
|
-
// race window. See `shouldSkipDuplicateBootCard`.
|
|
10451
|
+
// If the agent reconnected after a restart, post a boot card. The
|
|
10452
|
+
// restart-marker carries the ack chat; else fall back to resolveBootChatId
|
|
10453
|
+
// so crash-recovery reconnects also get a card. Skip if the boot path
|
|
10454
|
+
// already posted a card OR its sendMessage is in-flight (issue #489,
|
|
10455
|
+
// klanker 2026-05-01; earlier dedupe 2026-04-26 only covered the
|
|
10456
|
+
// post-resolution case). See `shouldSkipDuplicateBootCard`.
|
|
10462
10457
|
const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
|
|
10463
10458
|
if (dedupeDecision.skip) {
|
|
10464
10459
|
process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
|
|
@@ -10475,7 +10470,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10475
10470
|
clearRestartMarker()
|
|
10476
10471
|
}
|
|
10477
10472
|
|
|
10478
|
-
|
|
10473
|
+
// B2: boot path cleared the markers — reuse its reason (boot-reason.ts).
|
|
10474
|
+
const reason = determineBridgeReconnectReason({ marker, cleanMarker, sessionMarker: storedSession,
|
|
10475
|
+
now: nowMs, gatewayStartedAtMs: GATEWAY_STARTED_AT_MS, bootReason: bootReasonAtStartup })
|
|
10479
10476
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
10480
10477
|
|
|
10481
10478
|
if (target) {
|
|
@@ -11760,7 +11757,9 @@ const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
|
11760
11757
|
// while the CLI is still producing the answer; draining a represent into that busy
|
|
11761
11758
|
// session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
|
|
11762
11759
|
// bounded so a wedged session still drains (F1 then retracts it post-reply).
|
|
11763
|
-
|
|
11760
|
+
// staleGap = 3 poll intervals: a longer gap between busy consultations means the
|
|
11761
|
+
// buffer drained via a non-idle path so the next one is a NEW episode (#4341 f/u).
|
|
11762
|
+
const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS, IDLE_DRAIN_INTERVAL_MS * 3)
|
|
11764
11763
|
if (isGatewayMain && !STATIC) {
|
|
11765
11764
|
setInterval(() => {
|
|
11766
11765
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
@@ -23490,15 +23489,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23490
23489
|
} else {
|
|
23491
23490
|
const markerAgeMs = marker ? nowMs - marker.ts : undefined
|
|
23492
23491
|
const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
|
|
23492
|
+
// Markers were cleared above — stash for bridge re-register (B2).
|
|
23493
|
+
bootReasonAtStartup = reason
|
|
23493
23494
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
23494
23495
|
|
|
23495
|
-
// Issue #92: when reason='crash' AND no chat is resolvable,
|
|
23496
|
-
//
|
|
23497
|
-
//
|
|
23498
|
-
//
|
|
23499
|
-
// pipeline, which broadcasts to access.allowFrom (same path
|
|
23500
|
-
// permission requests use). The pipeline's per-agent per-kind
|
|
23501
|
-
// cooldown protects against crash loops spamming the chat.
|
|
23496
|
+
// Issue #92: when reason='crash' AND no chat is resolvable, the
|
|
23497
|
+
// gateway used to silently skip. Always surface unplanned crashes
|
|
23498
|
+
// via the operator-events pipeline (broadcasts to access.allowFrom;
|
|
23499
|
+
// its per-agent per-kind cooldown absorbs crash loops).
|
|
23502
23500
|
if (reason === 'crash') {
|
|
23503
23501
|
const cleanMarkerStale = cleanMarker
|
|
23504
23502
|
? !shouldSuppressRecoveryBanner(cleanMarker, nowMs, CLEAN_SHUTDOWN_MAX_AGE_MS)
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
|
|
57
57
|
} from '../tool-activity-summary.js'
|
|
58
58
|
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
59
|
+
import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
|
|
59
60
|
import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
|
|
60
61
|
import { chatKeyWithSuffix } from './chat-key.js'
|
|
61
62
|
import {
|
|
@@ -880,7 +881,26 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
880
881
|
// before the delete. turn-end stays the idempotent backstop: it no-ops
|
|
881
882
|
// once nothing is claimed for the key.
|
|
882
883
|
await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
|
|
883
|
-
|
|
884
|
+
// #4348 — silent-sentinel suppression. A turn whose entire user-facing
|
|
885
|
+
// outcome is a bare NO_REPLY / HEARTBEAT_OK (the sentinel-reply-guard
|
|
886
|
+
// dropped a sentinel-only reply, or the flush safety net classified the
|
|
887
|
+
// captured text as silent) said nothing to the user, so its activity/
|
|
888
|
+
// telemetry card is pure noise — most visibly on the forced-synthesis
|
|
889
|
+
// handback turns a background sub-agent injects. DELETE the card rather
|
|
890
|
+
// than finalizing a visible `done · … · ✓ NO_REPLY` record. Deterministic
|
|
891
|
+
// (reuses the shared `turn-flush-safety` silent predicates, no model
|
|
892
|
+
// behaviour) and normal-case-safe (`finalAnswerEverDelivered` keeps every
|
|
893
|
+
// card for a turn that actually delivered a substantive answer). The
|
|
894
|
+
// `finalHtmlOverride` finalize path is only taken by the foreground
|
|
895
|
+
// handoff-clear, which fires on a delivered final answer — so this gate
|
|
896
|
+
// never contends with it.
|
|
897
|
+
const silentSentinelTurn = isSilentSentinelCardOutcome({
|
|
898
|
+
replyCalled: turn.replyCalled,
|
|
899
|
+
lastReplyText: turn.lastReplyText,
|
|
900
|
+
capturedText: turn.capturedText,
|
|
901
|
+
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
902
|
+
})
|
|
903
|
+
if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
|
|
884
904
|
try {
|
|
885
905
|
await robustApiCall(
|
|
886
906
|
() => bot.api.deleteMessage(chat, id),
|
|
@@ -137,19 +137,50 @@ export function makeRepresentRedeliveryGuard(
|
|
|
137
137
|
* ordinary busy turn (which ends well within the bound) defers cleanly and never
|
|
138
138
|
* consumes the wedge budget.
|
|
139
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
|
+
*
|
|
140
153
|
* `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
|
|
141
|
-
* background-work grace being disabled).
|
|
154
|
+
* background-work grace being disabled). `staleGapMs <= 0` disables the
|
|
155
|
+
* gap-based episode reset (leaving only the `busy=false` reset).
|
|
142
156
|
*/
|
|
157
|
+
export const DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15_000
|
|
158
|
+
|
|
143
159
|
export function makeSessionBusyDrainDeferral(
|
|
144
160
|
boundMs: number,
|
|
161
|
+
staleGapMs: number = DEFAULT_DRAIN_DEFER_STALE_GAP_MS,
|
|
145
162
|
): (busy: boolean, now: number) => boolean {
|
|
146
163
|
let deferringSince: number | null = null
|
|
164
|
+
let lastCallAt: number | null = null
|
|
147
165
|
return (busy, now) => {
|
|
166
|
+
const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt
|
|
167
|
+
lastCallAt = now
|
|
148
168
|
if (!busy || boundMs <= 0) {
|
|
149
169
|
deferringSince = null
|
|
150
170
|
return false
|
|
151
171
|
}
|
|
152
|
-
|
|
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
|
+
}
|
|
153
184
|
// Bounded: stop deferring once we have been busy past the ceiling, so a
|
|
154
185
|
// wedged/hung session still eventually drains the buffered represent.
|
|
155
186
|
return now - deferringSince < boundMs
|
|
@@ -1138,8 +1138,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1138
1138
|
}
|
|
1139
1139
|
if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
|
|
1140
1140
|
const cardChatId = ev.chatId
|
|
1141
|
-
|
|
1142
|
-
|
|
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)
|
|
1143
1152
|
void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
|
|
1144
1153
|
if (cardId == null) return
|
|
1145
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;
|