switchroom 0.19.26 → 0.19.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,401 @@
1
+ """A permanently-failing entry must stop costing LLM lane time.
2
+
3
+ Lives under ``scripts/tests/`` because that is the only python test directory
4
+ CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
5
+ tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
6
+
7
+ BACKGROUND. The permanence gate (``pending.is_permanent_failure``) keeps an
8
+ entry queued past ``MAX_ATTEMPTS`` on anything but a 4xx, and ``_over_budget``
9
+ / ``_drain_order`` then DEMOTE it so it cannot starve healthy entries behind
10
+ it. Both are right, and together they bound head-of-line blocking — but not
11
+ TOTAL work: the entry is still retried in full on every single run, forever.
12
+
13
+ That was affordable when every drain was supervised: a 4-second in-hook pass,
14
+ or an operator watching a manual sweep. The ``hindsight-drain`` sidecar makes
15
+ it unaffordable. Its backlog budget is 3600s (``_backlog_budget_seconds``)
16
+ against a 900s cooldown, so a wedged agent spends ~80% of wall-clock draining,
17
+ unattended, against 4 lanes shared with live retains, reflect and
18
+ consolidation — indefinitely. The interim host stopgap recorded exactly that
19
+ shape while it ran: ``finn 280s drained=0 retried=3 STALLED``, ``carrie 564s
20
+ drained=0 retried=2 (lane busy)``.
21
+
22
+ So an entry past ``_attempt_ceiling()`` is PARKED. Parking is not retirement:
23
+ the entry stays on disk, keeps being swept for free by the reconcile pass, and
24
+ comes back in full under ``--force``. What it stops is paying ~168s of a
25
+ shared lane, again, for a POST that has already failed 20 times.
26
+
27
+ Every test here fails without the ``_park_broken`` call in the drain paths:
28
+ the parked entry is retained again, which is the unbounded-retry behaviour.
29
+ """
30
+
31
+ import io
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+ import unittest
37
+ import unittest.mock
38
+ from contextlib import redirect_stderr
39
+
40
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
41
+ if SCRIPTS_DIR not in sys.path:
42
+ sys.path.insert(0, SCRIPTS_DIR)
43
+
44
+ import drain_pending # noqa: E402
45
+ import lib.pending as pending # noqa: E402
46
+
47
+ from tests.test_pending_drops import ( # noqa: E402
48
+ CONFIG,
49
+ _QueueTempDirMixin,
50
+ _cd_doc,
51
+ _payload,
52
+ )
53
+
54
+
55
+ class CircuitBreakerTest(_QueueTempDirMixin, unittest.TestCase):
56
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
57
+
58
+ def setUp(self):
59
+ super().setUp()
60
+ os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
61
+ self.posted = []
62
+
63
+ def _seed(self, attempts_by_content):
64
+ """Queue one entry per ``{content: attempt_count}`` pair."""
65
+ clock = iter(1000.0 + i for i in range(50))
66
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
67
+ for i, content in enumerate(attempts_by_content):
68
+ pending.enqueue(
69
+ _payload(content=content, doc=_cd_doc(i)), RuntimeError("x")
70
+ )
71
+ for path, entry in pending.iter_entries():
72
+ entry["attempt_count"] = attempts_by_content[entry["content"]]
73
+ with open(path, "w", encoding="utf-8") as f:
74
+ json.dump(entry, f)
75
+
76
+ def _record_post(self, entry, timeout):
77
+ self.posted.append(entry["content"])
78
+
79
+ def _drain(self, present=False, **kw):
80
+ with unittest.mock.patch.object(
81
+ drain_pending, "_retry_one", self._record_post
82
+ ):
83
+ with unittest.mock.patch.object(
84
+ drain_pending, "_document_state", lambda e, timeout=30: present
85
+ ):
86
+ with redirect_stderr(io.StringIO()) as err:
87
+ summary = drain_pending.drain(CONFIG, **kw)
88
+ return summary, err.getvalue()
89
+
90
+ # ── the breaker itself ────────────────────────────────────────────
91
+
92
+ def test_the_unattended_backlog_drain_stops_retrying_a_wedged_entry(self):
93
+ ceiling = drain_pending._attempt_ceiling()
94
+ self._seed({"wedged": ceiling, "healthy": 1})
95
+
96
+ summary, err = self._drain(backlog=True, phase="drain")
97
+
98
+ self.assertEqual(
99
+ self.posted,
100
+ ["healthy"],
101
+ "an entry past the attempt ceiling must not be retained again",
102
+ )
103
+ self.assertEqual(summary["parked"], 1)
104
+ self.assertIn("parked 1 entries past the attempt ceiling", err)
105
+
106
+ def test_parking_is_not_retirement_the_entry_is_still_on_disk(self):
107
+ """The whole permanence gate rests on never destroying a memory. A
108
+ breaker that deleted, or promoted to ``.dead``, would trade the
109
+ wedge for the data loss the gate exists to prevent."""
110
+ ceiling = drain_pending._attempt_ceiling()
111
+ self._seed({"wedged": ceiling})
112
+
113
+ self._drain(backlog=True, phase="drain")
114
+
115
+ self.assertEqual(pending.count(), 1)
116
+ self.assertEqual(
117
+ [e["content"] for _p, e in pending.iter_entries()], ["wedged"]
118
+ )
119
+ self.assertEqual(
120
+ [n for n in os.listdir(self._dir) if n.endswith(".dead")],
121
+ [],
122
+ "parking must not retire the entry",
123
+ )
124
+
125
+ def test_a_parked_entry_is_still_reconciled_for_free(self):
126
+ """Parking withholds the expensive retry, not the cheap proof: if the
127
+ document DID land, the free presence pass still retires the entry."""
128
+ ceiling = drain_pending._attempt_ceiling()
129
+ self._seed({"wedged": ceiling})
130
+
131
+ summary, _ = self._drain(backlog=True, present=True, phase="reconcile")
132
+
133
+ self.assertEqual(summary["reconciled"], 1)
134
+ self.assertEqual(self.posted, [], "no POST was needed")
135
+ self.assertEqual(pending.count(), 0)
136
+
137
+ def test_force_retries_the_parked_entries_anyway(self):
138
+ """The escape hatch. Without it the breaker would be a one-way door,
139
+ and an operator who fixed the upstream could never replay."""
140
+ ceiling = drain_pending._attempt_ceiling()
141
+ self._seed({"wedged": ceiling, "healthy": 1})
142
+
143
+ summary, _ = self._drain(backlog=True, phase="drain", force=True)
144
+
145
+ self.assertEqual(sorted(self.posted), ["healthy", "wedged"])
146
+ self.assertEqual(summary["parked"], 0)
147
+
148
+ def test_an_entry_one_attempt_below_the_ceiling_still_drains(self):
149
+ """The boundary. A breaker that fired early would park entries the
150
+ demotion logic is still successfully draining."""
151
+ ceiling = drain_pending._attempt_ceiling()
152
+ self._seed({"nearly": ceiling - 1})
153
+
154
+ summary, _ = self._drain(backlog=True, phase="drain")
155
+
156
+ self.assertEqual(self.posted, ["nearly"])
157
+ self.assertEqual(summary["parked"], 0)
158
+
159
+ def test_the_in_hook_drain_parks_too(self):
160
+ """Same policy on the SessionStart path: a 4s budget spent on an
161
+ entry that has failed 20 times is 4s not spent on the rest."""
162
+ ceiling = drain_pending._attempt_ceiling()
163
+ self._seed({"wedged": ceiling, "healthy": 1})
164
+
165
+ summary, _ = self._drain()
166
+
167
+ self.assertEqual(self.posted, ["healthy"])
168
+ self.assertEqual(summary["parked"], 1)
169
+
170
+ def test_repeated_unattended_runs_never_touch_the_wedged_entry_again(self):
171
+ """The actual failure mode: not one wasted run, but every run from
172
+ here to the heat death of the container."""
173
+ ceiling = drain_pending._attempt_ceiling()
174
+ self._seed({"wedged": ceiling})
175
+
176
+ for _ in range(5):
177
+ self._drain(backlog=True, phase="drain")
178
+
179
+ self.assertEqual(self.posted, [], "5 unattended ticks, zero lane time")
180
+ self.assertEqual(pending.count(), 1, "and the memory is still there")
181
+
182
+ # ── the ceiling ───────────────────────────────────────────────────
183
+
184
+ def test_the_default_ceiling_is_a_multiple_of_the_attempt_budget(self):
185
+ self.assertEqual(
186
+ drain_pending._attempt_ceiling(),
187
+ pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
188
+ )
189
+ self.assertGreater(
190
+ drain_pending._attempt_ceiling(),
191
+ pending.MAX_ATTEMPTS,
192
+ "parking at the attempt budget would collide with demotion",
193
+ )
194
+
195
+ def test_the_ceiling_is_operator_tunable(self):
196
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "7"
197
+ self.assertEqual(drain_pending._attempt_ceiling(), 7)
198
+ self._seed({"wedged": 7, "healthy": 6})
199
+
200
+ summary, _ = self._drain(backlog=True, phase="drain")
201
+
202
+ self.assertEqual(self.posted, ["healthy"])
203
+ self.assertEqual(summary["parked"], 1)
204
+
205
+ def test_a_ceiling_below_the_attempt_budget_is_clamped_up(self):
206
+ """Below ``MAX_ATTEMPTS`` the breaker would park entries before the
207
+ ordinary retry policy has finished with them."""
208
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "1"
209
+ self.assertEqual(drain_pending._attempt_ceiling(), pending.MAX_ATTEMPTS)
210
+
211
+ def test_a_garbage_ceiling_falls_back_to_the_default(self):
212
+ os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "not-a-number"
213
+ self.assertEqual(
214
+ drain_pending._attempt_ceiling(),
215
+ pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
216
+ )
217
+
218
+ def test_a_corrupt_attempt_counter_never_parks_and_never_raises(self):
219
+ """Same rule as ``_over_budget``: a hand-edited counter must not
220
+ decide policy, and must not blow up the drain path."""
221
+ self.assertFalse(drain_pending._circuit_broken({"attempt_count": "many"}))
222
+ self.assertFalse(drain_pending._circuit_broken({"attempt_count": None}))
223
+ self.assertFalse(drain_pending._circuit_broken({}))
224
+
225
+ # ── reporting: parking must be VISIBLE, on every path ─────────────
226
+
227
+ def _main(self, argv, present=False):
228
+ """Run the CLI entrypoint, capturing what an operator would see."""
229
+ with unittest.mock.patch.object(drain_pending, "load_config", lambda: CONFIG):
230
+ with unittest.mock.patch.object(
231
+ drain_pending, "_retry_one", self._record_post
232
+ ):
233
+ with unittest.mock.patch.object(
234
+ drain_pending, "_document_state", lambda e, timeout=30: present
235
+ ):
236
+ with redirect_stderr(io.StringIO()) as err:
237
+ rc = drain_pending.main(argv)
238
+ return rc, err.getvalue()
239
+
240
+ def test_an_in_hook_run_that_only_parks_still_says_so(self):
241
+ """THE OBSERVABILITY HOLE THE CIRCUIT BREAKER OPENS.
242
+
243
+ A run whose entire queue is past the ceiling parks everything and
244
+ does nothing else, so every other summary counter is 0. With
245
+ ``parked`` missing from ``main()``'s gate that run printed NOTHING —
246
+ rc=0, empty stdout, empty stderr — which is byte-identical to "the
247
+ queue was empty". The operator watching a queue that will not shrink
248
+ then has no way to tell "parked by the breaker, needs ``--force``"
249
+ from "ordinary backlog, drains on its own", because ``switchroom
250
+ doctor`` reads only the queue DIRECTORY and both look like a file.
251
+
252
+ The ``--backlog`` path narrates its own parking. This is the IN-HOOK
253
+ path, which is the one that runs on every session boot.
254
+ """
255
+ ceiling = drain_pending._attempt_ceiling()
256
+ self._seed({"a": ceiling, "b": ceiling, "c": ceiling})
257
+
258
+ rc, err = self._main([])
259
+
260
+ self.assertEqual(rc, 0)
261
+ self.assertEqual(self.posted, [], "nothing was retried — all parked")
262
+ self.assertNotEqual(
263
+ err.strip(), "", "a run that parked the whole queue must not be silent"
264
+ )
265
+ self.assertIn("parked=3", err)
266
+
267
+ def test_the_summary_line_reports_parked_alongside_other_work(self):
268
+ """The gate is not the only half. With ``parked`` in the gate but
269
+ absent from the printed line, a mixed run opens the gate on its other
270
+ counters and still never names the parked entries."""
271
+ ceiling = drain_pending._attempt_ceiling()
272
+ self._seed({"wedged": ceiling, "healthy": 1})
273
+
274
+ rc, err = self._main([], present=True)
275
+
276
+ self.assertEqual(rc, 0)
277
+ self.assertIn("reconciled=1", err)
278
+ self.assertIn("parked=1", err)
279
+
280
+
281
+ class CeilingArithmeticTest(_QueueTempDirMixin, unittest.TestCase):
282
+ """How long 20 attempts actually buys — measured, not asserted in prose.
283
+
284
+ ``ATTEMPT_CEILING_MULTIPLE``'s comment used to claim 20 attempts was
285
+ "most of a day … far past any transient upstream outage". It is not, and
286
+ the reason is the non-obvious part: attempts convert to wall-clock only
287
+ via the QUEUE SIZE. A drain run attempts each live entry at most once and
288
+ ``enqueue`` already records attempt 1, so the floor is 19 further runs —
289
+ under 5 hours at the sidecar's 900s cooldown. ``STALL_THRESHOLD`` only
290
+ rations attempts while entries are inside ``MAX_ATTEMPTS``; past it they
291
+ abstain from the stall guard, so a uniformly over-budget queue is
292
+ attempted in full on every run.
293
+
294
+ This measures the real figures and pins the comment's table to them, so
295
+ the prose cannot drift away from the code again: change the mechanism and
296
+ the measured column moves; delete the table and the parse fails.
297
+ """
298
+
299
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
300
+
301
+ #: Rows the module comment must carry: queue size → drain runs until the
302
+ #: WHOLE queue is parked, under a total upstream outage.
303
+ SIZES = (1, 4, 13, 30)
304
+
305
+ #: The sidecar's default cooldown (``profiles/_base/start.sh.hbs``); the
306
+ #: comment's hours column is runs x this.
307
+ COOLDOWN_S = 900
308
+
309
+ def setUp(self):
310
+ super().setUp()
311
+ os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
312
+ # No p95 backoff, no pacing: this test counts RUNS, not seconds.
313
+ os.environ.pop("HINDSIGHT_DRAIN_P95_CMD", None)
314
+
315
+ def _runs_until_wholly_parked(self, n: int) -> int:
316
+ # Own queue dir per call, so several sizes can be measured in one
317
+ # test without one size's entries contaminating the next. The mixin
318
+ # owns HINDSIGHT_PENDING_DIR and restores it in tearDown; the
319
+ # directory itself lives under the mixin's tmp root and goes with it.
320
+ os.environ["HINDSIGHT_PENDING_DIR"] = os.path.join(
321
+ self._tmp, f"pending-retains-{n}"
322
+ )
323
+ clock = iter(1000.0 + i for i in range(n + 10))
324
+ with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
325
+ for i in range(n):
326
+ pending.enqueue(
327
+ _payload(content=f"e{i}", doc=_cd_doc(i)),
328
+ ConnectionError("upstream down"),
329
+ )
330
+
331
+ def always_fails(entry, timeout=30):
332
+ raise ConnectionError("upstream down")
333
+
334
+ runs = 0
335
+ # Generous ceiling on the loop itself: a mechanism change that made
336
+ # parking unreachable must fail loudly rather than hang CI.
337
+ while runs < 500:
338
+ runs += 1
339
+ with unittest.mock.patch.object(
340
+ drain_pending, "_retry_one", always_fails
341
+ ):
342
+ with unittest.mock.patch.object(
343
+ drain_pending, "_document_state", lambda e, timeout=30: False
344
+ ):
345
+ with redirect_stderr(io.StringIO()):
346
+ summary = drain_pending.drain(
347
+ CONFIG, backlog=True, phase="drain"
348
+ )
349
+ if summary["parked"] == n:
350
+ return runs
351
+ self.fail(f"a {n}-entry queue never parked wholly within 500 runs")
352
+
353
+ def _documented_table(self) -> dict:
354
+ """Parse the table out of ``ATTEMPT_CEILING_MULTIPLE``'s comment."""
355
+ with open(drain_pending.__file__, encoding="utf-8") as f:
356
+ src = f.read()
357
+ rows = re.findall(r"^#: (\d+)\s+(\d+)\s+([\d.]+) h$", src, re.M)
358
+ return {int(q): (int(r), float(h)) for q, r, h in rows}
359
+
360
+ def test_the_documented_table_is_what_the_code_actually_does(self):
361
+ documented = self._documented_table()
362
+ self.assertEqual(
363
+ sorted(documented),
364
+ sorted(self.SIZES),
365
+ "ATTEMPT_CEILING_MULTIPLE's comment must document exactly these "
366
+ "queue sizes — the prose is the deliverable here",
367
+ )
368
+ for n in self.SIZES:
369
+ with self.subTest(queue=n):
370
+ measured = self._runs_until_wholly_parked(n)
371
+ runs, hours = documented[n]
372
+ self.assertEqual(
373
+ measured,
374
+ runs,
375
+ f"a {n}-entry queue parks wholly in {measured} runs, but "
376
+ f"the comment says {runs}",
377
+ )
378
+ self.assertAlmostEqual(
379
+ hours, runs * self.COOLDOWN_S / 3600.0, places=1
380
+ )
381
+
382
+ def test_a_small_queue_parks_well_inside_one_overnight_outage(self):
383
+ """The claim the old comment made, stated as the outcome it is not.
384
+
385
+ 4 entries — a healthy agent — park entirely in 6 hours. So a single
386
+ overnight upstream outage CAN park a small queue wholesale, which is
387
+ exactly what "far past any transient upstream outage" denied. Pinned
388
+ as an outcome so nobody re-asserts the denial.
389
+ """
390
+ measured = self._runs_until_wholly_parked(4)
391
+ hours = measured * self.COOLDOWN_S / 3600.0
392
+ self.assertLess(
393
+ hours,
394
+ 12.0,
395
+ "a 4-entry queue parking in under 12h is the accepted trade — if "
396
+ "this ever became untrue the comment must be re-measured too",
397
+ )
398
+
399
+
400
+ if __name__ == "__main__":
401
+ unittest.main()
@@ -0,0 +1,286 @@
1
+ """Two drains of one queue must never run at once — whoever the caller is.
2
+
3
+ Lives under ``scripts/tests/`` because that is the only python test directory
4
+ CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
5
+ tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
6
+
7
+ WHY THIS IS LOAD-BEARING. Retain extraction on this fleet is served by 4
8
+ shared LLM lanes and a single entry measured 85-280s, so two drains walking
9
+ the same queue is not a tidiness problem: it is the same ~168s of a scarce
10
+ fleet-wide resource, spent twice, for one memory. The queue has three
11
+ independent drain paths and NONE of them is in a position to serialise the
12
+ others:
13
+
14
+ * ``session_start.py`` imports ``drain`` and calls it in-process at every
15
+ session boot, with no lock of its own;
16
+ * the ``hindsight-drain`` sidecar (``profiles/_base/start.sh.hbs``) ticks on
17
+ a timer inside the same container;
18
+ * ``switchroom doctor`` documents an out-of-band ``docker exec …
19
+ drain_pending.py --backlog`` for operators clearing a backlog.
20
+
21
+ An agent booting a session while the sidecar is mid-backlog is the ordinary
22
+ case, not a corner. So the lock is taken inside ``drain()`` — the one
23
+ function all three go through — and these tests pin that it holds for the
24
+ in-process caller AND for a genuinely separate process running the CLI.
25
+
26
+ Every test here fails if ``_exclusive_drain`` is removed from ``drain()``:
27
+ the work happens twice instead of being skipped.
28
+ """
29
+
30
+ import fcntl
31
+ import io
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import unittest
37
+ import unittest.mock
38
+ from contextlib import contextmanager, redirect_stderr
39
+
40
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
41
+ if SCRIPTS_DIR not in sys.path:
42
+ sys.path.insert(0, SCRIPTS_DIR)
43
+
44
+ import drain_pending # noqa: E402
45
+ import lib.pending as pending # noqa: E402
46
+
47
+ from tests.test_pending_drops import ( # noqa: E402
48
+ CONFIG,
49
+ _QueueTempDirMixin,
50
+ _cd_doc,
51
+ _payload,
52
+ )
53
+
54
+
55
+ @contextmanager
56
+ def _held_by_someone_else(path):
57
+ """Hold the drain lock on a SEPARATE open file description.
58
+
59
+ ``flock`` locks belong to the open file description, not to the process,
60
+ so a second ``open()`` of the same file contends with this one exactly as
61
+ another process would (flock(2): "these file descriptors are treated
62
+ independently"). That keeps the test deterministic — no sleeps, no race
63
+ with a child's startup — and the CLI case below covers a real process.
64
+ """
65
+ os.makedirs(os.path.dirname(path), exist_ok=True)
66
+ fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
67
+ try:
68
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
69
+ yield
70
+ finally:
71
+ os.close(fd)
72
+
73
+
74
+ class DrainLockTest(_QueueTempDirMixin, unittest.TestCase):
75
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_LOCK",)
76
+
77
+ def setUp(self):
78
+ super().setUp()
79
+ self.posted = []
80
+ self.presence_checks = []
81
+
82
+ def _seed(self, n=3):
83
+ for i in range(n):
84
+ pending.enqueue(_payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x"))
85
+
86
+ def _record_post(self, entry, timeout):
87
+ self.posted.append(entry["content"])
88
+
89
+ def _record_presence(self, entry, timeout=30):
90
+ self.presence_checks.append(entry["content"])
91
+ return True
92
+
93
+ def _drain(self, **kw):
94
+ with unittest.mock.patch.object(
95
+ drain_pending, "_retry_one", self._record_post
96
+ ):
97
+ with unittest.mock.patch.object(
98
+ drain_pending, "_document_state", self._record_presence
99
+ ):
100
+ with redirect_stderr(io.StringIO()) as err:
101
+ summary = drain_pending.drain(CONFIG, **kw)
102
+ return summary, err.getvalue()
103
+
104
+ # ── the lock sits beside the queue it protects ────────────────────
105
+
106
+ def test_the_lock_lives_next_to_the_queue_it_protects(self):
107
+ """One lock per queue, so ``HINDSIGHT_PENDING_DIR`` moves both."""
108
+ self.assertEqual(
109
+ drain_pending._drain_lock_path(),
110
+ os.path.join(self._tmp, "drain-pending.lock"),
111
+ )
112
+
113
+ def test_the_production_path_is_the_agent_hindsight_dir(self):
114
+ """What the sidecar and the doctor recovery command actually share."""
115
+ os.environ.pop("HINDSIGHT_PENDING_DIR")
116
+ with unittest.mock.patch.dict(os.environ, {"HOME": "/state/agent/home"}):
117
+ self.assertEqual(
118
+ drain_pending._drain_lock_path(),
119
+ "/state/agent/home/.hindsight/drain-pending.lock",
120
+ )
121
+
122
+ def test_it_is_not_the_path_the_interim_host_cron_wraps(self):
123
+ """Deliberate: same path would make that wrapper starve this drain.
124
+
125
+ The interim host stopgap runs ``docker exec … flock -n
126
+ $HOME/.hindsight/drain.lock python3 drain_pending.py --backlog``. If
127
+ this module locked THAT path, the wrapper would already hold it and
128
+ the drain it just launched would skip every time — a silent no-op for
129
+ as long as both exist.
130
+ """
131
+ self.assertNotIn("/drain.lock", drain_pending._drain_lock_path())
132
+
133
+ # ── the in-process caller (SessionStart) ──────────────────────────
134
+
135
+ def test_the_in_hook_drain_does_nothing_while_another_drain_holds_it(self):
136
+ """``session_start.py`` calls this exact function, with no lock."""
137
+ self._seed(3)
138
+
139
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
140
+ summary, err = self._drain()
141
+
142
+ self.assertEqual(self.posted, [], "no entry may be re-POSTed by a 2nd drain")
143
+ self.assertEqual(
144
+ self.presence_checks, [], "not even a free GET: the run did not happen"
145
+ )
146
+ self.assertTrue(summary["skipped_locked"])
147
+ self.assertEqual(summary["drained"], 0)
148
+ self.assertEqual(summary["reconciled"], 0)
149
+ self.assertEqual(pending.count(), 3, "the queue is untouched")
150
+ self.assertIn("another drain holds", err)
151
+
152
+ def test_the_backlog_drain_does_nothing_while_another_drain_holds_it(self):
153
+ """The sidecar's mode. Phase 1 is free, but it is not idempotent-free:
154
+ two reconcilers race on the same archive move."""
155
+ self._seed(3)
156
+
157
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
158
+ summary, _ = self._drain(backlog=True)
159
+
160
+ self.assertEqual(self.posted, [])
161
+ self.assertEqual(self.presence_checks, [])
162
+ self.assertTrue(summary["skipped_locked"])
163
+ self.assertEqual(pending.count(), 3)
164
+
165
+ def test_the_skip_is_the_lock_and_not_an_unconditional_refusal(self):
166
+ """Guard rail: a drain that always skipped would pass the tests above
167
+ and drain nothing in production, which is the bug the whole PR is
168
+ about. Same queue, same call, lock free → the work happens."""
169
+ self._seed(3)
170
+
171
+ # `phase="drain"` so the free reconcile pass does not retire the
172
+ # entries before phase 2 can post them — the assertion here is that
173
+ # the EXPENSIVE work runs when the lock is free.
174
+ summary, _ = self._drain(backlog=True, phase="drain")
175
+
176
+ self.assertEqual(sorted(self.posted), ["c0", "c1", "c2"])
177
+ self.assertFalse(summary["skipped_locked"])
178
+ self.assertEqual(pending.count(), 0)
179
+
180
+ def test_the_lock_is_released_when_the_run_finishes(self):
181
+ """Not a one-shot: the next tick, 900s later, must still get in."""
182
+ self._seed(1)
183
+ self._drain(backlog=True)
184
+
185
+ # If the previous run leaked its fd, this acquisition raises.
186
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
187
+ pass
188
+
189
+ def test_an_unopenable_lock_still_drains(self):
190
+ """A read-only ``.hindsight/`` must not silently disable memory replay.
191
+
192
+ Losing serialisation costs duplicated LLM work; losing the drain costs
193
+ the memory itself. The cheaper failure wins, loudly.
194
+ """
195
+ self._seed(1)
196
+ os.environ["HINDSIGHT_DRAIN_LOCK"] = os.path.join(
197
+ self._tmp, "no-such-dir", "x", "drain-pending.lock"
198
+ )
199
+ with unittest.mock.patch.object(
200
+ drain_pending.os, "makedirs", side_effect=PermissionError("read-only")
201
+ ):
202
+ summary, err = self._drain(backlog=True)
203
+
204
+ self.assertEqual(self.posted, ["c0"], "the drain must still run")
205
+ self.assertFalse(summary["skipped_locked"])
206
+ self.assertIn("WITHOUT serialisation", err)
207
+
208
+ # ── a genuinely separate process (the doctor recovery command) ────
209
+
210
+ def test_the_cli_a_separate_process_runs_also_skips(self):
211
+ """``switchroom doctor`` tells operators to run this by hand, with no
212
+ ``flock``. Before the lock moved into this module, doing that while
213
+ the sidecar ticked meant two processes on one queue."""
214
+ self._seed(2)
215
+ env = dict(os.environ)
216
+ env["HINDSIGHT_PENDING_DIR"] = self._dir
217
+ env["HOME"] = self._tmp
218
+
219
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
220
+ proc = subprocess.run(
221
+ [
222
+ sys.executable,
223
+ os.path.join(SCRIPTS_DIR, "drain_pending.py"),
224
+ "--backlog",
225
+ ],
226
+ capture_output=True,
227
+ text=True,
228
+ timeout=60,
229
+ env=env,
230
+ )
231
+
232
+ self.assertEqual(proc.returncode, 0, proc.stderr)
233
+ self.assertIn("another drain holds", proc.stderr)
234
+ self.assertEqual(
235
+ pending.count(), 2, "a second process must not touch the queue"
236
+ )
237
+ # And nothing was posted: the entries point at 127.0.0.1:9, so a run
238
+ # that had NOT skipped would have recorded attempts against them.
239
+ for _path, entry in pending.iter_entries():
240
+ self.assertEqual(int(entry.get("attempt_count", 0)), 1)
241
+
242
+
243
+ class SessionStartUsesTheLockedEntryPointTest(unittest.TestCase):
244
+ """The hook must not acquire a private, unlocked path into the queue.
245
+
246
+ ``session_start.py`` does ``from drain_pending import drain as
247
+ drain_pending_retains``. If it ever imported an impl helper instead (or a
248
+ future refactor moved the work out from under the lock) the serialisation
249
+ guarantee would silently regress for the one caller that runs on every
250
+ single session boot.
251
+ """
252
+
253
+ def test_session_start_imports_the_locked_public_drain(self):
254
+ with open(
255
+ os.path.join(SCRIPTS_DIR, "session_start.py"), encoding="utf-8"
256
+ ) as f:
257
+ src = f.read()
258
+ self.assertIn("from drain_pending import drain as drain_pending_retains", src)
259
+ self.assertNotIn("_drain_inhook_impl", src)
260
+ self.assertNotIn("_drain_backlog_impl", src)
261
+
262
+ def test_the_public_drain_is_what_takes_the_lock(self):
263
+ """Executable half of the check above: calling ``drain`` — the symbol
264
+ session_start binds — is what consults the lock."""
265
+ seen = []
266
+ real = drain_pending._exclusive_drain
267
+
268
+ def spy():
269
+ seen.append(True)
270
+ return real()
271
+
272
+ # A throwaway queue dir, so neither the empty queue nor the lock file
273
+ # this creates lands in the repo checkout.
274
+ with tempfile.TemporaryDirectory() as tmp:
275
+ env = {"HINDSIGHT_PENDING_DIR": os.path.join(tmp, "pending-retains")}
276
+ with unittest.mock.patch.dict(os.environ, env):
277
+ with unittest.mock.patch.object(
278
+ drain_pending, "_exclusive_drain", spy
279
+ ):
280
+ with redirect_stderr(io.StringIO()):
281
+ drain_pending.drain(CONFIG)
282
+ self.assertEqual(seen, [True], "drain() must consult the lock every call")
283
+
284
+
285
+ if __name__ == "__main__":
286
+ unittest.main()