switchroom 0.19.22 → 0.19.23

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 (37) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +68 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +552 -320
  5. package/dist/host-control/main.js +69 -2
  6. package/dist/vault/approvals/kernel-server.js +71 -4
  7. package/dist/vault/broker/server.js +71 -4
  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 +13 -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 +90 -107
  17. package/telegram-plugin/bunfig.toml +10 -0
  18. package/telegram-plugin/dist/gateway/gateway.js +108 -16
  19. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  20. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  21. package/telegram-plugin/gateway/gateway.ts +9 -7
  22. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  23. package/telegram-plugin/gateway/stream-render.ts +6 -0
  24. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  25. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  26. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  27. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  28. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  29. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  30. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  31. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  32. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  33. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  36. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  37. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -271,6 +271,110 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
271
271
  expect(oblLedger.isOpen('#t1')).toBe(false)
272
272
  })
273
273
 
274
+ // #3702 L3 — the close condition is LANDED, not confirmed. A snapshot whose
275
+ // chunks all landed but were NEVER corroborated (the read-back is 100% shed in
276
+ // production, #3703) is resolved on HYDRATION alone: the resume re-probes,
277
+ // resolves nothing again, sends zero messages, and closes.
278
+ //
279
+ // This is the case the durable outbound-text oracle CANNOT reach — history
280
+ // disabled here, so `hasOutboundWithText` never fires and the old
281
+ // confirmation-gated verdict would have kept the obligation OPEN, re-running
282
+ // the same shed probe on every sweep with no send available to it, until the
283
+ // represent cap escalated a false "never answered you" to the operator.
284
+ it('fully-landed-but-UNCONFIRMED snapshot, no oracle ⇒ ZERO sends and the obligation CLOSES', async () => {
285
+ const chunks = ['c0', 'c1']
286
+ const snapshot = {
287
+ chunks,
288
+ chunkStates: [
289
+ { index: 0, messageIds: [5000], confirmed: false },
290
+ { index: 1, messageIds: [5001], confirmed: false },
291
+ ],
292
+ }
293
+ const resumeLedger = new BackstopDeliveryLedger()
294
+ const sentIdx: number[] = []
295
+ const oblLedger = new ObligationLedger(2)
296
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
297
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
298
+
299
+ // No durable corroboration is available from EITHER source: the snapshot
300
+ // flags are false, history is off, and the re-probe sheds (ambiguous).
301
+ mockOracle = () => false
302
+
303
+ const lines: string[] = []
304
+ const dispatcher = createCapturedResumeDispatcher({
305
+ deliverAnswer: async (a) => {
306
+ a.resume.hydrate(resumeLedger, a.turnId)
307
+ const res = await runBackstopDelivery(
308
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
309
+ {
310
+ sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
311
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
312
+ },
313
+ 3,
314
+ )
315
+ expect(res.confirmed).toBe(false) // nothing corroborated it, at any point
316
+ expect(res.landedUnconfirmedIds).toEqual([5000, 5001]) // ...and it is counted
317
+ return { delivered: res.delivered, sentIds: res.sentIds }
318
+ },
319
+ obligationLedger: oblLedger,
320
+ backstopDeliveryLedger: resumeLedger,
321
+ flushedTurnSupersede: { record: () => {} },
322
+ historyEnabled: false,
323
+ stderr: (s) => lines.push(s),
324
+ })
325
+ dispatcher.dispatch(oblLedger.list()[0])
326
+ await flush(dispatcher)
327
+
328
+ expect(sentIdx).toEqual([]) // nothing re-posted — the user never sees a duplicate
329
+ expect(oblLedger.isOpen('#t1')).toBe(false) // closed on the landed-id evidence
330
+ // ...and the log must say what actually happened. These ids are LANDED, not
331
+ // confirmed: calling them "confirmed" is the overstatement #3702 removed from
332
+ // the verdict, and a log that reintroduces it re-lies to the next debugger.
333
+ const closed = lines.find((l) => l.includes('resume delivered'))
334
+ expect(closed).toBeDefined()
335
+ expect(closed).toContain('2 message id(s) landed')
336
+ expect(closed).not.toContain('confirmed')
337
+ })
338
+
339
+ // The other half of that decision: a positive ABSENCE still re-opens the send
340
+ // path, so "landed" is not a rubber stamp. (Inert in production until #3703
341
+ // wakes the probe, but the mechanism must remain correct.)
342
+ it('a landed chunk the re-probe finds ABSENT is demoted and RE-SENT, not closed on the stale id', async () => {
343
+ const chunks = ['c0']
344
+ const snapshot = { chunks, chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }] }
345
+ const resumeLedger = new BackstopDeliveryLedger()
346
+ const sentIdx: number[] = []
347
+ const oblLedger = new ObligationLedger(2)
348
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
349
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
350
+ mockOracle = () => false
351
+
352
+ let probes = 0
353
+ const dispatcher = createCapturedResumeDispatcher({
354
+ deliverAnswer: async (a) => {
355
+ a.resume.hydrate(resumeLedger, a.turnId)
356
+ const res = await runBackstopDelivery(
357
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
358
+ {
359
+ sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
360
+ readBack: async (): Promise<ReadBackResult> => (++probes === 1 ? 'absent' : 'exists'),
361
+ },
362
+ 3,
363
+ )
364
+ return { delivered: res.delivered, sentIds: res.sentIds }
365
+ },
366
+ obligationLedger: oblLedger,
367
+ backstopDeliveryLedger: resumeLedger,
368
+ flushedTurnSupersede: { record: () => {} },
369
+ historyEnabled: false,
370
+ })
371
+ dispatcher.dispatch(oblLedger.list()[0])
372
+ await flush(dispatcher)
373
+
374
+ expect(sentIdx).toEqual([0]) // the silently-dropped chunk IS re-sent
375
+ expect(oblLedger.isOpen('#t1')).toBe(false)
376
+ })
377
+
274
378
  it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
275
379
  const chunks = ['c0', 'c1']
276
380
  const snapshot = {
@@ -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']} "