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.
Files changed (51) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +95 -2
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/switchroom.js +749 -357
  5. package/dist/host-control/main.js +96 -3
  6. package/dist/vault/approvals/kernel-server.js +98 -5
  7. package/dist/vault/broker/server.js +98 -5
  8. package/package.json +5 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +12 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +97 -107
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +267 -52
  20. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  22. package/telegram-plugin/gateway/gateway.ts +43 -42
  23. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  24. package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
  25. package/telegram-plugin/gateway/stream-render.ts +6 -0
  26. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  27. package/telegram-plugin/gateway/turn-end.ts +1 -1
  28. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  29. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  30. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  31. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  32. package/telegram-plugin/send-gate.ts +185 -24
  33. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  34. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  35. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  36. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  37. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  38. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  39. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  41. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  42. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  43. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  44. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  45. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  46. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  47. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  49. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  50. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  51. 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 sheds draft edits with ZERO API calls, and the
20
- * stream recovers with full state after the window closes
21
- * - a shed draft is NOT recorded as delivered — a later flush of the
22
- * SAME text (the completed answer) still lands
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 shed with ZERO API calls; full state lands after it closes', async () => {
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
- // Both drafts shed as cosmetic — nothing reached the API.
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(2)
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
- expect(editBodies()).toEqual(['draft c — full state'])
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 shed draft is NOT recorded as delivered: a later finalize of the SAME text still lands', async () => {
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(1)
267
+ expect(gate.stats().global.shed).toBe(0)
257
268
 
258
- await clock.advance(30_000)
259
- // stream_reply done=true with the same text → finalize(text). If the shed
260
- // draft had been recorded as on-screen, draft-stream's dedupe would skip
261
- // this flush and the completed answer would never render.
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 shed
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(1) // only the draft
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 shed TAIL is not recorded as delivered: argument-less finalize() re-flushes and lands it (F2+F3)', async () => {
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 sheds; the anchor and other pieces are unaffected.
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 → shed, zero edits landed on
504
- // it; the flush is reported shed and the snapshot preserved (F2), NOT
505
- // recorded as delivered (F3).
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(1)
510
- expect(logs.some((m) => m.includes('shed by send gate'))).toBe(true)
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; the gateway-style ARGUMENT-LESS finalize (the
513
- // disconnect-flush / turn-end cleanup path) must re-deliver the shed
514
- // snapshot — pre-F2 the content was silently lost here.
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: () => ({ turn: null, tier: 'none' as const }),
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 = () => ({ turn, tier: 'latest-ended' as const })
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 = () => ({ turn, tier: 'latest-ended' as const })
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 { maybeRotate, TURNS_JSONL_MAX_BYTES, type RotateFs } from '../gateway/turns-jsonl-rotate.js'
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 renamed to ``.dead`` so the queue no longer drains it but the operator
9
- can still inspect via ``switchroom doctor``. An entry failing on anything
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 two-phase, out-of-hook replay:
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 # two-phase backlog replay
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
- """Two-phase backlog replay, off the SessionStart budget entirely.
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) and ``unknown``
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, two-phase backlog replay."""
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="two-phase backlog replay, off the SessionStart budget",
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']} "