switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -3,39 +3,100 @@
3
3
 
4
4
  SessionStart calls into ``drain()`` to retry any retain payloads that
5
5
  ``session_end.py`` queued on failure (#1071). Each entry is retried up
6
- to ``MAX_ATTEMPTS`` (5) times; after that it's renamed to ``.dead`` so
7
- the queue no longer drains it but the operator can still inspect via
8
- ``switchroom doctor``.
6
+ to ``MAX_ATTEMPTS`` (5) times; after that a **permanently** failing entry
7
+ (a 4xx that a re-POST cannot fix — see ``pending.is_permanent_failure``)
8
+ is renamed to ``.dead`` so the queue no longer drains it but the operator
9
+ can still inspect via ``switchroom doctor``. An entry failing on anything
10
+ else — a 5xx, a timeout, a connection error — stays queued past the
11
+ attempt budget: a transient upstream is never evidence that the memory
12
+ is unsaveable, and retiring it would lose content the user believes was
13
+ saved.
14
+
15
+ An entry past the budget is DEMOTED rather than retired (``_drain_order``
16
+ and ``_over_budget``): it sorts behind everything still inside its budget
17
+ and abstains from the stall guard. That is what keeps "never destroy a
18
+ memory" from degrading into "never drain anything" — the drain is
19
+ sequential and oldest-first, so without the demotion three chronically
20
+ failing entries sit at the head and end every run at zero progress.
9
21
 
10
22
  Boundaries
11
23
  ----------
12
24
  * Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s), but
13
- clamped per entry to the budget still remaining (see below) so a
14
- single slow entry can never overshoot the wall-clock cap. The default
15
- timeout (5s) intentionally exceeds the default budget (4s): the clamp,
16
- not the raw timeout, is what bounds a slow entry.
25
+ clamped to the budget still remaining (see below) so a single slow
26
+ entry can never overshoot the wall-clock cap. The default timeout (5s)
27
+ intentionally exceeds the default budget (4s): the clamp, not the raw
28
+ timeout, is what bounds a slow entry.
17
29
  * Total wall-clock cap: ``HINDSIGHT_DRAIN_BUDGET_S`` (default 4s) so
18
30
  drain never blocks SessionStart longer than the upstream hook timeout
19
- permits. This is the authoritative bound; the per-entry timeout is
20
- clamped down to ``max(1, remaining budget)`` before each request, so
21
- even one slow upstream entry overshoots the budget by at most the
22
- clamp floor (~1s), not by ``HINDSIGHT_DRAIN_TIMEOUT - budget``.
31
+ permits. This is the authoritative bound. The clamp is recomputed
32
+ **before every request** — the presence GET and, if it falls through,
33
+ the POST — against the budget remaining *at that moment*, not once per
34
+ entry. Clamping once per entry spends the same allowance twice (a 9s
35
+ budget with an 8s timeout measured 16.0s), so the overshoot is bounded
36
+ by the clamp floor (~1s) only if each request re-reads the remaining
37
+ budget.
23
38
  * Stall guard: if ``STALL_THRESHOLD`` (3) consecutive entries fail with
24
39
  the same error class, we stop draining for this session — that's a
25
40
  systemic outage, not a transient flake, and continuing would only
26
41
  burn the SessionStart timeout budget. The remaining entries stay
27
42
  queued for the next session.
28
43
 
44
+ Backlog mode (switchroom #3596)
45
+ -------------------------------
46
+ The bounds above are sized for the SessionStart hook and CANNOT clear an
47
+ accumulated backlog. Worse, they CREATE one: the per-entry timeout is
48
+ clamped to the remaining hook budget (1-8s) while ``_retry_one`` posts
49
+ synchronously and a real retain takes 30-90s, so **the server commits the
50
+ document and the client always gives up before the ack**. The entry is
51
+ never deleted and is re-posted on every session start. The queue depth was a symptom
52
+ of that loop, not of lost memory: a full sweep of 5,751 queued entries on
53
+ this fleet (2026-07-25) found **4,048 (70.4%) already existed as
54
+ documents**, 3,815 of them with facts extracted.
55
+
56
+ ``--backlog`` is therefore a two-phase, out-of-hook replay:
57
+
58
+ * **Phase 1 — reconcile (free).** GET the document. If it exists, the
59
+ memory is already durable; retire the queue entry without a POST. No
60
+ LLM work, no cost, idempotent, resumable at any point. Only for
61
+ post-#3244 CONTENT-derived ``document_id``s — a pre-#3244 bare session
62
+ id is answered 200 by any retain in that session, so its 200 says
63
+ nothing about this entry (see ``_reconcilable_on_presence``).
64
+ * **Phase 2 — drain (real work).** Only for genuinely absent documents:
65
+ POST with a realistic timeout, then **re-GET to confirm the document
66
+ exists before retiring the entry**. A 200 is an ack, not proof
67
+ (switchroom #3244).
68
+
69
+ "Retire" never means ``os.remove``: an entry leaves the queue by MOVING
70
+ into the bounded ``pending-reconciled/`` archive (``pending.
71
+ archive_reconciled``), because every retire decision here rests on a 200.
72
+ That holds unconditionally for THIS module. It does not make the queue
73
+ immortal: if the archive cannot be written the entry stays queued, and a
74
+ disk that stays full eventually drives ``pending._evict_to_fit`` to shed the
75
+ OLDEST live entries on the ENQUEUE path (ledgered, and a ``switchroom
76
+ doctor`` failure). Nothing the drain does deletes a turn; a full disk does.
77
+
78
+ Pacing is not optional. The local model group backing retain has a small,
79
+ fixed number of lanes shared with live retains, reflect and consolidation,
80
+ so backlog replay defaults to **concurrency 1** with a sleep between
81
+ entries, and will pause entirely while an operator-supplied p95 probe
82
+ reports the upstream is already slow.
83
+
29
84
  Standalone usage::
30
85
 
31
- python3 drain_pending.py # one-shot drain, prints summary
86
+ python3 drain_pending.py # bounded in-hook drain
87
+ python3 drain_pending.py --backlog # two-phase backlog replay
88
+ python3 drain_pending.py --backlog --phase reconcile # free pass only
89
+ python3 drain_pending.py --backlog --dry-run
32
90
  """
33
91
 
34
92
  from __future__ import annotations
35
93
 
94
+ import argparse
36
95
  import os
96
+ import subprocess
37
97
  import sys
38
98
  import time
99
+ from concurrent.futures import ThreadPoolExecutor
39
100
 
40
101
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
102
 
@@ -43,16 +104,78 @@ from lib.client import HindsightClient
43
104
  from lib.config import debug_log, load_config
44
105
  from lib.pending import (
45
106
  MAX_ATTEMPTS,
46
- delete_entry,
107
+ archive_reconciled,
108
+ is_content_derived_document_id,
109
+ is_permanent_failure,
47
110
  iter_entries,
48
111
  mark_dead,
49
112
  update_attempt,
50
113
  )
114
+ from lib.retain_split import retain_client_deadline
51
115
 
52
116
 
53
117
  STALL_THRESHOLD = 3
54
118
 
55
119
 
120
+ def _clamp(timeout: int, budget: float, started: float) -> int:
121
+ """Per-REQUEST HTTP timeout: ``timeout`` capped by the budget LEFT NOW.
122
+
123
+ Called immediately before each request — the presence GET and the POST
124
+ — not once per entry. Computing it once and spending it twice makes the
125
+ per-entry cost additive: with the fleet's own settings (budget 9s,
126
+ timeout 8s) a single slow entry measured 16.0s against a 9s budget,
127
+ while the module docstring promised an overshoot of at most the clamp
128
+ floor.
129
+
130
+ Floor at 1s so a near-exhausted budget still gets one bounded shot
131
+ rather than a 0s (instant-fail) request. That floor is the entire
132
+ overshoot: at most ~1s per request, ~2s for a GET+POST entry.
133
+
134
+ The 1s floor is expressed TWICE and the two are mutually redundant:
135
+ the early ``remaining < 1`` return and the ``max(1, ...)`` below each
136
+ enforce it alone (``int(0.5)`` → 0 → 1; ``int(-5)`` → -5 → 1). That
137
+ redundancy — not a coverage hole — is why single mutations here
138
+ survive: ``<`` → ``<=``, ``max(1`` → ``max(0``, and deleting either
139
+ guard are all EQUIVALENT mutants, verified by exhaustive comparison
140
+ over the boundary plus 200k random remainders (#3599 review R3-L4,
141
+ which reported it as unpinned). What matters is the floor's OUTCOME,
142
+ and that IS pinned: removing both guards fails
143
+ ``ClampAndEnvKnobBoundaryTest.test_clamp_floors_at_one_second_never_zero``
144
+ and ``test_session_start_reconcile_respects_the_hook_budget``. Left as
145
+ two lines deliberately — the shortcut states the intent where a reader
146
+ looks for it, and no mutation of it can change behaviour.
147
+
148
+ THE EQUIVALENCE HAS A PRECONDITION, and it is not this function's
149
+ (#3599 review R4-Lb — the paragraph above used to claim it
150
+ unconditionally). ``max(1, min(timeout, int(remaining)))`` collapses to
151
+ ``max(0, ...)`` only while ``timeout >= 1``. With ``timeout == 0`` a
152
+ healthy budget takes the ``max`` branch and the ``max(0`` mutant returns
153
+ **0** — the instant-fail request the floor exists to prevent, on every
154
+ entry. The only callsite invariant that rules this out is
155
+ ``_per_entry_timeout()``'s own ``max(1, v)``, which is what turns
156
+ ``HINDSIGHT_DRAIN_TIMEOUT=0`` into 1. Both callers below take their
157
+ ``timeout`` from it (``drain``'s local, line ~396). So the honest
158
+ statement is: equivalent FOR EVERY REACHABLE INPUT, because
159
+ ``_per_entry_timeout`` floors first — a fact pinned by
160
+ ``ClampAndEnvKnobBoundaryTest.test_the_per_entry_timeout_is_floored_at_one_second``,
161
+ not by anything here. Change that floor and this proof dies with it.
162
+ """
163
+ remaining = budget - (time.monotonic() - started)
164
+ if remaining < 1:
165
+ return 1
166
+ return max(1, min(timeout, int(remaining)))
167
+
168
+
169
+ def _reconcilable_on_presence(entry: dict) -> bool:
170
+ """May a bare presence GET retire this entry? See ``pending.py``.
171
+
172
+ Only for post-#3244 content-derived ``document_id``s. A pre-#3244
173
+ entry's id is a bare session id, so a 200 reflects *some* retain in
174
+ that session, not this entry's content.
175
+ """
176
+ return is_content_derived_document_id(entry.get("document_id"))
177
+
178
+
56
179
  def _per_entry_timeout() -> int:
57
180
  raw = os.environ.get("HINDSIGHT_DRAIN_TIMEOUT", "5")
58
181
  try:
@@ -71,13 +194,148 @@ def _budget_seconds() -> float:
71
194
  return 4.0
72
195
 
73
196
 
197
+ def _env_num(name: str, default, cast=float, lo=None, hi=None):
198
+ """Read a numeric env knob, falling back to ``default`` on garbage."""
199
+ try:
200
+ v = cast(os.environ.get(name) or default)
201
+ except (TypeError, ValueError):
202
+ v = cast(default)
203
+ if lo is not None:
204
+ v = max(lo, v)
205
+ if hi is not None:
206
+ v = min(hi, v)
207
+ return v
208
+
209
+
210
+ def _backlog_timeout() -> int:
211
+ """Per-entry HTTP timeout in backlog mode.
212
+
213
+ A synchronous retain takes 30-90s on this fleet, so the SessionStart
214
+ default (5-8s, further clamped by the hook budget) guarantees a
215
+ client-side timeout on a request the server then commits anyway.
216
+
217
+ The default is DERIVED, not a literal: it is
218
+ ``retain_split.retain_client_deadline()`` (310s), the same deadline the
219
+ retain content bound is sized against. Those two must be ONE number.
220
+ This function shipped as a bare ``180`` (#3599), and against a 180s
221
+ deadline both halves of the retain budget break: a maximally-sized part
222
+ is ~276s of sequential extraction, and the SERVER per-call timeout
223
+ derived in ``src/setup/hindsight.ts`` is larger still — so the drain
224
+ client would abandon a request the server is still legitimately working
225
+ on, leave the entry queued, and rebuild the re-post loop #3599 exists to
226
+ kill, one size class up.
227
+
228
+ This is the OUTERMOST deadline of the paired-budget family, and it is the
229
+ one the 2026-07-26 backlog-recovery logs show as a fixed ~280.1s per-entry
230
+ give-up. That was not an unexplained third number: it is this default at
231
+ the then-current ``DEFAULT_RETAIN_CLIENT_DEADLINE_S = 280.0``, plus the
232
+ request's own setup overhead. It was WRONG for the same reason #3611's
233
+ 204s was wrong — it did not cover hindsight's LiteLLM routing chain
234
+ (local 200s + OpenRouter fallback 90s + router margin 10s = 300s), so
235
+ every drained entry whose retain fell through to the fallback was
236
+ abandoned client-side at 280s while the server was still inside a
237
+ legitimate 300s budget. Raising the derivation's base to 310 fixes this
238
+ lane and the in-hook lane with the same number, by construction.
239
+ ``src/litellm/timeout-budget.ts`` is where the chain is declared, and
240
+ ``tests/setup/hindsight.test.ts`` fails if these drift apart again.
241
+
242
+ ``HINDSIGHT_DRAIN_BACKLOG_TIMEOUT`` still overrides it outright;
243
+ ``HINDSIGHT_RETAIN_CLIENT_DEADLINE_S`` moves the derivation and the
244
+ content bound together.
245
+ """
246
+ return _env_num(
247
+ "HINDSIGHT_DRAIN_BACKLOG_TIMEOUT",
248
+ int(retain_client_deadline()),
249
+ int,
250
+ lo=1,
251
+ )
252
+
253
+
254
+ def _backlog_budget_seconds() -> float:
255
+ """Total wall-clock cap in backlog mode (default 1h)."""
256
+ return _env_num("HINDSIGHT_DRAIN_BACKLOG_BUDGET_S", 3600, float, lo=1.0)
257
+
258
+
259
+ def _backlog_concurrency() -> int:
260
+ """Entries retried in parallel in backlog mode.
261
+
262
+ **Defaults to 1, deliberately.** The local model group serving retain
263
+ has a small fixed number of lanes (4 on this fleet: 2 boxes x 2 slots)
264
+ SHARED with live retains, reflect and consolidation. A drain at width
265
+ 4 consumes the whole pool; run per-agent across 11 agents and it is
266
+ 44 lanes of demand against 4, which trips the latency watchdog. One
267
+ lane leaves the rest for live work. Raise it only if you know the
268
+ pool is idle.
269
+ """
270
+ return _env_num("HINDSIGHT_DRAIN_CONCURRENCY", 1, int, lo=1, hi=16)
271
+
272
+
273
+ def _backlog_sleep_seconds() -> float:
274
+ """Pause between phase-2 retains, so replay never runs flat out."""
275
+ return _env_num("HINDSIGHT_DRAIN_SLEEP_S", 2.0, float, lo=0.0)
276
+
277
+
278
+ def _p95_backoff_ms() -> int:
279
+ """Pause phase 2 while the upstream p95 exceeds this."""
280
+ return _env_num("HINDSIGHT_DRAIN_P95_BACKOFF_MS", 38000, int, lo=0)
281
+
282
+
283
+ def _p95_probe_ms() -> int:
284
+ """Current upstream p95 in ms, or ``-1`` when unknown.
285
+
286
+ The probe is an operator-supplied command (``HINDSIGHT_DRAIN_P95_CMD``)
287
+ that prints a millisecond figure on stdout. It is NOT built in: the
288
+ authoritative latency figure on this fleet lives in LiteLLM's spend
289
+ log in postgres, which an agent container cannot reach — inventing a
290
+ weaker in-container proxy for it would be a worse signal that looks
291
+ like a better one. Unset ⇒ no backoff, and ``--backlog`` says so.
292
+
293
+ A CONFIGURED-BUT-BROKEN probe is not the same as an unset one, and
294
+ used to be indistinguishable: ``out.returncode`` was ignored, so a
295
+ typo'd or unauthorized command produced empty stdout, ``int()`` raised,
296
+ and the bare ``except`` returned ``-1`` — silently disabling the very
297
+ backoff the operator had asked for. It still returns ``-1`` (a replay
298
+ that halts because its probe is broken is worse than one that runs
299
+ unpaced), but it now says so loudly on stderr, naming the exit status
300
+ and stderr tail, so the gap is visible in the drain log.
301
+ """
302
+ cmd = os.environ.get("HINDSIGHT_DRAIN_P95_CMD")
303
+ if not cmd:
304
+ return -1
305
+ try:
306
+ out = subprocess.run(
307
+ cmd, shell=True, capture_output=True, text=True, timeout=45
308
+ )
309
+ except Exception as e:
310
+ _blog(
311
+ f"p95 probe FAILED to run ({type(e).__name__}: {e}) — backoff is "
312
+ f"DISABLED for this run. Fix HINDSIGHT_DRAIN_P95_CMD."
313
+ )
314
+ return -1
315
+ if out.returncode != 0:
316
+ _blog(
317
+ f"p95 probe exited {out.returncode} — backoff is DISABLED for this "
318
+ f"run. Fix HINDSIGHT_DRAIN_P95_CMD. stderr: "
319
+ f"{(out.stderr or '').strip()[:200]}"
320
+ )
321
+ return -1
322
+ try:
323
+ return int(out.stdout.strip().splitlines()[-1])
324
+ except (ValueError, IndexError):
325
+ _blog(
326
+ f"p95 probe exited 0 but printed no millisecond figure — backoff is "
327
+ f"DISABLED for this run. stdout: {(out.stdout or '').strip()[:200]!r}"
328
+ )
329
+ return -1
330
+
331
+
74
332
  def _retry_one(entry: dict, timeout: int) -> None:
75
333
  """POST a single queued retain. Raises on failure.
76
334
 
77
335
  Posts ``async_processing=False`` (commit-before-ack, switchroom #3244 §1.1):
78
- the drain is a DURABILITY path — it deletes the pending entry on a 200, so
336
+ the drain is a DURABILITY path — it retires the pending entry on a 200, so
79
337
  the 200 must prove durable persistence, not merely ack-of-receipt. A bare
80
- async 200 followed by a dropped extraction would delete the queue entry
338
+ async 200 followed by a dropped extraction would retire the queue entry
81
339
  while the content never lands, and (for boot-reconcile remainders whose
82
340
  watermark already advanced) there is no reconcile backstop — silent loss
83
341
  (the #3244 bug). All drained entries — Stop-hook A2 failures, SessionEnd
@@ -97,31 +355,234 @@ def _retry_one(entry: dict, timeout: int) -> None:
97
355
  )
98
356
 
99
357
 
100
- def drain(config: dict | None = None) -> dict:
358
+ def _document_state(entry: dict, timeout: int = 30):
359
+ """Tri-state presence of this entry's document. See ``document_exists``.
360
+
361
+ ``True`` present / ``False`` absent / ``None`` unknown. Never raises —
362
+ an unknown must never be mistaken for an absence (which would re-POST
363
+ a durable document) nor for a presence (which would delete the last
364
+ on-disk copy of a turn).
365
+ """
366
+ did = entry.get("document_id")
367
+ bank = entry.get("bank_id")
368
+ if not did or not bank:
369
+ return None
370
+ try:
371
+ client = HindsightClient(entry["api_url"], entry.get("api_token"))
372
+ return client.document_exists(bank, did, timeout=timeout)
373
+ except Exception:
374
+ return None
375
+
376
+
377
+ def _drain_order(entries: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
378
+ """Oldest-first, but with budget-exhausted entries demoted to the back.
379
+
380
+ THE HEAD-OF-LINE BUG THIS EXISTS FOR. Since the permanence gate in
381
+ ``_record_failure``, an entry failing on anything transient stays queued
382
+ past ``MAX_ATTEMPTS`` indefinitely — deliberately, because a transient
383
+ upstream is not evidence the memory is unsaveable. But ``iter_entries()``
384
+ is oldest-first, and the entries that have been failing longest are by
385
+ construction the OLDEST, so they sit at the head of every drain. Backlog
386
+ concurrency defaults to 1 (``_backlog_concurrency``), so the drain is
387
+ sequential: three such entries in a row trip ``STALL_THRESHOLD`` and the
388
+ run breaks having drained nothing — and, because they never retire, it
389
+ breaks identically on every subsequent run. Measured on this branch before
390
+ this function existed: 6 queued entries, the 3 oldest raising
391
+ ``TimeoutError``, 4 consecutive ``drain_backlog`` runs each returning
392
+ ``stalled=True, drained=0`` with the queue depth still 6. The 3 healthy
393
+ entries behind them were never even attempted. On ``main`` the same repro
394
+ converges: run 0 retires the 3 heads to ``.dead`` and run 1 onward drains
395
+ normally. So the permanence gate, alone, traded "rarely destroys a memory"
396
+ for "eventually drains nothing at all".
397
+
398
+ ``.dead`` was doing double duty: it was the honesty policy AND it was the
399
+ queue's only un-wedging mechanism. Removing it as a policy has to leave
400
+ the un-wedging behind, and demotion is that — it keeps every property the
401
+ gate was added for (the entry is still queued, still retried, never
402
+ destroyed) while removing the one it broke (it can no longer starve a
403
+ healthy entry behind it).
404
+
405
+ The terminal condition for a permanently-unsaveable entry is therefore no
406
+ longer deletion but DEMOTION: ``attempt_count`` only ever climbs, so such
407
+ an entry crosses the budget once and stays in the back group for good,
408
+ where it can delay only itself. It is still reconciled for free on every
409
+ run — ``_reconcile_phase`` sweeps ALL entries with a sub-second presence
410
+ GET, in no particular order and with no stall guard — so an entry whose
411
+ document did land is still retired without a POST.
412
+
413
+ Ordering is a stable partition, so relative age is preserved inside each
414
+ group and FIFO still holds for everything that has not blown its budget.
415
+ """
416
+ fresh: list[tuple[str, dict]] = []
417
+ exhausted: list[tuple[str, dict]] = []
418
+ for path, entry in entries:
419
+ (exhausted if _over_budget(entry) else fresh).append((path, entry))
420
+ return fresh + exhausted
421
+
422
+
423
+ def _over_budget(entry: dict) -> bool:
424
+ """Has this entry already burned its ``MAX_ATTEMPTS`` budget?
425
+
426
+ Such an entry is chronically failing but, since the permanence gate, is
427
+ never retired. It gets two demotions — last in the drain order
428
+ (``_drain_order``) and no vote in the stall guard (see below) — because
429
+ ordering alone does not close the wedge. Ordering fixes the common shape
430
+ (a few old poison entries in front of healthy ones), but not the shape
431
+ where the WHOLE queue is over budget: an upstream down for a week takes
432
+ every entry past 5 attempts, and when it recovers the partition is empty
433
+ on one side, the poisoned entries are at the head again, and the run
434
+ stalls before reaching the entries that would now succeed. Measured: with
435
+ ordering alone and all 6 entries at ``MAX_ATTEMPTS``, 4 consecutive runs
436
+ still returned ``stalled=True, drained=0``.
437
+ """
438
+ try:
439
+ return int(entry.get("attempt_count", 0)) >= MAX_ATTEMPTS
440
+ except (TypeError, ValueError):
441
+ # A hand-edited or corrupt counter must not decide ordering, and must
442
+ # not raise on the drain path. Treat it as fresh: the cost of guessing
443
+ # wrong here is one retry in the normal position, not a lost memory.
444
+ return False
445
+
446
+
447
+ def _record_failure(
448
+ config: dict,
449
+ path: str,
450
+ entry: dict,
451
+ e: Exception,
452
+ summary: dict,
453
+ ) -> str:
454
+ """Apply the per-entry failure policy. Returns the error class name.
455
+
456
+ Shared by the sequential (SessionStart) and backlog drains so both age
457
+ entries toward ``.dead`` on exactly the same schedule — and, since the
458
+ permanence gate below, refuse to retire them on exactly the same rule.
459
+ """
460
+ err_class = type(e).__name__
461
+ attempts = int(entry.get("attempt_count", 1))
462
+ # ``.dead`` retires a memory the user believes was saved, so it is gated on
463
+ # the failure being PERMANENT — a 4xx that re-POSTing cannot fix. A
464
+ # transient failure keeps its attempt counter climbing but stays queued,
465
+ # because an exhausted attempt budget is not evidence that the content is
466
+ # unpersistable.
467
+ #
468
+ # Before this gate, ANY five failures retired the entry. The dominant
469
+ # failure on this fleet is an HTTP 500 "Fact extraction failed … chunk 0:
470
+ # JSONDecodeError" — the extraction model returned an empty or non-JSON
471
+ # completion for one chunk on that sampling run. The identical content
472
+ # succeeds on a later attempt, so five unlucky samples were destroying
473
+ # memories that were never unsaveable. See ``pending.is_permanent_failure``.
474
+ #
475
+ # WHAT BOUNDS THIS, precisely — because an unbounded queue of undying
476
+ # entries would be a worse outcome than the bug this gate fixes.
477
+ #
478
+ # DISK is bounded by the queue's MAX_ENTRIES / MAX_BYTES caps, which shed
479
+ # the oldest entries into ``pending-evicted/`` (an archive, not a delete).
480
+ # Note what that bound is NOT: ``_evict_to_fit`` is called only from
481
+ # ``enqueue`` (lib/pending.py:933), so it fires on new writes, never on
482
+ # drain — and it "bounds" the queue by shedding memory unsaved, which is
483
+ # the very outcome this gate exists to avoid. It is a backstop, not the
484
+ # answer.
485
+ #
486
+ # PROGRESS is bounded by ``_drain_order`` / ``_over_budget``. An entry that
487
+ # can never be persisted no longer terminates by being destroyed; it
488
+ # terminates by being DEMOTED — sorted behind every entry still inside its
489
+ # budget, and stripped of its vote in the stall guard. ``attempt_count``
490
+ # only ever climbs, so the crossing happens once and is permanent. That is
491
+ # the real terminal condition, and it is what keeps a poisoned entry from
492
+ # starving the queue behind it. Without it, three such entries ended every
493
+ # drain at zero progress, permanently (tests/test_pending_wedge.py).
494
+ #
495
+ # The attempt counter itself was never a bound on either, and ``switchroom
496
+ # doctor`` still surfaces queue depth so the operator sees a growing tail.
497
+ if attempts >= MAX_ATTEMPTS and is_permanent_failure(e):
498
+ marker = mark_dead(path, entry)
499
+ summary["dead"] += 1
500
+ print(
501
+ f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
502
+ f"attempts on a permanent failure, marking dead at {marker} "
503
+ f"(last error: {err_class}: {e})",
504
+ file=sys.stderr,
505
+ )
506
+ else:
507
+ update_attempt(path, entry, e)
508
+ summary["retried"] += 1
509
+ debug_log(
510
+ config,
511
+ # ``attempts`` can now exceed MAX_ATTEMPTS — a transient failure
512
+ # keeps the entry queued past the budget instead of retiring it —
513
+ # so print the budget as a threshold, not as a fraction that would
514
+ # render the nonsense "retry 7/5".
515
+ f"drain_pending: retry {attempts} (budget {MAX_ATTEMPTS}) failed "
516
+ f"for {path} ({err_class}: {e})",
517
+ )
518
+ return err_class
519
+
520
+
521
+ def _new_summary() -> dict:
522
+ return {
523
+ "drained": 0,
524
+ "retried": 0,
525
+ "dead": 0,
526
+ "reconciled": 0,
527
+ "unknown": 0,
528
+ # Presence WAS established, but the entry could not be moved into
529
+ # the archive (ENOSPC, EACCES, read-only mount). It is still queued
530
+ # — archiving never falls back to a delete (#3599 review R3-M1) —
531
+ # so it must not be counted as drained/reconciled, which would
532
+ # report a retire that did not happen.
533
+ "archive_failed": 0,
534
+ "stalled": False,
535
+ "budget_exceeded": False,
536
+ }
537
+
538
+
539
+ def drain_backlog(config: dict | None = None, **kw) -> dict:
540
+ """Two-phase backlog replay, off the SessionStart budget entirely.
541
+
542
+ See the module docstring. Summary shape is ``drain()``'s plus
543
+ ``reconciled`` (already durable — no POST issued) and ``unknown``
544
+ (presence could not be established; left queued).
545
+ """
546
+ return drain(config, backlog=True, **kw)
547
+
548
+
549
+ def drain(
550
+ config: dict | None = None,
551
+ backlog: bool = False,
552
+ phase: str = "both",
553
+ dry_run: bool = False,
554
+ ) -> dict:
101
555
  """Walk the pending-retains directory and retry each entry.
102
556
 
557
+ ``backlog=False`` (default) is the bounded in-hook drain.
558
+ ``backlog=True`` is the operator backlog replay — see ``drain_backlog()``.
559
+
103
560
  Returns a summary dict::
104
561
 
105
- {"drained": int, # successful retries (entries deleted)
562
+ {"drained": int, # successful retries (entries archived)
106
563
  "retried": int, # failures kept for next session
107
564
  "dead": int, # entries promoted to .dead this run
565
+ "reconciled": int,# already durable, archived without a POST
566
+ "unknown": int, # presence unknown, left queued
567
+ "archive_failed": int, # durable, but the archive was unwritable
568
+ # so the entry is STILL QUEUED
108
569
  "stalled": bool, # stall guard tripped
109
570
  "budget_exceeded": bool}
110
571
  """
111
572
  config = config or load_config()
573
+ if backlog:
574
+ return _drain_backlog_impl(config, phase=phase, dry_run=dry_run)
112
575
  timeout = _per_entry_timeout()
113
576
  budget = _budget_seconds()
114
577
  started = time.monotonic()
115
578
 
116
- summary = {
117
- "drained": 0,
118
- "retried": 0,
119
- "dead": 0,
120
- "stalled": False,
121
- "budget_exceeded": False,
122
- }
579
+ summary = _new_summary()
123
580
 
124
- entries = iter_entries()
581
+ # Budget-exhausted entries go last so they cannot starve the healthy ones
582
+ # behind them — see ``_drain_order``. Matters even more here than in the
583
+ # backlog drain: the in-hook budget is ~4s, so a single entry at the head
584
+ # that always burns its clamped timeout consumes the entire run.
585
+ entries = _drain_order(iter_entries())
125
586
  if not entries:
126
587
  debug_log(config, "drain_pending: queue empty")
127
588
  return summary
@@ -138,42 +599,69 @@ def drain(config: dict | None = None) -> dict:
138
599
  debug_log(config, "drain_pending: total budget exceeded, stopping")
139
600
  break
140
601
 
141
- # Clamp the per-entry HTTP timeout to the budget still remaining
142
- # (#1094 item 2). Without this, a single slow entry using the full
143
- # HINDSIGHT_DRAIN_TIMEOUT (default 5s) overshoots the total budget
144
- # (default 4s). Floor at 1s so we still give a near-exhausted
145
- # budget one bounded shot rather than a 0s (instant-fail) request.
146
- remaining = budget - elapsed
147
- effective_timeout = max(1, min(timeout, int(remaining) if remaining >= 1 else 1))
602
+ # RECONCILE BEFORE RETRY — this is the fix for the re-post loop in
603
+ # the path that actually runs on every boot, not just in --backlog.
604
+ # 70.4% of the measured fleet backlog already existed as documents;
605
+ # re-POSTing those inside the hook is a guaranteed client timeout
606
+ # (the clamp is far below a 30-90s synchronous retain), 30-90s of
607
+ # wasted server-side extraction, and one more step toward .dead for
608
+ # a memory that was never actually lost.
609
+ #
610
+ # A GET is sub-second and it REPLACES that doomed POST, so this
611
+ # strictly reduces both hook latency and upstream load. Only a
612
+ # definite True retires the entry: False and None (unknown) fall
613
+ # through to the retry, because guessing "present" would retire the
614
+ # last on-disk copy of a turn.
615
+ #
616
+ # GATED ON THE ID SHAPE. A presence GET only proves *this* entry's
617
+ # content was committed when the document_id is content-derived
618
+ # (post-#3244 `retain.slice_document_id`). A pre-#3244 entry carries
619
+ # a bare session id, for which the bank answers 200 after ANY
620
+ # successful retain in that session — reconciling on that deletes a
621
+ # turn that was never committed. Such entries skip the free pass and
622
+ # go straight to the POST, which is the only thing that can make
623
+ # them durable.
624
+ if _reconcilable_on_presence(entry):
625
+ if _document_state(entry, timeout=_clamp(timeout, budget, started)) is True:
626
+ if archive_reconciled(path):
627
+ summary["reconciled"] += 1
628
+ else:
629
+ # Archive unwritable: the entry is STILL QUEUED (it is
630
+ # never deleted), so calling it reconciled would be a lie.
631
+ summary["archive_failed"] += 1
632
+ consecutive_failures = 0
633
+ last_error_class = None
634
+ continue
148
635
 
149
636
  try:
150
- _retry_one(entry, timeout=effective_timeout)
637
+ # Re-clamp: the GET above spent part of the budget, and the same
638
+ # allowance must not be handed out twice (#3599 review F2).
639
+ _retry_one(entry, timeout=_clamp(timeout, budget, started))
151
640
  except Exception as e:
152
- err_class = type(e).__name__
153
- if err_class == last_error_class:
154
- consecutive_failures += 1
155
- else:
156
- consecutive_failures = 1
157
- last_error_class = err_class
158
-
159
- attempts = int(entry.get("attempt_count", 1))
160
- if attempts >= MAX_ATTEMPTS:
161
- marker = mark_dead(path, entry)
162
- summary["dead"] += 1
163
- print(
164
- f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
165
- f"attempts, marking dead at {marker} (last error: {err_class}: {e})",
166
- file=sys.stderr,
167
- )
168
- else:
169
- update_attempt(path, entry, e)
170
- summary["retried"] += 1
171
- debug_log(
172
- config,
173
- f"drain_pending: retry {attempts}/{MAX_ATTEMPTS} failed for {path} ({err_class}: {e})",
174
- )
175
-
176
- if consecutive_failures >= STALL_THRESHOLD:
641
+ # Read the budget state BEFORE _record_failure: update_attempt
642
+ # mutates `entry["attempt_count"]` in place (lib/pending.py:1020),
643
+ # so asking afterwards would count the entry that just CROSSED the
644
+ # budget on this very failure as an abstainer, silently weakening
645
+ # the stall guard for ordinary entries.
646
+ abstains = _over_budget(entry)
647
+ err_class = _record_failure(config, path, entry, e, summary)
648
+ # STALL GUARD ABSTENTION — see ``_over_budget``. The guard exists
649
+ # to detect a broken UPSTREAM and stop hammering it. A chronically
650
+ # failing entry that has already burned its attempt budget is
651
+ # evidence about that ENTRY, not about the upstream, and letting it
652
+ # vote is what wedges the queue: it never retires, so it fails
653
+ # identically on every run, and three of them end every run before
654
+ # the healthy entries behind them are ever reached. It abstains —
655
+ # neither incrementing the counter nor resetting it, so a genuine
656
+ # upstream outage is still caught by the fresh entries around it.
657
+ if not abstains:
658
+ if err_class == last_error_class:
659
+ consecutive_failures += 1
660
+ else:
661
+ consecutive_failures = 1
662
+ last_error_class = err_class
663
+
664
+ if not abstains and consecutive_failures >= STALL_THRESHOLD:
177
665
  summary["stalled"] = True
178
666
  print(
179
667
  f"[Hindsight] drain_pending: {consecutive_failures} consecutive "
@@ -184,23 +672,315 @@ def drain(config: dict | None = None) -> dict:
184
672
  break
185
673
  continue
186
674
 
187
- # Success — delete the entry.
188
- delete_entry(path)
189
- summary["drained"] += 1
675
+ # Success — retire the entry. ARCHIVED, not deleted.
676
+ #
677
+ # The evidence here is the POST's own 200 and nothing else: a 5s
678
+ # hook budget has no room for the confirming re-GET the backlog
679
+ # drain issues. Not a BARE 200 though (#3599 review R4-B3 corrects
680
+ # this comment, which used to say so): ``_retry_one`` posts
681
+ # ``async_processing=False``, so the 200 is a commit-before-ack
682
+ # (#3244 §1.1) — the daemon's statement that it durably committed,
683
+ # not merely that it received. Weaker than an independent read,
684
+ # much stronger than an async ack.
685
+ #
686
+ # The residual risk is a daemon that does not honour ``async=false``.
687
+ # Archiving rather than deleting keeps a recoverable copy for that
688
+ # case — bounded by ``pending-reconciled/``'s caps, so recoverable
689
+ # has a horizon; ``pending._trim_dir`` documents exactly what that
690
+ # horizon costs.
691
+ if archive_reconciled(path):
692
+ summary["drained"] += 1
693
+ else:
694
+ summary["archive_failed"] += 1
190
695
  consecutive_failures = 0
191
696
  last_error_class = None
192
697
 
193
698
  return summary
194
699
 
195
700
 
196
- def main() -> int:
701
+ def _blog(msg: str) -> None:
702
+ print(f"[Hindsight] drain_pending(backlog): {msg}", file=sys.stderr)
703
+
704
+
705
+ def _reconcile_phase(config: dict, summary: dict, dry_run: bool) -> None:
706
+ """PHASE 1 — free pass: drop entries whose document already exists.
707
+
708
+ This is the phase that makes backlog replay affordable. 70.4% of a
709
+ measured 5,751-entry fleet backlog was already durable; POSTing those
710
+ is duplicated LLM extraction for zero new memory. A GET costs nothing
711
+ on the model pool.
712
+
713
+ Only a definite ``True`` retires an entry, and only for a post-#3244
714
+ content-derived ``document_id`` (see ``_reconcilable_on_presence``) —
715
+ a bare session id's 200 says nothing about *this* entry's content.
716
+ ``False`` leaves it for phase 2; ``None`` (unknown) leaves it queued
717
+ and is counted — never guessed. Retiring MOVES the entry into
718
+ ``pending-reconciled/``; it is never ``os.remove``d.
719
+ """
720
+ entries = iter_entries()
721
+ if not entries:
722
+ return
723
+ _blog(f"phase 1 reconcile: checking {len(entries)} entries (no LLM cost)")
724
+ skipped = 0
725
+ for path, entry in entries:
726
+ if not _reconcilable_on_presence(entry):
727
+ skipped += 1
728
+ continue
729
+ state = _document_state(entry)
730
+ if state is True:
731
+ if dry_run or archive_reconciled(path):
732
+ summary["reconciled"] += 1
733
+ else:
734
+ summary["archive_failed"] += 1
735
+ elif state is None:
736
+ summary["unknown"] += 1
737
+ if skipped:
738
+ _blog(
739
+ f"phase 1: {skipped} entries have a pre-#3244 (bare session) "
740
+ f"document_id — a presence GET cannot prove THEIR content was "
741
+ f"committed, so they go to phase 2 rather than the free pass"
742
+ )
743
+ _blog(
744
+ f"phase 1 done: {summary['reconciled']} already durable "
745
+ f"(no POST issued), {summary['unknown']} unknown (left queued)"
746
+ + (
747
+ f", {summary['archive_failed']} confirmed durable but NOT retired "
748
+ f"(archive unwritable — still queued)"
749
+ if summary["archive_failed"]
750
+ else ""
751
+ )
752
+ )
753
+
754
+
755
+ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
756
+ """Block while the p95 probe says upstream is slow. False ⇒ give up.
757
+
758
+ The wait is bounded by the SAME wall-clock budget as the drain itself.
759
+ An unbounded ``while True: sleep(120)`` would let a persistently
760
+ degraded upstream block ``drain_backlog()`` indefinitely, long past the
761
+ budget the operator set — the one guarantee this mode makes about how
762
+ long it will run.
763
+ """
764
+ if backoff_ms <= 0:
765
+ return True
766
+ while True:
767
+ p95 = _p95_probe_ms()
768
+ if p95 < 0 or p95 <= backoff_ms:
769
+ return True
770
+ if time.monotonic() - started + 120 > budget:
771
+ _blog(
772
+ f"upstream still slow (p95={p95}ms > {backoff_ms}ms) and the "
773
+ f"budget is exhausted — stopping rather than waiting past it. "
774
+ f"Remaining entries stay queued; re-run when upstream recovers."
775
+ )
776
+ return False
777
+ _blog(
778
+ f"BACKOFF: upstream p95={p95}ms > {backoff_ms}ms — pausing 120s so "
779
+ f"the replay never becomes the cause of a latency alarm"
780
+ )
781
+ time.sleep(120)
782
+
783
+
784
+ def _drain_backlog_impl(
785
+ config: dict, phase: str = "both", dry_run: bool = False
786
+ ) -> dict:
787
+ """Concurrent-capable, long-budget, two-phase backlog replay."""
788
+ summary = _new_summary()
789
+
790
+ if phase in ("reconcile", "both"):
791
+ _reconcile_phase(config, summary, dry_run)
792
+ if phase == "reconcile":
793
+ return summary
794
+
795
+ timeout = _backlog_timeout()
796
+ budget = _backlog_budget_seconds()
797
+ width = _backlog_concurrency()
798
+ sleep_s = _backlog_sleep_seconds()
799
+ backoff_ms = _p95_backoff_ms()
800
+ started = time.monotonic()
801
+
802
+ # See ``_drain_order``: without this, three budget-exhausted entries at the
803
+ # head trip the stall guard on every run forever and the drain never makes
804
+ # progress again.
805
+ entries = _drain_order(iter_entries())
806
+ if not entries:
807
+ _blog("phase 2: nothing left to retain")
808
+ return summary
809
+
810
+ _blog(
811
+ f"phase 2 drain: {len(entries)} entries genuinely need retaining "
812
+ f"(concurrency={width} timeout={timeout}s budget={budget:.0f}s "
813
+ f"sleep={sleep_s}s p95_probe="
814
+ f"{'on' if os.environ.get('HINDSIGHT_DRAIN_P95_CMD') else 'unset'})"
815
+ )
816
+ if dry_run:
817
+ _blog("dry run — no retains issued")
818
+ return summary
819
+
820
+ consecutive_failures = 0
821
+ last_error_class: str | None = None
822
+
823
+ # BUDGET GRANULARITY: checked between waves, not mid-wave, so a run can
824
+ # overshoot `budget` by at most one wave — up to HINDSIGHT_DRAIN_BACKLOG_
825
+ # TIMEOUT (280s default, `_backlog_timeout`) plus the confirming GETs.
826
+ # That is deliberate:
827
+ # abandoning an in-flight wave would leave entries whose POST the server
828
+ # is still committing, and the whole point of commit-before-delete is not
829
+ # to guess about those. Unlike the in-hook drain (whose overshoot is
830
+ # clamped to ~1s because a SessionStart hook has a hard deadline), this
831
+ # mode has no deadline to miss — so a bounded overshoot is the cheaper
832
+ # trade.
833
+ for start in range(0, len(entries), width):
834
+ if time.monotonic() - started > budget:
835
+ summary["budget_exceeded"] = True
836
+ _blog("budget exhausted, stopping. Remaining entries stay queued — re-run to continue.")
837
+ break
838
+
839
+ if not _wait_for_upstream(backoff_ms, started, budget):
840
+ summary["budget_exceeded"] = True
841
+ break
842
+
843
+ wave = entries[start : start + width]
844
+ # Results are collected in SUBMISSION order (not completion order)
845
+ # so the stall guard sees a deterministic sequence and behaves
846
+ # identically to the sequential drain.
847
+ with ThreadPoolExecutor(max_workers=width) as pool:
848
+ futures = [
849
+ pool.submit(_retry_one, entry, timeout) for _path, entry in wave
850
+ ]
851
+ outcomes = []
852
+ for fut in futures:
853
+ try:
854
+ fut.result()
855
+ outcomes.append(None)
856
+ except Exception as e: # noqa: BLE001 — per-entry policy below
857
+ outcomes.append(e)
858
+
859
+ for (path, entry), err in zip(wave, outcomes):
860
+ if err is None:
861
+ # COMMIT-BEFORE-RETIRE. A 200 is an ack, not proof the
862
+ # document is durable (switchroom #3244), so re-GET before
863
+ # retiring the last on-disk copy. Anything other than a
864
+ # definite True keeps the entry. This confirming GET is
865
+ # meaningful even for a pre-#3244 bare-session id: unlike
866
+ # the free reconcile pass, it is corroborated by the
867
+ # synchronous POST of THIS entry's content that just
868
+ # returned 200. And the entry is archived, not deleted.
869
+ if _document_state(entry) is True:
870
+ if archive_reconciled(path):
871
+ summary["drained"] += 1
872
+ else:
873
+ summary["archive_failed"] += 1
874
+ else:
875
+ summary["unknown"] += 1
876
+ _blog(
877
+ f"posted but document not confirmed, keeping entry: "
878
+ f"{os.path.basename(path)}"
879
+ )
880
+ consecutive_failures = 0
881
+ last_error_class = None
882
+ continue
883
+
884
+ # STALL GUARD, evaluated BEFORE the failure is recorded for the
885
+ # rest of the wave. Recording first would let a wave of `width`
886
+ # identical timeouts bump attempt_count on all of them before
887
+ # the loop breaks — aging entries toward .dead FASTER than the
888
+ # sequential drain, the opposite of the point. Counting first
889
+ # and breaking immediately after the tripping entry makes the
890
+ # two paths bump exactly the same number of entries.
891
+ err_class = type(err).__name__
892
+ # STALL GUARD ABSTENTION for entries past their attempt budget —
893
+ # see ``_over_budget`` and the matching branch in the sequential
894
+ # drain. Evaluated here, before ``_record_failure``, which is both
895
+ # where the sequential drain evaluates it and necessary anyway
896
+ # because ``update_attempt`` mutates ``attempt_count`` in place.
897
+ abstains = _over_budget(entry)
898
+ if not abstains:
899
+ if err_class == last_error_class:
900
+ consecutive_failures += 1
901
+ else:
902
+ consecutive_failures = 1
903
+ last_error_class = err_class
904
+
905
+ _record_failure(config, path, entry, err, summary)
906
+
907
+ if not abstains and consecutive_failures >= STALL_THRESHOLD:
908
+ summary["stalled"] = True
909
+ _blog(
910
+ f"{consecutive_failures} consecutive failures with "
911
+ f"{err_class}, stalling. Fix the upstream, then re-run. "
912
+ f"Remaining entries stay queued."
913
+ )
914
+ break
915
+
916
+ if summary["stalled"]:
917
+ break
918
+ if sleep_s:
919
+ time.sleep(sleep_s)
920
+
921
+ return summary
922
+
923
+
924
+ def _parse_args(argv: list[str] | None):
925
+ """Real argument parsing.
926
+
927
+ Was a bare ``"--backlog" in argv`` membership test, which silently ran
928
+ the 4-second in-hook drain on a typo like ``--backlogg`` while the
929
+ operator believed they had started a backlog replay.
930
+ """
931
+ ap = argparse.ArgumentParser(
932
+ prog="drain_pending.py",
933
+ description="Drain the hindsight pending-retains queue.",
934
+ )
935
+ ap.add_argument(
936
+ "--backlog",
937
+ action="store_true",
938
+ help="two-phase backlog replay, off the SessionStart budget",
939
+ )
940
+ ap.add_argument(
941
+ "--phase",
942
+ choices=["reconcile", "drain", "both"],
943
+ default="both",
944
+ help="with --backlog: run only the free reconcile pass, only the "
945
+ "retain pass, or both (default)",
946
+ )
947
+ ap.add_argument(
948
+ "--dry-run",
949
+ action="store_true",
950
+ help="with --backlog: report what would happen, issue no writes",
951
+ )
952
+ return ap.parse_args(argv)
953
+
954
+
955
+ def main(argv: list[str] | None = None) -> int:
956
+ args = _parse_args(sys.argv[1:] if argv is None else argv)
957
+ if (args.phase != "both" or args.dry_run) and not args.backlog:
958
+ print(
959
+ "[Hindsight] drain_pending: --phase/--dry-run require --backlog",
960
+ file=sys.stderr,
961
+ )
962
+ return 2
197
963
  config = load_config()
198
- summary = drain(config)
199
- if summary["drained"] or summary["retried"] or summary["dead"]:
964
+ summary = drain(
965
+ config, backlog=args.backlog, phase=args.phase, dry_run=args.dry_run
966
+ )
967
+ if any(
968
+ summary[k]
969
+ for k in (
970
+ "drained",
971
+ "retried",
972
+ "dead",
973
+ "reconciled",
974
+ "unknown",
975
+ "archive_failed",
976
+ )
977
+ ):
200
978
  print(
201
- f"[Hindsight] drain_pending: "
202
- f"drained={summary['drained']} retried={summary['retried']} "
203
- f"dead={summary['dead']} "
979
+ f"[Hindsight] drain_pending{'(backlog)' if args.backlog else ''}: "
980
+ f"drained={summary['drained']} reconciled={summary['reconciled']} "
981
+ f"retried={summary['retried']} dead={summary['dead']} "
982
+ f"unknown={summary['unknown']} "
983
+ f"archive_failed={summary['archive_failed']} "
204
984
  f"stalled={summary['stalled']} budget_exceeded={summary['budget_exceeded']}",
205
985
  file=sys.stderr,
206
986
  )