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
|
@@ -5,9 +5,15 @@ memory is the just-closed transcript — and the agent thinks it was
|
|
|
5
5
|
persisted. To prevent silent data loss (#1071), session_end.py
|
|
6
6
|
serializes the *exact retain payload* it would have POSTed into
|
|
7
7
|
``~/.hindsight/pending-retains/<unix-ms>-<short-uuid>.json``. The next
|
|
8
|
-
SessionStart drains the directory: oldest first, success
|
|
9
|
-
|
|
10
|
-
entry for
|
|
8
|
+
SessionStart drains the directory: oldest first, success RETIRES the
|
|
9
|
+
entry into the bounded ``pending-reconciled/`` archive (the drain never
|
|
10
|
+
deletes — see "Retiring an entry" below for that promise and its one
|
|
11
|
+
bound, a full disk), failure bumps an attempt
|
|
12
|
+
counter (up to MAX_ATTEMPTS) and leaves the entry for the run after
|
|
13
|
+
that. Exhausting MAX_ATTEMPTS retires the entry to ``.dead`` only when
|
|
14
|
+
the failure is PERMANENT (``is_permanent_failure`` — a 4xx a re-POST
|
|
15
|
+
cannot fix). A transient failure never retires the memory, however many
|
|
16
|
+
attempts it has burned.
|
|
11
17
|
|
|
12
18
|
Layout
|
|
13
19
|
------
|
|
@@ -44,27 +50,242 @@ Each entry is a JSON file ``<unix-ms>-<short-uuid>.json`` containing::
|
|
|
44
50
|
The file is written via ``write tmp + rename`` so concurrent agents
|
|
45
51
|
sharing ``$HOME`` (legacy installs) never observe a half-written entry.
|
|
46
52
|
|
|
47
|
-
Bounded directory
|
|
53
|
+
Bounded directory (switchroom #3596 — evict oldest, never refuse newest)
|
|
54
|
+
-----------------------------------------------------------------------
|
|
55
|
+
``MAX_ENTRIES`` and ``MAX_BYTES`` bound the queue, both env-overridable.
|
|
56
|
+
When either binds, ``enqueue()`` evicts the OLDEST entries into a bounded
|
|
57
|
+
archive (``pending-evicted/``) to make room, rather than refusing the
|
|
58
|
+
incoming — newest, most likely to matter — memory.
|
|
59
|
+
|
|
60
|
+
This is a PORT of the design validated out of band on the live fleet and
|
|
61
|
+
already in force there via env in ``switchroom.yaml`` (the file patches
|
|
62
|
+
themselves do not survive ``switchroom apply``, which is precisely why
|
|
63
|
+
this belongs in-repo). Refusing the newest entry was the wrong end to
|
|
64
|
+
shed from; the cap size was never the real bug.
|
|
65
|
+
|
|
66
|
+
Sizing rationale (measured 2026-07-25 on this fleet, carried over from
|
|
67
|
+
the validated patch): 5,751 queued entries occupied 432 MB — ~75 KB mean,
|
|
68
|
+
but content spans 499 B .. 744 KB (p50 21 KB), so a count cap alone
|
|
69
|
+
bounds disk only to within ~1500x; hence ``MAX_BYTES``. Retain fires
|
|
70
|
+
every 3rd turn and the busiest agent queues ~100 entries/day, so 2000
|
|
71
|
+
entries is ~3 weeks of total upstream outage headroom.
|
|
72
|
+
|
|
73
|
+
Deduplication
|
|
74
|
+
-------------
|
|
75
|
+
``reconcile_tail`` re-enqueues the same transcript slice on every boot
|
|
76
|
+
until its watermark is confirmed, so a stalled upstream multiplied one
|
|
77
|
+
memory into dozens of identical files. (The measured 63% / 84.7 MB
|
|
78
|
+
collapse on this fleet was achieved by ``dedupe_queue.py``, a one-shot
|
|
79
|
+
out-of-band sweep keyed on ``(bank, document_id, sha256(content))`` with
|
|
80
|
+
no size index. What follows is the *preventive* guard that stops the
|
|
81
|
+
duplicates accumulating in the first place — a different mechanism, and
|
|
82
|
+
it does not get the credit for that number.)
|
|
83
|
+
|
|
84
|
+
Same bank + same content-derived ``document_id`` + same content is the
|
|
85
|
+
same memory — the daemon would upsert them onto one document anyway — so
|
|
86
|
+
``enqueue()`` returns the existing path instead of writing a copy.
|
|
87
|
+
|
|
88
|
+
The dedupe key is carried IN THE FILENAME
|
|
89
|
+
(``<unix-ms>-<key>-<uuid>.json``), so a lookup is a prefix match over the
|
|
90
|
+
directory listing with ZERO file reads. It deliberately is not inferred
|
|
91
|
+
from the serialized bytes: an entry's JSON also carries ``failed_at``,
|
|
92
|
+
``error_message``, ``attempt_count`` and — after any drain attempt —
|
|
93
|
+
``last_attempt_at``, none of which are part of the memory's identity. An
|
|
94
|
+
earlier revision pre-filtered candidates on ``os.path.getsize()``, which
|
|
95
|
+
made the guard a no-op in exactly the scenario it was written for: the
|
|
96
|
+
queued copy is ALWAYS post-``update_attempt`` by the time
|
|
97
|
+
``reconcile_tail`` re-enqueues (the SessionStart drain attempts every
|
|
98
|
+
entry on every boot), so its size had already drifted, and a
|
|
99
|
+
one-character difference in the error string defeated it too.
|
|
100
|
+
|
|
101
|
+
Retiring an entry — archive, never delete
|
|
102
|
+
-----------------------------------------
|
|
103
|
+
The drain retires an entry through ``archive_reconciled()``, which MOVES it
|
|
104
|
+
into a bounded ``pending-reconciled/`` sibling. It never ``os.remove``s one.
|
|
105
|
+
Every retire decision on that path rests on an HTTP 200 — a presence GET or a
|
|
106
|
+
synchronous retain ack — and a 200 is evidence, not proof (#3244); an
|
|
107
|
+
irreversible delete on evidence is how the last on-disk copy of a turn
|
|
108
|
+
disappears. ``is_content_derived_document_id()`` is the second half of that
|
|
109
|
+
guard: a *presence-only* reconcile is sound only for post-#3244
|
|
110
|
+
content-derived ids, because a pre-#3244 bare session id answers 200 for any
|
|
111
|
+
retain in that session.
|
|
112
|
+
|
|
113
|
+
The heading says "never delete" about the DRAIN path, and that is exact. The
|
|
114
|
+
ENQUEUE path is different and the difference is the bound on this promise
|
|
115
|
+
(#3599 review R4-M1): if ``archive_reconciled`` cannot write (ENOSPC), it
|
|
116
|
+
keeps the entry queued; entries then accumulate until the queue hits its cap;
|
|
117
|
+
``_evict_to_fit`` fires; its own archive move fails for the same reason; and
|
|
118
|
+
it removes the OLDEST live entries to keep accepting the newest. So under a
|
|
119
|
+
sustained full disk "keep it queued" degrades to "keep the newest, drop the
|
|
120
|
+
oldest". Bounded, deliberate and loud — stderr, a ``+archive-failed`` line in
|
|
121
|
+
``pending-evictions.log``, and a ``switchroom doctor`` row that fails on any
|
|
122
|
+
eviction in the window — but it is loss, so no document here may claim
|
|
123
|
+
otherwise. Full statement in ``_evict_to_fit``'s docstring.
|
|
124
|
+
|
|
125
|
+
Oversized entries
|
|
48
126
|
-----------------
|
|
49
|
-
``
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
An entry larger than ``MAX_BYTES`` can never fit, and the eviction loop
|
|
128
|
+
would otherwise evict the ENTIRE queue trying to make room for it and
|
|
129
|
+
then write it anyway — trading every queued memory for one. ``enqueue()``
|
|
130
|
+
refuses that single entry instead (recorded as a residual drop). Not
|
|
131
|
+
reachable at the 256 MB default, but ``HINDSIGHT_PENDING_MAX_BYTES`` is
|
|
132
|
+
operator-tunable.
|
|
133
|
+
|
|
134
|
+
Residual drops
|
|
135
|
+
--------------
|
|
136
|
+
With eviction in place a *drop* is now rare: it means the entry could not
|
|
137
|
+
be written at all (disk full, permissions) even after making room. That
|
|
138
|
+
residual case still must not be silent, so it is recorded in the
|
|
139
|
+
``pending-drops.json`` ledger — a sibling of the queue dir, like the
|
|
140
|
+
eviction log, so it can never be mistaken for a queue entry.
|
|
141
|
+
|
|
142
|
+
Bounded entries
|
|
143
|
+
---------------
|
|
144
|
+
``MAX_BYTES`` is about the QUEUE's capacity. A second, much smaller bound
|
|
145
|
+
is about whether an entry can ever be DRAINED: ``enqueue()`` splits an
|
|
146
|
+
oversized payload into one entry PER PART (``lib/retain_split``) instead
|
|
147
|
+
of writing a single giant entry. This is load-bearing, not tidiness. The
|
|
148
|
+
daemon runs one sequential extraction LLM call per ``retain_chunk_size``
|
|
149
|
+
chars, so an entry above the derived content bound cannot complete inside
|
|
150
|
+
ANY client deadline — including the out-of-hook backlog deadline that
|
|
151
|
+
``drain_pending._backlog_timeout()`` now takes from the same derivation.
|
|
152
|
+
Such an entry fails every drain and burns its ``MAX_ATTEMPTS``: the
|
|
153
|
+
mechanism that stranded 154 of the 629 entries in the 2026-07-25 fleet
|
|
154
|
+
backlog. (It is no longer renamed ``.dead`` for that — a client-side
|
|
155
|
+
timeout is not a permanent failure — but it still never drains until it
|
|
156
|
+
is split, so splitting remains the fix. Such an entry is therefore
|
|
157
|
+
IMMORTAL, and ``enqueue`` is the only caller of the splitter, so one
|
|
158
|
+
queued before #3610 is never split in place. That is precisely the shape
|
|
159
|
+
``drain_pending._drain_order`` / ``_over_budget`` exist to contain: it is
|
|
160
|
+
demoted behind every entry still inside its attempt budget and abstains
|
|
161
|
+
from the stall guard, so it can delay only itself rather than wedging the
|
|
162
|
+
drain.) Note this is orthogonal to the re-post loop
|
|
163
|
+
#3599 fixed — a presence GET retires an oversized entry for free when the
|
|
164
|
+
document IS already durable; splitting is what makes the entry drainable
|
|
165
|
+
when it is NOT. Part document_ids are deterministic, so a part already
|
|
166
|
+
committed by the failed POST is upserted on drain, not duplicated.
|
|
53
167
|
"""
|
|
54
168
|
|
|
55
169
|
from __future__ import annotations
|
|
56
170
|
|
|
171
|
+
import hashlib
|
|
57
172
|
import json
|
|
58
173
|
import os
|
|
174
|
+
import re
|
|
175
|
+
import shutil
|
|
176
|
+
import sys
|
|
59
177
|
import time
|
|
60
178
|
import uuid
|
|
61
179
|
from typing import Optional
|
|
62
180
|
|
|
181
|
+
from .retain_split import part_document_id, part_metadata, split_retain_content
|
|
182
|
+
|
|
63
183
|
|
|
64
184
|
SCHEMA = 1
|
|
65
|
-
|
|
185
|
+
|
|
186
|
+
# Queue bounds. BOTH are enforced; whichever binds first triggers eviction.
|
|
187
|
+
# Env-driven so the operator can retune without a plugin redeploy — and so
|
|
188
|
+
# the values already set fleet-wide in switchroom.yaml keep working.
|
|
189
|
+
MAX_ENTRIES = int(os.environ.get("HINDSIGHT_PENDING_MAX_ENTRIES") or 2000)
|
|
190
|
+
MAX_BYTES = int(os.environ.get("HINDSIGHT_PENDING_MAX_BYTES") or (256 * 1024 * 1024))
|
|
191
|
+
# The archive is bounded too, else eviction just relocates the disk problem.
|
|
192
|
+
ARCHIVE_MAX_ENTRIES = int(
|
|
193
|
+
os.environ.get("HINDSIGHT_PENDING_ARCHIVE_MAX_ENTRIES") or 500
|
|
194
|
+
)
|
|
195
|
+
ARCHIVE_MAX_BYTES = int(
|
|
196
|
+
os.environ.get("HINDSIGHT_PENDING_ARCHIVE_MAX_BYTES") or (64 * 1024 * 1024)
|
|
197
|
+
)
|
|
198
|
+
# The reconciled archive (see ``archive_reconciled``) is bounded on exactly the
|
|
199
|
+
# same terms as ``pending-evicted/`` — an archive that grows without limit is
|
|
200
|
+
# just a slower disk problem.
|
|
201
|
+
RECONCILED_MAX_ENTRIES = int(
|
|
202
|
+
os.environ.get("HINDSIGHT_PENDING_RECONCILED_MAX_ENTRIES") or 500
|
|
203
|
+
)
|
|
204
|
+
RECONCILED_MAX_BYTES = int(
|
|
205
|
+
os.environ.get("HINDSIGHT_PENDING_RECONCILED_MAX_BYTES") or (64 * 1024 * 1024)
|
|
206
|
+
)
|
|
66
207
|
MAX_ATTEMPTS = 5
|
|
67
208
|
|
|
209
|
+
#: Residual-drop ledger. A SIBLING of the queue directory (like the
|
|
210
|
+
#: eviction log), never inside it — so it can never be listed as an entry,
|
|
211
|
+
#: drained, or counted against the caps.
|
|
212
|
+
DROPS_FILE = "pending-drops.json"
|
|
213
|
+
|
|
214
|
+
#: Cap on the stored ``error_message`` / ``last_error_message``. Upstream
|
|
215
|
+
#: errors can carry a full HTTP body; an unbounded copy per entry inflates
|
|
216
|
+
#: the queue against MAX_BYTES for no diagnostic gain.
|
|
217
|
+
MAX_ERROR_MESSAGE_CHARS = 500
|
|
218
|
+
|
|
219
|
+
#: The eviction ledger is append-only; these bound it so it cannot grow
|
|
220
|
+
#: without limit on a queue that evicts steadily.
|
|
221
|
+
EVICTIONS_LOG_MAX_BYTES = 1024 * 1024
|
|
222
|
+
EVICTIONS_LOG_KEEP_LINES = 2000
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
#: Post-#3244 ``document_id`` shape (``retain.slice_document_id``):
|
|
226
|
+
#: ``{session_id}-r{start_uuid}-{end_uuid}``, or the legacy-transcript fallback
|
|
227
|
+
#: ``{session_id}-r{sha256[:32]}``. ``subagent_retain.py`` uses the same recipe
|
|
228
|
+
#: over a ``{session}-sub-{agent}`` composite key, so it matches too.
|
|
229
|
+
#: A split retain appends ``-p{i}of{n}`` (``retain_split.part_document_id``).
|
|
230
|
+
#: That suffix is a pure function of the SAME content the core id derives
|
|
231
|
+
#: from — the split is deterministic in (content, bound) — so a part id is
|
|
232
|
+
#: content-derived exactly when its core is, and must be reconcilable on
|
|
233
|
+
#: presence for the same reason. Without this the split entries introduced by
|
|
234
|
+
#: #3610 would be the ONLY entries excluded from #3599's free phase-1
|
|
235
|
+
#: reconcile: every one would take a full re-POST forever, which is the
|
|
236
|
+
#: re-post loop #3599 exists to kill, aimed at the largest entries in the
|
|
237
|
+
#: queue. It does NOT loosen the pre-#3244 guard: a bare session id with a
|
|
238
|
+
#: part suffix (``{session}-p2of5``) still has no content-derived core and
|
|
239
|
+
#: still returns False.
|
|
240
|
+
_UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
241
|
+
#: ``*``, not ``?``: a part queued under one bound and drained under a smaller
|
|
242
|
+
#: one (an operator lowers ``HINDSIGHT_RETAIN_CLIENT_DEADLINE_S``) is re-split
|
|
243
|
+
#: at POST time into ``{core}-p2of5-p1of2``. Still a pure function of content,
|
|
244
|
+
#: still reconcilable; ``?`` would silently exclude it.
|
|
245
|
+
_PART_SUFFIX_RE = r"(?:-p[0-9]+of[0-9]+)*"
|
|
246
|
+
_CONTENT_DERIVED_ID_RE = re.compile(
|
|
247
|
+
r"-r(?:%s-%s|[0-9a-fA-F]{32})%s$" % (_UUID_RE, _UUID_RE, _PART_SUFFIX_RE)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_content_derived_document_id(document_id) -> bool:
|
|
252
|
+
"""True iff ``document_id`` is a post-#3244 CONTENT-derived id.
|
|
253
|
+
|
|
254
|
+
This is the gate on any *presence-only* reconcile (a GET that returns
|
|
255
|
+
200 ⇒ drop the queue entry). It is only sound when the id identifies
|
|
256
|
+
the entry's own content:
|
|
257
|
+
|
|
258
|
+
* **Post-#3244** the id is ``{session_id}-r{start_uuid}-{end_uuid}``
|
|
259
|
+
(``retain.slice_document_id``), a pure function of *which turns* the
|
|
260
|
+
entry carries. A 200 on that id proves *this* content was committed.
|
|
261
|
+
* **Pre-#3244** entries carry a BARE SESSION ID. The bank answers 200
|
|
262
|
+
for that id after ANY successful retain in that session, so a 200
|
|
263
|
+
proves nothing about the queued entry's own content. Reconciling on
|
|
264
|
+
it deletes a turn that was never committed — confirmed against a
|
|
265
|
+
live entry on this fleet (525 KB of content, ``document_id`` a bare
|
|
266
|
+
UUID, GET 200).
|
|
267
|
+
|
|
268
|
+
* **Split parts** (``{core}-p{i}of{n}``, #3610) inherit the verdict of
|
|
269
|
+
their core: the part suffix is derived from the same content, so a 200
|
|
270
|
+
on ``…-r{uuid}-{uuid}-p2of5`` proves that part's own content was
|
|
271
|
+
committed. A part suffix on a bare session id proves nothing and is
|
|
272
|
+
still False.
|
|
273
|
+
|
|
274
|
+
Anything unrecognised is False: the safe direction is to keep the
|
|
275
|
+
entry and let the POST path decide.
|
|
276
|
+
"""
|
|
277
|
+
if not isinstance(document_id, str):
|
|
278
|
+
return False
|
|
279
|
+
return bool(_CONTENT_DERIVED_ID_RE.search(document_id))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _clip_error(e: BaseException) -> str:
|
|
283
|
+
"""``str(e)`` truncated to ``MAX_ERROR_MESSAGE_CHARS``."""
|
|
284
|
+
s = str(e)
|
|
285
|
+
if len(s) <= MAX_ERROR_MESSAGE_CHARS:
|
|
286
|
+
return s
|
|
287
|
+
return s[:MAX_ERROR_MESSAGE_CHARS] + "…[truncated]"
|
|
288
|
+
|
|
68
289
|
|
|
69
290
|
def pending_dir() -> str:
|
|
70
291
|
"""Return the pending-retains directory path.
|
|
@@ -95,8 +316,13 @@ def _ensure_dir() -> str:
|
|
|
95
316
|
|
|
96
317
|
|
|
97
318
|
def _list_entries(d: str) -> list[str]:
|
|
98
|
-
"""Return sorted filenames
|
|
99
|
-
|
|
319
|
+
"""Return sorted filenames, oldest first.
|
|
320
|
+
|
|
321
|
+
Order is the lexicographic sort of ``<unix-ms>-[<key>-]<uuid>.json``.
|
|
322
|
+
The millisecond stamp is fixed-width and leading, so this is true
|
|
323
|
+
enqueue order down to the millisecond; entries sharing a millisecond
|
|
324
|
+
tie-break on the remaining segments (stable and total, but arbitrary —
|
|
325
|
+
the name carries no finer age information).
|
|
100
326
|
"""
|
|
101
327
|
try:
|
|
102
328
|
names = [n for n in os.listdir(d) if n.endswith(".json")]
|
|
@@ -112,6 +338,452 @@ def count() -> int:
|
|
|
112
338
|
return len(_list_entries(d))
|
|
113
339
|
|
|
114
340
|
|
|
341
|
+
def _sibling(name: str) -> str:
|
|
342
|
+
"""Path to ``name`` as a sibling of the queue directory."""
|
|
343
|
+
return os.path.join(os.path.dirname(pending_dir().rstrip("/")), name)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def evicted_dir() -> str:
|
|
347
|
+
"""Archive directory for FIFO-evicted entries (sibling of the queue)."""
|
|
348
|
+
return os.environ.get("HINDSIGHT_PENDING_EVICTED_DIR") or _sibling(
|
|
349
|
+
"pending-evicted"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def reconciled_dir() -> str:
|
|
354
|
+
"""Archive directory for reconciled/drained entries (sibling of the queue).
|
|
355
|
+
|
|
356
|
+
The out-of-band drainer this design was ported from ARCHIVES an entry it
|
|
357
|
+
stops draining; it never ``os.remove``s one. That is the difference
|
|
358
|
+
between "we believe this is durable upstream" and "the last on-disk copy
|
|
359
|
+
of this turn is gone", and only the second is irreversible.
|
|
360
|
+
"""
|
|
361
|
+
return os.environ.get("HINDSIGHT_PENDING_RECONCILED_DIR") or _sibling(
|
|
362
|
+
"pending-reconciled"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def evictions_log_path() -> str:
|
|
367
|
+
"""Append-only eviction ledger.
|
|
368
|
+
|
|
369
|
+
This is the deterministic, machine-readable signal that memories are
|
|
370
|
+
being shed — ``switchroom doctor`` reads it. Eviction is not silent
|
|
371
|
+
data loss, but it IS loss, so it must never be inferable only from a
|
|
372
|
+
depth reading.
|
|
373
|
+
"""
|
|
374
|
+
return _sibling("pending-evictions.log")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def drops_path() -> str:
|
|
378
|
+
"""Path of the residual-drop ledger (sibling of the queue dir)."""
|
|
379
|
+
return _sibling(DROPS_FILE)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _dir_bytes(d: str, names) -> int:
|
|
383
|
+
total = 0
|
|
384
|
+
for n in names:
|
|
385
|
+
try:
|
|
386
|
+
total += os.path.getsize(os.path.join(d, n))
|
|
387
|
+
except OSError:
|
|
388
|
+
pass
|
|
389
|
+
return total
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
393
|
+
"""Keep ``a`` under its count/byte caps, deleting OLDEST first.
|
|
394
|
+
|
|
395
|
+
Returns the number of archived copies dropped.
|
|
396
|
+
|
|
397
|
+
Oldest-first is load-bearing: ``names[0]`` is the lexicographically
|
|
398
|
+
smallest name, and names lead with a fixed-width millisecond stamp, so
|
|
399
|
+
it is the oldest archived entry. Trimming from the other end would keep
|
|
400
|
+
the stale tail and discard what was just shed.
|
|
401
|
+
|
|
402
|
+
Both caps are ``>`` — a directory sitting exactly ON a cap is within
|
|
403
|
+
it, and trimming there would shed one entry per call forever.
|
|
404
|
+
|
|
405
|
+
The archive caps are deliberately 4x SMALLER than the queue caps (500 /
|
|
406
|
+
64 MB vs 2000 / 256 MB) (#3599 review R3-L1). Sizing them to match
|
|
407
|
+
would put three full-queue copies on a container filesystem (queue +
|
|
408
|
+
``pending-evicted/`` + ``pending-reconciled/`` = 768 MB), which is the
|
|
409
|
+
disk problem the caps exist to bound.
|
|
410
|
+
|
|
411
|
+
WHAT THE TRIM COSTS, honestly (#3599 review R4-B3). An earlier revision
|
|
412
|
+
of this docstring justified the small caps by claiming an entry "only
|
|
413
|
+
reaches ``pending-reconciled/`` once its document was CONFIRMED present
|
|
414
|
+
upstream", so the trim "sheds a redundant copy". That is false for the
|
|
415
|
+
commonest path. Three of the four ways in are corroborated by a GET:
|
|
416
|
+
|
|
417
|
+
* in-hook reconcile (``drain_pending.drain``'s presence pass) — a GET
|
|
418
|
+
answered 200 for this entry's content-derived id;
|
|
419
|
+
* backlog phase 1 (``_reconcile_phase``) — the same, out of hook;
|
|
420
|
+
* backlog phase 2 — synchronous POST, then a CONFIRMING re-GET.
|
|
421
|
+
|
|
422
|
+
The fourth is not:
|
|
423
|
+
|
|
424
|
+
* ``drain``'s in-hook SUCCESS path retires on the POST's own 200 with
|
|
425
|
+
no confirming GET, because a 5s hook budget has no room for one.
|
|
426
|
+
That 200 is NOT a bare async ack — ``_retry_one`` posts
|
|
427
|
+
``async_processing=False``, so it is a commit-before-ack (#3244
|
|
428
|
+
§1.1) and real upstream evidence of persistence. But it is the
|
|
429
|
+
daemon's word about itself, not an independent read, and this is
|
|
430
|
+
the path that runs on every boot: the common case.
|
|
431
|
+
|
|
432
|
+
So for that population the archived copy CAN be the last on-disk copy
|
|
433
|
+
of a turn — precisely when the daemon did not honour ``async=false``.
|
|
434
|
+
Trimming it is a real, if narrow, loss, and the caps stay small anyway:
|
|
435
|
+
this archive is the horizon of a recovery CONVENIENCE, not a durability
|
|
436
|
+
guarantee. The durability guarantee is commit-before-ack. Raising these
|
|
437
|
+
caps 4x would only move the horizon while tripling the disk cost, and a
|
|
438
|
+
daemon that ignores ``async=false`` is a precondition violation to fix
|
|
439
|
+
upstream, not to paper over with 768 MB of container disk. Hence the
|
|
440
|
+
log line: "reversible" has a horizon and the operator is entitled to
|
|
441
|
+
know when it passed.
|
|
442
|
+
|
|
443
|
+
``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
|
|
444
|
+
an entry only reaches it through the ledgered eviction path, and under
|
|
445
|
+
sustained ENOSPC it may not reach it at all.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
names = sorted(n for n in os.listdir(a) if n.endswith(".json"))
|
|
449
|
+
except OSError:
|
|
450
|
+
return 0
|
|
451
|
+
dropped = []
|
|
452
|
+
while names and (
|
|
453
|
+
len(names) > max_entries or _dir_bytes(a, names) > max_bytes
|
|
454
|
+
):
|
|
455
|
+
try:
|
|
456
|
+
os.remove(os.path.join(a, names[0]))
|
|
457
|
+
except OSError:
|
|
458
|
+
pass
|
|
459
|
+
dropped.append(names.pop(0))
|
|
460
|
+
if dropped:
|
|
461
|
+
shown = ", ".join(dropped[:10])
|
|
462
|
+
if len(dropped) > 10:
|
|
463
|
+
shown += f", +{len(dropped) - 10} more"
|
|
464
|
+
print(
|
|
465
|
+
f"[Hindsight] pending: trimmed {len(dropped)} archived "
|
|
466
|
+
f"cop{'y' if len(dropped) == 1 else 'ies'} from "
|
|
467
|
+
f"{os.path.basename(a.rstrip('/'))} to stay under its caps "
|
|
468
|
+
f"({max_entries} entries / {max_bytes} bytes): {shown}",
|
|
469
|
+
file=sys.stderr,
|
|
470
|
+
)
|
|
471
|
+
return len(dropped)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _trim_archive() -> None:
|
|
475
|
+
"""Keep the eviction archive under its own count/byte caps."""
|
|
476
|
+
_trim_dir(evicted_dir(), ARCHIVE_MAX_ENTRIES, ARCHIVE_MAX_BYTES)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def archive_reconciled(path: str) -> Optional[str]:
|
|
480
|
+
"""Retire a queue entry into ``pending-reconciled/``. Returns the dest.
|
|
481
|
+
|
|
482
|
+
THE ONLY WAY the drain retires an entry. Every "we no longer need to
|
|
483
|
+
keep this queued" decision on the drain path rests on an HTTP 200 —
|
|
484
|
+
either a presence GET or a synchronous retain ack — and a 200 is
|
|
485
|
+
evidence, not proof (switchroom #3244). ``os.remove`` on that evidence
|
|
486
|
+
is irreversible; a bounded archive is not, and the archive is what the
|
|
487
|
+
out-of-band tooling this design was ported from always did.
|
|
488
|
+
|
|
489
|
+
Bounded exactly like ``pending-evicted/`` (``RECONCILED_MAX_ENTRIES`` /
|
|
490
|
+
``RECONCILED_MAX_BYTES``), so it can never become an unbounded disk
|
|
491
|
+
problem of its own.
|
|
492
|
+
|
|
493
|
+
Returns the destination path, or ``None`` when the entry could NOT be
|
|
494
|
+
retired — in which case the entry is still queued and untouched.
|
|
495
|
+
|
|
496
|
+
A failure to archive (ENOSPC, EACCES, a read-only mount) is NOT a
|
|
497
|
+
licence to delete (#3599 review R3-M1). An earlier revision fell back
|
|
498
|
+
to ``delete_entry`` on ``OSError``, reasoning that a queue which cannot
|
|
499
|
+
retire would re-POST forever; the cost of that loop is duplicated LLM
|
|
500
|
+
extraction, while the cost of the delete is the last on-disk copy of a
|
|
501
|
+
turn, silently and irreversibly. The cheaper failure wins, and the
|
|
502
|
+
caller is told so it can count the entry honestly rather than report a
|
|
503
|
+
retire that did not happen. The failure is also logged to stderr,
|
|
504
|
+
because the one thing worse than a full disk is a full disk nobody
|
|
505
|
+
hears about.
|
|
506
|
+
|
|
507
|
+
"STAYS QUEUED" IS BOUNDED, and the bound is worth stating here because
|
|
508
|
+
this is where the promise is made (#3599 review R4-M1). If the disk
|
|
509
|
+
stays full, entries pile up, the queue hits its cap, and ``enqueue()``
|
|
510
|
+
calls ``_evict_to_fit``, whose own archive move fails for the same
|
|
511
|
+
reason and which then removes the OLDEST live entries outright. So the
|
|
512
|
+
honest full statement is: this function never deletes, and under
|
|
513
|
+
sustained ENOSPC "keep it queued" degrades to "keep the newest, drop
|
|
514
|
+
the oldest" — loudly, via the eviction ledger and a failing doctor row.
|
|
515
|
+
"""
|
|
516
|
+
dest_dir = reconciled_dir()
|
|
517
|
+
try:
|
|
518
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
519
|
+
dest = os.path.join(dest_dir, os.path.basename(path))
|
|
520
|
+
shutil.move(path, dest)
|
|
521
|
+
except OSError as e:
|
|
522
|
+
print(
|
|
523
|
+
f"[Hindsight] pending: could not archive {os.path.basename(path)} "
|
|
524
|
+
f"into {dest_dir} ({e}) — entry STAYS QUEUED (never deleted); "
|
|
525
|
+
f"free disk space or fix permissions, then re-run the drain",
|
|
526
|
+
file=sys.stderr,
|
|
527
|
+
)
|
|
528
|
+
return None
|
|
529
|
+
_trim_dir(dest_dir, RECONCILED_MAX_ENTRIES, RECONCILED_MAX_BYTES)
|
|
530
|
+
return dest
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) -> None:
|
|
534
|
+
line = "%s evicted=%s bytes=%d reason=%s queue_depth=%d queue_bytes=%d" % (
|
|
535
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
536
|
+
name,
|
|
537
|
+
size,
|
|
538
|
+
reason,
|
|
539
|
+
depth,
|
|
540
|
+
nbytes,
|
|
541
|
+
)
|
|
542
|
+
log = evictions_log_path()
|
|
543
|
+
try:
|
|
544
|
+
with open(log, "a", encoding="utf-8") as f:
|
|
545
|
+
print(line, file=f)
|
|
546
|
+
# Bounded, not append-forever. `switchroom doctor` windows this by
|
|
547
|
+
# timestamp so a single legitimate eviction can't turn the row red
|
|
548
|
+
# permanently, but the FILE still needs a ceiling of its own.
|
|
549
|
+
if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
|
|
550
|
+
with open(log, encoding="utf-8") as f:
|
|
551
|
+
kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
|
|
552
|
+
tmp = log + ".tmp"
|
|
553
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
554
|
+
f.writelines(kept)
|
|
555
|
+
os.chmod(tmp, 0o600)
|
|
556
|
+
os.replace(tmp, log)
|
|
557
|
+
except OSError:
|
|
558
|
+
pass
|
|
559
|
+
print(
|
|
560
|
+
"[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
|
|
561
|
+
"newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
|
|
562
|
+
"Archived under %s." % (name, size, reason, depth, nbytes, evicted_dir()),
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _evict_to_fit(d: str, incoming_bytes: int) -> int:
|
|
568
|
+
"""Evict oldest entries until the incoming entry fits under BOTH caps.
|
|
569
|
+
|
|
570
|
+
Returns the number of entries evicted. FIFO: oldest filename first,
|
|
571
|
+
which is oldest by wall-clock because names are ``<unix-ms>-<uuid>.json``.
|
|
572
|
+
|
|
573
|
+
THE ONE PLACE A LIVE QUEUE ENTRY CAN BE REMOVED (#3599 review R4-M1),
|
|
574
|
+
and it must be read together with ``archive_reconciled``'s "the entry
|
|
575
|
+
STAYS QUEUED" promise, because it is the bound on that promise. Normally
|
|
576
|
+
an eviction is a MOVE into ``pending-evicted/`` and the payload survives.
|
|
577
|
+
Under sustained ENOSPC it is not: ``archive_reconciled`` keeps entries
|
|
578
|
+
queued, the queue fills, this function fires, its own archive move fails
|
|
579
|
+
for the same reason, and the fallback below ``os.remove``s the oldest
|
|
580
|
+
live entries to make room for the newest.
|
|
581
|
+
|
|
582
|
+
That is deliberate and it is the accepted behaviour, not an oversight:
|
|
583
|
+
the queue has to be bounded by something, and shedding the OLDEST turns
|
|
584
|
+
to keep accepting new ones is the least-bad bound. It is never silent —
|
|
585
|
+
stderr, a ``+archive-failed`` line in ``pending-evictions.log``, and a
|
|
586
|
+
``switchroom doctor`` row that fails on any eviction in the window. So
|
|
587
|
+
"keep it queued" degrades to "keep the newest, drop the oldest" when the
|
|
588
|
+
disk stays full, and every document that says "keep it queued" is
|
|
589
|
+
qualified by this paragraph.
|
|
590
|
+
"""
|
|
591
|
+
names = _list_entries(d)
|
|
592
|
+
nbytes = _dir_bytes(d, names)
|
|
593
|
+
evicted = 0
|
|
594
|
+
archive = evicted_dir()
|
|
595
|
+
while names and (
|
|
596
|
+
len(names) + 1 > MAX_ENTRIES or nbytes + incoming_bytes > MAX_BYTES
|
|
597
|
+
):
|
|
598
|
+
reason = "count" if len(names) + 1 > MAX_ENTRIES else "bytes"
|
|
599
|
+
victim = names.pop(0)
|
|
600
|
+
vpath = os.path.join(d, victim)
|
|
601
|
+
try:
|
|
602
|
+
vsize = os.path.getsize(vpath)
|
|
603
|
+
except OSError:
|
|
604
|
+
vsize = 0
|
|
605
|
+
try:
|
|
606
|
+
os.makedirs(archive, mode=0o700, exist_ok=True)
|
|
607
|
+
shutil.move(vpath, os.path.join(archive, victim))
|
|
608
|
+
except OSError:
|
|
609
|
+
# Archiving failed (disk full / perms). Still evict — keeping the
|
|
610
|
+
# newest memory is the priority — but say so loudly. This is the
|
|
611
|
+
# ONE ``os.remove`` in this module that can touch a LIVE entry,
|
|
612
|
+
# and with the archive move already failed there is no copy left:
|
|
613
|
+
# this line is the bound on "an entry is never deleted". The
|
|
614
|
+
# ``+archive-failed`` reason is what tells the operator (via the
|
|
615
|
+
# ledger and the doctor row) that the payload is gone, not merely
|
|
616
|
+
# moved.
|
|
617
|
+
try:
|
|
618
|
+
os.remove(vpath)
|
|
619
|
+
except OSError:
|
|
620
|
+
break
|
|
621
|
+
reason += "+archive-failed"
|
|
622
|
+
nbytes -= vsize
|
|
623
|
+
evicted += 1
|
|
624
|
+
_log_eviction(victim, vsize, reason, len(names), nbytes)
|
|
625
|
+
if evicted:
|
|
626
|
+
_trim_archive()
|
|
627
|
+
return evicted
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _dupe_key(entry: dict) -> Optional[str]:
|
|
631
|
+
"""Stable 16-hex identity of a queued retain, or ``None``.
|
|
632
|
+
|
|
633
|
+
Derived from ``(bank_id, document_id, sha256(content))``. The CONTENT
|
|
634
|
+
hash is what makes the key an identity: two entries sharing it carry
|
|
635
|
+
byte-identical content for the same bank and document, so they are the
|
|
636
|
+
same memory and the daemon would upsert them onto the same document.
|
|
637
|
+
|
|
638
|
+
Do NOT read this as "``document_id`` is content-derived, therefore a
|
|
639
|
+
matching id means matching content" — that only holds post-#3244
|
|
640
|
+
(``retain.slice_document_id``); pre-#3244 entries carry a bare session
|
|
641
|
+
id shared by every retain in that session. Dedupe is safe on either
|
|
642
|
+
because it hashes the content itself; a *presence GET* is not, which is
|
|
643
|
+
why that path is gated on ``is_content_derived_document_id``.
|
|
644
|
+
|
|
645
|
+
``None`` when there is no ``document_id``: identity cannot be
|
|
646
|
+
established, so the entry must always be kept rather than merged.
|
|
647
|
+
"""
|
|
648
|
+
did = entry.get("document_id")
|
|
649
|
+
if did is None:
|
|
650
|
+
return None
|
|
651
|
+
content = entry.get("content")
|
|
652
|
+
if not isinstance(content, str):
|
|
653
|
+
content = json.dumps(content, ensure_ascii=False, sort_keys=True)
|
|
654
|
+
h = hashlib.sha256()
|
|
655
|
+
# Length-prefixed so ("ab", "c") and ("a", "bc") cannot collide.
|
|
656
|
+
for part in (str(entry.get("bank_id")), str(did)):
|
|
657
|
+
h.update(b"%d:" % len(part))
|
|
658
|
+
h.update(part.encode("utf-8"))
|
|
659
|
+
h.update(hashlib.sha256(content.encode("utf-8")).digest())
|
|
660
|
+
return h.hexdigest()[:16]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _find_duplicate(d: str, key: Optional[str]) -> Optional[str]:
|
|
664
|
+
"""Return the path of an already-queued identical entry, or ``None``.
|
|
665
|
+
|
|
666
|
+
A pure filename prefix match — no file is opened, no payload is
|
|
667
|
+
hashed — because ``enqueue()`` stamps the key into the name. Entries
|
|
668
|
+
written by an older plugin build have no key segment and simply never
|
|
669
|
+
match, which is the safe direction: a missed dedupe costs a duplicate
|
|
670
|
+
file, a false one would discard a distinct memory.
|
|
671
|
+
"""
|
|
672
|
+
if not key:
|
|
673
|
+
return None
|
|
674
|
+
needle = f"-{key}-"
|
|
675
|
+
for name in _list_entries(d):
|
|
676
|
+
if needle in name:
|
|
677
|
+
return os.path.join(d, name)
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def quarantine_corrupt(path: str) -> Optional[str]:
|
|
682
|
+
"""Move an unparsable entry into the ``pending-corrupt/`` sibling.
|
|
683
|
+
|
|
684
|
+
Without this a corrupt entry is IMMORTAL: ``iter_entries()`` skips it,
|
|
685
|
+
so it is never reconciled, never drained and never aged to ``.dead``,
|
|
686
|
+
yet it still occupies a queue slot and still counts toward the depth
|
|
687
|
+
that ``switchroom doctor`` reports — inflating the warning forever
|
|
688
|
+
with eviction as its only exit. Mirrors the out-of-band drainer, which
|
|
689
|
+
moves unparsable entries to ``pending-corrupt`` rather than skipping.
|
|
690
|
+
|
|
691
|
+
Returns the new path, or ``None`` if the move failed.
|
|
692
|
+
"""
|
|
693
|
+
dest_dir = _sibling("pending-corrupt")
|
|
694
|
+
try:
|
|
695
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
696
|
+
dest = os.path.join(dest_dir, os.path.basename(path))
|
|
697
|
+
shutil.move(path, dest)
|
|
698
|
+
except OSError:
|
|
699
|
+
return None
|
|
700
|
+
print(
|
|
701
|
+
f"[Hindsight] pending-retains: entry is unparsable, quarantined to "
|
|
702
|
+
f"{dest} (it can no longer block the queue; inspect or delete it).",
|
|
703
|
+
file=sys.stderr,
|
|
704
|
+
)
|
|
705
|
+
return dest
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def read_drops() -> dict:
|
|
709
|
+
"""Return the residual-drop ledger, or ``{}`` when nothing was dropped.
|
|
710
|
+
|
|
711
|
+
Never raises. Catching ``ValueError`` (not ``json.JSONDecodeError``)
|
|
712
|
+
is deliberate: ``open(..., encoding="utf-8")`` raises
|
|
713
|
+
``UnicodeDecodeError`` — a ``ValueError`` subclass, NOT a
|
|
714
|
+
``JSONDecodeError`` — on a non-UTF-8 ledger. A narrower catch would
|
|
715
|
+
let a corrupt ledger turn ``enqueue()`` from "returns ``None``" into
|
|
716
|
+
a raiser at exactly the moment the queue is under stress, breaking
|
|
717
|
+
``session_end.py`` / ``subagent_retain.py``, which handle ``None``.
|
|
718
|
+
"""
|
|
719
|
+
try:
|
|
720
|
+
with open(drops_path(), encoding="utf-8") as f:
|
|
721
|
+
data = json.load(f)
|
|
722
|
+
return data if isinstance(data, dict) else {}
|
|
723
|
+
except (OSError, ValueError):
|
|
724
|
+
return {}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def record_drop(payload: dict, error: BaseException) -> int:
|
|
728
|
+
"""Record one permanently-dropped retain. Returns the new total.
|
|
729
|
+
|
|
730
|
+
A drop is the residual case: the payload could not be written even
|
|
731
|
+
after eviction made room (disk full, permissions). Rare, but the one
|
|
732
|
+
outcome where a turn's memory is genuinely gone, so it gets a loud
|
|
733
|
+
stderr line plus a durable ledger entry.
|
|
734
|
+
|
|
735
|
+
NOTE (accepted, not a silent bug): the ``count`` bump is a
|
|
736
|
+
read-modify-write with no lock. Two hooks dropping concurrently in
|
|
737
|
+
the same ``$HOME`` can lose an increment, so ``count`` is a floor,
|
|
738
|
+
not an exact tally. That is acceptable — the ledger's job is to make
|
|
739
|
+
loss *visible*, and any non-zero count already fails the doctor row.
|
|
740
|
+
Locking here would mean taking a lock on the disk-full path, which is
|
|
741
|
+
exactly where it is most likely to wedge.
|
|
742
|
+
"""
|
|
743
|
+
ledger = read_drops()
|
|
744
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
745
|
+
try:
|
|
746
|
+
prev = int(ledger.get("count", 0))
|
|
747
|
+
except (TypeError, ValueError):
|
|
748
|
+
prev = 0
|
|
749
|
+
count_now = prev + 1
|
|
750
|
+
ledger.update(
|
|
751
|
+
{
|
|
752
|
+
"schema": SCHEMA,
|
|
753
|
+
"count": count_now,
|
|
754
|
+
"last_dropped_at": now,
|
|
755
|
+
"last_error_class": type(error).__name__,
|
|
756
|
+
"last_error_message": _clip_error(error),
|
|
757
|
+
"last_bank_id": payload.get("bank_id"),
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
ledger.setdefault("first_dropped_at", now)
|
|
761
|
+
|
|
762
|
+
p = drops_path()
|
|
763
|
+
tmp = p + ".tmp"
|
|
764
|
+
try:
|
|
765
|
+
os.makedirs(os.path.dirname(p), mode=0o700, exist_ok=True)
|
|
766
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
767
|
+
json.dump(ledger, f, ensure_ascii=False)
|
|
768
|
+
os.chmod(tmp, 0o600)
|
|
769
|
+
os.replace(tmp, p)
|
|
770
|
+
except OSError:
|
|
771
|
+
try:
|
|
772
|
+
os.unlink(tmp)
|
|
773
|
+
except OSError:
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
print(
|
|
777
|
+
f"[Hindsight] pending-retains: DROPPED a failed retain for bank "
|
|
778
|
+
f"{payload.get('bank_id')!r} — could not write the queue entry even "
|
|
779
|
+
f"after eviction, so this turn's memory is permanently lost "
|
|
780
|
+
f"({type(error).__name__}: {_clip_error(error)}). "
|
|
781
|
+
f"Total dropped so far: {count_now}.",
|
|
782
|
+
file=sys.stderr,
|
|
783
|
+
)
|
|
784
|
+
return count_now
|
|
785
|
+
|
|
786
|
+
|
|
115
787
|
def enqueue(payload: dict, error: BaseException) -> Optional[str]:
|
|
116
788
|
"""Persist a failed retain payload.
|
|
117
789
|
|
|
@@ -119,40 +791,181 @@ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
|
|
|
119
791
|
``client.retain()`` plus connection info (``api_url``, ``api_token``)
|
|
120
792
|
so the drainer can rebuild the client without re-resolving config.
|
|
121
793
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
794
|
+
Oversized content is SPLIT into one entry PER PART before anything is
|
|
795
|
+
written, so no entry is ever queued that the drainer cannot finish
|
|
796
|
+
inside one client deadline (see "Bounded entries" above). Content at or
|
|
797
|
+
under the bound is written as a single entry exactly as before, with
|
|
798
|
+
the document_id and metadata untouched.
|
|
799
|
+
|
|
800
|
+
Returns the absolute path of the (first) written entry — which may be
|
|
801
|
+
an EXISTING identical entry (dedupe) — or ``None`` in the residual case
|
|
802
|
+
where NO part could be written at all. Atomic per entry: writes
|
|
803
|
+
``<name>.tmp`` then renames to ``<name>``.
|
|
804
|
+
|
|
805
|
+
A full queue no longer refuses the incoming entry: ``_evict_to_fit()``
|
|
806
|
+
sheds the OLDEST entries into ``pending-evicted/`` instead. Refusing
|
|
807
|
+
the newest memory was the wrong end to shed from — it is the turn most
|
|
808
|
+
likely to still matter.
|
|
125
809
|
"""
|
|
126
810
|
d = _ensure_dir()
|
|
127
|
-
|
|
811
|
+
|
|
812
|
+
content = payload.get("content")
|
|
813
|
+
parts = split_retain_content(content) if isinstance(content, str) else [content]
|
|
814
|
+
total = len(parts)
|
|
815
|
+
if total <= 1:
|
|
816
|
+
return _enqueue_one(d, payload, error)
|
|
817
|
+
|
|
818
|
+
base_doc = payload.get("document_id", "conversation")
|
|
819
|
+
base_meta = payload.get("metadata")
|
|
820
|
+
|
|
821
|
+
part_payloads = []
|
|
822
|
+
for index, part in enumerate(parts):
|
|
823
|
+
part_payload = dict(payload)
|
|
824
|
+
part_payload["content"] = part
|
|
825
|
+
part_payload["document_id"] = part_document_id(base_doc, index, total)
|
|
826
|
+
part_payload["metadata"] = part_metadata(base_meta, index, total)
|
|
827
|
+
part_payloads.append(part_payload)
|
|
828
|
+
|
|
829
|
+
# #3599's "an entry larger than the whole cap can never fit" guard,
|
|
830
|
+
# applied to the whole LOGICAL memory rather than to one part, against
|
|
831
|
+
# BOTH caps. Splitting would otherwise defeat it: each part fits, so the
|
|
832
|
+
# eviction loop becomes satisfiable and every part gets written — but the
|
|
833
|
+
# parts together still exceed the cap, so the later parts evict the
|
|
834
|
+
# earlier parts of the same memory AND every unrelated memory already
|
|
835
|
+
# queued. The queue is left holding a tail fragment of one memory and
|
|
836
|
+
# nothing else: strictly worse than #3599's outcome of refusing the one
|
|
837
|
+
# memory that cannot fit, so refuse it here too.
|
|
838
|
+
total_bytes = sum(_entry_blob_bytes(p, error) for p in part_payloads)
|
|
839
|
+
if total_bytes > MAX_BYTES:
|
|
840
|
+
record_drop(payload, ValueError(
|
|
841
|
+
f"entry is {total_bytes} bytes across {total} parts, larger than "
|
|
842
|
+
f"the whole HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); "
|
|
843
|
+
f"refusing this entry rather than evicting the entire queue for it"
|
|
844
|
+
))
|
|
845
|
+
return None
|
|
846
|
+
if total > MAX_ENTRIES:
|
|
847
|
+
record_drop(payload, ValueError(
|
|
848
|
+
f"entry splits into {total} parts, more than the whole "
|
|
849
|
+
f"HINDSIGHT_PENDING_MAX_ENTRIES cap ({MAX_ENTRIES}); refusing "
|
|
850
|
+
f"this entry rather than evicting the entire queue for it"
|
|
851
|
+
))
|
|
128
852
|
return None
|
|
129
853
|
|
|
854
|
+
first: Optional[str] = None
|
|
855
|
+
for part_payload in part_payloads:
|
|
856
|
+
# Each part goes through the FULL enqueue pipeline — dedupe, the
|
|
857
|
+
# MAX_BYTES refusal, eviction, the drop ledger — because each part
|
|
858
|
+
# is an independently drainable memory, not a fragment that only
|
|
859
|
+
# means something alongside its siblings. A part that cannot be
|
|
860
|
+
# written is recorded as a drop and the remaining parts still go in;
|
|
861
|
+
# returning ``None`` for the whole memory because part 7 of 9 hit
|
|
862
|
+
# ENOSPC would discard eight recoverable turns.
|
|
863
|
+
#
|
|
864
|
+
# A part CAN evict an earlier part of the same memory when the queue
|
|
865
|
+
# is already at its cap (eviction is FIFO and earlier parts are
|
|
866
|
+
# older). That is the same trade `_evict_to_fit` documents — the
|
|
867
|
+
# evicted part MOVES to ``pending-evicted/``, so it is shed, not
|
|
868
|
+
# destroyed, except under the sustained-ENOSPC case named there.
|
|
869
|
+
written = _enqueue_one(d, part_payload, error)
|
|
870
|
+
if written is not None and first is None:
|
|
871
|
+
first = written
|
|
872
|
+
return first
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _build_entry(payload: dict, error: BaseException) -> dict:
|
|
876
|
+
"""The on-disk entry dict for ``payload``, exactly as it will be written."""
|
|
130
877
|
entry = dict(payload)
|
|
131
878
|
entry["schema"] = SCHEMA
|
|
132
879
|
entry["failed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
133
880
|
entry["error_class"] = type(error).__name__
|
|
134
|
-
entry["error_message"] =
|
|
881
|
+
entry["error_message"] = _clip_error(error)
|
|
135
882
|
entry.setdefault("attempt_count", 1)
|
|
883
|
+
return entry
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _entry_blob_bytes(payload: dict, error: BaseException) -> int:
|
|
887
|
+
"""Serialised size of the entry ``payload`` would be written as.
|
|
888
|
+
|
|
889
|
+
Shares ``_build_entry`` with ``_enqueue_one`` so the pre-split total-size
|
|
890
|
+
guard measures the same bytes the per-entry ``MAX_BYTES`` guard does; the
|
|
891
|
+
only field that varies between the two calls is ``failed_at``, whose
|
|
892
|
+
encoding is fixed-width.
|
|
893
|
+
"""
|
|
894
|
+
return len(json.dumps(_build_entry(payload, error), ensure_ascii=False).encode("utf-8"))
|
|
895
|
+
|
|
136
896
|
|
|
897
|
+
def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
|
|
898
|
+
"""Write exactly ONE queue entry for ``payload``. See ``enqueue()``."""
|
|
899
|
+
entry = _build_entry(payload, error)
|
|
900
|
+
|
|
901
|
+
blob = json.dumps(entry, ensure_ascii=False)
|
|
902
|
+
blob_bytes = len(blob.encode("utf-8"))
|
|
903
|
+
|
|
904
|
+
key = _dupe_key(entry)
|
|
137
905
|
ts_ms = int(time.time() * 1000)
|
|
138
906
|
short_uuid = uuid.uuid4().hex[:12]
|
|
139
|
-
|
|
907
|
+
# The key goes in the NAME so dedupe is a listing prefix match with no
|
|
908
|
+
# file reads. The fixed-width millisecond timestamp remains the LEADING
|
|
909
|
+
# segment, so `_list_entries`' lexicographic sort orders entries by
|
|
910
|
+
# enqueue millisecond. Entries written inside the SAME millisecond tie-
|
|
911
|
+
# break on the dupe key (then the random uuid) — arbitrary, but stable
|
|
912
|
+
# and total; the filename carries no finer age information than the
|
|
913
|
+
# millisecond, so no ordering could do better.
|
|
914
|
+
name = f"{ts_ms}-{key}-{short_uuid}.json" if key else f"{ts_ms}-{short_uuid}.json"
|
|
140
915
|
final = os.path.join(d, name)
|
|
141
916
|
tmp = final + ".tmp"
|
|
142
917
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
918
|
+
# DEDUPE first: reconcile_tail re-enqueues the same transcript slice on
|
|
919
|
+
# every boot until its watermark is confirmed, so a stalled upstream would
|
|
920
|
+
# otherwise multiply one memory into dozens of queue files. Returning the
|
|
921
|
+
# existing path keeps every caller's "queued" contract intact.
|
|
922
|
+
dup = _find_duplicate(d, key)
|
|
923
|
+
if dup is not None:
|
|
924
|
+
return dup
|
|
925
|
+
|
|
926
|
+
# An entry bigger than the whole byte cap can never fit. Without this
|
|
927
|
+
# guard the eviction loop below evicts the ENTIRE queue trying to make
|
|
928
|
+
# room and then writes it anyway — trading every queued memory for one.
|
|
929
|
+
if blob_bytes > MAX_BYTES:
|
|
930
|
+
record_drop(payload, ValueError(
|
|
931
|
+
f"entry is {blob_bytes} bytes, larger than the whole "
|
|
932
|
+
f"HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); refusing this "
|
|
933
|
+
f"entry rather than evicting the entire queue for it"
|
|
934
|
+
))
|
|
935
|
+
return None
|
|
936
|
+
|
|
937
|
+
# Then make room by evicting the OLDEST entries rather than refusing
|
|
938
|
+
# this (newest, most valuable) one.
|
|
939
|
+
_evict_to_fit(d, blob_bytes)
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
943
|
+
f.write(blob)
|
|
944
|
+
os.chmod(tmp, 0o600)
|
|
945
|
+
os.rename(tmp, final)
|
|
946
|
+
except OSError as write_err:
|
|
947
|
+
# Residual drop: room was made and the write STILL failed (disk
|
|
948
|
+
# full, permissions). This is the only path that now loses a turn,
|
|
949
|
+
# and it is recorded rather than returned bare — callers handle
|
|
950
|
+
# ``None``, but none of them can see *why* without the ledger.
|
|
951
|
+
try:
|
|
952
|
+
os.unlink(tmp)
|
|
953
|
+
except OSError:
|
|
954
|
+
pass
|
|
955
|
+
record_drop(payload, write_err)
|
|
956
|
+
return None
|
|
147
957
|
return final
|
|
148
958
|
|
|
149
959
|
|
|
150
960
|
def iter_entries() -> list[tuple[str, dict]]:
|
|
151
961
|
"""Return ``[(path, entry_dict), ...]`` oldest first.
|
|
152
962
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
a
|
|
963
|
+
A malformed entry is QUARANTINED, not skipped. Skipping made it
|
|
964
|
+
immortal — never reconciled, never drained, never aged to ``.dead``,
|
|
965
|
+
but still holding a queue slot and still counted in the depth doctor
|
|
966
|
+
reports. Transient read errors (``OSError``) are still just skipped;
|
|
967
|
+
only unparsable content is moved aside. We never crash the
|
|
968
|
+
SessionStart hook on a corrupt entry either way.
|
|
156
969
|
"""
|
|
157
970
|
d = pending_dir()
|
|
158
971
|
out: list[tuple[str, dict]] = []
|
|
@@ -161,18 +974,48 @@ def iter_entries() -> list[tuple[str, dict]]:
|
|
|
161
974
|
try:
|
|
162
975
|
with open(p, encoding="utf-8") as f:
|
|
163
976
|
out.append((p, json.load(f)))
|
|
164
|
-
except
|
|
977
|
+
except OSError:
|
|
165
978
|
continue
|
|
979
|
+
except ValueError:
|
|
980
|
+
# ValueError, not JSONDecodeError: a non-UTF-8 entry raises
|
|
981
|
+
# UnicodeDecodeError, which is a ValueError but NOT a
|
|
982
|
+
# JSONDecodeError, and is exactly as unparsable.
|
|
983
|
+
quarantine_corrupt(p)
|
|
166
984
|
return out
|
|
167
985
|
|
|
168
986
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
987
|
+
# There is deliberately NO ``delete_entry`` primitive callable on the DRAIN
|
|
988
|
+
# path (#3599 review R3-M1). Its last caller was ``archive_reconciled``'s
|
|
989
|
+
# OSError fallback, and while it existed the "never irreversibly removed"
|
|
990
|
+
# claim in this module's docstring, in ``drain_pending``'s, in ``switchroom
|
|
991
|
+
# doctor``'s backlog fix text and in the CHANGELOG was one ``except
|
|
992
|
+
# OSError:`` away from being false. Making that invariant structural — the
|
|
993
|
+
# function does not exist, so no drain branch can reach it — is stronger
|
|
994
|
+
# than asserting it in prose. On the drain path an entry leaves the queue
|
|
995
|
+
# only by MOVING: ``archive_reconciled`` (retired), ``quarantine_corrupt``
|
|
996
|
+
# (unparsable), or the ``.dead`` rename.
|
|
997
|
+
#
|
|
998
|
+
# ONE ``os.remove`` in this module CAN touch a live entry, and an earlier
|
|
999
|
+
# revision of this comment wrongly said none could (#3599 review R4-M1):
|
|
1000
|
+
# ``_evict_to_fit``'s ``OSError`` fallback. It is on the ENQUEUE path, not
|
|
1001
|
+
# the drain path, and it fires only when the eviction archive move ALSO
|
|
1002
|
+
# failed — sustained ENOSPC. Read the whole degradation in one line:
|
|
1003
|
+
#
|
|
1004
|
+
# archive_reconciled keeps the entry queued → the queue fills →
|
|
1005
|
+
# _evict_to_fit runs → its archive move fails too → the OLDEST live
|
|
1006
|
+
# entries are removed to keep accepting the newest.
|
|
1007
|
+
#
|
|
1008
|
+
# So the queue IS bounded under a full disk, and it is bounded by dropping
|
|
1009
|
+
# the oldest turns. That is accepted behaviour (a queue must be bounded by
|
|
1010
|
+
# something, and the newest turn is the one most likely to still matter),
|
|
1011
|
+
# and it is loud: stderr, a ``+archive-failed`` ledger line, and a doctor
|
|
1012
|
+
# row that fails on any eviction in the window. It is NOT invisible and it
|
|
1013
|
+
# is NOT the drain deleting anything. Every "the entry is never deleted"
|
|
1014
|
+
# sentence in this repo means "not by the drain, and not while there is
|
|
1015
|
+
# disk"; ``_evict_to_fit``'s docstring carries the full statement.
|
|
1016
|
+
#
|
|
1017
|
+
# ``_trim_dir``'s ``os.remove`` is the third and mildest: it acts on an
|
|
1018
|
+
# already-retired copy in a bounded archive, never on a live entry.
|
|
176
1019
|
|
|
177
1020
|
|
|
178
1021
|
def update_attempt(path: str, entry: dict, error: BaseException) -> bool:
|
|
@@ -183,7 +1026,7 @@ def update_attempt(path: str, entry: dict, error: BaseException) -> bool:
|
|
|
183
1026
|
entry["attempt_count"] = int(entry.get("attempt_count", 1)) + 1
|
|
184
1027
|
entry["last_attempt_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
185
1028
|
entry["error_class"] = type(error).__name__
|
|
186
|
-
entry["error_message"] =
|
|
1029
|
+
entry["error_message"] = _clip_error(error)
|
|
187
1030
|
try:
|
|
188
1031
|
tmp = path + ".tmp"
|
|
189
1032
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
@@ -195,6 +1038,74 @@ def update_attempt(path: str, entry: dict, error: BaseException) -> bool:
|
|
|
195
1038
|
return False
|
|
196
1039
|
|
|
197
1040
|
|
|
1041
|
+
#: HTTP statuses that are 4xx but describe a TRANSIENT condition, so they
|
|
1042
|
+
#: must be read as retryable despite the 4xx class.
|
|
1043
|
+
_RETRYABLE_4XX = frozenset({408, 425, 429})
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def is_permanent_failure(error: BaseException) -> bool:
|
|
1047
|
+
"""True when ``error`` can never succeed on a later identical retry.
|
|
1048
|
+
|
|
1049
|
+
This is the gate on ``mark_dead`` (see ``drain_pending._record_failure``).
|
|
1050
|
+
Getting it wrong in the permissive direction costs a re-POST — which is an
|
|
1051
|
+
upsert, so it costs time. Getting it wrong in the strict direction costs
|
|
1052
|
+
the USER'S MEMORY. So the rule is deliberately asymmetric: an error is
|
|
1053
|
+
permanent only when we can positively identify it as a client-side defect
|
|
1054
|
+
in the request itself. Everything we cannot classify is retryable.
|
|
1055
|
+
|
|
1056
|
+
PERMANENT — a 4xx other than 408/425/429. The server understood us and
|
|
1057
|
+
rejected the request: a malformed payload, an unknown bank, an oversized
|
|
1058
|
+
body, a bad token. Re-POSTing the identical bytes reproduces it exactly,
|
|
1059
|
+
so attempts are pure waste and ``.dead`` is the honest outcome.
|
|
1060
|
+
|
|
1061
|
+
RETRYABLE — everything else. Notably 5xx, which is what a failed
|
|
1062
|
+
fact-extraction surfaces as::
|
|
1063
|
+
|
|
1064
|
+
HTTP 500 ...: {"detail": "Fact extraction failed: 1/1 chunks failed.
|
|
1065
|
+
First failures: chunk 0: JSONDecodeError: Expecting value: line 1
|
|
1066
|
+
column 1 (char 0)"}
|
|
1067
|
+
|
|
1068
|
+
That 500 means the extraction model returned an empty or non-JSON
|
|
1069
|
+
completion for one chunk (measured 2026-07-26: Ollama returning
|
|
1070
|
+
``content: ""`` with all-zero usage, and gpt-oss-20b emitting a numbered
|
|
1071
|
+
prose list instead of the JSON schema). It is a property of one sampling
|
|
1072
|
+
run, NOT of the queued content — the very same entry succeeds on a later
|
|
1073
|
+
attempt. Counting it toward ``MAX_ATTEMPTS`` is what turned a flaky model
|
|
1074
|
+
into permanently lost memories: five unlucky samples and a real memory
|
|
1075
|
+
went ``.dead``.
|
|
1076
|
+
|
|
1077
|
+
Timeouts, connection resets, DNS failures and anything unrecognised are
|
|
1078
|
+
retryable for the same reason — none of them is evidence that the content
|
|
1079
|
+
can never be persisted.
|
|
1080
|
+
|
|
1081
|
+
The fleet bears this out. A census of every ``.dead`` marker on this host
|
|
1082
|
+
(2026-07-26, 129 markers across 10 agents) found the retiring error was
|
|
1083
|
+
``TimeoutError`` 128 times and ``URLError`` once. **Not one was a 4xx.**
|
|
1084
|
+
Every permanently-lost memory here was lost to a transient failure, so
|
|
1085
|
+
this gate would have kept all 129 queued and drainable. Two were retired
|
|
1086
|
+
at 00:56Z that same morning — this was live, not historical.
|
|
1087
|
+
|
|
1088
|
+
``client.HindsightClient._request`` re-raises ``urllib`` HTTP failures as
|
|
1089
|
+
``RuntimeError(f"HTTP {code} from {url}: {body}")`` with the original
|
|
1090
|
+
``HTTPError`` chained on ``__cause__``, so the status is read from the
|
|
1091
|
+
cause when present and parsed out of the message otherwise (the message
|
|
1092
|
+
form is what a de-chained/re-serialised error leaves behind).
|
|
1093
|
+
"""
|
|
1094
|
+
code = getattr(error, "code", None)
|
|
1095
|
+
cause = getattr(error, "__cause__", None)
|
|
1096
|
+
if not isinstance(code, int) and cause is not None:
|
|
1097
|
+
code = getattr(cause, "code", None)
|
|
1098
|
+
if not isinstance(code, int):
|
|
1099
|
+
text = str(error)
|
|
1100
|
+
if text.startswith("HTTP "):
|
|
1101
|
+
head = text[5:].split(" ", 1)[0]
|
|
1102
|
+
if head.isdigit():
|
|
1103
|
+
code = int(head)
|
|
1104
|
+
if not isinstance(code, int):
|
|
1105
|
+
return False
|
|
1106
|
+
return 400 <= code < 500 and code not in _RETRYABLE_4XX
|
|
1107
|
+
|
|
1108
|
+
|
|
198
1109
|
def mark_dead(path: str, entry: dict) -> Optional[str]:
|
|
199
1110
|
"""Convert an entry that exceeded ``MAX_ATTEMPTS`` into a permanent
|
|
200
1111
|
failure marker at ``<path>.dead`` so the queue no longer drains it
|