switchroom 0.19.18 → 0.19.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""``pending.is_permanent_failure`` — the gate on retiring a memory.
|
|
2
|
+
|
|
3
|
+
Lives under ``scripts/tests/`` because that is the only python test
|
|
4
|
+
directory CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest
|
|
5
|
+
discover tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
|
|
6
|
+
|
|
7
|
+
The rule under test is asymmetric on purpose. Misclassifying a permanent
|
|
8
|
+
failure as retryable costs a re-POST (an upsert — time). Misclassifying a
|
|
9
|
+
transient failure as permanent costs the user's memory. So only a positively
|
|
10
|
+
identified client-side 4xx is permanent; everything else, including anything
|
|
11
|
+
unrecognised, is retryable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
19
|
+
|
|
20
|
+
from lib.pending import is_permanent_failure # noqa: E402
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _http(code, body=""):
|
|
24
|
+
"""The shape ``client.HindsightClient._request`` raises."""
|
|
25
|
+
return RuntimeError(f"HTTP {code} from http://hindsight/v1/x: {body}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestIsPermanentFailure(unittest.TestCase):
|
|
29
|
+
def test_client_errors_are_permanent(self):
|
|
30
|
+
for code in (400, 401, 403, 404, 409, 413, 422):
|
|
31
|
+
with self.subTest(code=code):
|
|
32
|
+
self.assertTrue(is_permanent_failure(_http(code)))
|
|
33
|
+
|
|
34
|
+
def test_transient_4xx_are_not_permanent(self):
|
|
35
|
+
"""408/425/429 wear a 4xx status but describe a transient state."""
|
|
36
|
+
for code in (408, 425, 429):
|
|
37
|
+
with self.subTest(code=code):
|
|
38
|
+
self.assertFalse(is_permanent_failure(_http(code)))
|
|
39
|
+
|
|
40
|
+
def test_server_errors_are_retryable(self):
|
|
41
|
+
for code in (500, 502, 503, 504):
|
|
42
|
+
with self.subTest(code=code):
|
|
43
|
+
self.assertFalse(is_permanent_failure(_http(code)))
|
|
44
|
+
|
|
45
|
+
def test_extraction_failure_500_is_retryable(self):
|
|
46
|
+
"""The measured dominant failure (2026-07-26 fleet backlog).
|
|
47
|
+
|
|
48
|
+
Both observed variants are one bad sampling run, not bad content:
|
|
49
|
+
an empty completion, and a numbered prose list where JSON was asked
|
|
50
|
+
for. Neither is evidence the memory can never be persisted.
|
|
51
|
+
"""
|
|
52
|
+
empty = _http(
|
|
53
|
+
500,
|
|
54
|
+
'{"detail":"Fact extraction failed: 1/1 chunks failed. First '
|
|
55
|
+
'failures: chunk 0: JSONDecodeError: Expecting value: line 1 '
|
|
56
|
+
'column 1 (char 0)"}',
|
|
57
|
+
)
|
|
58
|
+
prose = _http(
|
|
59
|
+
500,
|
|
60
|
+
'{"detail":"Fact extraction failed: 1/1 chunks failed. First '
|
|
61
|
+
'failures: chunk 0: JSONDecodeError: Extra data: line 1 column 2 '
|
|
62
|
+
'(char 1)"}',
|
|
63
|
+
)
|
|
64
|
+
self.assertFalse(is_permanent_failure(empty))
|
|
65
|
+
self.assertFalse(is_permanent_failure(prose))
|
|
66
|
+
|
|
67
|
+
def test_transport_failures_are_retryable(self):
|
|
68
|
+
for err in (
|
|
69
|
+
TimeoutError("timed out"),
|
|
70
|
+
ConnectionError("connection reset"),
|
|
71
|
+
OSError("network unreachable"),
|
|
72
|
+
):
|
|
73
|
+
with self.subTest(err=type(err).__name__):
|
|
74
|
+
self.assertFalse(is_permanent_failure(err))
|
|
75
|
+
|
|
76
|
+
def test_unclassifiable_error_is_retryable(self):
|
|
77
|
+
"""The fail-safe direction: unknown means keep the memory."""
|
|
78
|
+
self.assertFalse(is_permanent_failure(RuntimeError("something odd")))
|
|
79
|
+
self.assertFalse(is_permanent_failure(RuntimeError("HTTP from x")))
|
|
80
|
+
self.assertFalse(is_permanent_failure(RuntimeError("HTTP notanumber")))
|
|
81
|
+
|
|
82
|
+
def test_status_read_from_a_chained_cause(self):
|
|
83
|
+
"""A live ``urllib.error.HTTPError`` chained on ``__cause__``."""
|
|
84
|
+
|
|
85
|
+
class _FakeHTTPError(Exception):
|
|
86
|
+
code = 404
|
|
87
|
+
|
|
88
|
+
wrapped = RuntimeError("upstream rejected the retain")
|
|
89
|
+
wrapped.__cause__ = _FakeHTTPError()
|
|
90
|
+
self.assertTrue(is_permanent_failure(wrapped))
|
|
91
|
+
|
|
92
|
+
wrapped5xx = RuntimeError("upstream blew up")
|
|
93
|
+
cause = _FakeHTTPError()
|
|
94
|
+
cause.code = 503
|
|
95
|
+
wrapped5xx.__cause__ = cause
|
|
96
|
+
self.assertFalse(is_permanent_failure(wrapped5xx))
|
|
97
|
+
|
|
98
|
+
def test_direct_code_attribute_wins(self):
|
|
99
|
+
err = OSError("boom")
|
|
100
|
+
err.code = 400
|
|
101
|
+
self.assertTrue(is_permanent_failure(err))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
unittest.main()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The drain must never wedge on entries the permanence gate keeps forever.
|
|
2
|
+
|
|
3
|
+
Lives under ``scripts/tests/`` because that is the only python test directory
|
|
4
|
+
CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
|
|
5
|
+
tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
|
|
6
|
+
|
|
7
|
+
BACKGROUND. ``pending.is_permanent_failure`` stopped the drain retiring a
|
|
8
|
+
memory to ``.dead`` on a transient failure — correct, because a flaky
|
|
9
|
+
extraction model was destroying memories that were never unsaveable. But
|
|
10
|
+
``.dead`` was doing double duty: it was the honesty policy AND it was the
|
|
11
|
+
queue's only un-wedging mechanism. An entry that fails transiently on every
|
|
12
|
+
drain (an oversized payload enqueued before #3610 split them, say, which
|
|
13
|
+
``lib/pending``'s own docstring notes "still never drains until it is split")
|
|
14
|
+
is now immortal. ``iter_entries()`` is oldest-first and backlog concurrency
|
|
15
|
+
defaults to 1, so those immortal entries sit at the head of every run, and
|
|
16
|
+
three of them trip ``STALL_THRESHOLD`` before anything behind them is tried.
|
|
17
|
+
|
|
18
|
+
Without the ``_drain_order`` / ``_over_budget`` demotions this file pins,
|
|
19
|
+
that produced ``stalled=True, drained=0`` on EVERY run, forever, with healthy
|
|
20
|
+
entries queued behind and never attempted.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import unittest
|
|
29
|
+
import unittest.mock
|
|
30
|
+
from contextlib import redirect_stderr
|
|
31
|
+
|
|
32
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
33
|
+
if SCRIPTS_DIR not in sys.path:
|
|
34
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
35
|
+
|
|
36
|
+
import drain_pending # noqa: E402
|
|
37
|
+
import lib.pending as pending # noqa: E402
|
|
38
|
+
|
|
39
|
+
from tests.test_pending_drops import ( # noqa: E402
|
|
40
|
+
CONFIG,
|
|
41
|
+
_QueueTempDirMixin,
|
|
42
|
+
_cd_doc,
|
|
43
|
+
_payload,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
#: The immortal entries: they fail with a TRANSIENT error on every drain, so
|
|
47
|
+
#: the permanence gate correctly refuses to retire them — forever.
|
|
48
|
+
POISON = {"c0", "c1", "c2"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DrainWedgeTest(_QueueTempDirMixin, unittest.TestCase):
|
|
52
|
+
def _seed(self, n, attempts_for):
|
|
53
|
+
# Entry filenames are `<unix-ms>-<uuid>.json` and FIFO order is the
|
|
54
|
+
# lexicographic sort of that name, so pin the clock to make "oldest"
|
|
55
|
+
# deterministic rather than a uuid tie-break.
|
|
56
|
+
clock = iter(1000.0 + i for i in range(50))
|
|
57
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
58
|
+
for i in range(n):
|
|
59
|
+
pending.enqueue(
|
|
60
|
+
_payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x")
|
|
61
|
+
)
|
|
62
|
+
for path, entry in pending.iter_entries():
|
|
63
|
+
entry["attempt_count"] = attempts_for(entry["content"])
|
|
64
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
65
|
+
json.dump(entry, f)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _retry(entry, timeout):
|
|
69
|
+
if entry["content"] in POISON:
|
|
70
|
+
raise TimeoutError("client deadline exceeded")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def _drain_once(self):
|
|
74
|
+
# `_document_state` mirrors `_retry`: a healthy entry's POST lands and
|
|
75
|
+
# the confirming GET sees it, a poison entry never persists. Anything
|
|
76
|
+
# else would let the free reconcile pass retire the poison entries and
|
|
77
|
+
# hide the very wedge under test.
|
|
78
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", self._retry):
|
|
79
|
+
with unittest.mock.patch.object(
|
|
80
|
+
drain_pending,
|
|
81
|
+
"_document_state",
|
|
82
|
+
lambda e, timeout=30: e["content"] not in POISON,
|
|
83
|
+
):
|
|
84
|
+
with redirect_stderr(io.StringIO()):
|
|
85
|
+
return drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
86
|
+
|
|
87
|
+
def test_poison_heads_do_not_starve_healthy_entries_behind_them(self):
|
|
88
|
+
"""The common shape: a few immortal entries in front of good ones.
|
|
89
|
+
|
|
90
|
+
They are the OLDEST by construction (they have been failing longest),
|
|
91
|
+
so oldest-first ordering puts them exactly where they do most damage.
|
|
92
|
+
"""
|
|
93
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
|
|
94
|
+
|
|
95
|
+
summary = self._drain_once()
|
|
96
|
+
|
|
97
|
+
self.assertEqual(summary["drained"], 3, "healthy entries must drain")
|
|
98
|
+
self.assertFalse(summary["stalled"], "3 immortal entries must not stall the run")
|
|
99
|
+
self.assertEqual(pending.count(), 3, "only the immortal entries remain queued")
|
|
100
|
+
|
|
101
|
+
def test_whole_queue_over_budget_still_drains_after_recovery(self):
|
|
102
|
+
"""The shape that demotion-by-ORDERING alone does not fix.
|
|
103
|
+
|
|
104
|
+
An upstream down for a week takes every entry past ``MAX_ATTEMPTS``.
|
|
105
|
+
When it recovers, the over-budget partition holds the entire queue, so
|
|
106
|
+
ordering is a no-op and the poison entries are back at the head. Only
|
|
107
|
+
the stall-guard abstention (``_over_budget``) gets past them.
|
|
108
|
+
"""
|
|
109
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS)
|
|
110
|
+
|
|
111
|
+
summary = self._drain_once()
|
|
112
|
+
|
|
113
|
+
self.assertEqual(summary["drained"], 3, "recovered entries must drain")
|
|
114
|
+
self.assertFalse(summary["stalled"])
|
|
115
|
+
self.assertEqual(pending.count(), 3)
|
|
116
|
+
|
|
117
|
+
def test_repeated_runs_converge_instead_of_repeating_a_zero_drain(self):
|
|
118
|
+
"""The wedge was defined by NON-convergence: identical useless runs."""
|
|
119
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
|
|
120
|
+
|
|
121
|
+
depths = []
|
|
122
|
+
for _ in range(3):
|
|
123
|
+
self._drain_once()
|
|
124
|
+
depths.append(pending.count())
|
|
125
|
+
|
|
126
|
+
self.assertEqual(depths, [3, 3, 3], "converges after run 0 and stays there")
|
|
127
|
+
|
|
128
|
+
def test_a_genuine_upstream_outage_still_stalls(self):
|
|
129
|
+
"""The guard rail on the fix: abstention must not disarm the guard.
|
|
130
|
+
|
|
131
|
+
Entries INSIDE their attempt budget still vote, so a real outage is
|
|
132
|
+
caught after ``STALL_THRESHOLD`` and the drain stops hammering it.
|
|
133
|
+
"""
|
|
134
|
+
self._seed(6, lambda c: 0)
|
|
135
|
+
|
|
136
|
+
def all_fail(entry, timeout):
|
|
137
|
+
raise TimeoutError("upstream down")
|
|
138
|
+
|
|
139
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", all_fail):
|
|
140
|
+
with unittest.mock.patch.object(
|
|
141
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
142
|
+
):
|
|
143
|
+
with redirect_stderr(io.StringIO()):
|
|
144
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
145
|
+
|
|
146
|
+
self.assertTrue(summary["stalled"], "a real outage must still stall")
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
summary["retried"], 3, "and stop at STALL_THRESHOLD, not run the queue"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def test_the_entry_crossing_the_budget_on_this_failure_still_votes(self):
|
|
152
|
+
"""``update_attempt`` mutates ``attempt_count`` in place.
|
|
153
|
+
|
|
154
|
+
So the abstention check must read the counter BEFORE
|
|
155
|
+
``_record_failure``. Reading it after would let an entry at
|
|
156
|
+
``MAX_ATTEMPTS - 1`` abstain on the failure that takes it to the
|
|
157
|
+
budget, quietly weakening the guard for ordinary entries. Three such
|
|
158
|
+
entries are a genuine outage signal and must still stall.
|
|
159
|
+
"""
|
|
160
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS - 1)
|
|
161
|
+
|
|
162
|
+
def all_fail(entry, timeout):
|
|
163
|
+
raise TimeoutError("upstream down")
|
|
164
|
+
|
|
165
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", all_fail):
|
|
166
|
+
with unittest.mock.patch.object(
|
|
167
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
168
|
+
):
|
|
169
|
+
with redirect_stderr(io.StringIO()):
|
|
170
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
171
|
+
|
|
172
|
+
self.assertTrue(summary["stalled"])
|
|
173
|
+
self.assertEqual(summary["retried"], 3)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_the_in_hook_sequential_drain_is_protected_too(self):
|
|
177
|
+
"""``drain()`` is the path that runs on EVERY boot, not ``--backlog``.
|
|
178
|
+
|
|
179
|
+
It carries its own copy of the stall guard, so it needs its own
|
|
180
|
+
demotion or the wedge just moves to the path that matters most: the
|
|
181
|
+
in-hook budget is ~4s, so a single always-timing-out entry at the head
|
|
182
|
+
consumes the whole run before anything else is reached.
|
|
183
|
+
"""
|
|
184
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
|
|
185
|
+
|
|
186
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", self._retry):
|
|
187
|
+
with unittest.mock.patch.object(
|
|
188
|
+
drain_pending,
|
|
189
|
+
"_document_state",
|
|
190
|
+
lambda e, timeout=30: False,
|
|
191
|
+
):
|
|
192
|
+
with redirect_stderr(io.StringIO()):
|
|
193
|
+
summary = drain_pending.drain(CONFIG)
|
|
194
|
+
|
|
195
|
+
self.assertEqual(summary["drained"], 3, "healthy entries drain in-hook too")
|
|
196
|
+
self.assertFalse(summary["stalled"])
|
|
197
|
+
self.assertEqual(pending.count(), 3)
|
|
198
|
+
|
|
199
|
+
def test_poison_heads_do_not_eat_the_in_hook_budget(self):
|
|
200
|
+
"""Pins the ORDERING specifically, which the abstention alone does not.
|
|
201
|
+
|
|
202
|
+
Abstaining from the stall guard stops a poison entry ENDING the run,
|
|
203
|
+
but not its consuming the run. The in-hook budget is ~4s of wall clock
|
|
204
|
+
and a chronically failing entry fails by TIMING OUT, so three of them
|
|
205
|
+
at the head burn the whole budget and the loop exits on
|
|
206
|
+
`budget_exceeded` with the healthy entries behind them untouched —
|
|
207
|
+
no stall guard involved.
|
|
208
|
+
|
|
209
|
+
Mutation-checked: reverting only the ``_drain_order`` call in
|
|
210
|
+
``drain()`` (leaving the abstention intact) fails this test and no
|
|
211
|
+
other in the suite.
|
|
212
|
+
"""
|
|
213
|
+
os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "0.5"
|
|
214
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
|
|
215
|
+
|
|
216
|
+
def slow_poison(entry, timeout):
|
|
217
|
+
if entry["content"] in POISON:
|
|
218
|
+
time.sleep(0.25) # a client deadline being burned
|
|
219
|
+
raise TimeoutError("client deadline exceeded")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", slow_poison):
|
|
223
|
+
with unittest.mock.patch.object(
|
|
224
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
225
|
+
):
|
|
226
|
+
with redirect_stderr(io.StringIO()):
|
|
227
|
+
summary = drain_pending.drain(CONFIG)
|
|
228
|
+
|
|
229
|
+
self.assertEqual(
|
|
230
|
+
summary["drained"], 3, "healthy entries must be reached before the budget"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def test_poison_heads_do_not_eat_the_backlog_budget(self):
|
|
234
|
+
"""The same budget-consumption wedge on the ``--backlog`` path.
|
|
235
|
+
|
|
236
|
+
The tests above reach the backlog drain through the STALL guard, which
|
|
237
|
+
the abstention alone already defeats — so none of them notice if
|
|
238
|
+
``_drain_backlog_impl`` stops ordering. But its ordering is load-
|
|
239
|
+
bearing for the same reason the in-hook one is, only slower: waves are
|
|
240
|
+
width 1 (``_backlog_concurrency``) and a chronically failing entry
|
|
241
|
+
burns ``HINDSIGHT_DRAIN_BACKLOG_TIMEOUT`` (280s) before failing, so
|
|
242
|
+
~13 of them at the head exhaust the 1h budget and the run ends on
|
|
243
|
+
`budget_exceeded` having retained nothing — again with no stall guard
|
|
244
|
+
involved, and again identically on the next run.
|
|
245
|
+
|
|
246
|
+
Scaled down here: 3 poison entries at 0.6s against a 1.0s budget (the
|
|
247
|
+
floor ``_backlog_budget_seconds`` clamps to). Ordered correctly the
|
|
248
|
+
healthy entries cost ~0s and all drain; ordered oldest-first the
|
|
249
|
+
budget is gone after the second poison entry and none do.
|
|
250
|
+
|
|
251
|
+
Mutation-checked: reverting only the ``_drain_order`` call in
|
|
252
|
+
``_drain_backlog_impl`` fails this test and no other in the suite.
|
|
253
|
+
"""
|
|
254
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "1.0"
|
|
255
|
+
self._seed(6, lambda c: pending.MAX_ATTEMPTS if c in POISON else 0)
|
|
256
|
+
|
|
257
|
+
def slow_poison(entry, timeout):
|
|
258
|
+
if entry["content"] in POISON:
|
|
259
|
+
time.sleep(0.6) # a backlog-mode deadline being burned
|
|
260
|
+
raise TimeoutError("client deadline exceeded")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", slow_poison):
|
|
264
|
+
with unittest.mock.patch.object(
|
|
265
|
+
drain_pending,
|
|
266
|
+
"_document_state",
|
|
267
|
+
lambda e, timeout=30: e["content"] not in POISON,
|
|
268
|
+
):
|
|
269
|
+
with redirect_stderr(io.StringIO()):
|
|
270
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
271
|
+
|
|
272
|
+
self.assertEqual(
|
|
273
|
+
summary["drained"], 3, "healthy entries must be reached before the budget"
|
|
274
|
+
)
|
|
275
|
+
self.assertFalse(summary["stalled"], "this wedge is budget, not stall")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class DrainOrderTest(unittest.TestCase):
|
|
279
|
+
"""``_drain_order`` is a STABLE partition — age order survives inside it."""
|
|
280
|
+
|
|
281
|
+
def test_over_budget_entries_go_last_and_keep_relative_age(self):
|
|
282
|
+
entries = [
|
|
283
|
+
("p0", {"attempt_count": pending.MAX_ATTEMPTS}),
|
|
284
|
+
("p1", {"attempt_count": 0}),
|
|
285
|
+
("p2", {"attempt_count": pending.MAX_ATTEMPTS + 4}),
|
|
286
|
+
("p3", {"attempt_count": 2}),
|
|
287
|
+
]
|
|
288
|
+
self.assertEqual(
|
|
289
|
+
[p for p, _ in drain_pending._drain_order(entries)],
|
|
290
|
+
["p1", "p3", "p0", "p2"],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def test_a_missing_or_corrupt_counter_is_treated_as_fresh(self):
|
|
294
|
+
for value in ({}, {"attempt_count": None}, {"attempt_count": "many"}):
|
|
295
|
+
with self.subTest(value=value):
|
|
296
|
+
self.assertFalse(drain_pending._over_budget(value))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
unittest.main()
|