switchroom 0.19.22 → 0.19.23
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 +68 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +552 -320
- package/dist/host-control/main.js +69 -2
- package/dist/vault/approvals/kernel-server.js +71 -4
- package/dist/vault/broker/server.js +71 -4
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +108 -16
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +9 -7
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -70,8 +70,8 @@ bounds disk only to within ~1500x; hence ``MAX_BYTES``. Retain fires
|
|
|
70
70
|
every 3rd turn and the busiest agent queues ~100 entries/day, so 2000
|
|
71
71
|
entries is ~3 weeks of total upstream outage headroom.
|
|
72
72
|
|
|
73
|
-
Deduplication
|
|
74
|
-
|
|
73
|
+
Deduplication — the identity is (bank, part position, content)
|
|
74
|
+
--------------------------------------------------------------
|
|
75
75
|
``reconcile_tail`` re-enqueues the same transcript slice on every boot
|
|
76
76
|
until its watermark is confirmed, so a stalled upstream multiplied one
|
|
77
77
|
memory into dozens of identical files. (The measured 63% / 84.7 MB
|
|
@@ -81,9 +81,47 @@ no size index. What follows is the *preventive* guard that stops the
|
|
|
81
81
|
duplicates accumulating in the first place — a different mechanism, and
|
|
82
82
|
it does not get the credit for that number.)
|
|
83
83
|
|
|
84
|
-
Same bank + same
|
|
85
|
-
same memory
|
|
86
|
-
``
|
|
84
|
+
**Same bank + same position in a split + byte-identical content is the
|
|
85
|
+
same memory.** The key is ``(bank_id, part_position, sha256(content))``;
|
|
86
|
+
the rest of ``document_id`` is deliberately NOT in it (switchroom #3688).
|
|
87
|
+
Including the whole id made the guard a near-no-op against the producer
|
|
88
|
+
that actually fills this queue: ``subagent_retain.py`` embeds the
|
|
89
|
+
sub-agent's own session id in the document id
|
|
90
|
+
(``{parent}-sub-{agent_id}-r{start}-{end}``), so every SubagentStop
|
|
91
|
+
re-queues a near-identical slice of the SAME parent transcript under a
|
|
92
|
+
FRESH id. Measured on this fleet 2026-07-26: 1,060 queued files across 11
|
|
93
|
+
agents collapsing to ~368 ``(bank_id, part_position, sha256(content))``
|
|
94
|
+
groups (~65% duplicates, top group 32x — 32 distinct document ids over
|
|
95
|
+
one byte-identical 45,000-char part). Keyed with the whole id, the guard
|
|
96
|
+
matched none of them.
|
|
97
|
+
|
|
98
|
+
The part position (the ``-p{i}of{n}`` index, total discarded) stays in
|
|
99
|
+
the key because a split's parts are not guaranteed to differ from one
|
|
100
|
+
another — repetitive content cut on a character bound yields identical
|
|
101
|
+
parts — and merging those would leave the document missing positions
|
|
102
|
+
upstream. That is content loss, not a redundant copy, so it is not part
|
|
103
|
+
of the trade below. See :func:`_part_position`.
|
|
104
|
+
|
|
105
|
+
WHAT THIS COSTS, stated exactly. Two *different* documents whose content
|
|
106
|
+
happens to be byte-identical for one part now collapse onto one queue
|
|
107
|
+
entry, so the loser's document can end up missing that part upstream.
|
|
108
|
+
That is a real difference and it is the accepted trade, because the thing
|
|
109
|
+
this queue exists to protect is the MEMORY, not the container: the
|
|
110
|
+
surviving entry carries byte-identical content into the SAME bank, so no
|
|
111
|
+
text and no extractable fact is lost — only the second copy of it. The
|
|
112
|
+
alternative is the measured status quo, where identical text is extracted
|
|
113
|
+
by the LLM 32 times over, at ~168 s a part, for one memory. Collapsing is
|
|
114
|
+
also reversible: ``collapse_duplicates()`` MOVES the loser into the
|
|
115
|
+
bounded ``pending-duplicate/`` archive rather than deleting it.
|
|
116
|
+
|
|
117
|
+
The filename key only protects entries queued by THIS build. Entries
|
|
118
|
+
already on disk carry the old id-bearing key, so a new enqueue of their
|
|
119
|
+
content computes a different key and never matches them — the safe
|
|
120
|
+
direction (a missed dedupe costs a file, a false one would cost a
|
|
121
|
+
memory), but it leaves the accumulated backlog duplicated.
|
|
122
|
+
``collapse_duplicates()`` closes that: it recomputes the key from CONTENT
|
|
123
|
+
for every live entry, so it is generation-agnostic, and the drain runs it
|
|
124
|
+
before doing any work.
|
|
87
125
|
|
|
88
126
|
The dedupe key is carried IN THE FILENAME
|
|
89
127
|
(``<unix-ms>-<key>-<uuid>.json``), so a lookup is a prefix match over the
|
|
@@ -178,7 +216,12 @@ import time
|
|
|
178
216
|
import uuid
|
|
179
217
|
from typing import Optional
|
|
180
218
|
|
|
181
|
-
from .retain_split import
|
|
219
|
+
from .retain_split import (
|
|
220
|
+
part_document_id,
|
|
221
|
+
part_metadata,
|
|
222
|
+
retain_content_limit,
|
|
223
|
+
split_retain_content,
|
|
224
|
+
)
|
|
182
225
|
|
|
183
226
|
|
|
184
227
|
SCHEMA = 1
|
|
@@ -204,6 +247,64 @@ RECONCILED_MAX_ENTRIES = int(
|
|
|
204
247
|
RECONCILED_MAX_BYTES = int(
|
|
205
248
|
os.environ.get("HINDSIGHT_PENDING_RECONCILED_MAX_BYTES") or (64 * 1024 * 1024)
|
|
206
249
|
)
|
|
250
|
+
# ``collapse_duplicates`` retires the losing copies here, on the same terms
|
|
251
|
+
# as the other two archives: a collapsed duplicate is byte-identical to an
|
|
252
|
+
# entry that is STILL QUEUED, so this archive is the most redundant of the
|
|
253
|
+
# three — but it is still a MOVE, never a delete.
|
|
254
|
+
DUPLICATE_MAX_ENTRIES = int(
|
|
255
|
+
os.environ.get("HINDSIGHT_PENDING_DUPLICATE_MAX_ENTRIES") or 500
|
|
256
|
+
)
|
|
257
|
+
DUPLICATE_MAX_BYTES = int(
|
|
258
|
+
os.environ.get("HINDSIGHT_PENDING_DUPLICATE_MAX_BYTES") or (64 * 1024 * 1024)
|
|
259
|
+
)
|
|
260
|
+
# ``resplit_over_bound_entries`` retires the pre-split original here. Like
|
|
261
|
+
# the duplicate archive, this copy is REDUNDANT by construction: it is only
|
|
262
|
+
# moved once at least one NEW queue file exists for it (a dedupe hit is not a
|
|
263
|
+
# new file — see ``resplit_over_bound_entries``), and the parts carry the
|
|
264
|
+
# whole memory. So
|
|
265
|
+
# it is bounded on the same terms — a MOVE into a capped archive, never a
|
|
266
|
+
# delete of a memory that has nowhere else to live. (``pending-dead/`` is the
|
|
267
|
+
# deliberate exception: see :func:`dead_dir`.)
|
|
268
|
+
RESPLIT_MAX_ENTRIES = int(
|
|
269
|
+
os.environ.get("HINDSIGHT_PENDING_RESPLIT_MAX_ENTRIES") or 500
|
|
270
|
+
)
|
|
271
|
+
RESPLIT_MAX_BYTES = int(
|
|
272
|
+
os.environ.get("HINDSIGHT_PENDING_RESPLIT_MAX_BYTES") or (64 * 1024 * 1024)
|
|
273
|
+
)
|
|
274
|
+
# Fields that belong to a queue ENTRY rather than to the caller's PAYLOAD.
|
|
275
|
+
# `enqueue()` takes a payload, so an entry read back off disk has to be
|
|
276
|
+
# stripped of them before it can be re-enqueued (see
|
|
277
|
+
# `resplit_over_bound_entries`).
|
|
278
|
+
#
|
|
279
|
+
# Which members are load-bearing, checked against `_build_entry` (it is the
|
|
280
|
+
# only writer of the first four):
|
|
281
|
+
#
|
|
282
|
+
# - `attempt_count`, `last_attempt_at`, `dead_at` are LOAD-BEARING.
|
|
283
|
+
# `_build_entry` uses `setdefault` for `attempt_count` and never touches
|
|
284
|
+
# the other two, so leaving them in really does round-trip an exhausted
|
|
285
|
+
# retry budget — and a `last_attempt_at` from days ago paired with a
|
|
286
|
+
# fresh `attempt_count: 1` — straight into the new parts, sending a
|
|
287
|
+
# re-split part back toward `.dead` on its first failure.
|
|
288
|
+
# - `schema`, `failed_at`, `error_class`, `error_message` are
|
|
289
|
+
# DEFENCE-IN-DEPTH. `_build_entry` assigns all four unconditionally, so
|
|
290
|
+
# they cannot round-trip today. They are stripped anyway to keep the
|
|
291
|
+
# contract "what goes into `enqueue()` is a payload, not an entry" true
|
|
292
|
+
# of the dict itself, so no future `setdefault` here (as `attempt_count`
|
|
293
|
+
# already is) silently starts inheriting them. The strip is asserted on
|
|
294
|
+
# the payload, not only on the resulting entries — see
|
|
295
|
+
# `test_the_parts_carry_no_stale_attempt_metadata`.
|
|
296
|
+
_ENTRY_ONLY_FIELDS = frozenset(
|
|
297
|
+
{
|
|
298
|
+
"schema",
|
|
299
|
+
"failed_at",
|
|
300
|
+
"error_class",
|
|
301
|
+
"error_message",
|
|
302
|
+
"attempt_count",
|
|
303
|
+
"last_attempt_at",
|
|
304
|
+
"dead_at",
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
207
308
|
MAX_ATTEMPTS = 5
|
|
208
309
|
|
|
209
310
|
#: Residual-drop ledger. A SIBLING of the queue directory (like the
|
|
@@ -363,6 +464,72 @@ def reconciled_dir() -> str:
|
|
|
363
464
|
)
|
|
364
465
|
|
|
365
466
|
|
|
467
|
+
def duplicate_dir() -> str:
|
|
468
|
+
"""Archive directory for entries retired by ``collapse_duplicates``."""
|
|
469
|
+
return os.environ.get("HINDSIGHT_PENDING_DUPLICATE_DIR") or _sibling(
|
|
470
|
+
"pending-duplicate"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def dead_dir() -> str:
|
|
475
|
+
"""Archive directory for ``MAX_ATTEMPTS`` failures (sibling of the queue).
|
|
476
|
+
|
|
477
|
+
``.dead`` markers used to be written INSIDE the queue directory, as
|
|
478
|
+
``<entry>.json.dead``. A ``.dead`` marker is the ONLY remaining copy of
|
|
479
|
+
that memory — ``mark_dead`` unlinks the live entry once the marker is
|
|
480
|
+
durable — so leaving it in the live queue directory put the last copy of
|
|
481
|
+
a memory in the one directory that external janitorial tooling has every
|
|
482
|
+
reason to sweep. On this fleet exactly that happened: a host cron ran
|
|
483
|
+
``find <queue> -name '*.dead' -mtime +14 | xargs rm -f``. Measured on
|
|
484
|
+
2026-07-26, 6 such markers were live in the queue directory, each one on
|
|
485
|
+
a countdown to permanent deletion.
|
|
486
|
+
|
|
487
|
+
Fixing the janitor is not a fix — the next one has the same shape. The
|
|
488
|
+
product-level fix is that the live queue directory contains ONLY live
|
|
489
|
+
entries, so no glob over it can ever match a memory. Dead markers move
|
|
490
|
+
out to this sibling, alongside ``pending-evicted``/``pending-reconciled``
|
|
491
|
+
/``pending-corrupt``, and ``sweep_legacy_dead_markers`` migrates any that
|
|
492
|
+
a previous version left behind.
|
|
493
|
+
|
|
494
|
+
NOT TTL-pruned, deliberately, and NOT trimmed to a cap: a dead entry is
|
|
495
|
+
an unrecovered memory, and dropping it is the loss this whole subsystem
|
|
496
|
+
exists to prevent. It is bounded instead by making it hard to REACH --
|
|
497
|
+
``resplit_over_bound_entries`` recovers the entries that used to arrive
|
|
498
|
+
here, so the steady-state population is the genuinely unrecoverable ones.
|
|
499
|
+
"""
|
|
500
|
+
return os.environ.get("HINDSIGHT_PENDING_DEAD_DIR") or _sibling("pending-dead")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _retired_under(name: str) -> Optional[str]:
|
|
504
|
+
"""Which archive sibling holds the retired queue entry ``name``, or ``None``.
|
|
505
|
+
|
|
506
|
+
An entry that vanished from the live queue mid-drain did NOT necessarily
|
|
507
|
+
get evicted. There is no mutex around the queue directory —
|
|
508
|
+
``lib/pacing.py``'s ``retain-inflight.lock`` is a per-POST storm guard and
|
|
509
|
+
``drain_pending.py`` takes no lock at all — so an in-hook ``drain()`` in
|
|
510
|
+
another process can retire the very entry this one is working on into
|
|
511
|
+
``pending-reconciled/`` (memory confirmed durable upstream) between two
|
|
512
|
+
syscalls here. "Gone from the queue" therefore has to be RESOLVED, not
|
|
513
|
+
assumed: the difference between reconciled and evicted is the difference
|
|
514
|
+
between a memory that reached the bank and one that was shed, and callers
|
|
515
|
+
stamp the permanent-loss ledger off that distinction.
|
|
516
|
+
|
|
517
|
+
Search order is the archives that can hold a whole entry. ``mark_dead``
|
|
518
|
+
appends ``.dead`` to the name, so that spelling is checked too.
|
|
519
|
+
"""
|
|
520
|
+
for d in (
|
|
521
|
+
evicted_dir(),
|
|
522
|
+
reconciled_dir(),
|
|
523
|
+
duplicate_dir(),
|
|
524
|
+
dead_dir(),
|
|
525
|
+
_sibling("pending-corrupt"),
|
|
526
|
+
):
|
|
527
|
+
for candidate in (name, name + ".dead"):
|
|
528
|
+
if os.path.exists(os.path.join(d, candidate)):
|
|
529
|
+
return d
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
366
533
|
def evictions_log_path() -> str:
|
|
367
534
|
"""Append-only eviction ledger.
|
|
368
535
|
|
|
@@ -444,6 +611,12 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
|
444
611
|
an entry only reaches it through the ledgered eviction path, and under
|
|
445
612
|
sustained ENOSPC it may not reach it at all.
|
|
446
613
|
"""
|
|
614
|
+
# `.json` ONLY, and that is load-bearing beyond "skip stray files": a
|
|
615
|
+
# `.dead` marker is named `<entry>.json.dead`, so it can never be
|
|
616
|
+
# selected here even if a future caller points a trim at `pending-dead/`.
|
|
617
|
+
# That archive holds the only remaining copy of an unrecovered memory and
|
|
618
|
+
# must stay uncapped; making it structurally unreachable is stronger than
|
|
619
|
+
# relying on nobody wiring up the call. See :func:`dead_dir`.
|
|
447
620
|
try:
|
|
448
621
|
names = sorted(n for n in os.listdir(a) if n.endswith(".json"))
|
|
449
622
|
except OSError:
|
|
@@ -530,6 +703,341 @@ def archive_reconciled(path: str) -> Optional[str]:
|
|
|
530
703
|
return dest
|
|
531
704
|
|
|
532
705
|
|
|
706
|
+
def archive_duplicate(path: str) -> Optional[str]:
|
|
707
|
+
"""Retire a REDUNDANT queue entry into ``pending-duplicate/``.
|
|
708
|
+
|
|
709
|
+
Used only by ``collapse_duplicates()``, and only for an entry whose
|
|
710
|
+
``(bank_id, part_position, sha256(content))`` twin is still queued. Like
|
|
711
|
+
``archive_reconciled`` this MOVES rather than deletes — the survivor is
|
|
712
|
+
evidence, not proof, that the content will land, and this module never
|
|
713
|
+
turns evidence into an irreversible delete.
|
|
714
|
+
|
|
715
|
+
Returns the destination path, or ``None`` when the entry could NOT be
|
|
716
|
+
retired — in which case it is still queued and untouched, and the
|
|
717
|
+
caller must not count it as collapsed. A concurrent drain that retired
|
|
718
|
+
the same entry first lands here too (``shutil.move`` raises
|
|
719
|
+
``FileNotFoundError``, an ``OSError``), which is why the failure path
|
|
720
|
+
is a no-op rather than a raise.
|
|
721
|
+
"""
|
|
722
|
+
dest_dir = duplicate_dir()
|
|
723
|
+
try:
|
|
724
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
725
|
+
dest = os.path.join(dest_dir, os.path.basename(path))
|
|
726
|
+
shutil.move(path, dest)
|
|
727
|
+
except OSError as e:
|
|
728
|
+
print(
|
|
729
|
+
f"[Hindsight] pending: could not archive duplicate "
|
|
730
|
+
f"{os.path.basename(path)} into {dest_dir} ({e}) — entry STAYS "
|
|
731
|
+
f"QUEUED (never deleted)",
|
|
732
|
+
file=sys.stderr,
|
|
733
|
+
)
|
|
734
|
+
return None
|
|
735
|
+
_trim_dir(dest_dir, DUPLICATE_MAX_ENTRIES, DUPLICATE_MAX_BYTES)
|
|
736
|
+
return dest
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def collapse_duplicates() -> int:
|
|
740
|
+
"""Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
|
|
741
|
+
|
|
742
|
+
Returns the number of redundant copies retired.
|
|
743
|
+
|
|
744
|
+
``enqueue()``'s filename-keyed guard stops NEW duplicates. This is the
|
|
745
|
+
other half: it recomputes the key from the entry's own CONTENT, so it
|
|
746
|
+
also collapses entries written by an older build (whose filename key
|
|
747
|
+
still carries ``document_id`` and therefore never matches) and entries
|
|
748
|
+
whose duplicate arrived while a drain held them. Generation-agnostic by
|
|
749
|
+
construction — the filename is not consulted for identity at all.
|
|
750
|
+
|
|
751
|
+
SURVIVOR SELECTION is deterministic and durability-first: the copy with
|
|
752
|
+
the LOWEST ``attempt_count`` wins, ties broken by oldest filename. All
|
|
753
|
+
copies carry byte-identical content, so any of them delivers the same
|
|
754
|
+
memory; the one with the most attempts left is the one most likely to
|
|
755
|
+
get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
|
|
756
|
+
oldest outright would systematically keep the most-attempted copy —
|
|
757
|
+
exactly backwards.
|
|
758
|
+
|
|
759
|
+
An entry whose key is ``None`` (no ``content``) is never grouped: its
|
|
760
|
+
identity cannot be established, so it is always kept.
|
|
761
|
+
|
|
762
|
+
A copy that cannot be archived stays queued and is NOT counted, so the
|
|
763
|
+
return value is a count of retires that actually happened.
|
|
764
|
+
"""
|
|
765
|
+
groups: dict[str, list[tuple[int, str, dict]]] = {}
|
|
766
|
+
for order, (path, entry) in enumerate(iter_entries()):
|
|
767
|
+
key = _dupe_key(entry)
|
|
768
|
+
if not key:
|
|
769
|
+
continue
|
|
770
|
+
groups.setdefault(key, []).append((order, path, entry))
|
|
771
|
+
|
|
772
|
+
collapsed = 0
|
|
773
|
+
for key, members in groups.items():
|
|
774
|
+
if len(members) < 2:
|
|
775
|
+
continue
|
|
776
|
+
survivor = min(
|
|
777
|
+
members,
|
|
778
|
+
key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
|
|
779
|
+
)
|
|
780
|
+
for member in members:
|
|
781
|
+
if member is survivor:
|
|
782
|
+
continue
|
|
783
|
+
if archive_duplicate(member[1]) is not None:
|
|
784
|
+
collapsed += 1
|
|
785
|
+
print(
|
|
786
|
+
f"[Hindsight] pending: collapsed {len(members) - 1} duplicate "
|
|
787
|
+
f"cop{'y' if len(members) == 2 else 'ies'} of key {key} onto "
|
|
788
|
+
f"{os.path.basename(survivor[1])} (byte-identical content, same "
|
|
789
|
+
f"bank; the copies are archived under {duplicate_dir()}, not "
|
|
790
|
+
f"deleted)",
|
|
791
|
+
file=sys.stderr,
|
|
792
|
+
)
|
|
793
|
+
return collapsed
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def resplit_dir() -> str:
|
|
797
|
+
"""Archive directory for entries retired by ``resplit_over_bound_entries``."""
|
|
798
|
+
return os.environ.get("HINDSIGHT_PENDING_RESPLIT_DIR") or _sibling(
|
|
799
|
+
"pending-resplit"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def resplit_over_bound_entries() -> tuple[int, int]:
|
|
804
|
+
"""Re-split queued entries whose content exceeds the current bound.
|
|
805
|
+
|
|
806
|
+
Returns ``(entries_resplit, parts_written)``.
|
|
807
|
+
|
|
808
|
+
THE ENTRY THIS EXISTS FOR is one whose ``content`` is larger than
|
|
809
|
+
``retain_content_limit()``. Such an entry is not slow, it is IMPOSSIBLE:
|
|
810
|
+
the server runs one sequential extraction call per chunk, so the POST
|
|
811
|
+
cannot finish inside the deadline the drain waits, and re-POSTing it is
|
|
812
|
+
guaranteed waste. Today it either churns forever at the back of the drain
|
|
813
|
+
order or — if the server rejects the body as a 4xx, which
|
|
814
|
+
``is_permanent_failure`` correctly classifies as permanent — goes
|
|
815
|
+
``.dead``. Both outcomes are wrong for a memory that is perfectly
|
|
816
|
+
recoverable: ``enqueue()`` already knows how to split it.
|
|
817
|
+
|
|
818
|
+
Two ways an over-bound entry gets into a queue, and both are live here:
|
|
819
|
+
* it was enqueued by a build older than the split-on-enqueue path;
|
|
820
|
+
* the bound MOVED under it. It does that whenever the client deadline
|
|
821
|
+
or the deadline-safety fraction changes, so this is not a one-off
|
|
822
|
+
migration — it is the queue's standing response to a bound change.
|
|
823
|
+
Measured on this fleet 2026-07-26: 18 of 211 queued entries exceeded
|
|
824
|
+
100,000 chars, the largest 744,546 — i.e. 15x the bound.
|
|
825
|
+
|
|
826
|
+
Ordering matters. The original is archived into ``pending-resplit/`` only
|
|
827
|
+
AFTER at least one NEW queue file exists for it, so a crash mid-way leaves
|
|
828
|
+
the original queued (worst case: the parts are written twice, and a
|
|
829
|
+
re-split is deterministic, so the second write dedupes onto the first). If
|
|
830
|
+
NOT ONE new part file appeared — ``enqueue_parts`` refused the whole memory
|
|
831
|
+
as larger than the queue cap, the disk is full, or every part deduped onto
|
|
832
|
+
something already queued — the original is left queued untouched and is
|
|
833
|
+
not counted. Nothing is deleted on any path.
|
|
834
|
+
|
|
835
|
+
"NEW file", not "``_enqueue_one`` returned a path", is the load-bearing
|
|
836
|
+
distinction, and it is why the count is a set difference against the
|
|
837
|
+
queue listing taken just before the call. ``_enqueue_one`` returns the
|
|
838
|
+
path of an ALREADY-QUEUED identical entry on a dedupe hit. When the
|
|
839
|
+
splitter yields a SINGLE part for this content — which it does whenever
|
|
840
|
+
the entry is over the bound but still one part's worth under the CURRENT
|
|
841
|
+
bound — ``enqueue_parts`` re-enqueues the payload unchanged, so the dupe
|
|
842
|
+
key matches the very entry being re-split and the "written" path IS the
|
|
843
|
+
original. Counting that as a part archived a live memory into a capped
|
|
844
|
+
archive while logging "into 0 queued part(s)".
|
|
845
|
+
|
|
846
|
+
NEW, and STILL LIVE, for the same reason. A part written early in the
|
|
847
|
+
loop can be FIFO-shed by a later part once the queue is at its cap, and
|
|
848
|
+
``pending-evicted/`` is not re-drained by anything, so a shed part is a
|
|
849
|
+
slice of the memory that will not reach the bank. It is counted as shed
|
|
850
|
+
(named on its own stderr line) rather than as a queued part: the
|
|
851
|
+
``(entries, parts)`` tuple feeds phase 0c's summary, and a summary that
|
|
852
|
+
counts evicted parts as drainable work is the same lie one level down.
|
|
853
|
+
|
|
854
|
+
WHERE THE ORIGINAL WENT is resolved, never assumed. The archive move
|
|
855
|
+
failing with the original already gone from the queue is usually
|
|
856
|
+
``_evict_to_fit`` shedding it, but an unsynchronised in-hook ``drain()``
|
|
857
|
+
can equally have reconciled it away in the same window — see
|
|
858
|
+
``_retired_under``. The two mean opposite things for the deferred
|
|
859
|
+
per-part drops (shed: the missing part is genuinely lost; reconciled:
|
|
860
|
+
the whole memory is already durable upstream and the missing part is a
|
|
861
|
+
copy that was not made), so the ledger is stamped off the resolved
|
|
862
|
+
destination rather than off the failure alone.
|
|
863
|
+
|
|
864
|
+
A refusal here does NOT stamp the drop ledger
|
|
865
|
+
(``record_refusal_as_drop=False``): the ledger means permanently lost and
|
|
866
|
+
`switchroom doctor` fails on it, but the original is still queued and is
|
|
867
|
+
retried on every backlog drain, so stamping it would climb forever. The
|
|
868
|
+
same reasoning applies one level down, to a PER-PART failure — an
|
|
869
|
+
over-cap part, or a part whose write hit ENOSPC — which is why the drops
|
|
870
|
+
are collected on ``deferred_drops`` and only stamped
|
|
871
|
+
(``record_deferred_drops``) on the branches below where the original has
|
|
872
|
+
actually left the live queue.
|
|
873
|
+
|
|
874
|
+
WHAT THIS COSTS, honestly. One entry becomes N, so the queue gets DEEPER
|
|
875
|
+
(measured worst case on this fleet: 744,546 chars -> 23 parts). On a
|
|
876
|
+
queue already at its cap that means ``enqueue``'s eviction path sheds
|
|
877
|
+
the oldest entries into ``pending-evicted/`` — shed, not destroyed,
|
|
878
|
+
except under the sustained-ENOSPC case ``_evict_to_fit`` documents. The
|
|
879
|
+
trade is deliberate: depth is recoverable, and an over-bound entry is
|
|
880
|
+
not drainable at any depth.
|
|
881
|
+
"""
|
|
882
|
+
limit = retain_content_limit()
|
|
883
|
+
resplit = 0
|
|
884
|
+
parts = 0
|
|
885
|
+
for path, entry in iter_entries():
|
|
886
|
+
content = entry.get("content")
|
|
887
|
+
if not isinstance(content, str) or len(content) <= limit:
|
|
888
|
+
continue
|
|
889
|
+
|
|
890
|
+
payload = {k: v for k, v in entry.items() if k not in _ENTRY_ONLY_FIELDS}
|
|
891
|
+
d = _ensure_dir()
|
|
892
|
+
name = os.path.basename(path)
|
|
893
|
+
before = set(_list_entries(d))
|
|
894
|
+
deferred: list = []
|
|
895
|
+
_first, returned = enqueue_parts(
|
|
896
|
+
payload,
|
|
897
|
+
RuntimeError(
|
|
898
|
+
f"re-split: content was {len(content)} chars, over the current "
|
|
899
|
+
f"{limit}-char retain bound"
|
|
900
|
+
),
|
|
901
|
+
record_refusal_as_drop=False,
|
|
902
|
+
deferred_drops=deferred,
|
|
903
|
+
)
|
|
904
|
+
after = set(_list_entries(d))
|
|
905
|
+
# New FILES only. A queue-depth delta would be wrong in both
|
|
906
|
+
# directions: 0 when a part deduped onto an existing entry (the
|
|
907
|
+
# single-part case dedupes onto the original itself), and understated
|
|
908
|
+
# whenever `_evict_to_fit` shed an entry to make room for a part.
|
|
909
|
+
fresh = [
|
|
910
|
+
os.path.basename(p) for p in returned if os.path.basename(p) not in before
|
|
911
|
+
]
|
|
912
|
+
# ...and of those, only the ones STILL LIVE. A part written early in
|
|
913
|
+
# the loop can be FIFO-shed by a LATER part of the same memory when
|
|
914
|
+
# the queue is at its cap — `enqueue_parts` documents that trade — and
|
|
915
|
+
# the shed part lands in `pending-evicted/`, which nothing re-drains
|
|
916
|
+
# (every consumer only counts or trims it). Calling it a "queued part"
|
|
917
|
+
# is the same class of log-lie as the "STAYS QUEUED" line below: the
|
|
918
|
+
# operator reads N parts queued when N-1 are.
|
|
919
|
+
shed = sorted(n for n in fresh if n not in after)
|
|
920
|
+
queued = len(fresh) - len(shed)
|
|
921
|
+
# `queued == 0 while fresh` cannot arise through `_evict_to_fit` (it
|
|
922
|
+
# evicts to make room FOR the incoming part, so the last part written
|
|
923
|
+
# always survives), but the guard is on the LIVE count regardless:
|
|
924
|
+
# archiving the original is only safe once something of it is
|
|
925
|
+
# actually in the queue.
|
|
926
|
+
if queued == 0:
|
|
927
|
+
print(
|
|
928
|
+
f"[Hindsight] pending: could not re-split "
|
|
929
|
+
f"{name} ({len(content)} chars, bound "
|
|
930
|
+
f"{limit}) — entry STAYS QUEUED (never deleted)",
|
|
931
|
+
file=sys.stderr,
|
|
932
|
+
)
|
|
933
|
+
continue
|
|
934
|
+
if shed:
|
|
935
|
+
print(
|
|
936
|
+
f"[Hindsight] pending: re-split {name} wrote {len(fresh)} "
|
|
937
|
+
f"part(s) but the queue was at its cap, so {len(shed)} of "
|
|
938
|
+
f"them ({', '.join(shed)}) were themselves FIFO-evicted into "
|
|
939
|
+
f"{evicted_dir()} — nothing re-drains that directory, so only "
|
|
940
|
+
f"{queued} part(s) of this memory are live",
|
|
941
|
+
file=sys.stderr,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
dest_dir = resplit_dir()
|
|
945
|
+
durable_upstream = False
|
|
946
|
+
try:
|
|
947
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
948
|
+
shutil.move(path, os.path.join(dest_dir, name))
|
|
949
|
+
except OSError as e:
|
|
950
|
+
if os.path.exists(path):
|
|
951
|
+
# The parts are already queued; leaving the original queued
|
|
952
|
+
# too means the memory is retained twice, which the upsert on
|
|
953
|
+
# document_id makes harmless. Losing it would not be.
|
|
954
|
+
print(
|
|
955
|
+
f"[Hindsight] pending: re-split {name} "
|
|
956
|
+
f"into {queued} part(s) but could not archive the original "
|
|
957
|
+
f"into {dest_dir} ({e}) — it STAYS QUEUED (never deleted)",
|
|
958
|
+
file=sys.stderr,
|
|
959
|
+
)
|
|
960
|
+
continue
|
|
961
|
+
# The move failed because the original is NO LONGER IN THE QUEUE.
|
|
962
|
+
# Saying "STAYS QUEUED" here is simply false, and this is the line
|
|
963
|
+
# an operator reads under exactly that pressure. WHERE it went is
|
|
964
|
+
# not assumable, though: the usual cause is `_evict_to_fit`
|
|
965
|
+
# FIFO-shedding the very entry being re-split (it is the oldest —
|
|
966
|
+
# the parts are all newer than it), but an unsynchronised in-hook
|
|
967
|
+
# `drain()` can equally have RECONCILED it away inside the same
|
|
968
|
+
# window. Those two mean opposite things for the deferred drops,
|
|
969
|
+
# so resolve the destination instead of naming one.
|
|
970
|
+
gone_to = _retired_under(name)
|
|
971
|
+
durable_upstream = gone_to is not None and gone_to == reconciled_dir()
|
|
972
|
+
if durable_upstream:
|
|
973
|
+
where = (
|
|
974
|
+
f"it is NOT queued: a concurrent drain reconciled it into "
|
|
975
|
+
f"{gone_to} while the parts were being written, so the "
|
|
976
|
+
f"whole memory is already durable upstream"
|
|
977
|
+
)
|
|
978
|
+
elif gone_to is not None:
|
|
979
|
+
# Only NOW may the reassuring clause be printed. A non-empty
|
|
980
|
+
# `deferred` is exactly the case where the parts do NOT carry
|
|
981
|
+
# the whole memory (one never got written), and a non-empty
|
|
982
|
+
# `shed` the case where one of them is no longer queued.
|
|
983
|
+
carries = not deferred and not shed
|
|
984
|
+
where = (
|
|
985
|
+
f"it is NOT queued, it is under {gone_to} because writing "
|
|
986
|
+
f"the parts filled the queue and it was itself evicted"
|
|
987
|
+
+ (" (the parts carry the whole memory)" if carries else "")
|
|
988
|
+
)
|
|
989
|
+
else:
|
|
990
|
+
where = (
|
|
991
|
+
f"it is NOT queued and no archive sibling holds it either "
|
|
992
|
+
f"— it left the live queue by a path this drain cannot "
|
|
993
|
+
f"see, so treat the {queued} queued part(s) as the only "
|
|
994
|
+
f"copy"
|
|
995
|
+
)
|
|
996
|
+
print(
|
|
997
|
+
f"[Hindsight] pending: re-split {name} into "
|
|
998
|
+
f"{queued} queued part(s); the original could not be archived "
|
|
999
|
+
f"into {dest_dir} ({e}) — {where}",
|
|
1000
|
+
file=sys.stderr,
|
|
1001
|
+
)
|
|
1002
|
+
else:
|
|
1003
|
+
_trim_dir(dest_dir, RESPLIT_MAX_ENTRIES, RESPLIT_MAX_BYTES)
|
|
1004
|
+
print(
|
|
1005
|
+
f"[Hindsight] pending: re-split {name} "
|
|
1006
|
+
f"({len(content)} chars, over the {limit}-char bound) into "
|
|
1007
|
+
f"{queued} queued part(s); the original is archived under "
|
|
1008
|
+
f"{dest_dir}, not deleted",
|
|
1009
|
+
file=sys.stderr,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# Reached only on the branches where the original has LEFT the live
|
|
1013
|
+
# queue (archived, or evicted out from under the archive). Both are
|
|
1014
|
+
# a completed re-split, so both count — the `continue` above used to
|
|
1015
|
+
# skip the accounting on the evicted branch, returning (0, 0) after
|
|
1016
|
+
# genuinely writing N parts, which silenced phase 0c's `_blog` line
|
|
1017
|
+
# and under-reported `summary["resplit"]`.
|
|
1018
|
+
resplit += 1
|
|
1019
|
+
parts += queued
|
|
1020
|
+
# And only now can a per-part failure be called a loss: the original
|
|
1021
|
+
# is gone from the queue, so nothing will retry the parts that never
|
|
1022
|
+
# got written. While it was still queued, stamping these would have
|
|
1023
|
+
# failed `switchroom doctor` for a memory that was never lost.
|
|
1024
|
+
if durable_upstream:
|
|
1025
|
+
# ...unless the original left by being RECONCILED. The whole
|
|
1026
|
+
# memory reached the bank, so a part that never got written is a
|
|
1027
|
+
# redundant copy that was not made, not a lost turn.
|
|
1028
|
+
if deferred:
|
|
1029
|
+
print(
|
|
1030
|
+
f"[Hindsight] pending: {len(deferred)} part(s) of {name} "
|
|
1031
|
+
f"could not be written, but the original was reconciled "
|
|
1032
|
+
f"upstream, so nothing was lost and the drop ledger is "
|
|
1033
|
+
f"not stamped",
|
|
1034
|
+
file=sys.stderr,
|
|
1035
|
+
)
|
|
1036
|
+
else:
|
|
1037
|
+
record_deferred_drops(deferred)
|
|
1038
|
+
return resplit, parts
|
|
1039
|
+
|
|
1040
|
+
|
|
533
1041
|
def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) -> None:
|
|
534
1042
|
line = "%s evicted=%s bytes=%d reason=%s queue_depth=%d queue_bytes=%d" % (
|
|
535
1043
|
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
@@ -627,35 +1135,86 @@ def _evict_to_fit(d: str, incoming_bytes: int) -> int:
|
|
|
627
1135
|
return evicted
|
|
628
1136
|
|
|
629
1137
|
|
|
630
|
-
|
|
631
|
-
|
|
1138
|
+
_PART_SUFFIX_TAIL_RE = re.compile(r"-p(\d+)of\d+$")
|
|
1139
|
+
|
|
632
1140
|
|
|
633
|
-
|
|
634
|
-
|
|
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.
|
|
1141
|
+
def _part_position(entry: dict) -> str:
|
|
1142
|
+
"""Where this entry sits inside a split retain, as ``"7"`` / ``"2.1"``.
|
|
637
1143
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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``.
|
|
1144
|
+
``""`` for an unsplit retain. Read off the ``-p{i}of{n}`` suffix chain
|
|
1145
|
+
that :func:`lib.retain_split.part_document_id` appends, innermost split
|
|
1146
|
+
last, so a re-split part reads ``"2.1"`` (part 1 of part 2).
|
|
644
1147
|
|
|
645
|
-
|
|
646
|
-
|
|
1148
|
+
Only the INDEX is taken; the total is deliberately discarded. That is
|
|
1149
|
+
what lets the key still collapse the duplicates it exists for: the
|
|
1150
|
+
32-way group measured on this fleet carried ``-p7of15``, ``-p7of16``
|
|
1151
|
+
and ``-p7of18`` over byte-identical content, because the enclosing
|
|
1152
|
+
transcript kept growing while the part itself did not. Keying on the
|
|
1153
|
+
total would have split that group three ways and matched nothing.
|
|
1154
|
+
"""
|
|
1155
|
+
doc = entry.get("document_id")
|
|
1156
|
+
if not isinstance(doc, str):
|
|
1157
|
+
return ""
|
|
1158
|
+
indices = []
|
|
1159
|
+
while True:
|
|
1160
|
+
m = _PART_SUFFIX_TAIL_RE.search(doc)
|
|
1161
|
+
if not m:
|
|
1162
|
+
break
|
|
1163
|
+
indices.append(m.group(1))
|
|
1164
|
+
doc = doc[: m.start()]
|
|
1165
|
+
return ".".join(reversed(indices))
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _dupe_key(entry: dict) -> Optional[str]:
|
|
1169
|
+
"""Stable 16-hex identity of a queued retain, or ``None``.
|
|
1170
|
+
|
|
1171
|
+
Derived from ``(bank_id, part_position, sha256(content))`` and NOTHING
|
|
1172
|
+
ELSE. Two entries sharing this key carry byte-identical content, for
|
|
1173
|
+
the same bank, at the same position inside a split — the same memory,
|
|
1174
|
+
however it got queued and whatever document id the producer stamped on
|
|
1175
|
+
it.
|
|
1176
|
+
|
|
1177
|
+
The part position is in the key because a split's parts are NOT
|
|
1178
|
+
guaranteed to differ from each other: ``split_retain_content`` cuts on
|
|
1179
|
+
a character bound, so highly repetitive content (a long run of
|
|
1180
|
+
identical log lines, a padded transcript) yields byte-identical parts.
|
|
1181
|
+
Without the position, a 10-part memory collapses to a single queued
|
|
1182
|
+
part and the other nine positions never reach the bank — real loss, not
|
|
1183
|
+
a redundant copy. With it, parts of one memory can never merge into
|
|
1184
|
+
each other while duplicate re-enqueues of the SAME position still do.
|
|
1185
|
+
|
|
1186
|
+
The WHOLE ``document_id`` used to be the key's dominant term and is not
|
|
1187
|
+
any more (switchroom #3688) — see the module docstring for the
|
|
1188
|
+
measurement that forced it and for the exact cost. Short version: the
|
|
1189
|
+
id varies per enqueue for the producer that dominates this queue
|
|
1190
|
+
(``subagent_retain.py`` embeds the sub-agent session id), so an
|
|
1191
|
+
id-bearing key never matched a re-enqueue and the queue refilled itself
|
|
1192
|
+
faster than it drained. The part position is the one fragment of the id
|
|
1193
|
+
that survives into the key, and only because dropping it would lose
|
|
1194
|
+
content rather than duplicate it.
|
|
1195
|
+
|
|
1196
|
+
``None`` when ``content`` is absent: identity cannot be established, so
|
|
1197
|
+
the entry must always be kept rather than merged. An EMPTY string is
|
|
1198
|
+
identity enough — degenerate, but two empty retains into one bank are
|
|
1199
|
+
genuinely the same (non-)memory.
|
|
1200
|
+
|
|
1201
|
+
Nothing else in the entry may enter this key. ``failed_at``,
|
|
1202
|
+
``error_message``, ``attempt_count`` and ``last_attempt_at`` all drift
|
|
1203
|
+
between the first enqueue and the re-enqueue this guard exists to
|
|
1204
|
+
catch; keying on any of them re-creates the no-op the size pre-filter
|
|
1205
|
+
once was.
|
|
647
1206
|
"""
|
|
648
|
-
did = entry.get("document_id")
|
|
649
|
-
if did is None:
|
|
650
|
-
return None
|
|
651
1207
|
content = entry.get("content")
|
|
1208
|
+
if content is None:
|
|
1209
|
+
return None
|
|
652
1210
|
if not isinstance(content, str):
|
|
653
1211
|
content = json.dumps(content, ensure_ascii=False, sort_keys=True)
|
|
654
1212
|
h = hashlib.sha256()
|
|
655
|
-
# Length-prefixed so
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
h.update(
|
|
1213
|
+
# Length-prefixed so a bank id ending in digits cannot be confused with
|
|
1214
|
+
# the part position that follows it.
|
|
1215
|
+
for field in (str(entry.get("bank_id")), _part_position(entry)):
|
|
1216
|
+
h.update(b"%d:" % len(field))
|
|
1217
|
+
h.update(field.encode("utf-8"))
|
|
659
1218
|
h.update(hashlib.sha256(content.encode("utf-8")).digest())
|
|
660
1219
|
return h.hexdigest()[:16]
|
|
661
1220
|
|
|
@@ -784,6 +1343,21 @@ def record_drop(payload: dict, error: BaseException) -> int:
|
|
|
784
1343
|
return count_now
|
|
785
1344
|
|
|
786
1345
|
|
|
1346
|
+
def record_deferred_drops(drops: list) -> int:
|
|
1347
|
+
"""Stamp the ledger for drops ``enqueue_parts`` handed back deferred.
|
|
1348
|
+
|
|
1349
|
+
Call this ONLY once the caller's own copy of the memory has left the
|
|
1350
|
+
live queue — that is the moment a per-part failure stops being a
|
|
1351
|
+
retryable "nothing was written, the original stays queued" and becomes
|
|
1352
|
+
the permanent loss ``record_drop`` claims. Returns how many were
|
|
1353
|
+
recorded, so a caller can assert it stamped nothing on the keep-queued
|
|
1354
|
+
branches.
|
|
1355
|
+
"""
|
|
1356
|
+
for payload, error in drops:
|
|
1357
|
+
record_drop(payload, error)
|
|
1358
|
+
return len(drops)
|
|
1359
|
+
|
|
1360
|
+
|
|
787
1361
|
def enqueue(payload: dict, error: BaseException) -> Optional[str]:
|
|
788
1362
|
"""Persist a failed retain payload.
|
|
789
1363
|
|
|
@@ -807,13 +1381,72 @@ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
|
|
|
807
1381
|
the newest memory was the wrong end to shed from — it is the turn most
|
|
808
1382
|
likely to still matter.
|
|
809
1383
|
"""
|
|
1384
|
+
return enqueue_parts(payload, error)[0]
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def enqueue_parts(
|
|
1388
|
+
payload: dict,
|
|
1389
|
+
error: BaseException,
|
|
1390
|
+
*,
|
|
1391
|
+
record_refusal_as_drop: bool = True,
|
|
1392
|
+
deferred_drops: Optional[list] = None,
|
|
1393
|
+
) -> tuple[Optional[str], list[str]]:
|
|
1394
|
+
"""``enqueue()``, plus EVERY path ``_enqueue_one`` handed back.
|
|
1395
|
+
|
|
1396
|
+
``enqueue()`` returns only the first path, which cannot tell a caller
|
|
1397
|
+
how many entries the call actually put in the queue. Callers that own a
|
|
1398
|
+
copy of the memory (``resplit_over_bound_entries`` holds the original)
|
|
1399
|
+
need that: they may only retire their copy once the parts are down.
|
|
1400
|
+
|
|
1401
|
+
The returned list is the paths ``_enqueue_one`` RETURNED, which is not
|
|
1402
|
+
the same as the paths it CREATED — a dedupe hit returns the path of an
|
|
1403
|
+
already-queued identical entry. The caller decides what that means for
|
|
1404
|
+
it; :func:`resplit_over_bound_entries` subtracts the paths that were
|
|
1405
|
+
already queued before the call, which is what makes a re-split that
|
|
1406
|
+
deduped straight back onto its own original count as zero parts.
|
|
1407
|
+
|
|
1408
|
+
``record_refusal_as_drop=False`` suppresses the drop-ledger entry on the
|
|
1409
|
+
two whole-memory refusals below. ``record_drop`` means "this memory is
|
|
1410
|
+
PERMANENTLY LOST" — `switchroom doctor` fails on a non-zero ledger — so
|
|
1411
|
+
a caller that keeps the memory queued when the refusal comes back must
|
|
1412
|
+
not stamp it.
|
|
1413
|
+
|
|
1414
|
+
``deferred_drops`` closes the same hole on the PER-PART door. A per-part
|
|
1415
|
+
failure inside ``_enqueue_one`` — in practice a write that fails after
|
|
1416
|
+
eviction made room — is NOT self-evidently a loss:
|
|
1417
|
+
when every part fails, ``enqueue_parts`` hands back nothing, the caller
|
|
1418
|
+
keeps its copy queued, and phase 0c retries the whole thing on the next
|
|
1419
|
+
backlog drain — so stamping there makes the ledger climb by one part per
|
|
1420
|
+
part per drain while describing zero lost memories, and it never resets
|
|
1421
|
+
once the disk is fixed. Pass a list and those drops are collected onto it
|
|
1422
|
+
instead; call :func:`record_deferred_drops` only on the branch where the
|
|
1423
|
+
caller's copy actually leaves the live queue. With ``deferred_drops=None``
|
|
1424
|
+
(the producers: ``session_end`` / ``retain``, who hold no other copy) the
|
|
1425
|
+
ledger is stamped immediately, exactly as before.
|
|
1426
|
+
|
|
1427
|
+
"In practice a write failure" is exact, and the deferral of
|
|
1428
|
+
``_enqueue_one``'s OWN ``MAX_BYTES`` refusal is defence-in-depth rather
|
|
1429
|
+
than covered behaviour: no deferring caller can reach it today. On the
|
|
1430
|
+
multi-part path the ``total_bytes > MAX_BYTES`` guard above short-circuits
|
|
1431
|
+
first, and ``sum(part_bytes) <= MAX_BYTES`` implies every individual part
|
|
1432
|
+
is under it (both sides measure the same ``_build_entry`` blob). On the
|
|
1433
|
+
``total <= 1`` path the payload is re-enqueued UNCHANGED, so its dupe key
|
|
1434
|
+
is the key of the very entry ``resplit_over_bound_entries`` is re-splitting
|
|
1435
|
+
and ``_find_duplicate`` — which runs BEFORE the guard — returns that entry.
|
|
1436
|
+
The one residual crack is an original queued by a pre-#3688 build, whose
|
|
1437
|
+
filename carries no key segment for the prefix match to hit; the guard is
|
|
1438
|
+
kept deferral-aware for that case rather than assumed unreachable.
|
|
1439
|
+
Verified 2026-07-26 by making the branch raise when ``deferred_drops`` is
|
|
1440
|
+
not None: the whole python suite stayed green, i.e. no test reaches it.
|
|
1441
|
+
"""
|
|
810
1442
|
d = _ensure_dir()
|
|
811
1443
|
|
|
812
1444
|
content = payload.get("content")
|
|
813
1445
|
parts = split_retain_content(content) if isinstance(content, str) else [content]
|
|
814
1446
|
total = len(parts)
|
|
815
1447
|
if total <= 1:
|
|
816
|
-
|
|
1448
|
+
one = _enqueue_one(d, payload, error, deferred_drops=deferred_drops)
|
|
1449
|
+
return one, ([] if one is None else [one])
|
|
817
1450
|
|
|
818
1451
|
base_doc = payload.get("document_id", "conversation")
|
|
819
1452
|
base_meta = payload.get("metadata")
|
|
@@ -837,39 +1470,47 @@ def enqueue(payload: dict, error: BaseException) -> Optional[str]:
|
|
|
837
1470
|
# memory that cannot fit, so refuse it here too.
|
|
838
1471
|
total_bytes = sum(_entry_blob_bytes(p, error) for p in part_payloads)
|
|
839
1472
|
if total_bytes > MAX_BYTES:
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1473
|
+
if record_refusal_as_drop:
|
|
1474
|
+
record_drop(payload, ValueError(
|
|
1475
|
+
f"entry is {total_bytes} bytes across {total} parts, larger than "
|
|
1476
|
+
f"the whole HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); "
|
|
1477
|
+
f"refusing this entry rather than evicting the entire queue for it"
|
|
1478
|
+
))
|
|
1479
|
+
return None, []
|
|
846
1480
|
if total > MAX_ENTRIES:
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1481
|
+
if record_refusal_as_drop:
|
|
1482
|
+
record_drop(payload, ValueError(
|
|
1483
|
+
f"entry splits into {total} parts, more than the whole "
|
|
1484
|
+
f"HINDSIGHT_PENDING_MAX_ENTRIES cap ({MAX_ENTRIES}); refusing "
|
|
1485
|
+
f"this entry rather than evicting the entire queue for it"
|
|
1486
|
+
))
|
|
1487
|
+
return None, []
|
|
853
1488
|
|
|
854
1489
|
first: Optional[str] = None
|
|
1490
|
+
returned: list[str] = []
|
|
855
1491
|
for part_payload in part_payloads:
|
|
856
1492
|
# Each part goes through the FULL enqueue pipeline — dedupe, the
|
|
857
1493
|
# MAX_BYTES refusal, eviction, the drop ledger — because each part
|
|
858
1494
|
# is an independently drainable memory, not a fragment that only
|
|
859
1495
|
# means something alongside its siblings. A part that cannot be
|
|
860
|
-
# written is recorded as a drop
|
|
861
|
-
#
|
|
862
|
-
#
|
|
1496
|
+
# written is recorded as a drop (or collected onto ``deferred_drops``
|
|
1497
|
+
# for the caller to decide, see above) and the remaining parts still
|
|
1498
|
+
# go in; returning ``None`` for the whole memory because part 7 of 9
|
|
1499
|
+
# hit ENOSPC would discard eight recoverable turns.
|
|
863
1500
|
#
|
|
864
1501
|
# A part CAN evict an earlier part of the same memory when the queue
|
|
865
1502
|
# is already at its cap (eviction is FIFO and earlier parts are
|
|
866
1503
|
# older). That is the same trade `_evict_to_fit` documents — the
|
|
867
1504
|
# evicted part MOVES to ``pending-evicted/``, so it is shed, not
|
|
868
1505
|
# destroyed, except under the sustained-ENOSPC case named there.
|
|
869
|
-
written = _enqueue_one(
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1506
|
+
written = _enqueue_one(
|
|
1507
|
+
d, part_payload, error, deferred_drops=deferred_drops
|
|
1508
|
+
)
|
|
1509
|
+
if written is not None:
|
|
1510
|
+
returned.append(written)
|
|
1511
|
+
if first is None:
|
|
1512
|
+
first = written
|
|
1513
|
+
return first, returned
|
|
873
1514
|
|
|
874
1515
|
|
|
875
1516
|
def _build_entry(payload: dict, error: BaseException) -> dict:
|
|
@@ -894,8 +1535,28 @@ def _entry_blob_bytes(payload: dict, error: BaseException) -> int:
|
|
|
894
1535
|
return len(json.dumps(_build_entry(payload, error), ensure_ascii=False).encode("utf-8"))
|
|
895
1536
|
|
|
896
1537
|
|
|
897
|
-
def _enqueue_one(
|
|
898
|
-
|
|
1538
|
+
def _enqueue_one(
|
|
1539
|
+
d: str,
|
|
1540
|
+
payload: dict,
|
|
1541
|
+
error: BaseException,
|
|
1542
|
+
*,
|
|
1543
|
+
deferred_drops: Optional[list] = None,
|
|
1544
|
+
) -> Optional[str]:
|
|
1545
|
+
"""Write exactly ONE queue entry for ``payload``. See ``enqueue()``.
|
|
1546
|
+
|
|
1547
|
+
``deferred_drops``, when a list is passed, DEFERS both ``record_drop``
|
|
1548
|
+
calls below: the ``(payload, error)`` pair is appended to the list
|
|
1549
|
+
instead of being stamped on the ledger straight away. The caller then
|
|
1550
|
+
owns the decision and must call :func:`record_deferred_drops` if — and
|
|
1551
|
+
only if — it stops keeping its own copy of the memory. See
|
|
1552
|
+
``enqueue_parts`` for why that decision cannot be made here.
|
|
1553
|
+
"""
|
|
1554
|
+
def _drop(err: BaseException) -> None:
|
|
1555
|
+
if deferred_drops is None:
|
|
1556
|
+
record_drop(payload, err)
|
|
1557
|
+
else:
|
|
1558
|
+
deferred_drops.append((payload, err))
|
|
1559
|
+
|
|
899
1560
|
entry = _build_entry(payload, error)
|
|
900
1561
|
|
|
901
1562
|
blob = json.dumps(entry, ensure_ascii=False)
|
|
@@ -927,7 +1588,7 @@ def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
|
|
|
927
1588
|
# guard the eviction loop below evicts the ENTIRE queue trying to make
|
|
928
1589
|
# room and then writes it anyway — trading every queued memory for one.
|
|
929
1590
|
if blob_bytes > MAX_BYTES:
|
|
930
|
-
|
|
1591
|
+
_drop(ValueError(
|
|
931
1592
|
f"entry is {blob_bytes} bytes, larger than the whole "
|
|
932
1593
|
f"HINDSIGHT_PENDING_MAX_BYTES cap ({MAX_BYTES}); refusing this "
|
|
933
1594
|
f"entry rather than evicting the entire queue for it"
|
|
@@ -948,11 +1609,12 @@ def _enqueue_one(d: str, payload: dict, error: BaseException) -> Optional[str]:
|
|
|
948
1609
|
# full, permissions). This is the only path that now loses a turn,
|
|
949
1610
|
# and it is recorded rather than returned bare — callers handle
|
|
950
1611
|
# ``None``, but none of them can see *why* without the ledger.
|
|
1612
|
+
# ``_drop`` defers it for a caller that still holds the memory.
|
|
951
1613
|
try:
|
|
952
1614
|
os.unlink(tmp)
|
|
953
1615
|
except OSError:
|
|
954
1616
|
pass
|
|
955
|
-
|
|
1617
|
+
_drop(write_err)
|
|
956
1618
|
return None
|
|
957
1619
|
return final
|
|
958
1620
|
|
|
@@ -1107,12 +1769,28 @@ def is_permanent_failure(error: BaseException) -> bool:
|
|
|
1107
1769
|
|
|
1108
1770
|
|
|
1109
1771
|
def mark_dead(path: str, entry: dict) -> Optional[str]:
|
|
1110
|
-
"""
|
|
1111
|
-
failure marker at ``<path>.dead`` so the queue no longer drains it
|
|
1112
|
-
but operators can still inspect.
|
|
1772
|
+
"""Retire an entry that exceeded ``MAX_ATTEMPTS`` into ``dead_dir()``.
|
|
1113
1773
|
|
|
1114
1774
|
Returns the marker path, or ``None`` if it failed.
|
|
1115
1775
|
|
|
1776
|
+
WHERE THE MARKER GOES, AND WHY IT MOVED. This used to write
|
|
1777
|
+
``<path>.dead`` — i.e. INSIDE the live queue directory. Since the marker
|
|
1778
|
+
is the only remaining copy of the memory (the live entry is unlinked once
|
|
1779
|
+
it is durable), that put the last copy of a memory in the directory
|
|
1780
|
+
external janitors sweep. It is not hypothetical: a host cron on this
|
|
1781
|
+
fleet ran ``find <queue> -name '*.dead' -mtime +14 -delete``. Measured
|
|
1782
|
+
2026-07-26, 6 such markers were live in the queue directory, each on a
|
|
1783
|
+
countdown to permanent deletion. The marker now lands in the
|
|
1784
|
+
``pending-dead/`` sibling, so the live queue directory holds ONLY live
|
|
1785
|
+
entries and no glob over it can match a memory. See :func:`dead_dir`.
|
|
1786
|
+
|
|
1787
|
+
FAILURE IS NOT DELETION. If the marker cannot be written at all, this
|
|
1788
|
+
returns ``None`` and **leaves the live entry exactly where it is**. The
|
|
1789
|
+
drain then re-attempts it on the next run, which is wasteful and visible;
|
|
1790
|
+
the alternative — falling back to a marker inside the queue directory —
|
|
1791
|
+
would quietly restore the loss channel this function exists to close.
|
|
1792
|
+
Queued-and-retrying beats destroyed, every time.
|
|
1793
|
+
|
|
1116
1794
|
Crash-window invariant (#1094 item 3): **a live ``<path>.json`` entry
|
|
1117
1795
|
must never carry a ``dead_at`` stamp.** The old two-step form violated
|
|
1118
1796
|
this — it wrote the dead_at-stamped payload back to the *live* path
|
|
@@ -1120,21 +1798,25 @@ def mark_dead(path: str, entry: dict) -> Optional[str]:
|
|
|
1120
1798
|
crash between the two renames left a live entry with ``dead_at`` set
|
|
1121
1799
|
that the drainer would re-enter and re-bump. Here we instead:
|
|
1122
1800
|
|
|
1123
|
-
1. write the dead_at-stamped payload to
|
|
1124
|
-
2. ``os.replace(tmp, dead_path)`` — the
|
|
1125
|
-
|
|
1801
|
+
1. write the dead_at-stamped payload to a ``.tmp`` inside ``dead_dir()``
|
|
1802
|
+
2. ``os.replace(tmp, dead_path)`` — the marker appears in one atomic
|
|
1803
|
+
step, in a directory the drainer never lists at all
|
|
1126
1804
|
3. ``os.unlink(path)`` — drop the original live entry
|
|
1127
1805
|
|
|
1128
|
-
At every crash point the invariant holds: the ``dead_at`` stamp only
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1806
|
+
At every crash point the invariant holds: the ``dead_at`` stamp only ever
|
|
1807
|
+
lands in ``dead_dir()``. A crash after step 2 leaves both the (stale, no-
|
|
1808
|
+
dead_at) live entry and the marker; the next drain re-marks it dead
|
|
1809
|
+
(``os.replace`` overwrites the marker idempotently), never observing a
|
|
1810
|
+
live entry with dead_at. Step 1 writes the tmp in the DESTINATION
|
|
1811
|
+
directory so step 2 is a same-directory rename and cannot fail on a
|
|
1812
|
+
cross-filesystem boundary halfway through.
|
|
1133
1813
|
"""
|
|
1134
1814
|
entry["dead_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1135
|
-
|
|
1136
|
-
|
|
1815
|
+
dest_dir = dead_dir()
|
|
1816
|
+
dead_path = os.path.join(dest_dir, os.path.basename(path) + ".dead")
|
|
1817
|
+
tmp = dead_path + ".tmp"
|
|
1137
1818
|
try:
|
|
1819
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
1138
1820
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
1139
1821
|
json.dump(entry, f, ensure_ascii=False)
|
|
1140
1822
|
os.chmod(tmp, 0o600)
|
|
@@ -1148,9 +1830,64 @@ def mark_dead(path: str, entry: dict) -> Optional[str]:
|
|
|
1148
1830
|
pass
|
|
1149
1831
|
return dead_path
|
|
1150
1832
|
except OSError:
|
|
1151
|
-
# Clean up a possibly-orphaned tmp so it doesn't linger.
|
|
1833
|
+
# Clean up a possibly-orphaned tmp so it doesn't linger. The live
|
|
1834
|
+
# entry is deliberately left alone — see "FAILURE IS NOT DELETION".
|
|
1152
1835
|
try:
|
|
1153
1836
|
os.unlink(tmp)
|
|
1154
1837
|
except OSError:
|
|
1155
1838
|
pass
|
|
1156
1839
|
return None
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def sweep_legacy_dead_markers() -> int:
|
|
1843
|
+
"""Move ``*.dead`` markers left inside the queue dir into ``dead_dir()``.
|
|
1844
|
+
|
|
1845
|
+
Versions before this one wrote the marker as ``<entry>.json.dead``, next
|
|
1846
|
+
to the live entries. Those markers are the only copy of their memory and
|
|
1847
|
+
are sitting in the directory a janitor sweeps, so an upgrade has to
|
|
1848
|
+
RELOCATE them, not just stop producing new ones. Returns the number moved.
|
|
1849
|
+
|
|
1850
|
+
Idempotent and safe to run on every drain: a queue with no legacy markers
|
|
1851
|
+
does no work and returns 0. A marker whose name already exists in
|
|
1852
|
+
``dead_dir()`` is left where it is rather than overwritten — the two are
|
|
1853
|
+
the same entry by construction (the name carries the queue's unique
|
|
1854
|
+
suffix), but "leave both copies" is the answer that cannot lose one.
|
|
1855
|
+
"""
|
|
1856
|
+
d = pending_dir()
|
|
1857
|
+
try:
|
|
1858
|
+
names = sorted(n for n in os.listdir(d) if n.endswith(".dead"))
|
|
1859
|
+
except OSError:
|
|
1860
|
+
return 0
|
|
1861
|
+
if not names:
|
|
1862
|
+
return 0
|
|
1863
|
+
|
|
1864
|
+
dest_dir = dead_dir()
|
|
1865
|
+
try:
|
|
1866
|
+
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
|
1867
|
+
except OSError as e:
|
|
1868
|
+
print(
|
|
1869
|
+
f"[Hindsight] pending: cannot create {dest_dir} ({e}); "
|
|
1870
|
+
f"{len(names)} legacy .dead marker(s) STAY in the live queue "
|
|
1871
|
+
f"directory (they are not deleted)",
|
|
1872
|
+
file=sys.stderr,
|
|
1873
|
+
)
|
|
1874
|
+
return 0
|
|
1875
|
+
|
|
1876
|
+
moved = 0
|
|
1877
|
+
for name in names:
|
|
1878
|
+
dest = os.path.join(dest_dir, name)
|
|
1879
|
+
if os.path.exists(dest):
|
|
1880
|
+
continue
|
|
1881
|
+
try:
|
|
1882
|
+
shutil.move(os.path.join(d, name), dest)
|
|
1883
|
+
except OSError:
|
|
1884
|
+
continue
|
|
1885
|
+
moved += 1
|
|
1886
|
+
if moved:
|
|
1887
|
+
print(
|
|
1888
|
+
f"[Hindsight] pending: relocated {moved} legacy .dead marker(s) "
|
|
1889
|
+
f"out of the live queue directory into {dest_dir} — the live "
|
|
1890
|
+
f"queue now holds only live entries",
|
|
1891
|
+
file=sys.stderr,
|
|
1892
|
+
)
|
|
1893
|
+
return moved
|