switchroom 0.19.19 → 0.19.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
|
@@ -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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
#
|
|
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.
|
|
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.
|
|
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(
|
|
64
|
-
self.assertEqual(retain_content_limit(),
|
|
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
|
-
|
|
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(),
|
|
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),
|
|
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),
|
|
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) <=
|
|
110
|
-
# ~249 sequential extraction calls become bounded batches of
|
|
111
|
-
|
|
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),
|
|
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"]),
|
|
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
|
|
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.
|
|
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.
|