switchroom 0.19.18 → 0.19.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -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 deletes,
9
- failure bumps an attempt counter (up to MAX_ATTEMPTS) and leaves the
10
- entry for the run after that.
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
- ``MAX_ENTRIES`` (1000) caps the queue. When full, ``enqueue()`` refuses
50
- the entry and returns ``None`` — the caller logs loudly and the operator
51
- is expected to drain manually. A chronically full queue means upstream
52
- is broken for a long time; piling on more entries doesn't help.
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
- MAX_ENTRIES = 1000
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 (oldest first by lexicographic order on
99
- the ``<unix-ms>-<uuid>.json`` filename pattern).
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
- Returns the absolute path of the written entry, or ``None`` if the
123
- queue is full (``MAX_ENTRIES`` reached). Atomic: writes ``<name>.tmp``
124
- then renames to ``<name>``.
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
- if len(_list_entries(d)) >= MAX_ENTRIES:
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"] = str(error)
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
- name = f"{ts_ms}-{short_uuid}.json"
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
- with open(tmp, "w", encoding="utf-8") as f:
144
- json.dump(entry, f, ensure_ascii=False)
145
- os.chmod(tmp, 0o600)
146
- os.rename(tmp, final)
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
- Unreadable / malformed files are skipped silently — the drainer
154
- handles its own logging. We never crash the SessionStart hook on
155
- a corrupt entry.
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 (OSError, json.JSONDecodeError):
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
- def delete_entry(path: str) -> bool:
170
- """Remove a queue entry. Returns True on success, False otherwise."""
171
- try:
172
- os.remove(path)
173
- return True
174
- except OSError:
175
- return False
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"] = str(error)
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