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