switchroom 0.19.18 → 0.19.19

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 (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,2121 @@
1
+ """Queue eviction/dedupe + two-phase backlog drain (switchroom #3596).
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.
9
+
10
+ The behaviours pinned here are the ones a well-meaning refactor would
11
+ otherwise undo:
12
+ * eviction sheds the OLDEST entry, never the incoming newest one;
13
+ * reconcile-before-retain, so an already-durable document is not
14
+ re-extracted at LLM cost;
15
+ * commit-before-delete -- a 200 is an ack, not proof;
16
+ * backlog concurrency defaults to 1 (the model pool is fleet-shared);
17
+ * the stall guard does not overshoot under concurrency.
18
+ """
19
+
20
+ import io
21
+ import json
22
+ import os
23
+ import shutil
24
+ import sys
25
+ import tempfile
26
+ import time
27
+ import unittest
28
+ import unittest.mock
29
+ from contextlib import redirect_stderr
30
+
31
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
32
+ if SCRIPTS_DIR not in sys.path:
33
+ sys.path.insert(0, SCRIPTS_DIR)
34
+
35
+ import drain_pending # noqa: E402
36
+ import lib.pending as pending # noqa: E402
37
+ import lib.retain_split as retain_split # noqa: E402
38
+
39
+ CONFIG = {"debug": False}
40
+
41
+ #: A session id of the shape ``retain.py`` sees (a plain uuid).
42
+ SESSION = "11111111-2222-4333-8444-555555555555"
43
+
44
+
45
+ def _uuid(n: int) -> str:
46
+ h = f"{n:032x}"
47
+ return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
48
+
49
+
50
+ def _cd_doc(i: int = 1) -> str:
51
+ """A POST-#3244 content-derived document_id.
52
+
53
+ ``retain.slice_document_id`` → ``{session}-r{start_uuid}-{end_uuid}``.
54
+ Presence-only reconcile is gated on this shape, so the drain tests must
55
+ use it rather than a stand-in like ``doc-1``.
56
+ """
57
+ return f"{SESSION}-r{_uuid(i)}-{_uuid(i + 1000)}"
58
+
59
+
60
+ def _payload(bank="bank-a", content="hello", doc=None):
61
+ if doc is None:
62
+ doc = _cd_doc(1)
63
+ return {
64
+ "api_url": "http://127.0.0.1:9/none",
65
+ "api_token": None,
66
+ "bank_id": bank,
67
+ "document_id": doc,
68
+ "content": content,
69
+ "context": None,
70
+ "metadata": {},
71
+ "tags": None,
72
+ }
73
+
74
+
75
+ class _QueueTempDirMixin:
76
+ #: env knobs a case may set; restored in tearDown so none can leak.
77
+ ENV_KEYS = (
78
+ "HINDSIGHT_PENDING_DIR",
79
+ "HINDSIGHT_PENDING_EVICTED_DIR",
80
+ "HINDSIGHT_PENDING_RECONCILED_DIR",
81
+ # Absent from this tuple the budget/clamp cases ran against whatever
82
+ # the ambient env said — vacuously green in CI, where nothing sets it.
83
+ "HINDSIGHT_DRAIN_TIMEOUT",
84
+ "HINDSIGHT_DRAIN_BUDGET_S",
85
+ "HINDSIGHT_DRAIN_BACKLOG_BUDGET_S",
86
+ "HINDSIGHT_DRAIN_BACKLOG_TIMEOUT",
87
+ "HINDSIGHT_DRAIN_CONCURRENCY",
88
+ "HINDSIGHT_DRAIN_SLEEP_S",
89
+ "HINDSIGHT_DRAIN_P95_CMD",
90
+ "HINDSIGHT_DRAIN_P95_BACKOFF_MS",
91
+ # `_backlog_timeout` derives its default from this (#3610), so an
92
+ # ambient value would silently move the assertions below.
93
+ "HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
94
+ # The retain content bound, which decides whether `enqueue` splits.
95
+ "HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
96
+ )
97
+
98
+ def setUp(self):
99
+ self._env_prev = {k: os.environ.get(k) for k in self.ENV_KEYS}
100
+ self._tmp = tempfile.mkdtemp(prefix="hindsight-pending-test-")
101
+ self._dir = os.path.join(self._tmp, "pending-retains")
102
+ os.environ["HINDSIGHT_PENDING_DIR"] = self._dir
103
+ # Backlog replay paces itself by default; tests must not sleep.
104
+ os.environ["HINDSIGHT_DRAIN_SLEEP_S"] = "0"
105
+ # Hermeticity: a case that cares about the clamp sets this itself;
106
+ # nothing may inherit an ambient value from the caller's shell.
107
+ os.environ.pop("HINDSIGHT_DRAIN_TIMEOUT", None)
108
+ os.environ.pop("HINDSIGHT_DRAIN_BUDGET_S", None)
109
+ self._caps_prev = (pending.MAX_ENTRIES, pending.MAX_BYTES)
110
+
111
+ def tearDown(self):
112
+ pending.MAX_ENTRIES, pending.MAX_BYTES = self._caps_prev
113
+ for k, v in self._env_prev.items():
114
+ if v is None:
115
+ os.environ.pop(k, None)
116
+ else:
117
+ os.environ[k] = v
118
+ shutil.rmtree(self._tmp, ignore_errors=True)
119
+
120
+ def _names(self):
121
+ return sorted(n for n in os.listdir(self._dir) if n.endswith(".json"))
122
+
123
+ def _archive_names(self):
124
+ try:
125
+ return sorted(os.listdir(pending.evicted_dir()))
126
+ except OSError:
127
+ return []
128
+
129
+ def _reconciled_names(self):
130
+ try:
131
+ return sorted(os.listdir(pending.reconciled_dir()))
132
+ except OSError:
133
+ return []
134
+
135
+
136
+ class EvictionTest(_QueueTempDirMixin, unittest.TestCase):
137
+ """A full queue must shed the OLDEST entry, not refuse the newest.
138
+
139
+ The pre-fix behaviour returned ``None`` at the cap, i.e. it threw away
140
+ the turn that had just happened -- the one most likely to still matter
141
+ -- and kept a queue full of stale entries instead.
142
+ """
143
+
144
+ def test_full_queue_evicts_oldest_and_keeps_the_newest(self):
145
+ pending.MAX_ENTRIES = 3
146
+ # Entry filenames are `<unix-ms>-<uuid>.json` and FIFO order is the
147
+ # lexicographic sort of that name. Real entries are milliseconds
148
+ # apart; a test that enqueues four inside one millisecond would
149
+ # tie-break on the random uuid instead, so pin the clock.
150
+ clock = iter(1000.0 + i for i in range(10))
151
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
152
+ first = pending.enqueue(
153
+ _payload(content="oldest", doc="d0"), RuntimeError("x")
154
+ )
155
+ for i in range(1, 3):
156
+ pending.enqueue(
157
+ _payload(content=f"mid-{i}", doc=f"d{i}"), RuntimeError("x")
158
+ )
159
+ self.assertEqual(len(self._names()), 3)
160
+
161
+ with redirect_stderr(io.StringIO()) as err:
162
+ newest = pending.enqueue(
163
+ _payload(content="NEWEST", doc="d9"), RuntimeError("x")
164
+ )
165
+
166
+ self.assertIsNotNone(newest, "the incoming entry must never be refused")
167
+ self.assertEqual(len(self._names()), 3, "cap still honoured")
168
+ self.assertFalse(os.path.exists(first), "the OLDEST entry was evicted")
169
+ with open(newest, encoding="utf-8") as f:
170
+ self.assertEqual(json.load(f)["content"], "NEWEST")
171
+ self.assertIn("evicted OLDEST", err.getvalue())
172
+
173
+ def test_eviction_archives_rather_than_deletes(self):
174
+ pending.MAX_ENTRIES = 2
175
+ pending.enqueue(_payload(content="a", doc="d0"), RuntimeError("x"))
176
+ pending.enqueue(_payload(content="b", doc="d1"), RuntimeError("x"))
177
+ with redirect_stderr(io.StringIO()):
178
+ pending.enqueue(_payload(content="c", doc="d2"), RuntimeError("x"))
179
+ self.assertEqual(len(self._archive_names()), 1)
180
+
181
+ def test_a_full_disk_evicts_the_oldest_live_entry_outright_and_says_so(self):
182
+ """The bound on "the entry is never deleted" (#3599 review R4-M1).
183
+
184
+ Three documents in this PR asserted a structural invariant the code
185
+ does not have — that no ``os.remove`` in ``pending.py`` can touch a
186
+ LIVE queue entry. ``_evict_to_fit``'s ``OSError`` fallback can, and
187
+ under sustained ENOSPC it does: ``archive_reconciled`` keeps entries
188
+ queued, the queue fills, this fires, its own archive move fails for
189
+ the same reason, and the oldest live entries are removed to keep
190
+ accepting the newest.
191
+
192
+ That behaviour is ACCEPTED — a queue must be bounded by something,
193
+ and the newest turn is the one most likely to still matter — but it
194
+ is loss, so what is pinned here is that it is loud and correctly
195
+ labelled, not that it doesn't happen. The ``+archive-failed`` reason
196
+ is the operator's only signal that the payload is GONE rather than
197
+ merely shed, so it is asserted, not just the eviction.
198
+ """
199
+ pending.MAX_ENTRIES = 3
200
+ for i in range(3):
201
+ pending.enqueue(_payload(content=f"c{i}", doc=f"d{i}"), RuntimeError("x"))
202
+ before = self._names()
203
+ self.assertEqual(len(before), 3)
204
+
205
+ with unittest.mock.patch.object(
206
+ pending.shutil, "move", side_effect=OSError(28, "No space left on device")
207
+ ):
208
+ with redirect_stderr(io.StringIO()) as err:
209
+ got = pending.enqueue(
210
+ _payload(content="newest", doc="d-new"), RuntimeError("x")
211
+ )
212
+
213
+ self.assertIsNotNone(got, "the newest memory is still accepted")
214
+ names = self._names()
215
+ self.assertEqual(len(names), 3, "the queue stayed bounded")
216
+ self.assertNotIn(before[0], names, "the OLDEST live entry was removed")
217
+ # Set comparison, not slice: these are enqueued inside one
218
+ # millisecond, so their relative order tie-breaks on the dupe key
219
+ # (arbitrary but total — see enqueue()).
220
+ self.assertEqual(
221
+ set(names),
222
+ set(before[1:]) | {os.path.basename(got)},
223
+ "only the oldest went, and the newest arrived",
224
+ )
225
+ self.assertEqual(
226
+ self._archive_names(), [], "the archive move failed, so no copy was kept"
227
+ )
228
+ self.assertIn("evicted OLDEST entry", err.getvalue())
229
+
230
+ with open(pending.evictions_log_path(), encoding="utf-8") as f:
231
+ line = f.read().strip()
232
+ self.assertIn(f"evicted={before[0]}", line)
233
+ self.assertIn(
234
+ "+archive-failed",
235
+ line,
236
+ "the ledger must distinguish 'shed into the archive' from "
237
+ "'removed outright' — they are different categories of loss",
238
+ )
239
+
240
+ def test_byte_cap_also_triggers_eviction(self):
241
+ """Content spans 499 B .. 744 KB, so a count cap alone bounds disk
242
+ only to within ~1500x. Both caps are load-bearing."""
243
+ pending.MAX_ENTRIES = 1000
244
+ pending.enqueue(_payload(content="x" * 4000, doc="d0"), RuntimeError("x"))
245
+ pending.MAX_BYTES = 5000
246
+ with redirect_stderr(io.StringIO()):
247
+ pending.enqueue(_payload(content="y" * 4000, doc="d1"), RuntimeError("x"))
248
+ self.assertEqual(len(self._names()), 1)
249
+ self.assertEqual(len(self._archive_names()), 1)
250
+
251
+ def test_eviction_is_logged_to_the_ledger(self):
252
+ """Eviction is not silent loss, but it IS loss -- doctor reads this."""
253
+ pending.MAX_ENTRIES = 1
254
+ pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
255
+ with redirect_stderr(io.StringIO()):
256
+ pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
257
+ with open(pending.evictions_log_path(), encoding="utf-8") as f:
258
+ line = f.read().strip()
259
+ self.assertIn("evicted=", line)
260
+ self.assertIn("reason=count", line)
261
+ self.assertIn("queue_depth=", line)
262
+
263
+ def test_archive_is_itself_bounded(self):
264
+ """Eviction must not merely relocate the disk problem."""
265
+ pending.MAX_ENTRIES = 1
266
+ prev = pending.ARCHIVE_MAX_ENTRIES
267
+ pending.ARCHIVE_MAX_ENTRIES = 2
268
+ try:
269
+ with redirect_stderr(io.StringIO()):
270
+ for i in range(6):
271
+ pending.enqueue(_payload(doc=f"d{i}"), RuntimeError("x"))
272
+ self.assertLessEqual(len(self._archive_names()), 2)
273
+ finally:
274
+ pending.ARCHIVE_MAX_ENTRIES = prev
275
+
276
+ def test_archive_trim_keeps_the_newest_evictions(self):
277
+ """Direction matters: trimming the wrong end keeps the stale tail.
278
+
279
+ ``test_archive_is_itself_bounded`` only pins the COUNT, so reversing
280
+ the trim direction leaves it green while the archive discards
281
+ exactly what was just shed.
282
+ """
283
+ pending.MAX_ENTRIES = 1
284
+ prev = pending.ARCHIVE_MAX_ENTRIES
285
+ pending.ARCHIVE_MAX_ENTRIES = 2
286
+ try:
287
+ clock = iter(1000.0 + i for i in range(20))
288
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
289
+ with redirect_stderr(io.StringIO()):
290
+ paths = [
291
+ pending.enqueue(_payload(doc=f"d{i}"), RuntimeError("x"))
292
+ for i in range(5)
293
+ ]
294
+ evicted_names = [os.path.basename(p) for p in paths[:-1]]
295
+ self.assertEqual(
296
+ self._archive_names(),
297
+ sorted(evicted_names[-2:]),
298
+ "the archive must keep the most recently evicted entries",
299
+ )
300
+ finally:
301
+ pending.ARCHIVE_MAX_ENTRIES = prev
302
+
303
+ def test_eviction_ledger_trim_keeps_the_NEWEST_lines(self):
304
+ """The ledger is the operator's record of what was shed.
305
+
306
+ Trimming the head off (keeping the oldest lines) would leave doctor
307
+ reading a frozen prefix while every recent eviction fell out — and
308
+ the existing ledger test only greps for one line, so it stays green
309
+ either way.
310
+ """
311
+ pending.MAX_ENTRIES = 1
312
+ log = pending.evictions_log_path()
313
+ os.makedirs(os.path.dirname(log), exist_ok=True)
314
+ with open(log, "w", encoding="utf-8") as f:
315
+ for i in range(500):
316
+ f.write(f"ANCIENT-{i:03d} evicted=old bytes=0 reason=count\n")
317
+
318
+ prev = (pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES)
319
+ pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = 1, 3
320
+ try:
321
+ pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
322
+ with redirect_stderr(io.StringIO()):
323
+ pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
324
+ finally:
325
+ pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = prev
326
+
327
+ with open(log, encoding="utf-8") as f:
328
+ kept = [ln.rstrip("\n") for ln in f]
329
+ self.assertEqual(len(kept), 3, "trimmed to KEEP_LINES")
330
+ self.assertIn("evicted=", kept[-1], "the newest line is the real eviction")
331
+ self.assertEqual(
332
+ [ln for ln in kept if ln.startswith("ANCIENT-000")],
333
+ [],
334
+ "the OLDEST lines must be the ones dropped",
335
+ )
336
+ self.assertTrue(
337
+ kept[0].startswith("ANCIENT-498"),
338
+ f"kept the wrong end of the ledger: {kept[0]!r}",
339
+ )
340
+
341
+ def test_the_byte_cap_is_a_boundary_not_an_approximation(self):
342
+ """`nbytes + incoming > MAX_BYTES` evicts; `== MAX_BYTES` does not.
343
+
344
+ Off-by-one here is silent: it either sheds a memory that fit, or
345
+ overshoots the cap the operator set.
346
+ """
347
+ pending.MAX_ENTRIES = 1000
348
+ first = pending.enqueue(_payload(content="x" * 100, doc="d0"), RuntimeError("x"))
349
+ second = pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
350
+ exact = os.path.getsize(first) + os.path.getsize(second)
351
+ os.remove(second)
352
+
353
+ # Exactly at the cap: the incoming entry fits, nothing may be evicted.
354
+ pending.MAX_BYTES = exact
355
+ with redirect_stderr(io.StringIO()):
356
+ pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
357
+ self.assertEqual(len(self._names()), 2, "an entry that fits exactly must fit")
358
+ self.assertEqual(self._archive_names(), [], "nothing was over the cap")
359
+
360
+ # One byte tighter: the same pair no longer fits, so the oldest sheds.
361
+ os.remove(sorted(os.path.join(self._dir, n) for n in self._names())[-1])
362
+ pending.MAX_BYTES = exact - 1
363
+ with redirect_stderr(io.StringIO()):
364
+ pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
365
+ self.assertEqual(len(self._archive_names()), 1, "one byte over must evict")
366
+ self.assertEqual(len(self._names()), 1)
367
+
368
+ def test_ledgers_live_outside_the_queue_dir(self):
369
+ """So they can never be listed as an entry, drained, or counted."""
370
+ pending.MAX_ENTRIES = 1
371
+ pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
372
+ with redirect_stderr(io.StringIO()):
373
+ pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
374
+ for p in (pending.evictions_log_path(), pending.drops_path()):
375
+ self.assertNotEqual(os.path.dirname(p), self._dir.rstrip("/"))
376
+ self.assertEqual(pending.count(), 1)
377
+
378
+
379
+ class QueueOrderTest(_QueueTempDirMixin, unittest.TestCase):
380
+ """What the filename ordering does and does not promise.
381
+
382
+ FIFO is what eviction and the drain both rely on, and it is exact only
383
+ down to the millisecond: the name carries no finer age information, so
384
+ entries sharing a millisecond tie-break on the dupe key — stable and
385
+ total, but arbitrary. The comment used to claim plain oldest-first.
386
+ """
387
+
388
+ def _enqueue_at(self, ms, content):
389
+ with unittest.mock.patch.object(pending.time, "time", lambda: ms / 1000.0):
390
+ return pending.enqueue(_payload(content=content, doc=f"d-{content}"),
391
+ RuntimeError("x"))
392
+
393
+ def test_entries_are_ordered_by_enqueue_millisecond(self):
394
+ newest = self._enqueue_at(1_700_000_002_000, "third")
395
+ oldest = self._enqueue_at(1_700_000_000_000, "first")
396
+ middle = self._enqueue_at(1_700_000_001_000, "second")
397
+ self.assertEqual(
398
+ [p for p, _ in pending.iter_entries()],
399
+ [oldest, middle, newest],
400
+ "write order must not matter; the millisecond stamp orders them",
401
+ )
402
+
403
+ def test_same_millisecond_entries_are_ordered_stably_but_arbitrarily(self):
404
+ paths = [self._enqueue_at(1_700_000_000_000, f"c{i}") for i in range(4)]
405
+ got = [p for p, _ in pending.iter_entries()]
406
+ self.assertEqual(sorted(got), sorted(paths), "every entry is listed once")
407
+ self.assertEqual(got, sorted(got), "the order is the name sort, not enqueue order")
408
+ self.assertEqual(got, [p for p, _ in pending.iter_entries()], "and it is stable")
409
+
410
+
411
+ class DedupeTest(_QueueTempDirMixin, unittest.TestCase):
412
+ """``reconcile_tail`` re-enqueues the same slice on every boot until its
413
+ watermark is confirmed -- 63% of one measured fleet queue was dupes."""
414
+
415
+ def test_identical_entry_returns_the_existing_path(self):
416
+ a = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
417
+ b = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
418
+ self.assertEqual(a, b)
419
+ self.assertEqual(pending.count(), 1)
420
+
421
+ def test_different_content_is_not_deduped(self):
422
+ pending.enqueue(_payload(content="one", doc="d1"), RuntimeError("x"))
423
+ pending.enqueue(_payload(content="two", doc="d1"), RuntimeError("x"))
424
+ self.assertEqual(pending.count(), 2)
425
+
426
+ def test_dedupe_survives_an_attempted_entry(self):
427
+ """The scenario the guard exists for, and the one it used to miss.
428
+
429
+ The queued copy is ALWAYS post-``update_attempt`` by the time
430
+ ``reconcile_tail`` re-enqueues, because the SessionStart drain
431
+ attempts every entry on every boot. A size-indexed dedupe therefore
432
+ matched nothing in steady state and the queue grew by one duplicate
433
+ per boot.
434
+ """
435
+ first = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
436
+ _, entry = pending.iter_entries()[0]
437
+ pending.update_attempt(first, entry, TimeoutError("upstream timed out"))
438
+ self.assertNotEqual(
439
+ os.path.getsize(first),
440
+ len(json.dumps(entry, ensure_ascii=False).encode("utf-8")) - 1,
441
+ )
442
+
443
+ again = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
444
+ self.assertEqual(again, first, "attempted entry must still dedupe")
445
+ self.assertEqual(pending.count(), 1)
446
+
447
+ def test_dedupe_survives_a_different_error_message(self):
448
+ """The error string is not part of the memory's identity."""
449
+ a = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("short"))
450
+ b = pending.enqueue(
451
+ _payload(content="same", doc="d1"), ConnectionError("a much longer error")
452
+ )
453
+ self.assertEqual(a, b)
454
+ self.assertEqual(pending.count(), 1)
455
+
456
+ def test_dedupe_reads_no_files(self):
457
+ """The key is in the filename, so a lookup is a listing scan only."""
458
+ pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
459
+ real_open = open
460
+ queue_dir = self._dir.rstrip("/")
461
+
462
+ def no_entry_reads(path, *a, **kw):
463
+ if os.path.dirname(str(path)) == queue_dir:
464
+ raise AssertionError(f"dedupe opened a queue entry: {path}")
465
+ return real_open(path, *a, **kw)
466
+
467
+ with unittest.mock.patch("builtins.open", no_entry_reads):
468
+ self.assertIsNotNone(pending._find_duplicate(self._dir, pending._dupe_key(
469
+ {"bank_id": "bank-a", "document_id": "d1", "content": "same"}
470
+ )))
471
+
472
+ def test_different_banks_do_not_collide(self):
473
+ pending.enqueue(_payload(bank="bank-a", content="same"), RuntimeError("x"))
474
+ pending.enqueue(_payload(bank="bank-b", content="same"), RuntimeError("x"))
475
+ self.assertEqual(pending.count(), 2)
476
+
477
+ def test_entry_without_document_id_is_always_kept(self):
478
+ """No document_id => identity cannot be established => never merge."""
479
+ p = _payload()
480
+ p.pop("document_id")
481
+ pending.enqueue(dict(p), RuntimeError("x"))
482
+ pending.enqueue(dict(p), RuntimeError("x"))
483
+ self.assertEqual(pending.count(), 2)
484
+
485
+
486
+ class DropLedgerTest(_QueueTempDirMixin, unittest.TestCase):
487
+ """Residual drops -- the entry could not be written even after eviction."""
488
+
489
+ def _fail_queue_writes(self):
490
+ real_open = open
491
+ queue_dir = self._dir.rstrip("/")
492
+
493
+ def boom(path, *a, **kw):
494
+ p = str(path)
495
+ if p.endswith(".tmp") and os.path.dirname(p) == queue_dir:
496
+ raise OSError(28, "No space left on device")
497
+ return real_open(path, *a, **kw)
498
+
499
+ return unittest.mock.patch("builtins.open", boom)
500
+
501
+ def test_unwritable_queue_records_a_drop_and_returns_none(self):
502
+ with self._fail_queue_writes():
503
+ with redirect_stderr(io.StringIO()) as err:
504
+ got = pending.enqueue(_payload(), RuntimeError("upstream down"))
505
+
506
+ self.assertIsNone(got, "callers handle None; they must still get it")
507
+ self.assertIn("permanently lost", err.getvalue())
508
+ ledger = pending.read_drops()
509
+ self.assertEqual(ledger["count"], 1)
510
+ self.assertEqual(ledger["last_error_class"], "OSError")
511
+ self.assertEqual(ledger["last_bank_id"], "bank-a")
512
+
513
+ def test_first_dropped_at_is_not_overwritten_by_later_drops(self):
514
+ with self._fail_queue_writes():
515
+ with redirect_stderr(io.StringIO()):
516
+ pending.enqueue(_payload(content="a"), RuntimeError("x"))
517
+ first = pending.read_drops()["first_dropped_at"]
518
+ pending.enqueue(_payload(content="b"), RuntimeError("x"))
519
+ ledger = pending.read_drops()
520
+ self.assertEqual(ledger["count"], 2)
521
+ self.assertEqual(ledger["first_dropped_at"], first)
522
+
523
+ def test_read_drops_survives_a_non_utf8_ledger(self):
524
+ """The narrow-catch bug: UnicodeDecodeError is NOT a JSONDecodeError.
525
+
526
+ ``record_drop()`` reads the ledger first, so a corrupt ledger used
527
+ to turn ``enqueue()`` from documented-returns-None into a raiser at
528
+ exactly the moment the queue is under stress -- breaking
529
+ session_end.py / subagent_retain.py, which handle None.
530
+ """
531
+ os.makedirs(os.path.dirname(pending.drops_path()), exist_ok=True)
532
+ with open(pending.drops_path(), "wb") as f:
533
+ f.write(b"\xff\xfe\x00not utf-8 at all")
534
+ self.assertEqual(pending.read_drops(), {})
535
+ with redirect_stderr(io.StringIO()):
536
+ self.assertEqual(pending.record_drop(_payload(), RuntimeError("x")), 1)
537
+
538
+ def test_read_drops_survives_malformed_json(self):
539
+ os.makedirs(os.path.dirname(pending.drops_path()), exist_ok=True)
540
+ with open(pending.drops_path(), "w", encoding="utf-8") as f:
541
+ f.write("{not json")
542
+ self.assertEqual(pending.read_drops(), {})
543
+
544
+ def test_error_messages_are_truncated(self):
545
+ """An unbounded upstream error body inflates the queue against
546
+ MAX_BYTES for no diagnostic gain."""
547
+ path = pending.enqueue(_payload(), RuntimeError("E" * 5000))
548
+ with open(path, encoding="utf-8") as f:
549
+ stored = json.load(f)["error_message"]
550
+ self.assertLessEqual(len(stored), pending.MAX_ERROR_MESSAGE_CHARS + 20)
551
+ self.assertTrue(stored.endswith("[truncated]"))
552
+
553
+ def test_oversized_entry_is_refused_without_wiping_the_queue(self):
554
+ """One entry must never cost the whole queue.
555
+
556
+ With no guard the eviction loop can never satisfy its condition, so
557
+ it evicts EVERY entry and writes the oversized one anyway -- and
558
+ the bounded archive then discards most of what it just shed.
559
+ """
560
+ pending.MAX_BYTES = 100_000
561
+ for i in range(20):
562
+ pending.enqueue(_payload(content=f"turn-{i}", doc=f"d{i}"), RuntimeError("x"))
563
+ self.assertEqual(pending.count(), 20)
564
+
565
+ with redirect_stderr(io.StringIO()) as err:
566
+ got = pending.enqueue(
567
+ _payload(content="z" * 200_000, doc="huge"), RuntimeError("x")
568
+ )
569
+
570
+ self.assertIsNone(got, "the oversized entry is refused")
571
+ self.assertEqual(pending.count(), 20, "every other entry survived")
572
+ self.assertIn("larger than the whole", err.getvalue())
573
+ self.assertEqual(pending.read_drops()["count"], 1)
574
+
575
+ def test_a_memory_with_more_parts_than_the_count_cap_is_refused(self):
576
+ """The count-cap sibling of the byte-cap guard (#3610).
577
+
578
+ Splitting turns one memory into N entries, so the count cap can be
579
+ blown the same way the byte cap can — and with a satisfiable eviction
580
+ loop the later parts shed the earlier ones plus everything already
581
+ queued, leaving a tail fragment and nothing else. Refuse instead.
582
+ """
583
+ pending.MAX_ENTRIES = 4
584
+ pending.MAX_BYTES = 10**9 # only the COUNT cap may fire here
585
+ for i in range(3):
586
+ pending.enqueue(_payload(content=f"turn-{i}", doc=f"d{i}"), RuntimeError("x"))
587
+ self.assertEqual(pending.count(), 3)
588
+
589
+ os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
590
+ try:
591
+ with redirect_stderr(io.StringIO()) as err:
592
+ got = pending.enqueue(
593
+ _payload(content="z" * 30_000, doc=_cd_doc(9)), RuntimeError("x")
594
+ )
595
+ finally:
596
+ os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
597
+
598
+ self.assertIsNone(got, "a 10-part memory under a 4-entry cap is refused")
599
+ self.assertEqual(pending.count(), 3, "every other entry survived")
600
+ self.assertIn("more than the whole", err.getvalue())
601
+ self.assertEqual(pending.read_drops()["count"], 1)
602
+
603
+ def test_a_split_that_fits_the_count_cap_is_still_queued_per_part(self):
604
+ """The other side of that boundary: refusal must not swallow a
605
+ memory the queue can actually hold."""
606
+ pending.MAX_ENTRIES = 20
607
+ pending.MAX_BYTES = 10**9
608
+ os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
609
+ try:
610
+ got = pending.enqueue(
611
+ _payload(content="z" * 30_000, doc=_cd_doc(9)), RuntimeError("x")
612
+ )
613
+ finally:
614
+ os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
615
+
616
+ self.assertIsNotNone(got)
617
+ self.assertEqual(pending.count(), 10, "one entry per part")
618
+ self.assertEqual(pending.read_drops(), {}, "nothing was dropped")
619
+ docs = sorted(e["document_id"] for _p, e in pending.iter_entries())
620
+ self.assertEqual(docs[0], f"{_cd_doc(9)}-p10of10")
621
+ for _p, entry in pending.iter_entries():
622
+ self.assertLessEqual(len(entry["content"]), 3000)
623
+ self.assertTrue(
624
+ pending.is_content_derived_document_id(entry["document_id"]),
625
+ "every queued part stays inside the free presence reconcile",
626
+ )
627
+
628
+ def test_an_entry_exactly_at_the_byte_cap_is_enqueued_not_dropped(self):
629
+ """``blob_bytes > MAX_BYTES``, not ``>=`` (#3599 review R4-La).
630
+
631
+ The boundary was pinned in ``_trim_dir``, ``_evict_to_fit``,
632
+ ``_clamp`` and ``_env_num`` and missed here, so ``>`` → ``>=``
633
+ survived the suite. It is not equivalent and it is not cosmetic: an
634
+ entry whose serialised size lands exactly ON the cap DOES fit —
635
+ ``_evict_to_fit``'s own condition is ``nbytes + incoming >
636
+ MAX_BYTES``, which an empty queue satisfies — so under the mutant a
637
+ writable, storable turn becomes a permanent RESIDUAL DROP. That is
638
+ the one outcome in this module that loses a memory outright, and it
639
+ would be reached by an entry the queue could have held.
640
+ """
641
+ payload = _payload(content="q" * 500, doc="exact-fit")
642
+ probe = pending.enqueue(payload, RuntimeError("x"))
643
+ exact = os.path.getsize(probe)
644
+ os.remove(probe)
645
+
646
+ pending.MAX_BYTES = exact
647
+ with redirect_stderr(io.StringIO()) as err:
648
+ got = pending.enqueue(payload, RuntimeError("x"))
649
+
650
+ self.assertIsNotNone(got, "an entry exactly at the cap fits within it")
651
+ self.assertEqual(os.path.getsize(got), exact)
652
+ self.assertEqual(pending.count(), 1)
653
+ self.assertNotIn("larger than the whole", err.getvalue())
654
+ self.assertEqual(pending.read_drops(), {}, "nothing was dropped")
655
+
656
+ def test_one_byte_over_the_byte_cap_is_refused(self):
657
+ """The other side of the same boundary, so no single comparison
658
+ satisfies both cases."""
659
+ payload = _payload(content="q" * 500, doc="one-over")
660
+ probe = pending.enqueue(payload, RuntimeError("x"))
661
+ exact = os.path.getsize(probe)
662
+ os.remove(probe)
663
+
664
+ pending.MAX_BYTES = exact - 1
665
+ with redirect_stderr(io.StringIO()) as err:
666
+ self.assertIsNone(pending.enqueue(payload, RuntimeError("x")))
667
+ self.assertIn("larger than the whole", err.getvalue())
668
+ self.assertEqual(pending.read_drops()["count"], 1)
669
+
670
+ def test_corrupt_entry_is_quarantined_not_skipped_forever(self):
671
+ """A skipped corrupt entry is immortal: never reconciled, never
672
+ drained, never aged to .dead, but still counted in the depth."""
673
+ good = pending.enqueue(_payload(), RuntimeError("x"))
674
+ bad = os.path.join(self._dir, "1700000000000-deadbeefdead.json")
675
+ with open(bad, "w", encoding="utf-8") as f:
676
+ f.write("{ not json at all")
677
+
678
+ with redirect_stderr(io.StringIO()) as err:
679
+ entries = pending.iter_entries()
680
+
681
+ self.assertEqual([p for p, _ in entries], [good])
682
+ self.assertFalse(os.path.exists(bad), "no longer occupying a queue slot")
683
+ self.assertEqual(pending.count(), 1, "no longer inflating the depth")
684
+ self.assertIn("quarantined", err.getvalue())
685
+ quarantined = os.path.join(
686
+ os.path.dirname(self._dir.rstrip("/")), "pending-corrupt"
687
+ )
688
+ self.assertEqual(os.listdir(quarantined), [os.path.basename(bad)])
689
+
690
+ def test_transient_read_error_is_skipped_not_quarantined(self):
691
+ """An OSError is not evidence the payload is bad."""
692
+ pending.enqueue(_payload(), RuntimeError("x"))
693
+ real_open = open
694
+
695
+ def flaky(path, *a, **kw):
696
+ if str(path).endswith(".json"):
697
+ raise OSError(11, "Resource temporarily unavailable")
698
+ return real_open(path, *a, **kw)
699
+
700
+ with unittest.mock.patch("builtins.open", flaky):
701
+ self.assertEqual(pending.iter_entries(), [])
702
+ self.assertEqual(pending.count(), 1, "entry left intact for the next pass")
703
+
704
+ def test_healthy_enqueue_writes_no_drop_ledger(self):
705
+ self.assertIsNotNone(pending.enqueue(_payload(), RuntimeError("x")))
706
+ self.assertEqual(pending.read_drops(), {})
707
+ self.assertFalse(os.path.exists(pending.drops_path()))
708
+
709
+
710
+ class BacklogDrainTest(_QueueTempDirMixin, unittest.TestCase):
711
+ def _queue(self, n):
712
+ """Queue ``n`` entries with POST-#3244 content-derived document_ids."""
713
+ for i in range(n):
714
+ pending.enqueue(
715
+ _payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
716
+ )
717
+ self.assertEqual(pending.count(), n)
718
+
719
+ def test_session_start_drain_cannot_clear_a_backlog(self):
720
+ """Regression witness for the BUG, not just for the fix.
721
+
722
+ The in-hook drain clamps each entry to the remaining hook budget
723
+ while the retain is synchronous, so the queue barely moves. That
724
+ loop is what produced the backlog in the first place.
725
+ """
726
+ self._queue(20)
727
+ os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "2"
728
+
729
+ def slow_ok(entry, timeout):
730
+ import time as _t
731
+
732
+ _t.sleep(0.5)
733
+
734
+ with unittest.mock.patch.object(drain_pending, "_retry_one", slow_ok):
735
+ with unittest.mock.patch.object(
736
+ drain_pending, "_document_state", lambda e, timeout=30: False
737
+ ):
738
+ summary = drain_pending.drain(CONFIG)
739
+
740
+ self.assertTrue(summary["budget_exceeded"])
741
+ self.assertLess(summary["drained"], 20)
742
+ self.assertGreater(pending.count(), 0)
743
+
744
+ # The path that ACTUALLY runs on every boot is the sequential drain, not
745
+ # --backlog. Fixing the re-post loop only in the operator-invoked mode
746
+ # would change nothing operationally: every agent boot would go on
747
+ # re-POSTing its already-durable backlog exactly as before.
748
+ def test_session_start_drain_reconciles_before_re_posting(self):
749
+ self._queue(6)
750
+ posted = []
751
+ durable = {_cd_doc(i) for i in range(4)}
752
+
753
+ def exists(entry, timeout=30):
754
+ return entry["document_id"] in durable
755
+
756
+ with unittest.mock.patch.object(drain_pending, "_document_state", exists):
757
+ with unittest.mock.patch.object(
758
+ drain_pending, "_retry_one", lambda e, timeout: posted.append(e["document_id"])
759
+ ):
760
+ summary = drain_pending.drain(CONFIG)
761
+
762
+ self.assertEqual(summary["reconciled"], 4)
763
+ self.assertEqual(
764
+ sorted(posted),
765
+ sorted([_cd_doc(4), _cd_doc(5)]),
766
+ "already-durable entries must not be re-POSTed inside the hook",
767
+ )
768
+ self.assertEqual(pending.count(), 0)
769
+
770
+ def test_session_start_reconcile_never_deletes_on_an_unknown_result(self):
771
+ """Unknown must fall through to the retry, never to a delete."""
772
+ self._queue(3)
773
+ posted = []
774
+ with unittest.mock.patch.object(
775
+ drain_pending, "_document_state", lambda e, timeout=30: None
776
+ ):
777
+ with unittest.mock.patch.object(
778
+ drain_pending, "_retry_one", lambda e, timeout: posted.append(e)
779
+ ):
780
+ summary = drain_pending.drain(CONFIG)
781
+ self.assertEqual(summary["reconciled"], 0)
782
+ self.assertEqual(len(posted), 3, "unknown falls through to the retry")
783
+
784
+ def _stalled_upstream(self, budget, timeout):
785
+ """Simulate an upstream that never answers, on a virtual clock.
786
+
787
+ Every request consumes exactly the timeout it was given — the shape
788
+ of a hung upstream, and the shape that made the fleet's 9s budget
789
+ take 16.0s. The clock is virtual so the assertion is about the
790
+ drain's arithmetic, not about how fast the test box happens to be.
791
+
792
+ Returns ``(seen_get, seen_post, elapsed_fn, patch_contexts)``.
793
+ """
794
+ os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = str(budget)
795
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = str(timeout)
796
+ clock = {"t": 1000.0}
797
+ start = clock["t"]
798
+ seen_get, seen_post = [], []
799
+
800
+ def fake_get(entry, timeout=30):
801
+ seen_get.append(timeout)
802
+ clock["t"] += timeout
803
+ return False # absent -> falls through to the POST
804
+
805
+ def fake_post(entry, timeout):
806
+ seen_post.append(timeout)
807
+ clock["t"] += timeout
808
+ raise TimeoutError("upstream never answered")
809
+
810
+ ctx = (
811
+ unittest.mock.patch.object(
812
+ drain_pending.time, "monotonic", lambda: clock["t"]
813
+ ),
814
+ unittest.mock.patch.object(drain_pending, "_document_state", fake_get),
815
+ unittest.mock.patch.object(drain_pending, "_retry_one", fake_post),
816
+ )
817
+ return seen_get, seen_post, (lambda: clock["t"] - start), ctx
818
+
819
+ def test_the_per_entry_timeout_is_not_spent_twice(self):
820
+ """One entry = one budget, not one per request.
821
+
822
+ The clamp used to be computed ONCE per entry and then spent on the
823
+ presence GET *and* the POST. With the fleet's own settings (budget
824
+ 9s, timeout 8s) that is 8 + 8 = 16.0s against a 9s hook budget,
825
+ while the docstring promised an overshoot of at most the 1s clamp
826
+ floor. The POST's timeout must be recomputed against what is left.
827
+ """
828
+ self._queue(1)
829
+ seen_get, seen_post, elapsed, ctx = self._stalled_upstream(budget=9, timeout=8)
830
+ with ctx[0], ctx[1], ctx[2]:
831
+ drain_pending.drain(CONFIG)
832
+
833
+ self.assertEqual(seen_get, [8], "the GET gets the full budgeted clamp")
834
+ self.assertEqual(
835
+ seen_post, [1], "the POST must re-clamp against the budget LEFT"
836
+ )
837
+ self.assertLessEqual(
838
+ elapsed(),
839
+ 9,
840
+ f"one entry outspent the whole 9s budget "
841
+ f"(gets={seen_get} posts={seen_post})",
842
+ )
843
+ self.assertEqual(pending.count(), 1, "the failed entry stays queued")
844
+
845
+ def test_session_start_reconcile_respects_the_hook_budget(self):
846
+ """The GET must not be an extra call bolted onto an unchanged POST.
847
+
848
+ Asserts against the BUDGET, with ``HINDSIGHT_DRAIN_TIMEOUT`` set
849
+ explicitly high — the previous form asserted ``t <= 5`` while
850
+ ``_per_entry_timeout()`` defaults to 5, so it held whether or not
851
+ the clamp existed at all, and it never patched ``_retry_one``, so
852
+ the GET+POST path it is named for never ran.
853
+ """
854
+ self._queue(10)
855
+ budget = 9
856
+ seen_get, seen_post, elapsed, ctx = self._stalled_upstream(
857
+ budget=budget, timeout=300
858
+ )
859
+ with ctx[0], ctx[1], ctx[2]:
860
+ summary = drain_pending.drain(CONFIG)
861
+
862
+ self.assertTrue(seen_get and seen_post, "the GET+POST path must run")
863
+ self.assertTrue(
864
+ all(t <= budget for t in seen_get + seen_post),
865
+ f"a single request outlived the whole budget: {seen_get} {seen_post}",
866
+ )
867
+ self.assertLessEqual(
868
+ elapsed(),
869
+ budget + 2,
870
+ f"drain overshot the {budget}s hook budget by more than one "
871
+ f"floored GET+POST (gets={seen_get} posts={seen_post})",
872
+ )
873
+ self.assertTrue(summary["budget_exceeded"])
874
+ self.assertGreater(pending.count(), 0)
875
+
876
+ def test_reconcile_phase_skips_already_durable_entries_without_posting(self):
877
+ """The free pass: 70.4% of a measured fleet backlog was already
878
+ durable. Re-POSTing those is duplicated LLM extraction for zero new
879
+ memory; a GET costs nothing on the model pool."""
880
+ self._queue(6)
881
+ posted = []
882
+ durable = {_cd_doc(i) for i in range(3)}
883
+
884
+ def exists(entry, timeout=30):
885
+ return entry["document_id"] in durable
886
+
887
+ with unittest.mock.patch.object(drain_pending, "_document_state", exists):
888
+ with unittest.mock.patch.object(
889
+ drain_pending, "_retry_one", lambda e, timeout: posted.append(e)
890
+ ):
891
+ with redirect_stderr(io.StringIO()):
892
+ summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
893
+
894
+ self.assertEqual(summary["reconciled"], 3)
895
+ self.assertEqual(posted, [], "reconcile must issue no retains at all")
896
+ self.assertEqual(pending.count(), 3, "only absent documents remain queued")
897
+
898
+ def test_reconcile_never_drops_an_entry_on_an_unknown_result(self):
899
+ """Unknown != present. Guessing here deletes the last copy of a turn."""
900
+ self._queue(3)
901
+ with unittest.mock.patch.object(
902
+ drain_pending, "_document_state", lambda e, timeout=30: None
903
+ ):
904
+ with redirect_stderr(io.StringIO()):
905
+ summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
906
+ self.assertEqual(summary["reconciled"], 0)
907
+ self.assertEqual(summary["unknown"], 3)
908
+ self.assertEqual(pending.count(), 3)
909
+
910
+ def test_drain_confirms_the_document_before_deleting_the_entry(self):
911
+ """Commit-before-delete: a 200 is an ack, not proof (#3244)."""
912
+ self._queue(2)
913
+ with unittest.mock.patch.object(drain_pending, "_retry_one", lambda e, timeout: None):
914
+ # POST succeeds, but the document is still not there.
915
+ with unittest.mock.patch.object(
916
+ drain_pending, "_document_state", lambda e, timeout=30: False
917
+ ):
918
+ with redirect_stderr(io.StringIO()):
919
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
920
+
921
+ self.assertEqual(summary["drained"], 0)
922
+ self.assertEqual(summary["unknown"], 2)
923
+ self.assertEqual(pending.count(), 2, "unconfirmed entries stay queued")
924
+
925
+ def test_drain_never_retires_an_entry_on_an_unknown_result(self):
926
+ """Unknown != confirmed, on the PHASE-2 path too (#3599 review R3-B1).
927
+
928
+ The realistic shape: the POST returns 200, the confirming GET hits a
929
+ 503 or times out, so ``_document_state`` is ``None``. Counting that
930
+ as durable archives the entry and it is never retried — a degraded
931
+ upstream is exactly the condition this PR exists for. Mirrors
932
+ ``test_reconcile_never_drops_an_entry_on_an_unknown_result``; its
933
+ absence left ``is True`` at the phase-2 site mutable to
934
+ ``is not False`` with both suites green.
935
+ """
936
+ self._queue(4)
937
+ with unittest.mock.patch.object(
938
+ drain_pending, "_retry_one", lambda e, timeout: None
939
+ ):
940
+ with unittest.mock.patch.object(
941
+ drain_pending, "_document_state", lambda e, timeout=30: None
942
+ ):
943
+ with redirect_stderr(io.StringIO()):
944
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
945
+
946
+ self.assertEqual(summary["drained"], 0, "unknown is not a drain")
947
+ self.assertEqual(summary["unknown"], 4)
948
+ self.assertEqual(pending.count(), 4, "unconfirmed entries stay queued")
949
+
950
+ def test_drain_deletes_once_the_document_is_confirmed(self):
951
+ self._queue(5)
952
+ with unittest.mock.patch.object(drain_pending, "_retry_one", lambda e, timeout: None):
953
+ with unittest.mock.patch.object(
954
+ drain_pending, "_document_state", lambda e, timeout=30: True
955
+ ):
956
+ with redirect_stderr(io.StringIO()):
957
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
958
+ self.assertEqual(summary["drained"], 5)
959
+ self.assertEqual(pending.count(), 0)
960
+
961
+ def test_backlog_concurrency_defaults_to_one_lane(self):
962
+ """The model pool is small and fleet-shared; width 4 consumes all
963
+ of it, and 11 agents at width 4 is 44 lanes of demand against 4."""
964
+ os.environ.pop("HINDSIGHT_DRAIN_CONCURRENCY", None)
965
+ self.assertEqual(drain_pending._backlog_concurrency(), 1)
966
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "999"
967
+ self.assertEqual(drain_pending._backlog_concurrency(), 16, "clamped")
968
+
969
+ def test_backlog_uses_a_realistic_per_entry_timeout(self):
970
+ """A 1-8s clamp guarantees a client timeout on a 30-90s sync retain
971
+ that the server then commits anyway -- the root cause."""
972
+ os.environ.pop("HINDSIGHT_DRAIN_BACKLOG_TIMEOUT", None)
973
+ self.assertGreaterEqual(drain_pending._backlog_timeout(), 120)
974
+
975
+ self._queue(2)
976
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "90"
977
+ seen = []
978
+ with unittest.mock.patch.object(
979
+ drain_pending, "_retry_one", lambda e, timeout: seen.append(timeout)
980
+ ):
981
+ with unittest.mock.patch.object(
982
+ drain_pending, "_document_state", lambda e, timeout=30: True
983
+ ):
984
+ with redirect_stderr(io.StringIO()):
985
+ drain_pending.drain_backlog(CONFIG, phase="drain")
986
+ self.assertEqual(seen, [90, 90])
987
+
988
+ def test_p95_backoff_pauses_the_drain(self):
989
+ """Replay must never be the thing that trips the latency alarm."""
990
+ self._queue(1)
991
+ os.environ["HINDSIGHT_DRAIN_P95_BACKOFF_MS"] = "38000"
992
+ probes = [90000, 90000, 10000]
993
+ slept = []
994
+
995
+ with unittest.mock.patch.object(
996
+ drain_pending, "_p95_probe_ms", lambda: probes.pop(0)
997
+ ):
998
+ with unittest.mock.patch.object(drain_pending.time, "sleep", slept.append):
999
+ with unittest.mock.patch.object(
1000
+ drain_pending, "_retry_one", lambda e, timeout: None
1001
+ ):
1002
+ with unittest.mock.patch.object(
1003
+ drain_pending, "_document_state", lambda e, timeout=30: True
1004
+ ):
1005
+ with redirect_stderr(io.StringIO()) as err:
1006
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1007
+
1008
+ self.assertEqual(slept.count(120), 2, "backed off until p95 recovered")
1009
+ self.assertIn("BACKOFF", err.getvalue())
1010
+ self.assertEqual(summary["drained"], 1)
1011
+
1012
+ def test_p95_backoff_is_bounded_by_the_drain_budget(self):
1013
+ """A persistently slow upstream must not block past the budget.
1014
+
1015
+ An unbounded `while True: sleep(120)` would run indefinitely, past
1016
+ the one guarantee this mode makes about how long it will run.
1017
+ """
1018
+ self._queue(2)
1019
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "60"
1020
+ slept = []
1021
+
1022
+ with unittest.mock.patch.object(
1023
+ drain_pending, "_p95_probe_ms", lambda: 99000
1024
+ ):
1025
+ with unittest.mock.patch.object(drain_pending.time, "sleep", slept.append):
1026
+ with unittest.mock.patch.object(
1027
+ drain_pending, "_retry_one", lambda e, timeout: None
1028
+ ):
1029
+ with redirect_stderr(io.StringIO()) as err:
1030
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1031
+
1032
+ self.assertEqual(slept, [], "60s budget cannot absorb a 120s pause")
1033
+ self.assertTrue(summary["budget_exceeded"])
1034
+ self.assertIn("budget is exhausted", err.getvalue())
1035
+ self.assertEqual(pending.count(), 2, "entries stay queued")
1036
+
1037
+ def test_absent_p95_probe_does_not_block(self):
1038
+ os.environ.pop("HINDSIGHT_DRAIN_P95_CMD", None)
1039
+ self.assertEqual(drain_pending._p95_probe_ms(), -1)
1040
+
1041
+ def test_stall_guard_does_not_overshoot_under_concurrency(self):
1042
+ """A wave of `width` identical timeouts must not age `width` entries.
1043
+
1044
+ Recording failures for the whole wave before breaking would bump
1045
+ attempt_count on up to `width - 1` extra entries per wave, pushing
1046
+ them toward .dead FASTER than the sequential drain -- the opposite
1047
+ of this change's purpose.
1048
+ """
1049
+ self._queue(12)
1050
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "4"
1051
+
1052
+ def always_fail(entry, timeout):
1053
+ raise ConnectionError("upstream down")
1054
+
1055
+ with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
1056
+ with redirect_stderr(io.StringIO()):
1057
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1058
+
1059
+ self.assertTrue(summary["stalled"])
1060
+ self.assertEqual(
1061
+ summary["retried"],
1062
+ drain_pending.STALL_THRESHOLD,
1063
+ "exactly STALL_THRESHOLD entries aged, same as the sequential drain",
1064
+ )
1065
+ self.assertEqual(pending.count(), 12, "stalled entries stay queued")
1066
+
1067
+ def test_backlog_ages_entries_toward_dead_like_the_sequential_drain(self):
1068
+ self._queue(1)
1069
+ path, entry = pending.iter_entries()[0]
1070
+ entry["attempt_count"] = pending.MAX_ATTEMPTS
1071
+ with open(path, "w", encoding="utf-8") as f:
1072
+ json.dump(entry, f)
1073
+
1074
+ def always_fail(entry, timeout):
1075
+ raise ConnectionError("upstream down")
1076
+
1077
+ with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
1078
+ with redirect_stderr(io.StringIO()):
1079
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1080
+
1081
+ self.assertEqual(summary["dead"], 1)
1082
+ self.assertTrue(os.path.exists(path + ".dead"))
1083
+ self.assertFalse(os.path.exists(path))
1084
+
1085
+ def test_dry_run_issues_no_writes(self):
1086
+ self._queue(3)
1087
+ with unittest.mock.patch.object(
1088
+ drain_pending, "_document_state", lambda e, timeout=30: True
1089
+ ):
1090
+ with redirect_stderr(io.StringIO()):
1091
+ summary = drain_pending.drain_backlog(
1092
+ CONFIG, phase="reconcile", dry_run=True
1093
+ )
1094
+ self.assertEqual(summary["reconciled"], 3)
1095
+ self.assertEqual(pending.count(), 3, "a dry run must not delete anything")
1096
+
1097
+
1098
+ class ArchiveFailureNeverDeletesTest(_QueueTempDirMixin, unittest.TestCase):
1099
+ """A failure to ARCHIVE is not a licence to DELETE (#3599 review R3-M1).
1100
+
1101
+ The previous ``archive_reconciled`` fell back to ``delete_entry`` on
1102
+ ``OSError``. Reproduced: ENOSPC from ``os.makedirs`` removed the entry,
1103
+ left ``pending-reconciled/`` empty, and emitted nothing at all — silent,
1104
+ unrecorded, irreversible loss of the last on-disk copy of a turn. That
1105
+ falsified, verbatim, four separate claims (``drain_pending``'s module
1106
+ docstring, ``switchroom doctor``'s backlog fix text, the CHANGELOG, and
1107
+ the PR body). The claims are now true because the code is.
1108
+ """
1109
+
1110
+ def _queue(self, n):
1111
+ for i in range(n):
1112
+ pending.enqueue(
1113
+ _payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
1114
+ )
1115
+ self.assertEqual(pending.count(), n)
1116
+
1117
+ @staticmethod
1118
+ def _enospc(*_a, **_kw):
1119
+ raise OSError(28, "No space left on device")
1120
+
1121
+ def test_archive_reconciled_keeps_the_entry_when_the_archive_is_unwritable(self):
1122
+ self._queue(1)
1123
+ path = os.path.join(self._dir, self._names()[0])
1124
+ err = io.StringIO()
1125
+ with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
1126
+ with redirect_stderr(err):
1127
+ dest = pending.archive_reconciled(path)
1128
+
1129
+ self.assertIsNone(dest, "a failed archive reports failure, not a dest")
1130
+ self.assertTrue(
1131
+ os.path.exists(path),
1132
+ "the entry is the ONLY on-disk copy of the turn; a full disk "
1133
+ "must not destroy it",
1134
+ )
1135
+ self.assertEqual(pending.count(), 1)
1136
+ self.assertEqual(self._reconciled_names(), [])
1137
+
1138
+ def test_archive_failure_is_loud_on_stderr(self):
1139
+ """Silent loss was the worst part of the old behaviour. Even now
1140
+ that nothing is lost, an unwritable archive must be visible."""
1141
+ self._queue(1)
1142
+ path = os.path.join(self._dir, self._names()[0])
1143
+ err = io.StringIO()
1144
+ with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
1145
+ with redirect_stderr(err):
1146
+ pending.archive_reconciled(path)
1147
+ msg = err.getvalue()
1148
+ self.assertIn(os.path.basename(path), msg)
1149
+ self.assertIn("No space left on device", msg)
1150
+ self.assertRegex(msg, r"STAYS QUEUED")
1151
+
1152
+ def test_the_queue_module_exposes_no_delete_primitive(self):
1153
+ """Structural, not aspirational: with no ``delete_entry`` there is
1154
+ no branch by which the drain path can ``os.remove`` a live entry."""
1155
+ self.assertFalse(
1156
+ hasattr(pending, "delete_entry"),
1157
+ "re-adding a delete primitive re-opens the silent-loss path",
1158
+ )
1159
+
1160
+ def test_backlog_drain_counts_an_unarchivable_entry_as_not_retired(self):
1161
+ """The summary must not claim a retire that did not happen: the
1162
+ entry is still queued, so ``drained`` would be a lie."""
1163
+ self._queue(3)
1164
+ with unittest.mock.patch.object(
1165
+ drain_pending, "_retry_one", lambda e, timeout: None
1166
+ ):
1167
+ with unittest.mock.patch.object(
1168
+ drain_pending, "_document_state", lambda e, timeout=30: True
1169
+ ):
1170
+ with unittest.mock.patch.object(
1171
+ pending.os, "makedirs", self._enospc
1172
+ ):
1173
+ with redirect_stderr(io.StringIO()):
1174
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1175
+
1176
+ self.assertEqual(summary["drained"], 0)
1177
+ self.assertEqual(summary["archive_failed"], 3)
1178
+ self.assertEqual(pending.count(), 3, "every entry survives a full disk")
1179
+
1180
+ def test_reconcile_phase_counts_an_unarchivable_entry_as_not_retired(self):
1181
+ self._queue(3)
1182
+ with unittest.mock.patch.object(
1183
+ drain_pending, "_document_state", lambda e, timeout=30: True
1184
+ ):
1185
+ with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
1186
+ with redirect_stderr(io.StringIO()):
1187
+ summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
1188
+
1189
+ self.assertEqual(summary["reconciled"], 0)
1190
+ self.assertEqual(summary["archive_failed"], 3)
1191
+ self.assertEqual(pending.count(), 3)
1192
+
1193
+ def test_in_hook_drain_keeps_an_unarchivable_entry(self):
1194
+ """The bounded SessionStart path retires on its POST's own
1195
+ commit-before-ack 200 (``async_processing=False``, no confirming
1196
+ GET) and is the one most likely to run on a nearly-full container
1197
+ filesystem."""
1198
+ self._queue(2)
1199
+ with unittest.mock.patch.object(
1200
+ drain_pending, "_retry_one", lambda e, timeout: None
1201
+ ):
1202
+ with unittest.mock.patch.object(
1203
+ drain_pending, "_document_state", lambda e, timeout=30: False
1204
+ ):
1205
+ with unittest.mock.patch.object(
1206
+ pending.os, "makedirs", self._enospc
1207
+ ):
1208
+ with redirect_stderr(io.StringIO()):
1209
+ summary = drain_pending.drain(CONFIG)
1210
+
1211
+ self.assertEqual(summary["drained"], 0)
1212
+ self.assertEqual(summary["archive_failed"], 2)
1213
+ self.assertEqual(pending.count(), 2)
1214
+
1215
+
1216
+ class PresenceReconcileGateTest(_QueueTempDirMixin, unittest.TestCase):
1217
+ """A presence GET may only retire an entry whose id proves ITS content.
1218
+
1219
+ Post-#3244 (`retain.slice_document_id`) the id is
1220
+ ``{session}-r{start_uuid}-{end_uuid}`` — a function of which turns the
1221
+ entry carries, so a 200 proves this content was committed. A pre-#3244
1222
+ entry carries a BARE SESSION ID, for which the bank answers 200 after
1223
+ ANY successful retain in that session. Reconciling on that deletes a
1224
+ turn that was never committed. Confirmed against a live 525 KB entry on
1225
+ this fleet whose ``document_id`` is a bare uuid and whose GET returns
1226
+ 200.
1227
+ """
1228
+
1229
+ BARE_SESSION_ID = "d52ae253-2d26-42e5-a86b-9a354cc0ace5"
1230
+
1231
+ def test_the_id_shape_predicate_separates_the_two_generations(self):
1232
+ self.assertTrue(pending.is_content_derived_document_id(_cd_doc(1)))
1233
+ # sha256 fallback for uuid-less legacy transcripts
1234
+ self.assertTrue(
1235
+ pending.is_content_derived_document_id(f"{SESSION}-r" + "a1" * 16)
1236
+ )
1237
+ # sub-agent namespace reuses the same recipe
1238
+ self.assertTrue(
1239
+ pending.is_content_derived_document_id(
1240
+ f"{SESSION}-sub-worker7-r{_uuid(1)}-{_uuid(2)}"
1241
+ )
1242
+ )
1243
+ for bad in (
1244
+ self.BARE_SESSION_ID,
1245
+ "conversation",
1246
+ "",
1247
+ None,
1248
+ 123,
1249
+ f"{SESSION}-r{_uuid(1)}", # only one uuid: not the slice shape
1250
+ ):
1251
+ self.assertFalse(
1252
+ pending.is_content_derived_document_id(bad), f"must not accept {bad!r}"
1253
+ )
1254
+
1255
+ def test_a_split_part_inherits_the_verdict_of_its_core_id(self):
1256
+ """#3610's part ids must stay inside #3599's free reconcile.
1257
+
1258
+ The gate is anchored at the end of the id, so ``-p{i}of{n}`` would
1259
+ otherwise push EVERY split part outside it — and split parts are, by
1260
+ construction, the largest and most expensive entries in the queue.
1261
+ Each would take a full re-POST on every drain instead of a sub-second
1262
+ presence GET: the re-post loop #3599 fixed, aimed at the worst case.
1263
+
1264
+ The suffix must not rescue a pre-#3244 id, which is the whole point
1265
+ of the gate, so both directions are asserted here.
1266
+ """
1267
+ core = _cd_doc(1)
1268
+ for total in (2, 5, 12, 249):
1269
+ for index in range(min(total, 3)):
1270
+ part = retain_split.part_document_id(core, index, total)
1271
+ self.assertTrue(
1272
+ pending.is_content_derived_document_id(part),
1273
+ f"a split part of a content-derived id is content-derived: {part!r}",
1274
+ )
1275
+ # sha256 fallback core, and the sub-agent namespace, split too
1276
+ self.assertTrue(
1277
+ pending.is_content_derived_document_id(f"{SESSION}-r" + "a1" * 16 + "-p1of3")
1278
+ )
1279
+ self.assertTrue(
1280
+ pending.is_content_derived_document_id(
1281
+ f"{SESSION}-sub-worker7-r{_uuid(1)}-{_uuid(2)}-p2of4"
1282
+ )
1283
+ )
1284
+ # A part re-split under a lowered bound nests its suffix, and is still
1285
+ # a pure function of content.
1286
+ self.assertTrue(
1287
+ pending.is_content_derived_document_id(
1288
+ retain_split.part_document_id(
1289
+ retain_split.part_document_id(core, 1, 5), 0, 2
1290
+ )
1291
+ )
1292
+ )
1293
+ # A part suffix on a PRE-#3244 id still proves nothing.
1294
+ for bad in (
1295
+ f"{self.BARE_SESSION_ID}-p1of3",
1296
+ "conversation-p2of2",
1297
+ f"{SESSION}-r{_uuid(1)}-p1of2", # only one uuid: not the slice shape
1298
+ f"{core}-pXofY", # not the emitted suffix shape
1299
+ f"{core}-p1of2-trailing", # suffix must be terminal
1300
+ ):
1301
+ self.assertFalse(
1302
+ pending.is_content_derived_document_id(bad), f"must not accept {bad!r}"
1303
+ )
1304
+
1305
+ def _queue_bare(self, n=2):
1306
+ for i in range(n):
1307
+ pending.enqueue(
1308
+ _payload(content=f"turn-{i}", doc=self.BARE_SESSION_ID),
1309
+ RuntimeError("boom"),
1310
+ )
1311
+
1312
+ def test_a_bare_session_id_is_never_reconciled_on_presence_alone(self):
1313
+ """The bug: a 200 for the session deletes an uncommitted turn."""
1314
+ self._queue_bare(2)
1315
+ posted = []
1316
+
1317
+ def post_fails(entry, timeout):
1318
+ posted.append(entry)
1319
+ raise TimeoutError("upstream slow")
1320
+
1321
+ with unittest.mock.patch.object(
1322
+ drain_pending, "_document_state", lambda e, timeout=30: True
1323
+ ):
1324
+ with unittest.mock.patch.object(drain_pending, "_retry_one", post_fails):
1325
+ summary = drain_pending.drain(CONFIG)
1326
+
1327
+ self.assertEqual(summary["reconciled"], 0, "presence alone proves nothing here")
1328
+ self.assertEqual(len(posted), 2, "such entries must go to the POST instead")
1329
+ self.assertEqual(pending.count(), 2, "and stay queued when the POST fails")
1330
+ self.assertEqual(
1331
+ self._reconciled_names(),
1332
+ [],
1333
+ "nothing may be retired by the reconcile path on a bare session id",
1334
+ )
1335
+
1336
+ def test_backlog_reconcile_phase_also_refuses_a_bare_session_id(self):
1337
+ self._queue_bare(2)
1338
+ with unittest.mock.patch.object(
1339
+ drain_pending, "_document_state", lambda e, timeout=30: True
1340
+ ):
1341
+ with redirect_stderr(io.StringIO()) as err:
1342
+ summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
1343
+
1344
+ self.assertEqual(summary["reconciled"], 0)
1345
+ self.assertEqual(pending.count(), 2, "left for phase 2, which can make them durable")
1346
+ self.assertIn("pre-#3244", err.getvalue())
1347
+
1348
+ def test_reconciled_entries_are_archived_not_deleted(self):
1349
+ """The out-of-band tooling this design was ported from ARCHIVES.
1350
+
1351
+ Every retire decision rests on a 200, and a 200 is evidence, not
1352
+ proof (#3244). An irreversible ``os.remove`` on evidence is how the
1353
+ last on-disk copy of a turn disappears; ``pending-reconciled/`` is
1354
+ recoverable and bounded.
1355
+ """
1356
+ pending.enqueue(_payload(doc=_cd_doc(7)), RuntimeError("x"))
1357
+ name = self._names()[0]
1358
+ with unittest.mock.patch.object(
1359
+ drain_pending, "_document_state", lambda e, timeout=30: True
1360
+ ):
1361
+ summary = drain_pending.drain(CONFIG)
1362
+
1363
+ self.assertEqual(summary["reconciled"], 1)
1364
+ self.assertEqual(pending.count(), 0, "no longer queued")
1365
+ self.assertEqual(self._reconciled_names(), [name], "payload is recoverable")
1366
+
1367
+ def test_a_successful_in_hook_retain_also_archives_rather_than_deletes(self):
1368
+ """One durability rule on one path.
1369
+
1370
+ The in-hook success path retires on its POST's own 200 (a
1371
+ commit-before-ack, see the test below — NOT a bare async ack, as
1372
+ this docstring used to say) while the backlog path additionally
1373
+ re-GETs. Archiving both makes the weaker evidence cost a
1374
+ recoverable file rather than a lost turn.
1375
+ """
1376
+ pending.enqueue(_payload(doc=self.BARE_SESSION_ID), RuntimeError("x"))
1377
+ name = self._names()[0]
1378
+ with unittest.mock.patch.object(
1379
+ drain_pending, "_retry_one", lambda e, timeout: None
1380
+ ):
1381
+ summary = drain_pending.drain(CONFIG)
1382
+
1383
+ self.assertEqual(summary["drained"], 1)
1384
+ self.assertEqual(pending.count(), 0)
1385
+ self.assertEqual(self._reconciled_names(), [name])
1386
+
1387
+ def test_the_in_hook_success_path_commits_synchronously_and_never_re_gets(
1388
+ self,
1389
+ ):
1390
+ """The evidence the in-hook retire ACTUALLY rests on (#3599 R4-B3).
1391
+
1392
+ ``pending._trim_dir``'s cap justification names this population by
1393
+ hand — "retires on the POST's own 200 with no confirming GET, and
1394
+ that 200 is a commit-before-ack" — so both halves are pinned here
1395
+ rather than left as prose. Nothing else in the suite fails if the
1396
+ in-hook path goes back to ``async_processing=True`` (making the 200
1397
+ a bare ack, which is what the old comment claimed it already was)
1398
+ or starts issuing a confirming re-GET it has no hook budget for.
1399
+
1400
+ The event log is ordered on purpose: ONE presence GET *before* the
1401
+ POST is the reconcile free pass; the assertion is that nothing
1402
+ follows the POST.
1403
+ """
1404
+ pending.enqueue(_payload(doc=_cd_doc(42)), RuntimeError("x"))
1405
+ events = []
1406
+
1407
+ def fake_state(entry, timeout=30):
1408
+ events.append("GET")
1409
+ return False # absent → fall through to the POST
1410
+
1411
+ class _Client:
1412
+ def __init__(self, *_a, **_kw):
1413
+ pass
1414
+
1415
+ def retain(self, **kw):
1416
+ events.append(("POST", kw.get("async_processing")))
1417
+ return {}
1418
+
1419
+ with unittest.mock.patch.object(drain_pending, "HindsightClient", _Client):
1420
+ with unittest.mock.patch.object(
1421
+ drain_pending, "_document_state", fake_state
1422
+ ):
1423
+ summary = drain_pending.drain(CONFIG)
1424
+
1425
+ self.assertEqual(summary["drained"], 1)
1426
+ self.assertEqual(
1427
+ events,
1428
+ ["GET", ("POST", False)],
1429
+ "one presence GET before the POST, a SYNCHRONOUS post, and no "
1430
+ "confirming GET after it",
1431
+ )
1432
+ self.assertEqual(len(self._reconciled_names()), 1)
1433
+
1434
+ def test_the_backlog_confirmed_drain_archives_too(self):
1435
+ pending.enqueue(_payload(doc=_cd_doc(3)), RuntimeError("x"))
1436
+ name = self._names()[0]
1437
+ with unittest.mock.patch.object(
1438
+ drain_pending, "_retry_one", lambda e, timeout: None
1439
+ ):
1440
+ with unittest.mock.patch.object(
1441
+ drain_pending, "_document_state", lambda e, timeout=30: True
1442
+ ):
1443
+ with redirect_stderr(io.StringIO()):
1444
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1445
+ self.assertEqual(summary["drained"], 1)
1446
+ self.assertEqual(self._reconciled_names(), [name])
1447
+
1448
+ def test_the_reconciled_archive_is_bounded_and_sheds_oldest_first(self):
1449
+ """An unbounded archive is just a slower disk problem."""
1450
+ prev = pending.RECONCILED_MAX_ENTRIES
1451
+ pending.RECONCILED_MAX_ENTRIES = 2
1452
+ try:
1453
+ clock = iter(1000.0 + i for i in range(20))
1454
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
1455
+ for i in range(5):
1456
+ pending.enqueue(
1457
+ _payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x")
1458
+ )
1459
+ queued = self._names()
1460
+ with unittest.mock.patch.object(
1461
+ drain_pending, "_document_state", lambda e, timeout=30: True
1462
+ ):
1463
+ drain_pending.drain(CONFIG)
1464
+ self.assertEqual(
1465
+ self._reconciled_names(),
1466
+ queued[-2:],
1467
+ "the archive keeps the NEWEST retirements, trimming oldest-first",
1468
+ )
1469
+ finally:
1470
+ pending.RECONCILED_MAX_ENTRIES = prev
1471
+
1472
+ def test_an_unwritable_archive_keeps_the_entry_rather_than_deleting_it(self):
1473
+ """REVERSED from the original assertion, deliberately (#3599 R3-M1).
1474
+
1475
+ This case used to assert ``pending.count() == 0`` — i.e. that a
1476
+ failed archive deleted the entry — on the reasoning that a queue
1477
+ which cannot retire re-POSTs forever. That trade is backwards: the
1478
+ re-POST loop costs duplicated LLM extraction on a disk-full box,
1479
+ and the delete costs the last on-disk copy of the turn, silently.
1480
+ Sheds the cheaper failure; ``archive_failed`` keeps the summary
1481
+ honest about the entry still being queued.
1482
+ """
1483
+ pending.enqueue(_payload(doc=_cd_doc(9)), RuntimeError("x"))
1484
+ with unittest.mock.patch.object(
1485
+ pending.shutil, "move", side_effect=OSError(28, "No space left")
1486
+ ):
1487
+ with unittest.mock.patch.object(
1488
+ drain_pending, "_document_state", lambda e, timeout=30: True
1489
+ ):
1490
+ with redirect_stderr(io.StringIO()) as err:
1491
+ summary = drain_pending.drain(CONFIG)
1492
+ self.assertEqual(summary["reconciled"], 0, "nothing was retired")
1493
+ self.assertEqual(summary["archive_failed"], 1)
1494
+ self.assertEqual(pending.count(), 1, "the only copy of the turn survives")
1495
+ self.assertIn("No space left", err.getvalue())
1496
+
1497
+
1498
+ class TrimDirBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1499
+ """``_trim_dir``'s caps, at the boundary (#3599 review R3-L2).
1500
+
1501
+ A separate site from the enqueue cap in ``_evict_to_fit`` (pinned by
1502
+ ``EvictionTest``): ``>`` → ``>=`` here survived the whole sweep. Off by
1503
+ one in this direction sheds one archived copy on every call forever,
1504
+ including on a directory that is exactly at its cap.
1505
+ """
1506
+
1507
+ def _archive(self, sizes):
1508
+ """Write ``len(sizes)`` archive files of the given byte sizes."""
1509
+ d = os.path.join(self._tmp, "arch")
1510
+ os.makedirs(d, exist_ok=True)
1511
+ for i, n in enumerate(sizes):
1512
+ with open(os.path.join(d, f"{1000 + i:013d}-x.json"), "w") as f:
1513
+ f.write("x" * n)
1514
+ return d
1515
+
1516
+ def _names_in(self, d):
1517
+ return sorted(n for n in os.listdir(d) if n.endswith(".json"))
1518
+
1519
+ def test_exactly_at_the_byte_cap_is_within_it(self):
1520
+ d = self._archive([10, 10, 10])
1521
+ with redirect_stderr(io.StringIO()) as err:
1522
+ dropped = pending._trim_dir(d, max_entries=99, max_bytes=30)
1523
+ self.assertEqual(dropped, 0, "30 bytes under a 30-byte cap fits")
1524
+ self.assertEqual(len(self._names_in(d)), 3)
1525
+ self.assertEqual(err.getvalue(), "", "nothing dropped, nothing logged")
1526
+
1527
+ def test_one_byte_over_the_cap_sheds_the_oldest_only(self):
1528
+ d = self._archive([10, 10, 11])
1529
+ with redirect_stderr(io.StringIO()):
1530
+ dropped = pending._trim_dir(d, max_entries=99, max_bytes=30)
1531
+ self.assertEqual(dropped, 1)
1532
+ self.assertEqual(
1533
+ self._names_in(d),
1534
+ [f"{1001:013d}-x.json", f"{1002:013d}-x.json"],
1535
+ "oldest first — the newest archived copy is the one worth keeping",
1536
+ )
1537
+
1538
+ def test_exactly_at_the_count_cap_is_within_it(self):
1539
+ d = self._archive([1, 1, 1])
1540
+ self.assertEqual(pending._trim_dir(d, max_entries=3, max_bytes=10**9), 0)
1541
+ self.assertEqual(len(self._names_in(d)), 3)
1542
+
1543
+ def test_one_over_the_count_cap_sheds_exactly_one(self):
1544
+ d = self._archive([1, 1, 1, 1])
1545
+ with redirect_stderr(io.StringIO()):
1546
+ self.assertEqual(pending._trim_dir(d, max_entries=3, max_bytes=10**9), 1)
1547
+ self.assertEqual(len(self._names_in(d)), 3)
1548
+
1549
+ def test_a_trim_names_what_it_dropped_on_stderr(self):
1550
+ """The archive caps are 4x smaller than the queue caps, so a
1551
+ full-queue drain trims. Silent trimming makes "archived, never
1552
+ deleted" a half-truth; the log line is the other half."""
1553
+ d = self._archive([1] * 6)
1554
+ with redirect_stderr(io.StringIO()) as err:
1555
+ pending._trim_dir(d, max_entries=2, max_bytes=10**9)
1556
+ msg = err.getvalue()
1557
+ self.assertIn("trimmed 4 archived copies", msg)
1558
+ self.assertIn(f"{1000:013d}-x.json", msg, "names the oldest dropped")
1559
+ self.assertIn("arch", msg, "names the directory")
1560
+
1561
+ def test_exactly_ten_dropped_names_are_all_listed(self):
1562
+ """The name list truncates ABOVE ten, not at ten.
1563
+
1564
+ ``len(dropped) > 10`` vs ``>=`` differ only on a trim of exactly
1565
+ ten, where the mutant appends a nonsense "+0 more".
1566
+ """
1567
+ d = self._archive([1] * 12)
1568
+ with redirect_stderr(io.StringIO()) as err:
1569
+ pending._trim_dir(d, max_entries=2, max_bytes=10**9)
1570
+ msg = err.getvalue()
1571
+ self.assertIn("trimmed 10 archived copies", msg)
1572
+ self.assertNotIn("more", msg, "ten names fit; there is no remainder")
1573
+ self.assertEqual(msg.count("-x.json"), 10)
1574
+
1575
+ def test_eleven_dropped_names_truncate_to_ten_plus_a_remainder(self):
1576
+ d = self._archive([1] * 13)
1577
+ with redirect_stderr(io.StringIO()) as err:
1578
+ pending._trim_dir(d, max_entries=2, max_bytes=10**9)
1579
+ msg = err.getvalue()
1580
+ self.assertIn("trimmed 11 archived copies", msg)
1581
+ self.assertIn("+1 more", msg)
1582
+ self.assertEqual(msg.count("-x.json"), 10)
1583
+
1584
+ def test_a_large_trim_caps_the_name_list(self):
1585
+ """A 1500-entry trim must not emit a 1500-name line."""
1586
+ d = self._archive([1] * 20)
1587
+ with redirect_stderr(io.StringIO()) as err:
1588
+ pending._trim_dir(d, max_entries=2, max_bytes=10**9)
1589
+ msg = err.getvalue()
1590
+ self.assertIn("trimmed 18 archived copies", msg)
1591
+ self.assertIn("+8 more", msg)
1592
+ self.assertLessEqual(msg.count("-x.json"), 10)
1593
+
1594
+
1595
+ class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1596
+ """``_clamp``'s floor and ``_env_num``'s clamps (#3599 review R3-L4)."""
1597
+
1598
+ def test_clamp_floors_at_one_second_never_zero(self):
1599
+ """A 0s timeout fails instantly, turning a near-exhausted budget
1600
+ into a guaranteed failure rather than one last bounded shot."""
1601
+ started = time.monotonic()
1602
+ # Budget already fully spent: remaining <= 0.
1603
+ self.assertEqual(drain_pending._clamp(8, budget=0.0, started=started), 1)
1604
+ # Remaining just under the floor.
1605
+ self.assertEqual(drain_pending._clamp(8, budget=0.9, started=started), 1)
1606
+
1607
+ def test_the_per_entry_timeout_is_floored_at_one_second(self):
1608
+ """The invariant ``_clamp``'s equivalence proof rests on (R4-Lb).
1609
+
1610
+ ``_clamp``'s docstring argues its own ``max(1, ...)`` mutants are
1611
+ equivalent. They are — but only while ``timeout >= 1``: with
1612
+ ``timeout == 0`` and a healthy budget, ``max(0, min(0, n))`` returns
1613
+ 0, an instant-fail request on every entry. Nothing in ``_clamp``
1614
+ rules that input out; ``_per_entry_timeout``'s floor does, and it is
1615
+ the ONLY thing that does, because ``drain`` takes its ``timeout``
1616
+ from here and passes it to both ``_clamp`` callsites. So the proof
1617
+ lives in one function and its precondition lives in another — pin
1618
+ the precondition, or the proof rots the day someone "simplifies"
1619
+ this ``max``.
1620
+ """
1621
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "0"
1622
+ self.assertEqual(drain_pending._per_entry_timeout(), 1)
1623
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "-30"
1624
+ self.assertEqual(drain_pending._per_entry_timeout(), 1)
1625
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "garbage"
1626
+ self.assertEqual(drain_pending._per_entry_timeout(), 5, "default on junk")
1627
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "9"
1628
+ self.assertEqual(drain_pending._per_entry_timeout(), 9, "a real value passes")
1629
+
1630
+ # And the consequence the floor buys, stated at the clamp: the
1631
+ # smallest timeout _clamp can ever be handed still yields a
1632
+ # non-zero, bounded request.
1633
+ started = time.monotonic()
1634
+ self.assertEqual(
1635
+ drain_pending._clamp(
1636
+ drain_pending._per_entry_timeout(), budget=100.0, started=started
1637
+ ),
1638
+ 9,
1639
+ )
1640
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "0"
1641
+ self.assertEqual(
1642
+ drain_pending._clamp(
1643
+ drain_pending._per_entry_timeout(), budget=100.0, started=started
1644
+ ),
1645
+ 1,
1646
+ "a 0s knob must not reach urlopen as a 0s timeout",
1647
+ )
1648
+
1649
+ def test_clamp_hands_out_the_remaining_budget_once_past_the_floor(self):
1650
+ started = time.monotonic()
1651
+ self.assertEqual(drain_pending._clamp(8, budget=3.9, started=started), 3)
1652
+ self.assertEqual(
1653
+ drain_pending._clamp(2, budget=100.0, started=started),
1654
+ 2,
1655
+ "the caller's timeout still wins when the budget is ample",
1656
+ )
1657
+
1658
+ def test_env_num_lo_clamp_raises_a_too_small_value(self):
1659
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "0"
1660
+ self.assertEqual(
1661
+ drain_pending._backlog_timeout(), 1, "lo=1 must raise 0 to 1"
1662
+ )
1663
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "-5"
1664
+ self.assertEqual(drain_pending._backlog_timeout(), 1)
1665
+
1666
+ def test_env_num_lo_is_inclusive_and_leaves_larger_values_alone(self):
1667
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "1"
1668
+ self.assertEqual(drain_pending._backlog_timeout(), 1)
1669
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "45"
1670
+ self.assertEqual(drain_pending._backlog_timeout(), 45)
1671
+
1672
+ def test_env_num_hi_clamp_lowers_a_too_large_value(self):
1673
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "17"
1674
+ self.assertEqual(drain_pending._backlog_concurrency(), 16)
1675
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "16"
1676
+ self.assertEqual(drain_pending._backlog_concurrency(), 16)
1677
+
1678
+ def test_env_num_falls_back_to_the_default_on_garbage(self):
1679
+ default = int(retain_split.retain_client_deadline())
1680
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "not-a-number"
1681
+ self.assertEqual(drain_pending._backlog_timeout(), default)
1682
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = ""
1683
+ self.assertEqual(
1684
+ drain_pending._backlog_timeout(), default, "empty is unset, not 0"
1685
+ )
1686
+
1687
+ def test_the_backlog_timeout_defaults_to_the_retain_client_deadline(self):
1688
+ """The drain deadline and the content bound are ONE number (#3610).
1689
+
1690
+ A maximally-sized part is sized to just fit inside
1691
+ ``retain_client_deadline()``. If the drainer's own deadline were
1692
+ shorter — as the ``180`` literal #3599 shipped was — it would abandon a
1693
+ correctly-sized part mid-extraction, leave the entry queued, and
1694
+ rebuild the re-post loop #3599 fixed. Moving the deadline must move
1695
+ both, so this asserts the derivation, not the number.
1696
+ """
1697
+ os.environ.pop("HINDSIGHT_DRAIN_BACKLOG_TIMEOUT", None)
1698
+ os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
1699
+ self.assertEqual(drain_pending._backlog_timeout(), 280)
1700
+
1701
+ os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "600"
1702
+ try:
1703
+ self.assertEqual(
1704
+ drain_pending._backlog_timeout(),
1705
+ 600,
1706
+ "moving the client deadline must move the drain deadline",
1707
+ )
1708
+ self.assertEqual(
1709
+ retain_split.retain_content_limit(),
1710
+ 3000 * int(600 // 18.4),
1711
+ "and the content bound, from the same input",
1712
+ )
1713
+ finally:
1714
+ os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
1715
+
1716
+ def test_an_explicit_backlog_timeout_still_overrides_the_derivation(self):
1717
+ os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "600"
1718
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "42"
1719
+ try:
1720
+ self.assertEqual(drain_pending._backlog_timeout(), 42)
1721
+ finally:
1722
+ os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
1723
+
1724
+ def test_no_clamp_is_applied_when_lo_and_hi_are_absent(self):
1725
+ """``lo``/``hi`` default to ``None``; that branch must be a no-op,
1726
+ not an accidental floor at 0."""
1727
+ os.environ["X_TEST_KNOB"] = "-42"
1728
+ try:
1729
+ self.assertEqual(
1730
+ drain_pending._env_num("X_TEST_KNOB", 1, int), -42
1731
+ )
1732
+ finally:
1733
+ os.environ.pop("X_TEST_KNOB", None)
1734
+
1735
+
1736
+ class P95ProbeTest(_QueueTempDirMixin, unittest.TestCase):
1737
+ """A configured-but-broken probe is not the same as an unset one."""
1738
+
1739
+ def test_a_failing_probe_is_not_read_as_a_latency_figure(self):
1740
+ """Exit status was ignored, so a typo'd probe's stdout was trusted."""
1741
+ os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "echo 12345; exit 3"
1742
+ with redirect_stderr(io.StringIO()) as err:
1743
+ self.assertEqual(drain_pending._p95_probe_ms(), -1)
1744
+ self.assertIn("exited 3", err.getvalue())
1745
+ self.assertIn("DISABLED", err.getvalue())
1746
+
1747
+ def test_a_probe_that_prints_nothing_says_so(self):
1748
+ os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "true"
1749
+ with redirect_stderr(io.StringIO()) as err:
1750
+ self.assertEqual(drain_pending._p95_probe_ms(), -1)
1751
+ self.assertIn("DISABLED", err.getvalue())
1752
+
1753
+ def test_a_healthy_probe_is_read_silently(self):
1754
+ os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "echo 41000"
1755
+ with redirect_stderr(io.StringIO()) as err:
1756
+ self.assertEqual(drain_pending._p95_probe_ms(), 41000)
1757
+ self.assertEqual(err.getvalue(), "")
1758
+
1759
+
1760
+ class CliTest(unittest.TestCase):
1761
+ """A membership test on argv silently ran the WRONG drain on a typo."""
1762
+
1763
+ def _run(self, argv):
1764
+ seen = {}
1765
+
1766
+ def fake_drain(config=None, backlog=False, phase="both", dry_run=False):
1767
+ seen.update(backlog=backlog, phase=phase, dry_run=dry_run)
1768
+ return drain_pending._new_summary()
1769
+
1770
+ with unittest.mock.patch.object(drain_pending, "drain", fake_drain):
1771
+ with unittest.mock.patch.object(drain_pending, "load_config", lambda: {}):
1772
+ rc = drain_pending.main(argv)
1773
+ return rc, seen
1774
+
1775
+ def test_backlog_flag_selects_backlog_mode(self):
1776
+ _, seen = self._run(["--backlog"])
1777
+ self.assertTrue(seen["backlog"])
1778
+ _, seen = self._run([])
1779
+ self.assertFalse(seen["backlog"])
1780
+
1781
+ def test_typo_is_rejected_rather_than_silently_running_the_hook_drain(self):
1782
+ with redirect_stderr(io.StringIO()):
1783
+ with self.assertRaises(SystemExit) as cm:
1784
+ self._run(["--backlogg"])
1785
+ self.assertNotEqual(cm.exception.code, 0)
1786
+
1787
+ def test_phase_and_dry_run_are_plumbed(self):
1788
+ _, seen = self._run(["--backlog", "--phase", "reconcile", "--dry-run"])
1789
+ self.assertEqual(seen["phase"], "reconcile")
1790
+ self.assertTrue(seen["dry_run"])
1791
+
1792
+ def test_phase_without_backlog_is_refused(self):
1793
+ with redirect_stderr(io.StringIO()):
1794
+ rc, _ = self._run(["--phase", "reconcile"])
1795
+ self.assertEqual(rc, 2)
1796
+
1797
+
1798
+ class StallGuardBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1799
+ """The stall guard counts CONSECUTIVE failures of the SAME class.
1800
+
1801
+ Every prior stall test drove a homogeneous error stream, which cannot
1802
+ tell ``err_class == last_error_class`` from ``!=``: with one class the
1803
+ mutant takes the increment branch every time and the reset branch is
1804
+ never reached, so the drain stalls identically. Distinguishing them
1805
+ needs an ALTERNATING stream, where the real guard resets and the
1806
+ mutant stalls. Same false-green shape as Blocker 1 — the assertion
1807
+ exercised the code without pinning the invariant.
1808
+ """
1809
+
1810
+ def _queue(self, n):
1811
+ for i in range(n):
1812
+ pending.enqueue(
1813
+ _payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
1814
+ )
1815
+ self.assertEqual(pending.count(), n)
1816
+
1817
+ def _alternating(self):
1818
+ """A failure stream whose error CLASS changes every entry."""
1819
+ classes = [ConnectionError, TimeoutError]
1820
+ n = {"i": 0}
1821
+
1822
+ def fail(entry, timeout):
1823
+ cls = classes[n["i"] % 2]
1824
+ n["i"] += 1
1825
+ raise cls("upstream flapping")
1826
+
1827
+ return fail
1828
+
1829
+ def test_alternating_error_classes_never_stall_the_drain(self):
1830
+ self._queue(6)
1831
+ with unittest.mock.patch.object(
1832
+ drain_pending, "_document_state", lambda e, timeout=30: False
1833
+ ):
1834
+ with unittest.mock.patch.object(
1835
+ drain_pending, "_retry_one", self._alternating()
1836
+ ):
1837
+ with redirect_stderr(io.StringIO()):
1838
+ summary = drain_pending.drain(CONFIG)
1839
+
1840
+ self.assertFalse(
1841
+ summary["stalled"],
1842
+ "a flapping upstream is not a stall — the counter must RESET "
1843
+ "when the error class changes",
1844
+ )
1845
+ self.assertEqual(summary["retried"], 6, "every entry got its attempt")
1846
+ self.assertEqual(pending.count(), 6, "failed entries stay queued")
1847
+
1848
+ def test_alternating_error_classes_never_stall_the_backlog_drain(self):
1849
+ self._queue(6)
1850
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "1"
1851
+ with unittest.mock.patch.object(
1852
+ drain_pending, "_retry_one", self._alternating()
1853
+ ):
1854
+ with redirect_stderr(io.StringIO()):
1855
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1856
+
1857
+ self.assertFalse(summary["stalled"])
1858
+ self.assertEqual(summary["retried"], 6)
1859
+ self.assertEqual(pending.count(), 6)
1860
+
1861
+ def test_the_drain_stalls_at_exactly_stall_threshold(self):
1862
+ """AT the threshold, not one past it.
1863
+
1864
+ Queueing exactly ``STALL_THRESHOLD`` entries is what separates
1865
+ ``>=`` from ``>``: with ``>`` the run ends by exhausting the queue
1866
+ and reports no stall at all.
1867
+ """
1868
+ self._queue(drain_pending.STALL_THRESHOLD)
1869
+
1870
+ def always_fail(entry, timeout):
1871
+ raise ConnectionError("upstream down")
1872
+
1873
+ with unittest.mock.patch.object(
1874
+ drain_pending, "_document_state", lambda e, timeout=30: False
1875
+ ):
1876
+ with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
1877
+ with redirect_stderr(io.StringIO()) as err:
1878
+ summary = drain_pending.drain(CONFIG)
1879
+
1880
+ self.assertTrue(summary["stalled"])
1881
+ self.assertIn("stalling drain", err.getvalue())
1882
+ self.assertEqual(pending.count(), drain_pending.STALL_THRESHOLD)
1883
+
1884
+
1885
+ class BudgetBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1886
+ """The budget is spent DOWN TO the tick, not one tick short.
1887
+
1888
+ ``elapsed > budget`` vs ``>=`` is invisible to every wall-clock test
1889
+ (real elapsed never lands exactly on the budget), so both loops are
1890
+ driven here on a virtual clock that lands on it exactly.
1891
+ """
1892
+
1893
+ def _queue(self, n):
1894
+ for i in range(n):
1895
+ pending.enqueue(
1896
+ _payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
1897
+ )
1898
+
1899
+ def test_the_sequential_drain_uses_the_last_tick_of_its_budget(self):
1900
+ self._queue(2)
1901
+ os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "10"
1902
+ os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "10"
1903
+ start = 1000.0
1904
+ clock = {"t": start}
1905
+ gets = []
1906
+
1907
+ def fake_get(entry, timeout=30):
1908
+ gets.append(timeout)
1909
+ clock["t"] = start + 10.0 # entry 1 consumes the budget EXACTLY
1910
+ return True
1911
+
1912
+ with unittest.mock.patch.object(
1913
+ drain_pending.time, "monotonic", lambda: clock["t"]
1914
+ ):
1915
+ with unittest.mock.patch.object(
1916
+ drain_pending, "_document_state", fake_get
1917
+ ):
1918
+ summary = drain_pending.drain(CONFIG)
1919
+
1920
+ self.assertEqual(len(gets), 2, "entry 2 is still inside the budget at t==B")
1921
+ self.assertEqual(summary["reconciled"], 2)
1922
+ self.assertFalse(summary["budget_exceeded"])
1923
+ self.assertEqual(pending.count(), 0)
1924
+
1925
+ def test_the_backlog_wave_loop_uses_the_last_tick_of_its_budget(self):
1926
+ self._queue(2)
1927
+ os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "10"
1928
+ os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "1"
1929
+ # Keep _wait_for_upstream off the clock; its own boundary is pinned
1930
+ # by WaitForUpstreamBoundaryTest.
1931
+ os.environ["HINDSIGHT_DRAIN_P95_BACKOFF_MS"] = "0"
1932
+ start = 1000.0
1933
+ clock = {"t": start}
1934
+
1935
+ def fake_post(entry, timeout):
1936
+ clock["t"] = start + 10.0 # wave 1 consumes the budget EXACTLY
1937
+
1938
+ with unittest.mock.patch.object(
1939
+ drain_pending.time, "monotonic", lambda: clock["t"]
1940
+ ):
1941
+ with unittest.mock.patch.object(drain_pending, "_retry_one", fake_post):
1942
+ with unittest.mock.patch.object(
1943
+ drain_pending, "_document_state", lambda e, timeout=30: True
1944
+ ):
1945
+ with redirect_stderr(io.StringIO()):
1946
+ summary = drain_pending.drain_backlog(CONFIG, phase="drain")
1947
+
1948
+ self.assertEqual(summary["drained"], 2, "wave 2 starts at exactly t==B")
1949
+ self.assertFalse(summary["budget_exceeded"])
1950
+ self.assertEqual(pending.count(), 0)
1951
+
1952
+
1953
+ class WaitForUpstreamBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
1954
+ """``_wait_for_upstream`` called directly — its three comparisons.
1955
+
1956
+ Driving it through ``drain_backlog`` only ever exercised the
1957
+ disabled/slow/recovered cases; the boundaries below all survived a
1958
+ mutation sweep because no test pinned them.
1959
+ """
1960
+
1961
+ def _wait(self, backoff_ms, probes, now=1000.0, started=1000.0, budget=3600.0):
1962
+ slept = []
1963
+ seq = list(probes)
1964
+ with unittest.mock.patch.object(
1965
+ drain_pending.time, "monotonic", lambda: now
1966
+ ):
1967
+ with unittest.mock.patch.object(
1968
+ drain_pending, "_p95_probe_ms", lambda: seq.pop(0)
1969
+ ):
1970
+ with unittest.mock.patch.object(
1971
+ drain_pending.time, "sleep", slept.append
1972
+ ):
1973
+ with redirect_stderr(io.StringIO()) as err:
1974
+ got = drain_pending._wait_for_upstream(
1975
+ backoff_ms, started, budget
1976
+ )
1977
+ return got, slept, err.getvalue()
1978
+
1979
+ def test_backoff_disabled_never_pauses_even_when_the_probe_says_slow(self):
1980
+ """``backoff_ms <= 0`` means OFF — the probe is not consulted.
1981
+
1982
+ With ``<`` instead of ``<=``, a 0 (explicitly disabled) falls into
1983
+ the wait loop and a configured probe reporting 99s of p95 pauses
1984
+ the drain for two minutes a wave. Disabled must mean disabled.
1985
+ """
1986
+ got, slept, _ = self._wait(0, [99000, 99000, 99000])
1987
+ self.assertTrue(got)
1988
+ self.assertEqual(slept, [], "a disabled backoff must never sleep")
1989
+
1990
+ def test_a_p95_exactly_at_the_threshold_is_not_slow(self):
1991
+ """The knob is the tolerated ceiling, inclusive."""
1992
+ got, slept, out = self._wait(38000, [38000])
1993
+ self.assertTrue(got)
1994
+ self.assertEqual(slept, [])
1995
+ self.assertNotIn("BACKOFF", out)
1996
+
1997
+ def test_one_ms_over_the_threshold_is_slow(self):
1998
+ got, slept, out = self._wait(38000, [38001, 38000])
1999
+ self.assertTrue(got)
2000
+ self.assertEqual(slept, [120], "paused once, then p95 came back in")
2001
+ self.assertIn("BACKOFF", out)
2002
+
2003
+ def test_it_still_waits_when_the_pause_exactly_fits_the_budget(self):
2004
+ """120s of pause with exactly 120s left is affordable, not a give-up.
2005
+
2006
+ ``elapsed + 120 > budget`` vs ``>=``: only a clock that lands
2007
+ exactly on the budget separates them, so this one is virtual.
2008
+ """
2009
+ got, slept, out = self._wait(
2010
+ 38000, [99000, 1000], now=1120.0, started=1000.0, budget=240.0
2011
+ )
2012
+ self.assertTrue(got, "the wait fit the budget, so it must be taken")
2013
+ self.assertEqual(slept, [120])
2014
+ self.assertNotIn("budget is exhausted", out)
2015
+
2016
+ def test_it_gives_up_when_the_pause_would_overrun_the_budget(self):
2017
+ got, slept, out = self._wait(
2018
+ 38000, [99000], now=1121.0, started=1000.0, budget=240.0
2019
+ )
2020
+ self.assertFalse(got)
2021
+ self.assertEqual(slept, [])
2022
+ self.assertIn("budget is exhausted", out)
2023
+
2024
+ def test_a_zero_p95_is_fast_not_unknown(self):
2025
+ """``p95 < 0`` is the UNKNOWN sentinel; 0 is a real, fast reading.
2026
+
2027
+ (``< 0`` vs ``<= 0`` is an equivalent mutant here — a 0 reading
2028
+ returns True down either branch, since it is also ``<= backoff_ms``
2029
+ for any enabled backoff. Pinned anyway: the OUTCOME is what the
2030
+ drain depends on, and it must not become a pause.)
2031
+ """
2032
+ got, slept, _ = self._wait(38000, [0])
2033
+ self.assertTrue(got)
2034
+ self.assertEqual(slept, [])
2035
+
2036
+
2037
+ class ClipErrorBoundaryTest(unittest.TestCase):
2038
+ """``_clip_error`` clips ABOVE the cap and leaves the cap itself alone.
2039
+
2040
+ The ledger and every queued entry carry this string; the cap exists so
2041
+ one pathological upstream message cannot bloat the queue. ``<=`` vs
2042
+ ``<`` is invisible unless a message lands exactly on the cap, so that
2043
+ is the case pinned here.
2044
+ """
2045
+
2046
+ CAP = pending.MAX_ERROR_MESSAGE_CHARS
2047
+
2048
+ def test_a_message_exactly_at_the_cap_is_untouched(self):
2049
+ msg = "x" * self.CAP
2050
+ out = pending._clip_error(RuntimeError(msg))
2051
+ self.assertEqual(out, msg)
2052
+ self.assertNotIn("truncated", out)
2053
+
2054
+ def test_one_character_over_the_cap_is_clipped(self):
2055
+ out = pending._clip_error(RuntimeError("x" * (self.CAP + 1)))
2056
+ self.assertTrue(out.endswith("…[truncated]"))
2057
+ self.assertEqual(out[: self.CAP], "x" * self.CAP)
2058
+
2059
+ def test_a_short_message_is_untouched(self):
2060
+ self.assertEqual(pending._clip_error(RuntimeError("boom")), "boom")
2061
+
2062
+
2063
+ class EvictionsLogRotationBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
2064
+ """The eviction ledger rotates ABOVE its cap, not AT it.
2065
+
2066
+ ``switchroom doctor`` reads this file, and rotation drops all but
2067
+ ``EVICTIONS_LOG_KEEP_LINES``. ``>`` vs ``>=`` costs a whole ledger's
2068
+ history on a file that is exactly at its ceiling — invisible to the
2069
+ existing rotation test, which sets the cap to 1 byte and so can never
2070
+ land on the boundary.
2071
+ """
2072
+
2073
+ def setUp(self):
2074
+ super().setUp()
2075
+ self._prev = (
2076
+ pending.EVICTIONS_LOG_MAX_BYTES,
2077
+ pending.EVICTIONS_LOG_KEEP_LINES,
2078
+ )
2079
+ self.addCleanup(self._restore)
2080
+ log = pending.evictions_log_path()
2081
+ os.makedirs(os.path.dirname(log), exist_ok=True)
2082
+ self.log = log
2083
+
2084
+ def _restore(self):
2085
+ (
2086
+ pending.EVICTIONS_LOG_MAX_BYTES,
2087
+ pending.EVICTIONS_LOG_KEEP_LINES,
2088
+ ) = self._prev
2089
+
2090
+ def _one_line_bytes(self):
2091
+ """Write one ledger line with rotation effectively off; return its size."""
2092
+ pending.EVICTIONS_LOG_MAX_BYTES = 10**9
2093
+ pending.EVICTIONS_LOG_KEEP_LINES = 1
2094
+ with redirect_stderr(io.StringIO()):
2095
+ pending._log_eviction("aaaa.json", 10, "count", 1, 10)
2096
+ return os.path.getsize(self.log)
2097
+
2098
+ def _lines(self):
2099
+ with open(self.log, encoding="utf-8") as f:
2100
+ return [ln for ln in f.read().splitlines() if ln]
2101
+
2102
+ def test_a_ledger_exactly_at_the_cap_is_not_rotated(self):
2103
+ one = self._one_line_bytes()
2104
+ # Both lines are the same fixed-width shape, so two of them land
2105
+ # exactly on a 2*one cap.
2106
+ pending.EVICTIONS_LOG_MAX_BYTES = 2 * one
2107
+ with redirect_stderr(io.StringIO()):
2108
+ pending._log_eviction("aaaa.json", 10, "count", 1, 10)
2109
+ self.assertEqual(os.path.getsize(self.log), 2 * one, "fixture assumption")
2110
+ self.assertEqual(len(self._lines()), 2, "a ledger AT the cap is within it")
2111
+
2112
+ def test_one_byte_over_the_cap_rotates_to_the_keep_window(self):
2113
+ one = self._one_line_bytes()
2114
+ pending.EVICTIONS_LOG_MAX_BYTES = 2 * one - 1
2115
+ with redirect_stderr(io.StringIO()):
2116
+ pending._log_eviction("aaaa.json", 10, "count", 1, 10)
2117
+ self.assertEqual(len(self._lines()), 1, "over the cap, rotation runs")
2118
+
2119
+
2120
+ if __name__ == "__main__":
2121
+ unittest.main()