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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- 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()
|