switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """Replay harness for the #3994 sub-agent volume-gate recalibration.
3
+
4
+ WHAT THIS ANSWERS
5
+ -----------------
6
+ The sidechain retain now counts, in its volume gate, ONLY the chars the
7
+ text-only retain path keeps (``subagent_retain.retained_text_char_count`` ->
8
+ ``lib.content._extract_text_content``). The previous gate summed text PLUS
9
+ ``tool_use`` name+input serialized size — chars the payload no longer contains.
10
+ This harness re-measures the ``MIN_NON_TOOL_RESULT_CHARS`` floor against the
11
+ CORRECTED metric and shows, per transcript:
12
+
13
+ * old_metric — pre-#3994 count (text + tool_use name+input)
14
+ * new_metric — post-#3994 count (text-only, == what is retained)
15
+ * payload_chars— the ACTUAL text-only formatted payload size
16
+ (``prepare_retention_transcript(..., include_tool_calls=False)``)
17
+ * human_turns — genuine human turns (``count_human_turns``)
18
+ * gate decision at a swept set of candidate floors, old vs new
19
+
20
+ The headline the recalibration turns on: how many tool-heavy / prose-light
21
+ forks the OLD gate wrongly PASSED (clearing on tool volume) that the NEW gate
22
+ correctly SKIPS, while NO real worker (substantial text-only payload) is newly
23
+ skipped at the chosen floor.
24
+
25
+ DATA
26
+ ----
27
+ Two sources, ``--dir`` preferred:
28
+
29
+ * ``--dir <path>``: replay real ``*.jsonl`` sidechain transcripts (one Claude
30
+ Code sidechain per file). Fleet transcripts are NOT committed to this repo
31
+ (they are private agent memory), so this is how an operator re-runs the
32
+ measurement against ground truth.
33
+ * default (no ``--dir``): a SYNTHETIC corpus parameterized by the empirical
34
+ distributions already measured and cited in ``subagent_retain.py`` /
35
+ ``test_subagent_retain_learnings.py`` — real klanker sidechain text-only
36
+ sizes (p10 5,035 / p50 10,058 / p90 26,040 chars; 0 empty, 0 < 500 over 200
37
+ transcripts) plus the trivial-fork and tool-heavy/prose-light shapes the
38
+ gate exists to reject. Deterministic (fixed seed) so the committed report is
39
+ reproducible.
40
+
41
+ Run: python3 scripts/tests/data/replay_volume_gate_3994.py [--dir DIR] [--json]
42
+
43
+ The committed narrative report lives at
44
+ ``docs/measurements/subagent-volume-gate-3994.md`` and is regenerated from this
45
+ harness's output.
46
+ """
47
+
48
+ import argparse
49
+ import json
50
+ import os
51
+ import random
52
+ import sys
53
+
54
+ HERE = os.path.dirname(os.path.abspath(__file__))
55
+ SCRIPTS_DIR = os.path.abspath(os.path.join(HERE, "..", ".."))
56
+ if SCRIPTS_DIR not in sys.path:
57
+ sys.path.insert(0, SCRIPTS_DIR)
58
+
59
+ import subagent_retain # noqa: E402
60
+ from lib.content import ( # noqa: E402
61
+ _extract_message_blocks,
62
+ prepare_retention_transcript,
63
+ )
64
+
65
+ CANDIDATE_FLOORS = [500, 1000, 1500, 2000, 3000, 5000]
66
+ SHIPPED_FLOOR = subagent_retain.MIN_NON_TOOL_RESULT_CHARS # 2000
67
+ MIN_TURNS = subagent_retain.MIN_HUMAN_TURNS # 6
68
+
69
+ # A payload this size or larger is a "real worker" whose learnings we must not
70
+ # lose. Set well under the measured klanker p10 (5,035) so the "newly skipped
71
+ # real worker" check is strict, not self-serving.
72
+ REAL_WORKER_PAYLOAD_FLOOR = 2000
73
+
74
+
75
+ def _old_metric(messages):
76
+ """Pre-#3994 count: text + tool_use name+input serialized size."""
77
+ total = 0
78
+ for m in messages:
79
+ if not isinstance(m, dict):
80
+ continue
81
+ for b in _extract_message_blocks(m.get("content", ""), role=m.get("role", "")):
82
+ if not isinstance(b, dict) or b.get("type") == "tool_result":
83
+ continue
84
+ if b.get("type") == "text":
85
+ total += len(b.get("text", ""))
86
+ elif b.get("type") == "tool_use":
87
+ total += len(b.get("name", "")) + len(
88
+ json.dumps(b.get("input", {}), ensure_ascii=False)
89
+ )
90
+ return total
91
+
92
+
93
+ def _payload_chars(messages):
94
+ """Actual text-only formatted payload size (what is retained)."""
95
+ text, _ = prepare_retention_transcript(
96
+ messages, ["user", "assistant"], True, include_tool_calls=False
97
+ )
98
+ return len(text or "")
99
+
100
+
101
+ def measure(messages):
102
+ new_metric = subagent_retain.retained_text_char_count(messages)
103
+ return {
104
+ "human_turns": subagent_retain.count_human_turns(messages),
105
+ "old_metric": _old_metric(messages),
106
+ "new_metric": new_metric,
107
+ "payload_chars": _payload_chars(messages),
108
+ }
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Synthetic corpus (deterministic) — models the documented distributions.
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _user(text):
116
+ return {"role": "user", "content": text}
117
+
118
+
119
+ def _assistant_prose(text, *, tool_bulk=0):
120
+ content = [{"type": "text", "text": text}]
121
+ if tool_bulk:
122
+ content.append(
123
+ {"type": "tool_use", "name": "Bash",
124
+ "input": {"command": "grep -rn x " + ("q" * tool_bulk)}}
125
+ )
126
+ return {"role": "assistant", "content": content}
127
+
128
+
129
+ def _tool_result_user(bulk):
130
+ return {"role": "user",
131
+ "content": [{"type": "tool_result", "tool_use_id": "t", "content": "Z" * bulk}]}
132
+
133
+
134
+ def _real_worker(target_text_chars, rng):
135
+ """A genuine worker: 6-14 human turns, prose totalling ~target text chars,
136
+ interleaved with heavy tool traffic (the payload drops the tool traffic)."""
137
+ turns = rng.randint(6, 14)
138
+ per = max(80, target_text_chars // turns)
139
+ msgs = []
140
+ for i in range(turns):
141
+ msgs.append(_user(f"step {i}: continue the investigation and report findings"))
142
+ prose = (f"Finding {i}: confirmed the guard only probes observation rows; "
143
+ "world/experience facts never traverse the semantic dedup path. ")
144
+ prose = (prose * ((per // len(prose)) + 1))[:per]
145
+ msgs.append(_assistant_prose(prose, tool_bulk=rng.randint(2000, 8000)))
146
+ msgs.append(_tool_result_user(rng.randint(2000, 6000)))
147
+ return msgs
148
+
149
+
150
+ def _trivial_fork(rng):
151
+ """A 10-second fork: 2-4 turns, almost no prose. Both gates should SKIP."""
152
+ turns = rng.randint(2, 4)
153
+ msgs = []
154
+ for i in range(turns):
155
+ msgs.append(_user("go"))
156
+ msgs.append(_assistant_prose("ok", tool_bulk=rng.randint(0, 500)))
157
+ return msgs
158
+
159
+
160
+ def _tool_heavy_prose_light(rng):
161
+ """The recalibration target: clears MIN_TURNS human turns and emits a LOT of
162
+ tool traffic, but almost NO retainable prose. OLD gate passes (tool chars),
163
+ NEW gate skips (text-only ~0). Retaining it stores a near-empty document."""
164
+ turns = rng.randint(6, 10)
165
+ msgs = []
166
+ for i in range(turns):
167
+ msgs.append(_user("go")) # genuine human turn, but tiny
168
+ # No text block at all — pure tool_use with a large command body.
169
+ msgs.append({"role": "assistant",
170
+ "content": [{"type": "tool_use", "name": "Bash",
171
+ "input": {"command": "true " + ("x" * rng.randint(3000, 9000))}}]})
172
+ msgs.append(_tool_result_user(rng.randint(3000, 8000)))
173
+ return msgs
174
+
175
+
176
+ def synthetic_corpus(seed=1994):
177
+ rng = random.Random(seed)
178
+ corpus = []
179
+ # Real workers across the measured size range (p10..p90+).
180
+ for _ in range(120):
181
+ target = int(rng.triangular(1500, 30000, 10058)) # low, high, mode≈p50
182
+ corpus.append(("real_worker", _real_worker(target, rng)))
183
+ for _ in range(60):
184
+ corpus.append(("trivial_fork", _trivial_fork(rng)))
185
+ for _ in range(40):
186
+ corpus.append(("tool_heavy_prose_light", _tool_heavy_prose_light(rng)))
187
+ return corpus
188
+
189
+
190
+ def _load_dir(path):
191
+ corpus = []
192
+ for name in sorted(os.listdir(path)):
193
+ if not name.endswith(".jsonl"):
194
+ continue
195
+ msgs = subagent_retain.read_transcript(os.path.join(path, name))
196
+ corpus.append((name, msgs))
197
+ return corpus
198
+
199
+
200
+ def run(corpus):
201
+ rows = []
202
+ for label, msgs in corpus:
203
+ m = measure(msgs)
204
+ m["label"] = label
205
+ rows.append(m)
206
+
207
+ def gate(row, floor):
208
+ return row["human_turns"] >= MIN_TURNS and row["new_metric"] >= floor
209
+
210
+ def old_gate(row, floor):
211
+ return row["human_turns"] >= MIN_TURNS and row["old_metric"] >= floor
212
+
213
+ real = [r for r in rows if r["payload_chars"] >= REAL_WORKER_PAYLOAD_FLOOR]
214
+ empty_ish = [r for r in rows if r["payload_chars"] < 500]
215
+
216
+ floor_sweep = {}
217
+ for f in CANDIDATE_FLOORS:
218
+ newly_skipped_real = [
219
+ r for r in real if old_gate(r, SHIPPED_FLOOR) and not gate(r, f)
220
+ ]
221
+ # Tool-heavy/prose-light forks the OLD gate passed that NEW gate skips.
222
+ old_pass_new_skip_emptyish = [
223
+ r for r in empty_ish if old_gate(r, SHIPPED_FLOOR) and not gate(r, f)
224
+ ]
225
+ floor_sweep[f] = {
226
+ "passes_new": sum(1 for r in rows if gate(r, f)),
227
+ "real_workers_newly_skipped_vs_old_2000": len(newly_skipped_real),
228
+ "emptyish_forks_now_correctly_skipped": len(old_pass_new_skip_emptyish),
229
+ }
230
+
231
+ summary = {
232
+ "corpus_size": len(rows),
233
+ "shipped_floor": SHIPPED_FLOOR,
234
+ "min_turns": MIN_TURNS,
235
+ "counts_by_label": _counts(rows),
236
+ "payload_percentiles_all": _pcts([r["payload_chars"] for r in rows]),
237
+ "payload_percentiles_gate_passers_new_2000": _pcts(
238
+ [r["payload_chars"] for r in rows if gate(r, SHIPPED_FLOOR)]
239
+ ),
240
+ "old_gate_passed_but_payload_under_500": sum(
241
+ 1 for r in rows if old_gate(r, SHIPPED_FLOOR) and r["payload_chars"] < 500
242
+ ),
243
+ "new_gate_passed_but_payload_under_500": sum(
244
+ 1 for r in rows if gate(r, SHIPPED_FLOOR) and r["payload_chars"] < 500
245
+ ),
246
+ "floor_sweep": floor_sweep,
247
+ }
248
+ return summary, rows
249
+
250
+
251
+ def _counts(rows):
252
+ out = {}
253
+ for r in rows:
254
+ out[r["label"]] = out.get(r["label"], 0) + 1
255
+ return out
256
+
257
+
258
+ def _pcts(values):
259
+ if not values:
260
+ return {}
261
+ s = sorted(values)
262
+
263
+ def p(q):
264
+ return s[min(len(s) - 1, int(q * len(s)))]
265
+
266
+ return {"n": len(s), "min": s[0], "p10": p(0.10), "p50": p(0.50),
267
+ "p90": p(0.90), "max": s[-1]}
268
+
269
+
270
+ def main():
271
+ ap = argparse.ArgumentParser()
272
+ ap.add_argument("--dir", help="directory of real *.jsonl sidechain transcripts")
273
+ ap.add_argument("--json", action="store_true", help="emit JSON only")
274
+ args = ap.parse_args()
275
+
276
+ if args.dir:
277
+ corpus = _load_dir(args.dir)
278
+ source = f"real transcripts from {args.dir}"
279
+ else:
280
+ corpus = synthetic_corpus()
281
+ source = "synthetic corpus (distribution-parameterized, seed=1994)"
282
+
283
+ summary, _ = run(corpus)
284
+ summary["data_source"] = source
285
+
286
+ if args.json:
287
+ print(json.dumps(summary, indent=2))
288
+ return
289
+
290
+ print(f"# Volume-gate recalibration replay (#3994)\nsource: {source}\n")
291
+ print(json.dumps(summary, indent=2))
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()
@@ -58,11 +58,21 @@ class FakeDaemon:
58
58
  self.max_inflight_seen = 0
59
59
  self._inflight = 0
60
60
  self.membership_error = False
61
+ # #3291 partial-failure simulation: raise on the retain of the Nth
62
+ # DISTINCT document_id seen (distinct index, POST order = slice order),
63
+ # so a slice fails persistently (all bounded retries) without a fixed id.
64
+ self.fail_distinct_indices = set()
65
+ self._seen_doc_order = []
61
66
 
62
67
  def retain(self, bank_id, content, document_id="conversation", context=None,
63
68
  metadata=None, tags=None, timeout=15, async_processing=True,
64
69
  observation_scopes=None):
65
70
  self.observation_scopes_seen.append(observation_scopes)
71
+ if document_id not in self._seen_doc_order:
72
+ self._seen_doc_order.append(document_id)
73
+ distinct_idx = self._seen_doc_order.index(document_id)
74
+ if distinct_idx in self.fail_distinct_indices:
75
+ raise RuntimeError(f"simulated POST failure for slice #{distinct_idx}")
66
76
  self._inflight += 1
67
77
  self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
68
78
  try:
@@ -79,6 +89,12 @@ class FakeDaemon:
79
89
  return {did for did, d in self.docs.items()
80
90
  if d.get("bank_id") == bank_id and session_id in did}
81
91
 
92
+ def document_exists(self, bank_id, document_id, timeout=30):
93
+ """Tri-state presence check (mirrors HindsightClient.document_exists):
94
+ True when the slice doc is durable, False on a confirmed miss."""
95
+ d = self.docs.get(document_id)
96
+ return bool(d is not None and d.get("bank_id") == bank_id)
97
+
82
98
 
83
99
  def _write_registry(path, rows, with_session_id=False):
84
100
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -166,6 +182,7 @@ class FromLogsTestBase(unittest.TestCase):
166
182
  self._patches = [
167
183
  mock.patch.object(HindsightClient, "retain", self._fake_retain),
168
184
  mock.patch.object(HindsightClient, "list_session_document_ids", self._fake_membership),
185
+ mock.patch.object(HindsightClient, "document_exists", self._fake_doc_exists),
169
186
  mock.patch("backfill_transcripts.get_api_url", return_value="http://fake"),
170
187
  mock.patch("backfill_transcripts._SLEEP", self._fake_sleep),
171
188
  ]
@@ -178,6 +195,9 @@ class FromLogsTestBase(unittest.TestCase):
178
195
  def _fake_membership(self, *a, **kw):
179
196
  return self.daemon.list_session_document_ids(*a, **kw)
180
197
 
198
+ def _fake_doc_exists(self, *a, **kw):
199
+ return self.daemon.document_exists(*a, **kw)
200
+
181
201
  def _fake_sleep(self, secs):
182
202
  self.sleeps.append(secs)
183
203
 
@@ -448,6 +468,95 @@ class TestFromLogs(FromLogsTestBase):
448
468
  spaced = [s for s in self.sleeps if abs(s - 1.5) < 1e-9]
449
469
  self.assertEqual(len(spaced), 3)
450
470
 
471
+ # --- #3291: partial POST failure then re-run must GAP-COMPLETE, not skip --
472
+ # Run 1: slices 0..k-1 commit (async_processing=False ⇒ durable), slice k
473
+ # fails ⇒ session recorded ``failed``. Run 2 (the fix): the committed
474
+ # prefix must NOT classify the session ``present`` and skip it forever —
475
+ # it must recompute the deterministic slice ids and POST ONLY the missing
476
+ # slice(s). Pre-fix behaviour: run 2 sees the prefix via session
477
+ # membership → ``has_documents`` → skip → the tail is lost silently. -----
478
+ def test_partial_post_failure_then_rerun_gap_completes(self):
479
+ self._write_yaml({"clerk": None})
480
+ self._write_settings("clerk", "clerk")
481
+ base = 1_700_000_000_000
482
+ # 3 human turns, slice_turns=1 ⇒ 3 non-overlapping slices posted in order.
483
+ path, (first, last) = self._transcript("clerk", "sess-partial", 3, base)
484
+ self._registry("clerk", [{
485
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
486
+ "ended_at": str(last), "ended_via": "sigterm",
487
+ }])
488
+
489
+ # RUN 1 — the LAST slice (distinct index 2) fails every (bounded) attempt.
490
+ self.daemon.fail_distinct_indices = {2}
491
+ report1 = self._run("clerk")
492
+ ar1 = report1["agents"]["clerk"]
493
+
494
+ # Total-loss restore attempted all 3 slices; 2 committed, 1 failed.
495
+ self.assertEqual(ar1["sessions_total_loss"], 1)
496
+ self.assertEqual(ar1["sessions_restored"], 0) # NOT fully restored
497
+ self.assertEqual(ar1["posts_ok"], 2)
498
+ self.assertEqual(ar1["posts_failed"], 1)
499
+ entry1 = next(s for s in ar1["sessions"] if s["class"] == "total_loss")
500
+ all_ids = entry1["document_ids"]
501
+ self.assertEqual(len(all_ids), 3)
502
+ committed_after_1 = set(self.daemon.docs)
503
+ self.assertEqual(len(committed_after_1), 2) # durable committed prefix
504
+ missing = [d for d in all_ids if d not in committed_after_1]
505
+ self.assertEqual(len(missing), 1)
506
+ # Progress marked failed (NOT done) — the re-run must revisit it.
507
+ with open(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]) as f:
508
+ prog = json.load(f)
509
+ self.assertEqual(prog["clerk/sess-partial"]["status"], "failed")
510
+
511
+ # RUN 2 — no injected failure this time. The fix must gap-complete.
512
+ self.daemon.fail_distinct_indices = set()
513
+ posts_before = len(self.daemon.posts)
514
+ report2 = self._run("clerk")
515
+ ar2 = report2["agents"]["clerk"]
516
+
517
+ # It must NOT be skipped as an already-present session.
518
+ self.assertEqual(ar2["sessions_has_documents"], 0)
519
+ # Gap completion: exactly the missing slice posted, the 2 present skipped.
520
+ self.assertEqual(ar2["sessions_gap_completed"], 1)
521
+ self.assertEqual(ar2["slices_gap_filled"], 1)
522
+ self.assertEqual(ar2["slices_gap_present"], 2)
523
+ run2_posts = [did for did, _ in self.daemon.posts[posts_before:]]
524
+ self.assertEqual(run2_posts, missing) # ONLY the missing slice
525
+ # All three slices are now durable — the session is fully recovered.
526
+ self.assertEqual(set(self.daemon.docs), set(all_ids))
527
+ with open(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]) as f:
528
+ prog2 = json.load(f)
529
+ self.assertEqual(prog2["clerk/sess-partial"]["status"], "done")
530
+ # Watermark advanced to the true tail slice on completion.
531
+ self.assertIsNotNone(watermark.load("sess-partial"))
532
+
533
+ # --- #3291 self-heal: a persistently-failing slice must surface as an
534
+ # operator-visible INCOMPLETE metric + convergence WARN, never a silent
535
+ # trap. -----------------------------------------------------------------
536
+ def test_incomplete_session_surfaces_convergence_metric(self):
537
+ self._write_yaml({"clerk": None})
538
+ self._write_settings("clerk", "clerk")
539
+ base = 1_700_000_000_000
540
+ path, (first, last) = self._transcript("clerk", "sess-stuck", 2, base)
541
+ self._registry("clerk", [{
542
+ "turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
543
+ "ended_at": str(last), "ended_via": "sigterm",
544
+ }])
545
+ # The first slice fails every attempt → session left INCOMPLETE.
546
+ self.daemon.fail_distinct_indices = {0}
547
+
548
+ report = self._run("clerk")
549
+ ar = report["agents"]["clerk"]
550
+ # Metric: one session did not fully recover.
551
+ self.assertEqual(ar["sessions_incomplete"], 1)
552
+ self.assertEqual(ar["sessions_restored"], 0)
553
+ self.assertEqual(report["rollup"]["sessions_incomplete"], 1)
554
+ # Operator-visible: the formatted report carries the count + a WARN that
555
+ # tells them to re-run (so a non-converging session is never silent).
556
+ text = bf.format_report_from_logs(report)
557
+ self.assertIn("incomplete=1", text)
558
+ self.assertIn("CONVERGENCE", text)
559
+
451
560
  # --- direct session_id join (migrated registry) -------------------------
452
561
  def test_direct_sessionid_join(self):
453
562
  self._write_yaml({"clerk": None})
@@ -0,0 +1,135 @@
1
+ """Characterisation of ``recall._overlap_tokens`` — the transcript-fallback tokenizer.
2
+
3
+ WHY THIS FILE EXISTS
4
+ --------------------
5
+ ``_overlap_tokens`` is the keyword tokenizer behind the #3369 transcript-grep
6
+ recall fallback: a recent transcript turn is injected only if its token set
7
+ intersects the query's (``recall._build_transcript_fallback``). When the
8
+ lexical-overlap recall GATE was removed (#3761, commit e3b9d62f) the gate's
9
+ characterisation tests went with it — but ``_overlap_tokens`` itself stayed
10
+ load-bearing for the fallback. That left a real, shipped tokenizer with no
11
+ direct unit coverage: a change to its rules (letters-only vs alphanumeric, the
12
+ length floor, stopword handling) could silently move which transcript turns
13
+ recall can surface, and nothing would fail.
14
+
15
+ These tests pin the tokenizer's contract directly (#3777), including the #3578
16
+ decision that DIGITS CARRY SIGNAL (issue numbers, ports, versions). They assert
17
+ OUTCOMES of the tokenizer, not code paths, and each would fail on a real
18
+ regression of the documented behaviour.
19
+
20
+ Stdlib-only; runs under ``python3 -m unittest discover tests/``.
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import unittest
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+
31
+ import recall # noqa: E402
32
+
33
+ tok = recall._overlap_tokens
34
+
35
+
36
+ class OverlapTokensCharacterisation(unittest.TestCase):
37
+ """Pure-function contract for ``recall._overlap_tokens`` (#3777)."""
38
+
39
+ # --- empty / non-string inputs -------------------------------------------
40
+
41
+ def test_empty_string_is_empty_set(self):
42
+ self.assertEqual(tok(""), set())
43
+
44
+ def test_none_is_empty_set(self):
45
+ self.assertEqual(tok(None), set())
46
+
47
+ def test_non_string_scalar_is_empty_set(self):
48
+ self.assertEqual(tok(123), set())
49
+
50
+ def test_non_string_container_is_empty_set(self):
51
+ self.assertEqual(tok(["deploy", "server"]), set())
52
+ self.assertEqual(tok({"deploy": 1}), set())
53
+
54
+ def test_whitespace_only_is_empty_set(self):
55
+ self.assertEqual(tok(" \t\n "), set())
56
+
57
+ # --- basic tokenisation ---------------------------------------------------
58
+
59
+ def test_simple_words_split_on_whitespace(self):
60
+ self.assertEqual(tok("deploy staging server"), {"deploy", "staging", "server"})
61
+
62
+ def test_lowercased(self):
63
+ self.assertEqual(tok("Deploy STAGING Server"), {"deploy", "staging", "server"})
64
+
65
+ def test_case_folds_to_a_single_token(self):
66
+ self.assertEqual(tok("DEPLOY deploy Deploy"), {"deploy"})
67
+
68
+ def test_duplicates_collapse_to_a_set(self):
69
+ self.assertEqual(tok("server server server"), {"server"})
70
+
71
+ def test_trailing_token_without_separator_is_flushed(self):
72
+ # The final run has no trailing delimiter; it must still be emitted.
73
+ self.assertEqual(tok("deploy server"), {"deploy", "server"})
74
+
75
+ # --- punctuation as separators -------------------------------------------
76
+
77
+ def test_punctuation_is_a_separator(self):
78
+ self.assertEqual(tok("deploy, the staging server!"), {"deploy", "staging", "server"})
79
+
80
+ def test_hyphen_splits_tokens(self):
81
+ self.assertEqual(tok("multi-word thing"), {"multi", "word", "thing"})
82
+
83
+ def test_underscore_splits_tokens(self):
84
+ # '_' is not alphanumeric, so it is a separator (documented behaviour).
85
+ self.assertEqual(tok("foo_bar baz"), {"foo", "bar", "baz"})
86
+
87
+ def test_dotted_version_splits_on_dots(self):
88
+ self.assertEqual(tok("v0.19.24"), {"v0", "19", "24"})
89
+
90
+ # --- length floor ---------------------------------------------------------
91
+
92
+ def test_single_alpha_char_is_dropped(self):
93
+ self.assertEqual(tok("x y z deploy"), {"deploy"})
94
+
95
+ def test_single_digit_char_is_dropped_as_noise(self):
96
+ # A lone digit is noise, not an identifier — dropped by the > 1 guard,
97
+ # exactly like a lone letter.
98
+ self.assertEqual(tok("python 9 angular"), {"python", "angular"})
99
+
100
+ def test_two_char_token_survives_the_floor(self):
101
+ self.assertEqual(tok("pr go"), {"pr", "go"})
102
+
103
+ # --- stopwords ------------------------------------------------------------
104
+
105
+ def test_stopwords_removed(self):
106
+ self.assertEqual(tok("the is a to of"), set())
107
+
108
+ def test_content_survives_with_stopwords_present(self):
109
+ self.assertEqual(tok("what is the deploy status"), {"deploy", "status"})
110
+
111
+ # --- #3578: digits carry signal ------------------------------------------
112
+
113
+ def test_pure_digit_issue_number_is_a_token(self):
114
+ self.assertEqual(tok("PR 3993"), {"pr", "3993"})
115
+
116
+ def test_hash_prefixed_issue_number_keeps_the_digits(self):
117
+ self.assertEqual(tok("#3541 shipped"), {"3541", "shipped"})
118
+
119
+ def test_colon_prefixed_port_keeps_the_digits(self):
120
+ self.assertEqual(tok(":9077 port"), {"9077", "port"})
121
+
122
+ def test_mixed_alphanumeric_run_stays_intact(self):
123
+ self.assertEqual(tok("abc123def"), {"abc123def"})
124
+
125
+ def test_identifier_overlap_between_query_and_transcript(self):
126
+ # The #3578 motivating case: a transcript turn whose only tie to the
127
+ # query is an issue number must now share a token (it did not before).
128
+ query = tok("did PR 3993 land and what did v0.19.24 change")
129
+ turn = tok("3993 shipped in 0.19.24 last night")
130
+ self.assertIn("3993", query & turn)
131
+ self.assertTrue(query & turn)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -115,21 +115,15 @@ def _run_main_with(client, prompt="what did we decide", config_extra=None):
115
115
  return json.loads(raw)["hookSpecificOutput"]["additionalContext"], raw
116
116
 
117
117
 
118
- class GateHelpersAreGoneTests(unittest.TestCase):
119
- """The gate's machinery must not exist to be re-wired by accident."""
120
-
121
- def test_gate_helpers_are_removed(self):
122
- self.assertFalse(hasattr(recall, "containment_overlap"))
123
- self.assertFalse(hasattr(recall, "_filter_by_overlap"))
124
-
125
- def test_tokenizer_survives_for_the_transcript_fallback(self):
126
- """`_overlap_tokens` is still load-bearing for
127
- `_build_transcript_fallback`'s keyword match — deleting it with the
128
- gate would silently break the #3369 fallback."""
129
- self.assertEqual(
130
- recall._overlap_tokens("Deploy the staging server!"),
131
- {"deploy", "staging", "server"},
132
- )
118
+ #
119
+ # NOTE (#3999): the former ``GateHelpersAreGoneTests`` hasattr guard
120
+ # (``assertFalse(hasattr(recall, "containment_overlap"))``) was dropped as
121
+ # redundant — a re-added-but-unwired helper does nothing, and a re-WIRED gate is
122
+ # already caught by the outcome assertions in ``NoLexicalDroppingIntegrationTests``
123
+ # below (which fail if any candidate is dropped for lexical reasons). Direct
124
+ # characterisation of the surviving ``_overlap_tokens`` tokenizer now lives in
125
+ # ``test_overlap_tokens.py`` (#3777), not in a hasattr check here.
126
+ #
133
127
 
134
128
 
135
129
  class NoLexicalDroppingIntegrationTests(unittest.TestCase):
@@ -151,17 +145,20 @@ class NoLexicalDroppingIntegrationTests(unittest.TestCase):
151
145
  self.assertIn("vegan dinner recipes", ctx)
152
146
 
153
147
  def test_identifier_only_match_survives(self):
154
- """The measured root cause: `_overlap_tokens` drops digits and
155
- 1-char tokens, so a memory whose ONLY relationship to the prompt is
156
- an identifier scored 0.0 and was always discarded. Assert directly
157
- that the tokenizer still cannot see the identifier, AND that the
158
- memory is injected anyway."""
148
+ """A memory whose ONLY tie to the prompt is an identifier survives.
149
+
150
+ This was the measured root cause of the removed gate: `_overlap_tokens`
151
+ dropped digits, so an identifier-only memory scored 0.0 and was always
152
+ discarded. Two things now hold: (a) #3578 gave the tokenizer digit
153
+ vision, so query and memory now DO share the identifier token; and (b)
154
+ with the gate gone, the memory is injected regardless of overlap."""
159
155
  query = PREAMBLE + "did PR #3541 land, and what did v0.19.24 change"
160
156
  mem = "#3541 shipped in 0.19.24"
161
- self.assertEqual(
157
+ # Post-#3578 the identifier is visible on both sides.
158
+ self.assertIn(
159
+ "3541",
162
160
  recall._overlap_tokens(query) & recall._overlap_tokens(mem),
163
- set(),
164
- "fixture drifted: it must share no ALPHABETIC token with the query",
161
+ "the shared identifier must now be a token on both sides (#3578)",
165
162
  )
166
163
  ctx, _ = _run_main_with(_FakeClient([_memory(mem, score=0.95)]), prompt=query)
167
164
  self.assertIsNotNone(ctx)