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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. 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()