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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +129 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +796 -35
- package/dist/host-control/main.js +130 -9
- package/dist/vault/approvals/kernel-server.js +129 -8
- package/dist/vault/broker/server.js +129 -8
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +70 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +568 -49
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/settings.json +1 -1
- 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()
|