switchroom 0.19.27 → 0.19.29

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 (44) hide show
  1. package/dist/agent-scheduler/index.js +16 -2
  2. package/dist/auth-broker/index.js +141 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +18 -2
  5. package/dist/cli/switchroom.js +864 -43
  6. package/dist/host-control/main.js +144 -9
  7. package/dist/vault/approvals/kernel-server.js +146 -9
  8. package/dist/vault/broker/server.js +146 -9
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +79 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +585 -50
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/CHANGELOG.md +50 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
  29. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  30. package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
  31. package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
  32. package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
  33. package/vendor/hindsight-memory/scripts/retain.py +41 -1
  34. package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
  36. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
  42. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
  43. package/vendor/hindsight-memory/settings.json +1 -1
  44. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -1,11 +1,19 @@
1
1
  """Queue eviction/dedupe + two-phase backlog drain (switchroom #3596).
2
2
 
3
- These live under ``scripts/tests/`` deliberately: that is the ONLY python
4
- test directory CI discovers (``ci-tests-python.yml:63`` and
5
- ``ci-full.yml:140`` both run ``python3 -m unittest discover tests/`` with
6
- ``working-directory: vendor/hindsight-memory/scripts``). The sibling suite
7
- at ``vendor/hindsight-memory/tests/`` is never executed by CI, so a
8
- regression test placed there would gate nothing.
3
+ These live under ``scripts/tests/`` deliberately: it is the stdlib-only
4
+ suite, discovered by ``python3 -m unittest discover tests/`` with
5
+ ``working-directory: vendor/hindsight-memory/scripts`` in both
6
+ ``ci-tests-python.yml`` and ``ci-full.yml``, and it needs no third-party
7
+ package to gate a merge.
8
+
9
+ Until switchroom #3688 it was also the ONLY python test directory CI ran at
10
+ all: the sibling suite at ``vendor/hindsight-memory/tests/`` started no
11
+ check, so a regression test placed there gated nothing. #3688 turned that
12
+ suite on too (a ``pip install -q pytest`` step in the same two workflows --
13
+ it is pytest-only because its ``conftest.py`` is what puts ``scripts/`` on
14
+ ``sys.path``), and turning it on immediately surfaced one test that had
15
+ rotted red on ``main``. Both directories are now gates; prefer this one for
16
+ anything that can be written against the stdlib.
9
17
 
10
18
  The behaviours pinned here are the ones a well-meaning refactor would
11
19
  otherwise undo:
@@ -817,6 +825,202 @@ class CollapseDuplicatesTest(_QueueTempDirMixin, unittest.TestCase):
817
825
  self.assertEqual(summary["collapsed"], 0)
818
826
  self.assertEqual(pending.count(), 3)
819
827
 
828
+ # ---- #3688 review R1-M1: a broken phase 0 must not wedge the drain -----
829
+
830
+ def test_a_failing_phase_0_never_blocks_the_phases_that_drain(self):
831
+ """The module's own invariant, one level up.
832
+
833
+ ``iter_entries`` quarantines an unparsable entry precisely so that no
834
+ single corrupt file can make the whole queue immortal. Phase 0 runs
835
+ BEFORE the phases that drain, so an unguarded exception in it
836
+ re-creates that failure with a bigger blast radius: nothing drains,
837
+ ever, until a human intervenes — strictly worse than the pre-#3688
838
+ drain, where the same queue drains fine.
839
+
840
+ A collapse failure can only ever cost duplicated work (the pass just
841
+ MOVES byte-identical copies), so it is logged and stepped over.
842
+ """
843
+ self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "one-memory")
844
+
845
+ def boom():
846
+ raise ValueError("invalid literal for int() with base 10: 'many'")
847
+
848
+ posted = []
849
+ with unittest.mock.patch.object(drain_pending, "collapse_duplicates", boom):
850
+ with unittest.mock.patch.object(
851
+ drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
852
+ ):
853
+ with unittest.mock.patch.object(
854
+ drain_pending,
855
+ "_retry_one",
856
+ lambda e, timeout: posted.append(e["document_id"]),
857
+ ):
858
+ with redirect_stderr(io.StringIO()) as err:
859
+ summary = drain_pending.drain_backlog(CONFIG)
860
+
861
+ self.assertEqual(summary["collapsed"], 0, "nothing was collapsed, honestly")
862
+ self.assertEqual(len(posted), 1, "phase 2 still ran")
863
+ self.assertEqual(pending.count(), 0, "the entry drained anyway")
864
+ self.assertIn("phase 0 FAILED", err.getvalue(), "and it was not silent")
865
+
866
+ def test_a_malformed_attempt_count_cannot_make_the_backlog_immortal(self):
867
+ """The concrete reproduction, end to end.
868
+
869
+ Two byte-identical entries, one carrying ``"attempt_count": "many"``
870
+ — valid JSON, so ``iter_entries`` hands it over rather than
871
+ quarantining it. Before #3688 review R1-M1/R1-L3 the ``int()`` in
872
+ survivor selection raised, phase 0 died and phases 1 and 2 never ran.
873
+ Both halves are fixed (``_attempt_count`` no longer raises, and the
874
+ phase is guarded regardless); this asserts the OUTCOME either way.
875
+ """
876
+ self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=1)
877
+ self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts="many")
878
+
879
+ posted = []
880
+ with unittest.mock.patch.object(
881
+ drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
882
+ ):
883
+ with unittest.mock.patch.object(
884
+ drain_pending,
885
+ "_retry_one",
886
+ lambda e, timeout: posted.append(e["document_id"]),
887
+ ):
888
+ with redirect_stderr(io.StringIO()):
889
+ summary = drain_pending.drain_backlog(CONFIG)
890
+
891
+ self.assertEqual(summary["collapsed"], 1)
892
+ self.assertEqual(len(posted), 1, "the memory still reached the bank")
893
+ self.assertEqual(pending.count(), 0)
894
+
895
+ # ---- #3688 review R1-L3: what "lowest attempt_count" actually means ----
896
+
897
+ def test_a_never_attempted_copy_beats_a_once_attempted_older_one(self):
898
+ """``attempt_count: 0`` must WIN, not tie.
899
+
900
+ The rule is "the copy with the most retries left before
901
+ MAX_ATTEMPTS". ``int(x or 1)`` scored a 0 as a 1, so a
902
+ never-attempted copy tied with a once-attempted one and lost the
903
+ tie-break to it for being newer — backwards from the documented rule.
904
+ Latent today (``_build_entry`` seeds 1) and therefore exactly the
905
+ kind of thing nothing else would catch turning real.
906
+ """
907
+ self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=1)
908
+ keep = self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts=0)
909
+
910
+ with redirect_stderr(io.StringIO()):
911
+ self.assertEqual(pending.collapse_duplicates(), 1)
912
+
913
+ self.assertEqual(
914
+ [p for p, _ in pending.iter_entries()],
915
+ [keep],
916
+ "the copy with 5 retries left survives, not the one with 4",
917
+ )
918
+
919
+ def test_an_unparseable_attempt_count_is_never_preferred_as_survivor(self):
920
+ """Unknown burn count ⇒ scored as exhausted, never as fresh.
921
+
922
+ Costs nothing: every copy in a group is byte-identical and the loser
923
+ is archived, not deleted. The point is that it can neither win the
924
+ survivor slot on a lie nor raise.
925
+ """
926
+ keep = self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=2)
927
+ self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts="many")
928
+
929
+ with redirect_stderr(io.StringIO()):
930
+ self.assertEqual(pending.collapse_duplicates(), 1)
931
+
932
+ self.assertEqual([p for p, _ in pending.iter_entries()], [keep])
933
+
934
+ # ---- #3688 re-review B3: the two unpinned `_attempt_count` branches ----
935
+ #
936
+ # Both mutations SURVIVED the suite: deleting the `isinstance(raw, bool)`
937
+ # branch (M6) and turning `raw is None: return 1` into
938
+ # `return MAX_ATTEMPTS` (M7). The logic is right; it was simply ungated,
939
+ # so a refactor could undo it with CI green.
940
+
941
+ def test_a_boolean_attempt_count_is_scored_as_UNPARSEABLE_not_as_one(self):
942
+ """``bool`` must be rejected BEFORE ``int()`` sees it.
943
+
944
+ #3688 re-review B3 / mutation M6. ``bool`` is an ``int`` subclass,
945
+ so ``int(True)`` is ``1`` — the score of a FRESH, never-retried
946
+ copy. Delete the ``isinstance(raw, bool)`` guard (it reads as
947
+ redundant next to the ``try``/``except``, which is exactly why a
948
+ future "simplification" would take it) and an entry carrying
949
+ ``"attempt_count": true`` becomes the most PREFERRED survivor in
950
+ its group: it wins the election, and every well-formed
951
+ byte-identical sibling is archived instead. Silent, and invisible
952
+ to the rest of the suite.
953
+
954
+ Scoring it ``MAX_ATTEMPTS`` makes it the LEAST preferred, which is
955
+ the honest reading — a garbage count tells us nothing about how
956
+ many retries are left, so it must never be trusted over a real one.
957
+ A raise would be wrong here for a different reason: this runs
958
+ inside ``collapse_duplicates``'s sort key over the whole queue, so
959
+ one malformed file would abort the collapse for every other entry.
960
+ """
961
+ self.assertEqual(
962
+ pending._attempt_count({"attempt_count": True}),
963
+ pending.MAX_ATTEMPTS,
964
+ "True is not one attempt — bool must take the unparseable branch",
965
+ )
966
+ self.assertEqual(
967
+ pending._attempt_count({"attempt_count": False}),
968
+ pending.MAX_ATTEMPTS,
969
+ "False is not zero attempts either",
970
+ )
971
+
972
+ # …and the consequence that actually costs a memory: the malformed
973
+ # copy must LOSE the survivor election to a well-formed sibling.
974
+ self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=True)
975
+ keep = self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts=2)
976
+
977
+ with redirect_stderr(io.StringIO()):
978
+ self.assertEqual(pending.collapse_duplicates(), 1)
979
+
980
+ self.assertEqual(
981
+ [p for p, _ in pending.iter_entries()],
982
+ [keep],
983
+ "the well-formed copy survives; the boolean one is archived",
984
+ )
985
+ self.assertEqual(
986
+ self._duplicate_names(), ["0000000001000-aaaaaaaaaaa0.json"]
987
+ )
988
+
989
+ def test_a_missing_or_null_attempt_count_reads_as_FRESH_not_exhausted(self):
990
+ """Absent/``None`` scores ``1``, matching what ``_build_entry`` writes.
991
+
992
+ #3688 re-review B3 / mutation M7 (``return 1`` → ``return
993
+ MAX_ATTEMPTS`` survived). An entry from an older build that predates
994
+ the field has burned no *recorded* attempts; scoring it as exhausted
995
+ would systematically archive it in favour of a copy with a real,
996
+ HIGHER burn count — i.e. deliberately keep the copy closer to
997
+ ``.dead``, which is backwards from the durability-first rule
998
+ ``collapse_duplicates`` documents. This is the one malformed-ish
999
+ shape that is genuinely benign, and it must not be lumped in with
1000
+ the garbage above.
1001
+ """
1002
+ self.assertEqual(pending._attempt_count({"attempt_count": None}), 1)
1003
+ self.assertEqual(pending._attempt_count({}), 1, "absent reads the same")
1004
+ self.assertLess(
1005
+ pending._attempt_count({"attempt_count": None}),
1006
+ pending._attempt_count({"attempt_count": 2}),
1007
+ "a null count must be PREFERRED over a copy with 2 attempts burned",
1008
+ )
1009
+
1010
+ self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=2)
1011
+ keep = self._write_legacy(
1012
+ "0000000002000-aaaaaaaaaaa1.json", "same", attempts=None
1013
+ )
1014
+
1015
+ with redirect_stderr(io.StringIO()):
1016
+ self.assertEqual(pending.collapse_duplicates(), 1)
1017
+
1018
+ self.assertEqual(
1019
+ [p for p, _ in pending.iter_entries()],
1020
+ [keep],
1021
+ "the null-count copy has 5 retries left; the other has 3",
1022
+ )
1023
+
820
1024
 
821
1025
  class DropLedgerTest(_QueueTempDirMixin, unittest.TestCase):
822
1026
  """Residual drops -- the entry could not be written even after eviction."""
@@ -2076,6 +2280,267 @@ class TrimDirBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
2076
2280
  self.assertIn("+8 more", msg)
2077
2281
  self.assertLessEqual(msg.count("-x.json"), 10)
2078
2282
 
2283
+ def test_a_copy_that_could_NOT_be_removed_is_never_reported_as_dropped(self):
2284
+ """A failed ``os.remove`` must not produce a ``trimmed=`` line.
2285
+
2286
+ #3688 re-review B2. ``dropped.append`` and ``_log_archive_trim``
2287
+ ran UNCONDITIONALLY after the ``try``, so a read-only mount or an
2288
+ EACCES made ``_trim_dir`` return a drop count for files that were
2289
+ all still on disk and write one durable ``trimmed=`` line per
2290
+ phantom deletion. ``pending-evictions.log`` is the forensic record
2291
+ an operator reads AFTER suspected loss; one that claims deletions
2292
+ that never happened sends them hunting a payload that never left,
2293
+ and is worse than no ledger at all.
2294
+
2295
+ Measured on the unfixed tree with three files and a cap of one:
2296
+ ``dropped=2``, three files still present, two false ledger lines.
2297
+ """
2298
+ d = self._archive([10, 10, 10])
2299
+ real_remove = os.remove
2300
+
2301
+ def denied(path, *a, **kw):
2302
+ if os.path.dirname(os.path.abspath(path)) == os.path.abspath(d):
2303
+ raise PermissionError(13, "Permission denied")
2304
+ return real_remove(path, *a, **kw)
2305
+
2306
+ with unittest.mock.patch.object(os, "remove", denied):
2307
+ with redirect_stderr(io.StringIO()) as err:
2308
+ dropped = pending._trim_dir(d, max_entries=1, max_bytes=10**9)
2309
+
2310
+ self.assertEqual(dropped, 0, "nothing was deleted, so nothing was dropped")
2311
+ self.assertEqual(
2312
+ len(self._names_in(d)), 3, "fixture: every copy is still on disk"
2313
+ )
2314
+ self.assertEqual(
2315
+ err.getvalue(), "", "no stderr line may name a file still present"
2316
+ )
2317
+ ledger = pending.evictions_log_path()
2318
+ trimmed = 0
2319
+ if os.path.exists(ledger):
2320
+ with open(ledger, encoding="utf-8") as f:
2321
+ trimmed = f.read().count("trimmed=")
2322
+ self.assertEqual(
2323
+ trimmed, 0, "the ledger must never claim a deletion that did not happen"
2324
+ )
2325
+
2326
+ def test_a_partial_failure_ledgers_only_the_copies_actually_shed(self):
2327
+ """The mixed case: one victim deletes, the next does not.
2328
+
2329
+ Pins that the guard is per-victim rather than an all-or-nothing
2330
+ bail — the ledger and the returned count must both describe
2331
+ exactly the copies that really left.
2332
+ """
2333
+ d = self._archive([10, 10, 10, 10])
2334
+ victims = [f"{1000 + i:013d}-x.json" for i in range(4)]
2335
+ real_remove = os.remove
2336
+ allowed = {os.path.abspath(os.path.join(d, victims[0]))}
2337
+
2338
+ def selective(path, *a, **kw):
2339
+ if os.path.dirname(os.path.abspath(path)) == os.path.abspath(d):
2340
+ if os.path.abspath(path) not in allowed:
2341
+ raise PermissionError(13, "Permission denied")
2342
+ return real_remove(path, *a, **kw)
2343
+
2344
+ with unittest.mock.patch.object(os, "remove", selective):
2345
+ with redirect_stderr(io.StringIO()) as err:
2346
+ dropped = pending._trim_dir(d, max_entries=1, max_bytes=10**9)
2347
+
2348
+ self.assertEqual(dropped, 1, "only the one that really went is counted")
2349
+ self.assertEqual(
2350
+ self._names_in(d), victims[1:], "the undeletable copies are still here"
2351
+ )
2352
+ with open(pending.evictions_log_path(), encoding="utf-8") as f:
2353
+ lines = [ln for ln in f.read().splitlines() if "trimmed=" in ln]
2354
+ self.assertEqual(len(lines), 1, "exactly one real deletion, one ledger line")
2355
+ self.assertIn(victims[0], lines[0])
2356
+ for name in victims[1:]:
2357
+ self.assertNotIn(name, err.getvalue())
2358
+
2359
+
2360
+ class ArchiveTrimLedgerTest(_QueueTempDirMixin, unittest.TestCase):
2361
+ """A payload must not leave an ARCHIVE without a durable trace either.
2362
+
2363
+ ``_evict_to_fit`` has had ``pending-evictions.log`` since #3599, so the
2364
+ moment a memory leaves the live queue is on the record. The other end of
2365
+ the same payload's life — ``_trim_dir`` deleting it out of
2366
+ ``pending-evicted/``, ``pending-reconciled/`` or (since #3688)
2367
+ ``pending-duplicate/`` — was stderr-only, and stderr is gone with the
2368
+ process. #3688 review R1-M2.
2369
+
2370
+ Two properties, and the second is the one with teeth: the line must NOT
2371
+ be countable as an eviction. ``switchroom doctor``'s in-container probe
2372
+ counts ledger lines matching ``/evicted=/`` inside a 7-day window and
2373
+ FAILS the pending-retains row on any hit (src/cli/doctor.ts,
2374
+ ``buildPendingRetainsProbeScript``). A duplicate archive rolling over is
2375
+ not memory loss — a byte-identical copy is still queued — so it must not
2376
+ be able to turn that row red.
2377
+ """
2378
+
2379
+ def _archive(self, name, n=4):
2380
+ d = os.path.join(self._tmp, name)
2381
+ os.makedirs(d, exist_ok=True)
2382
+ for i in range(n):
2383
+ with open(os.path.join(d, f"{1000 + i:013d}-x.json"), "w") as f:
2384
+ f.write("x")
2385
+ return d
2386
+
2387
+ def _ledger(self):
2388
+ try:
2389
+ with open(pending.evictions_log_path(), encoding="utf-8") as f:
2390
+ return [ln.strip() for ln in f if ln.strip()]
2391
+ except OSError:
2392
+ return []
2393
+
2394
+ def test_every_bounded_archive_ledgers_the_copies_it_sheds(self):
2395
+ for name in ("pending-evicted", "pending-reconciled", "pending-duplicate"):
2396
+ d = self._archive(name)
2397
+ with redirect_stderr(io.StringIO()):
2398
+ pending._trim_dir(d, max_entries=1, max_bytes=10**9)
2399
+
2400
+ lines = self._ledger()
2401
+ self.assertEqual(len(lines), 9, "3 archives x 3 shed copies, all ledgered")
2402
+ for name in ("pending-evicted", "pending-reconciled", "pending-duplicate"):
2403
+ got = [ln for ln in lines if f"archive={name}" in ln]
2404
+ self.assertEqual(len(got), 3, f"{name} trims are on the record")
2405
+ self.assertIn("trimmed=0000000001000-x.json", got[0], "oldest first")
2406
+ self.assertIn("bytes=1", got[0], "and how much went with it")
2407
+
2408
+ def test_a_trim_can_never_be_counted_as_an_eviction_by_doctor(self):
2409
+ """``trimmed=``, never ``evicted=`` — see ``buildPendingRetainsProbeScript``.
2410
+
2411
+ Renaming the token to ``evicted=`` would make every duplicate-archive
2412
+ rollover fail the operator's pending-retains row, which is the check
2413
+ that means "memory was actually shed".
2414
+ """
2415
+ d = self._archive("pending-duplicate", n=3)
2416
+ with redirect_stderr(io.StringIO()):
2417
+ pending._trim_dir(d, max_entries=1, max_bytes=10**9)
2418
+
2419
+ lines = self._ledger()
2420
+ self.assertTrue(lines, "the trim was ledgered at all")
2421
+ for ln in lines:
2422
+ self.assertNotIn(
2423
+ "evicted=", ln, "doctor counts /evicted=/ and FAILS the row on it"
2424
+ )
2425
+ self.assertIn("trimmed=", ln)
2426
+ # doctor windows the ledger with `awk '$1 >= cutoff'`, so the
2427
+ # first field has to stay a lexicographically-sortable UTC stamp.
2428
+ self.assertRegex(ln.split()[0], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
2429
+
2430
+ def test_a_collapsed_duplicate_that_rolls_out_of_the_archive_is_traceable(self):
2431
+ """End to end: collapse → archive full → the copy is gone, on record.
2432
+
2433
+ This is the honest reading of "reversible": reversible until the
2434
+ archive rolls, and when it rolls the operator can still see WHAT
2435
+ rolled and WHEN.
2436
+ """
2437
+ prev = pending.DUPLICATE_MAX_ENTRIES
2438
+ pending.DUPLICATE_MAX_ENTRIES = 1
2439
+ try:
2440
+ os.makedirs(self._dir, mode=0o700, exist_ok=True)
2441
+ for i in range(4):
2442
+ entry = dict(_payload(content="same"))
2443
+ entry["attempt_count"] = 1
2444
+ with open(
2445
+ os.path.join(self._dir, f"{1000 + i:013d}-aaaaaaaaaaa{i}.json"),
2446
+ "w",
2447
+ encoding="utf-8",
2448
+ ) as f:
2449
+ json.dump(entry, f)
2450
+ with redirect_stderr(io.StringIO()):
2451
+ self.assertEqual(pending.collapse_duplicates(), 3)
2452
+ finally:
2453
+ pending.DUPLICATE_MAX_ENTRIES = prev
2454
+
2455
+ self.assertEqual(
2456
+ len(os.listdir(pending.duplicate_dir())), 1, "the archive stayed bounded"
2457
+ )
2458
+ trims = [ln for ln in self._ledger() if "archive=pending-duplicate" in ln]
2459
+ self.assertEqual(len(trims), 2, "the two shed copies are on the record")
2460
+
2461
+ def test_trim_noise_can_never_push_a_real_eviction_out_of_the_ledger(self):
2462
+ """The cost of SHARING ``pending-evictions.log`` with ``_trim_dir``.
2463
+
2464
+ Before #3688 every line in this file was an eviction, so "keep the
2465
+ newest ``EVICTIONS_LOG_KEEP_LINES`` lines" and "keep the newest 2,000
2466
+ evictions" meant the same thing. Ledgering trims broke that
2467
+ equivalence: one ``collapse_duplicates`` run over the measured
2468
+ 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
2469
+ tail-rotate would let that noise evict real ``evicted=`` lines from
2470
+ the 7-day window ``switchroom doctor`` reads.
2471
+
2472
+ That failure mode is worth spelling out because it INVERTS the fix:
2473
+ adding observability for the benign channel (a collapse) would have
2474
+ removed it for the only channel that means memory is actually gone.
2475
+ Rotation therefore fills its window evictions-first — inside the SAME
2476
+ budget, so the file is no less bounded than it was before #3688.
2477
+ """
2478
+ keep = pending.EVICTIONS_LOG_KEEP_LINES
2479
+ log = pending.evictions_log_path()
2480
+ os.makedirs(os.path.dirname(log), exist_ok=True)
2481
+ # The oldest lines are the real evictions; everything after them is
2482
+ # trim noise, deep enough to bury them past the tail window.
2483
+ with open(log, "w", encoding="utf-8") as f:
2484
+ for i in range(5):
2485
+ f.write(
2486
+ "2026-07-20T00:00:%02dZ evicted=old-%d.json bytes=1 "
2487
+ "reason=count queue_depth=1 queue_bytes=1\n" % (i, i)
2488
+ )
2489
+ for i in range(keep + 50):
2490
+ f.write(
2491
+ "2026-07-26T00:00:00Z trimmed=n-%d.json bytes=1 "
2492
+ "archive=pending-duplicate reason=archive-count "
2493
+ "archive_depth=0 archive_bytes=0\n" % i
2494
+ )
2495
+
2496
+ # Force the rotate on the next append.
2497
+ original = pending.EVICTIONS_LOG_MAX_BYTES
2498
+ pending.EVICTIONS_LOG_MAX_BYTES = 1
2499
+ try:
2500
+ pending._append_ledger("2026-07-26T00:00:01Z trimmed=last.json bytes=1")
2501
+ finally:
2502
+ pending.EVICTIONS_LOG_MAX_BYTES = original
2503
+
2504
+ lines = self._ledger()
2505
+ evictions = [ln for ln in lines if "evicted=" in ln]
2506
+ self.assertEqual(len(evictions), 5, "every real eviction survived the rotate")
2507
+ # …and the file is still BOUNDED, on the SAME budget as before
2508
+ # #3688. Prioritising evictions must not raise the ceiling, or it
2509
+ # trades a monitoring bug for the unbounded log #3599 bounded.
2510
+ self.assertLessEqual(len(lines), keep)
2511
+ # Chronological order is what doctor's `$1 >= cutoff` awk depends on.
2512
+ self.assertEqual(
2513
+ [ln.split()[0] for ln in lines],
2514
+ sorted(ln.split()[0] for ln in lines),
2515
+ "the rotate preserved timestamp order",
2516
+ )
2517
+
2518
+ def test_the_rotate_still_sheds_trim_lines(self):
2519
+ """The eviction rescue is a floor, not an excuse to keep everything.
2520
+
2521
+ Without this the previous test is satisfied by simply never
2522
+ rotating, which would restore the unbounded ledger #3599 bounded.
2523
+ """
2524
+ keep = pending.EVICTIONS_LOG_KEEP_LINES
2525
+ log = pending.evictions_log_path()
2526
+ os.makedirs(os.path.dirname(log), exist_ok=True)
2527
+ with open(log, "w", encoding="utf-8") as f:
2528
+ for i in range(keep + 500):
2529
+ f.write(
2530
+ "2026-07-26T00:00:00Z trimmed=n-%d.json bytes=1 "
2531
+ "archive=pending-duplicate reason=archive-count "
2532
+ "archive_depth=0 archive_bytes=0\n" % i
2533
+ )
2534
+ original = pending.EVICTIONS_LOG_MAX_BYTES
2535
+ pending.EVICTIONS_LOG_MAX_BYTES = 1
2536
+ try:
2537
+ pending._append_ledger("2026-07-26T00:00:01Z trimmed=last.json bytes=1")
2538
+ finally:
2539
+ pending.EVICTIONS_LOG_MAX_BYTES = original
2540
+ lines = self._ledger()
2541
+ self.assertEqual(len(lines), keep, "a pure-trim ledger tail-rotates")
2542
+ self.assertIn("trimmed=last.json", lines[-1])
2543
+
2079
2544
 
2080
2545
  class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
2081
2546
  """``_clamp``'s floor and ``_env_num``'s clamps (#3599 review R3-L4)."""
@@ -2259,8 +2724,10 @@ class CliTest(unittest.TestCase):
2259
2724
  def _run(self, argv):
2260
2725
  seen = {}
2261
2726
 
2262
- def fake_drain(config=None, backlog=False, phase="both", dry_run=False):
2263
- seen.update(backlog=backlog, phase=phase, dry_run=dry_run)
2727
+ def fake_drain(
2728
+ config=None, backlog=False, phase="both", dry_run=False, force=False
2729
+ ):
2730
+ seen.update(backlog=backlog, phase=phase, dry_run=dry_run, force=force)
2264
2731
  return drain_pending._new_summary()
2265
2732
 
2266
2733
  with unittest.mock.patch.object(drain_pending, "drain", fake_drain):
@@ -2285,11 +2752,60 @@ class CliTest(unittest.TestCase):
2285
2752
  self.assertEqual(seen["phase"], "reconcile")
2286
2753
  self.assertTrue(seen["dry_run"])
2287
2754
 
2755
+ def test_force_is_plumbed_and_defaults_off(self):
2756
+ # `--force` overrides the circuit breaker. Parsed-but-dropped would
2757
+ # leave the operator's explicit "retry the parked entries" silently
2758
+ # doing nothing, which is exactly the class of bug this file exists
2759
+ # for (a flag accepted and ignored).
2760
+ _, seen = self._run(["--backlog", "--force"])
2761
+ self.assertTrue(seen["force"])
2762
+ _, seen = self._run(["--backlog"])
2763
+ self.assertFalse(seen["force"])
2764
+
2288
2765
  def test_phase_without_backlog_is_refused(self):
2289
2766
  with redirect_stderr(io.StringIO()):
2290
2767
  rc, _ = self._run(["--phase", "reconcile"])
2291
2768
  self.assertEqual(rc, 2)
2292
2769
 
2770
+ # ---- #3895: a failed pre-drain phase must not be invisible ------------
2771
+
2772
+ def _run_with(self, summary, argv=("--backlog",)):
2773
+ with unittest.mock.patch.object(
2774
+ drain_pending, "drain", lambda *a, **kw: summary
2775
+ ):
2776
+ with unittest.mock.patch.object(drain_pending, "load_config", lambda: {}):
2777
+ with redirect_stderr(io.StringIO()) as err:
2778
+ drain_pending.main(list(argv))
2779
+ return err.getvalue()
2780
+
2781
+ def test_a_run_whose_phases_all_failed_does_not_look_like_a_clean_one(self):
2782
+ """The whole reason the failure is in the SUMMARY, not only a log.
2783
+
2784
+ Every pre-drain counter reads 0 on a run where the phases blew up
2785
+ and on a run over an empty, healthy queue — they are the same
2786
+ numbers. `main()`'s report gate is `any(...)` over those counters,
2787
+ so without `phase_failures` in it a permanently broken phase 0b/0c
2788
+ prints, from the CLI, EXACTLY what a clean run prints: nothing.
2789
+ Same failure shape `parked` was added to that gate for (#3893), one
2790
+ stage earlier in the run. Pinned as a DIFFERENCE rather than a
2791
+ substring, because "these two runs are indistinguishable" is the
2792
+ defect.
2793
+ """
2794
+ clean = drain_pending._new_summary()
2795
+ broken = drain_pending._new_summary()
2796
+ broken["phase_failures"] = ["0b", "0c"]
2797
+
2798
+ self.assertEqual(self._run_with(clean), "", "a clean quiet run stays quiet")
2799
+ out = self._run_with(broken)
2800
+ self.assertNotEqual(out, "", "a run that lost both phases must report")
2801
+ self.assertIn("phase_failures=0b,0c", out, "and it must name which")
2802
+
2803
+ def test_a_reported_run_with_no_phase_failure_says_so(self):
2804
+ """The negative half: the field is present, not conditional noise."""
2805
+ summary = drain_pending._new_summary()
2806
+ summary["drained"] = 1
2807
+ self.assertIn("phase_failures=none", self._run_with(summary))
2808
+
2293
2809
 
2294
2810
  class StallGuardBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
2295
2811
  """The stall guard counts CONSECUTIVE failures of the SAME class.
@@ -2851,6 +3367,61 @@ class DeadMarkersLeaveTheLiveQueueTest(_QueueTempDirMixin, unittest.TestCase):
2851
3367
  self.assertEqual(summary["dead_relocated"], 0)
2852
3368
  self.assertTrue(os.path.exists(legacy))
2853
3369
 
3370
+ # ---- #3895: a broken phase 0b must not wedge the drain ----------------
3371
+
3372
+ def test_a_failing_phase_0b_never_blocks_the_phases_that_drain(self):
3373
+ """Phase 0b's blast radius, pinned.
3374
+
3375
+ 0b runs BEFORE the phases that actually drain, so an exception out
3376
+ of it takes phases 1 and 2 with it and the whole backlog becomes
3377
+ immortal — for every OTHER entry in the queue, none of which had
3378
+ anything to do with the failure.
3379
+
3380
+ The raise is INJECTED here rather than provoked from queue data, and
3381
+ that is an honest statement about this phase: unlike phase 0c,
3382
+ `sweep_legacy_dead_markers` parses no entry — it walks filenames and
3383
+ already catches `OSError` per marker (`shutil.Error` is an `OSError`
3384
+ subclass, so a cross-device move is covered too). No queue state
3385
+ reachable today makes it raise; several were tried. The guard is
3386
+ therefore defence-in-depth on the CALLER contract, which is the part
3387
+ that matters: nothing that runs before the drain may be able to stop
3388
+ it, and any future line added inside that loop re-opens the hole
3389
+ this pins shut.
3390
+
3391
+ A sweep only MOVES a marker out of the janitor's path, so skipping
3392
+ it leaves the markers exactly where every earlier build left them.
3393
+ """
3394
+ path = pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
3395
+ self.assertTrue(os.path.exists(path), "fixture")
3396
+
3397
+ def boom():
3398
+ raise OSError(errno.EACCES, "Permission denied")
3399
+
3400
+ posted = []
3401
+ with unittest.mock.patch.object(
3402
+ drain_pending, "sweep_legacy_dead_markers", boom
3403
+ ):
3404
+ with unittest.mock.patch.object(
3405
+ drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
3406
+ ):
3407
+ with unittest.mock.patch.object(
3408
+ drain_pending,
3409
+ "_retry_one",
3410
+ lambda e, timeout: posted.append(e["document_id"]),
3411
+ ):
3412
+ with redirect_stderr(io.StringIO()) as err:
3413
+ summary = drain_pending.drain_backlog(CONFIG)
3414
+
3415
+ self.assertEqual(len(posted), 1, "phase 2 still ran")
3416
+ self.assertEqual(pending.count(), 0, "the entry drained anyway")
3417
+ self.assertEqual(summary["dead_relocated"], 0, "nothing moved, honestly")
3418
+ self.assertIn("phase 0b FAILED", err.getvalue(), "and it was not silent")
3419
+ self.assertEqual(
3420
+ summary["phase_failures"],
3421
+ ["0b"],
3422
+ "the failure must survive into the summary, not only into stderr",
3423
+ )
3424
+
2854
3425
 
2855
3426
  class ResplitOverBoundEntriesTest(_QueueTempDirMixin, unittest.TestCase):
2856
3427
  """An entry over the retain bound is RECOVERABLE, so it must never die.
@@ -3704,6 +4275,244 @@ class ResplitOverBoundEntriesTest(_QueueTempDirMixin, unittest.TestCase):
3704
4275
  self.assertEqual(summary["resplit"], 0)
3705
4276
  self.assertTrue(os.path.exists(path))
3706
4277
 
4278
+ # ---- #3895: a broken phase 0c must not wedge the drain ----------------
4279
+
4280
+ def test_a_non_dict_metadata_cannot_make_the_backlog_immortal(self):
4281
+ """The concrete reproduction, end to end — measured, not imagined.
4282
+
4283
+ Phase 0c PARSES every queued entry, so a
4284
+ semantically-malformed-but-valid-JSON entry reaches it.
4285
+ `iter_entries()`' quarantine cannot help: the file is valid JSON, so
4286
+ it is handed over rather than quarantined — that is the whole point
4287
+ of the quarantine, and the whole reason this class of entry is the
4288
+ dangerous one.
4289
+
4290
+ One entry with `"metadata": "nope"` (a string where a mapping
4291
+ belongs) is enough: the payload rebuild inside `enqueue_parts`
4292
+ raises `ValueError: dictionary update sequence element #0 has
4293
+ length 1; 2 is required`. Unguarded, that ONE entry stops phases 1
4294
+ and 2 for the WHOLE queue, on every run, forever.
4295
+
4296
+ The healthy entry is the assertion that matters: it has nothing to
4297
+ do with the malformed one and must still reach the bank.
4298
+ """
4299
+ os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
4300
+ bad = pending.enqueue(
4301
+ dict(_payload(content="m" * 40_000, doc=_cd_doc(1)), metadata="nope"),
4302
+ RuntimeError("x"),
4303
+ )
4304
+ pending.enqueue(
4305
+ _payload(content="a healthy memory", doc=_cd_doc(5)), RuntimeError("x")
4306
+ )
4307
+ self.assertEqual(pending.count(), 2, "fixture")
4308
+ self.assertTrue(os.path.exists(bad), "fixture")
4309
+ # The bound MOVES under the entry — exactly what a deadline change
4310
+ # does — so phase 0c now has work to do and reaches the bad entry.
4311
+ os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
4312
+
4313
+ posted = []
4314
+ with unittest.mock.patch.object(
4315
+ drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
4316
+ ):
4317
+ with unittest.mock.patch.object(
4318
+ drain_pending,
4319
+ "_retry_one",
4320
+ lambda e, timeout: posted.append(e["document_id"]),
4321
+ ):
4322
+ with redirect_stderr(io.StringIO()) as err:
4323
+ summary = drain_pending.drain_backlog(CONFIG)
4324
+
4325
+ self.assertIn("phase 0c FAILED", err.getvalue(), "and it was not silent")
4326
+ self.assertEqual(summary["phase_failures"], ["0c"])
4327
+ self.assertEqual(summary["resplit"], 0, "nothing was re-split, honestly")
4328
+ self.assertEqual(summary["resplit_parts"], 0)
4329
+ self.assertIn(_cd_doc(5), posted, "the healthy memory still reached the bank")
4330
+ self.assertEqual(pending.count(), 0, "and the queue drained")
4331
+
4332
+ def test_a_failing_phase_0c_never_blocks_the_phases_that_drain(self):
4333
+ """The guard itself, independent of which raise provokes it.
4334
+
4335
+ The reproduction above pins ONE measured raise. This pins the
4336
+ contract that outlives it: whatever `resplit_over_bound_entries`
4337
+ raises, phases 1 and 2 still run. Skipping a re-split costs the
4338
+ over-bound entries only — they stay as undrainable as they were
4339
+ before phase 0c existed — and costs the rest of the queue nothing.
4340
+ """
4341
+ pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
4342
+
4343
+ def boom():
4344
+ raise ValueError("invalid literal for int() with base 10: 'many'")
4345
+
4346
+ posted = []
4347
+ with unittest.mock.patch.object(
4348
+ drain_pending, "resplit_over_bound_entries", boom
4349
+ ):
4350
+ with unittest.mock.patch.object(
4351
+ drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
4352
+ ):
4353
+ with unittest.mock.patch.object(
4354
+ drain_pending,
4355
+ "_retry_one",
4356
+ lambda e, timeout: posted.append(e["document_id"]),
4357
+ ):
4358
+ with redirect_stderr(io.StringIO()) as err:
4359
+ summary = drain_pending.drain_backlog(CONFIG)
4360
+
4361
+ self.assertEqual(len(posted), 1, "phase 2 still ran")
4362
+ self.assertEqual(pending.count(), 0, "the entry drained anyway")
4363
+ self.assertEqual(summary["resplit"], 0)
4364
+ self.assertEqual(summary["resplit_parts"], 0)
4365
+ self.assertIn("phase 0c FAILED", err.getvalue())
4366
+ self.assertEqual(summary["phase_failures"], ["0c"])
4367
+
4368
+ def test_every_pre_drain_phase_can_fail_and_the_drain_still_completes(self):
4369
+ """All three at once, and the summary names all three, in run order.
4370
+
4371
+ Phases 0, 0b and 0c share a block and a failure mode, so the
4372
+ interesting case is not one of them breaking but the state where the
4373
+ whole pre-drain stage is unusable. The drain must still be the thing
4374
+ that runs, and the queue must still empty.
4375
+
4376
+ Phase 0 is included because #3894's guard now routes through the same
4377
+ `_phase_failed` helper: this is the test that keeps the three of them
4378
+ on ONE mechanism, rather than three `except` blocks that drift.
4379
+ """
4380
+ pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
4381
+
4382
+ def boom(*a, **kw):
4383
+ raise RuntimeError("the pre-drain stage is unusable")
4384
+
4385
+ posted = []
4386
+ with unittest.mock.patch.object(drain_pending, "collapse_duplicates", boom):
4387
+ with unittest.mock.patch.object(
4388
+ drain_pending, "sweep_legacy_dead_markers", boom
4389
+ ):
4390
+ with unittest.mock.patch.object(
4391
+ drain_pending, "resplit_over_bound_entries", boom
4392
+ ):
4393
+ with unittest.mock.patch.object(
4394
+ drain_pending,
4395
+ "_document_state",
4396
+ lambda e, timeout=30: bool(posted),
4397
+ ):
4398
+ with unittest.mock.patch.object(
4399
+ drain_pending,
4400
+ "_retry_one",
4401
+ lambda e, timeout: posted.append(e["document_id"]),
4402
+ ):
4403
+ with redirect_stderr(io.StringIO()):
4404
+ summary = drain_pending.drain_backlog(CONFIG)
4405
+
4406
+ self.assertEqual(len(posted), 1, "phase 2 still ran")
4407
+ self.assertEqual(pending.count(), 0)
4408
+ self.assertEqual(summary["phase_failures"], ["0", "0b", "0c"])
4409
+
4410
+
4411
+ class CorruptLedgerNeverStallsTheDrainTest(_QueueTempDirMixin, unittest.TestCase):
4412
+ """A corrupt ``pending-evictions.log`` must not take the caller down.
4413
+
4414
+ #3688 re-review B1, and the reason it is a blocker rather than a nit:
4415
+ the rotation now READS the ledger back (``open(log,
4416
+ encoding="utf-8")`` + ``readlines()``) and ``UnicodeDecodeError`` is a
4417
+ ``ValueError`` subclass, NOT an ``OSError``. Under a bare
4418
+ ``except OSError`` guard the codec error escapes the "best-effort"
4419
+ ledger entirely and propagates back out through every caller:
4420
+
4421
+ * ``archive_reconciled`` → ``_trim_dir`` → ``_log_archive_trim`` →
4422
+ ``_append_ledger`` — and ``archive_reconciled`` is called from the
4423
+ drain OUTSIDE the phase guard, so it reaches ``__main__`` and
4424
+ exits 2;
4425
+ * ``enqueue`` → ``_evict_to_fit`` → ``_log_eviction`` → the same —
4426
+ i.e. a failed retain could no longer even be queued.
4427
+
4428
+ On the drain sidecar that is a SILENT permanent stall: the wrapper's
4429
+ ``|| true`` swallows the exit code and the loop aborts identically on
4430
+ every cycle, forever, which is precisely what these queue-hardening
4431
+ changes exist to prevent. The trigger needs no exotic state — one
4432
+ non-UTF-8 byte in a >1 MB ledger plus any archive over its cap. The
4433
+ read-side surface is NOT new to the rotation, either: the pre-#3688
4434
+ tail-rotate already called ``readlines()`` under ``except OSError``
4435
+ (``lib/pending.py`` at 63182ce5), so this is a live latent bug on
4436
+ ``main``, not one this change introduces.
4437
+
4438
+ The write side is the same bug through the other door: a filename
4439
+ carrying a surrogate makes the ``print`` raise ``UnicodeEncodeError``,
4440
+ also a ``ValueError``. ``except (OSError, ValueError)`` closes both.
4441
+ """
4442
+
4443
+ def _corrupt_ledger(self):
4444
+ """A >``EVICTIONS_LOG_MAX_BYTES`` ledger that is not valid UTF-8."""
4445
+ log = pending.evictions_log_path()
4446
+ os.makedirs(os.path.dirname(log), exist_ok=True)
4447
+ with open(log, "wb") as f:
4448
+ f.write(b"\xff" * (pending.EVICTIONS_LOG_MAX_BYTES + 4096))
4449
+ return log
4450
+
4451
+ def _fill_archive_over_cap(self, d, n):
4452
+ os.makedirs(d, mode=0o700, exist_ok=True)
4453
+ for i in range(n):
4454
+ with open(os.path.join(d, f"{1000 + i:013d}-k-x.json"), "w") as f:
4455
+ f.write("{}")
4456
+
4457
+ def test_archive_reconciled_survives_a_non_utf8_ledger(self):
4458
+ """The drain path. Measured raising ``UnicodeDecodeError`` unfixed."""
4459
+ os.makedirs(self._dir, mode=0o700, exist_ok=True)
4460
+ self._fill_archive_over_cap(
4461
+ pending.reconciled_dir(), pending.RECONCILED_MAX_ENTRIES + 2
4462
+ )
4463
+ self._corrupt_ledger()
4464
+ entry = os.path.join(self._dir, "9999999999999-k-v.json")
4465
+ with open(entry, "w", encoding="utf-8") as f:
4466
+ json.dump(_payload(content="a real turn"), f)
4467
+
4468
+ with redirect_stderr(io.StringIO()):
4469
+ dest = pending.archive_reconciled(entry)
4470
+
4471
+ self.assertIsNotNone(dest, "the retire itself must still succeed")
4472
+ self.assertFalse(os.path.exists(entry), "entry moved out of the queue")
4473
+ with open(dest, encoding="utf-8") as f:
4474
+ self.assertEqual(json.load(f)["content"], "a real turn")
4475
+
4476
+ def test_enqueue_survives_a_non_utf8_ledger(self):
4477
+ """The eviction path. A corrupt ledger must not block QUEUEING."""
4478
+ pending.MAX_ENTRIES = 1
4479
+ first = pending.enqueue(_payload(content="m0", doc="d0"), RuntimeError("x"))
4480
+ self.assertIsNotNone(first, "fixture")
4481
+ self._corrupt_ledger()
4482
+
4483
+ with redirect_stderr(io.StringIO()):
4484
+ second = pending.enqueue(_payload(content="m1", doc="d1"), RuntimeError("x"))
4485
+
4486
+ self.assertIsNotNone(second, "a corrupt ledger must not refuse a memory")
4487
+ with open(second, encoding="utf-8") as f:
4488
+ self.assertEqual(json.load(f)["content"], "m1")
4489
+
4490
+ def test_a_surrogate_in_a_trimmed_name_does_not_raise(self):
4491
+ """The WRITE-side twin: ``UnicodeEncodeError`` is a ``ValueError`` too."""
4492
+ os.makedirs(self._dir, mode=0o700, exist_ok=True)
4493
+ # A name straight off `os.listdir` on a filesystem holding
4494
+ # undecodable bytes carries surrogate escapes (PEP 383).
4495
+ pending._log_archive_trim(
4496
+ "0000000001000-k-\udcff.json", 10, "count", "pending-duplicate", 1, 10
4497
+ )
4498
+
4499
+ def test_a_corrupt_ledger_is_still_repaired_on_the_next_clean_write(self):
4500
+ """Swallowing the codec error must not wedge the ledger forever.
4501
+
4502
+ The rotate is attempted again on every append, so once the file is
4503
+ replaced (or truncated) by anything the ledger resumes recording —
4504
+ the guard degrades observability for the corrupt file only, it does
4505
+ not permanently disable the eviction record.
4506
+ """
4507
+ self._corrupt_ledger()
4508
+ with redirect_stderr(io.StringIO()):
4509
+ pending._log_eviction("aaaa.json", 10, "count", 1, 10)
4510
+ # Corrupt bytes are still there (we never destroy an operator's
4511
+ # file) but the new line was appended regardless.
4512
+ with open(pending.evictions_log_path(), "rb") as f:
4513
+ raw = f.read()
4514
+ self.assertIn(b"evicted=aaaa.json", raw)
4515
+
3707
4516
 
3708
4517
  if __name__ == "__main__":
3709
4518
  unittest.main()