switchroom 0.18.24 → 0.18.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -0,0 +1,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())
|