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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -0,0 +1,762 @@
1
+ #!/usr/bin/env python3
2
+ """One-time backfill of transcript work already missing from Hindsight banks.
3
+
4
+ switchroom #3244 Part 2 (design-20260715.md). Recovers work an abrupt session
5
+ death silently dropped BEFORE the forward fix (PR1) shipped — e.g. clerk's
6
+ 2026-07-11 planner sessions whose Stop-hook retains were discarded and never
7
+ reconciled. This is a standalone, operator-run CLI, NOT runtime code: it scans
8
+ every agent's on-disk ``.jsonl`` transcripts (which survive every kind of
9
+ death) and re-POSTs anything missing from the bank, paced and resumable.
10
+
11
+ **Reuse, not fork (design Part 2).** It imports PR1's seams verbatim — the
12
+ deterministic content-derived ``document_id`` (``retain.slice_document_id`` via
13
+ ``build_retain_payload``), the durable watermark, the shared inflight pacing
14
+ lock, and the pending-retains layout. The backfill is "run the boot reconciler
15
+ over ALL history for ALL agents, paced, resumable, with a dry-run."
16
+
17
+ Guarantees, all resting on daemon contracts CONFIRMED against hindsight
18
+ v0.18.24 (daemon-contract-20260715.md):
19
+
20
+ * **Dedup by deterministic document_id + daemon UPSERT — ACROSS BACKFILL
21
+ RE-RUNS.** Every slice is posted under ``{session_id}-r{start_uuid}-{end_uuid}``
22
+ — a pure function of *which turns* it holds. The daemon UPSERTs on
23
+ ``(id, bank_id)`` (``ops_postgresql.py:63-69`` ``ON CONFLICT DO UPDATE``;
24
+ full-replace of derived memories, ``fact_storage.py:334-360``) and
25
+ short-circuits an identical re-post on ``content_hash``. So two backfill runs
26
+ over the same transcript **with the same ``slice_turns``** compute identical
27
+ ids and upsert to ONE document. NOTE — these ids do **NOT** converge with the
28
+ live/boot path's ids: the live path slices a *sliding* ``retainEveryNTurns``+
29
+ overlap window (``retain.py:130-136``) while the backfill slices *non-
30
+ overlapping* ``slice_turns`` (default 40) chunks, so the boundary uuids differ
31
+ and the daemon CANNOT upsert one against the other. The backfill is kept
32
+ duplicate-free **not** by id-convergence with the live path but by the
33
+ **total-loss gate** (§ classification below — it only ever re-posts sessions
34
+ the live path wrote *nothing* for) plus advancing the watermark after commit.
35
+ Do NOT remove the total-loss gate believing upsert will dedup live-vs-backfill
36
+ — it will not.
37
+ * **Never-storm pacing (HARD).** Strictly serial — one ``client.retain()`` in
38
+ flight at a time, fleet-wide, via PR1's shared ``retain-inflight.lock``
39
+ (acquired blocking around every POST) plus a backfill-only ``backfill.lock``
40
+ "one backfill process at a time" mutex. A deterministic ~1.5s inter-POST
41
+ delay, exponential backoff (cap 60s) + a circuit-breaker cooldown on repeated
42
+ failure, and session-granular slicing bound the downstream LLM extractor.
43
+ Every POST uses ``async_processing=False`` (commit-before-ack) so each write
44
+ is durably confirmed before the next is issued.
45
+ * **Dry-run is the DEFAULT and is genuinely ZERO-write.** Without ``--commit``
46
+ the tool calls ONLY ``build_retain_payload`` (network-free, mission-write-
47
+ free by contract) and reports what it WOULD recover: zero retain POSTs, zero
48
+ ``set_bank_mission`` PATCHes. An operator reviews the blast radius before
49
+ committing per agent.
50
+ * **Resumable.** Per-``(agent, session)`` progress in
51
+ ``~/.hindsight/backfill-progress.json`` (advanced to ``done`` only after ALL
52
+ of a session's slices are confirmed persisted). Kill it anywhere and re-run:
53
+ completed sessions are skipped, an in-progress session re-does its current
54
+ slices (safe upsert), no duplicates.
55
+
56
+ Classification & guards (design Part 2 dedup redesign + PR3248 review F1/F3):
57
+
58
+ * **Dynamic-bank agent** ⇒ **REFUSED (warn + skip, no writes).** Its bank is
59
+ composed from ``project``/``channel``/``user`` (bank.py), none recoverable
60
+ from a transcript scan, so backfilling would write to the WRONG bank. Only
61
+ static-bank agents are eligible (F1).
62
+ * **Active / recently-written session** (transcript mtime within
63
+ ``--min-idle-s``, default 1h) ⇒ **SKIPPED.** An in-flight session may have no
64
+ watermark yet; re-posting non-overlapping slices while the live path writes
65
+ sliding-window slices would duplicate the same turns under different ids the
66
+ daemon can't upsert away (F3).
67
+ * **No live watermark, idle session** ⇒ true-total-loss (the clerk case) —
68
+ post it in full. RESIDUAL (F3, narrow): a *closed* session whose live retain
69
+ landed but whose best-effort watermark WRITE failed has a bank doc and no
70
+ watermark; it is indistinguishable from total-loss without a per-document
71
+ existence query (which the vendored client lacks) and would be re-posted. The
72
+ ``--min-idle-s`` gate does not close this; it is a rare, documented residual.
73
+ * **Has a live watermark** ⇒ partially retained by the live path, whose slice
74
+ boundaries differ from the backfill's, so an id-collision is not guaranteed.
75
+ The vendored client exposes no per-document existence query, so the
76
+ conservative default is to **SKIP and report for manual review** rather than
77
+ risk a partial-overlap duplicate. Consequence: a permanently-closed partial
78
+ session's lost TAIL is not recovered by backfill (only the live path + boot
79
+ reconcile cover it) — surfaced in the report, not silently dropped.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import argparse
85
+ import glob
86
+ import json
87
+ import os
88
+ import sys
89
+ import time
90
+ from typing import Callable, Optional
91
+
92
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
93
+
94
+ from lib import watermark
95
+ from lib.bank import derive_bank_id
96
+ from lib.client import HindsightClient
97
+ from lib.config import debug_log, load_config
98
+ from lib.content import _is_tool_result_only_user_message
99
+ from lib.daemon import get_api_url
100
+ from lib.pacing import inflight_lock
101
+ from retain import build_retain_payload, read_transcript
102
+
103
+ # Injectable sleep so tests assert pacing via a fake clock, not wall time.
104
+ _SLEEP: Callable[[float], None] = time.sleep
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Config knobs (env-overridable, mirroring the HINDSIGHT_DRAIN_* convention).
109
+ # --------------------------------------------------------------------------- #
110
+ def _delay_ms(override: Optional[int] = None) -> float:
111
+ if override is not None:
112
+ return max(0, override)
113
+ try:
114
+ return max(0, int(os.environ.get("HINDSIGHT_BACKFILL_DELAY_MS", "1500")))
115
+ except ValueError:
116
+ return 1500
117
+
118
+
119
+ def _slice_turns(override: Optional[int] = None) -> int:
120
+ if override is not None:
121
+ return max(1, override)
122
+ try:
123
+ return max(1, int(os.environ.get("HINDSIGHT_BACKFILL_SLICE_TURNS", "40")))
124
+ except ValueError:
125
+ return 40
126
+
127
+
128
+ def _cooldown_s() -> float:
129
+ try:
130
+ return max(1.0, float(os.environ.get("HINDSIGHT_BACKFILL_COOLDOWN_S", "120")))
131
+ except ValueError:
132
+ return 120.0
133
+
134
+
135
+ def _min_idle_s(override: Optional[int] = None) -> float:
136
+ """A session whose transcript was modified more recently than this is treated
137
+ as ACTIVE / in-flight and is NEVER backfilled (F3): an active session may have
138
+ no watermark yet, so classifying it total-loss and re-posting non-overlapping
139
+ slices would duplicate turns the live path is concurrently writing. Default
140
+ 1h; override with ``--min-idle-s`` / ``HINDSIGHT_BACKFILL_MIN_IDLE_S``."""
141
+ if override is not None:
142
+ return max(0, override)
143
+ try:
144
+ return max(0.0, float(os.environ.get("HINDSIGHT_BACKFILL_MIN_IDLE_S", "3600")))
145
+ except ValueError:
146
+ return 3600.0
147
+
148
+
149
+ def _is_dynamic_bank(config: dict) -> bool:
150
+ """True when the loaded config resolves banks dynamically (per project /
151
+ channel / user). A transcript scan cannot reliably reconstruct such a bank
152
+ (F1): ``cwd`` is unknown at backfill time so ``project`` → ``unknown`` and
153
+ ``channel``/``user`` fall back to the operator's env — the recovered memory
154
+ would land in the WRONG bank. Such agents are refused (warn + skip)."""
155
+ return bool(config.get("dynamicBankId", False))
156
+
157
+
158
+ _STALL_THRESHOLD = 3 # consecutive failures → circuit-breaker cooldown
159
+ _BACKOFF_CAP_S = 60.0 # exponential backoff ceiling
160
+
161
+
162
+ def agents_dir() -> str:
163
+ """Root holding every agent's scaffold. Override with ``HINDSIGHT_AGENTS_DIR``."""
164
+ override = os.environ.get("HINDSIGHT_AGENTS_DIR")
165
+ if override:
166
+ return override
167
+ return os.path.join(os.path.expanduser("~"), ".switchroom", "agents")
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # Resumable per-(agent, session) progress store.
172
+ # --------------------------------------------------------------------------- #
173
+ def progress_path() -> str:
174
+ """Path of the backfill progress JSON. Override with ``HINDSIGHT_BACKFILL_PROGRESS``."""
175
+ override = os.environ.get("HINDSIGHT_BACKFILL_PROGRESS")
176
+ if override:
177
+ return override
178
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "backfill-progress.json")
179
+
180
+
181
+ class Progress:
182
+ """Small atomic JSON store: ``{"<agent>/<session>": {"status": ...}}``.
183
+
184
+ ``status`` ∈ {``done``, ``skipped_partial``, ``failed``}. Only ``done`` is a
185
+ resume-skip; a re-run retries ``failed``/``skipped_partial`` sessions.
186
+ """
187
+
188
+ def __init__(self, path: Optional[str] = None):
189
+ self.path = path or progress_path()
190
+ self._data: dict = {}
191
+ self._load()
192
+
193
+ # Reserved (non-session) key holding id-affecting run params. Cannot collide
194
+ # with an "<agent>/<session>" key.
195
+ PARAMS_KEY = "__backfill_params__"
196
+
197
+ @staticmethod
198
+ def _key(agent: str, session: str) -> str:
199
+ return f"{agent}/{session}"
200
+
201
+ def recorded_slice_turns(self) -> Optional[int]:
202
+ entry = self._data.get(self.PARAMS_KEY)
203
+ if isinstance(entry, dict):
204
+ val = entry.get("slice_turns")
205
+ return int(val) if isinstance(val, int) else None
206
+ return None
207
+
208
+ def ensure_slice_turns(self, slice_turns: int) -> None:
209
+ """Persist the run's ``slice_turns`` on first commit, and REFUSE to
210
+ resume with a different value (F2).
211
+
212
+ A different ``slice_turns`` re-slices at new boundaries → new
213
+ ``document_id``s → the already-committed run-1 slices orphan as
214
+ duplicates (the daemon can't upsert them away). Raises ``ValueError`` on
215
+ mismatch so the caller aborts before writing anything new.
216
+ """
217
+ recorded = self.recorded_slice_turns()
218
+ if recorded is None:
219
+ self._data[self.PARAMS_KEY] = {"slice_turns": int(slice_turns)}
220
+ self._flush()
221
+ return
222
+ if recorded != int(slice_turns):
223
+ raise ValueError(
224
+ f"backfill-progress.json was written with slice_turns={recorded}, "
225
+ f"but this run requested slice_turns={slice_turns}. Resuming with a "
226
+ f"different slice size would orphan the already-committed slices as "
227
+ f"duplicates. Re-run with --slice-turns {recorded}, or start a fresh "
228
+ f"backfill (new --backfill-progress path / clear the progress file)."
229
+ )
230
+
231
+ def _load(self) -> None:
232
+ try:
233
+ with open(self.path, encoding="utf-8") as f:
234
+ loaded = json.load(f)
235
+ if isinstance(loaded, dict):
236
+ self._data = loaded
237
+ except (OSError, json.JSONDecodeError):
238
+ self._data = {}
239
+
240
+ def status(self, agent: str, session: str) -> Optional[str]:
241
+ entry = self._data.get(self._key(agent, session))
242
+ return entry.get("status") if isinstance(entry, dict) else None
243
+
244
+ def is_done(self, agent: str, session: str) -> bool:
245
+ return self.status(agent, session) == "done"
246
+
247
+ def record(self, agent: str, session: str, status: str, **extra) -> None:
248
+ self._data[self._key(agent, session)] = {
249
+ "agent": agent,
250
+ "session": session,
251
+ "status": status,
252
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
253
+ **extra,
254
+ }
255
+ self._flush()
256
+
257
+ def _flush(self) -> None:
258
+ d = os.path.dirname(self.path)
259
+ try:
260
+ if d and not os.path.isdir(d):
261
+ os.makedirs(d, mode=0o700, exist_ok=True)
262
+ except OSError:
263
+ return
264
+ tmp = self.path + ".tmp"
265
+ try:
266
+ with open(tmp, "w", encoding="utf-8") as f:
267
+ json.dump(self._data, f, ensure_ascii=False)
268
+ f.flush()
269
+ os.fsync(f.fileno())
270
+ os.chmod(tmp, 0o600)
271
+ os.replace(tmp, self.path)
272
+ except OSError:
273
+ pass
274
+
275
+
276
+ # --------------------------------------------------------------------------- #
277
+ # Transcript chunking — forward, deterministic, human-turn bounded.
278
+ # --------------------------------------------------------------------------- #
279
+ def _human_turn_indices(messages: list) -> list:
280
+ return [
281
+ i
282
+ for i, m in enumerate(messages)
283
+ if isinstance(m, dict) and m.get("role") == "user" and not _is_tool_result_only_user_message(m)
284
+ ]
285
+
286
+
287
+ def chunk_by_human_turns(messages: list, turns: int) -> list:
288
+ """Partition ``messages`` into consecutive slices of up to ``turns`` human
289
+ turns each, on user-message boundaries.
290
+
291
+ Forward (oldest-first) and deterministic: re-running produces identical
292
+ slices ⇒ identical ``document_id``s ⇒ clean upsert / resume. Leading
293
+ non-human prelude before the first human turn is dropped (the live path
294
+ counts human turns too). Each slice runs from its first human turn up to
295
+ just before the next chunk's first human turn, so trailing assistant/tool
296
+ messages travel with their turn.
297
+ """
298
+ idx = _human_turn_indices(messages)
299
+ if not idx:
300
+ return []
301
+ slices = []
302
+ for start in range(0, len(idx), turns):
303
+ s = idx[start]
304
+ end_h = start + turns
305
+ e = idx[end_h] if end_h < len(idx) else len(messages)
306
+ slice_msgs = messages[s:e]
307
+ if slice_msgs:
308
+ slices.append(slice_msgs)
309
+ return slices
310
+
311
+
312
+ def _session_id_from_path(path: str) -> str:
313
+ return os.path.splitext(os.path.basename(path))[0]
314
+
315
+
316
+ def _resolve_bank_id(agent: str, session_id: str, config: dict) -> str:
317
+ synthetic_hook = {"session_id": session_id, "cwd": ""}
318
+ cfg = dict(config)
319
+ cfg.setdefault("agentName", agent)
320
+ if config.get("dynamicBankId"):
321
+ cfg["agentName"] = agent
322
+ return derive_bank_id(synthetic_hook, cfg)
323
+
324
+
325
+ # --------------------------------------------------------------------------- #
326
+ # The backfill.
327
+ # --------------------------------------------------------------------------- #
328
+ class Backfill:
329
+ def __init__(
330
+ self,
331
+ config: dict,
332
+ *,
333
+ commit: bool = False,
334
+ delay_ms: Optional[int] = None,
335
+ slice_turns: Optional[int] = None,
336
+ max_per_min: Optional[int] = None,
337
+ agents_root: Optional[str] = None,
338
+ min_idle_s: Optional[int] = None,
339
+ client: Optional[HindsightClient] = None,
340
+ progress: Optional[Progress] = None,
341
+ ):
342
+ self.config = config
343
+ self.commit = commit
344
+ self.delay_s = _delay_ms(delay_ms) / 1000.0
345
+ self.slice_turns = _slice_turns(slice_turns)
346
+ self.max_per_min = max_per_min
347
+ self.agents_root = agents_root or agents_dir()
348
+ self.min_idle_s = _min_idle_s(min_idle_s)
349
+ self.dynamic_bank = _is_dynamic_bank(config)
350
+ self.client = client
351
+ self.progress = progress or Progress()
352
+ self._api_url = None
353
+ self._api_token = config.get("hindsightApiToken")
354
+ # Pacing / breaker state.
355
+ self._posted_count = 0
356
+ self._consecutive_failures = 0
357
+ self._post_times: list = [] # monotonic timestamps for --max-per-min
358
+ # Per-agent + rollup report.
359
+ self.report: dict = {"agents": {}, "rollup": {}}
360
+
361
+ # -- daemon wiring ------------------------------------------------------ #
362
+ def _ensure_client(self) -> Optional[HindsightClient]:
363
+ if self.client is not None:
364
+ if self._api_url is None:
365
+ self._api_url = "http://backfill-client"
366
+ return self.client
367
+ if self._api_url is None:
368
+ try:
369
+ self._api_url = get_api_url(
370
+ self.config, debug_fn=lambda *a: debug_log(self.config, *a),
371
+ allow_daemon_start=False,
372
+ )
373
+ except (RuntimeError, ValueError) as e:
374
+ debug_log(self.config, f"backfill: daemon unavailable: {e}")
375
+ return None
376
+ self.client = HindsightClient(
377
+ self._api_url,
378
+ self._api_token,
379
+ request_timeout_override=self.config.get("requestTimeoutSeconds"),
380
+ )
381
+ return self.client
382
+
383
+ # -- pacing ------------------------------------------------------------- #
384
+ def _pace_before_post(self) -> None:
385
+ """Deterministic inter-POST delay + optional hard rate cap. First POST
386
+ of the run is not delayed."""
387
+ if self._posted_count > 0 and self.delay_s > 0:
388
+ _SLEEP(self.delay_s)
389
+ if self.max_per_min:
390
+ now = time.monotonic()
391
+ self._post_times = [t for t in self._post_times if now - t < 60.0]
392
+ if len(self._post_times) >= self.max_per_min:
393
+ oldest = self._post_times[0]
394
+ wait = 60.0 - (now - oldest)
395
+ if wait > 0:
396
+ _SLEEP(wait)
397
+
398
+ def _on_failure(self) -> None:
399
+ """Exponential backoff + circuit-breaker cooldown on repeated failure."""
400
+ self._consecutive_failures += 1
401
+ backoff = min(self.delay_s * (2 ** self._consecutive_failures), _BACKOFF_CAP_S)
402
+ _SLEEP(backoff)
403
+ if self._consecutive_failures >= _STALL_THRESHOLD:
404
+ debug_log(self.config, "backfill: circuit-breaker tripped, cooling down")
405
+ _SLEEP(_cooldown_s())
406
+ self._consecutive_failures = 0
407
+
408
+ def _post_slice(self, bank_id: str, built: dict) -> bool:
409
+ """Serial, paced, confirmed POST of one slice. Returns True on confirmed
410
+ persistence. NEVER concurrent — one in flight fleet-wide via the shared
411
+ inflight lock."""
412
+ client = self._ensure_client()
413
+ if client is None:
414
+ return False
415
+ payload = built["payload"]
416
+ self._pace_before_post()
417
+ with inflight_lock(blocking=True) as acquired:
418
+ if not acquired: # pragma: no cover - blocking acquire fails open
419
+ return False
420
+ try:
421
+ client.retain(
422
+ bank_id=bank_id,
423
+ content=payload["content"],
424
+ document_id=built["document_id"],
425
+ context=payload["context"],
426
+ metadata=payload["metadata"],
427
+ tags=payload["tags"],
428
+ timeout=15,
429
+ async_processing=False, # commit-before-ack (daemon contract 2)
430
+ )
431
+ except Exception as e:
432
+ debug_log(self.config, f"backfill: POST failed for {built['document_id']}: {e}")
433
+ self._posted_count += 1
434
+ self._on_failure()
435
+ return False
436
+ self._posted_count += 1
437
+ self._post_times.append(time.monotonic())
438
+ self._consecutive_failures = 0
439
+ return True
440
+
441
+ # -- scanning ----------------------------------------------------------- #
442
+ def _list_agents(self, agent_filter: Optional[set]) -> list:
443
+ try:
444
+ names = sorted(
445
+ n for n in os.listdir(self.agents_root)
446
+ if os.path.isdir(os.path.join(self.agents_root, n))
447
+ )
448
+ except OSError:
449
+ return []
450
+ if agent_filter:
451
+ names = [n for n in names if n in agent_filter]
452
+ return names
453
+
454
+ def _agent_transcripts(self, agent: str) -> list:
455
+ base = os.path.join(self.agents_root, agent, ".claude", "projects")
456
+ paths = sorted(glob.glob(os.path.join(base, "**", "*.jsonl"), recursive=True))
457
+ seen = set()
458
+ return [p for p in paths if not (p in seen or seen.add(p))]
459
+
460
+ def _agent_report(self, agent: str) -> dict:
461
+ return self.report["agents"].setdefault(agent, {
462
+ "bank_id": None,
463
+ "dynamic_bank_skipped": False,
464
+ "sessions_scanned": 0,
465
+ "sessions_total_loss": 0,
466
+ "sessions_partial_skipped": 0,
467
+ "sessions_active_skipped": 0,
468
+ "sessions_already_done": 0,
469
+ "turns_recovered": 0,
470
+ "slices": 0,
471
+ "posts_ok": 0,
472
+ "posts_failed": 0,
473
+ "sessions": [],
474
+ })
475
+
476
+ def run(self, agent_filter: Optional[set] = None) -> dict:
477
+ # F2: on a commit run, pin the id-affecting slice_turns; refuse to resume
478
+ # a progress file written with a different value (raises ValueError).
479
+ if self.commit:
480
+ self.progress.ensure_slice_turns(self.slice_turns)
481
+ for agent in self._list_agents(agent_filter):
482
+ self._backfill_agent(agent)
483
+ self._finalize_report()
484
+ return self.report
485
+
486
+ def _backfill_agent(self, agent: str) -> None:
487
+ ar = self._agent_report(agent)
488
+
489
+ # F1: a dynamic-bank agent's bank cannot be reconstructed from a
490
+ # transcript scan (cwd/channel/user are unknown at backfill time), so we
491
+ # REFUSE it entirely rather than write recovered memory to a guessed
492
+ # WRONG bank. Warn loudly, count, and skip — never post.
493
+ if self.dynamic_bank:
494
+ ar["dynamic_bank_skipped"] = True
495
+ debug_log(self.config,
496
+ f"backfill: REFUSING dynamic-bank agent {agent} — bank cannot be "
497
+ f"reliably reconstructed from a transcript scan; skipping.")
498
+ print(f"[Hindsight] backfill: WARNING — {agent} is dynamic-bank; refusing to "
499
+ f"guess its bank. Skipped (no writes).", file=sys.stderr)
500
+ return
501
+
502
+ now = time.time()
503
+ for path in self._agent_transcripts(agent):
504
+ session = _session_id_from_path(path)
505
+ ar["sessions_scanned"] += 1
506
+
507
+ # Resumability: an already-completed session is skipped cheaply.
508
+ if self.progress.is_done(agent, session):
509
+ ar["sessions_already_done"] += 1
510
+ continue
511
+
512
+ # F3: never touch an ACTIVE / recently-written session — it may have
513
+ # no watermark yet, and re-posting non-overlapping slices while the
514
+ # live path writes sliding-window slices duplicates the turns.
515
+ try:
516
+ idle = now - os.path.getmtime(path)
517
+ except OSError:
518
+ continue
519
+ if idle < self.min_idle_s:
520
+ ar["sessions_active_skipped"] += 1
521
+ ar["sessions"].append({"session": session, "class": "active",
522
+ "action": "skipped_recent", "idle_s": round(idle, 1)})
523
+ continue
524
+
525
+ messages = read_transcript(path)
526
+ if not messages:
527
+ continue
528
+
529
+ # Classification (design Part 2 dedup redesign).
530
+ live_wm = watermark.load(session)
531
+ if live_wm is not None:
532
+ # Partially retained by the live path — conservative skip
533
+ # (no existence query available in the vendored client).
534
+ ar["sessions_partial_skipped"] += 1
535
+ ar["sessions"].append({"session": session, "class": "partial", "action": "skipped_for_review"})
536
+ if self.commit:
537
+ self.progress.record(agent, session, "skipped_partial")
538
+ continue
539
+
540
+ # True-total-loss — recover in full.
541
+ slices = chunk_by_human_turns(messages, self.slice_turns)
542
+ if not slices:
543
+ continue
544
+
545
+ bank_id = _resolve_bank_id(agent, session, self.config)
546
+ if ar["bank_id"] is None:
547
+ ar["bank_id"] = bank_id
548
+ built_slices = []
549
+ n_turns = 0
550
+ for sl in slices:
551
+ built = build_retain_payload(
552
+ self.config, session, sl, messages,
553
+ bank_id=bank_id, api_url=self._api_url or "http://backfill-client",
554
+ api_token=self._api_token, retain_full_window=True, document_id=None,
555
+ )
556
+ if built is None:
557
+ continue
558
+ built_slices.append(built)
559
+ n_turns += len(_human_turn_indices(sl))
560
+
561
+ if not built_slices:
562
+ continue
563
+ ar["sessions_total_loss"] += 1
564
+ ar["slices"] += len(built_slices)
565
+ ar["turns_recovered"] += n_turns
566
+ ar["sessions"].append({
567
+ "session": session, "class": "total_loss",
568
+ "bank_id": bank_id,
569
+ "slices": len(built_slices), "turns": n_turns,
570
+ "document_ids": [b["document_id"] for b in built_slices],
571
+ })
572
+
573
+ if not self.commit:
574
+ # Dry-run: build-only, ZERO writes. Nothing posted, no mission
575
+ # PATCH, no progress mutation.
576
+ continue
577
+
578
+ all_ok = True
579
+ last_built = None
580
+ for built in built_slices:
581
+ if self._post_slice(bank_id, built):
582
+ ar["posts_ok"] += 1
583
+ last_built = built
584
+ else:
585
+ ar["posts_failed"] += 1
586
+ all_ok = False
587
+
588
+ if all_ok and last_built is not None:
589
+ # Advance the live watermark so the agent's own boot reconcile
590
+ # sees the tail as committed, and mark the session done for a
591
+ # cheap resume.
592
+ try:
593
+ watermark.commit(
594
+ session, last_built["last_uuid"], last_built["document_id"],
595
+ transcript_path=path, ordered_uuids=last_built["ordered_uuids"],
596
+ )
597
+ except Exception: # pragma: no cover - defensive
598
+ pass
599
+ self.progress.record(agent, session, "done", slices=len(built_slices), turns=n_turns)
600
+ else:
601
+ self.progress.record(agent, session, "failed")
602
+
603
+ def _finalize_report(self) -> None:
604
+ roll = {
605
+ "agents": len(self.report["agents"]),
606
+ "dynamic_bank_agents_skipped": 0,
607
+ "sessions_scanned": 0,
608
+ "sessions_total_loss": 0,
609
+ "sessions_partial_skipped": 0,
610
+ "sessions_active_skipped": 0,
611
+ "sessions_already_done": 0,
612
+ "turns_recovered": 0,
613
+ "slices": 0,
614
+ "posts_ok": 0,
615
+ "posts_failed": 0,
616
+ "slice_turns": self.slice_turns,
617
+ "committed": self.commit,
618
+ }
619
+ for ar in self.report["agents"].values():
620
+ if ar.get("dynamic_bank_skipped"):
621
+ roll["dynamic_bank_agents_skipped"] += 1
622
+ for k in (
623
+ "sessions_scanned", "sessions_total_loss", "sessions_partial_skipped",
624
+ "sessions_active_skipped", "sessions_already_done", "turns_recovered",
625
+ "slices", "posts_ok", "posts_failed",
626
+ ):
627
+ roll[k] += ar[k]
628
+ self.report["rollup"] = roll
629
+
630
+
631
+ # --------------------------------------------------------------------------- #
632
+ # Reporting / CLI.
633
+ # --------------------------------------------------------------------------- #
634
+ def format_report(report: dict) -> str:
635
+ roll = report.get("rollup", {})
636
+ mode = "COMMIT" if roll.get("committed") else "DRY-RUN (no writes)"
637
+ lines = [f"[Hindsight] backfill report — {mode}", ""]
638
+ for agent in sorted(report.get("agents", {})):
639
+ ar = report["agents"][agent]
640
+ if ar.get("dynamic_bank_skipped"):
641
+ lines.append(f" {agent}: DYNAMIC-BANK — REFUSED (bank not reconstructable, no writes)")
642
+ continue
643
+ lines.append(
644
+ f" {agent} [bank={ar.get('bank_id')}]: scanned={ar['sessions_scanned']} "
645
+ f"total_loss={ar['sessions_total_loss']} "
646
+ f"partial_skipped={ar['sessions_partial_skipped']} "
647
+ f"active_skipped={ar['sessions_active_skipped']} "
648
+ f"already_done={ar['sessions_already_done']} "
649
+ f"turns={ar['turns_recovered']} slices={ar['slices']} "
650
+ f"posts_ok={ar['posts_ok']} posts_failed={ar['posts_failed']}"
651
+ )
652
+ lines.append("")
653
+ lines.append(
654
+ f" ROLLUP: agents={roll.get('agents', 0)} "
655
+ f"dynamic_bank_refused={roll.get('dynamic_bank_agents_skipped', 0)} "
656
+ f"total_loss_sessions={roll.get('sessions_total_loss', 0)} "
657
+ f"partial_skipped={roll.get('sessions_partial_skipped', 0)} "
658
+ f"active_skipped={roll.get('sessions_active_skipped', 0)} "
659
+ f"turns_would_recover={roll.get('turns_recovered', 0)} "
660
+ f"slices={roll.get('slices', 0)} slice_turns={roll.get('slice_turns')} "
661
+ f"posts_ok={roll.get('posts_ok', 0)} posts_failed={roll.get('posts_failed', 0)}"
662
+ )
663
+ if not roll.get("committed"):
664
+ est_posts = roll.get("slices", 0)
665
+ delay_ms = _delay_ms()
666
+ est_secs = est_posts * (delay_ms / 1000.0)
667
+ lines.append(
668
+ f" ESTIMATE (at {delay_ms}ms/post): ~{est_posts} POSTs, "
669
+ f"~{est_secs:.0f}s wall-clock. Re-run with --commit to write."
670
+ )
671
+ return "\n".join(lines)
672
+
673
+
674
+ def _acquire_process_mutex():
675
+ """``backfill.lock`` — one backfill process at a time (distinct from the
676
+ per-POST storm guard). Returns the held fd, or None if flock unavailable /
677
+ already held."""
678
+ if sys.platform == "win32":
679
+ return None
680
+ import fcntl
681
+ path = os.environ.get("HINDSIGHT_BACKFILL_LOCK") or os.path.join(
682
+ os.path.expanduser("~"), ".hindsight", "backfill.lock"
683
+ )
684
+ d = os.path.dirname(path)
685
+ try:
686
+ if d and not os.path.isdir(d):
687
+ os.makedirs(d, mode=0o700, exist_ok=True)
688
+ fd = open(path, "w")
689
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
690
+ return fd
691
+ except OSError:
692
+ return None
693
+
694
+
695
+ def main(argv=None) -> int:
696
+ parser = argparse.ArgumentParser(
697
+ description="One-time backfill of transcript work missing from Hindsight banks (#3244).",
698
+ )
699
+ parser.add_argument("--commit", action="store_true",
700
+ help="Actually write. WITHOUT this flag the tool is a zero-write dry-run (the default).")
701
+ parser.add_argument("--agent", action="append", dest="agents", default=None,
702
+ help="Restrict to this agent (repeatable). Default: all agents.")
703
+ parser.add_argument("--agents-dir", default=None, help="Override the agents scaffold root.")
704
+ parser.add_argument("--delay-ms", type=int, default=None, help="Inter-POST delay (default 1500).")
705
+ parser.add_argument("--slice-turns", type=int, default=None, help="Human turns per document slice (default 40).")
706
+ parser.add_argument("--max-per-min", type=int, default=None, help="Optional hard POST rate cap.")
707
+ parser.add_argument("--min-idle-s", type=int, default=None,
708
+ help="Skip sessions whose transcript was modified within this many seconds "
709
+ "(treated as active/in-flight). Default 3600.")
710
+ parser.add_argument("--json", action="store_true", help="Emit the machine-readable report as JSON.")
711
+ args = parser.parse_args(argv)
712
+
713
+ # F1/F3 footgun guard: a blanket --commit across the whole fleet can write to
714
+ # a wrong (dynamic) bank or double-post an active session. Require an explicit
715
+ # per-agent opt-in for any write; dry-run may still scan the whole fleet.
716
+ if args.commit and not args.agents:
717
+ print("[Hindsight] backfill: --commit requires an explicit --agent <name> "
718
+ "(fleet-wide commit is refused; dry-run the fleet first, then commit per agent).",
719
+ file=sys.stderr)
720
+ return 2
721
+
722
+ config = load_config()
723
+
724
+ mutex = _acquire_process_mutex()
725
+ if mutex is None and sys.platform != "win32":
726
+ print("[Hindsight] backfill: could not take backfill.lock — another backfill may be "
727
+ "running. Refusing to co-run.", file=sys.stderr)
728
+ return 2
729
+
730
+ try:
731
+ bf = Backfill(
732
+ config,
733
+ commit=args.commit,
734
+ delay_ms=args.delay_ms,
735
+ slice_turns=args.slice_turns,
736
+ max_per_min=args.max_per_min,
737
+ agents_root=args.agents_dir,
738
+ min_idle_s=args.min_idle_s,
739
+ )
740
+ agent_filter = set(args.agents) if args.agents else None
741
+ try:
742
+ report = bf.run(agent_filter)
743
+ except ValueError as e:
744
+ # F2: slice_turns mismatch on resume — refuse before writing.
745
+ print(f"[Hindsight] backfill: {e}", file=sys.stderr)
746
+ return 2
747
+ finally:
748
+ if mutex is not None:
749
+ try:
750
+ mutex.close()
751
+ except OSError:
752
+ pass
753
+
754
+ if args.json:
755
+ print(json.dumps(report, ensure_ascii=False, indent=2))
756
+ else:
757
+ print(format_report(report))
758
+ return 0
759
+
760
+
761
+ if __name__ == "__main__":
762
+ sys.exit(main())