switchroom 0.20.7 → 0.20.9
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 +111 -14
- package/dist/auth-broker/index.js +113 -30
- package/dist/cli/autoaccept-poll.js +5 -3
- package/dist/cli/drive-write-pretool.mjs +5 -3
- package/dist/cli/ms-365-write-pretool.mjs +5 -3
- package/dist/cli/notion-write-pretool.mjs +67 -6
- package/dist/cli/switchroom.js +389 -31
- 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/profiles/default/CLAUDE.md.hbs +12 -13
- package/telegram-plugin/ask-user.ts +6 -7
- 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 +410 -178
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +4 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +36 -34
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
- package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
- package/telegram-plugin/gateway/stream-render.ts +11 -2
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
- package/telegram-plugin/render/emphasis-guard.ts +92 -12
- package/telegram-plugin/render/line-start-guard.ts +27 -2
- package/telegram-plugin/sticker-aliases.ts +12 -14
- package/telegram-plugin/tests/ask-user.test.ts +15 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -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/render/emphasis-guard.test.ts +105 -6
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
- package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
- package/telegram-plugin/tests/represent-guard.test.ts +45 -0
- package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
- package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
- package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
- package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/throttle-tier.ts +59 -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
|
}
|
|
@@ -28,7 +28,7 @@ export function createAuthBrokerClient(): {
|
|
|
28
28
|
listState: () => broker.listState(),
|
|
29
29
|
setActive: (label: string) => broker.setActive(label),
|
|
30
30
|
markExhausted: (until?: number) => broker.markExhausted(until),
|
|
31
|
-
markThrottled: (until: number) => broker.markThrottled(until),
|
|
31
|
+
markThrottled: (until: number, probeOnly?: boolean) => broker.markThrottled(until, probeOnly),
|
|
32
32
|
rmAccount: (label: string) => broker.rmAccount(label),
|
|
33
33
|
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
34
34
|
setOverride: (agent: string, account: string | null) =>
|
|
@@ -334,10 +334,12 @@ export interface AuthBrokerClient {
|
|
|
334
334
|
* per-account rate limit on the CALLER's own account — `throttled_until`
|
|
335
335
|
* in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
|
|
336
336
|
* eligibility. `escalated` is true when the broker's escalation guard
|
|
337
|
-
* (
|
|
337
|
+
* (a first-hit live probe corroborating a wall) converted it into the
|
|
338
338
|
* standard mark-exhausted + roll; `rolledTo` names the roll target then.
|
|
339
|
+
* `probeOnly` (#failover-429-corroborate, generic-transient origin): run ONLY
|
|
340
|
+
* the escalation probe — a healthy probe records nothing (account stays inert).
|
|
339
341
|
*/
|
|
340
|
-
markThrottled(until: number): Promise<{
|
|
342
|
+
markThrottled(until: number, probeOnly?: boolean): Promise<{
|
|
341
343
|
account: string
|
|
342
344
|
throttled_until: number
|
|
343
345
|
escalated: boolean
|
|
@@ -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
|
+
}
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
* (same pattern as checklist-message-handler.ts).
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { parseSourceMessageId } from './source-message-id.js'
|
|
37
|
+
|
|
36
38
|
export interface ChecklistTaskState {
|
|
37
39
|
/** 1-based sequential id assigned at send time (mirrors the native API's
|
|
38
40
|
* required per-task integer id; doubles as the patch handle in text mode). */
|
|
@@ -130,6 +132,11 @@ export function buildNativeChecklistPayload(p: {
|
|
|
130
132
|
replyToMessageId?: number
|
|
131
133
|
protectContent?: boolean
|
|
132
134
|
}): Record<string, unknown> {
|
|
135
|
+
// #4368 — defense at the payload boundary: a fabricated / out-of-int32
|
|
136
|
+
// reply anchor is dropped here so the native checklist sends UNANCHORED
|
|
137
|
+
// rather than Telegram 400ing on `reply_parameters.message_id`. Independent
|
|
138
|
+
// of any caller-side guard so this builder can never emit a bad anchor.
|
|
139
|
+
const replyAnchor = parseSourceMessageId(p.replyToMessageId)
|
|
133
140
|
return {
|
|
134
141
|
business_connection_id: p.businessConnectionId,
|
|
135
142
|
chat_id: p.chatId,
|
|
@@ -137,7 +144,7 @@ export function buildNativeChecklistPayload(p: {
|
|
|
137
144
|
title: p.title,
|
|
138
145
|
tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
|
|
139
146
|
},
|
|
140
|
-
...(
|
|
147
|
+
...(replyAnchor != null ? { reply_parameters: { message_id: replyAnchor } } : {}),
|
|
141
148
|
...(p.protectContent === true ? { protect_content: true } : {}),
|
|
142
149
|
}
|
|
143
150
|
}
|
|
@@ -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
|
+
}
|
|
@@ -427,6 +427,7 @@ import {
|
|
|
427
427
|
build429ClassifiedMetric,
|
|
428
428
|
classify429Detail,
|
|
429
429
|
decideThrottleTier,
|
|
430
|
+
routeRateLimit429,
|
|
430
431
|
throttleRetryInPlaceMaxMs,
|
|
431
432
|
} from '../throttle-tier.js'
|
|
432
433
|
import { createThrottleTierRunner } from './throttle-tier-wiring.js'
|
|
@@ -497,7 +498,7 @@ import {
|
|
|
497
498
|
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
498
499
|
type AnswerDeliveredLatch,
|
|
499
500
|
} from '../reply-owner-resolve.js'
|
|
500
|
-
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
501
|
+
import { SubagentHandbackMarker, cliTaskNotifLedger } from './subagent-handback-marker.js'
|
|
501
502
|
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
502
503
|
import { AnswerReadyFlushController, resolveAnswerReadyFlushMs, resolveAnswerStageMs } from '../answer-ready-flush.js'
|
|
503
504
|
// #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
|
|
@@ -660,7 +661,7 @@ import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
|
660
661
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
661
662
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
662
663
|
import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
|
|
663
|
-
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
664
|
+
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
|
|
664
665
|
import {
|
|
665
666
|
ObligationLedger,
|
|
666
667
|
obligationEscalationText,
|
|
@@ -857,7 +858,7 @@ import {
|
|
|
857
858
|
resolvePersonaName, shouldSkipDuplicateBootCard,
|
|
858
859
|
type BootCardHandle, type RestartReason,
|
|
859
860
|
} from './boot-card.js'
|
|
860
|
-
import { determineRestartReason } from './boot-reason.js'
|
|
861
|
+
import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
|
|
861
862
|
import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
862
863
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
863
864
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
@@ -7827,6 +7828,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7827
7828
|
agent,
|
|
7828
7829
|
shouldEmitCard: (a) => shouldEmitOperatorEvent(a, 'rate-limited'),
|
|
7829
7830
|
})
|
|
7831
|
+
routeRateLimit429(rateLimit429Classification, throttleTierRunner, agent) // #failover-429-corroborate probe-only seam; boolean return is for test observability (throttle-tier-route-429.test.ts), intentionally discarded here — see docstring
|
|
7830
7832
|
if (surface === 'litellm-local-notice') {
|
|
7831
7833
|
process.stderr.write(
|
|
7832
7834
|
`telegram gateway: 429 classified litellm-proxy-local agent=${agent} — ` +
|
|
@@ -9163,6 +9165,8 @@ let activeBootCard: BootCardHandle | null = null
|
|
|
9163
9165
|
// dedupe checks this so it can't race against the boot path's await.
|
|
9164
9166
|
// See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
|
|
9165
9167
|
let bootCardPending = false
|
|
9168
|
+
// Boot-determined restart reason — see determineBridgeReconnectReason (B2).
|
|
9169
|
+
let bootReasonAtStartup: RestartReason | null = null
|
|
9166
9170
|
|
|
9167
9171
|
// Issues card (#428) — pinned per-agent surface listing current
|
|
9168
9172
|
// unresolved entries from the issue sink (#425). Idempotent across
|
|
@@ -10366,17 +10370,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10366
10370
|
|
|
10367
10371
|
onClientRegistered(client: IpcClient) {
|
|
10368
10372
|
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
|
-
//
|
|
10373
|
+
// Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap,
|
|
10374
|
+
// STATUS-SILENT session — it must NOT drive the gateway's singleton
|
|
10375
|
+
// machinery. Drain any boot-window cron fire to it, then return early.
|
|
10376
|
+
// #4348: the drain routes through `redeliverBufferedInbound` (the shared
|
|
10377
|
+
// ack chokepoint) so a spooled fire is acked and never re-fires on restart.
|
|
10374
10378
|
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
|
-
}
|
|
10379
|
+
drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
|
|
10380
10380
|
return
|
|
10381
10381
|
}
|
|
10382
10382
|
// Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
|
|
@@ -10450,15 +10450,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10450
10450
|
})
|
|
10451
10451
|
}
|
|
10452
10452
|
|
|
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`.
|
|
10453
|
+
// If the agent reconnected after a restart, post a boot card. The
|
|
10454
|
+
// restart-marker carries the ack chat; else fall back to resolveBootChatId
|
|
10455
|
+
// so crash-recovery reconnects also get a card. Skip if the boot path
|
|
10456
|
+
// already posted a card OR its sendMessage is in-flight (issue #489,
|
|
10457
|
+
// klanker 2026-05-01; earlier dedupe 2026-04-26 only covered the
|
|
10458
|
+
// post-resolution case). See `shouldSkipDuplicateBootCard`.
|
|
10462
10459
|
const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
|
|
10463
10460
|
if (dedupeDecision.skip) {
|
|
10464
10461
|
process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
|
|
@@ -10475,7 +10472,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10475
10472
|
clearRestartMarker()
|
|
10476
10473
|
}
|
|
10477
10474
|
|
|
10478
|
-
|
|
10475
|
+
// B2: boot path cleared the markers — reuse its reason (boot-reason.ts).
|
|
10476
|
+
const reason = determineBridgeReconnectReason({ marker, cleanMarker, sessionMarker: storedSession,
|
|
10477
|
+
now: nowMs, gatewayStartedAtMs: GATEWAY_STARTED_AT_MS, bootReason: bootReasonAtStartup })
|
|
10479
10478
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
10480
10479
|
|
|
10481
10480
|
if (target) {
|
|
@@ -10693,6 +10692,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10693
10692
|
}
|
|
10694
10693
|
}
|
|
10695
10694
|
const ev = msg.event as unknown as SessionEvent
|
|
10695
|
+
if (ev.kind === 'task_notification') cliTaskNotifLedger.record(ev.taskId, ev.status, Date.now()) // double-wake dedup — ledger doc in subagent-handback-marker.ts
|
|
10696
10696
|
// #1122/#1126: session events used to be ingested into the pinned progress
|
|
10697
10697
|
// card here (`progressDriver.ingest`). The card is retired and the driver
|
|
10698
10698
|
// is permanently null, so that call was a dead no-op — removed. Session
|
|
@@ -11760,7 +11760,9 @@ const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
|
11760
11760
|
// while the CLI is still producing the answer; draining a represent into that busy
|
|
11761
11761
|
// session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
|
|
11762
11762
|
// bounded so a wedged session still drains (F1 then retracts it post-reply).
|
|
11763
|
-
|
|
11763
|
+
// staleGap = 3 poll intervals: a longer gap between busy consultations means the
|
|
11764
|
+
// buffer drained via a non-idle path so the next one is a NEW episode (#4341 f/u).
|
|
11765
|
+
const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS, IDLE_DRAIN_INTERVAL_MS * 3)
|
|
11764
11766
|
if (isGatewayMain && !STATIC) {
|
|
11765
11767
|
setInterval(() => {
|
|
11766
11768
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
@@ -11918,7 +11920,7 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
11918
11920
|
const tasks = args.tasks as Array<{ text: string; done?: boolean }> | undefined
|
|
11919
11921
|
if (!Array.isArray(tasks) || tasks.length === 0) throw new Error('send_checklist: tasks must be a non-empty array')
|
|
11920
11922
|
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
11921
|
-
const replyTo = args.reply_to
|
|
11923
|
+
const replyTo = parseSourceMessageId(args.reply_to as string | number | null | undefined) ?? undefined // #4368: drop a fabricated/out-of-int32 anchor so the send lands unanchored, not 400
|
|
11922
11924
|
const protectContent = args.protect_content === true
|
|
11923
11925
|
|
|
11924
11926
|
assertAllowedChat(chat_id)
|
|
@@ -23490,15 +23492,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23490
23492
|
} else {
|
|
23491
23493
|
const markerAgeMs = marker ? nowMs - marker.ts : undefined
|
|
23492
23494
|
const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
|
|
23495
|
+
// Markers were cleared above — stash for bridge re-register (B2).
|
|
23496
|
+
bootReasonAtStartup = reason
|
|
23493
23497
|
const target = resolveBootChatId(marker, markerAgeMs)
|
|
23494
23498
|
|
|
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.
|
|
23499
|
+
// Issue #92: when reason='crash' AND no chat is resolvable, the
|
|
23500
|
+
// gateway used to silently skip. Always surface unplanned crashes
|
|
23501
|
+
// via the operator-events pipeline (broadcasts to access.allowFrom;
|
|
23502
|
+
// its per-agent per-kind cooldown absorbs crash loops).
|
|
23502
23503
|
if (reason === 'crash') {
|
|
23503
23504
|
const cleanMarkerStale = cleanMarker
|
|
23504
23505
|
? !shouldSuppressRecoveryBanner(cleanMarker, nowMs, CLEAN_SHUTDOWN_MAX_AGE_MS)
|
|
@@ -24372,11 +24373,12 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24372
24373
|
// deterministic dedup key — closes the #1719
|
|
24373
24374
|
// re-fire-on-restart class.
|
|
24374
24375
|
jsonlAgentId: agentId,
|
|
24376
|
+
cliTaskNotificationSeen: cliTaskNotifLedger.seenRecently(agentId, Date.now()), // double-wake dedup, fail-open — ledger doc in subagent-handback-marker.ts
|
|
24375
24377
|
})
|
|
24376
24378
|
if (!decision.deliver) {
|
|
24377
|
-
if (decision.reason === 'no-chat') {
|
|
24379
|
+
if (decision.reason === 'no-chat' || decision.reason === 'cli-task-notification') {
|
|
24378
24380
|
process.stderr.write(
|
|
24379
|
-
`telegram gateway: subagent-handback ${agentId} — no chat to deliver to
|
|
24381
|
+
`telegram gateway: subagent-handback ${agentId} skipped — ${decision.reason === 'no-chat' ? 'no chat to deliver to' : 'CLI task-notification already woke the parent for this completion (double-wake dedup)'}\n`,
|
|
24380
24382
|
)
|
|
24381
24383
|
}
|
|
24382
24384
|
return
|
|
@@ -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),
|
|
@@ -73,6 +73,7 @@ import { getBuzzMirror } from './buzz-mirror.js'
|
|
|
73
73
|
import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
74
74
|
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
75
75
|
import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
76
|
+
import { parseSourceMessageId } from './source-message-id.js'
|
|
76
77
|
import {
|
|
77
78
|
decideSupersedeCorrection,
|
|
78
79
|
flushedAnswerMatchesReply,
|
|
@@ -1343,7 +1344,14 @@ export async function sendReply(
|
|
|
1343
1344
|
|
|
1344
1345
|
const files = (args.files as string[] | undefined) ?? []
|
|
1345
1346
|
const quoteOptIn = args.quote !== false
|
|
1346
|
-
|
|
1347
|
+
// #4368 — the model's reply tool can quote a synthetic inbound (a boot-
|
|
1348
|
+
// resume/handback/cron fabricated id at `Date.now()` scale). Route it through
|
|
1349
|
+
// the canonical guard so an out-of-int32 anchor is DROPPED (send lands
|
|
1350
|
+
// unanchored) rather than 400ing every chunk on `reply_parameters.message_id`.
|
|
1351
|
+
// A later `reply_to = latest` (quote-opt-in default) is a Telegram-returned
|
|
1352
|
+
// id and needs no re-check. Also closes the pre-existing NaN hole: a non-
|
|
1353
|
+
// numeric `reply_to` used to coerce to NaN and still build the anchor.
|
|
1354
|
+
let reply_to = parseSourceMessageId(args.reply_to as string | number | null | undefined) ?? undefined
|
|
1347
1355
|
const protectContent = args.protect_content === true
|
|
1348
1356
|
const quoteText = args.quote_text as string | undefined
|
|
1349
1357
|
const access = loadAccess()
|
|
@@ -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
|
|
@@ -183,6 +183,17 @@ export interface SubagentHandbackDecisionInput {
|
|
|
183
183
|
* the built inbound's `meta.subagent_jsonl_id`. See
|
|
184
184
|
* `SubagentHandbackContext.jsonlAgentId` for the dedup rationale. */
|
|
185
185
|
jsonlAgentId?: string
|
|
186
|
+
/**
|
|
187
|
+
* Double-wake dedup (v0.20.8 candidate): true iff the session tail already
|
|
188
|
+
* observed a TERMINAL CLI `<task-notification>` for EXACTLY this sub-agent's
|
|
189
|
+
* task id within `TASK_NOTIFICATION_DEDUP_TTL_MS` — i.e. the CLI itself has
|
|
190
|
+
* already woken (or queued the wake of) the parent with this completion, so
|
|
191
|
+
* a second gateway-synthesized wake would duplicate it. The caller resolves
|
|
192
|
+
* this from `CliTaskNotificationLedger.seenRecently(agentId, now)`
|
|
193
|
+
* (subagent-handback-marker.ts). FAIL-OPEN: omitted/false → deliver; only a
|
|
194
|
+
* confirmed exact-id in-window hit suppresses.
|
|
195
|
+
*/
|
|
196
|
+
cliTaskNotificationSeen?: boolean
|
|
186
197
|
/** Deterministic clock for tests. */
|
|
187
198
|
nowMs?: number
|
|
188
199
|
}
|
|
@@ -192,6 +203,7 @@ export type SubagentHandbackSkipReason =
|
|
|
192
203
|
| 'env-disabled'
|
|
193
204
|
| 'outcome-not-terminal'
|
|
194
205
|
| 'foreground'
|
|
206
|
+
| 'cli-task-notification'
|
|
195
207
|
| 'no-chat'
|
|
196
208
|
|
|
197
209
|
export type SubagentHandbackDecision =
|
|
@@ -208,7 +220,12 @@ export type SubagentHandbackDecision =
|
|
|
208
220
|
* stale historical-at-boot row, not a fresh completion.
|
|
209
221
|
* 3. foreground — a foreground sub-agent already handed its result
|
|
210
222
|
* back as the Task tool result in the parent's own turn.
|
|
211
|
-
* 4.
|
|
223
|
+
* 4. cli-task-notification — the claude CLI's OWN `<task-notification>`
|
|
224
|
+
* for exactly this task id was already observed in-window, so the CLI
|
|
225
|
+
* has already woken the parent with this completion; a second
|
|
226
|
+
* gateway-synthesized wake would double it (the double-message bug).
|
|
227
|
+
* Fail-open: only a confirmed exact-id hit skips.
|
|
228
|
+
* 5. no-chat — neither the fleet entry nor the owner chat resolved,
|
|
212
229
|
* so there is nowhere to deliver.
|
|
213
230
|
*/
|
|
214
231
|
export function decideSubagentHandback(
|
|
@@ -223,6 +240,9 @@ export function decideSubagentHandback(
|
|
|
223
240
|
if (!input.isBackground) {
|
|
224
241
|
return { deliver: false, reason: 'foreground' }
|
|
225
242
|
}
|
|
243
|
+
if (input.cliTaskNotificationSeen === true) {
|
|
244
|
+
return { deliver: false, reason: 'cli-task-notification' }
|
|
245
|
+
}
|
|
226
246
|
const chatId = input.fleetChatId || input.ownerChatId
|
|
227
247
|
if (!chatId) {
|
|
228
248
|
return { deliver: false, reason: 'no-chat' }
|