switchroom 0.19.18 → 0.19.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,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()
|