switchroom 0.19.25 → 0.19.26
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 +6 -2
- package/dist/auth-broker/index.js +9 -2
- package/dist/cli/notion-write-pretool.mjs +6 -2
- package/dist/cli/switchroom.js +908 -527
- package/dist/host-control/main.js +10 -3
- package/dist/vault/approvals/kernel-server.js +10 -2
- package/dist/vault/broker/server.js +10 -2
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +40 -4
- package/telegram-plugin/dist/gateway/gateway.js +275 -109
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Switchroom: the per-bank recall request timeout is a MANAGED KEY, not a literal.
|
|
2
|
+
|
|
3
|
+
`recall.py` used to pass a bare `timeout=8` to every `client.recall()` call.
|
|
4
|
+
That literal lived inside the vendored plugin tree, which `switchroom apply`
|
|
5
|
+
rm's and re-copies — so an operator who needed a different value had to
|
|
6
|
+
hand-edit the installed plugin and watch the next apply silently revert it. The
|
|
7
|
+
live fleet did exactly that, three separate times, each time quietly restoring
|
|
8
|
+
shipped defaults while switchroom.yaml read as though it were tuned.
|
|
9
|
+
|
|
10
|
+
It is now `recallRequestTimeoutSeconds` (env:
|
|
11
|
+
HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS), resolved from
|
|
12
|
+
`memory.recall.request_timeout_seconds` and stamped/exported by switchroom.
|
|
13
|
+
|
|
14
|
+
The assertions here are on the VALUE THAT REACHES THE CLIENT, because that is
|
|
15
|
+
the thing the bug got wrong. Asserting only that the config key parses would
|
|
16
|
+
stay green with the literal still hard-coded at the callsite.
|
|
17
|
+
|
|
18
|
+
Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
|
|
19
|
+
from ``scripts/``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
if SCRIPTS_DIR not in sys.path:
|
|
33
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
34
|
+
|
|
35
|
+
import recall # noqa: E402
|
|
36
|
+
from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
|
|
37
|
+
|
|
38
|
+
BARE = "what did we decide about the auth flow last week"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RecordingClient:
|
|
42
|
+
"""Captures the `timeout` kwarg of every recall() call."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.timeouts = []
|
|
46
|
+
|
|
47
|
+
def recall(self, **kwargs):
|
|
48
|
+
self.timeouts.append(kwargs.get("timeout"))
|
|
49
|
+
return {"memories": []}
|
|
50
|
+
|
|
51
|
+
def list_directives(self, *a, **kw):
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Harness(unittest.TestCase):
|
|
56
|
+
def setUp(self):
|
|
57
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-req-timeout-test-")
|
|
58
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
59
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
60
|
+
|
|
61
|
+
def tearDown(self):
|
|
62
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
63
|
+
if self._prev is None:
|
|
64
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
65
|
+
else:
|
|
66
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
67
|
+
|
|
68
|
+
def _timeouts(self, config_extra=None):
|
|
69
|
+
client = _RecordingClient()
|
|
70
|
+
hook_input = {
|
|
71
|
+
"prompt": BARE,
|
|
72
|
+
"session_id": "test-session",
|
|
73
|
+
"transcript_path": "",
|
|
74
|
+
"cwd": "/tmp",
|
|
75
|
+
}
|
|
76
|
+
config = {
|
|
77
|
+
"autoRecall": True,
|
|
78
|
+
"bankId": "own-bank",
|
|
79
|
+
"recallMaxTokens": 1024,
|
|
80
|
+
"recallBudget": "mid",
|
|
81
|
+
"recallContextTurns": 1,
|
|
82
|
+
"recallMaxQueryChars": 800,
|
|
83
|
+
"recallPromptPreamble": "",
|
|
84
|
+
}
|
|
85
|
+
if config_extra:
|
|
86
|
+
config.update(config_extra)
|
|
87
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
88
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
89
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
90
|
+
recall, "ensure_bank_mission", return_value=None
|
|
91
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
92
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
93
|
+
), patch("sys.stdout", new=io.StringIO()), patch(
|
|
94
|
+
"sys.stderr", new=io.StringIO()
|
|
95
|
+
):
|
|
96
|
+
recall.main()
|
|
97
|
+
return client.timeouts
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PerBankTimeoutIsConfigured(_Harness):
|
|
101
|
+
def test_default_matches_the_shipped_default(self):
|
|
102
|
+
# Promotion to a managed key must not change what switchroom ships:
|
|
103
|
+
# with the key absent from config, the value reaching the client is
|
|
104
|
+
# exactly DEFAULTS["recallRequestTimeoutSeconds"] — not the client
|
|
105
|
+
# library's own default, and not None.
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
self._timeouts(), [float(DEFAULTS["recallRequestTimeoutSeconds"])]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def test_configured_value_reaches_the_client(self):
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
self._timeouts({"recallRequestTimeoutSeconds": 13}), [13.0]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_applies_to_every_bank_in_a_fan_out(self):
|
|
116
|
+
# One hung bank must not be able to outlive its siblings' budget, so
|
|
117
|
+
# the value has to reach EVERY bank, not just the own-bank slot.
|
|
118
|
+
got = self._timeouts(
|
|
119
|
+
{
|
|
120
|
+
"recallRequestTimeoutSeconds": 5,
|
|
121
|
+
"recallAdditionalBanks": ["shared-a", "shared-b"],
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(len(got), 3)
|
|
125
|
+
self.assertEqual(set(got), {5.0})
|
|
126
|
+
|
|
127
|
+
def test_applies_on_the_serial_rollback_path_too(self):
|
|
128
|
+
got = self._timeouts(
|
|
129
|
+
{"recallRequestTimeoutSeconds": 7, "recallParallel": False}
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(got, [7.0])
|
|
132
|
+
|
|
133
|
+
def test_unusable_values_fall_back_instead_of_breaking_recall(self):
|
|
134
|
+
# A malformed or non-positive timeout must not take the pre-turn hook
|
|
135
|
+
# down to a traceback, and must never become "fail instantly".
|
|
136
|
+
for bad in ("not-a-number", None, 0, -3):
|
|
137
|
+
with self.subTest(bad=bad):
|
|
138
|
+
self.assertEqual(
|
|
139
|
+
self._timeouts({"recallRequestTimeoutSeconds": bad}),
|
|
140
|
+
[float(DEFAULTS["recallRequestTimeoutSeconds"])],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ManagedKeyIsWired(unittest.TestCase):
|
|
145
|
+
def test_key_has_a_default_and_an_env_override(self):
|
|
146
|
+
# Both halves are required for the key to be settable declaratively:
|
|
147
|
+
# the default is the shipped value, the env override is what lets
|
|
148
|
+
# switchroom.yaml outrank a stale ~/.hindsight/claude-code.json.
|
|
149
|
+
# 12s, raised from the historical 8 by #3760 after measuring that 8s
|
|
150
|
+
# fired on 96.8% of one agent's own-bank recalls and returned nothing.
|
|
151
|
+
# Pinned as a literal on purpose: src/setup/hindsight-recall-tunables.ts
|
|
152
|
+
# carries the same number for the env export, which OUTRANKS this file,
|
|
153
|
+
# and hindsight-reranker-budget.test.ts fails CI if the two drift.
|
|
154
|
+
self.assertEqual(DEFAULTS["recallRequestTimeoutSeconds"], 12)
|
|
155
|
+
self.assertIn("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", ENV_OVERRIDES)
|
|
156
|
+
key, typ = ENV_OVERRIDES["HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS"]
|
|
157
|
+
self.assertEqual(key, "recallRequestTimeoutSeconds")
|
|
158
|
+
# int, matching the sibling HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS
|
|
159
|
+
# override and switchroom's zod schema, which declares
|
|
160
|
+
# `memory.recall.request_timeout_seconds` as `.int()`
|
|
161
|
+
# (src/config/schema.ts). A float cast here would accept a value the
|
|
162
|
+
# schema can never produce; an int cast against a float-typed schema
|
|
163
|
+
# would silently DROP the override (`_cast_env` returns None and the
|
|
164
|
+
# assignment is skipped), which is the exact silent-fallthrough this
|
|
165
|
+
# test class exists to prevent.
|
|
166
|
+
self.assertIs(typ, int)
|
|
167
|
+
|
|
168
|
+
def test_env_override_wins_over_the_built_in_default(self):
|
|
169
|
+
with patch.dict(
|
|
170
|
+
os.environ,
|
|
171
|
+
{"HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS": "11"},
|
|
172
|
+
clear=False,
|
|
173
|
+
):
|
|
174
|
+
self.assertEqual(load_config()["recallRequestTimeoutSeconds"], 11)
|
|
175
|
+
self.assertNotEqual(
|
|
176
|
+
load_config()["recallRequestTimeoutSeconds"],
|
|
177
|
+
DEFAULTS["recallRequestTimeoutSeconds"],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class EnvBeatsTheUserConfigFile(unittest.TestCase):
|
|
182
|
+
"""The env export is the ONLY carrier that outranks ~/.hindsight/claude-code.json.
|
|
183
|
+
|
|
184
|
+
Load order (load_config, lib/config.py:398-455):
|
|
185
|
+
|
|
186
|
+
DEFAULTS -> plugin settings.json -> ~/.hindsight/claude-code.json -> env
|
|
187
|
+
|
|
188
|
+
Switchroom stamps the envelope into the plugin's settings.json too, but
|
|
189
|
+
claude-code.json sits ABOVE that stamp and SURVIVES `switchroom apply` — the
|
|
190
|
+
live fleet had exactly such a file pinning recallParallelDeadlineSeconds to a
|
|
191
|
+
stale 16. A settings-only stamp is therefore silently defeated, which is why
|
|
192
|
+
start.sh exports these two UNCONDITIONALLY. These tests pin that precedence
|
|
193
|
+
so a future "just make it conditional / drop the export" change fails loudly
|
|
194
|
+
instead of quietly handing authority back to a hand-edited file.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
ENVELOPE = (
|
|
198
|
+
("HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS", "recallParallelDeadlineSeconds", 17, 16),
|
|
199
|
+
("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", "recallRequestTimeoutSeconds", 13, 99),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def setUp(self):
|
|
203
|
+
self._tmp_home = tempfile.mkdtemp(prefix="recall-envelope-home-")
|
|
204
|
+
os.makedirs(os.path.join(self._tmp_home, ".hindsight"), exist_ok=True)
|
|
205
|
+
# Point the plugin-root settings.json probe at an empty dir so only the
|
|
206
|
+
# two layers under test are in play.
|
|
207
|
+
self._tmp_plugin = tempfile.mkdtemp(prefix="recall-envelope-plugin-")
|
|
208
|
+
self._stale = {key: stale for _, key, _, stale in self.ENVELOPE}
|
|
209
|
+
with open(
|
|
210
|
+
os.path.join(self._tmp_home, ".hindsight", "claude-code.json"), "w"
|
|
211
|
+
) as f:
|
|
212
|
+
json.dump(self._stale, f)
|
|
213
|
+
|
|
214
|
+
def tearDown(self):
|
|
215
|
+
shutil.rmtree(self._tmp_home, ignore_errors=True)
|
|
216
|
+
shutil.rmtree(self._tmp_plugin, ignore_errors=True)
|
|
217
|
+
|
|
218
|
+
def _patched_env(self, extra):
|
|
219
|
+
env = {"HOME": self._tmp_home, "CLAUDE_PLUGIN_ROOT": self._tmp_plugin}
|
|
220
|
+
env.update(extra)
|
|
221
|
+
return patch.dict(os.environ, env, clear=False)
|
|
222
|
+
|
|
223
|
+
def test_a_stale_user_config_wins_when_nothing_is_exported(self):
|
|
224
|
+
# Establishes that the fixture is real: without the exports the stale
|
|
225
|
+
# hand-edit IS authoritative, so the next test proves something.
|
|
226
|
+
for env_name, key, _, stale in self.ENVELOPE:
|
|
227
|
+
with self.subTest(env_name=env_name), self._patched_env({}):
|
|
228
|
+
os.environ.pop(env_name, None)
|
|
229
|
+
self.assertEqual(load_config()[key], stale)
|
|
230
|
+
|
|
231
|
+
def test_the_exported_envelope_overrides_a_stale_user_config(self):
|
|
232
|
+
exports = {env_name: str(want) for env_name, _, want, _ in self.ENVELOPE}
|
|
233
|
+
with self._patched_env(exports):
|
|
234
|
+
config = load_config()
|
|
235
|
+
for _, key, want, stale in self.ENVELOPE:
|
|
236
|
+
self.assertEqual(config[key], want)
|
|
237
|
+
self.assertNotEqual(config[key], stale)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
unittest.main()
|