switchroom 0.19.19 → 0.19.22

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 (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
@@ -26,6 +26,8 @@ import tempfile
26
26
  import time
27
27
  import unittest
28
28
  import unittest.mock
29
+ import urllib.error
30
+ import urllib.request
29
31
  from contextlib import redirect_stderr
30
32
 
31
33
  SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
@@ -1064,24 +1066,78 @@ class BacklogDrainTest(_QueueTempDirMixin, unittest.TestCase):
1064
1066
  )
1065
1067
  self.assertEqual(pending.count(), 12, "stalled entries stay queued")
1066
1068
 
1067
- def test_backlog_ages_entries_toward_dead_like_the_sequential_drain(self):
1069
+ def _exhaust_attempts(self):
1070
+ """Queue one entry already sitting on the MAX_ATTEMPTS boundary."""
1068
1071
  self._queue(1)
1069
1072
  path, entry = pending.iter_entries()[0]
1070
1073
  entry["attempt_count"] = pending.MAX_ATTEMPTS
1071
1074
  with open(path, "w", encoding="utf-8") as f:
1072
1075
  json.dump(entry, f)
1076
+ return path
1073
1077
 
1078
+ def _drain_failing_with(self, error):
1074
1079
  def always_fail(entry, timeout):
1075
- raise ConnectionError("upstream down")
1080
+ raise error
1076
1081
 
1077
1082
  with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
1078
1083
  with redirect_stderr(io.StringIO()):
1079
- summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1084
+ return drain_pending.drain_backlog(CONFIG, phase="drain")
1085
+
1086
+ def test_backlog_ages_entries_toward_dead_like_the_sequential_drain(self):
1087
+ """A PERMANENT failure past MAX_ATTEMPTS still retires the entry."""
1088
+ path = self._exhaust_attempts()
1089
+ summary = self._drain_failing_with(
1090
+ RuntimeError('HTTP 400 from http://h/v1/x: {"detail":"bad payload"}')
1091
+ )
1080
1092
 
1081
1093
  self.assertEqual(summary["dead"], 1)
1082
1094
  self.assertTrue(os.path.exists(path + ".dead"))
1083
1095
  self.assertFalse(os.path.exists(path))
1084
1096
 
1097
+ def test_extraction_500_past_max_attempts_never_kills_the_memory(self):
1098
+ """The regression this gate exists for.
1099
+
1100
+ An HTTP 500 "Fact extraction failed … JSONDecodeError" means the
1101
+ extraction model returned an empty or non-JSON completion on THAT
1102
+ sampling run. The identical content persists fine on a later attempt,
1103
+ so exhausting the attempt budget on it must NOT retire the memory.
1104
+ Before the ``is_permanent_failure`` gate this drain produced
1105
+ ``dead=1`` and removed the queue entry.
1106
+ """
1107
+ path = self._exhaust_attempts()
1108
+ summary = self._drain_failing_with(
1109
+ RuntimeError(
1110
+ "HTTP 500 from http://h/v1/x: "
1111
+ '{"detail":"Fact extraction failed: 1/1 chunks failed. '
1112
+ "First failures: chunk 0: JSONDecodeError: Expecting value: "
1113
+ 'line 1 column 1 (char 0)"}'
1114
+ )
1115
+ )
1116
+
1117
+ self.assertEqual(summary["dead"], 0, "a 5xx must never retire a memory")
1118
+ self.assertEqual(summary["retried"], 1)
1119
+ self.assertFalse(os.path.exists(path + ".dead"))
1120
+ self.assertTrue(os.path.exists(path), "the queued memory survives")
1121
+ self.assertEqual(pending.count(), 1)
1122
+
1123
+ def test_transient_transport_failure_past_max_attempts_stays_queued(self):
1124
+ """Timeouts / connection errors are transient too — never ``.dead``."""
1125
+ for error in (ConnectionError("upstream down"), TimeoutError("timed out")):
1126
+ with self.subTest(error=type(error).__name__):
1127
+ path = self._exhaust_attempts()
1128
+ summary = self._drain_failing_with(error)
1129
+ self.assertEqual(summary["dead"], 0)
1130
+ self.assertTrue(os.path.exists(path))
1131
+ os.unlink(path)
1132
+
1133
+ def test_attempt_count_keeps_climbing_past_the_budget_on_transients(self):
1134
+ """The counter still records reality; only ``.dead`` is gated."""
1135
+ path = self._exhaust_attempts()
1136
+ self._drain_failing_with(ConnectionError("upstream down"))
1137
+ with open(path, encoding="utf-8") as f:
1138
+ entry = json.load(f)
1139
+ self.assertEqual(entry["attempt_count"], pending.MAX_ATTEMPTS + 1)
1140
+
1085
1141
  def test_dry_run_issues_no_writes(self):
1086
1142
  self._queue(3)
1087
1143
  with unittest.mock.patch.object(
@@ -1095,6 +1151,96 @@ class BacklogDrainTest(_QueueTempDirMixin, unittest.TestCase):
1095
1151
  self.assertEqual(pending.count(), 3, "a dry run must not delete anything")
1096
1152
 
1097
1153
 
1154
+ class TransportFailureEndToEndTest(_QueueTempDirMixin, unittest.TestCase):
1155
+ """The permanence gate over the REAL client wrapping (#3669 follow-up).
1156
+
1157
+ Every other permanence test injects a synthetic exception at
1158
+ ``drain_pending._retry_one`` — i.e. ABOVE ``lib/client.py``. That skips
1159
+ the layer which actually decides what a fault LOOKS like to
1160
+ ``pending.is_permanent_failure``: ``client._request`` catches only
1161
+ ``HTTPError`` and re-raises it as ``RuntimeError("HTTP {code} from …")``
1162
+ (lib/client.py:91-97), while a bare ``URLError`` propagates untouched.
1163
+ So the gate's input shape is produced there and asserted nowhere.
1164
+
1165
+ These tests patch ``urllib.request.urlopen`` instead and drive the real
1166
+ drain, keeping that wrapping in the loop. #3669 added an equivalent
1167
+ end-to-end case, but to ``vendor/hindsight-memory/tests/`` — which no
1168
+ workflow discovers (ci-tests-python.yml runs ``unittest discover
1169
+ tests/`` from ``vendor/hindsight-memory/scripts``), so it never ran.
1170
+ """
1171
+
1172
+ def _queue_one(self):
1173
+ pending.enqueue(_payload(content="turn-0", doc=_cd_doc(0)), RuntimeError("boom"))
1174
+ self.assertEqual(pending.count(), 1)
1175
+
1176
+ def _exhaust_attempts(self):
1177
+ """One entry sitting on the MAX_ATTEMPTS boundary."""
1178
+ self._queue_one()
1179
+ path, entry = pending.iter_entries()[0]
1180
+ entry["attempt_count"] = pending.MAX_ATTEMPTS
1181
+ with open(path, "w", encoding="utf-8") as f:
1182
+ json.dump(entry, f)
1183
+ return path
1184
+
1185
+ @staticmethod
1186
+ def _http_error(code):
1187
+ def raise_it(*_a, **_kw):
1188
+ raise urllib.error.HTTPError(
1189
+ "http://127.0.0.1:9/none/v1/retain",
1190
+ code,
1191
+ "rejected",
1192
+ {},
1193
+ io.BytesIO(b'{"detail":"rejected"}'),
1194
+ )
1195
+
1196
+ return raise_it
1197
+
1198
+ def _drain_with_urlopen(self, side_effect):
1199
+ with unittest.mock.patch("urllib.request.urlopen", side_effect=side_effect):
1200
+ with redirect_stderr(io.StringIO()):
1201
+ return drain_pending.drain_backlog(CONFIG, phase="drain")
1202
+
1203
+ def test_outage_past_max_attempts_keeps_the_memory(self):
1204
+ """A dead daemon raises bare ``URLError`` — no ``.code``, so transient.
1205
+
1206
+ Before the gate this retired the entry at MAX_ATTEMPTS: an outage
1207
+ destroyed memory that was perfectly saveable.
1208
+ """
1209
+ path = self._exhaust_attempts()
1210
+
1211
+ def down(*_a, **_kw):
1212
+ raise urllib.error.URLError("still down")
1213
+
1214
+ summary = self._drain_with_urlopen(down)
1215
+ self.assertEqual(summary["dead"], 0, "an outage must never retire a memory")
1216
+ self.assertTrue(os.path.exists(path), "the queued memory survives")
1217
+ self.assertFalse(os.path.exists(path + ".dead"))
1218
+
1219
+ def test_rate_limit_past_max_attempts_keeps_the_memory(self):
1220
+ """429 wears a 4xx code but is the most transient fault there is.
1221
+
1222
+ This is the case the raw ``400 <= code < 500`` rule would get
1223
+ wrong, asserted through the real ``HTTP {code} from …`` wrapping.
1224
+ """
1225
+ path = self._exhaust_attempts()
1226
+ summary = self._drain_with_urlopen(self._http_error(429))
1227
+ self.assertEqual(summary["dead"], 0, "a rate limit must never retire a memory")
1228
+ self.assertTrue(os.path.exists(path))
1229
+ self.assertFalse(os.path.exists(path + ".dead"))
1230
+
1231
+ def test_server_rejection_past_max_attempts_still_retires(self):
1232
+ """The other half of the gate: a fix that never retires is a leak.
1233
+
1234
+ A positively-rejected payload must still age to ``.dead``, or the
1235
+ queue grows without bound.
1236
+ """
1237
+ path = self._exhaust_attempts()
1238
+ summary = self._drain_with_urlopen(self._http_error(400))
1239
+ self.assertEqual(summary["dead"], 1, "a 4xx rejection must still retire")
1240
+ self.assertTrue(os.path.exists(path + ".dead"))
1241
+ self.assertFalse(os.path.exists(path))
1242
+
1243
+
1098
1244
  class ArchiveFailureNeverDeletesTest(_QueueTempDirMixin, unittest.TestCase):
1099
1245
  """A failure to ARCHIVE is not a licence to DELETE (#3599 review R3-M1).
1100
1246
 
@@ -1696,7 +1842,15 @@ class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1696
1842
  """
1697
1843
  os.environ.pop("HINDSIGHT_DRAIN_BACKLOG_TIMEOUT", None)
1698
1844
  os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
1699
- self.assertEqual(drain_pending._backlog_timeout(), 280)
1845
+ # Assert the DERIVATION, per this test's own docstring: the drain
1846
+ # deadline is retain_client_deadline(), whatever that currently is.
1847
+ # It was pinned to the literal 280 here, which meant the test went
1848
+ # stale the moment the deadline became derived from the litellm
1849
+ # routing chain (280 -> 310) instead of being hand-set.
1850
+ self.assertEqual(
1851
+ drain_pending._backlog_timeout(),
1852
+ int(retain_split.retain_client_deadline()),
1853
+ )
1700
1854
 
1701
1855
  os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "600"
1702
1856
  try:
@@ -0,0 +1,105 @@
1
+ """``pending.is_permanent_failure`` — the gate on retiring a memory.
2
+
3
+ Lives under ``scripts/tests/`` because that is the only python test
4
+ directory CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest
5
+ discover tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
6
+
7
+ The rule under test is asymmetric on purpose. Misclassifying a permanent
8
+ failure as retryable costs a re-POST (an upsert — time). Misclassifying a
9
+ transient failure as permanent costs the user's memory. So only a positively
10
+ identified client-side 4xx is permanent; everything else, including anything
11
+ unrecognised, is retryable.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import unittest
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from lib.pending import is_permanent_failure # noqa: E402
21
+
22
+
23
+ def _http(code, body=""):
24
+ """The shape ``client.HindsightClient._request`` raises."""
25
+ return RuntimeError(f"HTTP {code} from http://hindsight/v1/x: {body}")
26
+
27
+
28
+ class TestIsPermanentFailure(unittest.TestCase):
29
+ def test_client_errors_are_permanent(self):
30
+ for code in (400, 401, 403, 404, 409, 413, 422):
31
+ with self.subTest(code=code):
32
+ self.assertTrue(is_permanent_failure(_http(code)))
33
+
34
+ def test_transient_4xx_are_not_permanent(self):
35
+ """408/425/429 wear a 4xx status but describe a transient state."""
36
+ for code in (408, 425, 429):
37
+ with self.subTest(code=code):
38
+ self.assertFalse(is_permanent_failure(_http(code)))
39
+
40
+ def test_server_errors_are_retryable(self):
41
+ for code in (500, 502, 503, 504):
42
+ with self.subTest(code=code):
43
+ self.assertFalse(is_permanent_failure(_http(code)))
44
+
45
+ def test_extraction_failure_500_is_retryable(self):
46
+ """The measured dominant failure (2026-07-26 fleet backlog).
47
+
48
+ Both observed variants are one bad sampling run, not bad content:
49
+ an empty completion, and a numbered prose list where JSON was asked
50
+ for. Neither is evidence the memory can never be persisted.
51
+ """
52
+ empty = _http(
53
+ 500,
54
+ '{"detail":"Fact extraction failed: 1/1 chunks failed. First '
55
+ 'failures: chunk 0: JSONDecodeError: Expecting value: line 1 '
56
+ 'column 1 (char 0)"}',
57
+ )
58
+ prose = _http(
59
+ 500,
60
+ '{"detail":"Fact extraction failed: 1/1 chunks failed. First '
61
+ 'failures: chunk 0: JSONDecodeError: Extra data: line 1 column 2 '
62
+ '(char 1)"}',
63
+ )
64
+ self.assertFalse(is_permanent_failure(empty))
65
+ self.assertFalse(is_permanent_failure(prose))
66
+
67
+ def test_transport_failures_are_retryable(self):
68
+ for err in (
69
+ TimeoutError("timed out"),
70
+ ConnectionError("connection reset"),
71
+ OSError("network unreachable"),
72
+ ):
73
+ with self.subTest(err=type(err).__name__):
74
+ self.assertFalse(is_permanent_failure(err))
75
+
76
+ def test_unclassifiable_error_is_retryable(self):
77
+ """The fail-safe direction: unknown means keep the memory."""
78
+ self.assertFalse(is_permanent_failure(RuntimeError("something odd")))
79
+ self.assertFalse(is_permanent_failure(RuntimeError("HTTP from x")))
80
+ self.assertFalse(is_permanent_failure(RuntimeError("HTTP notanumber")))
81
+
82
+ def test_status_read_from_a_chained_cause(self):
83
+ """A live ``urllib.error.HTTPError`` chained on ``__cause__``."""
84
+
85
+ class _FakeHTTPError(Exception):
86
+ code = 404
87
+
88
+ wrapped = RuntimeError("upstream rejected the retain")
89
+ wrapped.__cause__ = _FakeHTTPError()
90
+ self.assertTrue(is_permanent_failure(wrapped))
91
+
92
+ wrapped5xx = RuntimeError("upstream blew up")
93
+ cause = _FakeHTTPError()
94
+ cause.code = 503
95
+ wrapped5xx.__cause__ = cause
96
+ self.assertFalse(is_permanent_failure(wrapped5xx))
97
+
98
+ def test_direct_code_attribute_wins(self):
99
+ err = OSError("boom")
100
+ err.code = 400
101
+ self.assertTrue(is_permanent_failure(err))
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()
@@ -0,0 +1,300 @@
1
+ """The drain must never wedge on entries the permanence gate keeps forever.
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. ``pending.is_permanent_failure`` stopped the drain retiring a
8
+ memory to ``.dead`` on a transient failure — correct, because a flaky
9
+ extraction model was destroying memories that were never unsaveable. But
10
+ ``.dead`` was doing double duty: it was the honesty policy AND it was the
11
+ queue's only un-wedging mechanism. An entry that fails transiently on every
12
+ drain (an oversized payload enqueued before #3610 split them, say, which
13
+ ``lib/pending``'s own docstring notes "still never drains until it is split")
14
+ is now immortal. ``iter_entries()`` is oldest-first and backlog concurrency
15
+ defaults to 1, so those immortal entries sit at the head of every run, and
16
+ three of them trip ``STALL_THRESHOLD`` before anything behind them is tried.
17
+
18
+ Without the ``_drain_order`` / ``_over_budget`` demotions this file pins,
19
+ that produced ``stalled=True, drained=0`` on EVERY run, forever, with healthy
20
+ entries queued behind and never attempted.
21
+ """
22
+
23
+ import io
24
+ import json
25
+ import os
26
+ import sys
27
+ import time
28
+ import unittest
29
+ import unittest.mock
30
+ from contextlib import redirect_stderr
31
+
32
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
33
+ if SCRIPTS_DIR not in sys.path:
34
+ sys.path.insert(0, SCRIPTS_DIR)
35
+
36
+ import drain_pending # noqa: E402
37
+ import lib.pending as pending # noqa: E402
38
+
39
+ from tests.test_pending_drops import ( # noqa: E402
40
+ CONFIG,
41
+ _QueueTempDirMixin,
42
+ _cd_doc,
43
+ _payload,
44
+ )
45
+
46
+ #: The immortal entries: they fail with a TRANSIENT error on every drain, so
47
+ #: the permanence gate correctly refuses to retire them — forever.
48
+ POISON = {"c0", "c1", "c2"}
49
+
50
+
51
+ class DrainWedgeTest(_QueueTempDirMixin, unittest.TestCase):
52
+ def _seed(self, n, attempts_for):
53
+ # Entry filenames are `<unix-ms>-<uuid>.json` and FIFO order is the
54
+ # lexicographic sort of that name, so pin the clock to make "oldest"
55
+ # deterministic rather than a uuid tie-break.
56
+ clock = iter(1000.0 + i for i in range(50))
57
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
58
+ for i in range(n):
59
+ pending.enqueue(
60
+ _payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x")
61
+ )
62
+ for path, entry in pending.iter_entries():
63
+ entry["attempt_count"] = attempts_for(entry["content"])
64
+ with open(path, "w", encoding="utf-8") as f:
65
+ json.dump(entry, f)
66
+
67
+ @staticmethod
68
+ def _retry(entry, timeout):
69
+ if entry["content"] in POISON:
70
+ raise TimeoutError("client deadline exceeded")
71
+ return None
72
+
73
+ def _drain_once(self):
74
+ # `_document_state` mirrors `_retry`: a healthy entry's POST lands and
75
+ # the confirming GET sees it, a poison entry never persists. Anything
76
+ # else would let the free reconcile pass retire the poison entries and
77
+ # hide the very wedge under test.
78
+ with unittest.mock.patch.object(drain_pending, "_retry_one", self._retry):
79
+ with unittest.mock.patch.object(
80
+ drain_pending,
81
+ "_document_state",
82
+ lambda e, timeout=30: e["content"] not in POISON,
83
+ ):
84
+ with redirect_stderr(io.StringIO()):
85
+ return drain_pending.drain_backlog(CONFIG, phase="drain")
86
+
87
+ def test_poison_heads_do_not_starve_healthy_entries_behind_them(self):
88
+ """The common shape: a few immortal entries in front of good ones.
89
+
90
+ They are the OLDEST by construction (they have been failing longest),
91
+ so oldest-first ordering puts them exactly where they do most damage.
92
+ """
93
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
94
+
95
+ summary = self._drain_once()
96
+
97
+ self.assertEqual(summary["drained"], 3, "healthy entries must drain")
98
+ self.assertFalse(summary["stalled"], "3 immortal entries must not stall the run")
99
+ self.assertEqual(pending.count(), 3, "only the immortal entries remain queued")
100
+
101
+ def test_whole_queue_over_budget_still_drains_after_recovery(self):
102
+ """The shape that demotion-by-ORDERING alone does not fix.
103
+
104
+ An upstream down for a week takes every entry past ``MAX_ATTEMPTS``.
105
+ When it recovers, the over-budget partition holds the entire queue, so
106
+ ordering is a no-op and the poison entries are back at the head. Only
107
+ the stall-guard abstention (``_over_budget``) gets past them.
108
+ """
109
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS)
110
+
111
+ summary = self._drain_once()
112
+
113
+ self.assertEqual(summary["drained"], 3, "recovered entries must drain")
114
+ self.assertFalse(summary["stalled"])
115
+ self.assertEqual(pending.count(), 3)
116
+
117
+ def test_repeated_runs_converge_instead_of_repeating_a_zero_drain(self):
118
+ """The wedge was defined by NON-convergence: identical useless runs."""
119
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
120
+
121
+ depths = []
122
+ for _ in range(3):
123
+ self._drain_once()
124
+ depths.append(pending.count())
125
+
126
+ self.assertEqual(depths, [3, 3, 3], "converges after run 0 and stays there")
127
+
128
+ def test_a_genuine_upstream_outage_still_stalls(self):
129
+ """The guard rail on the fix: abstention must not disarm the guard.
130
+
131
+ Entries INSIDE their attempt budget still vote, so a real outage is
132
+ caught after ``STALL_THRESHOLD`` and the drain stops hammering it.
133
+ """
134
+ self._seed(6, lambda c: 0)
135
+
136
+ def all_fail(entry, timeout):
137
+ raise TimeoutError("upstream down")
138
+
139
+ with unittest.mock.patch.object(drain_pending, "_retry_one", all_fail):
140
+ with unittest.mock.patch.object(
141
+ drain_pending, "_document_state", lambda e, timeout=30: None
142
+ ):
143
+ with redirect_stderr(io.StringIO()):
144
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
145
+
146
+ self.assertTrue(summary["stalled"], "a real outage must still stall")
147
+ self.assertEqual(
148
+ summary["retried"], 3, "and stop at STALL_THRESHOLD, not run the queue"
149
+ )
150
+
151
+ def test_the_entry_crossing_the_budget_on_this_failure_still_votes(self):
152
+ """``update_attempt`` mutates ``attempt_count`` in place.
153
+
154
+ So the abstention check must read the counter BEFORE
155
+ ``_record_failure``. Reading it after would let an entry at
156
+ ``MAX_ATTEMPTS - 1`` abstain on the failure that takes it to the
157
+ budget, quietly weakening the guard for ordinary entries. Three such
158
+ entries are a genuine outage signal and must still stall.
159
+ """
160
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS - 1)
161
+
162
+ def all_fail(entry, timeout):
163
+ raise TimeoutError("upstream down")
164
+
165
+ with unittest.mock.patch.object(drain_pending, "_retry_one", all_fail):
166
+ with unittest.mock.patch.object(
167
+ drain_pending, "_document_state", lambda e, timeout=30: None
168
+ ):
169
+ with redirect_stderr(io.StringIO()):
170
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
171
+
172
+ self.assertTrue(summary["stalled"])
173
+ self.assertEqual(summary["retried"], 3)
174
+
175
+
176
+ def test_the_in_hook_sequential_drain_is_protected_too(self):
177
+ """``drain()`` is the path that runs on EVERY boot, not ``--backlog``.
178
+
179
+ It carries its own copy of the stall guard, so it needs its own
180
+ demotion or the wedge just moves to the path that matters most: the
181
+ in-hook budget is ~4s, so a single always-timing-out entry at the head
182
+ consumes the whole run before anything else is reached.
183
+ """
184
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
185
+
186
+ with unittest.mock.patch.object(drain_pending, "_retry_one", self._retry):
187
+ with unittest.mock.patch.object(
188
+ drain_pending,
189
+ "_document_state",
190
+ lambda e, timeout=30: False,
191
+ ):
192
+ with redirect_stderr(io.StringIO()):
193
+ summary = drain_pending.drain(CONFIG)
194
+
195
+ self.assertEqual(summary["drained"], 3, "healthy entries drain in-hook too")
196
+ self.assertFalse(summary["stalled"])
197
+ self.assertEqual(pending.count(), 3)
198
+
199
+ def test_poison_heads_do_not_eat_the_in_hook_budget(self):
200
+ """Pins the ORDERING specifically, which the abstention alone does not.
201
+
202
+ Abstaining from the stall guard stops a poison entry ENDING the run,
203
+ but not its consuming the run. The in-hook budget is ~4s of wall clock
204
+ and a chronically failing entry fails by TIMING OUT, so three of them
205
+ at the head burn the whole budget and the loop exits on
206
+ `budget_exceeded` with the healthy entries behind them untouched —
207
+ no stall guard involved.
208
+
209
+ Mutation-checked: reverting only the ``_drain_order`` call in
210
+ ``drain()`` (leaving the abstention intact) fails this test and no
211
+ other in the suite.
212
+ """
213
+ os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "0.5"
214
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
215
+
216
+ def slow_poison(entry, timeout):
217
+ if entry["content"] in POISON:
218
+ time.sleep(0.25) # a client deadline being burned
219
+ raise TimeoutError("client deadline exceeded")
220
+ return None
221
+
222
+ with unittest.mock.patch.object(drain_pending, "_retry_one", slow_poison):
223
+ with unittest.mock.patch.object(
224
+ drain_pending, "_document_state", lambda e, timeout=30: False
225
+ ):
226
+ with redirect_stderr(io.StringIO()):
227
+ summary = drain_pending.drain(CONFIG)
228
+
229
+ self.assertEqual(
230
+ summary["drained"], 3, "healthy entries must be reached before the budget"
231
+ )
232
+
233
+ def test_poison_heads_do_not_eat_the_backlog_budget(self):
234
+ """The same budget-consumption wedge on the ``--backlog`` path.
235
+
236
+ The tests above reach the backlog drain through the STALL guard, which
237
+ the abstention alone already defeats — so none of them notice if
238
+ ``_drain_backlog_impl`` stops ordering. But its ordering is load-
239
+ bearing for the same reason the in-hook one is, only slower: waves are
240
+ width 1 (``_backlog_concurrency``) and a chronically failing entry
241
+ burns ``HINDSIGHT_DRAIN_BACKLOG_TIMEOUT`` (280s) before failing, so
242
+ ~13 of them at the head exhaust the 1h budget and the run ends on
243
+ `budget_exceeded` having retained nothing — again with no stall guard
244
+ involved, and again identically on the next run.
245
+
246
+ Scaled down here: 3 poison entries at 0.6s against a 1.0s budget (the
247
+ floor ``_backlog_budget_seconds`` clamps to). Ordered correctly the
248
+ healthy entries cost ~0s and all drain; ordered oldest-first the
249
+ budget is gone after the second poison entry and none do.
250
+
251
+ Mutation-checked: reverting only the ``_drain_order`` call in
252
+ ``_drain_backlog_impl`` fails this test and no other in the suite.
253
+ """
254
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "1.0"
255
+ self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
256
+
257
+ def slow_poison(entry, timeout):
258
+ if entry["content"] in POISON:
259
+ time.sleep(0.6) # a backlog-mode deadline being burned
260
+ raise TimeoutError("client deadline exceeded")
261
+ return None
262
+
263
+ with unittest.mock.patch.object(drain_pending, "_retry_one", slow_poison):
264
+ with unittest.mock.patch.object(
265
+ drain_pending,
266
+ "_document_state",
267
+ lambda e, timeout=30: e["content"] not in POISON,
268
+ ):
269
+ with redirect_stderr(io.StringIO()):
270
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
271
+
272
+ self.assertEqual(
273
+ summary["drained"], 3, "healthy entries must be reached before the budget"
274
+ )
275
+ self.assertFalse(summary["stalled"], "this wedge is budget, not stall")
276
+
277
+
278
+ class DrainOrderTest(unittest.TestCase):
279
+ """``_drain_order`` is a STABLE partition — age order survives inside it."""
280
+
281
+ def test_over_budget_entries_go_last_and_keep_relative_age(self):
282
+ entries = [
283
+ ("p0", {"attempt_count": pending.MAX_ATTEMPTS}),
284
+ ("p1", {"attempt_count": 0}),
285
+ ("p2", {"attempt_count": pending.MAX_ATTEMPTS + 4}),
286
+ ("p3", {"attempt_count": 2}),
287
+ ]
288
+ self.assertEqual(
289
+ [p for p, _ in drain_pending._drain_order(entries)],
290
+ ["p1", "p3", "p0", "p2"],
291
+ )
292
+
293
+ def test_a_missing_or_corrupt_counter_is_treated_as_fresh(self):
294
+ for value in ({}, {"attempt_count": None}, {"attempt_count": "many"}):
295
+ with self.subTest(value=value):
296
+ self.assertFalse(drain_pending._over_budget(value))
297
+
298
+
299
+ if __name__ == "__main__":
300
+ unittest.main()