switchroom 0.19.19 → 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 +121 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +2995 -1583
- package/dist/host-control/main.js +122 -2
- package/dist/vault/approvals/kernel-server.js +124 -4
- package/dist/vault/broker/server.js +124 -4
- package/package.json +7 -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/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +873 -184
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +75 -63
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- 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/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/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/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
- package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
- package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
SessionStart calls into ``drain()`` to retry any retain payloads that
|
|
5
5
|
``session_end.py`` queued on failure (#1071). Each entry is retried up
|
|
6
|
-
to ``MAX_ATTEMPTS`` (5) times; after that
|
|
7
|
-
|
|
8
|
-
``
|
|
6
|
+
to ``MAX_ATTEMPTS`` (5) times; after that a **permanently** failing entry
|
|
7
|
+
(a 4xx that a re-POST cannot fix — see ``pending.is_permanent_failure``)
|
|
8
|
+
is retired into the ``pending-dead/`` archive so the queue no longer
|
|
9
|
+
drains it but the operator can still inspect via ``switchroom doctor``.
|
|
10
|
+
The marker deliberately does NOT stay in the live queue directory: it is
|
|
11
|
+
the only remaining copy of that memory, and leaving it among the live
|
|
12
|
+
entries put it in the path of every janitor that sweeps that
|
|
13
|
+
directory. An entry failing on anything
|
|
14
|
+
else — a 5xx, a timeout, a connection error — stays queued past the
|
|
15
|
+
attempt budget: a transient upstream is never evidence that the memory
|
|
16
|
+
is unsaveable, and retiring it would lose content the user believes was
|
|
17
|
+
saved.
|
|
18
|
+
|
|
19
|
+
An entry past the budget is DEMOTED rather than retired (``_drain_order``
|
|
20
|
+
and ``_over_budget``): it sorts behind everything still inside its budget
|
|
21
|
+
and abstains from the stall guard. That is what keeps "never destroy a
|
|
22
|
+
memory" from degrading into "never drain anything" — the drain is
|
|
23
|
+
sequential and oldest-first, so without the demotion three chronically
|
|
24
|
+
failing entries sit at the head and end every run at zero progress.
|
|
9
25
|
|
|
10
26
|
Boundaries
|
|
11
27
|
----------
|
|
@@ -36,14 +52,35 @@ accumulated backlog. Worse, they CREATE one: the per-entry timeout is
|
|
|
36
52
|
clamped to the remaining hook budget (1-8s) while ``_retry_one`` posts
|
|
37
53
|
synchronously and a real retain takes 30-90s, so **the server commits the
|
|
38
54
|
document and the client always gives up before the ack**. The entry is
|
|
39
|
-
never deleted and is re-posted on every session start
|
|
40
|
-
hits ``MAX_ATTEMPTS`` and goes ``.dead``. The queue depth was a symptom
|
|
55
|
+
never deleted and is re-posted on every session start. The queue depth was a symptom
|
|
41
56
|
of that loop, not of lost memory: a full sweep of 5,751 queued entries on
|
|
42
57
|
this fleet (2026-07-25) found **4,048 (70.4%) already existed as
|
|
43
58
|
documents**, 3,815 of them with facts extracted.
|
|
44
59
|
|
|
45
|
-
``--backlog`` is therefore a
|
|
46
|
-
|
|
60
|
+
``--backlog`` is therefore a three-phase, out-of-hook replay:
|
|
61
|
+
|
|
62
|
+
* **Phase 0 — collapse duplicates (free, no network).** Queued entries
|
|
63
|
+
sharing ``(bank_id, part_position, sha256(content))`` are the same
|
|
64
|
+
memory; the redundant copies are archived so the phases below never pay
|
|
65
|
+
for one memory twice. Measured 2026-07-26: 1,060 queued files across 11
|
|
66
|
+
agents fell into ~368 distinct groups — ~65% of the queue was duplicate,
|
|
67
|
+
with one group repeated 32 times. At ~168 s per phase-2 extraction that
|
|
68
|
+
one group alone was 90 minutes of LLM lane time for a single memory.
|
|
69
|
+
* **Phase 0b — relocate legacy ``.dead`` markers (free, no network).**
|
|
70
|
+
Markers written by an older build into the live queue directory are moved
|
|
71
|
+
into ``pending-dead/``. ``mark_dead`` no longer produces such a marker, so
|
|
72
|
+
after this phase has run once the live queue holds only live entries and no
|
|
73
|
+
janitor glob over it can match a memory. Note the CONDITION: phases 0b and
|
|
74
|
+
0c run in BACKLOG mode only (``drain_backlog``, and not under
|
|
75
|
+
``--dry-run``). The SessionStart ``drain()`` never calls them, so on a host
|
|
76
|
+
where the backlog drain has not run, legacy markers are still sitting in
|
|
77
|
+
the queue directory.
|
|
78
|
+
* **Phase 0c — re-split over-bound entries (free, no network).** An entry
|
|
79
|
+
whose content exceeds ``retain_content_limit()`` needs more sequential
|
|
80
|
+
extraction calls than fit the client deadline, so it can never be drained
|
|
81
|
+
as-is; splitting it makes every part drainable. Measured 2026-07-26: 18 of
|
|
82
|
+
211 queued entries exceeded 100,000 chars, the largest 744,546. Backlog
|
|
83
|
+
mode only, same as 0b.
|
|
47
84
|
* **Phase 1 — reconcile (free).** GET the document. If it exists, the
|
|
48
85
|
memory is already durable; retire the queue entry without a POST. No
|
|
49
86
|
LLM work, no cost, idempotent, resumable at any point. Only for
|
|
@@ -73,7 +110,7 @@ reports the upstream is already slow.
|
|
|
73
110
|
Standalone usage::
|
|
74
111
|
|
|
75
112
|
python3 drain_pending.py # bounded in-hook drain
|
|
76
|
-
python3 drain_pending.py --backlog #
|
|
113
|
+
python3 drain_pending.py --backlog # three-phase backlog replay
|
|
77
114
|
python3 drain_pending.py --backlog --phase reconcile # free pass only
|
|
78
115
|
python3 drain_pending.py --backlog --dry-run
|
|
79
116
|
"""
|
|
@@ -94,12 +131,16 @@ from lib.config import debug_log, load_config
|
|
|
94
131
|
from lib.pending import (
|
|
95
132
|
MAX_ATTEMPTS,
|
|
96
133
|
archive_reconciled,
|
|
134
|
+
collapse_duplicates,
|
|
97
135
|
is_content_derived_document_id,
|
|
136
|
+
is_permanent_failure,
|
|
98
137
|
iter_entries,
|
|
99
138
|
mark_dead,
|
|
139
|
+
resplit_over_bound_entries,
|
|
140
|
+
sweep_legacy_dead_markers,
|
|
100
141
|
update_attempt,
|
|
101
142
|
)
|
|
102
|
-
from lib.retain_split import retain_client_deadline
|
|
143
|
+
from lib.retain_split import retain_client_deadline, retain_content_limit
|
|
103
144
|
|
|
104
145
|
|
|
105
146
|
STALL_THRESHOLD = 3
|
|
@@ -203,16 +244,30 @@ def _backlog_timeout() -> int:
|
|
|
203
244
|
client-side timeout on a request the server then commits anyway.
|
|
204
245
|
|
|
205
246
|
The default is DERIVED, not a literal: it is
|
|
206
|
-
``retain_split.retain_client_deadline()`` (
|
|
247
|
+
``retain_split.retain_client_deadline()`` (310s), the same deadline the
|
|
207
248
|
retain content bound is sized against. Those two must be ONE number.
|
|
208
249
|
This function shipped as a bare ``180`` (#3599), and against a 180s
|
|
209
250
|
deadline both halves of the retain budget break: a maximally-sized part
|
|
210
251
|
is ~276s of sequential extraction, and the SERVER per-call timeout
|
|
211
|
-
derived in ``src/setup/hindsight.ts``
|
|
252
|
+
derived in ``src/setup/hindsight.ts`` is larger still — so the drain
|
|
212
253
|
client would abandon a request the server is still legitimately working
|
|
213
254
|
on, leave the entry queued, and rebuild the re-post loop #3599 exists to
|
|
214
255
|
kill, one size class up.
|
|
215
256
|
|
|
257
|
+
This is the OUTERMOST deadline of the paired-budget family, and it is the
|
|
258
|
+
one the 2026-07-26 backlog-recovery logs show as a fixed ~280.1s per-entry
|
|
259
|
+
give-up. That was not an unexplained third number: it is this default at
|
|
260
|
+
the then-current ``DEFAULT_RETAIN_CLIENT_DEADLINE_S = 280.0``, plus the
|
|
261
|
+
request's own setup overhead. It was WRONG for the same reason #3611's
|
|
262
|
+
204s was wrong — it did not cover hindsight's LiteLLM routing chain
|
|
263
|
+
(local 200s + OpenRouter fallback 90s + router margin 10s = 300s), so
|
|
264
|
+
every drained entry whose retain fell through to the fallback was
|
|
265
|
+
abandoned client-side at 280s while the server was still inside a
|
|
266
|
+
legitimate 300s budget. Raising the derivation's base to 310 fixes this
|
|
267
|
+
lane and the in-hook lane with the same number, by construction.
|
|
268
|
+
``src/litellm/timeout-budget.ts`` is where the chain is declared, and
|
|
269
|
+
``tests/setup/hindsight.test.ts`` fails if these drift apart again.
|
|
270
|
+
|
|
216
271
|
``HINDSIGHT_DRAIN_BACKLOG_TIMEOUT`` still overrides it outright;
|
|
217
272
|
``HINDSIGHT_RETAIN_CLIENT_DEADLINE_S`` moves the derivation and the
|
|
218
273
|
content bound together.
|
|
@@ -348,6 +403,76 @@ def _document_state(entry: dict, timeout: int = 30):
|
|
|
348
403
|
return None
|
|
349
404
|
|
|
350
405
|
|
|
406
|
+
def _drain_order(entries: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
|
|
407
|
+
"""Oldest-first, but with budget-exhausted entries demoted to the back.
|
|
408
|
+
|
|
409
|
+
THE HEAD-OF-LINE BUG THIS EXISTS FOR. Since the permanence gate in
|
|
410
|
+
``_record_failure``, an entry failing on anything transient stays queued
|
|
411
|
+
past ``MAX_ATTEMPTS`` indefinitely — deliberately, because a transient
|
|
412
|
+
upstream is not evidence the memory is unsaveable. But ``iter_entries()``
|
|
413
|
+
is oldest-first, and the entries that have been failing longest are by
|
|
414
|
+
construction the OLDEST, so they sit at the head of every drain. Backlog
|
|
415
|
+
concurrency defaults to 1 (``_backlog_concurrency``), so the drain is
|
|
416
|
+
sequential: three such entries in a row trip ``STALL_THRESHOLD`` and the
|
|
417
|
+
run breaks having drained nothing — and, because they never retire, it
|
|
418
|
+
breaks identically on every subsequent run. Measured on this branch before
|
|
419
|
+
this function existed: 6 queued entries, the 3 oldest raising
|
|
420
|
+
``TimeoutError``, 4 consecutive ``drain_backlog`` runs each returning
|
|
421
|
+
``stalled=True, drained=0`` with the queue depth still 6. The 3 healthy
|
|
422
|
+
entries behind them were never even attempted. On ``main`` the same repro
|
|
423
|
+
converges: run 0 retires the 3 heads to ``.dead`` and run 1 onward drains
|
|
424
|
+
normally. So the permanence gate, alone, traded "rarely destroys a memory"
|
|
425
|
+
for "eventually drains nothing at all".
|
|
426
|
+
|
|
427
|
+
``.dead`` was doing double duty: it was the honesty policy AND it was the
|
|
428
|
+
queue's only un-wedging mechanism. Removing it as a policy has to leave
|
|
429
|
+
the un-wedging behind, and demotion is that — it keeps every property the
|
|
430
|
+
gate was added for (the entry is still queued, still retried, never
|
|
431
|
+
destroyed) while removing the one it broke (it can no longer starve a
|
|
432
|
+
healthy entry behind it).
|
|
433
|
+
|
|
434
|
+
The terminal condition for a permanently-unsaveable entry is therefore no
|
|
435
|
+
longer deletion but DEMOTION: ``attempt_count`` only ever climbs, so such
|
|
436
|
+
an entry crosses the budget once and stays in the back group for good,
|
|
437
|
+
where it can delay only itself. It is still reconciled for free on every
|
|
438
|
+
run — ``_reconcile_phase`` sweeps ALL entries with a sub-second presence
|
|
439
|
+
GET, in no particular order and with no stall guard — so an entry whose
|
|
440
|
+
document did land is still retired without a POST.
|
|
441
|
+
|
|
442
|
+
Ordering is a stable partition, so relative age is preserved inside each
|
|
443
|
+
group and FIFO still holds for everything that has not blown its budget.
|
|
444
|
+
"""
|
|
445
|
+
fresh: list[tuple[str, dict]] = []
|
|
446
|
+
exhausted: list[tuple[str, dict]] = []
|
|
447
|
+
for path, entry in entries:
|
|
448
|
+
(exhausted if _over_budget(entry) else fresh).append((path, entry))
|
|
449
|
+
return fresh + exhausted
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _over_budget(entry: dict) -> bool:
|
|
453
|
+
"""Has this entry already burned its ``MAX_ATTEMPTS`` budget?
|
|
454
|
+
|
|
455
|
+
Such an entry is chronically failing but, since the permanence gate, is
|
|
456
|
+
never retired. It gets two demotions — last in the drain order
|
|
457
|
+
(``_drain_order``) and no vote in the stall guard (see below) — because
|
|
458
|
+
ordering alone does not close the wedge. Ordering fixes the common shape
|
|
459
|
+
(a few old poison entries in front of healthy ones), but not the shape
|
|
460
|
+
where the WHOLE queue is over budget: an upstream down for a week takes
|
|
461
|
+
every entry past 5 attempts, and when it recovers the partition is empty
|
|
462
|
+
on one side, the poisoned entries are at the head again, and the run
|
|
463
|
+
stalls before reaching the entries that would now succeed. Measured: with
|
|
464
|
+
ordering alone and all 6 entries at ``MAX_ATTEMPTS``, 4 consecutive runs
|
|
465
|
+
still returned ``stalled=True, drained=0``.
|
|
466
|
+
"""
|
|
467
|
+
try:
|
|
468
|
+
return int(entry.get("attempt_count", 0)) >= MAX_ATTEMPTS
|
|
469
|
+
except (TypeError, ValueError):
|
|
470
|
+
# A hand-edited or corrupt counter must not decide ordering, and must
|
|
471
|
+
# not raise on the drain path. Treat it as fresh: the cost of guessing
|
|
472
|
+
# wrong here is one retry in the normal position, not a lost memory.
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
|
|
351
476
|
def _record_failure(
|
|
352
477
|
config: dict,
|
|
353
478
|
path: str,
|
|
@@ -358,16 +483,53 @@ def _record_failure(
|
|
|
358
483
|
"""Apply the per-entry failure policy. Returns the error class name.
|
|
359
484
|
|
|
360
485
|
Shared by the sequential (SessionStart) and backlog drains so both age
|
|
361
|
-
entries toward ``.dead`` on exactly the same schedule
|
|
486
|
+
entries toward ``.dead`` on exactly the same schedule — and, since the
|
|
487
|
+
permanence gate below, refuse to retire them on exactly the same rule.
|
|
362
488
|
"""
|
|
363
489
|
err_class = type(e).__name__
|
|
364
490
|
attempts = int(entry.get("attempt_count", 1))
|
|
365
|
-
|
|
491
|
+
# ``.dead`` retires a memory the user believes was saved, so it is gated on
|
|
492
|
+
# the failure being PERMANENT — a 4xx that re-POSTing cannot fix. A
|
|
493
|
+
# transient failure keeps its attempt counter climbing but stays queued,
|
|
494
|
+
# because an exhausted attempt budget is not evidence that the content is
|
|
495
|
+
# unpersistable.
|
|
496
|
+
#
|
|
497
|
+
# Before this gate, ANY five failures retired the entry. The dominant
|
|
498
|
+
# failure on this fleet is an HTTP 500 "Fact extraction failed … chunk 0:
|
|
499
|
+
# JSONDecodeError" — the extraction model returned an empty or non-JSON
|
|
500
|
+
# completion for one chunk on that sampling run. The identical content
|
|
501
|
+
# succeeds on a later attempt, so five unlucky samples were destroying
|
|
502
|
+
# memories that were never unsaveable. See ``pending.is_permanent_failure``.
|
|
503
|
+
#
|
|
504
|
+
# WHAT BOUNDS THIS, precisely — because an unbounded queue of undying
|
|
505
|
+
# entries would be a worse outcome than the bug this gate fixes.
|
|
506
|
+
#
|
|
507
|
+
# DISK is bounded by the queue's MAX_ENTRIES / MAX_BYTES caps, which shed
|
|
508
|
+
# the oldest entries into ``pending-evicted/`` (an archive, not a delete).
|
|
509
|
+
# Note what that bound is NOT: ``_evict_to_fit`` is called only from
|
|
510
|
+
# ``enqueue`` (lib/pending.py:933), so it fires on new writes, never on
|
|
511
|
+
# drain — and it "bounds" the queue by shedding memory unsaved, which is
|
|
512
|
+
# the very outcome this gate exists to avoid. It is a backstop, not the
|
|
513
|
+
# answer.
|
|
514
|
+
#
|
|
515
|
+
# PROGRESS is bounded by ``_drain_order`` / ``_over_budget``. An entry that
|
|
516
|
+
# can never be persisted no longer terminates by being destroyed; it
|
|
517
|
+
# terminates by being DEMOTED — sorted behind every entry still inside its
|
|
518
|
+
# budget, and stripped of its vote in the stall guard. ``attempt_count``
|
|
519
|
+
# only ever climbs, so the crossing happens once and is permanent. That is
|
|
520
|
+
# the real terminal condition, and it is what keeps a poisoned entry from
|
|
521
|
+
# starving the queue behind it. Without it, three such entries ended every
|
|
522
|
+
# drain at zero progress, permanently (tests/test_pending_wedge.py).
|
|
523
|
+
#
|
|
524
|
+
# The attempt counter itself was never a bound on either, and ``switchroom
|
|
525
|
+
# doctor`` still surfaces queue depth so the operator sees a growing tail.
|
|
526
|
+
if attempts >= MAX_ATTEMPTS and is_permanent_failure(e):
|
|
366
527
|
marker = mark_dead(path, entry)
|
|
367
528
|
summary["dead"] += 1
|
|
368
529
|
print(
|
|
369
530
|
f"[Hindsight] drain_pending: entry exceeded {MAX_ATTEMPTS} "
|
|
370
|
-
f"attempts, marking dead at {marker}
|
|
531
|
+
f"attempts on a permanent failure, marking dead at {marker} "
|
|
532
|
+
f"(last error: {err_class}: {e})",
|
|
371
533
|
file=sys.stderr,
|
|
372
534
|
)
|
|
373
535
|
else:
|
|
@@ -375,7 +537,12 @@ def _record_failure(
|
|
|
375
537
|
summary["retried"] += 1
|
|
376
538
|
debug_log(
|
|
377
539
|
config,
|
|
378
|
-
|
|
540
|
+
# ``attempts`` can now exceed MAX_ATTEMPTS — a transient failure
|
|
541
|
+
# keeps the entry queued past the budget instead of retiring it —
|
|
542
|
+
# so print the budget as a threshold, not as a fraction that would
|
|
543
|
+
# render the nonsense "retry 7/5".
|
|
544
|
+
f"drain_pending: retry {attempts} (budget {MAX_ATTEMPTS}) failed "
|
|
545
|
+
f"for {path} ({err_class}: {e})",
|
|
379
546
|
)
|
|
380
547
|
return err_class
|
|
381
548
|
|
|
@@ -393,17 +560,24 @@ def _new_summary() -> dict:
|
|
|
393
560
|
# so it must not be counted as drained/reconciled, which would
|
|
394
561
|
# report a retire that did not happen.
|
|
395
562
|
"archive_failed": 0,
|
|
563
|
+
# Redundant copies retired by `pending.collapse_duplicates` before
|
|
564
|
+
# any network work. Backlog mode only — see `_drain_backlog_impl`.
|
|
565
|
+
"collapsed": 0,
|
|
566
|
+
"dead_relocated": 0,
|
|
567
|
+
"resplit": 0,
|
|
568
|
+
"resplit_parts": 0,
|
|
396
569
|
"stalled": False,
|
|
397
570
|
"budget_exceeded": False,
|
|
398
571
|
}
|
|
399
572
|
|
|
400
573
|
|
|
401
574
|
def drain_backlog(config: dict | None = None, **kw) -> dict:
|
|
402
|
-
"""
|
|
575
|
+
"""Three-phase backlog replay, off the SessionStart budget entirely.
|
|
403
576
|
|
|
404
577
|
See the module docstring. Summary shape is ``drain()``'s plus
|
|
405
|
-
``reconciled`` (already durable — no POST issued)
|
|
406
|
-
(presence could not be established; left queued)
|
|
578
|
+
``reconciled`` (already durable — no POST issued), ``unknown``
|
|
579
|
+
(presence could not be established; left queued) and ``collapsed``
|
|
580
|
+
(redundant duplicate copies archived before any network work).
|
|
407
581
|
"""
|
|
408
582
|
return drain(config, backlog=True, **kw)
|
|
409
583
|
|
|
@@ -428,6 +602,10 @@ def drain(
|
|
|
428
602
|
"unknown": int, # presence unknown, left queued
|
|
429
603
|
"archive_failed": int, # durable, but the archive was unwritable
|
|
430
604
|
# so the entry is STILL QUEUED
|
|
605
|
+
"collapsed": int, # duplicate copies archived (backlog mode only)
|
|
606
|
+
"dead_relocated": int, # legacy .dead markers moved out of the queue dir
|
|
607
|
+
"resplit": int, # over-bound entries split into drainable parts
|
|
608
|
+
"resplit_parts": int, # parts those entries became
|
|
431
609
|
"stalled": bool, # stall guard tripped
|
|
432
610
|
"budget_exceeded": bool}
|
|
433
611
|
"""
|
|
@@ -440,7 +618,11 @@ def drain(
|
|
|
440
618
|
|
|
441
619
|
summary = _new_summary()
|
|
442
620
|
|
|
443
|
-
entries
|
|
621
|
+
# Budget-exhausted entries go last so they cannot starve the healthy ones
|
|
622
|
+
# behind them — see ``_drain_order``. Matters even more here than in the
|
|
623
|
+
# backlog drain: the in-hook budget is ~4s, so a single entry at the head
|
|
624
|
+
# that always burns its clamped timeout consumes the entire run.
|
|
625
|
+
entries = _drain_order(iter_entries())
|
|
444
626
|
if not entries:
|
|
445
627
|
debug_log(config, "drain_pending: queue empty")
|
|
446
628
|
return summary
|
|
@@ -496,14 +678,30 @@ def drain(
|
|
|
496
678
|
# allowance must not be handed out twice (#3599 review F2).
|
|
497
679
|
_retry_one(entry, timeout=_clamp(timeout, budget, started))
|
|
498
680
|
except Exception as e:
|
|
681
|
+
# Read the budget state BEFORE _record_failure: update_attempt
|
|
682
|
+
# mutates `entry["attempt_count"]` in place (lib/pending.py:1020),
|
|
683
|
+
# so asking afterwards would count the entry that just CROSSED the
|
|
684
|
+
# budget on this very failure as an abstainer, silently weakening
|
|
685
|
+
# the stall guard for ordinary entries.
|
|
686
|
+
abstains = _over_budget(entry)
|
|
499
687
|
err_class = _record_failure(config, path, entry, e, summary)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
688
|
+
# STALL GUARD ABSTENTION — see ``_over_budget``. The guard exists
|
|
689
|
+
# to detect a broken UPSTREAM and stop hammering it. A chronically
|
|
690
|
+
# failing entry that has already burned its attempt budget is
|
|
691
|
+
# evidence about that ENTRY, not about the upstream, and letting it
|
|
692
|
+
# vote is what wedges the queue: it never retires, so it fails
|
|
693
|
+
# identically on every run, and three of them end every run before
|
|
694
|
+
# the healthy entries behind them are ever reached. It abstains —
|
|
695
|
+
# neither incrementing the counter nor resetting it, so a genuine
|
|
696
|
+
# upstream outage is still caught by the fresh entries around it.
|
|
697
|
+
if not abstains:
|
|
698
|
+
if err_class == last_error_class:
|
|
699
|
+
consecutive_failures += 1
|
|
700
|
+
else:
|
|
701
|
+
consecutive_failures = 1
|
|
702
|
+
last_error_class = err_class
|
|
505
703
|
|
|
506
|
-
if consecutive_failures >= STALL_THRESHOLD:
|
|
704
|
+
if not abstains and consecutive_failures >= STALL_THRESHOLD:
|
|
507
705
|
summary["stalled"] = True
|
|
508
706
|
print(
|
|
509
707
|
f"[Hindsight] drain_pending: {consecutive_failures} consecutive "
|
|
@@ -626,9 +824,65 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
|
|
|
626
824
|
def _drain_backlog_impl(
|
|
627
825
|
config: dict, phase: str = "both", dry_run: bool = False
|
|
628
826
|
) -> dict:
|
|
629
|
-
"""Concurrent-capable, long-budget,
|
|
827
|
+
"""Concurrent-capable, long-budget, three-phase backlog replay."""
|
|
630
828
|
summary = _new_summary()
|
|
631
829
|
|
|
830
|
+
# PHASE 0 — collapse duplicates (local, free, no network at all).
|
|
831
|
+
#
|
|
832
|
+
# Runs FIRST because every later phase is per-entry: a GET in phase 1
|
|
833
|
+
# and, worse, a ~168 s LLM-backed extraction in phase 2. On the measured
|
|
834
|
+
# 2026-07-26 fleet backlog ~65% of queued files were byte-identical
|
|
835
|
+
# copies of another queued file (top group 32x), so skipping this pass
|
|
836
|
+
# means paying phase 2 up to 32 times over for one memory.
|
|
837
|
+
#
|
|
838
|
+
# DELIBERATELY NOT run by the in-hook drain. It reads every queued entry
|
|
839
|
+
# to recompute identity from content — bounded, but not instant — and
|
|
840
|
+
# the SessionStart drain's whole contract is a hard wall-clock ceiling
|
|
841
|
+
# on hook latency. New duplicates cannot accumulate there anyway:
|
|
842
|
+
# `pending.enqueue`'s filename-keyed guard stops those at the producer.
|
|
843
|
+
if not dry_run:
|
|
844
|
+
summary["collapsed"] = collapse_duplicates()
|
|
845
|
+
if summary["collapsed"]:
|
|
846
|
+
_blog(
|
|
847
|
+
f"phase 0: collapsed {summary['collapsed']} duplicate entries "
|
|
848
|
+
f"(byte-identical content already queued under another entry); "
|
|
849
|
+
f"archived, not deleted"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# PHASE 0b — relocate any legacy `.dead` markers out of the live
|
|
853
|
+
# queue directory. Free, local, and an UPGRADE step: markers written
|
|
854
|
+
# by an older build sit in the directory external janitors sweep, and
|
|
855
|
+
# a marker is the only remaining copy of its memory.
|
|
856
|
+
moved = sweep_legacy_dead_markers()
|
|
857
|
+
if moved:
|
|
858
|
+
summary["dead_relocated"] = moved
|
|
859
|
+
_blog(
|
|
860
|
+
f"phase 0b: relocated {moved} legacy .dead marker(s) out of "
|
|
861
|
+
f"the live queue directory; the queue now holds only live "
|
|
862
|
+
f"entries, so no janitor glob over it can match a memory"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# PHASE 0c — re-split entries the drain provably CANNOT retain.
|
|
866
|
+
#
|
|
867
|
+
# An entry over `retain_content_limit()` needs more sequential
|
|
868
|
+
# extraction calls than fit the deadline, so every POST is guaranteed
|
|
869
|
+
# waste; if the server rejects the body as a 4xx it is classified
|
|
870
|
+
# permanent and the memory goes `.dead`. Splitting it makes every
|
|
871
|
+
# part drainable, which is the difference between a lost memory and a
|
|
872
|
+
# slow one. Runs after the duplicate collapse so a duplicated
|
|
873
|
+
# over-bound entry is split ONCE, not once per copy.
|
|
874
|
+
entries_split, parts_written = resplit_over_bound_entries()
|
|
875
|
+
if entries_split:
|
|
876
|
+
summary["resplit"] = entries_split
|
|
877
|
+
summary["resplit_parts"] = parts_written
|
|
878
|
+
_blog(
|
|
879
|
+
f"phase 0c: re-split {entries_split} entr"
|
|
880
|
+
f"{'y' if entries_split == 1 else 'ies'} over the "
|
|
881
|
+
f"{retain_content_limit()}-char retain bound into "
|
|
882
|
+
f"{parts_written} drainable part(s); the originals are "
|
|
883
|
+
f"archived, not deleted"
|
|
884
|
+
)
|
|
885
|
+
|
|
632
886
|
if phase in ("reconcile", "both"):
|
|
633
887
|
_reconcile_phase(config, summary, dry_run)
|
|
634
888
|
if phase == "reconcile":
|
|
@@ -641,7 +895,10 @@ def _drain_backlog_impl(
|
|
|
641
895
|
backoff_ms = _p95_backoff_ms()
|
|
642
896
|
started = time.monotonic()
|
|
643
897
|
|
|
644
|
-
entries
|
|
898
|
+
# See ``_drain_order``: without this, three budget-exhausted entries at the
|
|
899
|
+
# head trip the stall guard on every run forever and the drain never makes
|
|
900
|
+
# progress again.
|
|
901
|
+
entries = _drain_order(iter_entries())
|
|
645
902
|
if not entries:
|
|
646
903
|
_blog("phase 2: nothing left to retain")
|
|
647
904
|
return summary
|
|
@@ -728,15 +985,22 @@ def _drain_backlog_impl(
|
|
|
728
985
|
# and breaking immediately after the tripping entry makes the
|
|
729
986
|
# two paths bump exactly the same number of entries.
|
|
730
987
|
err_class = type(err).__name__
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
988
|
+
# STALL GUARD ABSTENTION for entries past their attempt budget —
|
|
989
|
+
# see ``_over_budget`` and the matching branch in the sequential
|
|
990
|
+
# drain. Evaluated here, before ``_record_failure``, which is both
|
|
991
|
+
# where the sequential drain evaluates it and necessary anyway
|
|
992
|
+
# because ``update_attempt`` mutates ``attempt_count`` in place.
|
|
993
|
+
abstains = _over_budget(entry)
|
|
994
|
+
if not abstains:
|
|
995
|
+
if err_class == last_error_class:
|
|
996
|
+
consecutive_failures += 1
|
|
997
|
+
else:
|
|
998
|
+
consecutive_failures = 1
|
|
999
|
+
last_error_class = err_class
|
|
736
1000
|
|
|
737
1001
|
_record_failure(config, path, entry, err, summary)
|
|
738
1002
|
|
|
739
|
-
if consecutive_failures >= STALL_THRESHOLD:
|
|
1003
|
+
if not abstains and consecutive_failures >= STALL_THRESHOLD:
|
|
740
1004
|
summary["stalled"] = True
|
|
741
1005
|
_blog(
|
|
742
1006
|
f"{consecutive_failures} consecutive failures with "
|
|
@@ -767,7 +1031,7 @@ def _parse_args(argv: list[str] | None):
|
|
|
767
1031
|
ap.add_argument(
|
|
768
1032
|
"--backlog",
|
|
769
1033
|
action="store_true",
|
|
770
|
-
help="
|
|
1034
|
+
help="three-phase backlog replay, off the SessionStart budget",
|
|
771
1035
|
)
|
|
772
1036
|
ap.add_argument(
|
|
773
1037
|
"--phase",
|
|
@@ -799,6 +1063,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
799
1063
|
if any(
|
|
800
1064
|
summary[k]
|
|
801
1065
|
for k in (
|
|
1066
|
+
"collapsed",
|
|
1067
|
+
"dead_relocated",
|
|
1068
|
+
"resplit",
|
|
802
1069
|
"drained",
|
|
803
1070
|
"retried",
|
|
804
1071
|
"dead",
|
|
@@ -810,6 +1077,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
810
1077
|
print(
|
|
811
1078
|
f"[Hindsight] drain_pending{'(backlog)' if args.backlog else ''}: "
|
|
812
1079
|
f"drained={summary['drained']} reconciled={summary['reconciled']} "
|
|
1080
|
+
f"collapsed={summary['collapsed']} "
|
|
1081
|
+
f"dead_relocated={summary['dead_relocated']} "
|
|
1082
|
+
f"resplit={summary['resplit']}(+{summary['resplit_parts']} parts) "
|
|
813
1083
|
f"retried={summary['retried']} dead={summary['dead']} "
|
|
814
1084
|
f"unknown={summary['unknown']} "
|
|
815
1085
|
f"archive_failed={summary['archive_failed']} "
|