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.
Files changed (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
@@ -0,0 +1,365 @@
1
+ """Switchroom #3619 — unit tests for the degraded-recall disclosure.
2
+
3
+ Measured 2026-07-26 across `recall_log.jsonl` fleet-wide: of 1280 rows carrying
4
+ `bank_timings`, 1148 (89.7%) had the agent's OWN bank time out, and only 28 of
5
+ those still returned any memories. That ran for weeks without anyone noticing,
6
+ because a degraded recall and an empty-but-healthy recall were byte-identical
7
+ from the agent's side: both emit nothing. The agent then answers from no
8
+ context while its CLAUDE.md tells it recall "auto-fires on every inbound
9
+ message", so it asserts "no prior context" rather than "I could not check".
10
+
11
+ `degraded_recall_notice` closes that. These tests pin the behaviour that makes
12
+ it trustworthy rather than noisy:
13
+
14
+ * It FIRES when the agent's own bank timed out or hard-errored.
15
+ * It does NOT fire for a side-bank-only failure — a shared profile bank
16
+ timing out does not mean the agent lost its own memory, and a notice that
17
+ cries wolf gets ignored exactly when it matters.
18
+ * It does NOT fire on a healthy-but-empty bank (the "you genuinely have no
19
+ relevant memories" turn), which is the whole distinction being drawn.
20
+ * The own bank is matched by `bank_id`, NEVER by position — fan-out
21
+ completion order is not the source of `bank_timings` order and a
22
+ positional match would silently report the wrong bank's health.
23
+ * It is emitted OUTSIDE the cached context: `_combine_context` puts it
24
+ first, and the cached `context_message` must never contain it, or a later
25
+ healthy cache hit replays a stale "recall was DEGRADED" notice.
26
+
27
+ Stdlib-only, no network, no server.
28
+ """
29
+
30
+ import io
31
+ import json
32
+ import os
33
+ import shutil
34
+ import socket
35
+ import sys
36
+ import tempfile
37
+ import unittest
38
+ from unittest.mock import patch
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 recall # noqa: E402
45
+ from recall import ( # noqa: E402
46
+ _combine_context,
47
+ degraded_recall_notice,
48
+ )
49
+
50
+ OWN = "overlord"
51
+ SIDE = "ken-profile"
52
+
53
+
54
+ def timing(bank_id, *, timed_out=False, errored=False, elapsed_ms=120):
55
+ """A `bank_timings` entry in the exact shape recall.py appends at
56
+ recall.py:1809 (parallel path) and recall.py:1851 (serial path)."""
57
+ return {
58
+ "bank_id": bank_id,
59
+ "elapsed_ms": elapsed_ms,
60
+ "timed_out": timed_out,
61
+ "errored": errored,
62
+ }
63
+
64
+
65
+ class TestDegradedNoticeFires(unittest.TestCase):
66
+ def test_fires_when_own_bank_times_out(self):
67
+ notice = degraded_recall_notice(
68
+ OWN, [timing(OWN, timed_out=True, elapsed_ms=8003)]
69
+ )
70
+ self.assertIn("DEGRADED", notice)
71
+ self.assertIn("timed out", notice)
72
+ # The bank must be named — a generic notice gives the operator nothing
73
+ # to grep for when three agents share a topic.
74
+ self.assertIn(OWN, notice)
75
+
76
+ def test_fires_when_own_bank_hard_errors(self):
77
+ notice = degraded_recall_notice(OWN, [timing(OWN, errored=True)])
78
+ self.assertIn("DEGRADED", notice)
79
+ self.assertIn("was unreachable", notice)
80
+ self.assertNotIn("timed out", notice)
81
+
82
+ def test_fires_even_when_side_bank_returned_results(self):
83
+ """The masking case that hid this for weeks: the own bank times out,
84
+ the smaller shared bank answers, so the turn LOOKS like a successful
85
+ recall (non-zero result_count) while the agent's own memory is gone."""
86
+ notice = degraded_recall_notice(
87
+ OWN,
88
+ [timing(OWN, timed_out=True, elapsed_ms=8001), timing(SIDE, elapsed_ms=940)],
89
+ )
90
+ self.assertIn("DEGRADED", notice)
91
+
92
+ def test_tells_the_agent_to_treat_absence_as_unknown(self):
93
+ """The notice exists to change the agent's BEHAVIOUR on a degraded
94
+ turn. If it stops saying so, it is decoration."""
95
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
96
+ self.assertIn("UNKNOWN", notice)
97
+
98
+ def test_is_a_single_short_line(self):
99
+ """Fires on an already-starved turn; a verbose block would spend the
100
+ very budget the degradation is starving."""
101
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
102
+ self.assertNotIn("\n", notice)
103
+ self.assertLess(len(notice), 500)
104
+
105
+
106
+ class TestDegradedNoticeSilent(unittest.TestCase):
107
+ def test_silent_when_own_bank_healthy_but_empty(self):
108
+ """The distinction the whole feature draws: a healthy bank with nothing
109
+ relevant must stay silent, or the notice fires on ordinary turns and
110
+ gets tuned out."""
111
+ self.assertEqual(degraded_recall_notice(OWN, [timing(OWN)]), "")
112
+
113
+ def test_silent_when_only_a_side_bank_fails(self):
114
+ self.assertEqual(
115
+ degraded_recall_notice(
116
+ OWN, [timing(OWN, elapsed_ms=310), timing(SIDE, timed_out=True)]
117
+ ),
118
+ "",
119
+ )
120
+
121
+ def test_silent_when_only_a_side_bank_hard_errors(self):
122
+ self.assertEqual(
123
+ degraded_recall_notice(OWN, [timing(OWN), timing(SIDE, errored=True)]),
124
+ "",
125
+ )
126
+
127
+ def test_silent_on_empty_or_missing_timings(self):
128
+ # Cache-hit and trivial-skip turns take an early return with no
129
+ # fan-out; nothing was attempted, so nothing is degraded.
130
+ self.assertEqual(degraded_recall_notice(OWN, []), "")
131
+ self.assertEqual(degraded_recall_notice(OWN, None), "")
132
+ self.assertEqual(degraded_recall_notice("", [timing(OWN, timed_out=True)]), "")
133
+
134
+ def test_silent_when_own_bank_absent_from_timings(self):
135
+ self.assertEqual(
136
+ degraded_recall_notice(OWN, [timing(SIDE, timed_out=True)]), ""
137
+ )
138
+
139
+ def test_tolerates_malformed_timing_entries(self):
140
+ """`bank_timings` is telemetry read back into a decision; a stray
141
+ non-dict must not take the whole hook down with a TypeError — the hook
142
+ failing is strictly worse than the notice not firing."""
143
+ self.assertEqual(
144
+ degraded_recall_notice(OWN, ["junk", None, timing(OWN)]), ""
145
+ )
146
+ self.assertIn(
147
+ "DEGRADED",
148
+ degraded_recall_notice(OWN, ["junk", timing(OWN, timed_out=True)]),
149
+ )
150
+
151
+
152
+ class TestOwnBankMatchedById(unittest.TestCase):
153
+ def test_matches_own_bank_by_id_not_position(self):
154
+ """Own-bank-first is the CONSTRUCTION order of bank_specs
155
+ (recall.py:1716), not a guarantee a future refactor preserves. A
156
+ positional [0] match here would report the side bank's health as the
157
+ agent's own — silently, and in the direction of a false all-clear."""
158
+ timings = [timing(SIDE, elapsed_ms=900), timing(OWN, timed_out=True)]
159
+ notice = degraded_recall_notice(OWN, timings)
160
+ self.assertIn("DEGRADED", notice)
161
+ self.assertIn(OWN, notice)
162
+ self.assertNotIn(SIDE, notice)
163
+
164
+ def test_a_healthy_own_bank_listed_second_stays_silent(self):
165
+ timings = [timing(SIDE, timed_out=True), timing(OWN, elapsed_ms=400)]
166
+ self.assertEqual(degraded_recall_notice(OWN, timings), "")
167
+
168
+
169
+ class TestNoticeIsNotCached(unittest.TestCase):
170
+ """The notice is per-turn state. `_combine_context`'s contract (recall.py
171
+ :1289) is that transient blocks are appended at EMIT time and kept out of
172
+ the string that reaches `_cache_store` / LAST_RECALL_STATE."""
173
+
174
+ def test_combine_puts_the_notice_first(self):
175
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
176
+ context = "## Relevant memories\n- something"
177
+ combined = _combine_context(_combine_context(notice, context), "")
178
+ # First, because it changes how everything after it should be read.
179
+ self.assertTrue(combined.startswith(notice))
180
+ self.assertIn(context, combined)
181
+
182
+ def test_combine_with_notice_and_nudge_keeps_both(self):
183
+ notice = degraded_recall_notice(OWN, [timing(OWN, timed_out=True)])
184
+ context = "## Relevant memories\n- something"
185
+ nudge = "[Hindsight] consider create_directive"
186
+ combined = _combine_context(_combine_context(notice, context), nudge)
187
+ self.assertTrue(combined.startswith(notice))
188
+ self.assertIn(context, combined)
189
+ self.assertTrue(combined.endswith(nudge))
190
+
191
+ def test_healthy_turn_combines_to_exactly_the_context(self):
192
+ """No notice ⇒ emitted context is byte-identical to the cached one, so
193
+ a healthy turn is unchanged by this feature."""
194
+ notice = degraded_recall_notice(OWN, [timing(OWN)])
195
+ context = "## Relevant memories\n- something"
196
+ self.assertEqual(_combine_context(_combine_context(notice, context), ""), context)
197
+
198
+ def test_cached_context_source_excludes_the_notice(self):
199
+ """Regression guard on the actual defect this could reintroduce:
200
+ if `degraded_block` were folded into `context_message`, that string is
201
+ what gets written to the cache (recall.py `_cache_store(cache_key,
202
+ context_message)`) and to LAST_RECALL_STATE — so the next HEALTHY turn
203
+ served from cache would replay "recall was DEGRADED" and the agent
204
+ would hedge for no reason."""
205
+ source = os.path.join(SCRIPTS_DIR, "recall.py")
206
+ with open(source, "r", encoding="utf-8") as fh:
207
+ body = fh.read()
208
+ # The composition of context_message must not reference degraded_block.
209
+ # Anchor on the assignment itself and the cache write, so a mutation
210
+ # that ADDS degraded_block to `parts` fails on the assertion below
211
+ # rather than on a missing anchor.
212
+ start_anchor = 'context_message = "\\n\\n".join(parts)'
213
+ end_anchor = "_cache_store(cache_key, context_message)"
214
+ self.assertIn(start_anchor, body, "context_message composition not found")
215
+ self.assertIn(end_anchor, body, "recall cache write not found")
216
+ # Walk back to the start of the `parts` accumulation.
217
+ start = body.rindex("parts = []", 0, body.index(start_anchor))
218
+ end = body.index(end_anchor)
219
+ self.assertNotIn(
220
+ "degraded_block",
221
+ body[start:end],
222
+ "degraded_block must not reach context_message: that string is "
223
+ "cached and would replay a stale DEGRADED notice on a healthy hit",
224
+ )
225
+ # And it must still reach the emitted additionalContext — the final
226
+ # emit site, which is the one after the cache write.
227
+ emit_at = body.index('"additionalContext"', end)
228
+ self.assertIn("degraded_block", body[emit_at : emit_at + 400])
229
+
230
+
231
+ def _memory(text, mem_id):
232
+ return {"text": text, "type": "fact", "mentioned_at": "2026-01-01", "id": mem_id}
233
+
234
+
235
+ class _Client:
236
+ """Fake HindsightClient with per-bank results / exceptions."""
237
+
238
+ def __init__(self, bank_results=None, bank_errors=None):
239
+ self._bank_results = bank_results or {}
240
+ self._bank_errors = bank_errors or {}
241
+
242
+ def list_directives(self, bank_id, active_only=True, timeout=2):
243
+ return {"items": []}
244
+
245
+ def recall(self, bank_id, query, **kwargs):
246
+ exc = self._bank_errors.get(bank_id)
247
+ if exc is not None:
248
+ raise exc
249
+ return {"results": list(self._bank_results.get(bank_id, []))}
250
+
251
+
252
+ class TestEndToEndEmit(unittest.TestCase):
253
+ """Drive `recall.main()` end to end. The unit tests above pin the helper;
254
+ these pin that the notice actually REACHES `additionalContext`, which is
255
+ the only thing the agent ever sees."""
256
+
257
+ OWN = "test-bank"
258
+ SIDE = "shared-bank"
259
+
260
+ def setUp(self):
261
+ self._tmpdir = tempfile.mkdtemp(prefix="recall-degraded-test-")
262
+ self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
263
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
264
+
265
+ def tearDown(self):
266
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
267
+ if self._prev is None:
268
+ os.environ.pop("CLAUDE_PLUGIN_DATA", None)
269
+ else:
270
+ os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
271
+
272
+ def _run(self, client, config_extra=None):
273
+ hook_input = {
274
+ "prompt": "what did we decide about the auth flow",
275
+ "session_id": "test-session",
276
+ "transcript_path": "",
277
+ "cwd": "/tmp",
278
+ }
279
+ config = {
280
+ "autoRecall": True,
281
+ "bankId": self.OWN,
282
+ "recallMaxTokens": 1024,
283
+ "recallBudget": "mid",
284
+ "recallContextTurns": 1,
285
+ "recallMaxQueryChars": 800,
286
+ "recallPromptPreamble": "",
287
+ "recallParallelDeadlineSeconds": 5,
288
+ }
289
+ if config_extra:
290
+ config.update(config_extra)
291
+ stdout = io.StringIO()
292
+ stderr = io.StringIO()
293
+ self._cached = []
294
+ with patch.object(recall, "load_config", return_value=config), patch.object(
295
+ recall, "get_api_url", return_value="http://localhost:18888"
296
+ ), patch.object(recall, "HindsightClient", return_value=client), patch.object(
297
+ recall, "ensure_bank_mission", return_value=None
298
+ ), patch.object(recall, "write_state", return_value=None), patch.object(
299
+ recall, "_cache_ttl_secs", return_value=300
300
+ ), patch.object(recall, "_cache_lookup", return_value=None), patch.object(
301
+ recall,
302
+ "_cache_store",
303
+ side_effect=lambda key, value: self._cached.append(value),
304
+ ), patch(
305
+ "sys.stdin", new=io.StringIO(json.dumps(hook_input))
306
+ ), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
307
+ recall.main()
308
+ raw = stdout.getvalue()
309
+ if not raw.strip():
310
+ return None
311
+ return json.loads(raw)["hookSpecificOutput"]["additionalContext"]
312
+
313
+ def test_masking_case_notice_rides_above_the_side_bank_memories(self):
314
+ """The exact shape that hid the outage: own bank times out, the smaller
315
+ shared bank answers, so the turn looks successful. The agent must still
316
+ be told its own memory is missing."""
317
+ client = _Client(
318
+ bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
319
+ bank_errors={self.OWN: socket.timeout("timed out")},
320
+ )
321
+ ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
322
+ self.assertIsNotNone(ctx)
323
+ self.assertTrue(ctx.startswith("[Hindsight] Memory recall was DEGRADED"))
324
+ self.assertIn(self.OWN, ctx)
325
+ # The side bank's memories are still delivered — the notice qualifies
326
+ # them, it does not suppress them.
327
+ self.assertIn("ken prefers TypeScript", ctx)
328
+
329
+ def test_notice_is_not_written_to_the_recall_cache(self):
330
+ """The cache write must carry the memories WITHOUT the notice, or the
331
+ next healthy turn served from cache replays a stale DEGRADED line."""
332
+ client = _Client(
333
+ bank_results={self.SIDE: [_memory("ken prefers TypeScript", "m1")]},
334
+ bank_errors={self.OWN: socket.timeout("timed out")},
335
+ )
336
+ self._run(client, {"recallAdditionalBanks": [self.SIDE]})
337
+ self.assertEqual(len(self._cached), 1)
338
+ self.assertNotIn("DEGRADED", self._cached[0])
339
+ self.assertIn("ken prefers TypeScript", self._cached[0])
340
+
341
+ def test_healthy_turn_emits_no_notice(self):
342
+ client = _Client(bank_results={self.OWN: [_memory("something", "m1")]})
343
+ ctx = self._run(client)
344
+ self.assertIsNotNone(ctx)
345
+ self.assertNotIn("DEGRADED", ctx)
346
+
347
+ def test_side_bank_only_timeout_emits_no_notice(self):
348
+ client = _Client(
349
+ bank_results={self.OWN: [_memory("something", "m1")]},
350
+ bank_errors={self.SIDE: socket.timeout("timed out")},
351
+ )
352
+ ctx = self._run(client, {"recallAdditionalBanks": [self.SIDE]})
353
+ self.assertIsNotNone(ctx)
354
+ self.assertNotIn("DEGRADED", ctx)
355
+
356
+ def test_total_failure_emits_the_notice_alone(self):
357
+ client = _Client(bank_errors={self.OWN: socket.timeout("timed out")})
358
+ ctx = self._run(client)
359
+ self.assertIsNotNone(ctx)
360
+ self.assertIn("DEGRADED", ctx)
361
+ self.assertNotIn("<hindsight_memories>", ctx)
362
+
363
+
364
+ if __name__ == "__main__":
365
+ unittest.main()
@@ -329,10 +329,18 @@ class TotalFailureStillLogs(_LogTestBase):
329
329
  bank_behaviour={"test-bank": socket.timeout("timed out")},
330
330
  )
331
331
  ctx, raw = _run_main_with(client, prompt=BARE)
332
- # No block injected (no directives, no memories).
333
- self.assertIsNone(ctx)
334
- self.assertEqual(raw.strip(), "")
335
- # …but the telemetry row exists and records the deadline hit.
332
+ # No memories and no directives were injected — there were none to
333
+ # inject. Switchroom #3619: the turn is no longer SILENT, though. The
334
+ # own bank timed out, so the agent gets the one-line degraded
335
+ # disclosure and nothing else previously "empty because the bank
336
+ # timed out" and "empty because the bank held nothing" were
337
+ # indistinguishable to the agent, which is what let a ~90% own-bank
338
+ # timeout rate hide for weeks.
339
+ self.assertIsNotNone(ctx)
340
+ self.assertIn("DEGRADED", ctx)
341
+ self.assertNotIn("<hindsight_memories>", ctx)
342
+ self.assertNotIn("<hindsight_directives>", ctx)
343
+ # …and the telemetry row still exists and records the deadline hit.
336
344
  entries = self._read_log()
337
345
  self.assertEqual(len(entries), 1)
338
346
  e = entries[0]
@@ -167,6 +167,31 @@ class _Harness(unittest.TestCase):
167
167
  context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
168
168
  return context
169
169
 
170
+ def _assert_no_fallback(self, context):
171
+ """Assert the transcript fallback did NOT fire.
172
+
173
+ Switchroom #3619: a degraded own-bank read now emits a one-line
174
+ DEGRADED disclosure, so "the hook emitted nothing at all" is no longer
175
+ a valid proxy for "the fallback did not fire" — assert on the fallback
176
+ block itself. These tests are about fallback GATING, not about whether
177
+ any context was produced.
178
+
179
+ Review follow-up (#3626): this deliberately does NOT tolerate a `None`
180
+ context. Every caller reaches it with the OWN bank hard-errored, which
181
+ is exactly the condition the disclosure must fire on, so `None` means
182
+ the disclosure regressed. An earlier `if context is None: return`
183
+ guard here made the `DEGRADED` assertion vacuous under precisely that
184
+ mutation — stubbing `degraded_block = ""` left all 12 tests in this
185
+ file green.
186
+ """
187
+ self.assertIsNotNone(
188
+ context,
189
+ "own bank hard-errored, so the #3619 degraded disclosure must have "
190
+ "been emitted — a silent turn is the regression this guards",
191
+ )
192
+ self.assertNotIn("<hindsight_transcript_fallback>", context)
193
+ self.assertIn("DEGRADED", context)
194
+
170
195
 
171
196
  class FiresOnEmptyFactLayer(_Harness):
172
197
  def test_fires_when_all_banks_zero_and_no_deadline_hit(self):
@@ -264,7 +289,7 @@ class DoesNotFireWhenBankErrored(_Harness):
264
289
  bank_errors={"own-bank": ConnectionRefusedError("connection refused")},
265
290
  )
266
291
  context = self._run(client, transcript_path=transcript)
267
- self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
292
+ self._assert_no_fallback(context)
268
293
  e = self._read_log()[0]
269
294
  self.assertTrue(e["bank_errored"])
270
295
  self.assertFalse(e["deadline_hit"])
@@ -283,7 +308,7 @@ class DoesNotFireWhenBankErrored(_Harness):
283
308
  transcript_path=transcript,
284
309
  config_extra={"recallParallel": False},
285
310
  )
286
- self.assertIsNone(context, "fallback must not fire when a bank hard-errored")
311
+ self._assert_no_fallback(context)
287
312
  e = self._read_log()[0]
288
313
  self.assertEqual(e["recall_mode"], "serial")
289
314
  self.assertTrue(e["bank_errored"])
@@ -10,6 +10,7 @@ memory is permanently unsaveable.
10
10
 
11
11
  import io
12
12
  import json
13
+ import math
13
14
  import os
14
15
  import shutil
15
16
  import sys
@@ -60,12 +61,13 @@ class TestDerivedLimit(unittest.TestCase):
60
61
  os.environ.pop(key, None)
61
62
 
62
63
  def test_limit_is_chunk_size_times_chunks_that_fit_the_deadline(self):
63
- # chunk_size * floor(deadline / latency) = 3000 * floor(280/18.4) = 45000
64
- self.assertEqual(retain_content_limit(), 45000)
64
+ # chunk_size * floor(deadline / latency) = 3000 * floor(310/18.4) = 48000
65
+ self.assertEqual(retain_content_limit(), 48000)
65
66
 
66
67
  def test_limit_is_derived_from_chunk_size_not_a_constant(self):
67
68
  os.environ["HINDSIGHT_RETAIN_CHUNK_SIZE"] = "1000"
68
- self.assertEqual(retain_content_limit(), 15000)
69
+ # 1000 * floor(310 / 18.4) = 1000 * 16
70
+ self.assertEqual(retain_content_limit(), 16000)
69
71
 
70
72
  def test_limit_tracks_the_client_deadline(self):
71
73
  os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "92"
@@ -78,7 +80,7 @@ class TestDerivedLimit(unittest.TestCase):
78
80
 
79
81
  def test_garbage_env_falls_back_to_the_derived_default(self):
80
82
  os.environ["HINDSIGHT_RETAIN_CHUNK_LATENCY_S"] = "not-a-number"
81
- self.assertEqual(retain_content_limit(), 45000)
83
+ self.assertEqual(retain_content_limit(), 48000)
82
84
 
83
85
 
84
86
  class TestSplitBounds(unittest.TestCase):
@@ -88,11 +90,11 @@ class TestSplitBounds(unittest.TestCase):
88
90
 
89
91
  def test_every_part_of_an_oversized_json_transcript_is_within_the_limit(self):
90
92
  content = _json_transcript(40, 5000) # ~200k chars
91
- self.assertGreater(len(content), 45000)
93
+ self.assertGreater(len(content), retain_content_limit())
92
94
  parts = split_retain_content(content)
93
95
  self.assertGreater(len(parts), 1)
94
96
  for part in parts:
95
- self.assertLessEqual(len(part), 45000)
97
+ self.assertLessEqual(len(part), retain_content_limit())
96
98
 
97
99
  def test_every_part_of_an_oversized_text_transcript_is_within_the_limit(self):
98
100
  content = _text_transcript(60, 4000) # ~240k chars
@@ -106,16 +108,22 @@ class TestSplitBounds(unittest.TestCase):
106
108
  content = _json_transcript(200, 3700)
107
109
  self.assertGreater(len(content), 744000)
108
110
  parts = split_retain_content(content)
109
- self.assertTrue(all(len(p) <= 45000 for p in parts))
110
- # ~249 sequential extraction calls become bounded batches of <=15.
111
- self.assertGreaterEqual(len(parts), 16)
111
+ self.assertTrue(all(len(p) <= retain_content_limit() for p in parts))
112
+ # ~249 sequential extraction calls become bounded batches of at most
113
+ # `retain_content_limit() / chunk_size` calls each. Both the bound and
114
+ # the expected part count are DERIVED here: the literal 16 this line
115
+ # carried was correct only at the 45,000-char bound and went stale the
116
+ # moment the client deadline moved 280 -> 310 (bound 48,000). A split
117
+ # must always produce at least ceil(len / bound) parts, whatever the
118
+ # bound currently is.
119
+ self.assertGreaterEqual(len(parts), math.ceil(len(content) / retain_content_limit()))
112
120
 
113
121
  def test_single_oversized_message_is_split_not_dropped(self):
114
122
  content = _json_transcript(1, 300000)
115
123
  parts = split_retain_content(content)
116
124
  self.assertGreater(len(parts), 1)
117
125
  for part in parts:
118
- self.assertLessEqual(len(part), 45000)
126
+ self.assertLessEqual(len(part), retain_content_limit())
119
127
 
120
128
  def test_single_unbreakable_line_still_respects_the_bound(self):
121
129
  # No structural boundary anywhere: the bound must still hold.
@@ -224,7 +232,7 @@ class TestClientEnforcesTheBound(unittest.TestCase):
224
232
  self.assertGreater(len(self.client.posts), 1)
225
233
  for _path, body, _timeout in self.client.posts:
226
234
  self.assertEqual(len(body["items"]), 1)
227
- self.assertLessEqual(len(body["items"][0]["content"]), 45000)
235
+ self.assertLessEqual(len(body["items"][0]["content"]), retain_content_limit())
228
236
 
229
237
  def test_small_retain_still_posts_exactly_once_with_the_original_id(self):
230
238
  self.client.retain("bank", "small transcript", document_id="doc")
@@ -1,5 +1,6 @@
1
1
  """Tests for drain_pending.drain() (#1071)."""
2
2
 
3
+ import io
3
4
  import json
4
5
  import os
5
6
  import sys
@@ -212,14 +213,17 @@ class DrainPendingTest(unittest.TestCase):
212
213
  self.assertEqual(entry["attempt_count"], 2)
213
214
  self.assertIn("last_attempt_at", entry)
214
215
 
215
- def test_drain_max_attempts_marks_dead(self):
216
+ def test_drain_max_attempts_marks_dead_on_a_permanent_failure(self):
217
+ """Exhausting the budget on a 4xx still retires the entry."""
216
218
  from lib.pending import MAX_ATTEMPTS
217
219
 
218
220
  path = _seed_entry(self._pending, attempt=MAX_ATTEMPTS)
219
221
  import drain_pending
220
222
 
221
223
  def boom(*a, **kw):
222
- raise urllib.error.URLError("still down")
224
+ raise urllib.error.HTTPError(
225
+ "http://h/v1/x", 400, "Bad Request", {}, io.BytesIO(b"bad payload")
226
+ )
223
227
 
224
228
  with patch("urllib.request.urlopen", side_effect=boom):
225
229
  summary = drain_pending.drain({})
@@ -227,6 +231,28 @@ class DrainPendingTest(unittest.TestCase):
227
231
  self.assertFalse(os.path.exists(path))
228
232
  self.assertTrue(os.path.exists(path + ".dead"))
229
233
 
234
+ def test_drain_max_attempts_keeps_the_memory_on_a_transient_failure(self):
235
+ """A dead upstream must never retire a memory, however many attempts.
236
+
237
+ ``URLError`` is what an unreachable daemon raises. Before the
238
+ permanence gate this retired the entry at MAX_ATTEMPTS — i.e. an
239
+ outage destroyed memory that was perfectly saveable.
240
+ """
241
+ from lib.pending import MAX_ATTEMPTS
242
+
243
+ path = _seed_entry(self._pending, attempt=MAX_ATTEMPTS)
244
+ import drain_pending
245
+
246
+ def boom(*a, **kw):
247
+ raise urllib.error.URLError("still down")
248
+
249
+ with patch("urllib.request.urlopen", side_effect=boom):
250
+ summary = drain_pending.drain({})
251
+ self.assertEqual(summary["dead"], 0)
252
+ self.assertEqual(summary["retried"], 1)
253
+ self.assertTrue(os.path.exists(path))
254
+ self.assertFalse(os.path.exists(path + ".dead"))
255
+
230
256
  def test_drain_stall_guard_stops_after_threshold(self):
231
257
  # Seed 10 entries; with a same-error-class stream, the stall
232
258
  # guard should trip at STALL_THRESHOLD (3) and leave the rest.