switchroom 0.19.27 → 0.19.28
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 +5 -2
- package/dist/auth-broker/index.js +129 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +796 -35
- package/dist/host-control/main.js +130 -9
- package/dist/vault/approvals/kernel-server.js +129 -8
- package/dist/vault/broker/server.js +129 -8
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +70 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +568 -49
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -556,6 +556,112 @@ def _dir_bytes(d: str, names) -> int:
|
|
|
556
556
|
return total
|
|
557
557
|
|
|
558
558
|
|
|
559
|
+
def _append_ledger(line: str) -> None:
|
|
560
|
+
"""Append one line to ``pending-evictions.log``, rotating at its cap.
|
|
561
|
+
|
|
562
|
+
Split out of ``_log_eviction`` (#3688 review R1-M2) so the OTHER way a
|
|
563
|
+
payload leaves this module for good — ``_trim_dir`` shedding an archived
|
|
564
|
+
copy — can be ledgered on exactly the same terms instead of leaving no
|
|
565
|
+
durable trace at all. Best-effort: a ledger that cannot be written *or
|
|
566
|
+
read back* must never take the caller down with it — hence the guard
|
|
567
|
+
catches ``ValueError`` as well as ``OSError`` (see there).
|
|
568
|
+
|
|
569
|
+
ROTATION PRIORITISES ``evicted=`` INSIDE THE SAME BUDGET, and that is a
|
|
570
|
+
direct consequence of sharing the file. Before #3688 every line here
|
|
571
|
+
was an eviction, so "keep the newest ``EVICTIONS_LOG_KEEP_LINES``
|
|
572
|
+
lines" and "keep the newest 2,000 evictions" were the same sentence.
|
|
573
|
+
They are not any more: a single ``collapse_duplicates`` run over the
|
|
574
|
+
measured 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
|
|
575
|
+
tail-rotate would let that noise push real ``evicted=`` lines out of
|
|
576
|
+
the 7-day window ``switchroom doctor`` reads — i.e. adding
|
|
577
|
+
observability for the BENIGN channel would have removed it for the one
|
|
578
|
+
channel that means memory is actually gone.
|
|
579
|
+
|
|
580
|
+
So the keep window is filled evictions-first and only then topped up
|
|
581
|
+
with the newest remaining lines. The budget is unchanged at
|
|
582
|
+
``EVICTIONS_LOG_KEEP_LINES`` lines TOTAL — a ledger of nothing but
|
|
583
|
+
evictions rotates exactly as it did before #3688 — and chronological
|
|
584
|
+
order is preserved because the selection is by index over the original
|
|
585
|
+
lines, which ``switchroom doctor``'s ``$1 >= cutoff`` awk depends on.
|
|
586
|
+
"""
|
|
587
|
+
log = evictions_log_path()
|
|
588
|
+
try:
|
|
589
|
+
with open(log, "a", encoding="utf-8") as f:
|
|
590
|
+
print(line, file=f)
|
|
591
|
+
# Bounded, not append-forever. `switchroom doctor` windows this by
|
|
592
|
+
# timestamp so a single legitimate eviction can't turn the row red
|
|
593
|
+
# permanently, but the FILE still needs a ceiling of its own.
|
|
594
|
+
if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
|
|
595
|
+
with open(log, encoding="utf-8") as f:
|
|
596
|
+
lines = f.readlines()
|
|
597
|
+
evictions = [i for i, ln in enumerate(lines) if "evicted=" in ln]
|
|
598
|
+
others = [i for i, ln in enumerate(lines) if "evicted=" not in ln]
|
|
599
|
+
keep_idx = set(evictions[-EVICTIONS_LOG_KEEP_LINES:])
|
|
600
|
+
room = EVICTIONS_LOG_KEEP_LINES - len(keep_idx)
|
|
601
|
+
if room > 0:
|
|
602
|
+
keep_idx.update(others[-room:])
|
|
603
|
+
kept = [ln for i, ln in enumerate(lines) if i in keep_idx]
|
|
604
|
+
tmp = log + ".tmp"
|
|
605
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
606
|
+
f.writelines(kept)
|
|
607
|
+
os.chmod(tmp, 0o600)
|
|
608
|
+
os.replace(tmp, log)
|
|
609
|
+
except (OSError, ValueError):
|
|
610
|
+
# ValueError, not just OSError, because BOTH codec errors are
|
|
611
|
+
# ValueError subclasses and neither is an OSError (#3688 re-review
|
|
612
|
+
# B1). The rotate READS the ledger back (``readlines()`` above) —
|
|
613
|
+
# something no revision before #3688 did — so a ledger holding one
|
|
614
|
+
# non-UTF-8 byte raises ``UnicodeDecodeError`` here, and a name
|
|
615
|
+
# carrying a surrogate raises ``UnicodeEncodeError`` at the
|
|
616
|
+
# ``print`` above. Under a bare ``except OSError`` both escape this
|
|
617
|
+
# "best-effort" guard: ``_trim_dir`` → ``_log_archive_trim`` → here
|
|
618
|
+
# runs inside ``archive_reconciled``/``enqueue``, so one corrupt
|
|
619
|
+
# ledger byte would take down every drain — on the sidecar,
|
|
620
|
+
# identically, every 900s forever. A ledger that cannot be written
|
|
621
|
+
# must never take the caller down with it, and that has to include
|
|
622
|
+
# the codec.
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _log_archive_trim(
|
|
627
|
+
name: str, size: int, reason: str, archive: str, depth: int, nbytes: int
|
|
628
|
+
) -> None:
|
|
629
|
+
"""Ledger ONE archived copy shed by ``_trim_dir``.
|
|
630
|
+
|
|
631
|
+
Until #3688 review R1-M2 a trim was stderr-only: an entry could enter
|
|
632
|
+
``pending-duplicate/`` (or ``pending-evicted/``, or
|
|
633
|
+
``pending-reconciled/``) and then be deleted from it with no durable
|
|
634
|
+
record anywhere, while an *eviction* — the other end of the same
|
|
635
|
+
payload's life — has had ``pending-evictions.log`` since #3599. stderr
|
|
636
|
+
is gone with the process; the ledger is what an operator can still read
|
|
637
|
+
a week later. Same file, because it answers one question ("what left
|
|
638
|
+
this agent's queue, and when").
|
|
639
|
+
|
|
640
|
+
THE FIRST TOKEN IS DELIBERATELY ``trimmed=``, NOT ``evicted=``.
|
|
641
|
+
``switchroom doctor``'s probe counts eviction ledger lines with
|
|
642
|
+
``awk '$1 >= cutoff && /evicted=/'`` (src/cli/doctor.ts, see
|
|
643
|
+
``buildPendingRetainsProbeScript``), and that count FAILS the
|
|
644
|
+
pending-retains row. A trim is not an eviction — nothing was shed from
|
|
645
|
+
the live queue and, for ``pending-duplicate/``, a byte-identical copy is
|
|
646
|
+
still queued — so a trim must not be able to turn that row red. No field
|
|
647
|
+
on this line may ever be formatted such that ``evicted=`` appears in it;
|
|
648
|
+
``ArchiveTrimLedgerTest`` pins that.
|
|
649
|
+
"""
|
|
650
|
+
_append_ledger(
|
|
651
|
+
"%s trimmed=%s bytes=%d archive=%s reason=archive-%s "
|
|
652
|
+
"archive_depth=%d archive_bytes=%d"
|
|
653
|
+
% (
|
|
654
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
655
|
+
name,
|
|
656
|
+
size,
|
|
657
|
+
archive,
|
|
658
|
+
reason,
|
|
659
|
+
depth,
|
|
660
|
+
nbytes,
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
559
665
|
def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
560
666
|
"""Keep ``a`` under its count/byte caps, deleting OLDEST first.
|
|
561
667
|
|
|
@@ -610,6 +716,21 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
|
610
716
|
``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
|
|
611
717
|
an entry only reaches it through the ledgered eviction path, and under
|
|
612
718
|
sustained ENOSPC it may not reach it at all.
|
|
719
|
+
|
|
720
|
+
EVERY DROP IS LEDGERED, AND ONLY A DROP (#3688 review R1-M2, tightened
|
|
721
|
+
by re-review B2). One ``trimmed=… archive=…`` line per shed copy goes
|
|
722
|
+
to ``pending-evictions.log`` via ``_log_archive_trim``, so the trim
|
|
723
|
+
horizon above is observable after the fact and not only in a stderr
|
|
724
|
+
stream nobody kept. That line is deliberately NOT counted as an
|
|
725
|
+
eviction by doctor — see ``_log_archive_trim``.
|
|
726
|
+
|
|
727
|
+
A victim whose ``os.remove`` FAILS (EACCES, read-only mount) is neither
|
|
728
|
+
ledgered nor counted nor named on stderr: it is still on disk, so a
|
|
729
|
+
``trimmed=`` line for it would be a false claim of deletion in exactly
|
|
730
|
+
the record an operator consults after suspected loss, and the returned
|
|
731
|
+
count is what callers report as "copies shed". ``TrimDirBoundaryTest``
|
|
732
|
+
pins that a failed remove yields ``0``, no ledger line, and every file
|
|
733
|
+
still present.
|
|
613
734
|
"""
|
|
614
735
|
# `.json` ONLY, and that is load-bearing beyond "skip stray files": a
|
|
615
736
|
# `.dead` marker is named `<entry>.json.dead`, so it can never be
|
|
@@ -622,14 +743,35 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
|
622
743
|
except OSError:
|
|
623
744
|
return 0
|
|
624
745
|
dropped = []
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
):
|
|
746
|
+
label = os.path.basename(a.rstrip("/"))
|
|
747
|
+
nbytes = _dir_bytes(a, names)
|
|
748
|
+
while names and (len(names) > max_entries or nbytes > max_bytes):
|
|
749
|
+
reason = "count" if len(names) > max_entries else "bytes"
|
|
750
|
+
victim = names.pop(0)
|
|
751
|
+
vpath = os.path.join(a, victim)
|
|
628
752
|
try:
|
|
629
|
-
os.
|
|
753
|
+
vsize = os.path.getsize(vpath)
|
|
630
754
|
except OSError:
|
|
631
|
-
|
|
632
|
-
|
|
755
|
+
vsize = 0
|
|
756
|
+
try:
|
|
757
|
+
os.remove(vpath)
|
|
758
|
+
except OSError:
|
|
759
|
+
# The copy is still on disk, so the running total would drift.
|
|
760
|
+
# Re-measure rather than assume — this preserves the original
|
|
761
|
+
# recompute-every-iteration semantics on the one path where a
|
|
762
|
+
# decrement would be wrong (EACCES / read-only mount).
|
|
763
|
+
nbytes = _dir_bytes(a, names)
|
|
764
|
+
# NOT counted and NOT ledgered (#3688 re-review B2). The ledger
|
|
765
|
+
# is the forensic record an operator reads AFTER suspected data
|
|
766
|
+
# loss; a `trimmed=` line for a copy still sitting on disk sends
|
|
767
|
+
# them hunting a payload that never left, and the returned count
|
|
768
|
+
# (and the stderr line below) would name files that were not
|
|
769
|
+
# dropped. A ledger that claims deletions which never happened
|
|
770
|
+
# is worse than no ledger.
|
|
771
|
+
continue
|
|
772
|
+
nbytes -= vsize
|
|
773
|
+
dropped.append(victim)
|
|
774
|
+
_log_archive_trim(victim, vsize, reason, label, len(names), nbytes)
|
|
633
775
|
if dropped:
|
|
634
776
|
shown = ", ".join(dropped[:10])
|
|
635
777
|
if len(dropped) > 10:
|
|
@@ -736,6 +878,47 @@ def archive_duplicate(path: str) -> Optional[str]:
|
|
|
736
878
|
return dest
|
|
737
879
|
|
|
738
880
|
|
|
881
|
+
def _attempt_count(entry: dict) -> int:
|
|
882
|
+
"""How many retries this copy has BURNED, for survivor selection.
|
|
883
|
+
|
|
884
|
+
Not ``int(entry.get("attempt_count", 1) or 1)`` (#3688 review R1-L3).
|
|
885
|
+
That expression had two defects, both latent rather than reachable
|
|
886
|
+
today — ``_build_entry`` seeds ``1`` and ``update_attempt`` only ever
|
|
887
|
+
increments — which is exactly why they need pinning rather than
|
|
888
|
+
ignoring: nothing would have caught them turning real.
|
|
889
|
+
|
|
890
|
+
* ``0 or 1`` is ``1``, so a NEVER-ATTEMPTED copy scored the same as one
|
|
891
|
+
that had already burned an attempt, and lost the tie-break to it if
|
|
892
|
+
it happened to be the newer file. That is backwards from the rule
|
|
893
|
+
``collapse_duplicates`` documents ("the copy with the most retries
|
|
894
|
+
left before ``MAX_ATTEMPTS`` wins") — a 0 must beat a 1.
|
|
895
|
+
* ``int("many")`` RAISES. A single semantically-malformed entry — valid
|
|
896
|
+
JSON, so ``iter_entries`` hands it over rather than quarantining it —
|
|
897
|
+
took the whole collapse pass down with it, and with it (before the
|
|
898
|
+
guard in ``drain_pending._drain_backlog_impl``) the whole drain.
|
|
899
|
+
Unparseable now scores ``MAX_ATTEMPTS``: we cannot tell how many
|
|
900
|
+
attempts it has left, so it is never PREFERRED as the survivor, but
|
|
901
|
+
it is never the reason a collapse fails either. Losing it costs
|
|
902
|
+
nothing anyway — every copy in a group is byte-identical, and the
|
|
903
|
+
loser is archived, not deleted.
|
|
904
|
+
|
|
905
|
+
A missing or ``None`` count reads as ``1``, matching what
|
|
906
|
+
``_build_entry`` writes, so an entry from an older build that predates
|
|
907
|
+
the field is treated as freshly queued rather than exhausted.
|
|
908
|
+
"""
|
|
909
|
+
raw = entry.get("attempt_count", 1)
|
|
910
|
+
if raw is None:
|
|
911
|
+
return 1
|
|
912
|
+
# bool is an int subclass; True would silently score 1. Neither True nor
|
|
913
|
+
# False is an attempt count, so both take the unparseable branch.
|
|
914
|
+
if isinstance(raw, bool):
|
|
915
|
+
return MAX_ATTEMPTS
|
|
916
|
+
try:
|
|
917
|
+
return int(raw)
|
|
918
|
+
except (TypeError, ValueError):
|
|
919
|
+
return MAX_ATTEMPTS
|
|
920
|
+
|
|
921
|
+
|
|
739
922
|
def collapse_duplicates() -> int:
|
|
740
923
|
"""Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
|
|
741
924
|
|
|
@@ -754,7 +937,8 @@ def collapse_duplicates() -> int:
|
|
|
754
937
|
memory; the one with the most attempts left is the one most likely to
|
|
755
938
|
get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
|
|
756
939
|
oldest outright would systematically keep the most-attempted copy —
|
|
757
|
-
exactly backwards.
|
|
940
|
+
exactly backwards. ``_attempt_count`` defines "lowest", including what
|
|
941
|
+
a ``0`` and what a malformed count are worth.
|
|
758
942
|
|
|
759
943
|
An entry whose key is ``None`` (no ``content``) is never grouped: its
|
|
760
944
|
identity cannot be established, so it is always kept.
|
|
@@ -773,10 +957,7 @@ def collapse_duplicates() -> int:
|
|
|
773
957
|
for key, members in groups.items():
|
|
774
958
|
if len(members) < 2:
|
|
775
959
|
continue
|
|
776
|
-
survivor = min(
|
|
777
|
-
members,
|
|
778
|
-
key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
|
|
779
|
-
)
|
|
960
|
+
survivor = min(members, key=lambda m: (_attempt_count(m[2]), m[0]))
|
|
780
961
|
for member in members:
|
|
781
962
|
if member is survivor:
|
|
782
963
|
continue
|
|
@@ -1047,23 +1228,7 @@ def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) ->
|
|
|
1047
1228
|
depth,
|
|
1048
1229
|
nbytes,
|
|
1049
1230
|
)
|
|
1050
|
-
|
|
1051
|
-
try:
|
|
1052
|
-
with open(log, "a", encoding="utf-8") as f:
|
|
1053
|
-
print(line, file=f)
|
|
1054
|
-
# Bounded, not append-forever. `switchroom doctor` windows this by
|
|
1055
|
-
# timestamp so a single legitimate eviction can't turn the row red
|
|
1056
|
-
# permanently, but the FILE still needs a ceiling of its own.
|
|
1057
|
-
if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
|
|
1058
|
-
with open(log, encoding="utf-8") as f:
|
|
1059
|
-
kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
|
|
1060
|
-
tmp = log + ".tmp"
|
|
1061
|
-
with open(tmp, "w", encoding="utf-8") as f:
|
|
1062
|
-
f.writelines(kept)
|
|
1063
|
-
os.chmod(tmp, 0o600)
|
|
1064
|
-
os.replace(tmp, log)
|
|
1065
|
-
except OSError:
|
|
1066
|
-
pass
|
|
1231
|
+
_append_ledger(line)
|
|
1067
1232
|
print(
|
|
1068
1233
|
"[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
|
|
1069
1234
|
"newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""A permanently-failing entry must stop costing LLM lane time.
|
|
2
|
+
|
|
3
|
+
Lives under ``scripts/tests/`` because that is the only python test directory
|
|
4
|
+
CI discovers (``ci-tests-python.yml`` runs ``python3 -m unittest discover
|
|
5
|
+
tests/`` with ``working-directory: vendor/hindsight-memory/scripts``).
|
|
6
|
+
|
|
7
|
+
BACKGROUND. The permanence gate (``pending.is_permanent_failure``) keeps an
|
|
8
|
+
entry queued past ``MAX_ATTEMPTS`` on anything but a 4xx, and ``_over_budget``
|
|
9
|
+
/ ``_drain_order`` then DEMOTE it so it cannot starve healthy entries behind
|
|
10
|
+
it. Both are right, and together they bound head-of-line blocking — but not
|
|
11
|
+
TOTAL work: the entry is still retried in full on every single run, forever.
|
|
12
|
+
|
|
13
|
+
That was affordable when every drain was supervised: a 4-second in-hook pass,
|
|
14
|
+
or an operator watching a manual sweep. The ``hindsight-drain`` sidecar makes
|
|
15
|
+
it unaffordable. Its backlog budget is 3600s (``_backlog_budget_seconds``)
|
|
16
|
+
against a 900s cooldown, so a wedged agent spends ~80% of wall-clock draining,
|
|
17
|
+
unattended, against 4 lanes shared with live retains, reflect and
|
|
18
|
+
consolidation — indefinitely. The interim host stopgap recorded exactly that
|
|
19
|
+
shape while it ran: ``finn 280s drained=0 retried=3 STALLED``, ``carrie 564s
|
|
20
|
+
drained=0 retried=2 (lane busy)``.
|
|
21
|
+
|
|
22
|
+
So an entry past ``_attempt_ceiling()`` is PARKED. Parking is not retirement:
|
|
23
|
+
the entry stays on disk, keeps being swept for free by the reconcile pass, and
|
|
24
|
+
comes back in full under ``--force``. What it stops is paying ~168s of a
|
|
25
|
+
shared lane, again, for a POST that has already failed 20 times.
|
|
26
|
+
|
|
27
|
+
Every test here fails without the ``_park_broken`` call in the drain paths:
|
|
28
|
+
the parked entry is retained again, which is the unbounded-retry behaviour.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import io
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import unittest
|
|
37
|
+
import unittest.mock
|
|
38
|
+
from contextlib import redirect_stderr
|
|
39
|
+
|
|
40
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
41
|
+
if SCRIPTS_DIR not in sys.path:
|
|
42
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
43
|
+
|
|
44
|
+
import drain_pending # noqa: E402
|
|
45
|
+
import lib.pending as pending # noqa: E402
|
|
46
|
+
|
|
47
|
+
from tests.test_pending_drops import ( # noqa: E402
|
|
48
|
+
CONFIG,
|
|
49
|
+
_QueueTempDirMixin,
|
|
50
|
+
_cd_doc,
|
|
51
|
+
_payload,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CircuitBreakerTest(_QueueTempDirMixin, unittest.TestCase):
|
|
56
|
+
ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
|
|
57
|
+
|
|
58
|
+
def setUp(self):
|
|
59
|
+
super().setUp()
|
|
60
|
+
os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
|
|
61
|
+
self.posted = []
|
|
62
|
+
|
|
63
|
+
def _seed(self, attempts_by_content):
|
|
64
|
+
"""Queue one entry per ``{content: attempt_count}`` pair."""
|
|
65
|
+
clock = iter(1000.0 + i for i in range(50))
|
|
66
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
67
|
+
for i, content in enumerate(attempts_by_content):
|
|
68
|
+
pending.enqueue(
|
|
69
|
+
_payload(content=content, doc=_cd_doc(i)), RuntimeError("x")
|
|
70
|
+
)
|
|
71
|
+
for path, entry in pending.iter_entries():
|
|
72
|
+
entry["attempt_count"] = attempts_by_content[entry["content"]]
|
|
73
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
74
|
+
json.dump(entry, f)
|
|
75
|
+
|
|
76
|
+
def _record_post(self, entry, timeout):
|
|
77
|
+
self.posted.append(entry["content"])
|
|
78
|
+
|
|
79
|
+
def _drain(self, present=False, **kw):
|
|
80
|
+
with unittest.mock.patch.object(
|
|
81
|
+
drain_pending, "_retry_one", self._record_post
|
|
82
|
+
):
|
|
83
|
+
with unittest.mock.patch.object(
|
|
84
|
+
drain_pending, "_document_state", lambda e, timeout=30: present
|
|
85
|
+
):
|
|
86
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
87
|
+
summary = drain_pending.drain(CONFIG, **kw)
|
|
88
|
+
return summary, err.getvalue()
|
|
89
|
+
|
|
90
|
+
# ── the breaker itself ────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def test_the_unattended_backlog_drain_stops_retrying_a_wedged_entry(self):
|
|
93
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
94
|
+
self._seed({"wedged": ceiling, "healthy": 1})
|
|
95
|
+
|
|
96
|
+
summary, err = self._drain(backlog=True, phase="drain")
|
|
97
|
+
|
|
98
|
+
self.assertEqual(
|
|
99
|
+
self.posted,
|
|
100
|
+
["healthy"],
|
|
101
|
+
"an entry past the attempt ceiling must not be retained again",
|
|
102
|
+
)
|
|
103
|
+
self.assertEqual(summary["parked"], 1)
|
|
104
|
+
self.assertIn("parked 1 entries past the attempt ceiling", err)
|
|
105
|
+
|
|
106
|
+
def test_parking_is_not_retirement_the_entry_is_still_on_disk(self):
|
|
107
|
+
"""The whole permanence gate rests on never destroying a memory. A
|
|
108
|
+
breaker that deleted, or promoted to ``.dead``, would trade the
|
|
109
|
+
wedge for the data loss the gate exists to prevent."""
|
|
110
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
111
|
+
self._seed({"wedged": ceiling})
|
|
112
|
+
|
|
113
|
+
self._drain(backlog=True, phase="drain")
|
|
114
|
+
|
|
115
|
+
self.assertEqual(pending.count(), 1)
|
|
116
|
+
self.assertEqual(
|
|
117
|
+
[e["content"] for _p, e in pending.iter_entries()], ["wedged"]
|
|
118
|
+
)
|
|
119
|
+
self.assertEqual(
|
|
120
|
+
[n for n in os.listdir(self._dir) if n.endswith(".dead")],
|
|
121
|
+
[],
|
|
122
|
+
"parking must not retire the entry",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def test_a_parked_entry_is_still_reconciled_for_free(self):
|
|
126
|
+
"""Parking withholds the expensive retry, not the cheap proof: if the
|
|
127
|
+
document DID land, the free presence pass still retires the entry."""
|
|
128
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
129
|
+
self._seed({"wedged": ceiling})
|
|
130
|
+
|
|
131
|
+
summary, _ = self._drain(backlog=True, present=True, phase="reconcile")
|
|
132
|
+
|
|
133
|
+
self.assertEqual(summary["reconciled"], 1)
|
|
134
|
+
self.assertEqual(self.posted, [], "no POST was needed")
|
|
135
|
+
self.assertEqual(pending.count(), 0)
|
|
136
|
+
|
|
137
|
+
def test_force_retries_the_parked_entries_anyway(self):
|
|
138
|
+
"""The escape hatch. Without it the breaker would be a one-way door,
|
|
139
|
+
and an operator who fixed the upstream could never replay."""
|
|
140
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
141
|
+
self._seed({"wedged": ceiling, "healthy": 1})
|
|
142
|
+
|
|
143
|
+
summary, _ = self._drain(backlog=True, phase="drain", force=True)
|
|
144
|
+
|
|
145
|
+
self.assertEqual(sorted(self.posted), ["healthy", "wedged"])
|
|
146
|
+
self.assertEqual(summary["parked"], 0)
|
|
147
|
+
|
|
148
|
+
def test_an_entry_one_attempt_below_the_ceiling_still_drains(self):
|
|
149
|
+
"""The boundary. A breaker that fired early would park entries the
|
|
150
|
+
demotion logic is still successfully draining."""
|
|
151
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
152
|
+
self._seed({"nearly": ceiling - 1})
|
|
153
|
+
|
|
154
|
+
summary, _ = self._drain(backlog=True, phase="drain")
|
|
155
|
+
|
|
156
|
+
self.assertEqual(self.posted, ["nearly"])
|
|
157
|
+
self.assertEqual(summary["parked"], 0)
|
|
158
|
+
|
|
159
|
+
def test_the_in_hook_drain_parks_too(self):
|
|
160
|
+
"""Same policy on the SessionStart path: a 4s budget spent on an
|
|
161
|
+
entry that has failed 20 times is 4s not spent on the rest."""
|
|
162
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
163
|
+
self._seed({"wedged": ceiling, "healthy": 1})
|
|
164
|
+
|
|
165
|
+
summary, _ = self._drain()
|
|
166
|
+
|
|
167
|
+
self.assertEqual(self.posted, ["healthy"])
|
|
168
|
+
self.assertEqual(summary["parked"], 1)
|
|
169
|
+
|
|
170
|
+
def test_repeated_unattended_runs_never_touch_the_wedged_entry_again(self):
|
|
171
|
+
"""The actual failure mode: not one wasted run, but every run from
|
|
172
|
+
here to the heat death of the container."""
|
|
173
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
174
|
+
self._seed({"wedged": ceiling})
|
|
175
|
+
|
|
176
|
+
for _ in range(5):
|
|
177
|
+
self._drain(backlog=True, phase="drain")
|
|
178
|
+
|
|
179
|
+
self.assertEqual(self.posted, [], "5 unattended ticks, zero lane time")
|
|
180
|
+
self.assertEqual(pending.count(), 1, "and the memory is still there")
|
|
181
|
+
|
|
182
|
+
# ── the ceiling ───────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def test_the_default_ceiling_is_a_multiple_of_the_attempt_budget(self):
|
|
185
|
+
self.assertEqual(
|
|
186
|
+
drain_pending._attempt_ceiling(),
|
|
187
|
+
pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
|
|
188
|
+
)
|
|
189
|
+
self.assertGreater(
|
|
190
|
+
drain_pending._attempt_ceiling(),
|
|
191
|
+
pending.MAX_ATTEMPTS,
|
|
192
|
+
"parking at the attempt budget would collide with demotion",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def test_the_ceiling_is_operator_tunable(self):
|
|
196
|
+
os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "7"
|
|
197
|
+
self.assertEqual(drain_pending._attempt_ceiling(), 7)
|
|
198
|
+
self._seed({"wedged": 7, "healthy": 6})
|
|
199
|
+
|
|
200
|
+
summary, _ = self._drain(backlog=True, phase="drain")
|
|
201
|
+
|
|
202
|
+
self.assertEqual(self.posted, ["healthy"])
|
|
203
|
+
self.assertEqual(summary["parked"], 1)
|
|
204
|
+
|
|
205
|
+
def test_a_ceiling_below_the_attempt_budget_is_clamped_up(self):
|
|
206
|
+
"""Below ``MAX_ATTEMPTS`` the breaker would park entries before the
|
|
207
|
+
ordinary retry policy has finished with them."""
|
|
208
|
+
os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "1"
|
|
209
|
+
self.assertEqual(drain_pending._attempt_ceiling(), pending.MAX_ATTEMPTS)
|
|
210
|
+
|
|
211
|
+
def test_a_garbage_ceiling_falls_back_to_the_default(self):
|
|
212
|
+
os.environ["HINDSIGHT_DRAIN_ATTEMPT_CEILING"] = "not-a-number"
|
|
213
|
+
self.assertEqual(
|
|
214
|
+
drain_pending._attempt_ceiling(),
|
|
215
|
+
pending.MAX_ATTEMPTS * drain_pending.ATTEMPT_CEILING_MULTIPLE,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def test_a_corrupt_attempt_counter_never_parks_and_never_raises(self):
|
|
219
|
+
"""Same rule as ``_over_budget``: a hand-edited counter must not
|
|
220
|
+
decide policy, and must not blow up the drain path."""
|
|
221
|
+
self.assertFalse(drain_pending._circuit_broken({"attempt_count": "many"}))
|
|
222
|
+
self.assertFalse(drain_pending._circuit_broken({"attempt_count": None}))
|
|
223
|
+
self.assertFalse(drain_pending._circuit_broken({}))
|
|
224
|
+
|
|
225
|
+
# ── reporting: parking must be VISIBLE, on every path ─────────────
|
|
226
|
+
|
|
227
|
+
def _main(self, argv, present=False):
|
|
228
|
+
"""Run the CLI entrypoint, capturing what an operator would see."""
|
|
229
|
+
with unittest.mock.patch.object(drain_pending, "load_config", lambda: CONFIG):
|
|
230
|
+
with unittest.mock.patch.object(
|
|
231
|
+
drain_pending, "_retry_one", self._record_post
|
|
232
|
+
):
|
|
233
|
+
with unittest.mock.patch.object(
|
|
234
|
+
drain_pending, "_document_state", lambda e, timeout=30: present
|
|
235
|
+
):
|
|
236
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
237
|
+
rc = drain_pending.main(argv)
|
|
238
|
+
return rc, err.getvalue()
|
|
239
|
+
|
|
240
|
+
def test_an_in_hook_run_that_only_parks_still_says_so(self):
|
|
241
|
+
"""THE OBSERVABILITY HOLE THE CIRCUIT BREAKER OPENS.
|
|
242
|
+
|
|
243
|
+
A run whose entire queue is past the ceiling parks everything and
|
|
244
|
+
does nothing else, so every other summary counter is 0. With
|
|
245
|
+
``parked`` missing from ``main()``'s gate that run printed NOTHING —
|
|
246
|
+
rc=0, empty stdout, empty stderr — which is byte-identical to "the
|
|
247
|
+
queue was empty". The operator watching a queue that will not shrink
|
|
248
|
+
then has no way to tell "parked by the breaker, needs ``--force``"
|
|
249
|
+
from "ordinary backlog, drains on its own", because ``switchroom
|
|
250
|
+
doctor`` reads only the queue DIRECTORY and both look like a file.
|
|
251
|
+
|
|
252
|
+
The ``--backlog`` path narrates its own parking. This is the IN-HOOK
|
|
253
|
+
path, which is the one that runs on every session boot.
|
|
254
|
+
"""
|
|
255
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
256
|
+
self._seed({"a": ceiling, "b": ceiling, "c": ceiling})
|
|
257
|
+
|
|
258
|
+
rc, err = self._main([])
|
|
259
|
+
|
|
260
|
+
self.assertEqual(rc, 0)
|
|
261
|
+
self.assertEqual(self.posted, [], "nothing was retried — all parked")
|
|
262
|
+
self.assertNotEqual(
|
|
263
|
+
err.strip(), "", "a run that parked the whole queue must not be silent"
|
|
264
|
+
)
|
|
265
|
+
self.assertIn("parked=3", err)
|
|
266
|
+
|
|
267
|
+
def test_the_summary_line_reports_parked_alongside_other_work(self):
|
|
268
|
+
"""The gate is not the only half. With ``parked`` in the gate but
|
|
269
|
+
absent from the printed line, a mixed run opens the gate on its other
|
|
270
|
+
counters and still never names the parked entries."""
|
|
271
|
+
ceiling = drain_pending._attempt_ceiling()
|
|
272
|
+
self._seed({"wedged": ceiling, "healthy": 1})
|
|
273
|
+
|
|
274
|
+
rc, err = self._main([], present=True)
|
|
275
|
+
|
|
276
|
+
self.assertEqual(rc, 0)
|
|
277
|
+
self.assertIn("reconciled=1", err)
|
|
278
|
+
self.assertIn("parked=1", err)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class CeilingArithmeticTest(_QueueTempDirMixin, unittest.TestCase):
|
|
282
|
+
"""How long 20 attempts actually buys — measured, not asserted in prose.
|
|
283
|
+
|
|
284
|
+
``ATTEMPT_CEILING_MULTIPLE``'s comment used to claim 20 attempts was
|
|
285
|
+
"most of a day … far past any transient upstream outage". It is not, and
|
|
286
|
+
the reason is the non-obvious part: attempts convert to wall-clock only
|
|
287
|
+
via the QUEUE SIZE. A drain run attempts each live entry at most once and
|
|
288
|
+
``enqueue`` already records attempt 1, so the floor is 19 further runs —
|
|
289
|
+
under 5 hours at the sidecar's 900s cooldown. ``STALL_THRESHOLD`` only
|
|
290
|
+
rations attempts while entries are inside ``MAX_ATTEMPTS``; past it they
|
|
291
|
+
abstain from the stall guard, so a uniformly over-budget queue is
|
|
292
|
+
attempted in full on every run.
|
|
293
|
+
|
|
294
|
+
This measures the real figures and pins the comment's table to them, so
|
|
295
|
+
the prose cannot drift away from the code again: change the mechanism and
|
|
296
|
+
the measured column moves; delete the table and the parse fails.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
ENV_KEYS = _QueueTempDirMixin.ENV_KEYS + ("HINDSIGHT_DRAIN_ATTEMPT_CEILING",)
|
|
300
|
+
|
|
301
|
+
#: Rows the module comment must carry: queue size → drain runs until the
|
|
302
|
+
#: WHOLE queue is parked, under a total upstream outage.
|
|
303
|
+
SIZES = (1, 4, 13, 30)
|
|
304
|
+
|
|
305
|
+
#: The sidecar's default cooldown (``profiles/_base/start.sh.hbs``); the
|
|
306
|
+
#: comment's hours column is runs x this.
|
|
307
|
+
COOLDOWN_S = 900
|
|
308
|
+
|
|
309
|
+
def setUp(self):
|
|
310
|
+
super().setUp()
|
|
311
|
+
os.environ.pop("HINDSIGHT_DRAIN_ATTEMPT_CEILING", None)
|
|
312
|
+
# No p95 backoff, no pacing: this test counts RUNS, not seconds.
|
|
313
|
+
os.environ.pop("HINDSIGHT_DRAIN_P95_CMD", None)
|
|
314
|
+
|
|
315
|
+
def _runs_until_wholly_parked(self, n: int) -> int:
|
|
316
|
+
# Own queue dir per call, so several sizes can be measured in one
|
|
317
|
+
# test without one size's entries contaminating the next. The mixin
|
|
318
|
+
# owns HINDSIGHT_PENDING_DIR and restores it in tearDown; the
|
|
319
|
+
# directory itself lives under the mixin's tmp root and goes with it.
|
|
320
|
+
os.environ["HINDSIGHT_PENDING_DIR"] = os.path.join(
|
|
321
|
+
self._tmp, f"pending-retains-{n}"
|
|
322
|
+
)
|
|
323
|
+
clock = iter(1000.0 + i for i in range(n + 10))
|
|
324
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
325
|
+
for i in range(n):
|
|
326
|
+
pending.enqueue(
|
|
327
|
+
_payload(content=f"e{i}", doc=_cd_doc(i)),
|
|
328
|
+
ConnectionError("upstream down"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def always_fails(entry, timeout=30):
|
|
332
|
+
raise ConnectionError("upstream down")
|
|
333
|
+
|
|
334
|
+
runs = 0
|
|
335
|
+
# Generous ceiling on the loop itself: a mechanism change that made
|
|
336
|
+
# parking unreachable must fail loudly rather than hang CI.
|
|
337
|
+
while runs < 500:
|
|
338
|
+
runs += 1
|
|
339
|
+
with unittest.mock.patch.object(
|
|
340
|
+
drain_pending, "_retry_one", always_fails
|
|
341
|
+
):
|
|
342
|
+
with unittest.mock.patch.object(
|
|
343
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
344
|
+
):
|
|
345
|
+
with redirect_stderr(io.StringIO()):
|
|
346
|
+
summary = drain_pending.drain(
|
|
347
|
+
CONFIG, backlog=True, phase="drain"
|
|
348
|
+
)
|
|
349
|
+
if summary["parked"] == n:
|
|
350
|
+
return runs
|
|
351
|
+
self.fail(f"a {n}-entry queue never parked wholly within 500 runs")
|
|
352
|
+
|
|
353
|
+
def _documented_table(self) -> dict:
|
|
354
|
+
"""Parse the table out of ``ATTEMPT_CEILING_MULTIPLE``'s comment."""
|
|
355
|
+
with open(drain_pending.__file__, encoding="utf-8") as f:
|
|
356
|
+
src = f.read()
|
|
357
|
+
rows = re.findall(r"^#: (\d+)\s+(\d+)\s+([\d.]+) h$", src, re.M)
|
|
358
|
+
return {int(q): (int(r), float(h)) for q, r, h in rows}
|
|
359
|
+
|
|
360
|
+
def test_the_documented_table_is_what_the_code_actually_does(self):
|
|
361
|
+
documented = self._documented_table()
|
|
362
|
+
self.assertEqual(
|
|
363
|
+
sorted(documented),
|
|
364
|
+
sorted(self.SIZES),
|
|
365
|
+
"ATTEMPT_CEILING_MULTIPLE's comment must document exactly these "
|
|
366
|
+
"queue sizes — the prose is the deliverable here",
|
|
367
|
+
)
|
|
368
|
+
for n in self.SIZES:
|
|
369
|
+
with self.subTest(queue=n):
|
|
370
|
+
measured = self._runs_until_wholly_parked(n)
|
|
371
|
+
runs, hours = documented[n]
|
|
372
|
+
self.assertEqual(
|
|
373
|
+
measured,
|
|
374
|
+
runs,
|
|
375
|
+
f"a {n}-entry queue parks wholly in {measured} runs, but "
|
|
376
|
+
f"the comment says {runs}",
|
|
377
|
+
)
|
|
378
|
+
self.assertAlmostEqual(
|
|
379
|
+
hours, runs * self.COOLDOWN_S / 3600.0, places=1
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def test_a_small_queue_parks_well_inside_one_overnight_outage(self):
|
|
383
|
+
"""The claim the old comment made, stated as the outcome it is not.
|
|
384
|
+
|
|
385
|
+
4 entries — a healthy agent — park entirely in 6 hours. So a single
|
|
386
|
+
overnight upstream outage CAN park a small queue wholesale, which is
|
|
387
|
+
exactly what "far past any transient upstream outage" denied. Pinned
|
|
388
|
+
as an outcome so nobody re-asserts the denial.
|
|
389
|
+
"""
|
|
390
|
+
measured = self._runs_until_wholly_parked(4)
|
|
391
|
+
hours = measured * self.COOLDOWN_S / 3600.0
|
|
392
|
+
self.assertLess(
|
|
393
|
+
hours,
|
|
394
|
+
12.0,
|
|
395
|
+
"a 4-entry queue parking in under 12h is the accepted trade — if "
|
|
396
|
+
"this ever became untrue the comment must be re-measured too",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
unittest.main()
|