switchroom 0.19.41 → 0.19.42

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 (29) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +772 -234
  5. package/dist/host-control/main.js +23 -3
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/dist/gateway/gateway.js +97 -83
  10. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  11. package/telegram-plugin/gateway/gateway.ts +7 -7
  12. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  13. package/telegram-plugin/gateway/model-command.ts +21 -109
  14. package/telegram-plugin/gateway/stream-render.ts +57 -0
  15. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  16. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  17. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  18. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  19. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  20. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  21. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  22. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  23. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  29. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -37,9 +37,9 @@ Flow:
37
37
  2. Resolve the sidechain transcript (agent_transcript_path → derived
38
38
  subagents/ dir → project-dir scan).
39
39
  3. Volume gate: skip sub-agents below the floor (< 6 human turns OR
40
- < 2,000 chars of non-tool-result text)SubagentStop fires for every
41
- Task including 10-second forks, and each retain is an LLM-backed
42
- extraction.
40
+ < 2,000 chars of RETAINED text — the chars the text-only path keeps,
41
+ #3994) — SubagentStop fires for every Task including 10-second forks, and
42
+ each retain is an LLM-backed extraction.
43
43
  4. Retain a bounded window (last N=40 human turns), tagged ``sidechain`` +
44
44
  ``parent_session:<id>``, with a deterministic content-derived document_id
45
45
  so re-fires upsert instead of duplicating.
@@ -62,7 +62,7 @@ from lib.bank import derive_bank_id, ensure_bank_mission
62
62
  from lib.client import HindsightClient
63
63
  from lib.config import debug_log, load_config
64
64
  from lib.content import (
65
- _extract_message_blocks,
65
+ _extract_text_content,
66
66
  _is_tool_result_only_user_message,
67
67
  slice_last_turns_by_user_boundary,
68
68
  transcript_first_line_is_sidechain,
@@ -78,8 +78,9 @@ from retain import build_retain_payload, read_transcript
78
78
  # Retain the last N human turns of the sidechain. The window is formatted on the
79
79
  # TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
80
80
  # ``run_subagent_retain``), so tool_use inputs and tool_result bodies are dropped
81
- # entirely rather than passed through / truncated. ``_extract_message_blocks`` is
82
- # still imported here, but only for the volume gate's char count.
81
+ # entirely rather than passed through / truncated. ``_extract_text_content`` is
82
+ # imported here so the volume gate's char count measures the SAME text the retain
83
+ # path keeps (#3994) — gate and payload never diverge.
83
84
  SIDECHAIN_WINDOW_TURNS = 40
84
85
 
85
86
  # Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
@@ -262,59 +263,63 @@ def count_human_turns(messages: list) -> int:
262
263
  return n
263
264
 
264
265
 
265
- def non_tool_result_char_count(messages: list, stop_at: int | None = None) -> int:
266
- """Total chars of non-tool-result content across the transcript.
266
+ def retained_text_char_count(messages: list, stop_at: int | None = None) -> int:
267
+ """Total chars of the content the TEXT-ONLY retain path actually keeps.
267
268
 
268
- Sums the extracted text + tool_use (command/input) blocks and EXCLUDES
269
- tool_result bodies the same "text vs tool output" split
270
- ``_extract_message_blocks`` already draws. This is the signal the volume
271
- gate wants: a 10-second fork with almost no narration/commands falls under
272
- the floor even if it emitted a large tool_result, while a real worker's
273
- commands and decisions count.
269
+ #3994 GATE COUNTS WHAT IS RETAINED. The sidechain payload is built with
270
+ ``retainToolCalls = False`` (``run_subagent_retain``), so what reaches memory
271
+ is exactly ``_extract_text_content``: assistant ``text`` blocks, channel-
272
+ message tool_use text, and plain-string user turns and NOTHING else. This
273
+ gate counts the same thing, per message, so "cleared the floor" now means
274
+ "has >= N chars of RETAINABLE prose", not "emitted N chars of tool traffic
275
+ the payload then drops".
274
276
 
275
- KNOWN MISMATCH (accepted, tracked as a follow-up): this counts ``tool_use``
276
- inputs, but those are no longer RETAINED the sidechain payload is built on
277
- the text-only path. So the gate can clear on tool volume that contributes
278
- nothing to the stored memory. It cannot produce an EMPTY retain (the
279
- ``MIN_HUMAN_TURNS`` floor guarantees prose-bearing user turns see the
280
- module docstring), so this is a precision issue in the gate, not a
281
- correctness bug. Tightening it to count only text is a separate change.
277
+ The previous revision counted ``tool_use`` name+input serialized size on top
278
+ of text. Those chars are no longer in the payload, so the old gate could
279
+ clear on tool volume that contributed zero to the stored memory (a tool-heavy
280
+ / prose-light fork passed, then retained a near-empty document). Counting via
281
+ ``_extract_text_content`` closes that mismatch: gate and payload measure the
282
+ identical char set. The 2,000-char floor was re-measured against this metric
283
+ see ``docs/measurements/subagent-volume-gate-3994.md`` and the replay
284
+ harness ``scripts/tests/data/replay_volume_gate_3994.py``.
282
285
 
283
286
  ``stop_at`` (review finding 4 — early short-circuit): return as soon as the
284
287
  running total reaches this many chars. The gate only needs to know whether
285
- the floor is CLEARED, not the exact size — so on a large worker transcript
286
- we stop the block walk the moment the floor is met (the returned value is
287
- then a floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the
288
- skip log's "chars>=N" read).
288
+ the floor is CLEARED, not the exact size — so on a large worker transcript we
289
+ stop the walk the moment the floor is met (the returned value is then a
290
+ floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the skip
291
+ log's "chars>=N" read).
289
292
  """
290
293
  total = 0
291
294
  for m in messages:
292
295
  if not isinstance(m, dict):
293
296
  continue
294
- blocks = _extract_message_blocks(m.get("content", ""), role=m.get("role", ""))
295
- for b in blocks:
296
- if not isinstance(b, dict) or b.get("type") == "tool_result":
297
- continue
298
- if b.get("type") == "text":
299
- total += len(b.get("text", ""))
300
- elif b.get("type") == "tool_use":
301
- # Command / input is a process fact; count its serialized size.
302
- total += len(b.get("name", "")) + len(json.dumps(b.get("input", {}), ensure_ascii=False))
297
+ # Count exactly the chars the text-only retain path keeps for this
298
+ # message identical extraction to ``prepare_retention_transcript``'s
299
+ # ``include_tool_calls=False`` branch, so gate and payload never diverge.
300
+ total += len(_extract_text_content(m.get("content", ""), role=m.get("role", "")))
303
301
  if stop_at is not None and total >= stop_at:
304
302
  return total
305
303
  return total
306
304
 
307
305
 
306
+ # Back-compat alias: the previous name measured a superset (text + tool_use).
307
+ # Kept so an out-of-tree caller does not break, but it now returns the
308
+ # recalibrated text-only count (#3994).
309
+ non_tool_result_char_count = retained_text_char_count
310
+
311
+
308
312
  def passes_volume_gate(messages: list, config: dict) -> tuple:
309
313
  """Return ``(passed, human_turns, char_count)`` for the volume gate.
310
314
 
311
315
  Skip sub-agents below EITHER floor: < ``MIN_HUMAN_TURNS`` human turns OR
312
- < ``MIN_NON_TOOL_RESULT_CHARS`` chars of non-tool-result text. The char walk
313
- short-circuits at the floor (finding 4) ``char_count`` is exact when below
314
- the floor and a lower bound (``>= floor``) once cleared.
316
+ < ``MIN_NON_TOOL_RESULT_CHARS`` chars of RETAINED text (the chars the
317
+ text-only path actually keeps, #3994). The char walk short-circuits at the
318
+ floor (finding 4) ``char_count`` is exact when below the floor and a lower
319
+ bound (``>= floor``) once cleared.
315
320
  """
316
321
  turns = count_human_turns(messages)
317
- chars = non_tool_result_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_CHARS)
322
+ chars = retained_text_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_CHARS)
318
323
  passed = turns >= MIN_HUMAN_TURNS and chars >= MIN_NON_TOOL_RESULT_CHARS
319
324
  return passed, turns, chars
320
325
 
@@ -521,7 +526,23 @@ def run_subagent_retain(hook_input: dict) -> dict:
521
526
  document_id=None, # content-derived from the slice's first/last uuids
522
527
  )
523
528
  if built is None:
529
+ # #4001 — this must NOT be silent. It is the "should never happen" branch
530
+ # (the volume gate guarantees >= MIN_NON_TOOL_RESULT_CHARS of retainable
531
+ # text, so a window that cleared the gate should always format to
532
+ # something), so if it fires it means the gate and the formatter disagree
533
+ # about what counts as retainable — a real regression that would
534
+ # otherwise vanish with debug off in the shipped settings. Emit
535
+ # unconditionally on stderr so it surfaces in the gateway log regardless
536
+ # of the debug flag; keep the debug_log for the verbose trace.
524
537
  debug_log(config, "SubagentStop: empty transcript after formatting, skipping")
538
+ print(
539
+ "[Hindsight] SubagentStop: sidechain window formatted to EMPTY on the "
540
+ f"text-only path despite clearing the volume gate "
541
+ f"(session={session_id} agent={agent_id} turns={turns} chars>={chars}) "
542
+ "— retain skipped. This should not happen; if it recurs the volume "
543
+ "gate and the text-only formatter have diverged (investigate #3994).",
544
+ file=sys.stderr,
545
+ )
525
546
  return {"status": "skipped", "reason": "empty transcript after formatting"}
526
547
 
527
548
  payload = built["payload"]
@@ -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})