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
@@ -70,8 +70,8 @@ bounds disk only to within ~1500x; hence ``MAX_BYTES``. Retain fires
70
70
  every 3rd turn and the busiest agent queues ~100 entries/day, so 2000
71
71
  entries is ~3 weeks of total upstream outage headroom.
72
72
 
73
- Deduplication
74
- -------------
73
+ Deduplication — the identity is (bank, part position, content)
74
+ --------------------------------------------------------------
75
75
  ``reconcile_tail`` re-enqueues the same transcript slice on every boot
76
76
  until its watermark is confirmed, so a stalled upstream multiplied one
77
77
  memory into dozens of identical files. (The measured 63% / 84.7 MB
@@ -81,9 +81,47 @@ no size index. What follows is the *preventive* guard that stops the
81
81
  duplicates accumulating in the first place — a different mechanism, and
82
82
  it does not get the credit for that number.)
83
83
 
84
- Same bank + same content-derived ``document_id`` + same content is the
85
- same memory the daemon would upsert them onto one document anyway — so
86
- ``enqueue()`` returns the existing path instead of writing a copy.
84
+ **Same bank + same position in a split + byte-identical content is the
85
+ same memory.** The key is ``(bank_id, part_position, sha256(content))``;
86
+ the rest of ``document_id`` is deliberately NOT in it (switchroom #3688).
87
+ Including the whole id made the guard a near-no-op against the producer
88
+ that actually fills this queue: ``subagent_retain.py`` embeds the
89
+ sub-agent's own session id in the document id
90
+ (``{parent}-sub-{agent_id}-r{start}-{end}``), so every SubagentStop
91
+ re-queues a near-identical slice of the SAME parent transcript under a
92
+ FRESH id. Measured on this fleet 2026-07-26: 1,060 queued files across 11
93
+ agents collapsing to ~368 ``(bank_id, part_position, sha256(content))``
94
+ groups (~65% duplicates, top group 32x — 32 distinct document ids over
95
+ one byte-identical 45,000-char part). Keyed with the whole id, the guard
96
+ matched none of them.
97
+
98
+ The part position (the ``-p{i}of{n}`` index, total discarded) stays in
99
+ the key because a split's parts are not guaranteed to differ from one
100
+ another — repetitive content cut on a character bound yields identical
101
+ parts — and merging those would leave the document missing positions
102
+ upstream. That is content loss, not a redundant copy, so it is not part
103
+ of the trade below. See :func:`_part_position`.
104
+
105
+ WHAT THIS COSTS, stated exactly. Two *different* documents whose content
106
+ happens to be byte-identical for one part now collapse onto one queue
107
+ entry, so the loser's document can end up missing that part upstream.
108
+ That is a real difference and it is the accepted trade, because the thing
109
+ this queue exists to protect is the MEMORY, not the container: the
110
+ surviving entry carries byte-identical content into the SAME bank, so no
111
+ text and no extractable fact is lost — only the second copy of it. The
112
+ alternative is the measured status quo, where identical text is extracted
113
+ by the LLM 32 times over, at ~168 s a part, for one memory. Collapsing is
114
+ also reversible: ``collapse_duplicates()`` MOVES the loser into the
115
+ bounded ``pending-duplicate/`` archive rather than deleting it.
116
+
117
+ The filename key only protects entries queued by THIS build. Entries
118
+ already on disk carry the old id-bearing key, so a new enqueue of their
119
+ content computes a different key and never matches them — the safe
120
+ direction (a missed dedupe costs a file, a false one would cost a
121
+ memory), but it leaves the accumulated backlog duplicated.
122
+ ``collapse_duplicates()`` closes that: it recomputes the key from CONTENT
123
+ for every live entry, so it is generation-agnostic, and the drain runs it
124
+ before doing any work.
87
125
 
88
126
  The dedupe key is carried IN THE FILENAME
89
127
  (``<unix-ms>-<key>-<uuid>.json``), so a lookup is a prefix match over the
@@ -178,7 +216,12 @@ import time
178
216
  import uuid
179
217
  from typing import Optional
180
218
 
181
- from .retain_split import part_document_id, part_metadata, split_retain_content
219
+ from .retain_split import (
220
+ part_document_id,
221
+ part_metadata,
222
+ retain_content_limit,
223
+ split_retain_content,
224
+ )
182
225
 
183
226
 
184
227
  SCHEMA = 1
@@ -204,6 +247,64 @@ RECONCILED_MAX_ENTRIES = int(
204
247
  RECONCILED_MAX_BYTES = int(
205
248
  os.environ.get("HINDSIGHT_PENDING_RECONCILED_MAX_BYTES") or (64 * 1024 * 1024)
206
249
  )
250
+ # ``collapse_duplicates`` retires the losing copies here, on the same terms
251
+ # as the other two archives: a collapsed duplicate is byte-identical to an
252
+ # entry that is STILL QUEUED, so this archive is the most redundant of the
253
+ # three — but it is still a MOVE, never a delete.
254
+ DUPLICATE_MAX_ENTRIES = int(
255
+ os.environ.get("HINDSIGHT_PENDING_DUPLICATE_MAX_ENTRIES") or 500
256
+ )
257
+ DUPLICATE_MAX_BYTES = int(
258
+ os.environ.get("HINDSIGHT_PENDING_DUPLICATE_MAX_BYTES") or (64 * 1024 * 1024)
259
+ )
260
+ # ``resplit_over_bound_entries`` retires the pre-split original here. Like
261
+ # the duplicate archive, this copy is REDUNDANT by construction: it is only
262
+ # moved once at least one NEW queue file exists for it (a dedupe hit is not a
263
+ # new file — see ``resplit_over_bound_entries``), and the parts carry the
264
+ # whole memory. So
265
+ # it is bounded on the same terms — a MOVE into a capped archive, never a
266
+ # delete of a memory that has nowhere else to live. (``pending-dead/`` is the
267
+ # deliberate exception: see :func:`dead_dir`.)
268
+ RESPLIT_MAX_ENTRIES = int(
269
+ os.environ.get("HINDSIGHT_PENDING_RESPLIT_MAX_ENTRIES") or 500
270
+ )
271
+ RESPLIT_MAX_BYTES = int(
272
+ os.environ.get("HINDSIGHT_PENDING_RESPLIT_MAX_BYTES") or (64 * 1024 * 1024)
273
+ )
274
+ # Fields that belong to a queue ENTRY rather than to the caller's PAYLOAD.
275
+ # `enqueue()` takes a payload, so an entry read back off disk has to be
276
+ # stripped of them before it can be re-enqueued (see
277
+ # `resplit_over_bound_entries`).
278
+ #
279
+ # Which members are load-bearing, checked against `_build_entry` (it is the
280
+ # only writer of the first four):
281
+ #
282
+ # - `attempt_count`, `last_attempt_at`, `dead_at` are LOAD-BEARING.
283
+ # `_build_entry` uses `setdefault` for `attempt_count` and never touches
284
+ # the other two, so leaving them in really does round-trip an exhausted
285
+ # retry budget — and a `last_attempt_at` from days ago paired with a
286
+ # fresh `attempt_count: 1` — straight into the new parts, sending a
287
+ # re-split part back toward `.dead` on its first failure.
288
+ # - `schema`, `failed_at`, `error_class`, `error_message` are
289
+ # DEFENCE-IN-DEPTH. `_build_entry` assigns all four unconditionally, so
290
+ # they cannot round-trip today. They are stripped anyway to keep the
291
+ # contract "what goes into `enqueue()` is a payload, not an entry" true
292
+ # of the dict itself, so no future `setdefault` here (as `attempt_count`
293
+ # already is) silently starts inheriting them. The strip is asserted on
294
+ # the payload, not only on the resulting entries — see
295
+ # `test_the_parts_carry_no_stale_attempt_metadata`.
296
+ _ENTRY_ONLY_FIELDS = frozenset(
297
+ {
298
+ "schema",
299
+ "failed_at",
300
+ "error_class",
301
+ "error_message",
302
+ "attempt_count",
303
+ "last_attempt_at",
304
+ "dead_at",
305
+ }
306
+ )
307
+
207
308
  MAX_ATTEMPTS = 5
208
309
 
209
310
  #: Residual-drop ledger. A SIBLING of the queue directory (like the
@@ -363,6 +464,72 @@ def reconciled_dir() -> str:
363
464
  )
364
465
 
365
466
 
467
+ def duplicate_dir() -> str:
468
+ """Archive directory for entries retired by ``collapse_duplicates``."""
469
+ return os.environ.get("HINDSIGHT_PENDING_DUPLICATE_DIR") or _sibling(
470
+ "pending-duplicate"
471
+ )
472
+
473
+
474
+ def dead_dir() -> str:
475
+ """Archive directory for ``MAX_ATTEMPTS`` failures (sibling of the queue).
476
+
477
+ ``.dead`` markers used to be written INSIDE the queue directory, as
478
+ ``<entry>.json.dead``. A ``.dead`` marker is the ONLY remaining copy of
479
+ that memory — ``mark_dead`` unlinks the live entry once the marker is
480
+ durable — so leaving it in the live queue directory put the last copy of
481
+ a memory in the one directory that external janitorial tooling has every
482
+ reason to sweep. On this fleet exactly that happened: a host cron ran
483
+ ``find <queue> -name '*.dead' -mtime +14 | xargs rm -f``. Measured on
484
+ 2026-07-26, 6 such markers were live in the queue directory, each one on
485
+ a countdown to permanent deletion.
486
+
487
+ Fixing the janitor is not a fix — the next one has the same shape. The
488
+ product-level fix is that the live queue directory contains ONLY live
489
+ entries, so no glob over it can ever match a memory. Dead markers move
490
+ out to this sibling, alongside ``pending-evicted``/``pending-reconciled``
491
+ /``pending-corrupt``, and ``sweep_legacy_dead_markers`` migrates any that
492
+ a previous version left behind.
493
+
494
+ NOT TTL-pruned, deliberately, and NOT trimmed to a cap: a dead entry is
495
+ an unrecovered memory, and dropping it is the loss this whole subsystem
496
+ exists to prevent. It is bounded instead by making it hard to REACH --
497
+ ``resplit_over_bound_entries`` recovers the entries that used to arrive
498
+ here, so the steady-state population is the genuinely unrecoverable ones.
499
+ """
500
+ return os.environ.get("HINDSIGHT_PENDING_DEAD_DIR") or _sibling("pending-dead")
501
+
502
+
503
+ def _retired_under(name: str) -> Optional[str]:
504
+ """Which archive sibling holds the retired queue entry ``name``, or ``None``.
505
+
506
+ An entry that vanished from the live queue mid-drain did NOT necessarily
507
+ get evicted. There is no mutex around the queue directory —
508
+ ``lib/pacing.py``'s ``retain-inflight.lock`` is a per-POST storm guard and
509
+ ``drain_pending.py`` takes no lock at all — so an in-hook ``drain()`` in
510
+ another process can retire the very entry this one is working on into
511
+ ``pending-reconciled/`` (memory confirmed durable upstream) between two
512
+ syscalls here. "Gone from the queue" therefore has to be RESOLVED, not
513
+ assumed: the difference between reconciled and evicted is the difference
514
+ between a memory that reached the bank and one that was shed, and callers
515
+ stamp the permanent-loss ledger off that distinction.
516
+
517
+ Search order is the archives that can hold a whole entry. ``mark_dead``
518
+ appends ``.dead`` to the name, so that spelling is checked too.
519
+ """
520
+ for d in (
521
+ evicted_dir(),
522
+ reconciled_dir(),
523
+ duplicate_dir(),
524
+ dead_dir(),
525
+ _sibling("pending-corrupt"),
526
+ ):
527
+ for candidate in (name, name + ".dead"):
528
+ if os.path.exists(os.path.join(d, candidate)):
529
+ return d
530
+ return None
531
+
532
+
366
533
  def evictions_log_path() -> str:
367
534
  """Append-only eviction ledger.
368
535
 
@@ -444,6 +611,12 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
444
611
  an entry only reaches it through the ledgered eviction path, and under
445
612
  sustained ENOSPC it may not reach it at all.
446
613
  """
614
+ # `.json` ONLY, and that is load-bearing beyond "skip stray files": a
615
+ # `.dead` marker is named `<entry>.json.dead`, so it can never be
616
+ # selected here even if a future caller points a trim at `pending-dead/`.
617
+ # That archive holds the only remaining copy of an unrecovered memory and
618
+ # must stay uncapped; making it structurally unreachable is stronger than
619
+ # relying on nobody wiring up the call. See :func:`dead_dir`.
447
620
  try:
448
621
  names = sorted(n for n in os.listdir(a) if n.endswith(".json"))
449
622
  except OSError:
@@ -530,6 +703,341 @@ def archive_reconciled(path: str) -> Optional[str]:
530
703
  return dest
531
704
 
532
705
 
706
+ def archive_duplicate(path: str) -> Optional[str]:
707
+ """Retire a REDUNDANT queue entry into ``pending-duplicate/``.
708
+
709
+ Used only by ``collapse_duplicates()``, and only for an entry whose
710
+ ``(bank_id, part_position, sha256(content))`` twin is still queued. Like
711
+ ``archive_reconciled`` this MOVES rather than deletes — the survivor is
712
+ evidence, not proof, that the content will land, and this module never
713
+ turns evidence into an irreversible delete.
714
+
715
+ Returns the destination path, or ``None`` when the entry could NOT be
716
+ retired — in which case it is still queued and untouched, and the
717
+ caller must not count it as collapsed. A concurrent drain that retired
718
+ the same entry first lands here too (``shutil.move`` raises
719
+ ``FileNotFoundError``, an ``OSError``), which is why the failure path
720
+ is a no-op rather than a raise.
721
+ """
722
+ dest_dir = duplicate_dir()
723
+ try:
724
+ os.makedirs(dest_dir, mode=0o700, exist_ok=True)
725
+ dest = os.path.join(dest_dir, os.path.basename(path))
726
+ shutil.move(path, dest)
727
+ except OSError as e:
728
+ print(
729
+ f"[Hindsight] pending: could not archive duplicate "
730
+ f"{os.path.basename(path)} into {dest_dir} ({e}) — entry STAYS "
731
+ f"QUEUED (never deleted)",
732
+ file=sys.stderr,
733
+ )
734
+ return None
735
+ _trim_dir(dest_dir, DUPLICATE_MAX_ENTRIES, DUPLICATE_MAX_BYTES)
736
+ return dest
737
+
738
+
739
+ def collapse_duplicates() -> int:
740
+ """Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
741
+
742
+ Returns the number of redundant copies retired.
743
+
744
+ ``enqueue()``'s filename-keyed guard stops NEW duplicates. This is the
745
+ other half: it recomputes the key from the entry's own CONTENT, so it
746
+ also collapses entries written by an older build (whose filename key
747
+ still carries ``document_id`` and therefore never matches) and entries
748
+ whose duplicate arrived while a drain held them. Generation-agnostic by
749
+ construction — the filename is not consulted for identity at all.
750
+
751
+ SURVIVOR SELECTION is deterministic and durability-first: the copy with
752
+ the LOWEST ``attempt_count`` wins, ties broken by oldest filename. All
753
+ copies carry byte-identical content, so any of them delivers the same
754
+ memory; the one with the most attempts left is the one most likely to
755
+ get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
756
+ oldest outright would systematically keep the most-attempted copy —
757
+ exactly backwards.
758
+
759
+ An entry whose key is ``None`` (no ``content``) is never grouped: its
760
+ identity cannot be established, so it is always kept.
761
+
762
+ A copy that cannot be archived stays queued and is NOT counted, so the
763
+ return value is a count of retires that actually happened.
764
+ """
765
+ groups: dict[str, list[tuple[int, str, dict]]] = {}
766
+ for order, (path, entry) in enumerate(iter_entries()):
767
+ key = _dupe_key(entry)
768
+ if not key:
769
+ continue
770
+ groups.setdefault(key, []).append((order, path, entry))
771
+
772
+ collapsed = 0
773
+ for key, members in groups.items():
774
+ if len(members) < 2:
775
+ continue
776
+ survivor = min(
777
+ members,
778
+ key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
779
+ )
780
+ for member in members:
781
+ if member is survivor:
782
+ continue
783
+ if archive_duplicate(member[1]) is not None:
784
+ collapsed += 1
785
+ print(
786
+ f"[Hindsight] pending: collapsed {len(members) - 1} duplicate "
787
+ f"cop{'y' if len(members) == 2 else 'ies'} of key {key} onto "
788
+ f"{os.path.basename(survivor[1])} (byte-identical content, same "
789
+ f"bank; the copies are archived under {duplicate_dir()}, not "
790
+ f"deleted)",
791
+ file=sys.stderr,
792
+ )
793
+ return collapsed
794
+
795
+
796
+ def resplit_dir() -> str:
797
+ """Archive directory for entries retired by ``resplit_over_bound_entries``."""
798
+ return os.environ.get("HINDSIGHT_PENDING_RESPLIT_DIR") or _sibling(
799
+ "pending-resplit"
800
+ )
801
+
802
+
803
+ def resplit_over_bound_entries() -> tuple[int, int]:
804
+ """Re-split queued entries whose content exceeds the current bound.
805
+
806
+ Returns ``(entries_resplit, parts_written)``.
807
+
808
+ THE ENTRY THIS EXISTS FOR is one whose ``content`` is larger than
809
+ ``retain_content_limit()``. Such an entry is not slow, it is IMPOSSIBLE:
810
+ the server runs one sequential extraction call per chunk, so the POST
811
+ cannot finish inside the deadline the drain waits, and re-POSTing it is
812
+ guaranteed waste. Today it either churns forever at the back of the drain
813
+ order or — if the server rejects the body as a 4xx, which
814
+ ``is_permanent_failure`` correctly classifies as permanent — goes
815
+ ``.dead``. Both outcomes are wrong for a memory that is perfectly
816
+ recoverable: ``enqueue()`` already knows how to split it.
817
+
818
+ Two ways an over-bound entry gets into a queue, and both are live here:
819
+ * it was enqueued by a build older than the split-on-enqueue path;
820
+ * the bound MOVED under it. It does that whenever the client deadline
821
+ or the deadline-safety fraction changes, so this is not a one-off
822
+ migration — it is the queue's standing response to a bound change.
823
+ Measured on this fleet 2026-07-26: 18 of 211 queued entries exceeded
824
+ 100,000 chars, the largest 744,546 — i.e. 15x the bound.
825
+
826
+ Ordering matters. The original is archived into ``pending-resplit/`` only
827
+ AFTER at least one NEW queue file exists for it, so a crash mid-way leaves
828
+ the original queued (worst case: the parts are written twice, and a
829
+ re-split is deterministic, so the second write dedupes onto the first). If
830
+ NOT ONE new part file appeared — ``enqueue_parts`` refused the whole memory
831
+ as larger than the queue cap, the disk is full, or every part deduped onto
832
+ something already queued — the original is left queued untouched and is
833
+ not counted. Nothing is deleted on any path.
834
+
835
+ "NEW file", not "``_enqueue_one`` returned a path", is the load-bearing
836
+ distinction, and it is why the count is a set difference against the
837
+ queue listing taken just before the call. ``_enqueue_one`` returns the
838
+ path of an ALREADY-QUEUED identical entry on a dedupe hit. When the
839
+ splitter yields a SINGLE part for this content — which it does whenever
840
+ the entry is over the bound but still one part's worth under the CURRENT
841
+ bound — ``enqueue_parts`` re-enqueues the payload unchanged, so the dupe
842
+ key matches the very entry being re-split and the "written" path IS the
843
+ original. Counting that as a part archived a live memory into a capped
844
+ archive while logging "into 0 queued part(s)".
845
+
846
+ NEW, and STILL LIVE, for the same reason. A part written early in the
847
+ loop can be FIFO-shed by a later part once the queue is at its cap, and
848
+ ``pending-evicted/`` is not re-drained by anything, so a shed part is a
849
+ slice of the memory that will not reach the bank. It is counted as shed
850
+ (named on its own stderr line) rather than as a queued part: the
851
+ ``(entries, parts)`` tuple feeds phase 0c's summary, and a summary that
852
+ counts evicted parts as drainable work is the same lie one level down.
853
+
854
+ WHERE THE ORIGINAL WENT is resolved, never assumed. The archive move
855
+ failing with the original already gone from the queue is usually
856
+ ``_evict_to_fit`` shedding it, but an unsynchronised in-hook ``drain()``
857
+ can equally have reconciled it away in the same window — see
858
+ ``_retired_under``. The two mean opposite things for the deferred
859
+ per-part drops (shed: the missing part is genuinely lost; reconciled:
860
+ the whole memory is already durable upstream and the missing part is a
861
+ copy that was not made), so the ledger is stamped off the resolved
862
+ destination rather than off the failure alone.
863
+
864
+ A refusal here does NOT stamp the drop ledger
865
+ (``record_refusal_as_drop=False``): the ledger means permanently lost and
866
+ `switchroom doctor` fails on it, but the original is still queued and is
867
+ retried on every backlog drain, so stamping it would climb forever. The
868
+ same reasoning applies one level down, to a PER-PART failure — an
869
+ over-cap part, or a part whose write hit ENOSPC — which is why the drops
870
+ are collected on ``deferred_drops`` and only stamped
871
+ (``record_deferred_drops``) on the branches below where the original has
872
+ actually left the live queue.
873
+
874
+ WHAT THIS COSTS, honestly. One entry becomes N, so the queue gets DEEPER
875
+ (measured worst case on this fleet: 744,546 chars -> 23 parts). On a
876
+ queue already at its cap that means ``enqueue``'s eviction path sheds
877
+ the oldest entries into ``pending-evicted/`` — shed, not destroyed,
878
+ except under the sustained-ENOSPC case ``_evict_to_fit`` documents. The
879
+ trade is deliberate: depth is recoverable, and an over-bound entry is
880
+ not drainable at any depth.
881
+ """
882
+ limit = retain_content_limit()
883
+ resplit = 0
884
+ parts = 0
885
+ for path, entry in iter_entries():
886
+ content = entry.get("content")
887
+ if not isinstance(content, str) or len(content) <= limit:
888
+ continue
889
+
890
+ payload = {k: v for k, v in entry.items() if k not in _ENTRY_ONLY_FIELDS}
891
+ d = _ensure_dir()
892
+ name = os.path.basename(path)
893
+ before = set(_list_entries(d))
894
+ deferred: list = []
895
+ _first, returned = enqueue_parts(
896
+ payload,
897
+ RuntimeError(
898
+ f"re-split: content was {len(content)} chars, over the current "
899
+ f"{limit}-char retain bound"
900
+ ),
901
+ record_refusal_as_drop=False,
902
+ deferred_drops=deferred,
903
+ )
904
+ after = set(_list_entries(d))
905
+ # New FILES only. A queue-depth delta would be wrong in both
906
+ # directions: 0 when a part deduped onto an existing entry (the
907
+ # single-part case dedupes onto the original itself), and understated
908
+ # whenever `_evict_to_fit` shed an entry to make room for a part.
909
+ fresh = [
910
+ os.path.basename(p) for p in returned if os.path.basename(p) not in before
911
+ ]
912
+ # ...and of those, only the ones STILL LIVE. A part written early in
913
+ # the loop can be FIFO-shed by a LATER part of the same memory when
914
+ # the queue is at its cap — `enqueue_parts` documents that trade — and
915
+ # the shed part lands in `pending-evicted/`, which nothing re-drains
916
+ # (every consumer only counts or trims it). Calling it a "queued part"
917
+ # is the same class of log-lie as the "STAYS QUEUED" line below: the
918
+ # operator reads N parts queued when N-1 are.
919
+ shed = sorted(n for n in fresh if n not in after)
920
+ queued = len(fresh) - len(shed)
921
+ # `queued == 0 while fresh` cannot arise through `_evict_to_fit` (it
922
+ # evicts to make room FOR the incoming part, so the last part written
923
+ # always survives), but the guard is on the LIVE count regardless:
924
+ # archiving the original is only safe once something of it is
925
+ # actually in the queue.
926
+ if queued == 0:
927
+ print(
928
+ f"[Hindsight] pending: could not re-split "
929
+ f"{name} ({len(content)} chars, bound "
930
+ f"{limit}) — entry STAYS QUEUED (never deleted)",
931
+ file=sys.stderr,
932
+ )
933
+ continue
934
+ if shed:
935
+ print(
936
+ f"[Hindsight] pending: re-split {name} wrote {len(fresh)} "
937
+ f"part(s) but the queue was at its cap, so {len(shed)} of "
938
+ f"them ({', '.join(shed)}) were themselves FIFO-evicted into "
939
+ f"{evicted_dir()} — nothing re-drains that directory, so only "
940
+ f"{queued} part(s) of this memory are live",
941
+ file=sys.stderr,
942
+ )
943
+
944
+ dest_dir = resplit_dir()
945
+ durable_upstream = False
946
+ try:
947
+ os.makedirs(dest_dir, mode=0o700, exist_ok=True)
948
+ shutil.move(path, os.path.join(dest_dir, name))
949
+ except OSError as e:
950
+ if os.path.exists(path):
951
+ # The parts are already queued; leaving the original queued
952
+ # too means the memory is retained twice, which the upsert on
953
+ # document_id makes harmless. Losing it would not be.
954
+ print(
955
+ f"[Hindsight] pending: re-split {name} "
956
+ f"into {queued} part(s) but could not archive the original "
957
+ f"into {dest_dir} ({e}) — it STAYS QUEUED (never deleted)",
958
+ file=sys.stderr,
959
+ )
960
+ continue
961
+ # The move failed because the original is NO LONGER IN THE QUEUE.
962
+ # Saying "STAYS QUEUED" here is simply false, and this is the line
963
+ # an operator reads under exactly that pressure. WHERE it went is
964
+ # not assumable, though: the usual cause is `_evict_to_fit`
965
+ # FIFO-shedding the very entry being re-split (it is the oldest —
966
+ # the parts are all newer than it), but an unsynchronised in-hook
967
+ # `drain()` can equally have RECONCILED it away inside the same
968
+ # window. Those two mean opposite things for the deferred drops,
969
+ # so resolve the destination instead of naming one.
970
+ gone_to = _retired_under(name)
971
+ durable_upstream = gone_to is not None and gone_to == reconciled_dir()
972
+ if durable_upstream:
973
+ where = (
974
+ f"it is NOT queued: a concurrent drain reconciled it into "
975
+ f"{gone_to} while the parts were being written, so the "
976
+ f"whole memory is already durable upstream"
977
+ )
978
+ elif gone_to is not None:
979
+ # Only NOW may the reassuring clause be printed. A non-empty
980
+ # `deferred` is exactly the case where the parts do NOT carry
981
+ # the whole memory (one never got written), and a non-empty
982
+ # `shed` the case where one of them is no longer queued.
983
+ carries = not deferred and not shed
984
+ where = (
985
+ f"it is NOT queued, it is under {gone_to} because writing "
986
+ f"the parts filled the queue and it was itself evicted"
987
+ + (" (the parts carry the whole memory)" if carries else "")
988
+ )
989
+ else:
990
+ where = (
991
+ f"it is NOT queued and no archive sibling holds it either "
992
+ f"— it left the live queue by a path this drain cannot "
993
+ f"see, so treat the {queued} queued part(s) as the only "
994
+ f"copy"
995
+ )
996
+ print(
997
+ f"[Hindsight] pending: re-split {name} into "
998
+ f"{queued} queued part(s); the original could not be archived "
999
+ f"into {dest_dir} ({e}) — {where}",
1000
+ file=sys.stderr,
1001
+ )
1002
+ else:
1003
+ _trim_dir(dest_dir, RESPLIT_MAX_ENTRIES, RESPLIT_MAX_BYTES)
1004
+ print(
1005
+ f"[Hindsight] pending: re-split {name} "
1006
+ f"({len(content)} chars, over the {limit}-char bound) into "
1007
+ f"{queued} queued part(s); the original is archived under "
1008
+ f"{dest_dir}, not deleted",
1009
+ file=sys.stderr,
1010
+ )
1011
+
1012
+ # Reached only on the branches where the original has LEFT the live
1013
+ # queue (archived, or evicted out from under the archive). Both are
1014
+ # a completed re-split, so both count — the `continue` above used to
1015
+ # skip the accounting on the evicted branch, returning (0, 0) after
1016
+ # genuinely writing N parts, which silenced phase 0c's `_blog` line
1017
+ # and under-reported `summary["resplit"]`.
1018
+ resplit += 1
1019
+ parts += queued
1020
+ # And only now can a per-part failure be called a loss: the original
1021
+ # is gone from the queue, so nothing will retry the parts that never
1022
+ # got written. While it was still queued, stamping these would have
1023
+ # failed `switchroom doctor` for a memory that was never lost.
1024
+ if durable_upstream:
1025
+ # ...unless the original left by being RECONCILED. The whole
1026
+ # memory reached the bank, so a part that never got written is a
1027
+ # redundant copy that was not made, not a lost turn.
1028
+ if deferred:
1029
+ print(
1030
+ f"[Hindsight] pending: {len(deferred)} part(s) of {name} "
1031
+ f"could not be written, but the original was reconciled "
1032
+ f"upstream, so nothing was lost and the drop ledger is "
1033
+ f"not stamped",
1034
+ file=sys.stderr,
1035
+ )
1036
+ else:
1037
+ record_deferred_drops(deferred)
1038
+ return resplit, parts
1039
+
1040
+
533
1041
  def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) -> None:
534
1042
  line = "%s evicted=%s bytes=%d reason=%s queue_depth=%d queue_bytes=%d" % (
535
1043
  time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@@ -627,35 +1135,86 @@ def _evict_to_fit(d: str, incoming_bytes: int) -> int:
627
1135
  return evicted
628
1136
 
629
1137
 
630
- def _dupe_key(entry: dict) -> Optional[str]:
631
- """Stable 16-hex identity of a queued retain, or ``None``.
1138
+ _PART_SUFFIX_TAIL_RE = re.compile(r"-p(\d+)of\d+$")
1139
+
632
1140
 
633
- Derived from ``(bank_id, document_id, sha256(content))``. The CONTENT
634
- hash is what makes the key an identity: two entries sharing it carry
635
- byte-identical content for the same bank and document, so they are the
636
- same memory and the daemon would upsert them onto the same document.
1141
+ def _part_position(entry: dict) -> str:
1142
+ """Where this entry sits inside a split retain, as ``"7"`` / ``"2.1"``.
637
1143
 
638
- Do NOT read this as "``document_id`` is content-derived, therefore a
639
- matching id means matching content" — that only holds post-#3244
640
- (``retain.slice_document_id``); pre-#3244 entries carry a bare session
641
- id shared by every retain in that session. Dedupe is safe on either
642
- because it hashes the content itself; a *presence GET* is not, which is
643
- why that path is gated on ``is_content_derived_document_id``.
1144
+ ``""`` for an unsplit retain. Read off the ``-p{i}of{n}`` suffix chain
1145
+ that :func:`lib.retain_split.part_document_id` appends, innermost split
1146
+ last, so a re-split part reads ``"2.1"`` (part 1 of part 2).
644
1147
 
645
- ``None`` when there is no ``document_id``: identity cannot be
646
- established, so the entry must always be kept rather than merged.
1148
+ Only the INDEX is taken; the total is deliberately discarded. That is
1149
+ what lets the key still collapse the duplicates it exists for: the
1150
+ 32-way group measured on this fleet carried ``-p7of15``, ``-p7of16``
1151
+ and ``-p7of18`` over byte-identical content, because the enclosing
1152
+ transcript kept growing while the part itself did not. Keying on the
1153
+ total would have split that group three ways and matched nothing.
1154
+ """
1155
+ doc = entry.get("document_id")
1156
+ if not isinstance(doc, str):
1157
+ return ""
1158
+ indices = []
1159
+ while True:
1160
+ m = _PART_SUFFIX_TAIL_RE.search(doc)
1161
+ if not m:
1162
+ break
1163
+ indices.append(m.group(1))
1164
+ doc = doc[: m.start()]
1165
+ return ".".join(reversed(indices))
1166
+
1167
+
1168
+ def _dupe_key(entry: dict) -> Optional[str]:
1169
+ """Stable 16-hex identity of a queued retain, or ``None``.
1170
+
1171
+ Derived from ``(bank_id, part_position, sha256(content))`` and NOTHING
1172
+ ELSE. Two entries sharing this key carry byte-identical content, for
1173
+ the same bank, at the same position inside a split — the same memory,
1174
+ however it got queued and whatever document id the producer stamped on
1175
+ it.
1176
+
1177
+ The part position is in the key because a split's parts are NOT
1178
+ guaranteed to differ from each other: ``split_retain_content`` cuts on
1179
+ a character bound, so highly repetitive content (a long run of
1180
+ identical log lines, a padded transcript) yields byte-identical parts.
1181
+ Without the position, a 10-part memory collapses to a single queued
1182
+ part and the other nine positions never reach the bank — real loss, not
1183
+ a redundant copy. With it, parts of one memory can never merge into
1184
+ each other while duplicate re-enqueues of the SAME position still do.
1185
+
1186
+ The WHOLE ``document_id`` used to be the key's dominant term and is not
1187
+ any more (switchroom #3688) — see the module docstring for the
1188
+ measurement that forced it and for the exact cost. Short version: the
1189
+ id varies per enqueue for the producer that dominates this queue
1190
+ (``subagent_retain.py`` embeds the sub-agent session id), so an
1191
+ id-bearing key never matched a re-enqueue and the queue refilled itself
1192
+ faster than it drained. The part position is the one fragment of the id
1193
+ that survives into the key, and only because dropping it would lose
1194
+ content rather than duplicate it.
1195
+
1196
+ ``None`` when ``content`` is absent: identity cannot be established, so
1197
+ the entry must always be kept rather than merged. An EMPTY string is
1198
+ identity enough — degenerate, but two empty retains into one bank are
1199
+ genuinely the same (non-)memory.
1200
+
1201
+ Nothing else in the entry may enter this key. ``failed_at``,
1202
+ ``error_message``, ``attempt_count`` and ``last_attempt_at`` all drift
1203
+ between the first enqueue and the re-enqueue this guard exists to
1204
+ catch; keying on any of them re-creates the no-op the size pre-filter
1205
+ once was.
647
1206
  """
648
- did = entry.get("document_id")
649
- if did is None:
650
- return None
651
1207
  content = entry.get("content")
1208
+ if content is None:
1209
+ return None
652
1210
  if not isinstance(content, str):
653
1211
  content = json.dumps(content, ensure_ascii=False, sort_keys=True)
654
1212
  h = hashlib.sha256()
655
- # Length-prefixed so ("ab", "c") and ("a", "bc") cannot collide.
656
- for part in (str(entry.get("bank_id")), str(did)):
657
- h.update(b"%d:" % len(part))
658
- h.update(part.encode("utf-8"))
1213
+ # Length-prefixed so a bank id ending in digits cannot be confused with
1214
+ # the part position that follows it.
1215
+ for field in (str(entry.get("bank_id")), _part_position(entry)):
1216
+ h.update(b"%d:" % len(field))
1217
+ h.update(field.encode("utf-8"))
659
1218
  h.update(hashlib.sha256(content.encode("utf-8")).digest())
660
1219
  return h.hexdigest()[:16]
661
1220
 
@@ -784,6 +1343,21 @@ def record_drop(payload: dict, error: BaseException) -> int:
784
1343
  return count_now
785
1344
 
786
1345
 
1346
+ def record_deferred_drops(drops: list) -> int:
1347
+ """Stamp the ledger for drops ``enqueue_parts`` handed back deferred.
1348
+
1349
+ Call this ONLY once the caller's own copy of the memory has left the
1350
+ live queue — that is the moment a per-part failure stops being a
1351
+ retryable "nothing was written, the original stays queued" and becomes
1352
+ the permanent loss ``record_drop`` claims. Returns how many were
1353
+ recorded, so a caller can assert it stamped nothing on the keep-queued
1354
+ branches.
1355
+ """
1356
+ for payload, error in drops:
1357
+ record_drop(payload, error)
1358
+ return len(drops)
1359
+
1360
+
787
1361
  def enqueue(payload: dict, error: BaseException) -> Optional[str]:
788
1362
  """Persist a failed retain payload.
789
1363
 
@@ -807,13 +1381,72 @@ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
807
1381
  the newest memory was the wrong end to shed from — it is the turn most
808
1382
  likely to still matter.
809
1383
  """
1384
+ return enqueue_parts(payload, error)[0]
1385
+
1386
+
1387
+ def enqueue_parts(
1388
+ payload: dict,
1389
+ error: BaseException,
1390
+ *,
1391
+ record_refusal_as_drop: bool = True,
1392
+ deferred_drops: Optional[list] = None,
1393
+ ) -> tuple[Optional[str], list[str]]:
1394
+ """``enqueue()``, plus EVERY path ``_enqueue_one`` handed back.
1395
+
1396
+ ``enqueue()`` returns only the first path, which cannot tell a caller
1397
+ how many entries the call actually put in the queue. Callers that own a
1398
+ copy of the memory (``resplit_over_bound_entries`` holds the original)
1399
+ need that: they may only retire their copy once the parts are down.
1400
+
1401
+ The returned list is the paths ``_enqueue_one`` RETURNED, which is not
1402
+ the same as the paths it CREATED — a dedupe hit returns the path of an
1403
+ already-queued identical entry. The caller decides what that means for
1404
+ it; :func:`resplit_over_bound_entries` subtracts the paths that were
1405
+ already queued before the call, which is what makes a re-split that
1406
+ deduped straight back onto its own original count as zero parts.
1407
+
1408
+ ``record_refusal_as_drop=False`` suppresses the drop-ledger entry on the
1409
+ two whole-memory refusals below. ``record_drop`` means "this memory is
1410
+ PERMANENTLY LOST" — `switchroom doctor` fails on a non-zero ledger — so
1411
+ a caller that keeps the memory queued when the refusal comes back must
1412
+ not stamp it.
1413
+
1414
+ ``deferred_drops`` closes the same hole on the PER-PART door. A per-part
1415
+ failure inside ``_enqueue_one`` — in practice a write that fails after
1416
+ eviction made room — is NOT self-evidently a loss:
1417
+ when every part fails, ``enqueue_parts`` hands back nothing, the caller
1418
+ keeps its copy queued, and phase 0c retries the whole thing on the next
1419
+ backlog drain — so stamping there makes the ledger climb by one part per
1420
+ part per drain while describing zero lost memories, and it never resets
1421
+ once the disk is fixed. Pass a list and those drops are collected onto it
1422
+ instead; call :func:`record_deferred_drops` only on the branch where the
1423
+ caller's copy actually leaves the live queue. With ``deferred_drops=None``
1424
+ (the producers: ``session_end`` / ``retain``, who hold no other copy) the
1425
+ ledger is stamped immediately, exactly as before.
1426
+
1427
+ "In practice a write failure" is exact, and the deferral of
1428
+ ``_enqueue_one``'s OWN ``MAX_BYTES`` refusal is defence-in-depth rather
1429
+ than covered behaviour: no deferring caller can reach it today. On the
1430
+ multi-part path the ``total_bytes > MAX_BYTES`` guard above short-circuits
1431
+ first, and ``sum(part_bytes) <= MAX_BYTES`` implies every individual part
1432
+ is under it (both sides measure the same ``_build_entry`` blob). On the
1433
+ ``total <= 1`` path the payload is re-enqueued UNCHANGED, so its dupe key
1434
+ is the key of the very entry ``resplit_over_bound_entries`` is re-splitting
1435
+ and ``_find_duplicate`` — which runs BEFORE the guard — returns that entry.
1436
+ The one residual crack is an original queued by a pre-#3688 build, whose
1437
+ filename carries no key segment for the prefix match to hit; the guard is
1438
+ kept deferral-aware for that case rather than assumed unreachable.
1439
+ Verified 2026-07-26 by making the branch raise when ``deferred_drops`` is
1440
+ not None: the whole python suite stayed green, i.e. no test reaches it.
1441
+ """
810
1442
  d = _ensure_dir()
811
1443
 
812
1444
  content = payload.get("content")
813
1445
  parts = split_retain_content(content) if isinstance(content, str) else [content]
814
1446
  total = len(parts)
815
1447
  if total <= 1:
816
- return _enqueue_one(d, payload, error)
1448
+ one = _enqueue_one(d, payload, error, deferred_drops=deferred_drops)
1449
+ return one, ([] if one is None else [one])
817
1450
 
818
1451
  base_doc = payload.get("document_id", "conversation")
819
1452
  base_meta = payload.get("metadata")
@@ -837,39 +1470,47 @@ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
837
1470
  # memory that cannot fit, so refuse it here too.
838
1471
  total_bytes = sum(_entry_blob_bytes(p, error) for p in part_payloads)
839
1472
  if total_bytes > MAX_BYTES:
840
- record_drop(payload, ValueError(
841
- f"entry is {total_bytes} bytes across {total} parts, larger than "
842
- f"the whole HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); "
843
- f"refusing this entry rather than evicting the entire queue for it"
844
- ))
845
- return None
1473
+ if record_refusal_as_drop:
1474
+ record_drop(payload, ValueError(
1475
+ f"entry is {total_bytes} bytes across {total} parts, larger than "
1476
+ f"the whole HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); "
1477
+ f"refusing this entry rather than evicting the entire queue for it"
1478
+ ))
1479
+ return None, []
846
1480
  if total > MAX_ENTRIES:
847
- record_drop(payload, ValueError(
848
- f"entry splits into {total} parts, more than the whole "
849
- f"HINDSIGHT_PENDING_MAX_ENTRIES cap ({MAX_ENTRIES}); refusing "
850
- f"this entry rather than evicting the entire queue for it"
851
- ))
852
- return None
1481
+ if record_refusal_as_drop:
1482
+ record_drop(payload, ValueError(
1483
+ f"entry splits into {total} parts, more than the whole "
1484
+ f"HINDSIGHT_PENDING_MAX_ENTRIES cap ({MAX_ENTRIES}); refusing "
1485
+ f"this entry rather than evicting the entire queue for it"
1486
+ ))
1487
+ return None, []
853
1488
 
854
1489
  first: Optional[str] = None
1490
+ returned: list[str] = []
855
1491
  for part_payload in part_payloads:
856
1492
  # Each part goes through the FULL enqueue pipeline — dedupe, the
857
1493
  # MAX_BYTES refusal, eviction, the drop ledger — because each part
858
1494
  # is an independently drainable memory, not a fragment that only
859
1495
  # means something alongside its siblings. A part that cannot be
860
- # written is recorded as a drop and the remaining parts still go in;
861
- # returning ``None`` for the whole memory because part 7 of 9 hit
862
- # ENOSPC would discard eight recoverable turns.
1496
+ # written is recorded as a drop (or collected onto ``deferred_drops``
1497
+ # for the caller to decide, see above) and the remaining parts still
1498
+ # go in; returning ``None`` for the whole memory because part 7 of 9
1499
+ # hit ENOSPC would discard eight recoverable turns.
863
1500
  #
864
1501
  # A part CAN evict an earlier part of the same memory when the queue
865
1502
  # is already at its cap (eviction is FIFO and earlier parts are
866
1503
  # older). That is the same trade `_evict_to_fit` documents — the
867
1504
  # evicted part MOVES to ``pending-evicted/``, so it is shed, not
868
1505
  # destroyed, except under the sustained-ENOSPC case named there.
869
- written = _enqueue_one(d, part_payload, error)
870
- if written is not None and first is None:
871
- first = written
872
- return first
1506
+ written = _enqueue_one(
1507
+ d, part_payload, error, deferred_drops=deferred_drops
1508
+ )
1509
+ if written is not None:
1510
+ returned.append(written)
1511
+ if first is None:
1512
+ first = written
1513
+ return first, returned
873
1514
 
874
1515
 
875
1516
  def _build_entry(payload: dict, error: BaseException) -> dict:
@@ -894,8 +1535,28 @@ def _entry_blob_bytes(payload: dict, error: BaseException) -> int:
894
1535
  return len(json.dumps(_build_entry(payload, error), ensure_ascii=False).encode("utf-8"))
895
1536
 
896
1537
 
897
- def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
898
- """Write exactly ONE queue entry for ``payload``. See ``enqueue()``."""
1538
+ def _enqueue_one(
1539
+ d: str,
1540
+ payload: dict,
1541
+ error: BaseException,
1542
+ *,
1543
+ deferred_drops: Optional[list] = None,
1544
+ ) -> Optional[str]:
1545
+ """Write exactly ONE queue entry for ``payload``. See ``enqueue()``.
1546
+
1547
+ ``deferred_drops``, when a list is passed, DEFERS both ``record_drop``
1548
+ calls below: the ``(payload, error)`` pair is appended to the list
1549
+ instead of being stamped on the ledger straight away. The caller then
1550
+ owns the decision and must call :func:`record_deferred_drops` if — and
1551
+ only if — it stops keeping its own copy of the memory. See
1552
+ ``enqueue_parts`` for why that decision cannot be made here.
1553
+ """
1554
+ def _drop(err: BaseException) -> None:
1555
+ if deferred_drops is None:
1556
+ record_drop(payload, err)
1557
+ else:
1558
+ deferred_drops.append((payload, err))
1559
+
899
1560
  entry = _build_entry(payload, error)
900
1561
 
901
1562
  blob = json.dumps(entry, ensure_ascii=False)
@@ -927,7 +1588,7 @@ def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
927
1588
  # guard the eviction loop below evicts the ENTIRE queue trying to make
928
1589
  # room and then writes it anyway — trading every queued memory for one.
929
1590
  if blob_bytes > MAX_BYTES:
930
- record_drop(payload, ValueError(
1591
+ _drop(ValueError(
931
1592
  f"entry is {blob_bytes} bytes, larger than the whole "
932
1593
  f"HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); refusing this "
933
1594
  f"entry rather than evicting the entire queue for it"
@@ -948,11 +1609,12 @@ def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
948
1609
  # full, permissions). This is the only path that now loses a turn,
949
1610
  # and it is recorded rather than returned bare — callers handle
950
1611
  # ``None``, but none of them can see *why* without the ledger.
1612
+ # ``_drop`` defers it for a caller that still holds the memory.
951
1613
  try:
952
1614
  os.unlink(tmp)
953
1615
  except OSError:
954
1616
  pass
955
- record_drop(payload, write_err)
1617
+ _drop(write_err)
956
1618
  return None
957
1619
  return final
958
1620
 
@@ -1107,12 +1769,28 @@ def is_permanent_failure(error: BaseException) -> bool:
1107
1769
 
1108
1770
 
1109
1771
  def mark_dead(path: str, entry: dict) -> Optional[str]:
1110
- """Convert an entry that exceeded ``MAX_ATTEMPTS`` into a permanent
1111
- failure marker at ``<path>.dead`` so the queue no longer drains it
1112
- but operators can still inspect.
1772
+ """Retire an entry that exceeded ``MAX_ATTEMPTS`` into ``dead_dir()``.
1113
1773
 
1114
1774
  Returns the marker path, or ``None`` if it failed.
1115
1775
 
1776
+ WHERE THE MARKER GOES, AND WHY IT MOVED. This used to write
1777
+ ``<path>.dead`` — i.e. INSIDE the live queue directory. Since the marker
1778
+ is the only remaining copy of the memory (the live entry is unlinked once
1779
+ it is durable), that put the last copy of a memory in the directory
1780
+ external janitors sweep. It is not hypothetical: a host cron on this
1781
+ fleet ran ``find <queue> -name '*.dead' -mtime +14 -delete``. Measured
1782
+ 2026-07-26, 6 such markers were live in the queue directory, each on a
1783
+ countdown to permanent deletion. The marker now lands in the
1784
+ ``pending-dead/`` sibling, so the live queue directory holds ONLY live
1785
+ entries and no glob over it can match a memory. See :func:`dead_dir`.
1786
+
1787
+ FAILURE IS NOT DELETION. If the marker cannot be written at all, this
1788
+ returns ``None`` and **leaves the live entry exactly where it is**. The
1789
+ drain then re-attempts it on the next run, which is wasteful and visible;
1790
+ the alternative — falling back to a marker inside the queue directory —
1791
+ would quietly restore the loss channel this function exists to close.
1792
+ Queued-and-retrying beats destroyed, every time.
1793
+
1116
1794
  Crash-window invariant (#1094 item 3): **a live ``<path>.json`` entry
1117
1795
  must never carry a ``dead_at`` stamp.** The old two-step form violated
1118
1796
  this — it wrote the dead_at-stamped payload back to the *live* path
@@ -1120,21 +1798,25 @@ def mark_dead(path: str, entry: dict) -> Optional[str]:
1120
1798
  crash between the two renames left a live entry with ``dead_at`` set
1121
1799
  that the drainer would re-enter and re-bump. Here we instead:
1122
1800
 
1123
- 1. write the dead_at-stamped payload to ``<path>.tmp``
1124
- 2. ``os.replace(tmp, dead_path)`` — the .dead marker appears in one
1125
- atomic step (never drained: the drainer only lists ``*.json``)
1801
+ 1. write the dead_at-stamped payload to a ``.tmp`` inside ``dead_dir()``
1802
+ 2. ``os.replace(tmp, dead_path)`` — the marker appears in one atomic
1803
+ step, in a directory the drainer never lists at all
1126
1804
  3. ``os.unlink(path)`` — drop the original live entry
1127
1805
 
1128
- At every crash point the invariant holds: the ``dead_at`` stamp only
1129
- ever lands on ``<path>.dead``. A crash after step 2 leaves both the
1130
- (stale, no-dead_at) live entry and the .dead marker; the next drain
1131
- re-marks it dead (os.replace overwrites the marker idempotently),
1132
- never observing a live entry with dead_at.
1806
+ At every crash point the invariant holds: the ``dead_at`` stamp only ever
1807
+ lands in ``dead_dir()``. A crash after step 2 leaves both the (stale, no-
1808
+ dead_at) live entry and the marker; the next drain re-marks it dead
1809
+ (``os.replace`` overwrites the marker idempotently), never observing a
1810
+ live entry with dead_at. Step 1 writes the tmp in the DESTINATION
1811
+ directory so step 2 is a same-directory rename and cannot fail on a
1812
+ cross-filesystem boundary halfway through.
1133
1813
  """
1134
1814
  entry["dead_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
1135
- dead_path = path + ".dead"
1136
- tmp = path + ".tmp"
1815
+ dest_dir = dead_dir()
1816
+ dead_path = os.path.join(dest_dir, os.path.basename(path) + ".dead")
1817
+ tmp = dead_path + ".tmp"
1137
1818
  try:
1819
+ os.makedirs(dest_dir, mode=0o700, exist_ok=True)
1138
1820
  with open(tmp, "w", encoding="utf-8") as f:
1139
1821
  json.dump(entry, f, ensure_ascii=False)
1140
1822
  os.chmod(tmp, 0o600)
@@ -1148,9 +1830,64 @@ def mark_dead(path: str, entry: dict) -> Optional[str]:
1148
1830
  pass
1149
1831
  return dead_path
1150
1832
  except OSError:
1151
- # Clean up a possibly-orphaned tmp so it doesn't linger.
1833
+ # Clean up a possibly-orphaned tmp so it doesn't linger. The live
1834
+ # entry is deliberately left alone — see "FAILURE IS NOT DELETION".
1152
1835
  try:
1153
1836
  os.unlink(tmp)
1154
1837
  except OSError:
1155
1838
  pass
1156
1839
  return None
1840
+
1841
+
1842
+ def sweep_legacy_dead_markers() -> int:
1843
+ """Move ``*.dead`` markers left inside the queue dir into ``dead_dir()``.
1844
+
1845
+ Versions before this one wrote the marker as ``<entry>.json.dead``, next
1846
+ to the live entries. Those markers are the only copy of their memory and
1847
+ are sitting in the directory a janitor sweeps, so an upgrade has to
1848
+ RELOCATE them, not just stop producing new ones. Returns the number moved.
1849
+
1850
+ Idempotent and safe to run on every drain: a queue with no legacy markers
1851
+ does no work and returns 0. A marker whose name already exists in
1852
+ ``dead_dir()`` is left where it is rather than overwritten — the two are
1853
+ the same entry by construction (the name carries the queue's unique
1854
+ suffix), but "leave both copies" is the answer that cannot lose one.
1855
+ """
1856
+ d = pending_dir()
1857
+ try:
1858
+ names = sorted(n for n in os.listdir(d) if n.endswith(".dead"))
1859
+ except OSError:
1860
+ return 0
1861
+ if not names:
1862
+ return 0
1863
+
1864
+ dest_dir = dead_dir()
1865
+ try:
1866
+ os.makedirs(dest_dir, mode=0o700, exist_ok=True)
1867
+ except OSError as e:
1868
+ print(
1869
+ f"[Hindsight] pending: cannot create {dest_dir} ({e}); "
1870
+ f"{len(names)} legacy .dead marker(s) STAY in the live queue "
1871
+ f"directory (they are not deleted)",
1872
+ file=sys.stderr,
1873
+ )
1874
+ return 0
1875
+
1876
+ moved = 0
1877
+ for name in names:
1878
+ dest = os.path.join(dest_dir, name)
1879
+ if os.path.exists(dest):
1880
+ continue
1881
+ try:
1882
+ shutil.move(os.path.join(d, name), dest)
1883
+ except OSError:
1884
+ continue
1885
+ moved += 1
1886
+ if moved:
1887
+ print(
1888
+ f"[Hindsight] pending: relocated {moved} legacy .dead marker(s) "
1889
+ f"out of the live queue directory into {dest_dir} — the live "
1890
+ f"queue now holds only live entries",
1891
+ file=sys.stderr,
1892
+ )
1893
+ return moved