switchroom 0.18.24 → 0.18.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/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Switchroom #3244 — durable-retain / boot-reconciliation outcome tests.
|
|
2
|
+
|
|
3
|
+
Stdlib-only (`python3 -m unittest discover tests/`). Every test drives the real
|
|
4
|
+
hook code against a FAKE in-process daemon (no network, no LLM) and asserts
|
|
5
|
+
OUTCOMES — content landed in the bank / entries landed in the pending queue /
|
|
6
|
+
the watermark's value — not merely that a code path ran.
|
|
7
|
+
|
|
8
|
+
Covers the PR1 slice of design-20260715.md §4: tests 1 (SIGKILL→boot recall),
|
|
9
|
+
2 (failed Stop retain enqueued), 3 (live-Stop + reconcile ⇒ ONE doc via the
|
|
10
|
+
single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
|
|
11
|
+
(200-not-persisted / commit-before-ack), 9 (dry-run/seam zero writes), 10 (a
|
|
12
|
+
bound ENQUEUES the remainder).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
24
|
+
if SCRIPTS_DIR not in sys.path:
|
|
25
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
26
|
+
|
|
27
|
+
import reconcile_tail # noqa: E402
|
|
28
|
+
import retain # noqa: E402
|
|
29
|
+
from lib import watermark # noqa: E402
|
|
30
|
+
from lib.client import HindsightClient # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeDaemon:
|
|
34
|
+
"""Records retained documents by document_id with upsert semantics.
|
|
35
|
+
|
|
36
|
+
``fail`` → every POST raises (stressed/unreachable daemon).
|
|
37
|
+
``drop_async`` → an ``async_processing=True`` POST 200s but does NOT persist
|
|
38
|
+
(ack-of-receipt, extraction lost) — the #3244 async-200 hazard. An
|
|
39
|
+
``async_processing=False`` POST always persists (commit-before-ack).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.docs = {} # document_id -> {content, metadata, async}
|
|
44
|
+
self.posts = [] # [(document_id, async_processing)]
|
|
45
|
+
self.fail = False
|
|
46
|
+
self.drop_async = False
|
|
47
|
+
|
|
48
|
+
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
49
|
+
metadata=None, tags=None, timeout=15, async_processing=True):
|
|
50
|
+
self.posts.append((document_id, async_processing))
|
|
51
|
+
if self.fail:
|
|
52
|
+
raise RuntimeError("simulated daemon failure")
|
|
53
|
+
persisted = (not async_processing) or (not self.drop_async)
|
|
54
|
+
if persisted:
|
|
55
|
+
# Upsert: same document_id overwrites (the daemon contract §1).
|
|
56
|
+
self.docs[document_id] = {
|
|
57
|
+
"content": content, "metadata": metadata, "async": async_processing,
|
|
58
|
+
}
|
|
59
|
+
return {"ok": True}
|
|
60
|
+
|
|
61
|
+
# Any content substring present across all stored docs?
|
|
62
|
+
def content_blob(self):
|
|
63
|
+
return "\n".join(d["content"] for d in self.docs.values())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_transcript(path, n_turns, session_prefix="u"):
|
|
67
|
+
"""Flat-format JSONL: n_turns human turns (user+assistant), each with a uuid."""
|
|
68
|
+
lines = []
|
|
69
|
+
for i in range(n_turns):
|
|
70
|
+
lines.append(json.dumps({"role": "user", "content": f"user turn {i}", "uuid": f"{session_prefix}-u{i}"}))
|
|
71
|
+
lines.append(json.dumps({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"{session_prefix}-a{i}"}))
|
|
72
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
73
|
+
f.write("\n".join(lines))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DurabilityTestBase(unittest.TestCase):
|
|
77
|
+
def setUp(self):
|
|
78
|
+
self.tmp = tempfile.mkdtemp(prefix="hs-3244-")
|
|
79
|
+
self.plugin_root = os.path.join(self.tmp, "plugin_root")
|
|
80
|
+
self.home = os.path.join(self.tmp, "home")
|
|
81
|
+
self.data = os.path.join(self.tmp, "data")
|
|
82
|
+
self.transcripts = os.path.join(self.tmp, "transcripts")
|
|
83
|
+
for d in (self.plugin_root, self.home, self.data, self.transcripts):
|
|
84
|
+
os.makedirs(d)
|
|
85
|
+
|
|
86
|
+
# chunked + n=3 → the deployed default, which exercises the
|
|
87
|
+
# content-derived document_id path (§1). bankMission empty → no mission
|
|
88
|
+
# PATCH. autoRetain on.
|
|
89
|
+
settings = {
|
|
90
|
+
"autoRetain": True,
|
|
91
|
+
"autoRecall": True,
|
|
92
|
+
"retainMode": "chunked",
|
|
93
|
+
"retainEveryNTurns": 3,
|
|
94
|
+
"retainOverlapTurns": 0,
|
|
95
|
+
"bankId": "test-bank",
|
|
96
|
+
"reconcileOnStart": True,
|
|
97
|
+
}
|
|
98
|
+
with open(os.path.join(self.plugin_root, "settings.json"), "w") as f:
|
|
99
|
+
json.dump(settings, f)
|
|
100
|
+
|
|
101
|
+
self.env = mock.patch.dict(os.environ, {
|
|
102
|
+
"CLAUDE_PLUGIN_ROOT": self.plugin_root,
|
|
103
|
+
"CLAUDE_PLUGIN_DATA": self.data,
|
|
104
|
+
"HOME": self.home,
|
|
105
|
+
"HINDSIGHT_PENDING_DIR": os.path.join(self.home, ".hindsight", "pending-retains"),
|
|
106
|
+
"HINDSIGHT_RETAINED_DIR": os.path.join(self.home, ".hindsight", "retained"),
|
|
107
|
+
"HINDSIGHT_INFLIGHT_LOCK": os.path.join(self.home, ".hindsight", "retain-inflight.lock"),
|
|
108
|
+
"HINDSIGHT_TRANSCRIPTS_DIR": self.transcripts,
|
|
109
|
+
}, clear=False)
|
|
110
|
+
self.env.start()
|
|
111
|
+
for k in list(os.environ):
|
|
112
|
+
if k.startswith("HINDSIGHT_") and k not in (
|
|
113
|
+
"HINDSIGHT_PENDING_DIR", "HINDSIGHT_RETAINED_DIR",
|
|
114
|
+
"HINDSIGHT_INFLIGHT_LOCK", "HINDSIGHT_TRANSCRIPTS_DIR",
|
|
115
|
+
):
|
|
116
|
+
os.environ.pop(k, None)
|
|
117
|
+
|
|
118
|
+
self.daemon = FakeDaemon()
|
|
119
|
+
self._patches = [
|
|
120
|
+
mock.patch.object(HindsightClient, "retain", self._fake_retain),
|
|
121
|
+
mock.patch("retain.get_api_url", return_value="http://fake"),
|
|
122
|
+
mock.patch("reconcile_tail.get_api_url", return_value="http://fake"),
|
|
123
|
+
]
|
|
124
|
+
for p in self._patches:
|
|
125
|
+
p.start()
|
|
126
|
+
|
|
127
|
+
def _fake_retain(self, *a, **kw):
|
|
128
|
+
return self.daemon.retain(*a, **kw)
|
|
129
|
+
|
|
130
|
+
def tearDown(self):
|
|
131
|
+
for p in self._patches:
|
|
132
|
+
p.stop()
|
|
133
|
+
self.env.stop()
|
|
134
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
135
|
+
|
|
136
|
+
def _config(self):
|
|
137
|
+
from lib.config import load_config
|
|
138
|
+
return load_config()
|
|
139
|
+
|
|
140
|
+
def _pending_entries(self):
|
|
141
|
+
from lib import pending
|
|
142
|
+
return pending.iter_entries()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestForwardFix(DurabilityTestBase):
|
|
146
|
+
# -- Test 1: SIGKILL-after-work → next-boot reconcile surfaces it -----------
|
|
147
|
+
def test_sigkill_then_boot_reconcile_recovers_all_turns(self):
|
|
148
|
+
session = "sessA"
|
|
149
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
150
|
+
_write_transcript(tpath, 5, session_prefix=session)
|
|
151
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
152
|
+
|
|
153
|
+
# (b) Stop retains fire but the daemon REFUSES them. run via main() so
|
|
154
|
+
# the A2 enqueue path is exercised.
|
|
155
|
+
self.daemon.fail = True
|
|
156
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
157
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
158
|
+
retain.main()
|
|
159
|
+
|
|
160
|
+
# watermark did NOT advance (no confirmed persistence) ...
|
|
161
|
+
self.assertIsNone(watermark.load(session))
|
|
162
|
+
# ... and the failed Stop retain was ENQUEUED, not dropped (A2).
|
|
163
|
+
self.assertEqual(len(self._pending_entries()), 1)
|
|
164
|
+
|
|
165
|
+
# (c) No SessionEnd fires (abrupt kill). (d) Boot against a healthy daemon.
|
|
166
|
+
self.daemon.fail = False
|
|
167
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
168
|
+
|
|
169
|
+
# OUTCOME: all 5 turns' content is now in the bank.
|
|
170
|
+
blob = self.daemon.content_blob()
|
|
171
|
+
for i in range(5):
|
|
172
|
+
self.assertIn(f"user turn {i}", blob)
|
|
173
|
+
# And the watermark now anchors on the last committed entry.
|
|
174
|
+
wm = watermark.load(session)
|
|
175
|
+
self.assertIsNotNone(wm)
|
|
176
|
+
self.assertEqual(wm["last_uuid"], f"{session}-a4")
|
|
177
|
+
|
|
178
|
+
# -- Test 2: failed Stop retain is enqueued, not dropped --------------------
|
|
179
|
+
def test_failed_stop_retain_enqueued_with_deterministic_id(self):
|
|
180
|
+
session = "sessB"
|
|
181
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
182
|
+
_write_transcript(tpath, 3, session_prefix=session)
|
|
183
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
184
|
+
|
|
185
|
+
self.daemon.fail = True
|
|
186
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
187
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
188
|
+
retain.main()
|
|
189
|
+
|
|
190
|
+
entries = self._pending_entries()
|
|
191
|
+
self.assertEqual(len(entries), 1)
|
|
192
|
+
_, entry = entries[0]
|
|
193
|
+
# The queued payload carries the deterministic content-derived id.
|
|
194
|
+
self.assertTrue(entry["document_id"].startswith(f"{session}-r"))
|
|
195
|
+
self.assertIn(f"{session}-u", entry["document_id"]) # full uuids, not 8-char
|
|
196
|
+
|
|
197
|
+
# Drain against a healthy daemon delivers it and deletes the entry.
|
|
198
|
+
self.daemon.fail = False
|
|
199
|
+
from drain_pending import drain
|
|
200
|
+
drain(self._config())
|
|
201
|
+
self.assertEqual(len(self._pending_entries()), 0)
|
|
202
|
+
self.assertIn(entry["document_id"], self.daemon.docs)
|
|
203
|
+
|
|
204
|
+
# -- Test 3 (rewritten): live Stop + reconcile of same window ⇒ ONE doc -----
|
|
205
|
+
def test_live_stop_and_reconcile_converge_on_single_document(self):
|
|
206
|
+
session = "sessC"
|
|
207
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
208
|
+
_write_transcript(tpath, 3, session_prefix=session) # window(3) == whole transcript
|
|
209
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
210
|
+
|
|
211
|
+
# Live Stop retain — writes the content-derived id, advances watermark.
|
|
212
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
213
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
214
|
+
retain.main()
|
|
215
|
+
self.assertEqual(len(self.daemon.docs), 1)
|
|
216
|
+
live_id = self.daemon.posts[0][0]
|
|
217
|
+
self.assertTrue(live_id.startswith(f"{session}-r"))
|
|
218
|
+
|
|
219
|
+
# Simulate a watermark loss (container recreate) so reconcile RE-posts
|
|
220
|
+
# the same window — proving the two paths collide on document_id.
|
|
221
|
+
os.remove(os.path.join(os.environ["HINDSIGHT_RETAINED_DIR"], f"{session}.json"))
|
|
222
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
223
|
+
|
|
224
|
+
# Two POSTs, identical id, still exactly ONE stored document (upsert).
|
|
225
|
+
self.assertEqual(len(self.daemon.docs), 1)
|
|
226
|
+
self.assertEqual(self.daemon.posts[-1][0], live_id)
|
|
227
|
+
|
|
228
|
+
# -- Test 7: commit-before-ack; 200-not-persisted doesn't advance watermark -
|
|
229
|
+
def test_durability_posts_are_synchronous_and_gate_the_watermark(self):
|
|
230
|
+
session = "sessD"
|
|
231
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
232
|
+
_write_transcript(tpath, 3, session_prefix=session)
|
|
233
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
234
|
+
|
|
235
|
+
# A failing durability POST must NOT advance the watermark ...
|
|
236
|
+
self.daemon.fail = True
|
|
237
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
238
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
239
|
+
retain.main()
|
|
240
|
+
self.assertIsNone(watermark.load(session))
|
|
241
|
+
|
|
242
|
+
# ... and every durability POST is async_processing=False (commit-before
|
|
243
|
+
# -ack), so a bare async-200 can never falsely mark work committed.
|
|
244
|
+
self.assertTrue(self.daemon.posts, "expected at least one durability POST")
|
|
245
|
+
self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
|
|
246
|
+
|
|
247
|
+
# Next boot re-catches the turns and advances the watermark.
|
|
248
|
+
self.daemon.fail = False
|
|
249
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
250
|
+
self.assertIsNotNone(watermark.load(session))
|
|
251
|
+
self.assertIn("user turn 2", self.daemon.content_blob())
|
|
252
|
+
|
|
253
|
+
# -- Test 10: a bound (200-turn cap) ENQUEUES the remainder, no silent loss -
|
|
254
|
+
def test_turn_cap_enqueues_remainder_never_truncates(self):
|
|
255
|
+
# Regression for F1/F2 (reviewer reproduced silent loss): the older
|
|
256
|
+
# remainder is delivered by the drain, which the daemon may 200-then-drop
|
|
257
|
+
# on an async post; if the watermark had advanced past the remainder,
|
|
258
|
+
# that drop would be permanent loss with no reconcile backstop. This test
|
|
259
|
+
# sets drop_async=True on the drain path — it must still land durably
|
|
260
|
+
# (drain now posts commit-before-ack) AND the watermark must NOT have
|
|
261
|
+
# jumped past the un-confirmed remainder after the capped reconcile.
|
|
262
|
+
session = "sessE"
|
|
263
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
264
|
+
_write_transcript(tpath, 6, session_prefix=session)
|
|
265
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
266
|
+
|
|
267
|
+
# Cap the boot reconcile at 2 human turns; the other 4 must be deferred,
|
|
268
|
+
# not dropped.
|
|
269
|
+
with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_MAX_TURNS": "2"}):
|
|
270
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
271
|
+
|
|
272
|
+
# Inline landed the most-recent 2 turns ...
|
|
273
|
+
blob = self.daemon.content_blob()
|
|
274
|
+
self.assertIn("user turn 5", blob)
|
|
275
|
+
self.assertIn("user turn 4", blob)
|
|
276
|
+
# ... the older remainder is ENQUEUED (not lost) ...
|
|
277
|
+
self.assertEqual(len(self._pending_entries()), 1)
|
|
278
|
+
# ... and the watermark did NOT advance past the un-confirmed remainder
|
|
279
|
+
# (F2): it must not sit at the transcript end. With no prior watermark
|
|
280
|
+
# and a split, no watermark is written at all.
|
|
281
|
+
wm = watermark.load(session)
|
|
282
|
+
self.assertTrue(wm is None or wm["last_uuid"] != f"{session}-a5")
|
|
283
|
+
|
|
284
|
+
# An adversarial daemon that DROPS async extractions (the #3244 hazard):
|
|
285
|
+
# the drain must still persist the remainder durably (commit-before-ack).
|
|
286
|
+
self.daemon.drop_async = True
|
|
287
|
+
from drain_pending import drain
|
|
288
|
+
drain(self._config())
|
|
289
|
+
blob2 = self.daemon.content_blob()
|
|
290
|
+
for i in range(6):
|
|
291
|
+
self.assertIn(f"user turn {i}", blob2)
|
|
292
|
+
|
|
293
|
+
# -- Test 9 (seam): build_retain_payload is network- and mission-write-free -
|
|
294
|
+
def test_build_retain_payload_is_write_free(self):
|
|
295
|
+
session = "sessF"
|
|
296
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
297
|
+
_write_transcript(tpath, 2, session_prefix=session)
|
|
298
|
+
msgs = retain.read_transcript(tpath)
|
|
299
|
+
|
|
300
|
+
def _boom(*a, **kw):
|
|
301
|
+
raise AssertionError("build_retain_payload must not touch the network")
|
|
302
|
+
|
|
303
|
+
with mock.patch.object(HindsightClient, "retain", _boom), \
|
|
304
|
+
mock.patch.object(HindsightClient, "set_bank_mission", _boom):
|
|
305
|
+
built = retain.build_retain_payload(
|
|
306
|
+
self._config(), session, msgs, msgs,
|
|
307
|
+
bank_id="test-bank", api_url="http://fake", api_token=None,
|
|
308
|
+
)
|
|
309
|
+
self.assertIsNotNone(built)
|
|
310
|
+
self.assertTrue(built["document_id"].startswith(f"{session}-r"))
|
|
311
|
+
self.assertEqual(built["last_uuid"], f"{session}-a1")
|
|
312
|
+
# No POST reached the daemon.
|
|
313
|
+
self.assertEqual(self.daemon.posts, [])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TestWatermark(DurabilityTestBase):
|
|
317
|
+
# -- Test 6: monotonicity + uuid-not-found branch --------------------------
|
|
318
|
+
def test_watermark_never_regresses_and_handles_stale_anchor(self):
|
|
319
|
+
session = "wm1"
|
|
320
|
+
ordered = ["e0", "e1", "e2", "e3"]
|
|
321
|
+
watermark.commit(session, "e2", "doc-e2", ordered_uuids=ordered)
|
|
322
|
+
self.assertEqual(watermark.load(session)["last_uuid"], "e2")
|
|
323
|
+
|
|
324
|
+
# Backward move refused (e1 is before e2).
|
|
325
|
+
watermark.commit(session, "e1", "doc-e1", ordered_uuids=ordered)
|
|
326
|
+
self.assertEqual(watermark.load(session)["last_uuid"], "e2")
|
|
327
|
+
|
|
328
|
+
# Forward move accepted.
|
|
329
|
+
watermark.commit(session, "e3", "doc-e3", ordered_uuids=ordered)
|
|
330
|
+
self.assertEqual(watermark.load(session)["last_uuid"], "e3")
|
|
331
|
+
|
|
332
|
+
# uuid-not-found branch: the stored anchor was compacted away — accept
|
|
333
|
+
# the incoming commit rather than crash/mis-compare.
|
|
334
|
+
compacted_order = ["c0", "c1"]
|
|
335
|
+
got = watermark.commit(session, "c1", "doc-c1", ordered_uuids=compacted_order)
|
|
336
|
+
self.assertIsNotNone(got)
|
|
337
|
+
self.assertEqual(watermark.load(session)["last_uuid"], "c1")
|
|
338
|
+
|
|
339
|
+
def test_watermark_ignores_empty_uuid(self):
|
|
340
|
+
self.assertIsNone(watermark.commit("wm2", "", "doc", ordered_uuids=[]))
|
|
341
|
+
self.assertIsNone(watermark.load("wm2"))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _stdin(obj):
|
|
345
|
+
import io
|
|
346
|
+
return io.StringIO(json.dumps(obj))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
unittest.main()
|
|
@@ -801,7 +801,13 @@ class TestRetainHook:
|
|
|
801
801
|
# Should not raise
|
|
802
802
|
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
|
|
803
803
|
|
|
804
|
-
def
|
|
804
|
+
def test_retain_durability_post_is_commit_before_ack(self, monkeypatch, tmp_path):
|
|
805
|
+
# Switchroom #3244 §1.1: the Stop-hook durability retain now POSTs with
|
|
806
|
+
# ``async=false`` (commit-before-ack) so the 200 proves durable
|
|
807
|
+
# persistence before the watermark advances — a bare async-200
|
|
808
|
+
# (ack-of-receipt) must never mark unpersisted work committed. This
|
|
809
|
+
# replaces the former ``async: true`` assertion (the deliberate
|
|
810
|
+
# behaviour change; other, non-watermark callers still default async).
|
|
805
811
|
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
806
812
|
transcript = make_transcript_file(tmp_path, messages)
|
|
807
813
|
hook_input = make_hook_input(transcript_path=transcript)
|
|
@@ -815,7 +821,7 @@ class TestRetainHook:
|
|
|
815
821
|
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
816
822
|
|
|
817
823
|
if "body" in captured:
|
|
818
|
-
assert captured["body"].get("async") is
|
|
824
|
+
assert captured["body"].get("async") is False
|
|
819
825
|
|
|
820
826
|
def test_retain_includes_context_label(self, monkeypatch, tmp_path):
|
|
821
827
|
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|