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
|
@@ -24317,11 +24317,14 @@ function createIpcClient(options) {
|
|
|
24317
24317
|
function scheduleReconnect() {
|
|
24318
24318
|
if (closed)
|
|
24319
24319
|
return;
|
|
24320
|
+
if (reconnectTimer)
|
|
24321
|
+
return;
|
|
24320
24322
|
log(`reconnecting in ${currentDelay}ms`);
|
|
24321
24323
|
reconnectTimer = setTimeout(() => {
|
|
24322
24324
|
reconnectTimer = null;
|
|
24323
|
-
if (!closed)
|
|
24324
|
-
doConnect();
|
|
24325
|
+
if (!closed) {
|
|
24326
|
+
doConnect().catch(() => {});
|
|
24327
|
+
}
|
|
24325
24328
|
}, currentDelay);
|
|
24326
24329
|
currentDelay = Math.min(currentDelay * 2, maxReconnectDelayMs);
|
|
24327
24330
|
}
|
|
@@ -83,3 +83,64 @@ export function determineRestartReason(opts: {
|
|
|
83
83
|
if (sessionMarker != null) return 'crash'
|
|
84
84
|
return 'fresh'
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Boot-reason window during which a bridge re-register reuses the reason
|
|
89
|
+
* the boot path already determined. Matches the restart-marker and
|
|
90
|
+
* operator-marker freshness windows (5 min) — a bridge that survived a
|
|
91
|
+
* gateway restart reconnects within seconds of the new gateway's boot,
|
|
92
|
+
* comfortably inside it.
|
|
93
|
+
*/
|
|
94
|
+
export const BOOT_REASON_REUSE_WINDOW_MS = 5 * 60_000
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Determine the restart reason for a BRIDGE RE-REGISTER (gateway.ts
|
|
98
|
+
* `onClientRegistered`, the `bridge-reconnect` path) — as opposed to the
|
|
99
|
+
* gateway's own boot path.
|
|
100
|
+
*
|
|
101
|
+
* Why this exists (fleet-audit B2, kdogg 2026-08-02 06:27 trace): on a
|
|
102
|
+
* planned gateway restart (`cli: restart` SIGTERM), the bridge — living
|
|
103
|
+
* inside the separate claude process — survives and reconnects a few
|
|
104
|
+
* seconds after the new gateway boots. By then the boot path has already
|
|
105
|
+
* READ AND CLEARED the restart / clean-shutdown markers (the 2026-05-25
|
|
106
|
+
* GC, gateway.ts boot path), so re-deriving the reason from disk falls
|
|
107
|
+
* through to the sessionMarker branch and every such re-register logs —
|
|
108
|
+
* and, when a chat is resolvable, POSTS a boot card claiming —
|
|
109
|
+
* `reason=crash` for a perfectly graceful restart. Hundreds of these per
|
|
110
|
+
* agent fleet-wide (lawgpt 310, reggie 179, ziggy 175, kdogg 174).
|
|
111
|
+
*
|
|
112
|
+
* Decision:
|
|
113
|
+
* 1. Any on-disk marker still present → normal `determineRestartReason`
|
|
114
|
+
* (in-gateway /restart flows where the gateway never went down write
|
|
115
|
+
* a marker the boot path never consumed — keep honoring it).
|
|
116
|
+
* 2. No markers, but the gateway booted recently (<5 min) and recorded
|
|
117
|
+
* the reason it determined at boot → reuse that reason. The bridge
|
|
118
|
+
* is re-registering into the SAME restart episode the boot path
|
|
119
|
+
* already classified.
|
|
120
|
+
* 3. Otherwise (gateway long-lived, markers absent) → fall through to
|
|
121
|
+
* `determineRestartReason` — a marker-less bridge re-register hours
|
|
122
|
+
* into a gateway's life still classifies conservatively as 'crash'
|
|
123
|
+
* (the claude/bridge side genuinely died and came back).
|
|
124
|
+
*/
|
|
125
|
+
export function determineBridgeReconnectReason(opts: {
|
|
126
|
+
marker: { ts: number } | null
|
|
127
|
+
cleanMarker: CleanShutdownMarker | null
|
|
128
|
+
sessionMarker: SessionMarker | null
|
|
129
|
+
now: number
|
|
130
|
+
/** `GATEWAY_STARTED_AT_MS` of the running gateway process. */
|
|
131
|
+
gatewayStartedAtMs: number
|
|
132
|
+
/** Reason the gateway's own boot path determined (null if it never ran). */
|
|
133
|
+
bootReason: RestartReason | null
|
|
134
|
+
bootReasonReuseWindowMs?: number
|
|
135
|
+
cleanMaxAgeMs?: number
|
|
136
|
+
markerMaxAgeMs?: number
|
|
137
|
+
operatorMaxAgeMs?: number
|
|
138
|
+
}): RestartReason {
|
|
139
|
+
const { gatewayStartedAtMs, bootReason, bootReasonReuseWindowMs = BOOT_REASON_REUSE_WINDOW_MS } = opts
|
|
140
|
+
if (opts.marker == null && opts.cleanMarker == null
|
|
141
|
+
&& bootReason != null
|
|
142
|
+
&& opts.now - gatewayStartedAtMs < bootReasonReuseWindowMs) {
|
|
143
|
+
return bootReason
|
|
144
|
+
}
|
|
145
|
+
return determineRestartReason(opts)
|
|
146
|
+
}
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import type { InboundMessage, GatewayToClient } from './ipc-protocol.js'
|
|
21
|
+
import type { InboundSpool } from './inbound-spool.js'
|
|
22
|
+
import { redeliverBufferedInbound, type PendingInboundBuffer } from './pending-inbound-buffer.js'
|
|
23
|
+
|
|
20
24
|
/** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
|
|
21
25
|
export const CRON_IDENTITY_SUFFIX = "-cron";
|
|
22
26
|
|
|
@@ -140,3 +144,65 @@ export function deliverInjectWithFallback(
|
|
|
140
144
|
}
|
|
141
145
|
return { target, delivered: false, fellBackToMain: false };
|
|
142
146
|
}
|
|
147
|
+
|
|
148
|
+
/** Minimal view of the IPC client the cron-bridge register handler needs.
|
|
149
|
+
* A structural subset of `IpcClient` so this stays unit-testable without the
|
|
150
|
+
* gateway's module-load side effects. */
|
|
151
|
+
export interface CronBridgeRegisterClient {
|
|
152
|
+
agentName: string | null | undefined;
|
|
153
|
+
send: (msg: GatewayToClient) => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Drain a cheap-cron (`<agent>-cron`) bridge's buffered fires when it
|
|
158
|
+
* registers — the Tier-1 §2.4/§3.3 status-silent path (#4348).
|
|
159
|
+
*
|
|
160
|
+
* The cron bridge is STATUS-SILENT: it must NOT drive the gateway's singleton
|
|
161
|
+
* machinery (shadow bridge-state, warmup, boot card). But it MUST still flush
|
|
162
|
+
* any cron fire buffered+spooled during the boot window — a due tick that
|
|
163
|
+
* arrived before the cron bridge registered.
|
|
164
|
+
*
|
|
165
|
+
* THE BUG (#4348): the pre-fix path did a raw `pendingInboundBuffer.drain()` +
|
|
166
|
+
* `client.send()` loop and returned early WITHOUT ever reaching `spool.ack`.
|
|
167
|
+
* `spool.ack` lives ONLY inside `redeliverBufferedInbound` — the one chokepoint
|
|
168
|
+
* every other drain path (bridgeUp, idle-drain, silence-poke, turn-end) routes
|
|
169
|
+
* through. So the durable spool entry stayed live, boot-replay re-pushed it on
|
|
170
|
+
* the next restart, and the SAME cron fire re-fired: a duplicate delivery
|
|
171
|
+
* bounded only by the 15-min escalation sweep. This was the ONLY drain that
|
|
172
|
+
* bypassed the chokepoint.
|
|
173
|
+
*
|
|
174
|
+
* THE FIX: route the drain through `redeliverBufferedInbound` too, so each
|
|
175
|
+
* delivered fire is spool-acked exactly once and cannot re-fire after a
|
|
176
|
+
* restart. `beforeRedeliver` (the represent-veto) rides along by construction;
|
|
177
|
+
* it self-gates and is a no-op for cron-sourced fires. A `send` throw now
|
|
178
|
+
* re-buffers the fire (lossless) instead of dropping it — strictly safer than
|
|
179
|
+
* the pre-fix best-effort drop and identical to every sibling drain path.
|
|
180
|
+
*
|
|
181
|
+
* Returns the `redeliverBufferedInbound` counts for observability.
|
|
182
|
+
*/
|
|
183
|
+
export function drainCronBridgeOnRegister(
|
|
184
|
+
client: CronBridgeRegisterClient,
|
|
185
|
+
buffer: PendingInboundBuffer,
|
|
186
|
+
spool?: InboundSpool,
|
|
187
|
+
log?: (line: string) => void,
|
|
188
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
|
|
189
|
+
// Status-silent handshake ack (unchanged from the pre-fix path).
|
|
190
|
+
client.send({ type: "status", status: "agent_connected" });
|
|
191
|
+
const send = (msg: InboundMessage): boolean => {
|
|
192
|
+
try {
|
|
193
|
+
client.send(msg);
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const result = redeliverBufferedInbound(buffer, client.agentName ?? "", send, spool);
|
|
200
|
+
if (result.drained > 0 && log != null) {
|
|
201
|
+
log(
|
|
202
|
+
`telegram gateway: cron-bridge drain agent=${client.agentName} ` +
|
|
203
|
+
`drained=${result.drained} redelivered=${result.redelivered} ` +
|
|
204
|
+
`rebuffered=${result.rebuffered}\n`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
@@ -461,7 +461,7 @@ import {
|
|
|
461
461
|
type SendReplyGatewayDeps,
|
|
462
462
|
type DeliverCapturedProseDeps,
|
|
463
463
|
} from './outbound-send-path.js'
|
|
464
|
-
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
|
|
464
|
+
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows, parkedTurnStartCount } from './stream-render.js'
|
|
465
465
|
import { createNarrativeLane } from './narrative-lane.js'
|
|
466
466
|
import {
|
|
467
467
|
parseAgentCallback,
|
|
@@ -659,7 +659,8 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
659
659
|
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
|
-
import {
|
|
662
|
+
import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
|
|
663
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
|
|
663
664
|
import {
|
|
664
665
|
ObligationLedger,
|
|
665
666
|
obligationEscalationText,
|
|
@@ -856,7 +857,7 @@ import {
|
|
|
856
857
|
resolvePersonaName, shouldSkipDuplicateBootCard,
|
|
857
858
|
type BootCardHandle, type RestartReason,
|
|
858
859
|
} from './boot-card.js'
|
|
859
|
-
import { determineRestartReason } from './boot-reason.js'
|
|
860
|
+
import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
|
|
860
861
|
import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
861
862
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
862
863
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
@@ -9162,6 +9163,8 @@ let activeBootCard: BootCardHandle | null = null
|
|
|
9162
9163
|
// dedupe checks this so it can't race against the boot path's await.
|
|
9163
9164
|
// See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
|
|
9164
9165
|
let bootCardPending = false
|
|
9166
|
+
// Boot-determined restart reason — see determineBridgeReconnectReason (B2).
|
|
9167
|
+
let bootReasonAtStartup: RestartReason | null = null
|
|
9165
9168
|
|
|
9166
9169
|
// Issues card (#428) — pinned per-agent surface listing current
|
|
9167
9170
|
// unresolved entries from the issue sink (#425). Idempotent across
|
|
@@ -9788,6 +9791,11 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9788
9791
|
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9789
9792
|
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9790
9793
|
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
9794
|
+
// F1 (fix/represent-double-send-delivery-recheck) — delivery-time represent
|
|
9795
|
+
// retract. Every drain path hands its buffered inbounds through this gate; a
|
|
9796
|
+
// stale `obligation_represent` (its reply landed since the sweep decided it) is
|
|
9797
|
+
// dropped here rather than duplicated into the CLI queue. See represent-delivery-guard.ts.
|
|
9798
|
+
beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) }),
|
|
9791
9799
|
})
|
|
9792
9800
|
|
|
9793
9801
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -10360,17 +10368,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10360
10368
|
|
|
10361
10369
|
onClientRegistered(client: IpcClient) {
|
|
10362
10370
|
process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
|
|
10363
|
-
// Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
|
|
10364
|
-
//
|
|
10365
|
-
//
|
|
10366
|
-
//
|
|
10367
|
-
//
|
|
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.
|
|
10368
10376
|
if (isCronIdentity(client.agentName)) {
|
|
10369
|
-
client
|
|
10370
|
-
const pending = pendingInboundBuffer.drain(client.agentName ?? '')
|
|
10371
|
-
for (const m of pending) {
|
|
10372
|
-
try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
|
|
10373
|
-
}
|
|
10377
|
+
drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
|
|
10374
10378
|
return
|
|
10375
10379
|
}
|
|
10376
10380
|
// Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
|
|
@@ -10444,15 +10448,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10444
10448
|
})
|
|
10445
10449
|
}
|
|
10446
10450
|
|
|
10447
|
-
// If the agent reconnected after a
|
|
10448
|
-
//
|
|
10449
|
-
//
|
|
10450
|
-
//
|
|
10451
|
-
//
|
|
10452
|
-
//
|
|
10453
|
-
// 2026-05-01). The original dedupe (msgId 2245+2248, 2026-04-26) only
|
|
10454
|
-
// covered the post-resolution case; bootCardPending closes the in-flight
|
|
10455
|
-
// 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`.
|
|
10456
10457
|
const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
|
|
10457
10458
|
if (dedupeDecision.skip) {
|
|
10458
10459
|
process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
|
|
@@ -10469,7 +10470,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10469
10470
|
clearRestartMarker()
|
|
10470
10471
|
}
|
|
10471
10472
|
|
|
10472
|
-
|
|
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 })
|
|
10473
10476
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
10474
10477
|
|
|
10475
10478
|
if (target) {
|
|
@@ -11742,30 +11745,21 @@ if (isGatewayMain) (() => { // #2996 P0c: gated — starts webhook-ingest UDS s
|
|
|
11742
11745
|
})()
|
|
11743
11746
|
|
|
11744
11747
|
// ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
|
|
11745
|
-
//
|
|
11746
|
-
//
|
|
11747
|
-
//
|
|
11748
|
-
//
|
|
11749
|
-
//
|
|
11750
|
-
//
|
|
11751
|
-
//
|
|
11752
|
-
// while idle; last `bridge registered` predated the buffer push, so
|
|
11753
|
-
// onClientRegistered's drain never ran for it).
|
|
11754
|
-
//
|
|
11755
|
-
// This is the third drain trigger. It's gated to be zero-cost and
|
|
11756
|
-
// zero-churn: skip entirely when nothing is buffered (one Map.get, no
|
|
11757
|
-
// log), when the bridge isn't alive (exactly sendToAgent's own guard —
|
|
11758
|
-
// so we never drain into a dead bridge and re-buffer/log-spin), OR
|
|
11759
|
-
// when a turn is in flight. The turn gate is #1556: a message
|
|
11760
|
-
// delivered while a turn is active is NOT safely queued by the bridge
|
|
11761
|
-
// — claude types it into its TUI composer and the auto-submit races
|
|
11762
|
-
// turn-completion, stranding it (the lawgpt wedge). Draining only at
|
|
11763
|
-
// `activeTurnStartedAt.size === 0` guarantees the channel notification
|
|
11764
|
-
// lands at an idle prompt and submits as a fresh turn. Only when there
|
|
11765
|
-
// IS a buffered message AND a live bridge AND no active turn do we
|
|
11766
|
-
// reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
|
|
11767
|
-
// per-message miss).
|
|
11748
|
+
// The THIRD drain trigger, beside bridge re-register (onClientRegistered) and
|
|
11749
|
+
// the silence-poke framework fallback (#1546): neither fires when a message is
|
|
11750
|
+
// buffered during a bridge-IPC flap that settles with no clean re-register while
|
|
11751
|
+
// claude is idle, so it orphans until a manual restart (finn, 2026-05-19). Gated
|
|
11752
|
+
// zero-cost/zero-churn: skip when nothing is buffered, when the bridge is dead,
|
|
11753
|
+
// or when a turn is in flight (#1556 — a message delivered mid-turn is typed into
|
|
11754
|
+
// claude's TUI composer and the auto-submit races turn-completion, stranding it).
|
|
11768
11755
|
const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
11756
|
+
// F2 (drain half) — bounded busy-defer. A ~5-min poke can clear the machine turn
|
|
11757
|
+
// while the CLI is still producing the answer; draining a represent into that busy
|
|
11758
|
+
// session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
|
|
11759
|
+
// bounded so a wedged session still drains (F1 then retracts it post-reply).
|
|
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)
|
|
11769
11763
|
if (isGatewayMain && !STATIC) {
|
|
11770
11764
|
setInterval(() => {
|
|
11771
11765
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
@@ -11775,8 +11769,11 @@ if (isGatewayMain && !STATIC) {
|
|
|
11775
11769
|
() => {
|
|
11776
11770
|
// #1556: never drain mid-turn — that re-creates the composer
|
|
11777
11771
|
// wedge this buffer exists to prevent. Gate reads the delivery
|
|
11778
|
-
// machine (turnInFlightForGate)
|
|
11772
|
+
// machine (turnInFlightForGate) AND, per F2, stream-render's live-turn
|
|
11773
|
+
// mirror (a poke can clear the former while the latter is still busy).
|
|
11779
11774
|
if (turnInFlightForGate()) return false
|
|
11775
|
+
const sessionBusy = (currentTurn != null && currentTurn.endedAt == null) || parkedTurnStartCount() > 0
|
|
11776
|
+
if (idleDrainBusyDefer(sessionBusy, Date.now())) return false
|
|
11780
11777
|
const c = ipcServer.getClient(selfAgent)
|
|
11781
11778
|
return c != null && c.isAlive()
|
|
11782
11779
|
},
|
|
@@ -23492,15 +23489,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23492
23489
|
} else {
|
|
23493
23490
|
const markerAgeMs = marker ? nowMs - marker.ts : undefined
|
|
23494
23491
|
const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
|
|
23492
|
+
// Markers were cleared above — stash for bridge re-register (B2).
|
|
23493
|
+
bootReasonAtStartup = reason
|
|
23495
23494
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
23496
23495
|
|
|
23497
|
-
// Issue #92: when reason='crash' AND no chat is resolvable,
|
|
23498
|
-
//
|
|
23499
|
-
//
|
|
23500
|
-
//
|
|
23501
|
-
// pipeline, which broadcasts to access.allowFrom (same path
|
|
23502
|
-
// permission requests use). The pipeline's per-agent per-kind
|
|
23503
|
-
// 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).
|
|
23504
23500
|
if (reason === 'crash') {
|
|
23505
23501
|
const cleanMarkerStale = cleanMarker
|
|
23506
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),
|
|
@@ -178,6 +178,15 @@ export class ObligationLedger {
|
|
|
178
178
|
return this.open.has(originTurnId)
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/** The open obligation for `originTurnId`, or undefined if none is open. Read-
|
|
182
|
+
* only accessor for the delivery-time represent re-check (represent-delivery-
|
|
183
|
+
* guard.ts): a represent buffered while the obligation was open must be re-
|
|
184
|
+
* evaluated against the obligation's CURRENT cutoff at the moment it is handed
|
|
185
|
+
* to the CLI bridge, since a reply may have landed between decision and drain. */
|
|
186
|
+
get(originTurnId: string): Obligation | undefined {
|
|
187
|
+
return this.open.get(originTurnId)
|
|
188
|
+
}
|
|
189
|
+
|
|
181
190
|
hasOpen(): boolean {
|
|
182
191
|
return this.open.size > 0
|
|
183
192
|
}
|
|
@@ -23,6 +23,7 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
|
|
|
23
23
|
import { driveEscalation } from './escalation-drive.js'
|
|
24
24
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
25
25
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
26
|
+
import { parkedTurnStartCount } from './stream-render.js'
|
|
26
27
|
|
|
27
28
|
export function createObligationWiring(deps: ObligationWiringDeps) {
|
|
28
29
|
const {
|
|
@@ -161,13 +162,26 @@ function obligationSweep(): void {
|
|
|
161
162
|
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
162
163
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
163
164
|
const now = Date.now()
|
|
165
|
+
// Session-busy signal (F2, decision half). A ~5-min silence poke can clear the
|
|
166
|
+
// machine turn (`turnInFlightForGate()` above → false) while the CLI session is
|
|
167
|
+
// STILL producing the answer — stream-render's live-turn mirror (`currentTurn`
|
|
168
|
+
// whose endedAt is null, or a parked turn-start) still knows it is busy. Without
|
|
169
|
+
// this, the sweep decides + buffers a represent mid-answer; the real reply lands
|
|
170
|
+
// seconds later and the model consumes the queued represent → a duplicate answer
|
|
171
|
+
// (the two-authorities-disagree defect). We treat a busy mirror the SAME as
|
|
172
|
+
// in-flight background work: defer the represent, BOUNDED by the same grace, so a
|
|
173
|
+
// wedged/hung session cannot silence a genuinely-unanswered turn forever.
|
|
174
|
+
const liveTurn = getCurrentTurn()
|
|
175
|
+
const sessionBusy = (liveTurn != null && liveTurn.endedAt == null) || parkedTurnStartCount() > 0
|
|
164
176
|
// Background-work grace: while genuine autonomous sub-agent work is in flight
|
|
165
177
|
// (a running worker, or an orphaned foreground sub-agent — neither visible to
|
|
166
|
-
// the turn machine),
|
|
167
|
-
//
|
|
168
|
-
//
|
|
178
|
+
// the turn machine), OR the CLI session is still busy behind a poke-cleared
|
|
179
|
+
// machine turn, an obligation younger than the ceiling is NOT re-presented
|
|
180
|
+
// /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS (measured from
|
|
181
|
+
// openedAt in decideAtIdle) so escalation always eventually fires. =0 disables it.
|
|
169
182
|
const backgroundWorkActive =
|
|
170
|
-
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
|
|
183
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
|
|
184
|
+
(agentHasInFlightBackgroundWork(now) || sessionBusy)
|
|
171
185
|
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
172
186
|
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
173
187
|
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
@@ -189,6 +203,7 @@ function obligationSweep(): void {
|
|
|
189
203
|
lastBgWorkDeferLogMs = now
|
|
190
204
|
process.stderr.write(
|
|
191
205
|
`telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
|
|
206
|
+
`or busy CLI session behind a poke-cleared turn ` +
|
|
192
207
|
`(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
|
|
193
208
|
)
|
|
194
209
|
}
|
|
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
|
|
|
49
49
|
depth: (agent: string) => number
|
|
50
50
|
/** Test-only: total depth across all agents. */
|
|
51
51
|
totalDepth: () => number
|
|
52
|
+
/**
|
|
53
|
+
* Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
|
|
54
|
+
* it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
|
|
55
|
+
* (drop) the message (the spool entry is acked so it is not boot-replayed, and
|
|
56
|
+
* it is NOT re-buffered). The gateway wires this to the represent delivery
|
|
57
|
+
* re-check (represent-delivery-guard.ts): a represent buffered while its
|
|
58
|
+
* obligation was open must be re-evaluated at drain time, since a reply may have
|
|
59
|
+
* landed between the sweep's decision and this drain. Undefined ⇒ never retract.
|
|
60
|
+
*/
|
|
61
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export interface PendingInboundBufferOptions {
|
|
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
|
|
|
94
104
|
* breaks the push hot path.
|
|
95
105
|
*/
|
|
96
106
|
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
107
|
+
/**
|
|
108
|
+
* Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
|
|
109
|
+
* gateway supplies the represent delivery re-check here so EVERY drain path
|
|
110
|
+
* (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
|
|
111
|
+
* by construction rather than by per-call-site discipline.
|
|
112
|
+
*/
|
|
113
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/**
|
|
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
|
|
|
123
140
|
// silently dropped (clerk 2026-06-03). `send` returning true only means
|
|
124
141
|
// the bytes reached the bridge, NOT that claude consumed them.
|
|
125
142
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
126
|
-
): { drained: number; redelivered: number; rebuffered: number } {
|
|
143
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
|
|
127
144
|
const pending = buffer.drain(agent)
|
|
128
145
|
let redelivered = 0
|
|
129
146
|
let rebuffered = 0
|
|
147
|
+
let retracted = 0
|
|
130
148
|
// Collapse consecutive same-sender Telegram user messages into one turn
|
|
131
149
|
// (see planBufferedRedelivery) so a forwarded burst that spanned a turn
|
|
132
150
|
// boundary doesn't fan out into N sequential replies. System inbounds
|
|
133
151
|
// (vault grants, approvals, cron, handbacks — anything with meta.source)
|
|
134
152
|
// are never merged and are delivered individually exactly as before.
|
|
135
153
|
for (const { merged, originals } of planBufferedRedelivery(pending)) {
|
|
154
|
+
// Delivery-time retract (F1): a buffered `obligation_represent` whose reply
|
|
155
|
+
// has landed since the sweep decided it is stale — drop it rather than hand
|
|
156
|
+
// a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
|
|
157
|
+
// do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
|
|
158
|
+
// FAIL-OPEN: a throwing predicate must never silence or lose a real message,
|
|
159
|
+
// nor abort the drain loop (which would strand every following message and
|
|
160
|
+
// crash the setInterval tick). On throw we treat it as "deliver".
|
|
161
|
+
let proceed = true
|
|
162
|
+
if (buffer.beforeRedeliver != null) {
|
|
163
|
+
try {
|
|
164
|
+
proceed = buffer.beforeRedeliver(merged)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
proceed = true
|
|
167
|
+
process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!proceed) {
|
|
171
|
+
for (const o of originals) spool?.ack(o)
|
|
172
|
+
retracted += originals.length
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
136
175
|
let delivered = false
|
|
137
176
|
try {
|
|
138
177
|
delivered = send(merged)
|
|
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
|
|
|
157
196
|
rebuffered += originals.length
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
|
-
return { drained: pending.length, redelivered, rebuffered }
|
|
199
|
+
return { drained: pending.length, redelivered, rebuffered, retracted }
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
/** True when `msg` is an ordinary Telegram user message eligible to be
|
|
@@ -312,7 +351,7 @@ export function idleDrainTick(
|
|
|
312
351
|
// enrols redelivered inbounds in the deliver-until-acked queue (parity with
|
|
313
352
|
// the bridgeUp drain — clerk lost-message incident, 2026-06-03).
|
|
314
353
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
315
|
-
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
354
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
|
|
316
355
|
if (!agent) return null
|
|
317
356
|
if (buffer.depth(agent) === 0) return null
|
|
318
357
|
if (!isBridgeAlive()) return null
|
|
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
|
|
|
412
451
|
for (const q of queues.values()) n += q.length
|
|
413
452
|
return n
|
|
414
453
|
},
|
|
454
|
+
beforeRedeliver: opts.beforeRedeliver,
|
|
415
455
|
}
|
|
416
456
|
}
|