switchroom 0.19.27 → 0.19.28

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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,286 @@
1
+ """Two drains of one queue must never run at once — whoever the caller is.
2
+
3
+ Lives under ``scripts/tests/`` because that is the only python test directory
4
+ CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
5
+ tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
6
+
7
+ WHY THIS IS LOAD-BEARING. Retain extraction on this fleet is served by 4
8
+ shared LLM lanes and a single entry measured 85-280s, so two drains walking
9
+ the same queue is not a tidiness problem: it is the same ~168s of a scarce
10
+ fleet-wide resource, spent twice, for one memory. The queue has three
11
+ independent drain paths and NONE of them is in a position to serialise the
12
+ others:
13
+
14
+ * ``session_start.py`` imports ``drain`` and calls it in-process at every
15
+ session boot, with no lock of its own;
16
+ * the ``hindsight-drain`` sidecar (``profiles/_base/start.sh.hbs``) ticks on
17
+ a timer inside the same container;
18
+ * ``switchroom doctor`` documents an out-of-band ``docker exec …
19
+ drain_pending.py --backlog`` for operators clearing a backlog.
20
+
21
+ An agent booting a session while the sidecar is mid-backlog is the ordinary
22
+ case, not a corner. So the lock is taken inside ``drain()`` — the one
23
+ function all three go through — and these tests pin that it holds for the
24
+ in-process caller AND for a genuinely separate process running the CLI.
25
+
26
+ Every test here fails if ``_exclusive_drain`` is removed from ``drain()``:
27
+ the work happens twice instead of being skipped.
28
+ """
29
+
30
+ import fcntl
31
+ import io
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import unittest
37
+ import unittest.mock
38
+ from contextlib import contextmanager, redirect_stderr
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 drain_pending # noqa: E402
45
+ import lib.pending as pending # noqa: E402
46
+
47
+ from tests.test_pending_drops import ( # noqa: E402
48
+ CONFIG,
49
+ _QueueTempDirMixin,
50
+ _cd_doc,
51
+ _payload,
52
+ )
53
+
54
+
55
+ @contextmanager
56
+ def _held_by_someone_else(path):
57
+ """Hold the drain lock on a SEPARATE open file description.
58
+
59
+ ``flock`` locks belong to the open file description, not to the process,
60
+ so a second ``open()`` of the same file contends with this one exactly as
61
+ another process would (flock(2): "these file descriptors are treated
62
+ independently"). That keeps the test deterministic — no sleeps, no race
63
+ with a child's startup — and the CLI case below covers a real process.
64
+ """
65
+ os.makedirs(os.path.dirname(path), exist_ok=True)
66
+ fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
67
+ try:
68
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
69
+ yield
70
+ finally:
71
+ os.close(fd)
72
+
73
+
74
+ class DrainLockTest(_QueueTempDirMixin, unittest.TestCase):
75
+ ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_LOCK",)
76
+
77
+ def setUp(self):
78
+ super().setUp()
79
+ self.posted = []
80
+ self.presence_checks = []
81
+
82
+ def _seed(self, n=3):
83
+ for i in range(n):
84
+ pending.enqueue(_payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x"))
85
+
86
+ def _record_post(self, entry, timeout):
87
+ self.posted.append(entry["content"])
88
+
89
+ def _record_presence(self, entry, timeout=30):
90
+ self.presence_checks.append(entry["content"])
91
+ return True
92
+
93
+ def _drain(self, **kw):
94
+ with unittest.mock.patch.object(
95
+ drain_pending, "_retry_one", self._record_post
96
+ ):
97
+ with unittest.mock.patch.object(
98
+ drain_pending, "_document_state", self._record_presence
99
+ ):
100
+ with redirect_stderr(io.StringIO()) as err:
101
+ summary = drain_pending.drain(CONFIG, **kw)
102
+ return summary, err.getvalue()
103
+
104
+ # ── the lock sits beside the queue it protects ────────────────────
105
+
106
+ def test_the_lock_lives_next_to_the_queue_it_protects(self):
107
+ """One lock per queue, so ``HINDSIGHT_PENDING_DIR`` moves both."""
108
+ self.assertEqual(
109
+ drain_pending._drain_lock_path(),
110
+ os.path.join(self._tmp, "drain-pending.lock"),
111
+ )
112
+
113
+ def test_the_production_path_is_the_agent_hindsight_dir(self):
114
+ """What the sidecar and the doctor recovery command actually share."""
115
+ os.environ.pop("HINDSIGHT_PENDING_DIR")
116
+ with unittest.mock.patch.dict(os.environ, {"HOME": "/state/agent/home"}):
117
+ self.assertEqual(
118
+ drain_pending._drain_lock_path(),
119
+ "/state/agent/home/.hindsight/drain-pending.lock",
120
+ )
121
+
122
+ def test_it_is_not_the_path_the_interim_host_cron_wraps(self):
123
+ """Deliberate: same path would make that wrapper starve this drain.
124
+
125
+ The interim host stopgap runs ``docker exec … flock -n
126
+ $HOME/.hindsight/drain.lock python3 drain_pending.py --backlog``. If
127
+ this module locked THAT path, the wrapper would already hold it and
128
+ the drain it just launched would skip every time — a silent no-op for
129
+ as long as both exist.
130
+ """
131
+ self.assertNotIn("/drain.lock", drain_pending._drain_lock_path())
132
+
133
+ # ── the in-process caller (SessionStart) ──────────────────────────
134
+
135
+ def test_the_in_hook_drain_does_nothing_while_another_drain_holds_it(self):
136
+ """``session_start.py`` calls this exact function, with no lock."""
137
+ self._seed(3)
138
+
139
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
140
+ summary, err = self._drain()
141
+
142
+ self.assertEqual(self.posted, [], "no entry may be re-POSTed by a 2nd drain")
143
+ self.assertEqual(
144
+ self.presence_checks, [], "not even a free GET: the run did not happen"
145
+ )
146
+ self.assertTrue(summary["skipped_locked"])
147
+ self.assertEqual(summary["drained"], 0)
148
+ self.assertEqual(summary["reconciled"], 0)
149
+ self.assertEqual(pending.count(), 3, "the queue is untouched")
150
+ self.assertIn("another drain holds", err)
151
+
152
+ def test_the_backlog_drain_does_nothing_while_another_drain_holds_it(self):
153
+ """The sidecar's mode. Phase 1 is free, but it is not idempotent-free:
154
+ two reconcilers race on the same archive move."""
155
+ self._seed(3)
156
+
157
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
158
+ summary, _ = self._drain(backlog=True)
159
+
160
+ self.assertEqual(self.posted, [])
161
+ self.assertEqual(self.presence_checks, [])
162
+ self.assertTrue(summary["skipped_locked"])
163
+ self.assertEqual(pending.count(), 3)
164
+
165
+ def test_the_skip_is_the_lock_and_not_an_unconditional_refusal(self):
166
+ """Guard rail: a drain that always skipped would pass the tests above
167
+ and drain nothing in production, which is the bug the whole PR is
168
+ about. Same queue, same call, lock free → the work happens."""
169
+ self._seed(3)
170
+
171
+ # `phase="drain"` so the free reconcile pass does not retire the
172
+ # entries before phase 2 can post them — the assertion here is that
173
+ # the EXPENSIVE work runs when the lock is free.
174
+ summary, _ = self._drain(backlog=True, phase="drain")
175
+
176
+ self.assertEqual(sorted(self.posted), ["c0", "c1", "c2"])
177
+ self.assertFalse(summary["skipped_locked"])
178
+ self.assertEqual(pending.count(), 0)
179
+
180
+ def test_the_lock_is_released_when_the_run_finishes(self):
181
+ """Not a one-shot: the next tick, 900s later, must still get in."""
182
+ self._seed(1)
183
+ self._drain(backlog=True)
184
+
185
+ # If the previous run leaked its fd, this acquisition raises.
186
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
187
+ pass
188
+
189
+ def test_an_unopenable_lock_still_drains(self):
190
+ """A read-only ``.hindsight/`` must not silently disable memory replay.
191
+
192
+ Losing serialisation costs duplicated LLM work; losing the drain costs
193
+ the memory itself. The cheaper failure wins, loudly.
194
+ """
195
+ self._seed(1)
196
+ os.environ["HINDSIGHT_DRAIN_LOCK"] = os.path.join(
197
+ self._tmp, "no-such-dir", "x", "drain-pending.lock"
198
+ )
199
+ with unittest.mock.patch.object(
200
+ drain_pending.os, "makedirs", side_effect=PermissionError("read-only")
201
+ ):
202
+ summary, err = self._drain(backlog=True)
203
+
204
+ self.assertEqual(self.posted, ["c0"], "the drain must still run")
205
+ self.assertFalse(summary["skipped_locked"])
206
+ self.assertIn("WITHOUT serialisation", err)
207
+
208
+ # ── a genuinely separate process (the doctor recovery command) ────
209
+
210
+ def test_the_cli_a_separate_process_runs_also_skips(self):
211
+ """``switchroom doctor`` tells operators to run this by hand, with no
212
+ ``flock``. Before the lock moved into this module, doing that while
213
+ the sidecar ticked meant two processes on one queue."""
214
+ self._seed(2)
215
+ env = dict(os.environ)
216
+ env["HINDSIGHT_PENDING_DIR"] = self._dir
217
+ env["HOME"] = self._tmp
218
+
219
+ with _held_by_someone_else(drain_pending._drain_lock_path()):
220
+ proc = subprocess.run(
221
+ [
222
+ sys.executable,
223
+ os.path.join(SCRIPTS_DIR, "drain_pending.py"),
224
+ "--backlog",
225
+ ],
226
+ capture_output=True,
227
+ text=True,
228
+ timeout=60,
229
+ env=env,
230
+ )
231
+
232
+ self.assertEqual(proc.returncode, 0, proc.stderr)
233
+ self.assertIn("another drain holds", proc.stderr)
234
+ self.assertEqual(
235
+ pending.count(), 2, "a second process must not touch the queue"
236
+ )
237
+ # And nothing was posted: the entries point at 127.0.0.1:9, so a run
238
+ # that had NOT skipped would have recorded attempts against them.
239
+ for _path, entry in pending.iter_entries():
240
+ self.assertEqual(int(entry.get("attempt_count", 0)), 1)
241
+
242
+
243
+ class SessionStartUsesTheLockedEntryPointTest(unittest.TestCase):
244
+ """The hook must not acquire a private, unlocked path into the queue.
245
+
246
+ ``session_start.py`` does ``from drain_pending import drain as
247
+ drain_pending_retains``. If it ever imported an impl helper instead (or a
248
+ future refactor moved the work out from under the lock) the serialisation
249
+ guarantee would silently regress for the one caller that runs on every
250
+ single session boot.
251
+ """
252
+
253
+ def test_session_start_imports_the_locked_public_drain(self):
254
+ with open(
255
+ os.path.join(SCRIPTS_DIR, "session_start.py"), encoding="utf-8"
256
+ ) as f:
257
+ src = f.read()
258
+ self.assertIn("from drain_pending import drain as drain_pending_retains", src)
259
+ self.assertNotIn("_drain_inhook_impl", src)
260
+ self.assertNotIn("_drain_backlog_impl", src)
261
+
262
+ def test_the_public_drain_is_what_takes_the_lock(self):
263
+ """Executable half of the check above: calling ``drain`` — the symbol
264
+ session_start binds — is what consults the lock."""
265
+ seen = []
266
+ real = drain_pending._exclusive_drain
267
+
268
+ def spy():
269
+ seen.append(True)
270
+ return real()
271
+
272
+ # A throwaway queue dir, so neither the empty queue nor the lock file
273
+ # this creates lands in the repo checkout.
274
+ with tempfile.TemporaryDirectory() as tmp:
275
+ env = {"HINDSIGHT_PENDING_DIR": os.path.join(tmp, "pending-retains")}
276
+ with unittest.mock.patch.dict(os.environ, env):
277
+ with unittest.mock.patch.object(
278
+ drain_pending, "_exclusive_drain", spy
279
+ ):
280
+ with redirect_stderr(io.StringIO()):
281
+ drain_pending.drain(CONFIG)
282
+ self.assertEqual(seen, [True], "drain() must consult the lock every call")
283
+
284
+
285
+ if __name__ == "__main__":
286
+ unittest.main()