switchroom 0.12.15 → 0.12.16
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +28 -5
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +32 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +48 -1
package/dist/cli/switchroom.js
CHANGED
|
@@ -47242,8 +47242,8 @@ var {
|
|
|
47242
47242
|
} = import__.default;
|
|
47243
47243
|
|
|
47244
47244
|
// src/build-info.ts
|
|
47245
|
-
var VERSION = "0.12.
|
|
47246
|
-
var COMMIT_SHA = "
|
|
47245
|
+
var VERSION = "0.12.16";
|
|
47246
|
+
var COMMIT_SHA = "b30ce83a";
|
|
47247
47247
|
|
|
47248
47248
|
// src/cli/agent.ts
|
|
47249
47249
|
init_source();
|
package/package.json
CHANGED
|
@@ -43366,6 +43366,15 @@ function redeliverBufferedInbound(buffer, agent, send) {
|
|
|
43366
43366
|
}
|
|
43367
43367
|
return { drained: pending.length, redelivered, rebuffered };
|
|
43368
43368
|
}
|
|
43369
|
+
function idleDrainTick(buffer, agent, isBridgeAlive, send) {
|
|
43370
|
+
if (!agent)
|
|
43371
|
+
return null;
|
|
43372
|
+
if (buffer.depth(agent) === 0)
|
|
43373
|
+
return null;
|
|
43374
|
+
if (!isBridgeAlive())
|
|
43375
|
+
return null;
|
|
43376
|
+
return redeliverBufferedInbound(buffer, agent, send);
|
|
43377
|
+
}
|
|
43369
43378
|
function createPendingInboundBuffer(opts = {}) {
|
|
43370
43379
|
const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
|
|
43371
43380
|
const log = opts.log ?? ((line) => process.stderr.write(line));
|
|
@@ -46768,11 +46777,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
46768
46777
|
}
|
|
46769
46778
|
|
|
46770
46779
|
// ../src/build-info.ts
|
|
46771
|
-
var VERSION = "0.12.
|
|
46772
|
-
var COMMIT_SHA = "
|
|
46773
|
-
var COMMIT_DATE = "2026-05-
|
|
46774
|
-
var LATEST_PR =
|
|
46775
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
46780
|
+
var VERSION = "0.12.16";
|
|
46781
|
+
var COMMIT_SHA = "b30ce83a";
|
|
46782
|
+
var COMMIT_DATE = "2026-05-19T08:46:47Z";
|
|
46783
|
+
var LATEST_PR = 1550;
|
|
46784
|
+
var COMMITS_AHEAD_OF_TAG = 25;
|
|
46776
46785
|
|
|
46777
46786
|
// gateway/boot-version.ts
|
|
46778
46787
|
function formatRelativeAgo(iso) {
|
|
@@ -48777,6 +48786,20 @@ ${reminder}
|
|
|
48777
48786
|
log: (msg) => process.stderr.write(`telegram gateway: ipc \u2014 ${msg}
|
|
48778
48787
|
`)
|
|
48779
48788
|
});
|
|
48789
|
+
var IDLE_DRAIN_INTERVAL_MS = 5000;
|
|
48790
|
+
if (!STATIC) {
|
|
48791
|
+
setInterval(() => {
|
|
48792
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
48793
|
+
const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
|
|
48794
|
+
const c = ipcServer.getClient(selfAgent);
|
|
48795
|
+
return c != null && c.isAlive();
|
|
48796
|
+
}, (m) => ipcServer.sendToAgent(selfAgent, m));
|
|
48797
|
+
if (r != null && r.redelivered > 0) {
|
|
48798
|
+
process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
|
|
48799
|
+
`);
|
|
48800
|
+
}
|
|
48801
|
+
}, IDLE_DRAIN_INTERVAL_MS).unref();
|
|
48802
|
+
}
|
|
48780
48803
|
var ALLOWED_TOOLS = new Set([
|
|
48781
48804
|
"reply",
|
|
48782
48805
|
"stream_reply",
|
|
@@ -245,7 +245,7 @@ import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
|
245
245
|
import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
|
|
246
246
|
import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
247
247
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
248
|
-
import { createPendingInboundBuffer, redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
248
|
+
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
249
249
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
250
250
|
import {
|
|
251
251
|
buildVaultGrantApprovedInbound,
|
|
@@ -3272,6 +3272,48 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3272
3272
|
log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
|
|
3273
3273
|
})
|
|
3274
3274
|
|
|
3275
|
+
// ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
|
|
3276
|
+
// pendingInboundBuffer otherwise drains only on (a) bridge re-register
|
|
3277
|
+
// (onClientRegistered) or (b) the silence-poke framework fallback
|
|
3278
|
+
// clearing a wedged turn (#1546). NEITHER fires when a message is
|
|
3279
|
+
// buffered during a bridge-IPC flap that then settles with no
|
|
3280
|
+
// subsequent clean re-register AND claude is idle (no active turn →
|
|
3281
|
+
// silence-poke never arms). The message orphans until a manual restart
|
|
3282
|
+
// (finn, 2026-05-19 — buffered "verify with mff-query.py cashflow"
|
|
3283
|
+
// while idle; last `bridge registered` predated the buffer push, so
|
|
3284
|
+
// onClientRegistered's drain never ran for it).
|
|
3285
|
+
//
|
|
3286
|
+
// This is the third drain trigger. It's gated to be zero-cost and
|
|
3287
|
+
// zero-churn: skip entirely when nothing is buffered (one Map.get, no
|
|
3288
|
+
// log) or when the bridge isn't alive (exactly sendToAgent's own
|
|
3289
|
+
// guard — so we never drain into a dead bridge and re-buffer/log-spin).
|
|
3290
|
+
// Only when there IS a buffered message AND a live bridge do we reuse
|
|
3291
|
+
// the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
|
|
3292
|
+
// per-message miss). A message delivered while a turn is active is
|
|
3293
|
+
// queued normally by the bridge — same as a live arrival, not lost.
|
|
3294
|
+
const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
3295
|
+
if (!STATIC) {
|
|
3296
|
+
setInterval(() => {
|
|
3297
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
3298
|
+
const r = idleDrainTick(
|
|
3299
|
+
pendingInboundBuffer,
|
|
3300
|
+
selfAgent,
|
|
3301
|
+
() => {
|
|
3302
|
+
const c = ipcServer.getClient(selfAgent)
|
|
3303
|
+
return c != null && c.isAlive()
|
|
3304
|
+
},
|
|
3305
|
+
(m) => ipcServer.sendToAgent(selfAgent, m),
|
|
3306
|
+
)
|
|
3307
|
+
if (r != null && r.redelivered > 0) {
|
|
3308
|
+
process.stderr.write(
|
|
3309
|
+
`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} ` +
|
|
3310
|
+
`buffered inbound for ${selfAgent}` +
|
|
3311
|
+
`${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ''}\n`,
|
|
3312
|
+
)
|
|
3313
|
+
}
|
|
3314
|
+
}, IDLE_DRAIN_INTERVAL_MS).unref()
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3275
3317
|
// ─── Tool execution ──────────────────────────────────────────────────────
|
|
3276
3318
|
|
|
3277
3319
|
/** Allowlisted tool names that bridges may invoke via IPC. Prevents a rogue
|
|
@@ -93,6 +93,38 @@ export function redeliverBufferedInbound(
|
|
|
93
93
|
return { drained: pending.length, redelivered, rebuffered }
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* One opportunistic idle-drain tick. The third drain trigger, beside
|
|
98
|
+
* `onClientRegistered` (bridge re-register) and the silence-poke
|
|
99
|
+
* wedge-clear (#1546). Closes the orphan gap those two miss: a message
|
|
100
|
+
* buffered during a bridge-IPC flap that settles with no subsequent
|
|
101
|
+
* clean re-register while claude is idle (no turn → no silence-poke)
|
|
102
|
+
* — it would otherwise sit until a manual restart (finn, 2026-05-19).
|
|
103
|
+
*
|
|
104
|
+
* Gated to be zero-cost / zero-churn so it can run on a short timer:
|
|
105
|
+
* - empty buffer → return null (one Map.get, NO drain, NO log)
|
|
106
|
+
* - bridge not alive → return null (never drain into a dead bridge,
|
|
107
|
+
* which would re-buffer+log-spin every tick; onClientRegistered
|
|
108
|
+
* will drain on the eventual reconnect instead)
|
|
109
|
+
* - otherwise → `redeliverBufferedInbound` (lossless: re-buffers any
|
|
110
|
+
* per-message miss). A message delivered mid-turn is queued
|
|
111
|
+
* normally by the bridge, same as a live arrival — not lost.
|
|
112
|
+
*
|
|
113
|
+
* Returns the redeliver counts only when it actually ran, else null
|
|
114
|
+
* (so the caller logs only on a real flush).
|
|
115
|
+
*/
|
|
116
|
+
export function idleDrainTick(
|
|
117
|
+
buffer: PendingInboundBuffer,
|
|
118
|
+
agent: string,
|
|
119
|
+
isBridgeAlive: () => boolean,
|
|
120
|
+
send: (msg: InboundMessage) => boolean,
|
|
121
|
+
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
122
|
+
if (!agent) return null
|
|
123
|
+
if (buffer.depth(agent) === 0) return null
|
|
124
|
+
if (!isBridgeAlive()) return null
|
|
125
|
+
return redeliverBufferedInbound(buffer, agent, send)
|
|
126
|
+
}
|
|
127
|
+
|
|
96
128
|
export function createPendingInboundBuffer(
|
|
97
129
|
opts: PendingInboundBufferOptions = {},
|
|
98
130
|
): PendingInboundBuffer {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest'
|
|
10
|
-
import { createPendingInboundBuffer, redeliverBufferedInbound, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
|
|
10
|
+
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
|
|
11
11
|
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
12
12
|
|
|
13
13
|
function inbound(source: string, ts = Date.now()): InboundMessage {
|
|
@@ -200,3 +200,50 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
|
|
|
200
200
|
expect(buf.depth('clerk')).toBe(1) // untouched
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)', () => {
|
|
205
|
+
it('no-op (returns null, no send) when the buffer is empty', () => {
|
|
206
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
207
|
+
let sent = 0
|
|
208
|
+
const r = idleDrainTick(buf, 'finn', () => true, () => { sent++; return true })
|
|
209
|
+
expect(r).toBeNull()
|
|
210
|
+
expect(sent).toBe(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('no-op (returns null) when bridge is NOT alive — never drains into a dead bridge', () => {
|
|
214
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
215
|
+
buf.push('finn', inbound('user', 1))
|
|
216
|
+
let sent = 0
|
|
217
|
+
const r = idleDrainTick(buf, 'finn', () => false, () => { sent++; return true })
|
|
218
|
+
expect(r).toBeNull()
|
|
219
|
+
expect(sent).toBe(0)
|
|
220
|
+
expect(buf.depth('finn')).toBe(1) // untouched — onClientRegistered will get it on reconnect
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('flushes the buffer when bridge is alive AND something is buffered (the finn fix)', () => {
|
|
224
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
225
|
+
buf.push('finn', inbound('user', 2013)) // the orphaned "verify with mff-query.py" class
|
|
226
|
+
const seen: number[] = []
|
|
227
|
+
const r = idleDrainTick(buf, 'finn', () => true, (m) => { seen.push(m.messageId as number); return true })
|
|
228
|
+
expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0 })
|
|
229
|
+
expect(seen).toEqual([2013])
|
|
230
|
+
expect(buf.depth('finn')).toBe(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('is lossless — a delivery miss re-buffers, returns null on empty agent', () => {
|
|
234
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
235
|
+
buf.push('finn', inbound('user', 1))
|
|
236
|
+
const r = idleDrainTick(buf, 'finn', () => true, () => false)
|
|
237
|
+
expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
|
|
238
|
+
expect(buf.depth('finn')).toBe(1) // nothing lost
|
|
239
|
+
expect(idleDrainTick(buf, '', () => true, () => true)).toBeNull() // empty agent guard
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('checks depth BEFORE isBridgeAlive — empty buffer never probes the bridge', () => {
|
|
243
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
244
|
+
let probed = false
|
|
245
|
+
const r = idleDrainTick(buf, 'finn', () => { probed = true; return true }, () => true)
|
|
246
|
+
expect(r).toBeNull()
|
|
247
|
+
expect(probed).toBe(false) // cheap path: Map.get only, no bridge probe, no log
|
|
248
|
+
})
|
|
249
|
+
})
|