switchroom 0.19.22 → 0.19.23
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 +2 -1
- package/dist/auth-broker/index.js +68 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +552 -320
- package/dist/host-control/main.js +69 -2
- package/dist/vault/approvals/kernel-server.js +71 -4
- package/dist/vault/broker/server.js +71 -4
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +108 -16
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +9 -7
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -57,22 +57,24 @@ class TestDerivedLimit(unittest.TestCase):
|
|
|
57
57
|
"HINDSIGHT_RETAIN_CHUNK_SIZE",
|
|
58
58
|
"HINDSIGHT_RETAIN_CHUNK_LATENCY_S",
|
|
59
59
|
"HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
|
|
60
|
+
"HINDSIGHT_RETAIN_DEADLINE_SAFETY",
|
|
60
61
|
):
|
|
61
62
|
os.environ.pop(key, None)
|
|
62
63
|
|
|
63
64
|
def test_limit_is_chunk_size_times_chunks_that_fit_the_deadline(self):
|
|
64
|
-
# chunk_size * floor(deadline / latency)
|
|
65
|
-
|
|
65
|
+
# chunk_size * floor(deadline * safety / latency)
|
|
66
|
+
# = 3000 * floor(310 * 0.7 / 18.4) = 3000 * 11 = 33000
|
|
67
|
+
self.assertEqual(retain_content_limit(), 33000)
|
|
66
68
|
|
|
67
69
|
def test_limit_is_derived_from_chunk_size_not_a_constant(self):
|
|
68
70
|
os.environ["HINDSIGHT_RETAIN_CHUNK_SIZE"] = "1000"
|
|
69
|
-
# 1000 * floor(310 / 18.4) = 1000 *
|
|
70
|
-
self.assertEqual(retain_content_limit(),
|
|
71
|
+
# 1000 * floor(310 * 0.7 / 18.4) = 1000 * 11
|
|
72
|
+
self.assertEqual(retain_content_limit(), 11000)
|
|
71
73
|
|
|
72
74
|
def test_limit_tracks_the_client_deadline(self):
|
|
73
75
|
os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "92"
|
|
74
|
-
# floor(92 / 18.4) = 5 chunks
|
|
75
|
-
self.assertEqual(retain_content_limit(),
|
|
76
|
+
# floor(92 * 0.7 / 18.4) = floor(3.5) = 3 chunks
|
|
77
|
+
self.assertEqual(retain_content_limit(), 9000)
|
|
76
78
|
|
|
77
79
|
def test_limit_never_falls_below_one_chunk(self):
|
|
78
80
|
os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "1"
|
|
@@ -80,7 +82,77 @@ class TestDerivedLimit(unittest.TestCase):
|
|
|
80
82
|
|
|
81
83
|
def test_garbage_env_falls_back_to_the_derived_default(self):
|
|
82
84
|
os.environ["HINDSIGHT_RETAIN_CHUNK_LATENCY_S"] = "not-a-number"
|
|
85
|
+
self.assertEqual(retain_content_limit(), 33000)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestTheBoundLeavesMarginUnderTheDeadline(unittest.TestCase):
|
|
89
|
+
"""The bound must NOT consume the whole deadline (#3693).
|
|
90
|
+
|
|
91
|
+
Sizing a maximally-sized part to ~100% of the client deadline is what
|
|
92
|
+
manufactured `.dead` entries on this fleet: every `.dead` marker measured
|
|
93
|
+
on 2026-07-26 held 16,076-44,568 chars, i.e. every one of them sat UNDER
|
|
94
|
+
the then-current bound and still could not finish inside the deadline the
|
|
95
|
+
bound was derived from. `latency` is a MEAN (n=752), so half the
|
|
96
|
+
extraction calls are slower than it; a part sized to the mean misses its
|
|
97
|
+
deadline about half the time, the drain bumps its attempt count, and
|
|
98
|
+
MAX_ATTEMPTS later the memory is dead.
|
|
99
|
+
|
|
100
|
+
These assert the OUTCOME — real headroom — rather than the arithmetic, so
|
|
101
|
+
they fail on the zero-margin derivation regardless of how it is spelled.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def tearDown(self):
|
|
105
|
+
for key in (
|
|
106
|
+
"HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
|
|
107
|
+
"HINDSIGHT_RETAIN_CHUNK_SIZE",
|
|
108
|
+
"HINDSIGHT_RETAIN_CHUNK_LATENCY_S",
|
|
109
|
+
"HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
|
|
110
|
+
"HINDSIGHT_RETAIN_DEADLINE_SAFETY",
|
|
111
|
+
):
|
|
112
|
+
os.environ.pop(key, None)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _worst_case_seconds() -> float:
|
|
116
|
+
"""Sequential extraction time of a maximally-sized part."""
|
|
117
|
+
chunks = retain_content_limit() / retain_split.DEFAULT_RETAIN_CHUNK_SIZE
|
|
118
|
+
return chunks * retain_split.DEFAULT_RETAIN_CHUNK_LATENCY_S
|
|
119
|
+
|
|
120
|
+
def test_a_maximally_sized_part_finishes_well_inside_the_deadline(self):
|
|
121
|
+
deadline = retain_split.retain_client_deadline()
|
|
122
|
+
worst = self._worst_case_seconds()
|
|
123
|
+
# The pre-#3693 derivation gave 294.4s against 310s -> 0.95 here.
|
|
124
|
+
self.assertLessEqual(
|
|
125
|
+
worst / deadline,
|
|
126
|
+
0.8,
|
|
127
|
+
f"a maximally-sized part is {worst:.1f}s of extraction against a "
|
|
128
|
+
f"{deadline:.0f}s deadline: no room for the POST, server queueing, "
|
|
129
|
+
f"or a chunk slower than the mean",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def test_the_headroom_absorbs_a_chunk_distribution_worse_than_its_mean(self):
|
|
133
|
+
deadline = retain_split.retain_client_deadline()
|
|
134
|
+
# Every chunk running 25% slower than the measured mean must still fit.
|
|
135
|
+
self.assertLessEqual(self._worst_case_seconds() * 1.25, deadline)
|
|
136
|
+
|
|
137
|
+
def test_the_margin_holds_at_every_deadline_not_just_the_default(self):
|
|
138
|
+
for seconds in ("120", "200", "310", "600", "900"):
|
|
139
|
+
os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = seconds
|
|
140
|
+
worst = self._worst_case_seconds()
|
|
141
|
+
deadline = float(seconds)
|
|
142
|
+
with self.subTest(deadline=seconds):
|
|
143
|
+
self.assertLessEqual(worst / deadline, 0.8)
|
|
144
|
+
|
|
145
|
+
def test_the_safety_fraction_is_operator_tunable(self):
|
|
146
|
+
os.environ["HINDSIGHT_RETAIN_DEADLINE_SAFETY"] = "0.5"
|
|
147
|
+
# floor(310 * 0.5 / 18.4) = floor(8.42) = 8 chunks
|
|
148
|
+
self.assertEqual(retain_content_limit(), 24000)
|
|
149
|
+
|
|
150
|
+
def test_a_safety_fraction_above_one_cannot_reintroduce_the_overrun(self):
|
|
151
|
+
os.environ["HINDSIGHT_RETAIN_DEADLINE_SAFETY"] = "2.0"
|
|
152
|
+
# Clamped to 1.0, so the WORST an operator can do is the old
|
|
153
|
+
# zero-margin bound -- never a part sized to overrun outright.
|
|
83
154
|
self.assertEqual(retain_content_limit(), 48000)
|
|
155
|
+
self.assertLessEqual(self._worst_case_seconds(), retain_split.retain_client_deadline())
|
|
84
156
|
|
|
85
157
|
|
|
86
158
|
class TestSplitBounds(unittest.TestCase):
|
|
@@ -15,6 +15,21 @@ if SCRIPTS_DIR not in sys.path:
|
|
|
15
15
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _dead_marker(path: str) -> str:
|
|
19
|
+
"""Where `mark_dead` puts the marker for the queue entry at `path`.
|
|
20
|
+
|
|
21
|
+
NOT `<path>.dead` any more: a marker is the only remaining copy of its
|
|
22
|
+
memory, so it leaves the live queue directory for the `pending-dead/`
|
|
23
|
+
sibling rather than sitting in the directory external janitors glob
|
|
24
|
+
(switchroom #3697). Asserting the old spelling made this suite red on a
|
|
25
|
+
deliberate change -- invisibly, because CI discovers only
|
|
26
|
+
`scripts/tests/`.
|
|
27
|
+
"""
|
|
28
|
+
from lib.pending import dead_dir
|
|
29
|
+
|
|
30
|
+
return os.path.join(dead_dir(), os.path.basename(path) + ".dead")
|
|
31
|
+
|
|
32
|
+
|
|
18
33
|
class FakeOk:
|
|
19
34
|
"""Minimal urlopen() context-manager stand-in for a 200 OK."""
|
|
20
35
|
|
|
@@ -229,7 +244,7 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
229
244
|
summary = drain_pending.drain({})
|
|
230
245
|
self.assertEqual(summary["dead"], 1)
|
|
231
246
|
self.assertFalse(os.path.exists(path))
|
|
232
|
-
self.assertTrue(os.path.exists(path
|
|
247
|
+
self.assertTrue(os.path.exists(_dead_marker(path)))
|
|
233
248
|
|
|
234
249
|
def test_drain_max_attempts_keeps_the_memory_on_a_transient_failure(self):
|
|
235
250
|
"""A dead upstream must never retire a memory, however many attempts.
|
|
@@ -251,7 +266,7 @@ class DrainPendingTest(unittest.TestCase):
|
|
|
251
266
|
self.assertEqual(summary["dead"], 0)
|
|
252
267
|
self.assertEqual(summary["retried"], 1)
|
|
253
268
|
self.assertTrue(os.path.exists(path))
|
|
254
|
-
self.assertFalse(os.path.exists(path
|
|
269
|
+
self.assertFalse(os.path.exists(_dead_marker(path)))
|
|
255
270
|
|
|
256
271
|
def test_drain_stall_guard_stops_after_threshold(self):
|
|
257
272
|
# Seed 10 entries; with a same-error-class stream, the stall
|
|
@@ -38,7 +38,10 @@ class PendingQueueTest(unittest.TestCase):
|
|
|
38
38
|
"api_url": "http://fake:9077",
|
|
39
39
|
"api_token": None,
|
|
40
40
|
"bank_id": "test-bank",
|
|
41
|
-
|
|
41
|
+
# Distinct per document: the queue's dedupe key is
|
|
42
|
+
# (bank_id, part_position, sha256(content)) since switchroom
|
|
43
|
+
# #3688, so a shared body would collapse these into one entry.
|
|
44
|
+
"content": f"user: hello\nassistant: hi ({document_id})",
|
|
42
45
|
"document_id": document_id,
|
|
43
46
|
"context": "claude-code",
|
|
44
47
|
"metadata": {"session_id": "sess-1"},
|
|
@@ -59,7 +62,7 @@ class PendingQueueTest(unittest.TestCase):
|
|
|
59
62
|
with open(path) as f:
|
|
60
63
|
entry = json.load(f)
|
|
61
64
|
self.assertEqual(entry["bank_id"], "test-bank")
|
|
62
|
-
self.assertEqual(entry["content"], "
|
|
65
|
+
self.assertEqual(entry["content"], self._sample_payload()["content"])
|
|
63
66
|
self.assertEqual(entry["document_id"], "doc-1")
|
|
64
67
|
self.assertEqual(entry["error_class"], "ValueError")
|
|
65
68
|
self.assertEqual(entry["error_message"], "nope")
|
|
@@ -200,8 +203,13 @@ class PendingQueueTest(unittest.TestCase):
|
|
|
200
203
|
self.assertNotIn(
|
|
201
204
|
"dead_at", e, "a live queue entry must never carry dead_at"
|
|
202
205
|
)
|
|
203
|
-
# The .dead marker exists and carries dead_at.
|
|
204
|
-
dead
|
|
206
|
+
# The .dead marker exists and carries dead_at. It lives in the
|
|
207
|
+
# `pending-dead/` SIBLING, not in the live queue directory: a marker
|
|
208
|
+
# is the only remaining copy of its memory, and the queue dir is the
|
|
209
|
+
# one external janitors glob (switchroom #3697).
|
|
210
|
+
dead = os.path.join(
|
|
211
|
+
pending_mod.dead_dir(), os.path.basename(path) + ".dead"
|
|
212
|
+
)
|
|
205
213
|
self.assertTrue(os.path.isfile(dead))
|
|
206
214
|
with open(dead) as f:
|
|
207
215
|
self.assertIn("dead_at", json.load(f))
|