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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- 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
|
|
7
|
-
|
|
8
|
-
``
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 —
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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(
|
|
199
|
-
|
|
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']}
|
|
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
|
)
|