switchroom 0.12.17 → 0.12.19
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -361
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +510 -199
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/compact-notify.ts +94 -0
- package/telegram-plugin/gateway/gateway.ts +280 -8
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +85 -0
- package/telegram-plugin/gateway/inbound-spool.ts +272 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +42 -3
- package/telegram-plugin/tests/compact-notify.test.ts +138 -0
- package/telegram-plugin/tests/inbound-delivery-gate.test.ts +53 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +229 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +66 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure transition selector for the proactive-compaction user
|
|
3
|
+
* notification (a single Telegram card edited START → FINISH). Kept
|
|
4
|
+
* side-effect-free so the lifecycle — the part prone to double-post /
|
|
5
|
+
* stale-edit / spurious-finish — is unit-testable in isolation. The
|
|
6
|
+
* impure shell (send/editMessageText, the wall-clock timeout) lives in
|
|
7
|
+
* gateway.ts.
|
|
8
|
+
*
|
|
9
|
+
* The "finished" signal is the proactive-compaction state machine's
|
|
10
|
+
* re-arm edge (`armed: false → true`, which the decider only produces
|
|
11
|
+
* when post-`/compact` occupancy drops below 0.6×cap — i.e. context
|
|
12
|
+
* verifiably shrank). This module does NOT own the wall-clock timeout
|
|
13
|
+
* (an idle agent produces no evaluations, so a dangling card must be
|
|
14
|
+
* resolved by a real timer in the shell, independent of this loop).
|
|
15
|
+
*
|
|
16
|
+
* Session reset/rotation (`session.max_idle` / `max_turns`) is
|
|
17
|
+
* deliberately silent and is not represented here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CompactNotifyState {
|
|
21
|
+
phase: "idle" | "awaiting";
|
|
22
|
+
/**
|
|
23
|
+
* The active session file when START was posted. FINISH is only
|
|
24
|
+
* accepted if the re-arm edge is observed against this SAME file —
|
|
25
|
+
* a sub-agent transcript briefly leading session-tail's currentFile
|
|
26
|
+
* can read a small occupancy and spuriously satisfy the re-arm
|
|
27
|
+
* condition; gating on the start-file rejects that false positive.
|
|
28
|
+
*/
|
|
29
|
+
fileAtStart: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* - `none` — no card action this evaluation.
|
|
34
|
+
* - `start` — post a fresh START card.
|
|
35
|
+
* - `start-superseding` — a prior card is still outstanding; mark it
|
|
36
|
+
* superseded, then post a fresh START card.
|
|
37
|
+
* - `finish` — edit the outstanding card to FINISHED.
|
|
38
|
+
*/
|
|
39
|
+
export type CompactNotifyAction =
|
|
40
|
+
| "none"
|
|
41
|
+
| "start"
|
|
42
|
+
| "start-superseding"
|
|
43
|
+
| "finish";
|
|
44
|
+
|
|
45
|
+
export interface CompactNotifyEvent {
|
|
46
|
+
/** The proactive-compaction decider fired `/compact` this eval. */
|
|
47
|
+
fired: boolean;
|
|
48
|
+
/** Decider re-arm edge this eval (wasArmed=false → state.armed=true). */
|
|
49
|
+
rearmed: boolean;
|
|
50
|
+
/** session-tail's active file used for the occupancy read this eval. */
|
|
51
|
+
activeFile: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function idleCompactNotifyState(): CompactNotifyState {
|
|
55
|
+
return { phase: "idle", fileAtStart: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Select the card action for this idle evaluation and the next state.
|
|
60
|
+
* Pure: same inputs → same outputs, no I/O.
|
|
61
|
+
*
|
|
62
|
+
* Precedence:
|
|
63
|
+
* 1. A fire always (re)starts a card. If one was still outstanding,
|
|
64
|
+
* supersede it first (single-outstanding-card invariant).
|
|
65
|
+
* 2. While awaiting, a re-arm edge ON THE SAME start-file → FINISH.
|
|
66
|
+
* 3. Otherwise hold (the shell's wall-clock timeout resolves a card
|
|
67
|
+
* that never gets a re-arm — e.g. a compaction that didn't shrink
|
|
68
|
+
* context, or an agent that went idle right after START).
|
|
69
|
+
*/
|
|
70
|
+
export function nextCompactNotify(
|
|
71
|
+
state: CompactNotifyState,
|
|
72
|
+
ev: CompactNotifyEvent,
|
|
73
|
+
): { state: CompactNotifyState; action: CompactNotifyAction } {
|
|
74
|
+
if (ev.fired) {
|
|
75
|
+
const superseding = state.phase === "awaiting";
|
|
76
|
+
return {
|
|
77
|
+
state: { phase: "awaiting", fileAtStart: ev.activeFile },
|
|
78
|
+
action: superseding ? "start-superseding" : "start",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (state.phase === "awaiting") {
|
|
83
|
+
if (
|
|
84
|
+
ev.rearmed &&
|
|
85
|
+
ev.activeFile != null &&
|
|
86
|
+
ev.activeFile === state.fileAtStart
|
|
87
|
+
) {
|
|
88
|
+
return { state: idleCompactNotifyState(), action: "finish" };
|
|
89
|
+
}
|
|
90
|
+
return { state, action: "none" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { state, action: "none" };
|
|
94
|
+
}
|
|
@@ -17,7 +17,7 @@ import { execFileSync, execSync, spawn } from 'child_process'
|
|
|
17
17
|
import {
|
|
18
18
|
readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync,
|
|
19
19
|
statSync, renameSync, realpathSync, chmodSync, openSync, closeSync,
|
|
20
|
-
existsSync, unlinkSync,
|
|
20
|
+
existsSync, unlinkSync, appendFileSync,
|
|
21
21
|
} from 'fs'
|
|
22
22
|
import { homedir } from 'os'
|
|
23
23
|
import { join, extname, sep, basename } from 'path'
|
|
@@ -230,6 +230,7 @@ import { refreshBanner } from '../slot-banner-driver.js'
|
|
|
230
230
|
import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
231
231
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
232
232
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
233
|
+
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
233
234
|
import {
|
|
234
235
|
tryHostdDispatch,
|
|
235
236
|
hostdRequestId,
|
|
@@ -248,6 +249,8 @@ import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js
|
|
|
248
249
|
import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
249
250
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
250
251
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
252
|
+
import { createInboundSpool } from './inbound-spool.js'
|
|
253
|
+
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
251
254
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
252
255
|
import {
|
|
253
256
|
buildVaultGrantApprovedInbound,
|
|
@@ -1086,6 +1089,25 @@ let lastSessionActiveFile: string | null = null
|
|
|
1086
1089
|
// turn and we must not double-dispatch before the first send settles.
|
|
1087
1090
|
let compactState: CompactState = initialCompactState()
|
|
1088
1091
|
let compactDispatching = false
|
|
1092
|
+
|
|
1093
|
+
// User-facing proactive-compaction notification (single Telegram card
|
|
1094
|
+
// edited START → FINISH; transition selection is pure in
|
|
1095
|
+
// ./compact-notify). Session reset/rotation stays silent — not here.
|
|
1096
|
+
// The wall-clock timer is owned HERE (not the pure helper): an idle
|
|
1097
|
+
// agent produces no idle evaluations, so a card that never gets a
|
|
1098
|
+
// re-arm edge must be resolved by a real timer, independent of the
|
|
1099
|
+
// eval loop, or it would dangle forever.
|
|
1100
|
+
const COMPACT_CARD_TIMEOUT_MS = 15 * 60 * 1000
|
|
1101
|
+
interface OutstandingCompactCard {
|
|
1102
|
+
chatId: string
|
|
1103
|
+
threadId: number | undefined
|
|
1104
|
+
messageId: number
|
|
1105
|
+
occAtStart: number
|
|
1106
|
+
capAtStart: number
|
|
1107
|
+
timer: ReturnType<typeof setTimeout>
|
|
1108
|
+
}
|
|
1109
|
+
let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
|
|
1110
|
+
let outstandingCompactCard: OutstandingCompactCard | null = null
|
|
1089
1111
|
const activeDraftStreams = new Map<string, DraftStreamHandle>()
|
|
1090
1112
|
const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
|
|
1091
1113
|
const suppressPtyPreview = new Set<string>()
|
|
@@ -1258,6 +1280,30 @@ function purgeReactionTracking(key: string): void {
|
|
|
1258
1280
|
// response to the client was already sent when the restart was
|
|
1259
1281
|
// scheduled, so nobody is waiting on this.
|
|
1260
1282
|
if (activeTurnStartedAt.size === 0) {
|
|
1283
|
+
// #1556: the deterministic delivery point. claude has just gone
|
|
1284
|
+
// idle — flush any inbound held mid-turn so the channel
|
|
1285
|
+
// notification lands at the idle prompt and submits as a fresh
|
|
1286
|
+
// turn (instead of stranding in the composer, the lawgpt wedge).
|
|
1287
|
+
// Zero-churn: depth check first, no work on the common empty path.
|
|
1288
|
+
// Lossless: redeliver re-buffers any per-message miss (bridge
|
|
1289
|
+
// mid-reconnect), which onClientRegistered then drains.
|
|
1290
|
+
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1291
|
+
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
1292
|
+
const fr = redeliverBufferedInbound(
|
|
1293
|
+
pendingInboundBuffer,
|
|
1294
|
+
selfAgentForFlush,
|
|
1295
|
+
(m) => ipcServer.sendToAgent(selfAgentForFlush, m),
|
|
1296
|
+
inboundSpool,
|
|
1297
|
+
)
|
|
1298
|
+
if (fr.redelivered > 0) {
|
|
1299
|
+
process.stderr.write(
|
|
1300
|
+
`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} ` +
|
|
1301
|
+
`held inbound for ${selfAgentForFlush}` +
|
|
1302
|
+
`${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1261
1307
|
if (pendingRestarts.size > 0) {
|
|
1262
1308
|
for (const [agentName, _timestamp] of pendingRestarts.entries()) {
|
|
1263
1309
|
triggerSelfRestart(agentName, 'turn-complete-pending-restart');
|
|
@@ -1321,8 +1367,35 @@ function maybeProactiveCompact(): void {
|
|
|
1321
1367
|
const t = turns[0];
|
|
1322
1368
|
const occupancy = t.input + t.cacheRead + t.cacheCreate;
|
|
1323
1369
|
|
|
1370
|
+
const wasArmed = compactState.armed;
|
|
1324
1371
|
const decision = decideProactiveCompact(compactState, occupancy, cap);
|
|
1325
1372
|
compactState = decision.state;
|
|
1373
|
+
// Re-arm edge: the decider only flips disarmed→armed when post-
|
|
1374
|
+
// /compact occupancy fell below 0.6×cap — i.e. context verifiably
|
|
1375
|
+
// shrank. That edge is the honest "compaction finished" signal.
|
|
1376
|
+
const rearmed = !wasArmed && decision.state.armed;
|
|
1377
|
+
|
|
1378
|
+
// User-facing notification transitions (pure). Resolved synchronously
|
|
1379
|
+
// here — before any await and before the !fire return — so a
|
|
1380
|
+
// re-entrant purge pass can't double-post (compactState was already
|
|
1381
|
+
// persisted/disarmed above). All card I/O is reached only past the
|
|
1382
|
+
// `cap == null` opt-in return earlier in this function, so a fleet
|
|
1383
|
+
// with the feature off never posts anything (Defaults preserved).
|
|
1384
|
+
const nt = nextCompactNotify(compactNotifyState, {
|
|
1385
|
+
fired: decision.fire,
|
|
1386
|
+
rearmed,
|
|
1387
|
+
activeFile: file,
|
|
1388
|
+
});
|
|
1389
|
+
compactNotifyState = nt.state;
|
|
1390
|
+
if (nt.action === 'finish') {
|
|
1391
|
+
void resolveCompactCard('finished', occupancy);
|
|
1392
|
+
} else if (nt.action === 'start' || nt.action === 'start-superseding') {
|
|
1393
|
+
if (nt.action === 'start-superseding') {
|
|
1394
|
+
void resolveCompactCard('superseded', occupancy);
|
|
1395
|
+
}
|
|
1396
|
+
void postCompactCard(occupancy, cap);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1326
1399
|
if (!decision.fire) return;
|
|
1327
1400
|
|
|
1328
1401
|
// Set the re-entrancy guard synchronously BEFORE the await so a
|
|
@@ -1345,6 +1418,109 @@ function maybeProactiveCompact(): void {
|
|
|
1345
1418
|
});
|
|
1346
1419
|
}
|
|
1347
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* Post the START card for a proactive compaction. Best-effort: a failed
|
|
1423
|
+
* send just means no card (the compaction itself still happens). The
|
|
1424
|
+
* outstanding record + a wall-clock timeout are armed so the card can
|
|
1425
|
+
* never dangle if the re-arm edge never arrives (failed/idle case).
|
|
1426
|
+
*/
|
|
1427
|
+
async function postCompactCard(occ: number, cap: number): Promise<void> {
|
|
1428
|
+
try {
|
|
1429
|
+
const chatId = loadAccess().allowFrom[0];
|
|
1430
|
+
if (!chatId) return;
|
|
1431
|
+
const threadId = chatThreadMap.get(chatId);
|
|
1432
|
+
const text =
|
|
1433
|
+
`🗜️ <b>Context compaction</b>\n` +
|
|
1434
|
+
`Working context hit ~${occ.toLocaleString()} tokens ` +
|
|
1435
|
+
`(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
|
|
1436
|
+
`Older detail moves to Hindsight; I'll confirm here once the ` +
|
|
1437
|
+
`context has shrunk (may take a turn or two).`;
|
|
1438
|
+
const sent = await swallowingApiCall(
|
|
1439
|
+
() =>
|
|
1440
|
+
bot.api.sendMessage(chatId, text, {
|
|
1441
|
+
parse_mode: 'HTML',
|
|
1442
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
1443
|
+
}),
|
|
1444
|
+
{ chat_id: chatId, verb: 'proactiveCompact.start' },
|
|
1445
|
+
);
|
|
1446
|
+
const messageId = (sent as { message_id?: number } | undefined)
|
|
1447
|
+
?.message_id;
|
|
1448
|
+
if (typeof messageId !== 'number') return;
|
|
1449
|
+
const timer = setTimeout(() => {
|
|
1450
|
+
void resolveCompactCard('timeout', null);
|
|
1451
|
+
}, COMPACT_CARD_TIMEOUT_MS);
|
|
1452
|
+
timer.unref?.();
|
|
1453
|
+
outstandingCompactCard = {
|
|
1454
|
+
chatId,
|
|
1455
|
+
threadId,
|
|
1456
|
+
messageId,
|
|
1457
|
+
occAtStart: occ,
|
|
1458
|
+
capAtStart: cap,
|
|
1459
|
+
timer,
|
|
1460
|
+
};
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
process.stderr.write(
|
|
1463
|
+
`telegram gateway: proactive-compact start card failed: ` +
|
|
1464
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Resolve the outstanding START card to a terminal state by editing it
|
|
1471
|
+
* in place. `finished` is driven by the decider re-arm edge (context
|
|
1472
|
+
* verifiably shrank), `superseded` when a newer compaction starts first,
|
|
1473
|
+
* `timeout` by the wall-clock timer (re-arm never arrived). The
|
|
1474
|
+
* outstanding record + timer are cleared synchronously BEFORE the await
|
|
1475
|
+
* so a stale message_id can never be edited twice and the timer can't
|
|
1476
|
+
* double-fire. On `timeout` the pure notify state is also reset so a
|
|
1477
|
+
* future compaction starts a clean lifecycle.
|
|
1478
|
+
*/
|
|
1479
|
+
async function resolveCompactCard(
|
|
1480
|
+
kind: 'finished' | 'superseded' | 'timeout',
|
|
1481
|
+
occNow: number | null,
|
|
1482
|
+
): Promise<void> {
|
|
1483
|
+
const card = outstandingCompactCard;
|
|
1484
|
+
if (!card) return;
|
|
1485
|
+
outstandingCompactCard = null;
|
|
1486
|
+
clearTimeout(card.timer);
|
|
1487
|
+
if (kind === 'timeout') compactNotifyState = idleCompactNotifyState();
|
|
1488
|
+
let text: string;
|
|
1489
|
+
if (kind === 'finished') {
|
|
1490
|
+
text =
|
|
1491
|
+
`✅ <b>Context compacted</b>\n` +
|
|
1492
|
+
`Working context reduced` +
|
|
1493
|
+
(occNow != null
|
|
1494
|
+
? ` (~${card.occAtStart.toLocaleString()} → ` +
|
|
1495
|
+
`~${occNow.toLocaleString()} tokens)`
|
|
1496
|
+
: '') +
|
|
1497
|
+
`. Hindsight retains the detail.`;
|
|
1498
|
+
} else if (kind === 'superseded') {
|
|
1499
|
+
text =
|
|
1500
|
+
`↩️ <b>Context compaction superseded</b>\n` +
|
|
1501
|
+
`A newer compaction started before this one confirmed.`;
|
|
1502
|
+
} else {
|
|
1503
|
+
text =
|
|
1504
|
+
`⚠️ <b>Compaction issued</b>\n` +
|
|
1505
|
+
`<code>/compact</code> was requested but the context isn't ` +
|
|
1506
|
+
`confirmed reduced yet. Native compaction and Hindsight still apply.`;
|
|
1507
|
+
}
|
|
1508
|
+
try {
|
|
1509
|
+
await swallowingApiCall(
|
|
1510
|
+
() =>
|
|
1511
|
+
bot.api.editMessageText(card.chatId, card.messageId, text, {
|
|
1512
|
+
parse_mode: 'HTML',
|
|
1513
|
+
}),
|
|
1514
|
+
{ chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
|
|
1515
|
+
);
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
process.stderr.write(
|
|
1518
|
+
`telegram gateway: proactive-compact ${kind} card edit failed: ` +
|
|
1519
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1348
1524
|
function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
|
|
1349
1525
|
const key = statusKey(chatId, threadId)
|
|
1350
1526
|
const ctrl = activeStatusReactions.get(key)
|
|
@@ -2861,6 +3037,7 @@ silencePoke.startTimer({
|
|
|
2861
3037
|
pendingInboundBuffer,
|
|
2862
3038
|
fbSelfAgent,
|
|
2863
3039
|
(m) => ipcServer.sendToAgent(fbSelfAgent, m),
|
|
3040
|
+
inboundSpool,
|
|
2864
3041
|
)
|
|
2865
3042
|
process.stderr.write(
|
|
2866
3043
|
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
@@ -2879,7 +3056,42 @@ silencePoke.startTimer({
|
|
|
2879
3056
|
// vault_request_access card during the 100ms bridge-reconnect window
|
|
2880
3057
|
// would mint the grant but silently drop the `vault_grant_approved`
|
|
2881
3058
|
// inbound, leaving the agent stuck waiting for a manual poke.
|
|
2882
|
-
|
|
3059
|
+
// Durable inbound spool on the persistent per-agent volume
|
|
3060
|
+
// (STATE_DIR = /state/agent/telegram in prod — survives container
|
|
3061
|
+
// recreate). Makes the "⏳ your message is queued and will be
|
|
3062
|
+
// processed when it reconnects" promise deterministic across a
|
|
3063
|
+
// gateway/container restart (finn/carrie lost-on-restart incident,
|
|
3064
|
+
// 2026-05-19). STATIC mode has no runtime/bridge, so no spool.
|
|
3065
|
+
const inboundSpool = STATIC
|
|
3066
|
+
? undefined
|
|
3067
|
+
: createInboundSpool({
|
|
3068
|
+
path: join(STATE_DIR, 'inbound-spool.jsonl'),
|
|
3069
|
+
fs: {
|
|
3070
|
+
appendFileSync: (p, d) => appendFileSync(p, d),
|
|
3071
|
+
readFileSync: (p) => readFileSync(p, 'utf8'),
|
|
3072
|
+
writeFileSync: (p, d) => writeFileSync(p, d),
|
|
3073
|
+
renameSync: (a, b) => renameSync(a, b),
|
|
3074
|
+
existsSync: (p) => existsSync(p),
|
|
3075
|
+
statSizeSync: (p) => statSync(p).size,
|
|
3076
|
+
},
|
|
3077
|
+
})
|
|
3078
|
+
const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
|
|
3079
|
+
// Boot-replay: re-queue every un-acked spooled inbound into the
|
|
3080
|
+
// in-memory buffer so the existing drain triggers (onClientRegistered
|
|
3081
|
+
// / silence-poke #1546 / idle-drain #1549) deliver them. push →
|
|
3082
|
+
// spool.put dedups on the already-live id, so this re-push does NOT
|
|
3083
|
+
// double-append. This is what makes a queued message survive a
|
|
3084
|
+
// restart instead of being silently lost.
|
|
3085
|
+
if (inboundSpool != null) {
|
|
3086
|
+
const replay = inboundSpool.liveEntries()
|
|
3087
|
+
for (const e of replay) pendingInboundBuffer.push(e.agent, e.msg)
|
|
3088
|
+
if (replay.length > 0) {
|
|
3089
|
+
process.stderr.write(
|
|
3090
|
+
`telegram gateway: inbound-spool boot-replay re-queued ${replay.length} ` +
|
|
3091
|
+
`un-acked inbound (durable-queue, survives restart)\n`,
|
|
3092
|
+
)
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
2883
3095
|
const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
2884
3096
|
|
|
2885
3097
|
/**
|
|
@@ -2930,6 +3142,12 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
2930
3142
|
for (const msg of pending) {
|
|
2931
3143
|
try {
|
|
2932
3144
|
client.send(msg)
|
|
3145
|
+
// Confirmed delivery to the just-registered live bridge →
|
|
3146
|
+
// tombstone the durable spool entry so it isn't boot-replayed
|
|
3147
|
+
// again. A throw below leaves it spooled (un-acked) so the
|
|
3148
|
+
// idle-drain / escalation path still recovers it — strictly
|
|
3149
|
+
// safer than the old log-and-drop.
|
|
3150
|
+
inboundSpool?.ack(msg)
|
|
2933
3151
|
} catch (err) {
|
|
2934
3152
|
process.stderr.write(
|
|
2935
3153
|
`telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
|
|
@@ -3392,12 +3610,17 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3392
3610
|
//
|
|
3393
3611
|
// This is the third drain trigger. It's gated to be zero-cost and
|
|
3394
3612
|
// zero-churn: skip entirely when nothing is buffered (one Map.get, no
|
|
3395
|
-
// log)
|
|
3396
|
-
//
|
|
3397
|
-
//
|
|
3398
|
-
//
|
|
3399
|
-
//
|
|
3400
|
-
//
|
|
3613
|
+
// log), when the bridge isn't alive (exactly sendToAgent's own guard —
|
|
3614
|
+
// so we never drain into a dead bridge and re-buffer/log-spin), OR
|
|
3615
|
+
// when a turn is in flight. The turn gate is #1556: a message
|
|
3616
|
+
// delivered while a turn is active is NOT safely queued by the bridge
|
|
3617
|
+
// — claude types it into its TUI composer and the auto-submit races
|
|
3618
|
+
// turn-completion, stranding it (the lawgpt wedge). Draining only at
|
|
3619
|
+
// `activeTurnStartedAt.size === 0` guarantees the channel notification
|
|
3620
|
+
// lands at an idle prompt and submits as a fresh turn. Only when there
|
|
3621
|
+
// IS a buffered message AND a live bridge AND no active turn do we
|
|
3622
|
+
// reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
|
|
3623
|
+
// per-message miss).
|
|
3401
3624
|
const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
3402
3625
|
if (!STATIC) {
|
|
3403
3626
|
setInterval(() => {
|
|
@@ -3406,10 +3629,14 @@ if (!STATIC) {
|
|
|
3406
3629
|
pendingInboundBuffer,
|
|
3407
3630
|
selfAgent,
|
|
3408
3631
|
() => {
|
|
3632
|
+
// #1556: never drain mid-turn — that re-creates the composer
|
|
3633
|
+
// wedge this buffer exists to prevent.
|
|
3634
|
+
if (activeTurnStartedAt.size > 0) return false
|
|
3409
3635
|
const c = ipcServer.getClient(selfAgent)
|
|
3410
3636
|
return c != null && c.isAlive()
|
|
3411
3637
|
},
|
|
3412
3638
|
(m) => ipcServer.sendToAgent(selfAgent, m),
|
|
3639
|
+
inboundSpool,
|
|
3413
3640
|
)
|
|
3414
3641
|
if (r != null && r.redelivered > 0) {
|
|
3415
3642
|
process.stderr.write(
|
|
@@ -3418,6 +3645,28 @@ if (!STATIC) {
|
|
|
3418
3645
|
`${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ''}\n`,
|
|
3419
3646
|
)
|
|
3420
3647
|
}
|
|
3648
|
+
// Bounded escalation: a spooled inbound still un-acked past its
|
|
3649
|
+
// bound (default 15 min — well past the 5-min silence-poke ladder)
|
|
3650
|
+
// is undeliverable in practice. Retract the "will be processed"
|
|
3651
|
+
// promise EXPLICITLY (honest failure) instead of letting it sit
|
|
3652
|
+
// forever. This is what makes the guarantee deterministic: every
|
|
3653
|
+
// queued message ends either delivered or visibly retracted.
|
|
3654
|
+
inboundSpool?.sweepEscalations((e) => {
|
|
3655
|
+
const chat = e.msg.chatId
|
|
3656
|
+
const threadOpts =
|
|
3657
|
+
typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
|
|
3658
|
+
? { message_thread_id: Number(e.msg.meta.threadId) }
|
|
3659
|
+
: {}
|
|
3660
|
+
void swallowingApiCall(
|
|
3661
|
+
() =>
|
|
3662
|
+
bot.api.sendMessage(
|
|
3663
|
+
chat,
|
|
3664
|
+
"⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.",
|
|
3665
|
+
{ ...threadOpts },
|
|
3666
|
+
),
|
|
3667
|
+
{ chat_id: chat, verb: 'inbound-spool-escalation' },
|
|
3668
|
+
)
|
|
3669
|
+
})
|
|
3421
3670
|
}, IDLE_DRAIN_INTERVAL_MS).unref()
|
|
3422
3671
|
}
|
|
3423
3672
|
|
|
@@ -7227,6 +7476,29 @@ async function handleInbound(
|
|
|
7227
7476
|
// push to pendingInboundBuffer, which onClientRegistered drains on
|
|
7228
7477
|
// the next bridge register — so the notice below is now truthful.
|
|
7229
7478
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
7479
|
+
|
|
7480
|
+
// #1556: turn-gated delivery. A non-steering inbound that arrives
|
|
7481
|
+
// mid-turn must NOT be sent to the bridge now — claude would type it
|
|
7482
|
+
// into its TUI composer and the auto-submit races turn-completion,
|
|
7483
|
+
// stranding the message (the lawgpt wedge, 2026-05-19). Buffer it;
|
|
7484
|
+
// `purgeReactionTracking`'s turn-complete hook and the turn-gated
|
|
7485
|
+
// idle-drain flush it the instant claude goes idle, where the channel
|
|
7486
|
+
// notification submits cleanly as a fresh turn. Steering messages are
|
|
7487
|
+
// exempt — reaching claude mid-turn is the whole point of /steer.
|
|
7488
|
+
if (
|
|
7489
|
+
decideInboundDelivery({
|
|
7490
|
+
turnInFlight: activeTurnStartedAt.size > 0,
|
|
7491
|
+
isSteering,
|
|
7492
|
+
}) === 'buffer-until-idle'
|
|
7493
|
+
) {
|
|
7494
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
7495
|
+
process.stderr.write(
|
|
7496
|
+
`telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
|
|
7497
|
+
`chat=${chat_id} msg=${msgId ?? '-'} — will flush on turn-complete\n`,
|
|
7498
|
+
)
|
|
7499
|
+
return
|
|
7500
|
+
}
|
|
7501
|
+
|
|
7230
7502
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
7231
7503
|
if (!delivered) {
|
|
7232
7504
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound delivery gate (#1556 — the lawgpt composer-wedge).
|
|
3
|
+
*
|
|
4
|
+
* Pure decision: given the live turn state, should a freshly-received
|
|
5
|
+
* Telegram inbound be delivered to the bridge *now*, or held in the
|
|
6
|
+
* pending-inbound buffer until claude is idle?
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists
|
|
9
|
+
*
|
|
10
|
+
* The gateway used to `ipcServer.sendToAgent(inbound)` unconditionally,
|
|
11
|
+
* buffering ONLY when the bridge was offline. The load-bearing (and
|
|
12
|
+
* false) assumption — stated verbatim in three places before this fix
|
|
13
|
+
* (`pending-inbound-buffer.ts`, the idle-drain comment, and the
|
|
14
|
+
* implicit unconditional send) — was:
|
|
15
|
+
*
|
|
16
|
+
* "a message delivered while a turn is active is queued normally by
|
|
17
|
+
* the bridge, same as a live arrival, not lost."
|
|
18
|
+
*
|
|
19
|
+
* It is not. The bridge converts an inbound into an MCP
|
|
20
|
+
* `notifications/claude/channel` notification (`bridge.ts:onInbound`).
|
|
21
|
+
* When claude receives that notification mid-turn, the unmodified CLI
|
|
22
|
+
* types the text into its TUI composer and relies on an auto-submit
|
|
23
|
+
* once the turn ends. That submit races turn-completion and frequently
|
|
24
|
+
* does not fire — the message strands in the composer, claude sits at
|
|
25
|
+
* an idle prompt with the user's instruction un-actioned, and nothing
|
|
26
|
+
* self-heals it (the turn-active watchdog only catches *in-turn* hangs;
|
|
27
|
+
* this is *between-turns*-with-undelivered-input, which reads as
|
|
28
|
+
* healthy idle). Observed live: agent `lawgpt`, 2026-05-19 — a
|
|
29
|
+
* follow-up message sat unsubmitted indefinitely; only a restart
|
|
30
|
+
* cleared it, and the restart *lost* the message.
|
|
31
|
+
*
|
|
32
|
+
* ## The deterministic guarantee
|
|
33
|
+
*
|
|
34
|
+
* A non-steering inbound on the Telegram `handleInbound` path is
|
|
35
|
+
* delivered to the bridge ONLY when no turn is in flight. The channel
|
|
36
|
+
* notification therefore always lands at an idle claude prompt, where
|
|
37
|
+
* it submits cleanly as a fresh turn. It can be *delayed* (until the
|
|
38
|
+
* current turn completes) but can never strand in the composer. The
|
|
39
|
+
* turn-complete hook (`purgeReactionTracking`) and the turn-gated
|
|
40
|
+
* idle-drain timer flush the buffer the instant
|
|
41
|
+
* `activeTurnStartedAt.size === 0`.
|
|
42
|
+
*
|
|
43
|
+
* Scope: this gates the Telegram `handleInbound` path only — the one
|
|
44
|
+
* the lawgpt wedge hit. The `inject_inbound` IPC path (cron / synthetic
|
|
45
|
+
* operator wakeups) reaches the bridge directly and is deliberately
|
|
46
|
+
* NOT gated here: cron fires carry at-least-once replay semantics and
|
|
47
|
+
* their delivery contract is a separate product decision, out of scope
|
|
48
|
+
* for this bug.
|
|
49
|
+
*
|
|
50
|
+
* ## Steering is deliberately exempt
|
|
51
|
+
*
|
|
52
|
+
* An explicit `/steer` (`/s`) message is *meant* to reach claude
|
|
53
|
+
* mid-turn — that is the whole point of the steering feature (redirect
|
|
54
|
+
* the agent while it works). Steering messages keep immediate delivery.
|
|
55
|
+
* The wedge only ever affected the queued-mid-turn default path.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
export interface InboundDeliveryGateInput {
|
|
59
|
+
/** A turn is in flight RIGHT NOW (live: `activeTurnStartedAt.size > 0`),
|
|
60
|
+
* evaluated at delivery time — not a receipt-time snapshot, so a turn
|
|
61
|
+
* that completed between receipt and here correctly reads as idle. */
|
|
62
|
+
turnInFlight: boolean
|
|
63
|
+
/** This inbound carried an explicit `/steer` (`/s`) prefix and is an
|
|
64
|
+
* intentional mid-turn redirect. */
|
|
65
|
+
isSteering: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type InboundDeliveryDecision =
|
|
69
|
+
/** Send to the bridge now (idle prompt, or an intentional steer). */
|
|
70
|
+
| 'deliver'
|
|
71
|
+
/** Hold in the pending-inbound buffer; the turn-complete hook /
|
|
72
|
+
* turn-gated idle-drain flushes it when claude goes idle. */
|
|
73
|
+
| 'buffer-until-idle'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pure. The ONLY condition that defers delivery is "a turn is in flight
|
|
77
|
+
* AND this is not a steering message". Everything else delivers
|
|
78
|
+
* immediately (idle → submits at once; steering → intentional mid-turn).
|
|
79
|
+
*/
|
|
80
|
+
export function decideInboundDelivery(
|
|
81
|
+
input: InboundDeliveryGateInput,
|
|
82
|
+
): InboundDeliveryDecision {
|
|
83
|
+
if (input.turnInFlight && !input.isSteering) return 'buffer-until-idle'
|
|
84
|
+
return 'deliver'
|
|
85
|
+
}
|