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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inbound-spool.ts — durable, crash-tolerant spool for buffered inbound.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `pending-inbound-buffer.ts` is in-memory only. A
|
|
5
|
+
* gateway/container restart (switchroom update, agent restart, a
|
|
6
|
+
* self-restart, an OOM) destroys it — so the user-facing promise
|
|
7
|
+
* "⏳ your message is queued and will be processed when it reconnects"
|
|
8
|
+
* (gateway.ts) is a lie across a restart. Proven twice: finn and
|
|
9
|
+
* carrie (2026-05-19) lost the user's message on restart and the user
|
|
10
|
+
* had to resend. #1546/#1549 only shrank the in-memory delivery
|
|
11
|
+
* window; they cannot survive process death.
|
|
12
|
+
*
|
|
13
|
+
* This module makes the promise DETERMINISTIC: every buffered inbound
|
|
14
|
+
* is also appended to a JSONL spool on the persistent per-agent volume
|
|
15
|
+
* (`/state/agent/telegram/…`, survives container recreate). On boot the
|
|
16
|
+
* gateway replays un-acked entries back into the in-memory buffer, so
|
|
17
|
+
* the existing drain machinery delivers them. An entry is acked (and
|
|
18
|
+
* tombstoned) ONLY on confirmed delivery to a live registered bridge.
|
|
19
|
+
* Un-acked entries older than `escalateAfterMs` are surfaced to the
|
|
20
|
+
* user via an explicit "couldn't deliver — resend?" callback and then
|
|
21
|
+
* dropped: the promise is then ALWAYS resolved — kept, or visibly
|
|
22
|
+
* retracted — never silently lost.
|
|
23
|
+
*
|
|
24
|
+
* Scope (v1): the ack is "delivered to a live registered bridge", not
|
|
25
|
+
* "claude consumed it". A true claude→gateway consumption-ack needs a
|
|
26
|
+
* new bidirectional bridge protocol (high blast radius) and is a
|
|
27
|
+
* documented follow-up. v1 already eliminates the silent-loss-on-
|
|
28
|
+
* restart class — the actual incident class.
|
|
29
|
+
*
|
|
30
|
+
* Crash-consistency: append-only JSONL, one self-contained JSON object
|
|
31
|
+
* per line, written with a trailing newline in a single `appendFileSync`
|
|
32
|
+
* (atomic for small writes on local fs). A torn final line on a crash
|
|
33
|
+
* mid-write is tolerated: replay skips any line that does not
|
|
34
|
+
* round-trip `JSON.parse` + shape-check. Acks are themselves appended
|
|
35
|
+
* as tombstone lines (`{t:"ack",id}`) rather than rewriting the file;
|
|
36
|
+
* a bounded `compact()` rewrites the file dropping acked/escalated ids
|
|
37
|
+
* when it grows past `compactAtBytes`.
|
|
38
|
+
*
|
|
39
|
+
* This module is PURE w.r.t. its injected fs + clock seams so the
|
|
40
|
+
* crash/dedup/replay/escalation logic is unit-tested without a real
|
|
41
|
+
* gateway (mirrors the #1544/#1546/#1549 pure-seam idiom).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
45
|
+
|
|
46
|
+
/** Stable dedup id for an inbound. Real Telegram messages have a
|
|
47
|
+
* unique (chatId, messageId). Synthetic/cron inbounds use messageId
|
|
48
|
+
* 0 — fall back to a deterministic id from source+ts so retried
|
|
49
|
+
* synthetics of the SAME logical event dedup, but distinct events
|
|
50
|
+
* (different ts) do not collapse. */
|
|
51
|
+
export function spoolId(msg: InboundMessage): string {
|
|
52
|
+
if (typeof msg.messageId === 'number' && msg.messageId > 0) {
|
|
53
|
+
return `m:${msg.chatId}:${msg.messageId}`
|
|
54
|
+
}
|
|
55
|
+
const src = msg.meta?.source ?? '-'
|
|
56
|
+
return `s:${msg.chatId}:${src}:${msg.ts}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SpoolRecord {
|
|
60
|
+
t: 'put' | 'ack'
|
|
61
|
+
id: string
|
|
62
|
+
/** Present only on `put`. The full inbound to replay. */
|
|
63
|
+
msg?: InboundMessage
|
|
64
|
+
/** Present only on `put`. Owning agent (replay re-pushes per agent). */
|
|
65
|
+
agent?: string
|
|
66
|
+
/** Present only on `put`. ms epoch first-spooled — drives escalation. */
|
|
67
|
+
firstAt?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface InboundSpoolFsSeam {
|
|
71
|
+
appendFileSync: (path: string, data: string) => void
|
|
72
|
+
readFileSync: (path: string) => string
|
|
73
|
+
writeFileSync: (path: string, data: string) => void
|
|
74
|
+
/** Atomic same-dir replace (POSIX rename). Used so compaction can't
|
|
75
|
+
* lose entries to a crash mid-rewrite. */
|
|
76
|
+
renameSync: (from: string, to: string) => void
|
|
77
|
+
existsSync: (path: string) => boolean
|
|
78
|
+
statSizeSync: (path: string) => number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface InboundSpoolOptions {
|
|
82
|
+
path: string
|
|
83
|
+
fs: InboundSpoolFsSeam
|
|
84
|
+
now?: () => number
|
|
85
|
+
log?: (line: string) => void
|
|
86
|
+
/** Un-acked entries older than this are escalated then dropped.
|
|
87
|
+
* Default 15 min — comfortably past the 5-min silence-poke ladder
|
|
88
|
+
* so self-heal gets every chance before we retract the promise. */
|
|
89
|
+
escalateAfterMs?: number
|
|
90
|
+
/** Rewrite-compact the JSONL once it exceeds this. Default 256 KiB. */
|
|
91
|
+
compactAtBytes?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ReplayEntry {
|
|
95
|
+
agent: string
|
|
96
|
+
msg: InboundMessage
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface InboundSpool {
|
|
100
|
+
/** Durably record `msg` for `agent`. Idempotent by spoolId: a
|
|
101
|
+
* re-spool of an already-live id is a no-op (returns false). */
|
|
102
|
+
put: (agent: string, msg: InboundMessage) => boolean
|
|
103
|
+
/** Tombstone `id` — call ONLY on confirmed delivery to a live
|
|
104
|
+
* registered bridge. Idempotent. */
|
|
105
|
+
ack: (msg: InboundMessage) => void
|
|
106
|
+
/** Live (un-acked) entries, oldest first. Used at boot to re-push
|
|
107
|
+
* into the in-memory buffer. Pure read — does not mutate. */
|
|
108
|
+
liveEntries: () => ReplayEntry[]
|
|
109
|
+
/** Escalate+drop entries older than `escalateAfterMs`. Calls
|
|
110
|
+
* `onEscalate` once per dropped entry (post the "couldn't deliver"
|
|
111
|
+
* card there). Returns the count escalated. Safe to call on a timer. */
|
|
112
|
+
sweepEscalations: (onEscalate: (e: ReplayEntry) => void) => number
|
|
113
|
+
/** Test/observability: count of live (un-acked) ids. */
|
|
114
|
+
liveCount: () => number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
118
|
+
const { path, fs } = opts
|
|
119
|
+
const now = opts.now ?? Date.now
|
|
120
|
+
const log = opts.log ?? ((l: string) => process.stderr.write(l))
|
|
121
|
+
const escalateAfterMs = opts.escalateAfterMs ?? 15 * 60 * 1000
|
|
122
|
+
const compactAtBytes = opts.compactAtBytes ?? 256 * 1024
|
|
123
|
+
|
|
124
|
+
// In-memory projection of the on-disk log, rebuilt from the file at
|
|
125
|
+
// construction. `live` maps spoolId → the put record (insertion order
|
|
126
|
+
// preserved via the Map). An `ack` deletes from `live`.
|
|
127
|
+
const live = new Map<string, { agent: string; msg: InboundMessage; firstAt: number }>()
|
|
128
|
+
|
|
129
|
+
function parseLine(line: string): SpoolRecord | null {
|
|
130
|
+
const s = line.trim()
|
|
131
|
+
if (!s) return null
|
|
132
|
+
let rec: unknown
|
|
133
|
+
try {
|
|
134
|
+
rec = JSON.parse(s)
|
|
135
|
+
} catch {
|
|
136
|
+
return null // torn / partial line from a crash mid-append — skip
|
|
137
|
+
}
|
|
138
|
+
if (rec == null || typeof rec !== 'object') return null
|
|
139
|
+
const r = rec as Record<string, unknown>
|
|
140
|
+
if (r.t !== 'put' && r.t !== 'ack') return null
|
|
141
|
+
if (typeof r.id !== 'string' || r.id.length === 0) return null
|
|
142
|
+
if (r.t === 'put') {
|
|
143
|
+
if (r.msg == null || typeof r.msg !== 'object') return null
|
|
144
|
+
if (typeof r.agent !== 'string' || r.agent.length === 0) return null
|
|
145
|
+
if (typeof r.firstAt !== 'number') return null
|
|
146
|
+
}
|
|
147
|
+
return r as unknown as SpoolRecord
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Rebuild `live` from the file. Tolerates a torn last line.
|
|
151
|
+
function hydrate(): void {
|
|
152
|
+
live.clear()
|
|
153
|
+
if (!fs.existsSync(path)) return
|
|
154
|
+
let raw = ''
|
|
155
|
+
try {
|
|
156
|
+
raw = fs.readFileSync(path)
|
|
157
|
+
} catch {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
for (const line of raw.split('\n')) {
|
|
161
|
+
const rec = parseLine(line)
|
|
162
|
+
if (rec == null) continue
|
|
163
|
+
if (rec.t === 'put') {
|
|
164
|
+
// Last put for an id wins; an ack later removes it.
|
|
165
|
+
live.set(rec.id, {
|
|
166
|
+
agent: rec.agent as string,
|
|
167
|
+
msg: rec.msg as InboundMessage,
|
|
168
|
+
firstAt: rec.firstAt as number,
|
|
169
|
+
})
|
|
170
|
+
} else {
|
|
171
|
+
live.delete(rec.id)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendRecord(rec: SpoolRecord): void {
|
|
177
|
+
try {
|
|
178
|
+
fs.appendFileSync(path, JSON.stringify(rec) + '\n')
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Durability is best-effort relative to fs availability; a spool
|
|
181
|
+
// write failure must NOT break live delivery. Log loudly — a
|
|
182
|
+
// persistently failing spool means we're back to in-memory-only
|
|
183
|
+
// semantics and the operator should know.
|
|
184
|
+
log(
|
|
185
|
+
`inbound-spool: append FAILED path=${path} id=${rec.id} t=${rec.t}: ` +
|
|
186
|
+
`${(err as Error).message} — durability degraded to in-memory\n`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function maybeCompact(): void {
|
|
192
|
+
let size = 0
|
|
193
|
+
try {
|
|
194
|
+
size = fs.existsSync(path) ? fs.statSizeSync(path) : 0
|
|
195
|
+
} catch {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (size <= compactAtBytes) return
|
|
199
|
+
// Rewrite the file as exactly the current live set (one put per
|
|
200
|
+
// live id, no acks). ATOMIC: write a sibling tmp then rename over
|
|
201
|
+
// the real path. rename(2) is atomic within a filesystem, so a
|
|
202
|
+
// crash at any point leaves EITHER the full pre-compaction log OR
|
|
203
|
+
// the full compacted log on disk — never a truncated/torn file
|
|
204
|
+
// that loses live entries after the tear. (Plain writeFileSync is
|
|
205
|
+
// not atomic; a crash mid-write of a >256 KiB rewrite could drop
|
|
206
|
+
// entries past the tear — the residual the reviewer flagged.)
|
|
207
|
+
const lines: string[] = []
|
|
208
|
+
for (const [id, e] of live) {
|
|
209
|
+
lines.push(
|
|
210
|
+
JSON.stringify({ t: 'put', id, agent: e.agent, msg: e.msg, firstAt: e.firstAt } satisfies SpoolRecord),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
const tmp = path + '.compact.tmp'
|
|
214
|
+
try {
|
|
215
|
+
fs.writeFileSync(tmp, lines.length ? lines.join('\n') + '\n' : '')
|
|
216
|
+
fs.renameSync(tmp, path)
|
|
217
|
+
log(`inbound-spool: compacted path=${path} live=${live.size}\n`)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Compaction is opportunistic — a failure keeps the (larger but
|
|
220
|
+
// correct) append-only log; never lose data trying to shrink it.
|
|
221
|
+
log(`inbound-spool: compact FAILED path=${path}: ${(err as Error).message}\n`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
hydrate()
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
put(agent, msg) {
|
|
229
|
+
const id = spoolId(msg)
|
|
230
|
+
if (live.has(id)) return false // dedup: already spooled & un-acked
|
|
231
|
+
const firstAt = now()
|
|
232
|
+
live.set(id, { agent, msg, firstAt })
|
|
233
|
+
appendRecord({ t: 'put', id, agent, msg, firstAt })
|
|
234
|
+
maybeCompact()
|
|
235
|
+
return true
|
|
236
|
+
},
|
|
237
|
+
ack(msg) {
|
|
238
|
+
const id = spoolId(msg)
|
|
239
|
+
if (!live.has(id)) return // idempotent / unknown id
|
|
240
|
+
live.delete(id)
|
|
241
|
+
appendRecord({ t: 'ack', id })
|
|
242
|
+
maybeCompact()
|
|
243
|
+
},
|
|
244
|
+
liveEntries() {
|
|
245
|
+
// Insertion order = Map iteration order = oldest first.
|
|
246
|
+
return [...live.values()].map((e) => ({ agent: e.agent, msg: e.msg }))
|
|
247
|
+
},
|
|
248
|
+
sweepEscalations(onEscalate) {
|
|
249
|
+
const cutoff = now() - escalateAfterMs
|
|
250
|
+
let n = 0
|
|
251
|
+
for (const [id, e] of [...live.entries()]) {
|
|
252
|
+
if (e.firstAt > cutoff) continue
|
|
253
|
+
live.delete(id)
|
|
254
|
+
appendRecord({ t: 'ack', id }) // tombstone — promise retracted
|
|
255
|
+
try {
|
|
256
|
+
onEscalate({ agent: e.agent, msg: e.msg })
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
|
|
259
|
+
}
|
|
260
|
+
n++
|
|
261
|
+
}
|
|
262
|
+
if (n > 0) {
|
|
263
|
+
log(`inbound-spool: escalated+dropped ${n} undelivered entr${n === 1 ? 'y' : 'ies'} (older than ${escalateAfterMs}ms)\n`)
|
|
264
|
+
maybeCompact()
|
|
265
|
+
}
|
|
266
|
+
return n
|
|
267
|
+
},
|
|
268
|
+
liveCount() {
|
|
269
|
+
return live.size
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
|
+
import type { InboundSpool } from './inbound-spool.js'
|
|
33
34
|
|
|
34
35
|
/** Default cap per agent. Tuned for `should fit a reasonable backlog of
|
|
35
36
|
* approval cards stacked while bridge is offline` but no more. */
|
|
@@ -52,6 +53,19 @@ export interface PendingInboundBuffer {
|
|
|
52
53
|
export interface PendingInboundBufferOptions {
|
|
53
54
|
capPerAgent?: number
|
|
54
55
|
log?: (line: string) => void
|
|
56
|
+
/**
|
|
57
|
+
* Durable spool. When set, every `push` is also recorded on the
|
|
58
|
+
* persistent per-agent volume so a gateway/container restart cannot
|
|
59
|
+
* silently lose the message (the finn/carrie incident class). The
|
|
60
|
+
* in-memory queue stays the hot path + cap; the spool is the
|
|
61
|
+
* crash-survivable record, acked only on confirmed delivery (by
|
|
62
|
+
* `redeliverBufferedInbound`/`idleDrainTick`), boot-replayed by the
|
|
63
|
+
* gateway, and escalated-then-dropped if undeliverable past its
|
|
64
|
+
* bound. The in-memory cap eviction does NOT touch the spool — an
|
|
65
|
+
* evicted-from-memory entry survives in the spool (strictly safer
|
|
66
|
+
* than the old silent in-memory drop).
|
|
67
|
+
*/
|
|
68
|
+
spool?: InboundSpool
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
/**
|
|
@@ -72,6 +86,7 @@ export function redeliverBufferedInbound(
|
|
|
72
86
|
buffer: PendingInboundBuffer,
|
|
73
87
|
agent: string,
|
|
74
88
|
send: (msg: InboundMessage) => boolean,
|
|
89
|
+
spool?: InboundSpool,
|
|
75
90
|
): { drained: number; redelivered: number; rebuffered: number } {
|
|
76
91
|
const pending = buffer.drain(agent)
|
|
77
92
|
let redelivered = 0
|
|
@@ -85,6 +100,11 @@ export function redeliverBufferedInbound(
|
|
|
85
100
|
}
|
|
86
101
|
if (delivered) {
|
|
87
102
|
redelivered++
|
|
103
|
+
// Confirmed delivery to a live registered bridge → the durable
|
|
104
|
+
// promise is kept; tombstone the spool entry so it is NOT
|
|
105
|
+
// boot-replayed again. A miss leaves it spooled (re-pushed below
|
|
106
|
+
// AND still live in the spool) for the next drain / escalation.
|
|
107
|
+
spool?.ack(msg)
|
|
88
108
|
} else {
|
|
89
109
|
buffer.push(agent, msg)
|
|
90
110
|
rebuffered++
|
|
@@ -107,8 +127,19 @@ export function redeliverBufferedInbound(
|
|
|
107
127
|
* which would re-buffer+log-spin every tick; onClientRegistered
|
|
108
128
|
* will drain on the eventual reconnect instead)
|
|
109
129
|
* - otherwise → `redeliverBufferedInbound` (lossless: re-buffers any
|
|
110
|
-
* per-message miss).
|
|
111
|
-
*
|
|
130
|
+
* per-message miss).
|
|
131
|
+
*
|
|
132
|
+
* NOTE (#1556): a message delivered mid-turn is NOT safely queued by
|
|
133
|
+
* the bridge — the prior "queued normally, same as a live arrival"
|
|
134
|
+
* claim here was the false assumption behind the lawgpt composer
|
|
135
|
+
* wedge. claude types a mid-turn channel notification into its TUI
|
|
136
|
+
* composer and the auto-submit races turn-completion, stranding it.
|
|
137
|
+
* The `idleDrainTick` caller therefore also gates on
|
|
138
|
+
* `activeTurnStartedAt.size === 0`, so this function is never invoked
|
|
139
|
+
* mid-turn. The Telegram `handleInbound` delivery path is turn-gated
|
|
140
|
+
* (gateway.ts); the `inject_inbound` cron/synthetic path is a separate
|
|
141
|
+
* delivery contract and deliberately not gated — see
|
|
142
|
+
* `inbound-delivery-gate.ts`.
|
|
112
143
|
*
|
|
113
144
|
* Returns the redeliver counts only when it actually ran, else null
|
|
114
145
|
* (so the caller logs only on a real flush).
|
|
@@ -118,11 +149,12 @@ export function idleDrainTick(
|
|
|
118
149
|
agent: string,
|
|
119
150
|
isBridgeAlive: () => boolean,
|
|
120
151
|
send: (msg: InboundMessage) => boolean,
|
|
152
|
+
spool?: InboundSpool,
|
|
121
153
|
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
122
154
|
if (!agent) return null
|
|
123
155
|
if (buffer.depth(agent) === 0) return null
|
|
124
156
|
if (!isBridgeAlive()) return null
|
|
125
|
-
return redeliverBufferedInbound(buffer, agent, send)
|
|
157
|
+
return redeliverBufferedInbound(buffer, agent, send, spool)
|
|
126
158
|
}
|
|
127
159
|
|
|
128
160
|
export function createPendingInboundBuffer(
|
|
@@ -130,6 +162,7 @@ export function createPendingInboundBuffer(
|
|
|
130
162
|
): PendingInboundBuffer {
|
|
131
163
|
const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP
|
|
132
164
|
const log = opts.log ?? ((line: string) => process.stderr.write(line))
|
|
165
|
+
const spool = opts.spool
|
|
133
166
|
const queues = new Map<string, InboundMessage[]>()
|
|
134
167
|
|
|
135
168
|
return {
|
|
@@ -149,6 +182,12 @@ export function createPendingInboundBuffer(
|
|
|
149
182
|
)
|
|
150
183
|
}
|
|
151
184
|
q.push(msg)
|
|
185
|
+
// Durable record FIRST-class to the in-memory queue: spool BEFORE
|
|
186
|
+
// returning, regardless of the cap eviction above — an entry the
|
|
187
|
+
// in-memory cap drops still survives in the spool (boot-replayed /
|
|
188
|
+
// escalated), which is the whole point. spool.put dedups by
|
|
189
|
+
// spoolId so a boot-replay re-push is a no-op here.
|
|
190
|
+
spool?.put(agent, msg)
|
|
152
191
|
log(
|
|
153
192
|
`pending-inbound-buffer: agent=${agent} buffered source=${msg.meta?.source ?? '-'} ` +
|
|
154
193
|
`depth_after=${q.length} evicted=${evicted}\n`,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
nextCompactNotify,
|
|
4
|
+
idleCompactNotifyState,
|
|
5
|
+
type CompactNotifyState,
|
|
6
|
+
} from '../gateway/compact-notify.js'
|
|
7
|
+
|
|
8
|
+
const FILE_A = '/state/.../sess-a.jsonl'
|
|
9
|
+
const FILE_B = '/state/.../sess-b.jsonl'
|
|
10
|
+
|
|
11
|
+
describe('nextCompactNotify', () => {
|
|
12
|
+
it('idle + fired → start, awaiting with fileAtStart', () => {
|
|
13
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
14
|
+
fired: true,
|
|
15
|
+
rearmed: false,
|
|
16
|
+
activeFile: FILE_A,
|
|
17
|
+
})
|
|
18
|
+
expect(r.action).toBe('start')
|
|
19
|
+
expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_A })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('idle + nothing → none', () => {
|
|
23
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
24
|
+
fired: false,
|
|
25
|
+
rearmed: false,
|
|
26
|
+
activeFile: FILE_A,
|
|
27
|
+
})
|
|
28
|
+
expect(r.action).toBe('none')
|
|
29
|
+
expect(r.state.phase).toBe('idle')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('idle + spurious rearm (no card outstanding) → none', () => {
|
|
33
|
+
const r = nextCompactNotify(idleCompactNotifyState(), {
|
|
34
|
+
fired: false,
|
|
35
|
+
rearmed: true,
|
|
36
|
+
activeFile: FILE_A,
|
|
37
|
+
})
|
|
38
|
+
expect(r.action).toBe('none')
|
|
39
|
+
expect(r.state.phase).toBe('idle')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('awaiting + rearm on SAME file → finish, back to idle', () => {
|
|
43
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
44
|
+
const r = nextCompactNotify(awaiting, {
|
|
45
|
+
fired: false,
|
|
46
|
+
rearmed: true,
|
|
47
|
+
activeFile: FILE_A,
|
|
48
|
+
})
|
|
49
|
+
expect(r.action).toBe('finish')
|
|
50
|
+
expect(r.state).toEqual(idleCompactNotifyState())
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('awaiting + rearm on DIFFERENT file → none (sub-agent false-positive guard)', () => {
|
|
54
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
55
|
+
const r = nextCompactNotify(awaiting, {
|
|
56
|
+
fired: false,
|
|
57
|
+
rearmed: true,
|
|
58
|
+
activeFile: FILE_B,
|
|
59
|
+
})
|
|
60
|
+
expect(r.action).toBe('none')
|
|
61
|
+
expect(r.state).toBe(awaiting) // unchanged, still awaiting
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('awaiting + rearm but activeFile null → none', () => {
|
|
65
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
66
|
+
const r = nextCompactNotify(awaiting, {
|
|
67
|
+
fired: false,
|
|
68
|
+
rearmed: true,
|
|
69
|
+
activeFile: null,
|
|
70
|
+
})
|
|
71
|
+
expect(r.action).toBe('none')
|
|
72
|
+
expect(r.state.phase).toBe('awaiting')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('awaiting + no rearm → none, stays awaiting (shell timeout owns dangling)', () => {
|
|
76
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
77
|
+
const r = nextCompactNotify(awaiting, {
|
|
78
|
+
fired: false,
|
|
79
|
+
rearmed: false,
|
|
80
|
+
activeFile: FILE_A,
|
|
81
|
+
})
|
|
82
|
+
expect(r.action).toBe('none')
|
|
83
|
+
expect(r.state.phase).toBe('awaiting')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('awaiting + fired again → start-superseding, awaiting with NEW fileAtStart', () => {
|
|
87
|
+
const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
|
|
88
|
+
const r = nextCompactNotify(awaiting, {
|
|
89
|
+
fired: true,
|
|
90
|
+
rearmed: false,
|
|
91
|
+
activeFile: FILE_B,
|
|
92
|
+
})
|
|
93
|
+
expect(r.action).toBe('start-superseding')
|
|
94
|
+
expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_B })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('full healthy cycle: fire → hold → rearm(same file) → idle', () => {
|
|
98
|
+
let s = idleCompactNotifyState()
|
|
99
|
+
const fires: string[] = []
|
|
100
|
+
const seq = [
|
|
101
|
+
{ fired: true, rearmed: false, activeFile: FILE_A }, // START
|
|
102
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // cooldown/hold
|
|
103
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // hold
|
|
104
|
+
{ fired: false, rearmed: true, activeFile: FILE_A }, // FINISH
|
|
105
|
+
{ fired: false, rearmed: false, activeFile: FILE_A }, // idle, nothing
|
|
106
|
+
]
|
|
107
|
+
for (const ev of seq) {
|
|
108
|
+
const r = nextCompactNotify(s, ev)
|
|
109
|
+
s = r.state
|
|
110
|
+
fires.push(r.action)
|
|
111
|
+
}
|
|
112
|
+
expect(fires).toEqual(['start', 'none', 'none', 'finish', 'none'])
|
|
113
|
+
expect(s).toEqual(idleCompactNotifyState())
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('rearm that arrives only after a file flip never finishes the wrong card', () => {
|
|
117
|
+
// START on FILE_A; a sub-agent (FILE_B) leads and "rearms" → ignored;
|
|
118
|
+
// later the real drop is observed back on FILE_A → finish.
|
|
119
|
+
let s = nextCompactNotify(idleCompactNotifyState(), {
|
|
120
|
+
fired: true,
|
|
121
|
+
rearmed: false,
|
|
122
|
+
activeFile: FILE_A,
|
|
123
|
+
}).state
|
|
124
|
+
const mid = nextCompactNotify(s, {
|
|
125
|
+
fired: false,
|
|
126
|
+
rearmed: true,
|
|
127
|
+
activeFile: FILE_B,
|
|
128
|
+
})
|
|
129
|
+
expect(mid.action).toBe('none')
|
|
130
|
+
s = mid.state
|
|
131
|
+
const fin = nextCompactNotify(s, {
|
|
132
|
+
fired: false,
|
|
133
|
+
rearmed: true,
|
|
134
|
+
activeFile: FILE_A,
|
|
135
|
+
})
|
|
136
|
+
expect(fin.action).toBe('finish')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression coverage for #1556 — the lawgpt composer wedge.
|
|
7
|
+
*
|
|
8
|
+
* Before this gate, the gateway sent every inbound to the bridge
|
|
9
|
+
* immediately, buffering only when the bridge was offline. A
|
|
10
|
+
* non-steering message that arrived mid-turn was typed into claude's
|
|
11
|
+
* TUI composer and stranded when the auto-submit raced
|
|
12
|
+
* turn-completion. The deterministic invariant the gate enforces:
|
|
13
|
+
*
|
|
14
|
+
* a non-steering inbound is delivered ONLY when no turn is in flight.
|
|
15
|
+
*
|
|
16
|
+
* Steering (/steer, /s) is the sole exemption — reaching claude
|
|
17
|
+
* mid-turn is the entire point of that feature.
|
|
18
|
+
*/
|
|
19
|
+
describe('decideInboundDelivery', () => {
|
|
20
|
+
it('delivers immediately when claude is idle (no turn in flight)', () => {
|
|
21
|
+
expect(
|
|
22
|
+
decideInboundDelivery({ turnInFlight: false, isSteering: false }),
|
|
23
|
+
).toBe('deliver')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('BUFFERS a non-steering message that arrives mid-turn (the wedge fix)', () => {
|
|
27
|
+
expect(
|
|
28
|
+
decideInboundDelivery({ turnInFlight: true, isSteering: false }),
|
|
29
|
+
).toBe('buffer-until-idle')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('delivers a steering message mid-turn (steering is intentionally exempt)', () => {
|
|
33
|
+
expect(
|
|
34
|
+
decideInboundDelivery({ turnInFlight: true, isSteering: true }),
|
|
35
|
+
).toBe('deliver')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('delivers a steering message when idle (steer with no active turn)', () => {
|
|
39
|
+
expect(
|
|
40
|
+
decideInboundDelivery({ turnInFlight: false, isSteering: true }),
|
|
41
|
+
).toBe('deliver')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is total: the ONLY deferral path is mid-turn AND not steering', () => {
|
|
45
|
+
for (const turnInFlight of [true, false]) {
|
|
46
|
+
for (const isSteering of [true, false]) {
|
|
47
|
+
const decision = decideInboundDelivery({ turnInFlight, isSteering })
|
|
48
|
+
const expectBuffer = turnInFlight && !isSteering
|
|
49
|
+
expect(decision).toBe(expectBuffer ? 'buffer-until-idle' : 'deliver')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|