switchroom 0.19.19 → 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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +121 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +2995 -1583
  5. package/dist/host-control/main.js +122 -2
  6. package/dist/vault/approvals/kernel-server.js +124 -4
  7. package/dist/vault/broker/server.js +124 -4
  8. package/package.json +7 -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/skills/switchroom-release/SKILL.md +103 -20
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/card-format.ts +92 -3
  20. package/telegram-plugin/dist/gateway/gateway.js +873 -184
  21. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  22. package/telegram-plugin/format.ts +19 -7
  23. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  24. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  26. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  27. package/telegram-plugin/gateway/gateway.ts +75 -63
  28. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  29. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  30. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  31. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  32. package/telegram-plugin/gateway/stream-render.ts +6 -0
  33. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  34. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  35. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  36. package/telegram-plugin/retry-api-call.ts +15 -2
  37. package/telegram-plugin/send-gate.ts +1 -1
  38. package/telegram-plugin/status-no-truncate.ts +64 -1
  39. package/telegram-plugin/status-pin-driver.ts +50 -27
  40. package/telegram-plugin/status-pin.ts +43 -5
  41. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  42. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  43. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  44. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  45. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  46. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  47. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  48. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  49. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  50. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  51. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  52. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  53. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  54. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  55. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  56. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  57. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  58. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  59. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  60. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  61. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  62. package/telegram-plugin/tool-activity-summary.ts +85 -13
  63. package/telegram-plugin/worker-activity-feed.ts +5 -1
  64. package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
  65. package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
  66. package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
  67. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
  76. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -3,9 +3,25 @@
3
3
 
4
4
  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
- to ``MAX_ATTEMPTS`` (5) times; after that it's renamed to ``.dead`` so
7
- the queue no longer drains it but the operator can still inspect via
8
- ``switchroom doctor``.
6
+ to ``MAX_ATTEMPTS`` (5) times; after that a **permanently** failing entry
7
+ (a 4xx that a re-POST cannot fix — see ``pending.is_permanent_failure``)
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
14
+ else — a 5xx, a timeout, a connection error — stays queued past the
15
+ attempt budget: a transient upstream is never evidence that the memory
16
+ is unsaveable, and retiring it would lose content the user believes was
17
+ saved.
18
+
19
+ An entry past the budget is DEMOTED rather than retired (``_drain_order``
20
+ and ``_over_budget``): it sorts behind everything still inside its budget
21
+ and abstains from the stall guard. That is what keeps "never destroy a
22
+ memory" from degrading into "never drain anything" — the drain is
23
+ sequential and oldest-first, so without the demotion three chronically
24
+ failing entries sit at the head and end every run at zero progress.
9
25
 
10
26
  Boundaries
11
27
  ----------
@@ -36,14 +52,35 @@ accumulated backlog. Worse, they CREATE one: the per-entry timeout is
36
52
  clamped to the remaining hook budget (1-8s) while ``_retry_one`` posts
37
53
  synchronously and a real retain takes 30-90s, so **the server commits the
38
54
  document and the client always gives up before the ack**. The entry is
39
- never deleted and is re-posted on every session start, forever, until it
40
- hits ``MAX_ATTEMPTS`` and goes ``.dead``. The queue depth was a symptom
55
+ never deleted and is re-posted on every session start. The queue depth was a symptom
41
56
  of that loop, not of lost memory: a full sweep of 5,751 queued entries on
42
57
  this fleet (2026-07-25) found **4,048 (70.4%) already existed as
43
58
  documents**, 3,815 of them with facts extracted.
44
59
 
45
- ``--backlog`` is therefore a two-phase, out-of-hook replay:
46
-
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.
47
84
  * **Phase 1 — reconcile (free).** GET the document. If it exists, the
48
85
  memory is already durable; retire the queue entry without a POST. No
49
86
  LLM work, no cost, idempotent, resumable at any point. Only for
@@ -73,7 +110,7 @@ reports the upstream is already slow.
73
110
  Standalone usage::
74
111
 
75
112
  python3 drain_pending.py # bounded in-hook drain
76
- python3 drain_pending.py --backlog # two-phase backlog replay
113
+ python3 drain_pending.py --backlog # three-phase backlog replay
77
114
  python3 drain_pending.py --backlog --phase reconcile # free pass only
78
115
  python3 drain_pending.py --backlog --dry-run
79
116
  """
@@ -94,12 +131,16 @@ from lib.config import debug_log, load_config
94
131
  from lib.pending import (
95
132
  MAX_ATTEMPTS,
96
133
  archive_reconciled,
134
+ collapse_duplicates,
97
135
  is_content_derived_document_id,
136
+ is_permanent_failure,
98
137
  iter_entries,
99
138
  mark_dead,
139
+ resplit_over_bound_entries,
140
+ sweep_legacy_dead_markers,
100
141
  update_attempt,
101
142
  )
102
- from lib.retain_split import retain_client_deadline
143
+ from lib.retain_split import retain_client_deadline, retain_content_limit
103
144
 
104
145
 
105
146
  STALL_THRESHOLD = 3
@@ -203,16 +244,30 @@ def _backlog_timeout() -> int:
203
244
  client-side timeout on a request the server then commits anyway.
204
245
 
205
246
  The default is DERIVED, not a literal: it is
206
- ``retain_split.retain_client_deadline()`` (280s), the same deadline the
247
+ ``retain_split.retain_client_deadline()`` (310s), the same deadline the
207
248
  retain content bound is sized against. Those two must be ONE number.
208
249
  This function shipped as a bare ``180`` (#3599), and against a 180s
209
250
  deadline both halves of the retain budget break: a maximally-sized part
210
251
  is ~276s of sequential extraction, and the SERVER per-call timeout
211
- derived in ``src/setup/hindsight.ts`` (#3611) is 204s — so the drain
252
+ derived in ``src/setup/hindsight.ts`` is larger still — so the drain
212
253
  client would abandon a request the server is still legitimately working
213
254
  on, leave the entry queued, and rebuild the re-post loop #3599 exists to
214
255
  kill, one size class up.
215
256
 
257
+ This is the OUTERMOST deadline of the paired-budget family, and it is the
258
+ one the 2026-07-26 backlog-recovery logs show as a fixed ~280.1s per-entry
259
+ give-up. That was not an unexplained third number: it is this default at
260
+ the then-current ``DEFAULT_RETAIN_CLIENT_DEADLINE_S = 280.0``, plus the
261
+ request's own setup overhead. It was WRONG for the same reason #3611's
262
+ 204s was wrong — it did not cover hindsight's LiteLLM routing chain
263
+ (local 200s + OpenRouter fallback 90s + router margin 10s = 300s), so
264
+ every drained entry whose retain fell through to the fallback was
265
+ abandoned client-side at 280s while the server was still inside a
266
+ legitimate 300s budget. Raising the derivation's base to 310 fixes this
267
+ lane and the in-hook lane with the same number, by construction.
268
+ ``src/litellm/timeout-budget.ts`` is where the chain is declared, and
269
+ ``tests/setup/hindsight.test.ts`` fails if these drift apart again.
270
+
216
271
  ``HINDSIGHT_DRAIN_BACKLOG_TIMEOUT`` still overrides it outright;
217
272
  ``HINDSIGHT_RETAIN_CLIENT_DEADLINE_S`` moves the derivation and the
218
273
  content bound together.
@@ -348,6 +403,76 @@ def _document_state(entry: dict, timeout: int = 30):
348
403
  return None
349
404
 
350
405
 
406
+ def _drain_order(entries: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
407
+ """Oldest-first, but with budget-exhausted entries demoted to the back.
408
+
409
+ THE HEAD-OF-LINE BUG THIS EXISTS FOR. Since the permanence gate in
410
+ ``_record_failure``, an entry failing on anything transient stays queued
411
+ past ``MAX_ATTEMPTS`` indefinitely — deliberately, because a transient
412
+ upstream is not evidence the memory is unsaveable. But ``iter_entries()``
413
+ is oldest-first, and the entries that have been failing longest are by
414
+ construction the OLDEST, so they sit at the head of every drain. Backlog
415
+ concurrency defaults to 1 (``_backlog_concurrency``), so the drain is
416
+ sequential: three such entries in a row trip ``STALL_THRESHOLD`` and the
417
+ run breaks having drained nothing — and, because they never retire, it
418
+ breaks identically on every subsequent run. Measured on this branch before
419
+ this function existed: 6 queued entries, the 3 oldest raising
420
+ ``TimeoutError``, 4 consecutive ``drain_backlog`` runs each returning
421
+ ``stalled=True, drained=0`` with the queue depth still 6. The 3 healthy
422
+ entries behind them were never even attempted. On ``main`` the same repro
423
+ converges: run 0 retires the 3 heads to ``.dead`` and run 1 onward drains
424
+ normally. So the permanence gate, alone, traded "rarely destroys a memory"
425
+ for "eventually drains nothing at all".
426
+
427
+ ``.dead`` was doing double duty: it was the honesty policy AND it was the
428
+ queue's only un-wedging mechanism. Removing it as a policy has to leave
429
+ the un-wedging behind, and demotion is that — it keeps every property the
430
+ gate was added for (the entry is still queued, still retried, never
431
+ destroyed) while removing the one it broke (it can no longer starve a
432
+ healthy entry behind it).
433
+
434
+ The terminal condition for a permanently-unsaveable entry is therefore no
435
+ longer deletion but DEMOTION: ``attempt_count`` only ever climbs, so such
436
+ an entry crosses the budget once and stays in the back group for good,
437
+ where it can delay only itself. It is still reconciled for free on every
438
+ run — ``_reconcile_phase`` sweeps ALL entries with a sub-second presence
439
+ GET, in no particular order and with no stall guard — so an entry whose
440
+ document did land is still retired without a POST.
441
+
442
+ Ordering is a stable partition, so relative age is preserved inside each
443
+ group and FIFO still holds for everything that has not blown its budget.
444
+ """
445
+ fresh: list[tuple[str, dict]] = []
446
+ exhausted: list[tuple[str, dict]] = []
447
+ for path, entry in entries:
448
+ (exhausted if _over_budget(entry) else fresh).append((path, entry))
449
+ return fresh + exhausted
450
+
451
+
452
+ def _over_budget(entry: dict) -> bool:
453
+ """Has this entry already burned its ``MAX_ATTEMPTS`` budget?
454
+
455
+ Such an entry is chronically failing but, since the permanence gate, is
456
+ never retired. It gets two demotions — last in the drain order
457
+ (``_drain_order``) and no vote in the stall guard (see below) — because
458
+ ordering alone does not close the wedge. Ordering fixes the common shape
459
+ (a few old poison entries in front of healthy ones), but not the shape
460
+ where the WHOLE queue is over budget: an upstream down for a week takes
461
+ every entry past 5 attempts, and when it recovers the partition is empty
462
+ on one side, the poisoned entries are at the head again, and the run
463
+ stalls before reaching the entries that would now succeed. Measured: with
464
+ ordering alone and all 6 entries at ``MAX_ATTEMPTS``, 4 consecutive runs
465
+ still returned ``stalled=True, drained=0``.
466
+ """
467
+ try:
468
+ return int(entry.get("attempt_count", 0)) >= MAX_ATTEMPTS
469
+ except (TypeError, ValueError):
470
+ # A hand-edited or corrupt counter must not decide ordering, and must
471
+ # not raise on the drain path. Treat it as fresh: the cost of guessing
472
+ # wrong here is one retry in the normal position, not a lost memory.
473
+ return False
474
+
475
+
351
476
  def _record_failure(
352
477
  config: dict,
353
478
  path: str,
@@ -358,16 +483,53 @@ def _record_failure(
358
483
  """Apply the per-entry failure policy. Returns the error class name.
359
484
 
360
485
  Shared by the sequential (SessionStart) and backlog drains so both age
361
- entries toward ``.dead`` on exactly the same schedule.
486
+ entries toward ``.dead`` on exactly the same schedule — and, since the
487
+ permanence gate below, refuse to retire them on exactly the same rule.
362
488
  """
363
489
  err_class = type(e).__name__
364
490
  attempts = int(entry.get("attempt_count", 1))
365
- if attempts >= MAX_ATTEMPTS:
491
+ # ``.dead`` retires a memory the user believes was saved, so it is gated on
492
+ # the failure being PERMANENT — a 4xx that re-POSTing cannot fix. A
493
+ # transient failure keeps its attempt counter climbing but stays queued,
494
+ # because an exhausted attempt budget is not evidence that the content is
495
+ # unpersistable.
496
+ #
497
+ # Before this gate, ANY five failures retired the entry. The dominant
498
+ # failure on this fleet is an HTTP 500 "Fact extraction failed … chunk 0:
499
+ # JSONDecodeError" — the extraction model returned an empty or non-JSON
500
+ # completion for one chunk on that sampling run. The identical content
501
+ # succeeds on a later attempt, so five unlucky samples were destroying
502
+ # memories that were never unsaveable. See ``pending.is_permanent_failure``.
503
+ #
504
+ # WHAT BOUNDS THIS, precisely — because an unbounded queue of undying
505
+ # entries would be a worse outcome than the bug this gate fixes.
506
+ #
507
+ # DISK is bounded by the queue's MAX_ENTRIES / MAX_BYTES caps, which shed
508
+ # the oldest entries into ``pending-evicted/`` (an archive, not a delete).
509
+ # Note what that bound is NOT: ``_evict_to_fit`` is called only from
510
+ # ``enqueue`` (lib/pending.py:933), so it fires on new writes, never on
511
+ # drain — and it "bounds" the queue by shedding memory unsaved, which is
512
+ # the very outcome this gate exists to avoid. It is a backstop, not the
513
+ # answer.
514
+ #
515
+ # PROGRESS is bounded by ``_drain_order`` / ``_over_budget``. An entry that
516
+ # can never be persisted no longer terminates by being destroyed; it
517
+ # terminates by being DEMOTED — sorted behind every entry still inside its
518
+ # budget, and stripped of its vote in the stall guard. ``attempt_count``
519
+ # only ever climbs, so the crossing happens once and is permanent. That is
520
+ # the real terminal condition, and it is what keeps a poisoned entry from
521
+ # starving the queue behind it. Without it, three such entries ended every
522
+ # drain at zero progress, permanently (tests/test_pending_wedge.py).
523
+ #
524
+ # The attempt counter itself was never a bound on either, and ``switchroom
525
+ # doctor`` still surfaces queue depth so the operator sees a growing tail.
526
+ if attempts >= MAX_ATTEMPTS and is_permanent_failure(e):
366
527
  marker = mark_dead(path, entry)
367
528
  summary["dead"] += 1
368
529
  print(
369
530
  f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
370
- f"attempts, marking dead at {marker} (last error: {err_class}: {e})",
531
+ f"attempts on a permanent failure, marking dead at {marker} "
532
+ f"(last error: {err_class}: {e})",
371
533
  file=sys.stderr,
372
534
  )
373
535
  else:
@@ -375,7 +537,12 @@ def _record_failure(
375
537
  summary["retried"] += 1
376
538
  debug_log(
377
539
  config,
378
- f"drain_pending: retry {attempts}/{MAX_ATTEMPTS} failed for {path} ({err_class}: {e})",
540
+ # ``attempts`` can now exceed MAX_ATTEMPTS — a transient failure
541
+ # keeps the entry queued past the budget instead of retiring it —
542
+ # so print the budget as a threshold, not as a fraction that would
543
+ # render the nonsense "retry 7/5".
544
+ f"drain_pending: retry {attempts} (budget {MAX_ATTEMPTS}) failed "
545
+ f"for {path} ({err_class}: {e})",
379
546
  )
380
547
  return err_class
381
548
 
@@ -393,17 +560,24 @@ def _new_summary() -> dict:
393
560
  # so it must not be counted as drained/reconciled, which would
394
561
  # report a retire that did not happen.
395
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,
396
569
  "stalled": False,
397
570
  "budget_exceeded": False,
398
571
  }
399
572
 
400
573
 
401
574
  def drain_backlog(config: dict | None = None, **kw) -> dict:
402
- """Two-phase backlog replay, off the SessionStart budget entirely.
575
+ """Three-phase backlog replay, off the SessionStart budget entirely.
403
576
 
404
577
  See the module docstring. Summary shape is ``drain()``'s plus
405
- ``reconciled`` (already durable — no POST issued) and ``unknown``
406
- (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).
407
581
  """
408
582
  return drain(config, backlog=True, **kw)
409
583
 
@@ -428,6 +602,10 @@ def drain(
428
602
  "unknown": int, # presence unknown, left queued
429
603
  "archive_failed": int, # durable, but the archive was unwritable
430
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
431
609
  "stalled": bool, # stall guard tripped
432
610
  "budget_exceeded": bool}
433
611
  """
@@ -440,7 +618,11 @@ def drain(
440
618
 
441
619
  summary = _new_summary()
442
620
 
443
- entries = iter_entries()
621
+ # Budget-exhausted entries go last so they cannot starve the healthy ones
622
+ # behind them — see ``_drain_order``. Matters even more here than in the
623
+ # backlog drain: the in-hook budget is ~4s, so a single entry at the head
624
+ # that always burns its clamped timeout consumes the entire run.
625
+ entries = _drain_order(iter_entries())
444
626
  if not entries:
445
627
  debug_log(config, "drain_pending: queue empty")
446
628
  return summary
@@ -496,14 +678,30 @@ def drain(
496
678
  # allowance must not be handed out twice (#3599 review F2).
497
679
  _retry_one(entry, timeout=_clamp(timeout, budget, started))
498
680
  except Exception as e:
681
+ # Read the budget state BEFORE _record_failure: update_attempt
682
+ # mutates `entry["attempt_count"]` in place (lib/pending.py:1020),
683
+ # so asking afterwards would count the entry that just CROSSED the
684
+ # budget on this very failure as an abstainer, silently weakening
685
+ # the stall guard for ordinary entries.
686
+ abstains = _over_budget(entry)
499
687
  err_class = _record_failure(config, path, entry, e, summary)
500
- if err_class == last_error_class:
501
- consecutive_failures += 1
502
- else:
503
- consecutive_failures = 1
504
- last_error_class = err_class
688
+ # STALL GUARD ABSTENTION — see ``_over_budget``. The guard exists
689
+ # to detect a broken UPSTREAM and stop hammering it. A chronically
690
+ # failing entry that has already burned its attempt budget is
691
+ # evidence about that ENTRY, not about the upstream, and letting it
692
+ # vote is what wedges the queue: it never retires, so it fails
693
+ # identically on every run, and three of them end every run before
694
+ # the healthy entries behind them are ever reached. It abstains —
695
+ # neither incrementing the counter nor resetting it, so a genuine
696
+ # upstream outage is still caught by the fresh entries around it.
697
+ if not abstains:
698
+ if err_class == last_error_class:
699
+ consecutive_failures += 1
700
+ else:
701
+ consecutive_failures = 1
702
+ last_error_class = err_class
505
703
 
506
- if consecutive_failures >= STALL_THRESHOLD:
704
+ if not abstains and consecutive_failures >= STALL_THRESHOLD:
507
705
  summary["stalled"] = True
508
706
  print(
509
707
  f"[Hindsight] drain_pending: {consecutive_failures} consecutive "
@@ -626,9 +824,65 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
626
824
  def _drain_backlog_impl(
627
825
  config: dict, phase: str = "both", dry_run: bool = False
628
826
  ) -> dict:
629
- """Concurrent-capable, long-budget, two-phase backlog replay."""
827
+ """Concurrent-capable, long-budget, three-phase backlog replay."""
630
828
  summary = _new_summary()
631
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
+
632
886
  if phase in ("reconcile", "both"):
633
887
  _reconcile_phase(config, summary, dry_run)
634
888
  if phase == "reconcile":
@@ -641,7 +895,10 @@ def _drain_backlog_impl(
641
895
  backoff_ms = _p95_backoff_ms()
642
896
  started = time.monotonic()
643
897
 
644
- entries = iter_entries()
898
+ # See ``_drain_order``: without this, three budget-exhausted entries at the
899
+ # head trip the stall guard on every run forever and the drain never makes
900
+ # progress again.
901
+ entries = _drain_order(iter_entries())
645
902
  if not entries:
646
903
  _blog("phase 2: nothing left to retain")
647
904
  return summary
@@ -728,15 +985,22 @@ def _drain_backlog_impl(
728
985
  # and breaking immediately after the tripping entry makes the
729
986
  # two paths bump exactly the same number of entries.
730
987
  err_class = type(err).__name__
731
- if err_class == last_error_class:
732
- consecutive_failures += 1
733
- else:
734
- consecutive_failures = 1
735
- last_error_class = err_class
988
+ # STALL GUARD ABSTENTION for entries past their attempt budget —
989
+ # see ``_over_budget`` and the matching branch in the sequential
990
+ # drain. Evaluated here, before ``_record_failure``, which is both
991
+ # where the sequential drain evaluates it and necessary anyway
992
+ # because ``update_attempt`` mutates ``attempt_count`` in place.
993
+ abstains = _over_budget(entry)
994
+ if not abstains:
995
+ if err_class == last_error_class:
996
+ consecutive_failures += 1
997
+ else:
998
+ consecutive_failures = 1
999
+ last_error_class = err_class
736
1000
 
737
1001
  _record_failure(config, path, entry, err, summary)
738
1002
 
739
- if consecutive_failures >= STALL_THRESHOLD:
1003
+ if not abstains and consecutive_failures >= STALL_THRESHOLD:
740
1004
  summary["stalled"] = True
741
1005
  _blog(
742
1006
  f"{consecutive_failures} consecutive failures with "
@@ -767,7 +1031,7 @@ def _parse_args(argv: list[str] | None):
767
1031
  ap.add_argument(
768
1032
  "--backlog",
769
1033
  action="store_true",
770
- help="two-phase backlog replay, off the SessionStart budget",
1034
+ help="three-phase backlog replay, off the SessionStart budget",
771
1035
  )
772
1036
  ap.add_argument(
773
1037
  "--phase",
@@ -799,6 +1063,9 @@ def main(argv: list[str] | None = None) -> int:
799
1063
  if any(
800
1064
  summary[k]
801
1065
  for k in (
1066
+ "collapsed",
1067
+ "dead_relocated",
1068
+ "resplit",
802
1069
  "drained",
803
1070
  "retried",
804
1071
  "dead",
@@ -810,6 +1077,9 @@ def main(argv: list[str] | None = None) -> int:
810
1077
  print(
811
1078
  f"[Hindsight] drain_pending{'(backlog)' if args.backlog else ''}: "
812
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) "
813
1083
  f"retried={summary['retried']} dead={summary['dead']} "
814
1084
  f"unknown={summary['unknown']} "
815
1085
  f"archive_failed={summary['archive_failed']} "