switchroom 0.19.22 → 0.19.24
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 +5 -2
- package/dist/auth-broker/index.js +95 -2
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +749 -357
- package/dist/host-control/main.js +96 -3
- package/dist/vault/approvals/kernel-server.js +98 -5
- package/dist/vault/broker/server.js +98 -5
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +12 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +97 -107
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +267 -52
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +43 -42
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
* collapse; the latest snapshot lands floor-paced
|
|
17
17
|
* - the floor is per-message (stream A does not delay stream B)
|
|
18
18
|
* - the gate's no-op skip drops a repeat payload for the same message
|
|
19
|
-
* - an open flood window
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* - an open flood window COALESCES draft edits with ZERO API calls, and the
|
|
20
|
+
* newest state lands once the window closes (#3716 — cosmetic edits are
|
|
21
|
+
* never shed; the last edit of a burst is the one still on screen, so
|
|
22
|
+
* dropping it stranded the message on a stale body)
|
|
23
|
+
* - a draft held through a window still renders the completed answer — a
|
|
24
|
+
* later flush of the SAME text is then a benign no-op, not a loss
|
|
23
25
|
* - the finalize flush is `critical`: never shed; waits out a short
|
|
24
26
|
* window; fails fast (structured, logged) on a long one
|
|
27
|
+
* - shed-honesty (F2+F3) remains wired for any `SEND_GATE_SHED` the retry
|
|
28
|
+
* policy does return, pinned directly rather than through the gate
|
|
25
29
|
* - regression pin: the controller passes messageId / editPayload /
|
|
26
30
|
* priorityClass through the retry policy on every edit
|
|
27
31
|
*
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
*/
|
|
33
37
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
34
38
|
import { createStreamController, type RetryPolicy } from '../stream-controller.js'
|
|
35
|
-
import { createSendGate, type Clock, type SendGateConfig } from '../send-gate.js'
|
|
39
|
+
import { createSendGate, SEND_GATE_SHED, type Clock, type SendGateConfig } from '../send-gate.js'
|
|
36
40
|
import { isFloodWaitActiveError } from '../retry-api-call.js'
|
|
37
41
|
import { renderOutboundChunks } from '../render/rich-render.js'
|
|
38
42
|
import { createMockBot, installBotResetHook } from './bot-api.harness.js'
|
|
@@ -221,7 +225,7 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
221
225
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
|
|
222
226
|
})
|
|
223
227
|
|
|
224
|
-
it('open flood window: draft edits
|
|
228
|
+
it('open flood window: draft edits COALESCE with ZERO API calls; newest state lands after it closes (#3716)', async () => {
|
|
225
229
|
const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
|
|
226
230
|
const stream = createStreamController({
|
|
227
231
|
bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
|
|
@@ -233,17 +237,24 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
233
237
|
await flush()
|
|
234
238
|
void stream.update('draft b')
|
|
235
239
|
await tick()
|
|
236
|
-
//
|
|
240
|
+
// Flood safety is unchanged — still ZERO API calls while the window is
|
|
241
|
+
// open. What changed is the mechanism: the drafts are HELD (last-write-
|
|
242
|
+
// wins), not discarded, so no state is lost.
|
|
237
243
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
238
|
-
expect(gate.stats().global.shed).toBe(
|
|
244
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
239
245
|
|
|
240
|
-
await clock.advance(30_000) // window closes
|
|
246
|
+
await clock.advance(30_000) // window closes → the held draft lands
|
|
241
247
|
void stream.update('draft c — full state')
|
|
242
248
|
await tick()
|
|
243
|
-
|
|
249
|
+
await clock.advance(1_500) // clear the per-message edit floor
|
|
250
|
+
|
|
251
|
+
// The guarantee is the newest state reaches the screen, never that some
|
|
252
|
+
// intermediate was dropped to get there.
|
|
253
|
+
expect(editBodies().at(-1)).toBe('draft c — full state')
|
|
254
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
244
255
|
})
|
|
245
256
|
|
|
246
|
-
it('a
|
|
257
|
+
it('a draft held through a window still renders: the completed answer reaches the screen exactly once', async () => {
|
|
247
258
|
const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
|
|
248
259
|
const stream = createStreamController({
|
|
249
260
|
bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
|
|
@@ -253,12 +264,16 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
253
264
|
void stream.update('the completed answer')
|
|
254
265
|
await flush()
|
|
255
266
|
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
256
|
-
expect(gate.stats().global.shed).toBe(
|
|
267
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
257
268
|
|
|
258
|
-
await clock.advance(30_000)
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
269
|
+
await clock.advance(30_000) // window closes → the held draft lands
|
|
270
|
+
|
|
271
|
+
// stream_reply done=true with the same text → finalize(text). Pre-#3716
|
|
272
|
+
// the draft was SHED here, and the guarantee was that the stream must not
|
|
273
|
+
// record it as on-screen so this flush could re-deliver it. Now the draft
|
|
274
|
+
// is never dropped, so it renders on its own and the identical finalize is
|
|
275
|
+
// a benign no-op. Either way the user sees the completed answer — and now
|
|
276
|
+
// it costs one API call instead of two.
|
|
262
277
|
await stream.finalize('the completed answer')
|
|
263
278
|
expect(editBodies()).toEqual(['the completed answer'])
|
|
264
279
|
})
|
|
@@ -277,7 +292,7 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
277
292
|
gate.openFloodWindow('global', clock.now() + 30_000) // short: <= 60s fail-fast ceiling
|
|
278
293
|
void stream.update('draft while banned')
|
|
279
294
|
await flush()
|
|
280
|
-
expect(bot.api.editMessageText).not.toHaveBeenCalled() // draft
|
|
295
|
+
expect(bot.api.editMessageText).not.toHaveBeenCalled() // draft held, not sent
|
|
281
296
|
|
|
282
297
|
const fin = stream.finalize('the answer')
|
|
283
298
|
await flush()
|
|
@@ -286,9 +301,12 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
286
301
|
|
|
287
302
|
await clock.advance(30_000)
|
|
288
303
|
await fin
|
|
304
|
+
// The critical finalize coalesced ONTO the held draft and upgraded its
|
|
305
|
+
// class, so the whole burst resolves as a single send carrying the final
|
|
306
|
+
// body — the draft is superseded rather than dropped.
|
|
289
307
|
expect(editBodies()).toEqual(['the answer'])
|
|
290
308
|
expect(editTimes).toEqual([30_000])
|
|
291
|
-
expect(gate.stats().global.shed).toBe(
|
|
309
|
+
expect(gate.stats().global.shed).toBe(0) // nothing is shed any more
|
|
292
310
|
})
|
|
293
311
|
|
|
294
312
|
it('finalize under a LONG window fails fast (structured FLOOD_WAIT_ACTIVE, logged) — no API call, no hang', async () => {
|
|
@@ -316,6 +334,40 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
316
334
|
}
|
|
317
335
|
})
|
|
318
336
|
|
|
337
|
+
/**
|
|
338
|
+
* REGRESSION PIN for the trap #3716 opened. Once cosmetic edits stopped
|
|
339
|
+
* shedding they began OCCUPYING the driver, and draft-stream serializes its
|
|
340
|
+
* own flushes — so a draft parked behind a 6h ban held the finalize upstream
|
|
341
|
+
* of the gate, where the fail-fast path could never see it. `failedFast` went
|
|
342
|
+
* to 0 and the reply path wedged for the length of the ban: the exact failure
|
|
343
|
+
* the gate was built to eliminate, reintroduced by the fix for a different
|
|
344
|
+
* one. The preceding test does NOT catch this — it finalizes with no draft in
|
|
345
|
+
* flight.
|
|
346
|
+
*/
|
|
347
|
+
it('a draft parked behind a LONG window never wedges the finalize behind it (#3716)', async () => {
|
|
348
|
+
const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
|
|
349
|
+
const logs: string[] = []
|
|
350
|
+
const stream = createStreamController({
|
|
351
|
+
bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
|
|
352
|
+
log: (m) => logs.push(m),
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
gate.openFloodWindow('global', clock.now() + 21_397_000) // the 2026-07-12 ban: ~5.9h
|
|
356
|
+
void stream.update('draft while banned')
|
|
357
|
+
await flush()
|
|
358
|
+
expect(gate.stats().global.shed).toBe(0) // held, not dropped
|
|
359
|
+
|
|
360
|
+
// The cosmetic draft settles its caller as soon as it is queued, so the
|
|
361
|
+
// finalize reaches the gate. If it did not, this await never returns.
|
|
362
|
+
const fin = stream.finalize('the answer')
|
|
363
|
+
await tick()
|
|
364
|
+
await fin
|
|
365
|
+
|
|
366
|
+
expect(gate.stats().global.failedFast).toBe(1)
|
|
367
|
+
expect(bot.api.editMessageText).not.toHaveBeenCalled()
|
|
368
|
+
expect(logs.some((m) => m.includes('FLOOD_WAIT_ACTIVE'))).toBe(true)
|
|
369
|
+
})
|
|
370
|
+
|
|
319
371
|
it('REGRESSION PIN: every edit passes messageId / editPayload / priorityClass to the retry policy', async () => {
|
|
320
372
|
// Spy retry with NO gate — pins exactly what the controller hands to
|
|
321
373
|
// robustApiCall (the #3110 bypass was these fields being absent).
|
|
@@ -474,7 +526,7 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
474
526
|
expect(bot.api.editMessageText.mock.calls.length).toBe(editCalls)
|
|
475
527
|
})
|
|
476
528
|
|
|
477
|
-
it('a
|
|
529
|
+
it('a TAIL suppressed by a msg-scoped window is HELD, not lost: the completed answer lands when it closes', async () => {
|
|
478
530
|
const { clock, gate, retry } = makeGatedRetry({
|
|
479
531
|
editFloorMs: 1500,
|
|
480
532
|
globalPerSec: 1000, globalBurst: 100, perChatPerSec: 1000, perChatBurst: 100,
|
|
@@ -495,27 +547,81 @@ describe('stream-controller × send gate (#3110)', () => {
|
|
|
495
547
|
const lastTailId = anchorId + pieceCount - 1
|
|
496
548
|
|
|
497
549
|
// Flood window scoped to the LAST tail message only (H1 msg-edit scope):
|
|
498
|
-
// its edit
|
|
550
|
+
// its edit is suppressed; the anchor and other pieces are unaffected.
|
|
499
551
|
gate.openFloodWindow(`msg-edit:1:${lastTailId}`, clock.now() + 30_000)
|
|
500
552
|
|
|
501
553
|
void stream.update(b2)
|
|
502
554
|
await tick()
|
|
503
|
-
// The changed piece is the suppressed tail →
|
|
504
|
-
//
|
|
505
|
-
//
|
|
555
|
+
// The changed piece is the suppressed tail → zero edits land on it while
|
|
556
|
+
// the window is open. Pre-#3716 it was SHED and the completed answer only
|
|
557
|
+
// survived because the stream refused to record it as delivered; now the
|
|
558
|
+
// edit is held by the gate, so the content is safe by construction.
|
|
506
559
|
const tailEdits = () =>
|
|
507
560
|
bot.api.editMessageText.mock.calls.filter(([, id]) => id === lastTailId)
|
|
508
561
|
expect(tailEdits()).toHaveLength(0)
|
|
509
|
-
expect(gate.stats().global.shed).toBe(
|
|
510
|
-
expect(logs.some((m) => m.includes('shed by send gate'))).toBe(
|
|
562
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
563
|
+
expect(logs.some((m) => m.includes('shed by send gate'))).toBe(false)
|
|
511
564
|
|
|
512
|
-
// Window closes
|
|
513
|
-
// disconnect-flush / turn-end cleanup
|
|
514
|
-
//
|
|
565
|
+
// Window closes → the held tail edit lands on its own. The gateway-style
|
|
566
|
+
// ARGUMENT-LESS finalize (disconnect-flush / turn-end cleanup) is then a
|
|
567
|
+
// no-op rather than a rescue.
|
|
515
568
|
await clock.advance(31_000)
|
|
516
569
|
await stream.finalize()
|
|
517
570
|
expect(tailEdits()).toHaveLength(1)
|
|
518
571
|
const [, , tailBody] = tailEdits()[0]
|
|
519
572
|
expect(String(tailBody)).toContain('tail v2')
|
|
520
573
|
})
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* #3716 removed the gate's cosmetic-EDIT shed, so no edit the stream makes
|
|
577
|
+
* can return `SEND_GATE_SHED` any more. The sentinel is still the contract
|
|
578
|
+
* for non-edit cosmetic sends, and the controller's F2/F3 shed-honesty
|
|
579
|
+
* handling is the guard if any edit path is ever re-tagged — so pin it
|
|
580
|
+
* directly against the retry seam instead of through the gate, where it
|
|
581
|
+
* would silently rot into an assertion about behaviour that cannot occur.
|
|
582
|
+
*/
|
|
583
|
+
it('F2+F3 shed-honesty is still wired: a SHED tail is not recorded as delivered and re-flushes', async () => {
|
|
584
|
+
const base = ('a_b_c_d_e ').repeat(3000)
|
|
585
|
+
const b1 = `${base}tail v1`
|
|
586
|
+
const b2 = `${base}tail v2 — the completed answer`
|
|
587
|
+
const pieceCount = renderOutboundChunks(b1).length
|
|
588
|
+
expect(pieceCount).toBeGreaterThan(1)
|
|
589
|
+
|
|
590
|
+
// Shed exactly one message id, chosen after the first flush assigns ids.
|
|
591
|
+
let shedId: number | null = null
|
|
592
|
+
const retry: RetryPolicy = async (fn, opts) => {
|
|
593
|
+
if (shedId != null && opts?.messageId === shedId) {
|
|
594
|
+
return SEND_GATE_SHED as never
|
|
595
|
+
}
|
|
596
|
+
return await fn()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const logs: string[] = []
|
|
600
|
+
const stream = createStreamController({
|
|
601
|
+
bot, chatId: '1', throttleMs: 250, retry, log: (m) => logs.push(m),
|
|
602
|
+
})
|
|
603
|
+
void stream.update(b1)
|
|
604
|
+
await flush()
|
|
605
|
+
const anchorId = stream.getMessageId() as number
|
|
606
|
+
shedId = anchorId + pieceCount - 1
|
|
607
|
+
|
|
608
|
+
const tailEdits = () =>
|
|
609
|
+
bot.api.editMessageText.mock.calls.filter(([, id]) => id === shedId)
|
|
610
|
+
|
|
611
|
+
void stream.update(b2)
|
|
612
|
+
await tick()
|
|
613
|
+
expect(tailEdits()).toHaveLength(0)
|
|
614
|
+
expect(logs.some((m) => m.includes('shed by send gate'))).toBe(true)
|
|
615
|
+
|
|
616
|
+
// The shed piece must NOT have been recorded as on screen: an
|
|
617
|
+
// argument-less finalize re-delivers the preserved snapshot.
|
|
618
|
+
shedId = null
|
|
619
|
+
await stream.finalize()
|
|
620
|
+
const landed = bot.api.editMessageText.mock.calls.filter(
|
|
621
|
+
([, id]) => id === anchorId + pieceCount - 1,
|
|
622
|
+
)
|
|
623
|
+
expect(landed).toHaveLength(1)
|
|
624
|
+
const [, , tailBody] = landed[0]
|
|
625
|
+
expect(String(tailBody)).toContain('tail v2')
|
|
626
|
+
})
|
|
521
627
|
})
|
|
@@ -39,6 +39,28 @@ import {
|
|
|
39
39
|
TYPING_REFRESH_MS,
|
|
40
40
|
} from '../typing-emitter.js'
|
|
41
41
|
import type { CurrentTurn } from '../gateway/gateway.js'
|
|
42
|
+
import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
43
|
+
|
|
44
|
+
/** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
|
|
45
|
+
* candidate set the content-gate bypass corroborates against. These fixtures
|
|
46
|
+
* never exercise the supersede path, so the candidates mirror the resolved turn
|
|
47
|
+
* (the corroborated shape) with no override needed. */
|
|
48
|
+
function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
|
|
49
|
+
const id = turn?.turnId ?? null
|
|
50
|
+
return {
|
|
51
|
+
turn,
|
|
52
|
+
tier,
|
|
53
|
+
candidates: {
|
|
54
|
+
liveTurnId: tier === 'live' ? id : null,
|
|
55
|
+
originTurnId: null,
|
|
56
|
+
quotedTurnId: null,
|
|
57
|
+
latestEndedTurnId: id,
|
|
58
|
+
latestEndedAgeMs: 1_000,
|
|
59
|
+
latestEndedTtlMs: 60_000,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
|
|
43
65
|
const CHAT = '1001'
|
|
44
66
|
|
|
@@ -251,7 +273,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedT
|
|
|
251
273
|
assertSendable: () => {},
|
|
252
274
|
statusKey: key,
|
|
253
275
|
streamKey: key,
|
|
254
|
-
resolveReplyOwnerTurn: () => (
|
|
276
|
+
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
255
277
|
getLastSubagentHandbackAt: () => null,
|
|
256
278
|
findTurnByOriginId: () => null,
|
|
257
279
|
findTurnByQuotedMessageId: () => null,
|
|
@@ -443,7 +465,7 @@ describe('F3 — flush record() → same-turn reworded reply collapse (end-to-en
|
|
|
443
465
|
// The model's REAL reply lands late with a REWORDED version of the same
|
|
444
466
|
// answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
|
|
445
467
|
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
446
|
-
s.deps.resolveReplyOwnerTurn = () => (
|
|
468
|
+
s.deps.resolveReplyOwnerTurn = () => ownerRes(turn, 'latest-ended')
|
|
447
469
|
// (getLastSubagentHandbackAt returns null in the base deps → own answer.)
|
|
448
470
|
|
|
449
471
|
const res = await sendReply(s.deps, req(REWORDED))
|
|
@@ -496,7 +518,7 @@ describe('F5 — take()-before-record() interleaving delivers exactly one messag
|
|
|
496
518
|
).toBe('no-record') // record genuinely not written yet
|
|
497
519
|
|
|
498
520
|
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
499
|
-
s.deps.resolveReplyOwnerTurn = () => (
|
|
521
|
+
s.deps.resolveReplyOwnerTurn = () => ownerRes(turn, 'latest-ended')
|
|
500
522
|
// The same answer landing again in the race window → latch backstop suppresses.
|
|
501
523
|
const res = await sendReply(s.deps, req(ANSWER))
|
|
502
524
|
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
1
5
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
maybeRotate,
|
|
9
|
+
resolveAgentStateDir,
|
|
10
|
+
resolveTurnsJsonlPath,
|
|
11
|
+
DEFAULT_AGENT_STATE_DIR,
|
|
12
|
+
TURNS_JSONL_MAX_BYTES,
|
|
13
|
+
type RotateFs,
|
|
14
|
+
} from '../gateway/turns-jsonl-rotate.js'
|
|
15
|
+
|
|
16
|
+
const GATEWAY_SRC = readFileSync(
|
|
17
|
+
resolve(fileURLToPath(new URL('.', import.meta.url)), '..', 'gateway', 'gateway.ts'),
|
|
18
|
+
'utf-8',
|
|
19
|
+
)
|
|
4
20
|
|
|
5
21
|
describe('maybeRotate — turns.jsonl size cap', () => {
|
|
6
22
|
const mkFs = (size: number | undefined) => {
|
|
@@ -37,3 +53,78 @@ describe('maybeRotate — turns.jsonl size cap', () => {
|
|
|
37
53
|
expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
38
54
|
})
|
|
39
55
|
})
|
|
56
|
+
|
|
57
|
+
describe('resolveTurnsJsonlPath — the turn record must follow the state dir', () => {
|
|
58
|
+
// Regression: the path was hard-coded to `/state/agent/turns.jsonl`, so a test
|
|
59
|
+
// that isolated TELEGRAM_STATE_DIR / SWITCHROOM_AGENT_STATE_DIR into a tmpdir
|
|
60
|
+
// still appended its synthetic turn rows into the PRODUCTION turn record of
|
|
61
|
+
// whichever agent container it ran in — which the fleet-health L0 sensor then
|
|
62
|
+
// scored as that agent's real production failures.
|
|
63
|
+
it('honours SWITCHROOM_AGENT_STATE_DIR', () => {
|
|
64
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: '/tmp/iso-123' })).toBe(
|
|
65
|
+
'/tmp/iso-123/turns.jsonl',
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('strips a trailing slash rather than doubling it', () => {
|
|
70
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: '/tmp/iso-123/' })).toBe(
|
|
71
|
+
'/tmp/iso-123/turns.jsonl',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to the container default when unset or blank', () => {
|
|
76
|
+
expect(resolveTurnsJsonlPath({})).toBe(`${DEFAULT_AGENT_STATE_DIR}/turns.jsonl`)
|
|
77
|
+
expect(resolveTurnsJsonlPath({ SWITCHROOM_AGENT_STATE_DIR: ' ' })).toBe(
|
|
78
|
+
`${DEFAULT_AGENT_STATE_DIR}/turns.jsonl`,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('resolveAgentStateDir — ONE normaliser for every writer in the state dir', () => {
|
|
84
|
+
// The gateway had two writers into this dir 26 lines apart reading the env
|
|
85
|
+
// var two different ways: the turn record via the normaliser, the
|
|
86
|
+
// context-occupancy snapshot via a bare `process.env.X ?? '/state/agent'`.
|
|
87
|
+
// For a value like `/x/ ` those resolve to different directories, so the two
|
|
88
|
+
// artifacts of the same turn land in two places.
|
|
89
|
+
it('trims, strips a trailing slash, and treats blank as unset', () => {
|
|
90
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: '/x' })).toBe('/x')
|
|
91
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: ' /x/ ' })).toBe('/x')
|
|
92
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: '/x///' })).toBe('/x')
|
|
93
|
+
expect(resolveAgentStateDir({ SWITCHROOM_AGENT_STATE_DIR: ' ' })).toBe(DEFAULT_AGENT_STATE_DIR)
|
|
94
|
+
expect(resolveAgentStateDir({})).toBe(DEFAULT_AGENT_STATE_DIR)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('the turn record and the state dir agree for any messy value', () => {
|
|
98
|
+
const env = { SWITCHROOM_AGENT_STATE_DIR: ' /tmp/iso-9/ ' }
|
|
99
|
+
expect(resolveTurnsJsonlPath(env)).toBe(`${resolveAgentStateDir(env)}/turns.jsonl`)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('gateway.ts call sites — the defect site itself, not just the helper', () => {
|
|
104
|
+
// The bug was a hard-coded literal at the CALLSITE. A unit test of the pure
|
|
105
|
+
// helper passes with the literal re-inlined, so these read the real source.
|
|
106
|
+
// Source-text assertions are the repo's cheap deterministic pattern for
|
|
107
|
+
// pinning a callsite (see per-topic-current-turn.test.ts).
|
|
108
|
+
it('emitTurnRecord resolves the path — the hard-coded literal is gone', () => {
|
|
109
|
+
const body = GATEWAY_SRC.split('function emitTurnRecord(')[1]?.split('\n}')[0] ?? ''
|
|
110
|
+
expect(body.length).toBeGreaterThan(50)
|
|
111
|
+
expect(body).toMatch(/const turnsPath = resolveTurnsJsonlPath\(\)/)
|
|
112
|
+
// The append + the rotate both use the RESOLVED path, not a literal.
|
|
113
|
+
expect(body).toMatch(/appendFileSync\(turnsPath, /)
|
|
114
|
+
expect(body).toMatch(/maybeRotate\(turnsPath, /)
|
|
115
|
+
expect(body).not.toMatch(/turns\.jsonl/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('no hard-coded turns.jsonl path survives anywhere in gateway.ts', () => {
|
|
119
|
+
const hits = GATEWAY_SRC.match(/['"`][^'"`\n]*\/turns\.jsonl['"`]/g) ?? []
|
|
120
|
+
expect(hits, `hard-coded turns.jsonl path(s): ${JSON.stringify(hits)}`).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('every state-dir read in gateway.ts goes through resolveAgentStateDir', () => {
|
|
124
|
+
// Consistency, not style: a second inline `process.env.X ?? '/state/agent'`
|
|
125
|
+
// silently opts that writer out of trim + trailing-slash normalisation.
|
|
126
|
+
const inline = GATEWAY_SRC.match(/process\.env\.SWITCHROOM_AGENT_STATE_DIR/g) ?? []
|
|
127
|
+
expect(inline, `inline state-dir read(s) in gateway.ts: ${inline.length}`).toEqual([])
|
|
128
|
+
expect(GATEWAY_SRC).toMatch(/resolveAgentStateDir\(\)/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -5,8 +5,12 @@ SessionStart calls into ``drain()`` to retry any retain payloads that
|
|
|
5
5
|
``session_end.py`` queued on failure (#1071). Each entry is retried up
|
|
6
6
|
to ``MAX_ATTEMPTS`` (5) times; after that a **permanently** failing entry
|
|
7
7
|
(a 4xx that a re-POST cannot fix — see ``pending.is_permanent_failure``)
|
|
8
|
-
is
|
|
9
|
-
can still inspect via ``switchroom doctor``.
|
|
8
|
+
is retired into the ``pending-dead/`` archive so the queue no longer
|
|
9
|
+
drains it but the operator can still inspect via ``switchroom doctor``.
|
|
10
|
+
The marker deliberately does NOT stay in the live queue directory: it is
|
|
11
|
+
the only remaining copy of that memory, and leaving it among the live
|
|
12
|
+
entries put it in the path of every janitor that sweeps that
|
|
13
|
+
directory. An entry failing on anything
|
|
10
14
|
else — a 5xx, a timeout, a connection error — stays queued past the
|
|
11
15
|
attempt budget: a transient upstream is never evidence that the memory
|
|
12
16
|
is unsaveable, and retiring it would lose content the user believes was
|
|
@@ -53,8 +57,30 @@ of that loop, not of lost memory: a full sweep of 5,751 queued entries on
|
|
|
53
57
|
this fleet (2026-07-25) found **4,048 (70.4%) already existed as
|
|
54
58
|
documents**, 3,815 of them with facts extracted.
|
|
55
59
|
|
|
56
|
-
``--backlog`` is therefore a
|
|
57
|
-
|
|
60
|
+
``--backlog`` is therefore a three-phase, out-of-hook replay:
|
|
61
|
+
|
|
62
|
+
* **Phase 0 — collapse duplicates (free, no network).** Queued entries
|
|
63
|
+
sharing ``(bank_id, part_position, sha256(content))`` are the same
|
|
64
|
+
memory; the redundant copies are archived so the phases below never pay
|
|
65
|
+
for one memory twice. Measured 2026-07-26: 1,060 queued files across 11
|
|
66
|
+
agents fell into ~368 distinct groups — ~65% of the queue was duplicate,
|
|
67
|
+
with one group repeated 32 times. At ~168 s per phase-2 extraction that
|
|
68
|
+
one group alone was 90 minutes of LLM lane time for a single memory.
|
|
69
|
+
* **Phase 0b — relocate legacy ``.dead`` markers (free, no network).**
|
|
70
|
+
Markers written by an older build into the live queue directory are moved
|
|
71
|
+
into ``pending-dead/``. ``mark_dead`` no longer produces such a marker, so
|
|
72
|
+
after this phase has run once the live queue holds only live entries and no
|
|
73
|
+
janitor glob over it can match a memory. Note the CONDITION: phases 0b and
|
|
74
|
+
0c run in BACKLOG mode only (``drain_backlog``, and not under
|
|
75
|
+
``--dry-run``). The SessionStart ``drain()`` never calls them, so on a host
|
|
76
|
+
where the backlog drain has not run, legacy markers are still sitting in
|
|
77
|
+
the queue directory.
|
|
78
|
+
* **Phase 0c — re-split over-bound entries (free, no network).** An entry
|
|
79
|
+
whose content exceeds ``retain_content_limit()`` needs more sequential
|
|
80
|
+
extraction calls than fit the client deadline, so it can never be drained
|
|
81
|
+
as-is; splitting it makes every part drainable. Measured 2026-07-26: 18 of
|
|
82
|
+
211 queued entries exceeded 100,000 chars, the largest 744,546. Backlog
|
|
83
|
+
mode only, same as 0b.
|
|
58
84
|
* **Phase 1 — reconcile (free).** GET the document. If it exists, the
|
|
59
85
|
memory is already durable; retire the queue entry without a POST. No
|
|
60
86
|
LLM work, no cost, idempotent, resumable at any point. Only for
|
|
@@ -84,7 +110,7 @@ reports the upstream is already slow.
|
|
|
84
110
|
Standalone usage::
|
|
85
111
|
|
|
86
112
|
python3 drain_pending.py # bounded in-hook drain
|
|
87
|
-
python3 drain_pending.py --backlog #
|
|
113
|
+
python3 drain_pending.py --backlog # three-phase backlog replay
|
|
88
114
|
python3 drain_pending.py --backlog --phase reconcile # free pass only
|
|
89
115
|
python3 drain_pending.py --backlog --dry-run
|
|
90
116
|
"""
|
|
@@ -105,13 +131,16 @@ from lib.config import debug_log, load_config
|
|
|
105
131
|
from lib.pending import (
|
|
106
132
|
MAX_ATTEMPTS,
|
|
107
133
|
archive_reconciled,
|
|
134
|
+
collapse_duplicates,
|
|
108
135
|
is_content_derived_document_id,
|
|
109
136
|
is_permanent_failure,
|
|
110
137
|
iter_entries,
|
|
111
138
|
mark_dead,
|
|
139
|
+
resplit_over_bound_entries,
|
|
140
|
+
sweep_legacy_dead_markers,
|
|
112
141
|
update_attempt,
|
|
113
142
|
)
|
|
114
|
-
from lib.retain_split import retain_client_deadline
|
|
143
|
+
from lib.retain_split import retain_client_deadline, retain_content_limit
|
|
115
144
|
|
|
116
145
|
|
|
117
146
|
STALL_THRESHOLD = 3
|
|
@@ -531,17 +560,24 @@ def _new_summary() -> dict:
|
|
|
531
560
|
# so it must not be counted as drained/reconciled, which would
|
|
532
561
|
# report a retire that did not happen.
|
|
533
562
|
"archive_failed": 0,
|
|
563
|
+
# Redundant copies retired by `pending.collapse_duplicates` before
|
|
564
|
+
# any network work. Backlog mode only — see `_drain_backlog_impl`.
|
|
565
|
+
"collapsed": 0,
|
|
566
|
+
"dead_relocated": 0,
|
|
567
|
+
"resplit": 0,
|
|
568
|
+
"resplit_parts": 0,
|
|
534
569
|
"stalled": False,
|
|
535
570
|
"budget_exceeded": False,
|
|
536
571
|
}
|
|
537
572
|
|
|
538
573
|
|
|
539
574
|
def drain_backlog(config: dict | None = None, **kw) -> dict:
|
|
540
|
-
"""
|
|
575
|
+
"""Three-phase backlog replay, off the SessionStart budget entirely.
|
|
541
576
|
|
|
542
577
|
See the module docstring. Summary shape is ``drain()``'s plus
|
|
543
|
-
``reconciled`` (already durable — no POST issued)
|
|
544
|
-
(presence could not be established; left queued)
|
|
578
|
+
``reconciled`` (already durable — no POST issued), ``unknown``
|
|
579
|
+
(presence could not be established; left queued) and ``collapsed``
|
|
580
|
+
(redundant duplicate copies archived before any network work).
|
|
545
581
|
"""
|
|
546
582
|
return drain(config, backlog=True, **kw)
|
|
547
583
|
|
|
@@ -566,6 +602,10 @@ def drain(
|
|
|
566
602
|
"unknown": int, # presence unknown, left queued
|
|
567
603
|
"archive_failed": int, # durable, but the archive was unwritable
|
|
568
604
|
# so the entry is STILL QUEUED
|
|
605
|
+
"collapsed": int, # duplicate copies archived (backlog mode only)
|
|
606
|
+
"dead_relocated": int, # legacy .dead markers moved out of the queue dir
|
|
607
|
+
"resplit": int, # over-bound entries split into drainable parts
|
|
608
|
+
"resplit_parts": int, # parts those entries became
|
|
569
609
|
"stalled": bool, # stall guard tripped
|
|
570
610
|
"budget_exceeded": bool}
|
|
571
611
|
"""
|
|
@@ -784,9 +824,65 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
|
|
|
784
824
|
def _drain_backlog_impl(
|
|
785
825
|
config: dict, phase: str = "both", dry_run: bool = False
|
|
786
826
|
) -> dict:
|
|
787
|
-
"""Concurrent-capable, long-budget,
|
|
827
|
+
"""Concurrent-capable, long-budget, three-phase backlog replay."""
|
|
788
828
|
summary = _new_summary()
|
|
789
829
|
|
|
830
|
+
# PHASE 0 — collapse duplicates (local, free, no network at all).
|
|
831
|
+
#
|
|
832
|
+
# Runs FIRST because every later phase is per-entry: a GET in phase 1
|
|
833
|
+
# and, worse, a ~168 s LLM-backed extraction in phase 2. On the measured
|
|
834
|
+
# 2026-07-26 fleet backlog ~65% of queued files were byte-identical
|
|
835
|
+
# copies of another queued file (top group 32x), so skipping this pass
|
|
836
|
+
# means paying phase 2 up to 32 times over for one memory.
|
|
837
|
+
#
|
|
838
|
+
# DELIBERATELY NOT run by the in-hook drain. It reads every queued entry
|
|
839
|
+
# to recompute identity from content — bounded, but not instant — and
|
|
840
|
+
# the SessionStart drain's whole contract is a hard wall-clock ceiling
|
|
841
|
+
# on hook latency. New duplicates cannot accumulate there anyway:
|
|
842
|
+
# `pending.enqueue`'s filename-keyed guard stops those at the producer.
|
|
843
|
+
if not dry_run:
|
|
844
|
+
summary["collapsed"] = collapse_duplicates()
|
|
845
|
+
if summary["collapsed"]:
|
|
846
|
+
_blog(
|
|
847
|
+
f"phase 0: collapsed {summary['collapsed']} duplicate entries "
|
|
848
|
+
f"(byte-identical content already queued under another entry); "
|
|
849
|
+
f"archived, not deleted"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# PHASE 0b — relocate any legacy `.dead` markers out of the live
|
|
853
|
+
# queue directory. Free, local, and an UPGRADE step: markers written
|
|
854
|
+
# by an older build sit in the directory external janitors sweep, and
|
|
855
|
+
# a marker is the only remaining copy of its memory.
|
|
856
|
+
moved = sweep_legacy_dead_markers()
|
|
857
|
+
if moved:
|
|
858
|
+
summary["dead_relocated"] = moved
|
|
859
|
+
_blog(
|
|
860
|
+
f"phase 0b: relocated {moved} legacy .dead marker(s) out of "
|
|
861
|
+
f"the live queue directory; the queue now holds only live "
|
|
862
|
+
f"entries, so no janitor glob over it can match a memory"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# PHASE 0c — re-split entries the drain provably CANNOT retain.
|
|
866
|
+
#
|
|
867
|
+
# An entry over `retain_content_limit()` needs more sequential
|
|
868
|
+
# extraction calls than fit the deadline, so every POST is guaranteed
|
|
869
|
+
# waste; if the server rejects the body as a 4xx it is classified
|
|
870
|
+
# permanent and the memory goes `.dead`. Splitting it makes every
|
|
871
|
+
# part drainable, which is the difference between a lost memory and a
|
|
872
|
+
# slow one. Runs after the duplicate collapse so a duplicated
|
|
873
|
+
# over-bound entry is split ONCE, not once per copy.
|
|
874
|
+
entries_split, parts_written = resplit_over_bound_entries()
|
|
875
|
+
if entries_split:
|
|
876
|
+
summary["resplit"] = entries_split
|
|
877
|
+
summary["resplit_parts"] = parts_written
|
|
878
|
+
_blog(
|
|
879
|
+
f"phase 0c: re-split {entries_split} entr"
|
|
880
|
+
f"{'y' if entries_split == 1 else 'ies'} over the "
|
|
881
|
+
f"{retain_content_limit()}-char retain bound into "
|
|
882
|
+
f"{parts_written} drainable part(s); the originals are "
|
|
883
|
+
f"archived, not deleted"
|
|
884
|
+
)
|
|
885
|
+
|
|
790
886
|
if phase in ("reconcile", "both"):
|
|
791
887
|
_reconcile_phase(config, summary, dry_run)
|
|
792
888
|
if phase == "reconcile":
|
|
@@ -935,7 +1031,7 @@ def _parse_args(argv: list[str] | None):
|
|
|
935
1031
|
ap.add_argument(
|
|
936
1032
|
"--backlog",
|
|
937
1033
|
action="store_true",
|
|
938
|
-
help="
|
|
1034
|
+
help="three-phase backlog replay, off the SessionStart budget",
|
|
939
1035
|
)
|
|
940
1036
|
ap.add_argument(
|
|
941
1037
|
"--phase",
|
|
@@ -967,6 +1063,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
967
1063
|
if any(
|
|
968
1064
|
summary[k]
|
|
969
1065
|
for k in (
|
|
1066
|
+
"collapsed",
|
|
1067
|
+
"dead_relocated",
|
|
1068
|
+
"resplit",
|
|
970
1069
|
"drained",
|
|
971
1070
|
"retried",
|
|
972
1071
|
"dead",
|
|
@@ -978,6 +1077,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
978
1077
|
print(
|
|
979
1078
|
f"[Hindsight] drain_pending{'(backlog)' if args.backlog else ''}: "
|
|
980
1079
|
f"drained={summary['drained']} reconciled={summary['reconciled']} "
|
|
1080
|
+
f"collapsed={summary['collapsed']} "
|
|
1081
|
+
f"dead_relocated={summary['dead_relocated']} "
|
|
1082
|
+
f"resplit={summary['resplit']}(+{summary['resplit_parts']} parts) "
|
|
981
1083
|
f"retried={summary['retried']} dead={summary['dead']} "
|
|
982
1084
|
f"unknown={summary['unknown']} "
|
|
983
1085
|
f"archive_failed={summary['archive_failed']} "
|