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.
Files changed (35) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +9 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +908 -527
  5. package/dist/host-control/main.js +10 -3
  6. package/dist/vault/approvals/kernel-server.js +10 -2
  7. package/dist/vault/broker/server.js +10 -2
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +6 -0
  10. package/profiles/_base/start.sh.hbs +40 -4
  11. package/telegram-plugin/dist/gateway/gateway.js +275 -109
  12. package/telegram-plugin/gateway/gateway.ts +53 -52
  13. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  14. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  15. package/telegram-plugin/status-no-truncate.ts +49 -0
  16. package/telegram-plugin/status-pin-driver.ts +28 -0
  17. package/telegram-plugin/status-pin.ts +33 -4
  18. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  19. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  20. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  21. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  22. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  23. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  24. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  25. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  26. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  27. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  28. package/telegram-plugin/tier-downgrade.ts +3 -2
  29. package/telegram-plugin/tool-activity-summary.ts +61 -18
  30. package/telegram-plugin/uat/assertions.ts +21 -2
  31. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  32. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  33. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  34. package/telegram-plugin/worker-activity-feed.ts +38 -17
  35. 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()