switchroom 0.19.27 → 0.19.28

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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  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/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -556,6 +556,112 @@ def _dir_bytes(d: str, names) -> int:
556
556
  return total
557
557
 
558
558
 
559
+ def _append_ledger(line: str) -> None:
560
+ """Append one line to ``pending-evictions.log``, rotating at its cap.
561
+
562
+ Split out of ``_log_eviction`` (#3688 review R1-M2) so the OTHER way a
563
+ payload leaves this module for good — ``_trim_dir`` shedding an archived
564
+ copy — can be ledgered on exactly the same terms instead of leaving no
565
+ durable trace at all. Best-effort: a ledger that cannot be written *or
566
+ read back* must never take the caller down with it — hence the guard
567
+ catches ``ValueError`` as well as ``OSError`` (see there).
568
+
569
+ ROTATION PRIORITISES ``evicted=`` INSIDE THE SAME BUDGET, and that is a
570
+ direct consequence of sharing the file. Before #3688 every line here
571
+ was an eviction, so "keep the newest ``EVICTIONS_LOG_KEEP_LINES``
572
+ lines" and "keep the newest 2,000 evictions" were the same sentence.
573
+ They are not any more: a single ``collapse_duplicates`` run over the
574
+ measured 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
575
+ tail-rotate would let that noise push real ``evicted=`` lines out of
576
+ the 7-day window ``switchroom doctor`` reads — i.e. adding
577
+ observability for the BENIGN channel would have removed it for the one
578
+ channel that means memory is actually gone.
579
+
580
+ So the keep window is filled evictions-first and only then topped up
581
+ with the newest remaining lines. The budget is unchanged at
582
+ ``EVICTIONS_LOG_KEEP_LINES`` lines TOTAL — a ledger of nothing but
583
+ evictions rotates exactly as it did before #3688 — and chronological
584
+ order is preserved because the selection is by index over the original
585
+ lines, which ``switchroom doctor``'s ``$1 >= cutoff`` awk depends on.
586
+ """
587
+ log = evictions_log_path()
588
+ try:
589
+ with open(log, "a", encoding="utf-8") as f:
590
+ print(line, file=f)
591
+ # Bounded, not append-forever. `switchroom doctor` windows this by
592
+ # timestamp so a single legitimate eviction can't turn the row red
593
+ # permanently, but the FILE still needs a ceiling of its own.
594
+ if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
595
+ with open(log, encoding="utf-8") as f:
596
+ lines = f.readlines()
597
+ evictions = [i for i, ln in enumerate(lines) if "evicted=" in ln]
598
+ others = [i for i, ln in enumerate(lines) if "evicted=" not in ln]
599
+ keep_idx = set(evictions[-EVICTIONS_LOG_KEEP_LINES:])
600
+ room = EVICTIONS_LOG_KEEP_LINES - len(keep_idx)
601
+ if room > 0:
602
+ keep_idx.update(others[-room:])
603
+ kept = [ln for i, ln in enumerate(lines) if i in keep_idx]
604
+ tmp = log + ".tmp"
605
+ with open(tmp, "w", encoding="utf-8") as f:
606
+ f.writelines(kept)
607
+ os.chmod(tmp, 0o600)
608
+ os.replace(tmp, log)
609
+ except (OSError, ValueError):
610
+ # ValueError, not just OSError, because BOTH codec errors are
611
+ # ValueError subclasses and neither is an OSError (#3688 re-review
612
+ # B1). The rotate READS the ledger back (``readlines()`` above) —
613
+ # something no revision before #3688 did — so a ledger holding one
614
+ # non-UTF-8 byte raises ``UnicodeDecodeError`` here, and a name
615
+ # carrying a surrogate raises ``UnicodeEncodeError`` at the
616
+ # ``print`` above. Under a bare ``except OSError`` both escape this
617
+ # "best-effort" guard: ``_trim_dir`` → ``_log_archive_trim`` → here
618
+ # runs inside ``archive_reconciled``/``enqueue``, so one corrupt
619
+ # ledger byte would take down every drain — on the sidecar,
620
+ # identically, every 900s forever. A ledger that cannot be written
621
+ # must never take the caller down with it, and that has to include
622
+ # the codec.
623
+ pass
624
+
625
+
626
+ def _log_archive_trim(
627
+ name: str, size: int, reason: str, archive: str, depth: int, nbytes: int
628
+ ) -> None:
629
+ """Ledger ONE archived copy shed by ``_trim_dir``.
630
+
631
+ Until #3688 review R1-M2 a trim was stderr-only: an entry could enter
632
+ ``pending-duplicate/`` (or ``pending-evicted/``, or
633
+ ``pending-reconciled/``) and then be deleted from it with no durable
634
+ record anywhere, while an *eviction* — the other end of the same
635
+ payload's life — has had ``pending-evictions.log`` since #3599. stderr
636
+ is gone with the process; the ledger is what an operator can still read
637
+ a week later. Same file, because it answers one question ("what left
638
+ this agent's queue, and when").
639
+
640
+ THE FIRST TOKEN IS DELIBERATELY ``trimmed=``, NOT ``evicted=``.
641
+ ``switchroom doctor``'s probe counts eviction ledger lines with
642
+ ``awk '$1 >= cutoff && /evicted=/'`` (src/cli/doctor.ts, see
643
+ ``buildPendingRetainsProbeScript``), and that count FAILS the
644
+ pending-retains row. A trim is not an eviction — nothing was shed from
645
+ the live queue and, for ``pending-duplicate/``, a byte-identical copy is
646
+ still queued — so a trim must not be able to turn that row red. No field
647
+ on this line may ever be formatted such that ``evicted=`` appears in it;
648
+ ``ArchiveTrimLedgerTest`` pins that.
649
+ """
650
+ _append_ledger(
651
+ "%s trimmed=%s bytes=%d archive=%s reason=archive-%s "
652
+ "archive_depth=%d archive_bytes=%d"
653
+ % (
654
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
655
+ name,
656
+ size,
657
+ archive,
658
+ reason,
659
+ depth,
660
+ nbytes,
661
+ )
662
+ )
663
+
664
+
559
665
  def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
560
666
  """Keep ``a`` under its count/byte caps, deleting OLDEST first.
561
667
 
@@ -610,6 +716,21 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
610
716
  ``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
611
717
  an entry only reaches it through the ledgered eviction path, and under
612
718
  sustained ENOSPC it may not reach it at all.
719
+
720
+ EVERY DROP IS LEDGERED, AND ONLY A DROP (#3688 review R1-M2, tightened
721
+ by re-review B2). One ``trimmed=… archive=…`` line per shed copy goes
722
+ to ``pending-evictions.log`` via ``_log_archive_trim``, so the trim
723
+ horizon above is observable after the fact and not only in a stderr
724
+ stream nobody kept. That line is deliberately NOT counted as an
725
+ eviction by doctor — see ``_log_archive_trim``.
726
+
727
+ A victim whose ``os.remove`` FAILS (EACCES, read-only mount) is neither
728
+ ledgered nor counted nor named on stderr: it is still on disk, so a
729
+ ``trimmed=`` line for it would be a false claim of deletion in exactly
730
+ the record an operator consults after suspected loss, and the returned
731
+ count is what callers report as "copies shed". ``TrimDirBoundaryTest``
732
+ pins that a failed remove yields ``0``, no ledger line, and every file
733
+ still present.
613
734
  """
614
735
  # `.json` ONLY, and that is load-bearing beyond "skip stray files": a
615
736
  # `.dead` marker is named `<entry>.json.dead`, so it can never be
@@ -622,14 +743,35 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
622
743
  except OSError:
623
744
  return 0
624
745
  dropped = []
625
- while names and (
626
- len(names) > max_entries or _dir_bytes(a, names) > max_bytes
627
- ):
746
+ label = os.path.basename(a.rstrip("/"))
747
+ nbytes = _dir_bytes(a, names)
748
+ while names and (len(names) > max_entries or nbytes > max_bytes):
749
+ reason = "count" if len(names) > max_entries else "bytes"
750
+ victim = names.pop(0)
751
+ vpath = os.path.join(a, victim)
628
752
  try:
629
- os.remove(os.path.join(a, names[0]))
753
+ vsize = os.path.getsize(vpath)
630
754
  except OSError:
631
- pass
632
- dropped.append(names.pop(0))
755
+ vsize = 0
756
+ try:
757
+ os.remove(vpath)
758
+ except OSError:
759
+ # The copy is still on disk, so the running total would drift.
760
+ # Re-measure rather than assume — this preserves the original
761
+ # recompute-every-iteration semantics on the one path where a
762
+ # decrement would be wrong (EACCES / read-only mount).
763
+ nbytes = _dir_bytes(a, names)
764
+ # NOT counted and NOT ledgered (#3688 re-review B2). The ledger
765
+ # is the forensic record an operator reads AFTER suspected data
766
+ # loss; a `trimmed=` line for a copy still sitting on disk sends
767
+ # them hunting a payload that never left, and the returned count
768
+ # (and the stderr line below) would name files that were not
769
+ # dropped. A ledger that claims deletions which never happened
770
+ # is worse than no ledger.
771
+ continue
772
+ nbytes -= vsize
773
+ dropped.append(victim)
774
+ _log_archive_trim(victim, vsize, reason, label, len(names), nbytes)
633
775
  if dropped:
634
776
  shown = ", ".join(dropped[:10])
635
777
  if len(dropped) > 10:
@@ -736,6 +878,47 @@ def archive_duplicate(path: str) -> Optional[str]:
736
878
  return dest
737
879
 
738
880
 
881
+ def _attempt_count(entry: dict) -> int:
882
+ """How many retries this copy has BURNED, for survivor selection.
883
+
884
+ Not ``int(entry.get("attempt_count", 1) or 1)`` (#3688 review R1-L3).
885
+ That expression had two defects, both latent rather than reachable
886
+ today — ``_build_entry`` seeds ``1`` and ``update_attempt`` only ever
887
+ increments — which is exactly why they need pinning rather than
888
+ ignoring: nothing would have caught them turning real.
889
+
890
+ * ``0 or 1`` is ``1``, so a NEVER-ATTEMPTED copy scored the same as one
891
+ that had already burned an attempt, and lost the tie-break to it if
892
+ it happened to be the newer file. That is backwards from the rule
893
+ ``collapse_duplicates`` documents ("the copy with the most retries
894
+ left before ``MAX_ATTEMPTS`` wins") — a 0 must beat a 1.
895
+ * ``int("many")`` RAISES. A single semantically-malformed entry — valid
896
+ JSON, so ``iter_entries`` hands it over rather than quarantining it —
897
+ took the whole collapse pass down with it, and with it (before the
898
+ guard in ``drain_pending._drain_backlog_impl``) the whole drain.
899
+ Unparseable now scores ``MAX_ATTEMPTS``: we cannot tell how many
900
+ attempts it has left, so it is never PREFERRED as the survivor, but
901
+ it is never the reason a collapse fails either. Losing it costs
902
+ nothing anyway — every copy in a group is byte-identical, and the
903
+ loser is archived, not deleted.
904
+
905
+ A missing or ``None`` count reads as ``1``, matching what
906
+ ``_build_entry`` writes, so an entry from an older build that predates
907
+ the field is treated as freshly queued rather than exhausted.
908
+ """
909
+ raw = entry.get("attempt_count", 1)
910
+ if raw is None:
911
+ return 1
912
+ # bool is an int subclass; True would silently score 1. Neither True nor
913
+ # False is an attempt count, so both take the unparseable branch.
914
+ if isinstance(raw, bool):
915
+ return MAX_ATTEMPTS
916
+ try:
917
+ return int(raw)
918
+ except (TypeError, ValueError):
919
+ return MAX_ATTEMPTS
920
+
921
+
739
922
  def collapse_duplicates() -> int:
740
923
  """Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
741
924
 
@@ -754,7 +937,8 @@ def collapse_duplicates() -> int:
754
937
  memory; the one with the most attempts left is the one most likely to
755
938
  get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
756
939
  oldest outright would systematically keep the most-attempted copy —
757
- exactly backwards.
940
+ exactly backwards. ``_attempt_count`` defines "lowest", including what
941
+ a ``0`` and what a malformed count are worth.
758
942
 
759
943
  An entry whose key is ``None`` (no ``content``) is never grouped: its
760
944
  identity cannot be established, so it is always kept.
@@ -773,10 +957,7 @@ def collapse_duplicates() -> int:
773
957
  for key, members in groups.items():
774
958
  if len(members) < 2:
775
959
  continue
776
- survivor = min(
777
- members,
778
- key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
779
- )
960
+ survivor = min(members, key=lambda m: (_attempt_count(m[2]), m[0]))
780
961
  for member in members:
781
962
  if member is survivor:
782
963
  continue
@@ -1047,23 +1228,7 @@ def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) ->
1047
1228
  depth,
1048
1229
  nbytes,
1049
1230
  )
1050
- log = evictions_log_path()
1051
- try:
1052
- with open(log, "a", encoding="utf-8") as f:
1053
- print(line, file=f)
1054
- # Bounded, not append-forever. `switchroom doctor` windows this by
1055
- # timestamp so a single legitimate eviction can't turn the row red
1056
- # permanently, but the FILE still needs a ceiling of its own.
1057
- if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
1058
- with open(log, encoding="utf-8") as f:
1059
- kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
1060
- tmp = log + ".tmp"
1061
- with open(tmp, "w", encoding="utf-8") as f:
1062
- f.writelines(kept)
1063
- os.chmod(tmp, 0o600)
1064
- os.replace(tmp, log)
1065
- except OSError:
1066
- pass
1231
+ _append_ledger(line)
1067
1232
  print(
1068
1233
  "[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
1069
1234
  "newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
@@ -0,0 +1,401 @@
1
+ """A permanently-failing entry must stop costing LLM lane time.
2
+
3
+ Lives under ``scripts/tests/`` because that is the only python test directory
4
+ CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
5
+ tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
6
+
7
+ BACKGROUND. The permanence gate (``pending.is_permanent_failure``) keeps an
8
+ entry queued past ``MAX_ATTEMPTS`` on anything but a 4xx, and ``_over_budget``
9
+ / ``_drain_order`` then DEMOTE it so it cannot starve healthy entries behind
10
+ it. Both are right, and together they bound head-of-line blocking — but not
11
+ TOTAL work: the entry is still retried in full on every single run, forever.
12
+
13
+ That was affordable when every drain was supervised: a 4-second in-hook pass,
14
+ or an operator watching a manual sweep. The ``hindsight-drain`` sidecar makes
15
+ it unaffordable. Its backlog budget is 3600s (``_backlog_budget_seconds``)
16
+ against a 900s cooldown, so a wedged agent spends ~80% of wall-clock draining,
17
+ unattended, against 4 lanes shared with live retains, reflect and
18
+ consolidation — indefinitely. The interim host stopgap recorded exactly that
19
+ shape while it ran: ``finn 280s drained=0 retried=3 STALLED``, ``carrie 564s
20
+ drained=0 retried=2 (lane busy)``.
21
+
22
+ So an entry past ``_attempt_ceiling()`` is PARKED. Parking is not retirement:
23
+ the entry stays on disk, keeps being swept for free by the reconcile pass, and
24
+ comes back in full under ``--force``. What it stops is paying ~168s of a
25
+ shared lane, again, for a POST that has already failed 20 times.
26
+
27
+ Every test here fails without the ``_park_broken`` call in the drain paths:
28
+ the parked entry is retained again, which is the unbounded-retry behaviour.
29
+ """
30
+
31
+ import io
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+ import unittest
37
+ import unittest.mock
38
+ from contextlib import redirect_stderr
39
+
40
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
41
+ if SCRIPTS_DIR not in sys.path:
42
+ sys.path.insert(0, SCRIPTS_DIR)
43
+
44
+ import drain_pending # noqa: E402
45
+ import lib.pending as pending # noqa: E402
46
+
47
+ from tests.test_pending_drops import ( # noqa: E402
48
+ CONFIG,
49
+ _QueueTempDirMixin,
50
+ _cd_doc,
51
+ _payload,
52
+ )
53
+
54
+
55
+ class CircuitBreakerTest(_QueueTempDirMixin, unittest.TestCase):
56
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
57
+
58
+ def setUp(self):
59
+ super().setUp()
60
+ os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
61
+ self.posted = []
62
+
63
+ def _seed(self, attempts_by_content):
64
+ """Queue one entry per ``{content: attempt_count}`` pair."""
65
+ clock = iter(1000.0 + i for i in range(50))
66
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
67
+ for i, content in enumerate(attempts_by_content):
68
+ pending.enqueue(
69
+ _payload(content=content, doc=_cd_doc(i)), RuntimeError("x")
70
+ )
71
+ for path, entry in pending.iter_entries():
72
+ entry["attempt_count"] = attempts_by_content[entry["content"]]
73
+ with open(path, "w", encoding="utf-8") as f:
74
+ json.dump(entry, f)
75
+
76
+ def _record_post(self, entry, timeout):
77
+ self.posted.append(entry["content"])
78
+
79
+ def _drain(self, present=False, **kw):
80
+ with unittest.mock.patch.object(
81
+ drain_pending, "_retry_one", self._record_post
82
+ ):
83
+ with unittest.mock.patch.object(
84
+ drain_pending, "_document_state", lambda e, timeout=30: present
85
+ ):
86
+ with redirect_stderr(io.StringIO()) as err:
87
+ summary = drain_pending.drain(CONFIG, **kw)
88
+ return summary, err.getvalue()
89
+
90
+ # ── the breaker itself ────────────────────────────────────────────
91
+
92
+ def test_the_unattended_backlog_drain_stops_retrying_a_wedged_entry(self):
93
+ ceiling = drain_pending._attempt_ceiling()
94
+ self._seed({"wedged": ceiling, "healthy": 1})
95
+
96
+ summary, err = self._drain(backlog=True, phase="drain")
97
+
98
+ self.assertEqual(
99
+ self.posted,
100
+ ["healthy"],
101
+ "an entry past the attempt ceiling must not be retained again",
102
+ )
103
+ self.assertEqual(summary["parked"], 1)
104
+ self.assertIn("parked 1 entries past the attempt ceiling", err)
105
+
106
+ def test_parking_is_not_retirement_the_entry_is_still_on_disk(self):
107
+ """The whole permanence gate rests on never destroying a memory. A
108
+ breaker that deleted, or promoted to ``.dead``, would trade the
109
+ wedge for the data loss the gate exists to prevent."""
110
+ ceiling = drain_pending._attempt_ceiling()
111
+ self._seed({"wedged": ceiling})
112
+
113
+ self._drain(backlog=True, phase="drain")
114
+
115
+ self.assertEqual(pending.count(), 1)
116
+ self.assertEqual(
117
+ [e["content"] for _p, e in pending.iter_entries()], ["wedged"]
118
+ )
119
+ self.assertEqual(
120
+ [n for n in os.listdir(self._dir) if n.endswith(".dead")],
121
+ [],
122
+ "parking must not retire the entry",
123
+ )
124
+
125
+ def test_a_parked_entry_is_still_reconciled_for_free(self):
126
+ """Parking withholds the expensive retry, not the cheap proof: if the
127
+ document DID land, the free presence pass still retires the entry."""
128
+ ceiling = drain_pending._attempt_ceiling()
129
+ self._seed({"wedged": ceiling})
130
+
131
+ summary, _ = self._drain(backlog=True, present=True, phase="reconcile")
132
+
133
+ self.assertEqual(summary["reconciled"], 1)
134
+ self.assertEqual(self.posted, [], "no POST was needed")
135
+ self.assertEqual(pending.count(), 0)
136
+
137
+ def test_force_retries_the_parked_entries_anyway(self):
138
+ """The escape hatch. Without it the breaker would be a one-way door,
139
+ and an operator who fixed the upstream could never replay."""
140
+ ceiling = drain_pending._attempt_ceiling()
141
+ self._seed({"wedged": ceiling, "healthy": 1})
142
+
143
+ summary, _ = self._drain(backlog=True, phase="drain", force=True)
144
+
145
+ self.assertEqual(sorted(self.posted), ["healthy", "wedged"])
146
+ self.assertEqual(summary["parked"], 0)
147
+
148
+ def test_an_entry_one_attempt_below_the_ceiling_still_drains(self):
149
+ """The boundary. A breaker that fired early would park entries the
150
+ demotion logic is still successfully draining."""
151
+ ceiling = drain_pending._attempt_ceiling()
152
+ self._seed({"nearly": ceiling - 1})
153
+
154
+ summary, _ = self._drain(backlog=True, phase="drain")
155
+
156
+ self.assertEqual(self.posted, ["nearly"])
157
+ self.assertEqual(summary["parked"], 0)
158
+
159
+ def test_the_in_hook_drain_parks_too(self):
160
+ """Same policy on the SessionStart path: a 4s budget spent on an
161
+ entry that has failed 20 times is 4s not spent on the rest."""
162
+ ceiling = drain_pending._attempt_ceiling()
163
+ self._seed({"wedged": ceiling, "healthy": 1})
164
+
165
+ summary, _ = self._drain()
166
+
167
+ self.assertEqual(self.posted, ["healthy"])
168
+ self.assertEqual(summary["parked"], 1)
169
+
170
+ def test_repeated_unattended_runs_never_touch_the_wedged_entry_again(self):
171
+ """The actual failure mode: not one wasted run, but every run from
172
+ here to the heat death of the container."""
173
+ ceiling = drain_pending._attempt_ceiling()
174
+ self._seed({"wedged": ceiling})
175
+
176
+ for _ in range(5):
177
+ self._drain(backlog=True, phase="drain")
178
+
179
+ self.assertEqual(self.posted, [], "5 unattended ticks, zero lane time")
180
+ self.assertEqual(pending.count(), 1, "and the memory is still there")
181
+
182
+ # ── the ceiling ───────────────────────────────────────────────────
183
+
184
+ def test_the_default_ceiling_is_a_multiple_of_the_attempt_budget(self):
185
+ self.assertEqual(
186
+ drain_pending._attempt_ceiling(),
187
+ pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
188
+ )
189
+ self.assertGreater(
190
+ drain_pending._attempt_ceiling(),
191
+ pending.MAX_ATTEMPTS,
192
+ "parking at the attempt budget would collide with demotion",
193
+ )
194
+
195
+ def test_the_ceiling_is_operator_tunable(self):
196
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "7"
197
+ self.assertEqual(drain_pending._attempt_ceiling(), 7)
198
+ self._seed({"wedged": 7, "healthy": 6})
199
+
200
+ summary, _ = self._drain(backlog=True, phase="drain")
201
+
202
+ self.assertEqual(self.posted, ["healthy"])
203
+ self.assertEqual(summary["parked"], 1)
204
+
205
+ def test_a_ceiling_below_the_attempt_budget_is_clamped_up(self):
206
+ """Below ``MAX_ATTEMPTS`` the breaker would park entries before the
207
+ ordinary retry policy has finished with them."""
208
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "1"
209
+ self.assertEqual(drain_pending._attempt_ceiling(), pending.MAX_ATTEMPTS)
210
+
211
+ def test_a_garbage_ceiling_falls_back_to_the_default(self):
212
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "not-a-number"
213
+ self.assertEqual(
214
+ drain_pending._attempt_ceiling(),
215
+ pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
216
+ )
217
+
218
+ def test_a_corrupt_attempt_counter_never_parks_and_never_raises(self):
219
+ """Same rule as ``_over_budget``: a hand-edited counter must not
220
+ decide policy, and must not blow up the drain path."""
221
+ self.assertFalse(drain_pending._circuit_broken({"attempt_count": "many"}))
222
+ self.assertFalse(drain_pending._circuit_broken({"attempt_count": None}))
223
+ self.assertFalse(drain_pending._circuit_broken({}))
224
+
225
+ # ── reporting: parking must be VISIBLE, on every path ─────────────
226
+
227
+ def _main(self, argv, present=False):
228
+ """Run the CLI entrypoint, capturing what an operator would see."""
229
+ with unittest.mock.patch.object(drain_pending, "load_config", lambda: CONFIG):
230
+ with unittest.mock.patch.object(
231
+ drain_pending, "_retry_one", self._record_post
232
+ ):
233
+ with unittest.mock.patch.object(
234
+ drain_pending, "_document_state", lambda e, timeout=30: present
235
+ ):
236
+ with redirect_stderr(io.StringIO()) as err:
237
+ rc = drain_pending.main(argv)
238
+ return rc, err.getvalue()
239
+
240
+ def test_an_in_hook_run_that_only_parks_still_says_so(self):
241
+ """THE OBSERVABILITY HOLE THE CIRCUIT BREAKER OPENS.
242
+
243
+ A run whose entire queue is past the ceiling parks everything and
244
+ does nothing else, so every other summary counter is 0. With
245
+ ``parked`` missing from ``main()``'s gate that run printed NOTHING —
246
+ rc=0, empty stdout, empty stderr — which is byte-identical to "the
247
+ queue was empty". The operator watching a queue that will not shrink
248
+ then has no way to tell "parked by the breaker, needs ``--force``"
249
+ from "ordinary backlog, drains on its own", because ``switchroom
250
+ doctor`` reads only the queue DIRECTORY and both look like a file.
251
+
252
+ The ``--backlog`` path narrates its own parking. This is the IN-HOOK
253
+ path, which is the one that runs on every session boot.
254
+ """
255
+ ceiling = drain_pending._attempt_ceiling()
256
+ self._seed({"a": ceiling, "b": ceiling, "c": ceiling})
257
+
258
+ rc, err = self._main([])
259
+
260
+ self.assertEqual(rc, 0)
261
+ self.assertEqual(self.posted, [], "nothing was retried — all parked")
262
+ self.assertNotEqual(
263
+ err.strip(), "", "a run that parked the whole queue must not be silent"
264
+ )
265
+ self.assertIn("parked=3", err)
266
+
267
+ def test_the_summary_line_reports_parked_alongside_other_work(self):
268
+ """The gate is not the only half. With ``parked`` in the gate but
269
+ absent from the printed line, a mixed run opens the gate on its other
270
+ counters and still never names the parked entries."""
271
+ ceiling = drain_pending._attempt_ceiling()
272
+ self._seed({"wedged": ceiling, "healthy": 1})
273
+
274
+ rc, err = self._main([], present=True)
275
+
276
+ self.assertEqual(rc, 0)
277
+ self.assertIn("reconciled=1", err)
278
+ self.assertIn("parked=1", err)
279
+
280
+
281
+ class CeilingArithmeticTest(_QueueTempDirMixin, unittest.TestCase):
282
+ """How long 20 attempts actually buys — measured, not asserted in prose.
283
+
284
+ ``ATTEMPT_CEILING_MULTIPLE``'s comment used to claim 20 attempts was
285
+ "most of a day … far past any transient upstream outage". It is not, and
286
+ the reason is the non-obvious part: attempts convert to wall-clock only
287
+ via the QUEUE SIZE. A drain run attempts each live entry at most once and
288
+ ``enqueue`` already records attempt 1, so the floor is 19 further runs —
289
+ under 5 hours at the sidecar's 900s cooldown. ``STALL_THRESHOLD`` only
290
+ rations attempts while entries are inside ``MAX_ATTEMPTS``; past it they
291
+ abstain from the stall guard, so a uniformly over-budget queue is
292
+ attempted in full on every run.
293
+
294
+ This measures the real figures and pins the comment's table to them, so
295
+ the prose cannot drift away from the code again: change the mechanism and
296
+ the measured column moves; delete the table and the parse fails.
297
+ """
298
+
299
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
300
+
301
+ #: Rows the module comment must carry: queue size → drain runs until the
302
+ #: WHOLE queue is parked, under a total upstream outage.
303
+ SIZES = (1, 4, 13, 30)
304
+
305
+ #: The sidecar's default cooldown (``profiles/_base/start.sh.hbs``); the
306
+ #: comment's hours column is runs x this.
307
+ COOLDOWN_S = 900
308
+
309
+ def setUp(self):
310
+ super().setUp()
311
+ os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
312
+ # No p95 backoff, no pacing: this test counts RUNS, not seconds.
313
+ os.environ.pop("HINDSIGHT_DRAIN_P95_CMD", None)
314
+
315
+ def _runs_until_wholly_parked(self, n: int) -> int:
316
+ # Own queue dir per call, so several sizes can be measured in one
317
+ # test without one size's entries contaminating the next. The mixin
318
+ # owns HINDSIGHT_PENDING_DIR and restores it in tearDown; the
319
+ # directory itself lives under the mixin's tmp root and goes with it.
320
+ os.environ["HINDSIGHT_PENDING_DIR"] = os.path.join(
321
+ self._tmp, f"pending-retains-{n}"
322
+ )
323
+ clock = iter(1000.0 + i for i in range(n + 10))
324
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
325
+ for i in range(n):
326
+ pending.enqueue(
327
+ _payload(content=f"e{i}", doc=_cd_doc(i)),
328
+ ConnectionError("upstream down"),
329
+ )
330
+
331
+ def always_fails(entry, timeout=30):
332
+ raise ConnectionError("upstream down")
333
+
334
+ runs = 0
335
+ # Generous ceiling on the loop itself: a mechanism change that made
336
+ # parking unreachable must fail loudly rather than hang CI.
337
+ while runs < 500:
338
+ runs += 1
339
+ with unittest.mock.patch.object(
340
+ drain_pending, "_retry_one", always_fails
341
+ ):
342
+ with unittest.mock.patch.object(
343
+ drain_pending, "_document_state", lambda e, timeout=30: False
344
+ ):
345
+ with redirect_stderr(io.StringIO()):
346
+ summary = drain_pending.drain(
347
+ CONFIG, backlog=True, phase="drain"
348
+ )
349
+ if summary["parked"] == n:
350
+ return runs
351
+ self.fail(f"a {n}-entry queue never parked wholly within 500 runs")
352
+
353
+ def _documented_table(self) -> dict:
354
+ """Parse the table out of ``ATTEMPT_CEILING_MULTIPLE``'s comment."""
355
+ with open(drain_pending.__file__, encoding="utf-8") as f:
356
+ src = f.read()
357
+ rows = re.findall(r"^#: (\d+)\s+(\d+)\s+([\d.]+) h$", src, re.M)
358
+ return {int(q): (int(r), float(h)) for q, r, h in rows}
359
+
360
+ def test_the_documented_table_is_what_the_code_actually_does(self):
361
+ documented = self._documented_table()
362
+ self.assertEqual(
363
+ sorted(documented),
364
+ sorted(self.SIZES),
365
+ "ATTEMPT_CEILING_MULTIPLE's comment must document exactly these "
366
+ "queue sizes — the prose is the deliverable here",
367
+ )
368
+ for n in self.SIZES:
369
+ with self.subTest(queue=n):
370
+ measured = self._runs_until_wholly_parked(n)
371
+ runs, hours = documented[n]
372
+ self.assertEqual(
373
+ measured,
374
+ runs,
375
+ f"a {n}-entry queue parks wholly in {measured} runs, but "
376
+ f"the comment says {runs}",
377
+ )
378
+ self.assertAlmostEqual(
379
+ hours, runs * self.COOLDOWN_S / 3600.0, places=1
380
+ )
381
+
382
+ def test_a_small_queue_parks_well_inside_one_overnight_outage(self):
383
+ """The claim the old comment made, stated as the outcome it is not.
384
+
385
+ 4 entries — a healthy agent — park entirely in 6 hours. So a single
386
+ overnight upstream outage CAN park a small queue wholesale, which is
387
+ exactly what "far past any transient upstream outage" denied. Pinned
388
+ as an outcome so nobody re-asserts the denial.
389
+ """
390
+ measured = self._runs_until_wholly_parked(4)
391
+ hours = measured * self.COOLDOWN_S / 3600.0
392
+ self.assertLess(
393
+ hours,
394
+ 12.0,
395
+ "a 4-entry queue parking in under 12h is the accepted trade — if "
396
+ "this ever became untrue the comment must be re-measured too",
397
+ )
398
+
399
+
400
+ if __name__ == "__main__":
401
+ unittest.main()