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,438 @@
1
+ """Oversized retain content must be bounded and split before it is POSTed.
2
+
3
+ These tests assert the OUTCOME of the fix, not that a function runs: each one
4
+ fails against the pre-fix code, where ``client.retain()`` posted a single item
5
+ of unbounded length. The bug being fixed is that the daemon runs one
6
+ sequential extraction LLM call per 3000 chars, so a 744k-char retain is ~249
7
+ sequential calls and can never complete inside any client deadline — the
8
+ memory is permanently unsaveable.
9
+ """
10
+
11
+ import io
12
+ import json
13
+ import math
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ import unittest
19
+ from contextlib import redirect_stderr
20
+
21
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
22
+
23
+ from lib import client as client_mod # noqa: E402
24
+ from lib import pending # noqa: E402
25
+ from lib import retain_split # noqa: E402
26
+ from lib.client import HindsightClient # noqa: E402
27
+ from lib.retain_split import ( # noqa: E402
28
+ part_document_id,
29
+ part_metadata,
30
+ retain_content_limit,
31
+ split_retain_content,
32
+ )
33
+
34
+
35
+ def _json_transcript(n_messages: int, chars_each: int) -> str:
36
+ return json.dumps(
37
+ [
38
+ {"role": "user" if i % 2 == 0 else "assistant",
39
+ "content": [{"type": "text", "text": "x" * chars_each}]}
40
+ for i in range(n_messages)
41
+ ],
42
+ indent=None,
43
+ ensure_ascii=False,
44
+ )
45
+
46
+
47
+ def _text_transcript(n_blocks: int, chars_each: int) -> str:
48
+ return "\n\n".join(
49
+ f"[role: user]\n{'y' * chars_each}\n[user:end]" for _ in range(n_blocks)
50
+ )
51
+
52
+
53
+ class TestDerivedLimit(unittest.TestCase):
54
+ def tearDown(self):
55
+ for key in (
56
+ "HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
57
+ "HINDSIGHT_RETAIN_CHUNK_SIZE",
58
+ "HINDSIGHT_RETAIN_CHUNK_LATENCY_S",
59
+ "HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
60
+ ):
61
+ os.environ.pop(key, None)
62
+
63
+ def test_limit_is_chunk_size_times_chunks_that_fit_the_deadline(self):
64
+ # chunk_size * floor(deadline / latency) = 3000 * floor(310/18.4) = 48000
65
+ self.assertEqual(retain_content_limit(), 48000)
66
+
67
+ def test_limit_is_derived_from_chunk_size_not_a_constant(self):
68
+ os.environ["HINDSIGHT_RETAIN_CHUNK_SIZE"] = "1000"
69
+ # 1000 * floor(310 / 18.4) = 1000 * 16
70
+ self.assertEqual(retain_content_limit(), 16000)
71
+
72
+ def test_limit_tracks_the_client_deadline(self):
73
+ os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "92"
74
+ # floor(92 / 18.4) = 5 chunks
75
+ self.assertEqual(retain_content_limit(), 15000)
76
+
77
+ def test_limit_never_falls_below_one_chunk(self):
78
+ os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "1"
79
+ self.assertEqual(retain_content_limit(), 3000)
80
+
81
+ def test_garbage_env_falls_back_to_the_derived_default(self):
82
+ os.environ["HINDSIGHT_RETAIN_CHUNK_LATENCY_S"] = "not-a-number"
83
+ self.assertEqual(retain_content_limit(), 48000)
84
+
85
+
86
+ class TestSplitBounds(unittest.TestCase):
87
+ def test_content_within_the_limit_is_untouched(self):
88
+ content = _json_transcript(3, 100)
89
+ self.assertEqual(split_retain_content(content), [content])
90
+
91
+ def test_every_part_of_an_oversized_json_transcript_is_within_the_limit(self):
92
+ content = _json_transcript(40, 5000) # ~200k chars
93
+ self.assertGreater(len(content), retain_content_limit())
94
+ parts = split_retain_content(content)
95
+ self.assertGreater(len(parts), 1)
96
+ for part in parts:
97
+ self.assertLessEqual(len(part), retain_content_limit())
98
+
99
+ def test_every_part_of_an_oversized_text_transcript_is_within_the_limit(self):
100
+ content = _text_transcript(60, 4000) # ~240k chars
101
+ parts = split_retain_content(content, 45000)
102
+ self.assertGreater(len(parts), 1)
103
+ for part in parts:
104
+ self.assertLessEqual(len(part), 45000)
105
+
106
+ def test_worst_case_backlog_entry_is_bounded(self):
107
+ # The largest entry measured in the 2026-07-25 fleet backlog.
108
+ content = _json_transcript(200, 3700)
109
+ self.assertGreater(len(content), 744000)
110
+ parts = split_retain_content(content)
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()))
120
+
121
+ def test_single_oversized_message_is_split_not_dropped(self):
122
+ content = _json_transcript(1, 300000)
123
+ parts = split_retain_content(content)
124
+ self.assertGreater(len(parts), 1)
125
+ for part in parts:
126
+ self.assertLessEqual(len(part), retain_content_limit())
127
+
128
+ def test_single_unbreakable_line_still_respects_the_bound(self):
129
+ # No structural boundary anywhere: the bound must still hold.
130
+ parts = split_retain_content("z" * 500000, 45000)
131
+ self.assertTrue(all(len(p) <= 45000 for p in parts))
132
+ self.assertEqual("".join(parts), "z" * 500000)
133
+
134
+
135
+ class TestSplitPreservesStructure(unittest.TestCase):
136
+ def test_json_parts_are_each_a_valid_json_transcript(self):
137
+ content = _json_transcript(40, 5000)
138
+ for part in split_retain_content(content):
139
+ decoded = json.loads(part)
140
+ self.assertIsInstance(decoded, list)
141
+ self.assertTrue(decoded)
142
+ for message in decoded:
143
+ self.assertIn("role", message)
144
+ self.assertIn("content", message)
145
+
146
+ def test_json_split_loses_no_message_and_keeps_order(self):
147
+ content = _json_transcript(40, 5000)
148
+ original = json.loads(content)
149
+ rejoined = []
150
+ for part in split_retain_content(content):
151
+ rejoined.extend(json.loads(part))
152
+ self.assertEqual([m["role"] for m in rejoined], [m["role"] for m in original])
153
+ self.assertEqual(
154
+ "".join(b["text"] for m in rejoined for b in m["content"]),
155
+ "".join(b["text"] for m in original for b in m["content"]),
156
+ )
157
+
158
+ def test_a_split_message_keeps_its_role_on_every_fragment(self):
159
+ content = json.dumps(
160
+ [{"role": "assistant", "content": [{"type": "text", "text": "q" * 300000}]}],
161
+ indent=None,
162
+ ensure_ascii=False,
163
+ )
164
+ for part in split_retain_content(content):
165
+ for message in json.loads(part):
166
+ self.assertEqual(message["role"], "assistant")
167
+
168
+ def test_text_parts_keep_whole_role_blocks(self):
169
+ content = _text_transcript(60, 4000)
170
+ for part in split_retain_content(content, 45000):
171
+ self.assertTrue(part.startswith("[role: user]"))
172
+ self.assertTrue(part.endswith("[user:end]"))
173
+ self.assertEqual(part.count("[role: user]"), part.count("[user:end]"))
174
+
175
+ def test_an_oversized_text_block_is_rewrapped_with_its_role_markers(self):
176
+ content = _text_transcript(1, 200000)
177
+ parts = split_retain_content(content, 45000)
178
+ self.assertGreater(len(parts), 1)
179
+ for part in parts:
180
+ self.assertTrue(part.startswith("[role: user]\n"))
181
+ self.assertTrue(part.endswith("\n[user:end]"))
182
+
183
+ def test_split_is_deterministic(self):
184
+ content = _json_transcript(40, 5000)
185
+ self.assertEqual(split_retain_content(content), split_retain_content(content))
186
+
187
+
188
+ class TestPartIdentity(unittest.TestCase):
189
+ def test_unsplit_document_id_and_metadata_are_unchanged(self):
190
+ self.assertEqual(part_document_id("sess-rA-B", 0, 1), "sess-rA-B")
191
+ self.assertEqual(part_metadata({"session_id": "s"}, 0, 1), {"session_id": "s"})
192
+
193
+ def test_part_ids_are_unique_so_parts_cannot_overwrite_each_other(self):
194
+ ids = [part_document_id("sess-rA-B", i, 5) for i in range(5)]
195
+ self.assertEqual(len(set(ids)), 5)
196
+
197
+ def test_part_ids_keep_the_session_prefix(self):
198
+ self.assertTrue(part_document_id("sess123-rA-B", 2, 5).startswith("sess123"))
199
+
200
+ def test_part_metadata_records_provenance_without_mutating_the_input(self):
201
+ original = {"session_id": "s"}
202
+ meta = part_metadata(original, 2, 5)
203
+ self.assertEqual(meta["retain_part_index"], "3")
204
+ self.assertEqual(meta["retain_part_count"], "5")
205
+ self.assertEqual(original, {"session_id": "s"})
206
+
207
+
208
+ class _RecordingClient(HindsightClient):
209
+ """Client that records POSTs instead of issuing them."""
210
+
211
+ def __init__(self, *a, fail_on=None, **kw):
212
+ super().__init__(*a, **kw)
213
+ self.posts = []
214
+ self.fail_on = fail_on
215
+
216
+ def _request(self, method, path, body=None, timeout=None):
217
+ if self.fail_on is not None and len(self.posts) == self.fail_on:
218
+ self.posts.append((path, body, timeout))
219
+ raise RuntimeError("upstream boom")
220
+ self.posts.append((path, body, timeout))
221
+ return {"ok": True}
222
+
223
+
224
+ class TestClientEnforcesTheBound(unittest.TestCase):
225
+ def setUp(self):
226
+ self.client = _RecordingClient("http://hindsight.invalid")
227
+
228
+ def test_oversized_retain_is_never_posted_as_one_item(self):
229
+ # The bug: pre-fix this was a single POST of 200k chars, which the
230
+ # daemon turns into ~67 sequential LLM calls and no deadline covers.
231
+ self.client.retain("bank", _json_transcript(40, 5000), document_id="doc")
232
+ self.assertGreater(len(self.client.posts), 1)
233
+ for _path, body, _timeout in self.client.posts:
234
+ self.assertEqual(len(body["items"]), 1)
235
+ self.assertLessEqual(len(body["items"][0]["content"]), retain_content_limit())
236
+
237
+ def test_small_retain_still_posts_exactly_once_with_the_original_id(self):
238
+ self.client.retain("bank", "small transcript", document_id="doc")
239
+ self.assertEqual(len(self.client.posts), 1)
240
+ self.assertEqual(self.client.posts[0][1]["items"][0]["document_id"], "doc")
241
+ self.assertNotIn("retain_part_index", self.client.posts[0][1]["items"][0]["metadata"])
242
+
243
+ def test_each_part_gets_a_distinct_document_id(self):
244
+ self.client.retain("bank", _json_transcript(40, 5000), document_id="doc")
245
+ ids = [b["items"][0]["document_id"] for _p, b, _t in self.client.posts]
246
+ self.assertEqual(len(set(ids)), len(ids))
247
+ self.assertTrue(all(i.startswith("doc-p") for i in ids))
248
+
249
+ def test_every_part_carries_the_callers_commit_before_ack_and_context(self):
250
+ self.client.retain(
251
+ "bank",
252
+ _json_transcript(40, 5000),
253
+ document_id="doc",
254
+ context="claude-code",
255
+ tags=["t"],
256
+ async_processing=False,
257
+ timeout=280,
258
+ )
259
+ for _path, body, timeout in self.client.posts:
260
+ self.assertIs(body["async"], False)
261
+ self.assertEqual(body["items"][0]["context"], "claude-code")
262
+ self.assertEqual(body["items"][0]["tags"], ["t"])
263
+ # Each part gets a positive request deadline, clamped to what is
264
+ # left of the caller's 280s wall budget (never more than it).
265
+ self.assertGreater(timeout, 0)
266
+ self.assertLessEqual(timeout, 280)
267
+
268
+ def test_a_failing_part_raises_so_the_caller_still_enqueues_and_retries(self):
269
+ client = _RecordingClient("http://hindsight.invalid", fail_on=2)
270
+ with self.assertRaises(RuntimeError):
271
+ client.retain("bank", _json_transcript(40, 5000), document_id="doc")
272
+ self.assertEqual(len(client.posts), 3) # stops at the failure
273
+
274
+ def test_retry_after_partial_failure_reposts_identical_part_ids(self):
275
+ content = _json_transcript(40, 5000)
276
+ first = _RecordingClient("http://hindsight.invalid", fail_on=2)
277
+ with self.assertRaises(RuntimeError):
278
+ first.retain("bank", content, document_id="doc")
279
+ second = _RecordingClient("http://hindsight.invalid")
280
+ second.retain("bank", content, document_id="doc")
281
+ committed = [b["items"][0]["document_id"] for _p, b, _t in first.posts[:2]]
282
+ retried = [b["items"][0]["document_id"] for _p, b, _t in second.posts[:2]]
283
+ # Identical ids ⇒ the daemon upserts the already-committed parts
284
+ # instead of double-storing them.
285
+ self.assertEqual(committed, retried)
286
+
287
+ def test_split_count_is_reported_on_the_response(self):
288
+ response = self.client.retain("bank", _json_transcript(40, 5000), document_id="doc")
289
+ self.assertEqual(response["split_parts"], len(self.client.posts))
290
+
291
+ def test_limit_is_read_at_call_time_from_the_environment(self):
292
+ os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "6000"
293
+ try:
294
+ self.client.retain("bank", _json_transcript(10, 1000), document_id="doc")
295
+ finally:
296
+ os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
297
+ self.assertGreater(len(self.client.posts), 1)
298
+ for _p, body, _t in self.client.posts:
299
+ self.assertLessEqual(len(body["items"][0]["content"]), 6000)
300
+
301
+
302
+ class TestRetainRespectsTheCallersWallBudget(unittest.TestCase):
303
+ """Splitting must not turn one bounded POST into N of them.
304
+
305
+ The Stop hook passes ``timeout=15`` because it has a hook budget. Posting
306
+ 17 parts at 15s each would stall the session for over four minutes — a
307
+ worse bug than the one being fixed. Fails against the first cut of the fix,
308
+ which passed the caller's timeout to every part with no overall bound.
309
+ """
310
+
311
+ def test_total_wall_time_is_bounded_by_the_callers_timeout(self):
312
+ class _SlowClient(_RecordingClient):
313
+ def _request(self, method, path, body=None, timeout=None):
314
+ self.clock[0] += 9.0 # each part burns 9s
315
+ return super()._request(method, path, body, timeout)
316
+
317
+ client = _SlowClient("http://hindsight.invalid")
318
+ client.clock = [0.0]
319
+ real_monotonic = client_mod.time.monotonic
320
+ client_mod.time.monotonic = lambda: client.clock[0]
321
+ try:
322
+ with self.assertRaises(TimeoutError):
323
+ client.retain("bank", _json_transcript(40, 20000), timeout=15)
324
+ finally:
325
+ client_mod.time.monotonic = real_monotonic
326
+ # 15s budget, 9s per part: part 1 runs, part 2 starts (6s left), part 3
327
+ # must not. Bounded — not all 40-message-worth of parts.
328
+ self.assertEqual(len(client.posts), 2)
329
+
330
+ def test_parts_that_fit_the_budget_all_post(self):
331
+ client = _RecordingClient("http://hindsight.invalid")
332
+ content = _json_transcript(40, 20000)
333
+ result = client.retain("bank", content, timeout=280)
334
+ self.assertGreater(len(client.posts), 1)
335
+ self.assertEqual(result["split_parts"], len(client.posts))
336
+
337
+
338
+ class TestEnqueueQueuesBoundedEntries(unittest.TestCase):
339
+ """An oversized entry can never be drained, so it must never be queued.
340
+
341
+ drain_pending marks an entry ``.dead`` after MAX_ATTEMPTS failures. A
342
+ queued 744k-char payload fails every attempt by construction, so queuing it
343
+ whole is a scheduled data loss. Fails against pre-fix pending.enqueue,
344
+ which wrote one entry of whatever size it was handed.
345
+ """
346
+
347
+ def setUp(self):
348
+ self.tmp = tempfile.mkdtemp()
349
+ self._prev = os.environ.get("HOME")
350
+ os.environ["HOME"] = self.tmp
351
+
352
+ def tearDown(self):
353
+ if self._prev is None:
354
+ os.environ.pop("HOME", None)
355
+ else:
356
+ os.environ["HOME"] = self._prev
357
+ shutil.rmtree(self.tmp, ignore_errors=True)
358
+
359
+ def _payload(self, content):
360
+ return {
361
+ "api_url": "http://hindsight.invalid",
362
+ "bank_id": "bank",
363
+ "document_id": "sess-r1-2",
364
+ "content": content,
365
+ "metadata": {"source": "test"},
366
+ }
367
+
368
+ def test_small_payload_still_queues_exactly_one_unchanged_entry(self):
369
+ pending.enqueue(self._payload("short content"), RuntimeError("boom"))
370
+ entries = pending.iter_entries()
371
+ self.assertEqual(len(entries), 1)
372
+ self.assertEqual(entries[0][1]["content"], "short content")
373
+ self.assertEqual(entries[0][1]["document_id"], "sess-r1-2")
374
+
375
+ def test_oversized_payload_is_queued_as_multiple_bounded_entries(self):
376
+ content = _json_transcript(40, 20000)
377
+ pending.enqueue(self._payload(content), RuntimeError("boom"))
378
+ entries = pending.iter_entries()
379
+ limit = retain_content_limit()
380
+ self.assertGreater(len(entries), 1)
381
+ for _path, entry in entries:
382
+ self.assertLessEqual(len(entry["content"]), limit)
383
+
384
+ def test_queued_parts_have_distinct_deterministic_ids(self):
385
+ content = _json_transcript(40, 20000)
386
+ pending.enqueue(self._payload(content), RuntimeError("boom"))
387
+ ids = [e["document_id"] for _p, e in pending.iter_entries()]
388
+ self.assertEqual(len(ids), len(set(ids)))
389
+ total = len(ids)
390
+ self.assertEqual(
391
+ sorted(ids),
392
+ sorted(part_document_id("sess-r1-2", i, total) for i in range(total)),
393
+ )
394
+
395
+ def test_queue_cap_is_still_respected_when_splitting(self):
396
+ """A memory needing more parts than the cap is REFUSED, not partly kept.
397
+
398
+ This asserted "queue the 2 parts that fit" before the rebase onto
399
+ #3599. That answer stopped being available: #3599 replaced the
400
+ "refuse at MAX_ENTRIES" branch with ``_evict_to_fit``, so writing
401
+ part 20 of 20 under a 2-entry cap now sheds parts 1-18 of the SAME
402
+ memory along with everything else already queued, and leaves a
403
+ meaningless tail fragment behind. Refusing matches #3599's own
404
+ byte-cap guard: one memory must never cost the whole queue.
405
+ """
406
+ content = _json_transcript(40, 20000)
407
+ original = pending.MAX_ENTRIES
408
+ pending.MAX_ENTRIES = 2
409
+ try:
410
+ with redirect_stderr(io.StringIO()) as err:
411
+ got = pending.enqueue(self._payload(content), RuntimeError("boom"))
412
+ finally:
413
+ pending.MAX_ENTRIES = original
414
+ self.assertIsNone(got)
415
+ self.assertEqual(len(pending.iter_entries()), 0)
416
+ self.assertIn("more than the whole", err.getvalue())
417
+
418
+
419
+ class TestModuleIsPure(unittest.TestCase):
420
+ def test_splitter_imports_nothing_nondeterministic_or_networked(self):
421
+ # The split must be a pure function of (content, limit): a clock or an
422
+ # RNG in here would make retry ids diverge and turn every retry into a
423
+ # duplicate document instead of an upsert.
424
+ import ast
425
+
426
+ with open(retain_split.__file__, encoding="utf-8") as fh:
427
+ tree = ast.parse(fh.read())
428
+ imported = set()
429
+ for node in ast.walk(tree):
430
+ if isinstance(node, ast.Import):
431
+ imported.update(alias.name.split(".")[0] for alias in node.names)
432
+ elif isinstance(node, ast.ImportFrom) and node.module:
433
+ imported.add(node.module.split(".")[0])
434
+ self.assertEqual(imported, {"__future__", "json", "os", "typing"})
435
+
436
+
437
+ if __name__ == "__main__":
438
+ unittest.main()
@@ -0,0 +1,204 @@
1
+ """A partial plugin rollout must fail LOUDLY, not silently (#3599 R4-Lc).
2
+
3
+ The deploy hazard this pins is not a bug in any one file. The plugin tree
4
+ ships as loose files under ``scripts/``, and #3599 REMOVED a symbol
5
+ (``lib.pending.delete_entry``) whose only importer was ``drain_pending``.
6
+ So a rollout that lands the new ``lib/pending.py`` beside an old
7
+ ``drain_pending.py`` — an interrupted copy, a hand-patched file, a
8
+ ``.bak`` restored over one half — makes ``session_start.py``'s
9
+
10
+ from drain_pending import drain
11
+
12
+ raise ``ImportError``. That used to be caught by a bare ``except
13
+ Exception`` and written to ``debug_log``, which is OFF by default: the
14
+ whole pending-retains drain would stop, on every boot, and say nothing.
15
+ Turns queue up forever, and the only downstream signal is ``switchroom
16
+ doctor`` reporting a growing backlog — which names the queue, not the
17
+ cause.
18
+
19
+ These tests live under ``scripts/tests/`` because that is the ONLY python
20
+ test directory CI discovers (``ci-tests-python.yml:63-64`` runs
21
+ ``python3 -m unittest discover tests/`` from ``vendor/hindsight-memory/
22
+ scripts``).
23
+ """
24
+
25
+ import io
26
+ import os
27
+ import sys
28
+ import types
29
+ import unittest
30
+ import unittest.mock
31
+ from contextlib import redirect_stderr
32
+
33
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
+ if SCRIPTS_DIR not in sys.path:
35
+ sys.path.insert(0, SCRIPTS_DIR)
36
+
37
+ import session_start # noqa: E402
38
+
39
+
40
+ class _Client:
41
+ """Stand-in for HindsightClient — reachable, no network."""
42
+
43
+ def __init__(self, *_a, **_kw):
44
+ pass
45
+
46
+ def health_check(self, timeout=5, retries=3):
47
+ return True
48
+
49
+
50
+ class VersionSkewIsLoudTest(unittest.TestCase):
51
+ """Every import-level skew in the tree reaches stderr."""
52
+
53
+ CONFIG = {"autoRetain": True, "autoRecall": True}
54
+
55
+ def _run_main(self, broken: str):
56
+ """Run ``session_start.main()`` with ``broken`` importable but
57
+ missing the name the hook imports from it — exactly the shape a
58
+ half-rolled-out tree produces."""
59
+ stub = types.ModuleType(broken) # module exists, symbol does not
60
+ with unittest.mock.patch.dict(sys.modules, {broken: stub}):
61
+ with unittest.mock.patch.object(
62
+ session_start, "load_config", lambda: dict(self.CONFIG)
63
+ ):
64
+ with unittest.mock.patch.object(
65
+ session_start,
66
+ "get_api_url",
67
+ lambda cfg, debug_fn=None, allow_daemon_start=True: (
68
+ "http://127.0.0.1:9/none"
69
+ ),
70
+ ):
71
+ with unittest.mock.patch.object(
72
+ session_start, "HindsightClient", _Client
73
+ ):
74
+ with unittest.mock.patch.object(
75
+ sys, "stdin", io.StringIO("{}")
76
+ ):
77
+ with redirect_stderr(io.StringIO()) as err:
78
+ session_start.main()
79
+ return err.getvalue()
80
+
81
+ def test_a_half_rolled_out_tree_says_the_drain_is_disabled(self):
82
+ out = self._run_main("drain_pending")
83
+ self.assertIn(session_start.SKEW_MARKER, out)
84
+ self.assertIn("drain_pending", out)
85
+ self.assertIn(
86
+ "Queued retains will NOT be replayed",
87
+ out,
88
+ "the operator must be told what the skew COSTS, not just that "
89
+ "an import failed",
90
+ )
91
+ self.assertIn("Re-deploy the plugin tree WHOLE", out, "and how to fix it")
92
+
93
+ def test_the_same_guard_covers_boot_reconciliation(self):
94
+ """``reconcile_tail`` is the other half of the #3244 durability
95
+ machinery and is imported the same way, so it fails the same way."""
96
+ out = self._run_main("reconcile_tail")
97
+ self.assertIn(session_start.SKEW_MARKER, out)
98
+ self.assertIn("reconcile_tail", out)
99
+ self.assertIn("Un-committed turns will NOT be recovered", out)
100
+
101
+ def test_a_skew_does_not_take_session_start_down(self):
102
+ """Loud, but never fatal: SessionStart still completes. A hook that
103
+ crashes on a skew trades a silent drain outage for a broken boot."""
104
+ self._run_main("drain_pending") # no exception escapes _run_main
105
+
106
+ def test_a_healthy_tree_prints_no_skew_warning(self):
107
+ """The control case. Without it the assertions above are satisfied
108
+ by a warning that fires unconditionally."""
109
+ with unittest.mock.patch.object(
110
+ session_start, "load_config", lambda: dict(self.CONFIG)
111
+ ):
112
+ with unittest.mock.patch.object(
113
+ session_start,
114
+ "get_api_url",
115
+ lambda cfg, debug_fn=None, allow_daemon_start=True: (
116
+ "http://127.0.0.1:9/none"
117
+ ),
118
+ ):
119
+ with unittest.mock.patch.object(
120
+ session_start, "HindsightClient", _Client
121
+ ):
122
+ with unittest.mock.patch.object(sys, "stdin", io.StringIO("{}")):
123
+ with redirect_stderr(io.StringIO()) as err:
124
+ session_start.main()
125
+ self.assertNotIn(session_start.SKEW_MARKER, err.getvalue())
126
+
127
+ def test_a_runtime_error_is_still_swallowed_quietly(self):
128
+ """Only SKEW is loud. A transient drain failure stays best-effort —
129
+ making every upstream hiccup print would train operators to ignore
130
+ the line that matters.
131
+ """
132
+ import drain_pending
133
+
134
+ def boom(_config):
135
+ raise RuntimeError("upstream on fire")
136
+
137
+ with unittest.mock.patch.object(drain_pending, "drain", boom):
138
+ with unittest.mock.patch.object(
139
+ session_start, "load_config", lambda: dict(self.CONFIG)
140
+ ):
141
+ with unittest.mock.patch.object(
142
+ session_start,
143
+ "get_api_url",
144
+ lambda cfg, debug_fn=None, allow_daemon_start=True: (
145
+ "http://127.0.0.1:9/none"
146
+ ),
147
+ ):
148
+ with unittest.mock.patch.object(
149
+ session_start, "HindsightClient", _Client
150
+ ):
151
+ with unittest.mock.patch.object(
152
+ sys, "stdin", io.StringIO("{}")
153
+ ):
154
+ with redirect_stderr(io.StringIO()) as err:
155
+ session_start.main()
156
+ self.assertNotIn(session_start.SKEW_MARKER, err.getvalue())
157
+
158
+
159
+ class RemovedSymbolIsNotImportedTest(unittest.TestCase):
160
+ """The skew guard is the safety net; this is the thing it nets.
161
+
162
+ ``delete_entry`` is gone from ``lib.pending`` (#3599 R3-M1). If any
163
+ module in this tree imports it again, every agent running a mixed tree
164
+ loses its drain — so the absence is asserted from the importer side too,
165
+ not only from the module side.
166
+ """
167
+
168
+ def test_no_script_in_the_tree_imports_delete_entry(self):
169
+ # Parsed, not grepped: several docstrings discuss the removal by
170
+ # name, and a substring match would fail on the prose that explains
171
+ # why the symbol is gone.
172
+ import ast
173
+
174
+ offenders = []
175
+ for root, _dirs, files in os.walk(SCRIPTS_DIR):
176
+ if os.path.basename(root) in ("tests", "__pycache__"):
177
+ continue
178
+ for f in files:
179
+ if not f.endswith(".py"):
180
+ continue
181
+ p = os.path.join(root, f)
182
+ with open(p, encoding="utf-8") as fh:
183
+ tree = ast.parse(fh.read(), filename=p)
184
+ rel = os.path.relpath(p, SCRIPTS_DIR)
185
+ for node in ast.walk(tree):
186
+ if isinstance(node, ast.ImportFrom) and any(
187
+ a.name == "delete_entry" for a in node.names
188
+ ):
189
+ offenders.append(rel)
190
+ elif (
191
+ isinstance(node, ast.Attribute)
192
+ and node.attr == "delete_entry"
193
+ ):
194
+ offenders.append(rel)
195
+ self.assertEqual(
196
+ offenders,
197
+ [],
198
+ "delete_entry was removed; re-introducing an import of it "
199
+ "breaks every partially-rolled-out agent",
200
+ )
201
+
202
+
203
+ if __name__ == "__main__":
204
+ unittest.main()