switchroom 0.19.27 → 0.19.29
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 +16 -2
- package/dist/auth-broker/index.js +141 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +18 -2
- package/dist/cli/switchroom.js +864 -43
- package/dist/host-control/main.js +144 -9
- package/dist/vault/approvals/kernel-server.js +146 -9
- package/dist/vault/broker/server.js +146 -9
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +585 -50
- 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/CHANGELOG.md +50 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
- package/vendor/hindsight-memory/scripts/retain.py +41 -1
- package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
- 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_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -23,6 +23,49 @@ memory" from degrading into "never drain anything" — the drain is
|
|
|
23
23
|
sequential and oldest-first, so without the demotion three chronically
|
|
24
24
|
failing entries sit at the head and end every run at zero progress.
|
|
25
25
|
|
|
26
|
+
Serialisation
|
|
27
|
+
-------------
|
|
28
|
+
``drain()`` takes an EXCLUSIVE, non-blocking lock (``_exclusive_drain``,
|
|
29
|
+
``_drain_lock_path``) for the whole run and returns a zero summary with
|
|
30
|
+
``skipped_locked=True`` if another drain already holds it. That lives HERE,
|
|
31
|
+
not in a caller, because the queue has several independent drain paths and a
|
|
32
|
+
guarantee that depends on every caller remembering to wrap itself in
|
|
33
|
+
``flock`` is not a guarantee:
|
|
34
|
+
|
|
35
|
+
* the SessionStart hook (``session_start.py`` imports ``drain`` directly),
|
|
36
|
+
* the ``hindsight-drain`` sidecar in ``profiles/_base/start.sh.hbs``,
|
|
37
|
+
* the operator's out-of-band replay, which ``switchroom doctor`` documents
|
|
38
|
+
as a bare ``docker exec … drain_pending.py --backlog``.
|
|
39
|
+
|
|
40
|
+
Two of those overlapping means two processes iterate the same queue and
|
|
41
|
+
re-POST the same entry — ~168s of a 4-slot fleet-wide LLM lane, twice, for
|
|
42
|
+
one memory. The lock is ``fcntl.flock``, so the kernel releases it if the
|
|
43
|
+
process dies; there is no stale-lock recovery to get wrong.
|
|
44
|
+
|
|
45
|
+
The lock file is ``drain-pending.lock``, deliberately NOT the ``drain.lock``
|
|
46
|
+
that the interim host cron wraps its ``docker exec`` in. Same-path would
|
|
47
|
+
mean that wrapper's outer ``flock`` starves the ``drain_pending.py`` it just
|
|
48
|
+
launched — a silent no-op, i.e. the drain quietly stops. With a distinct
|
|
49
|
+
name an external ``flock`` wrapper on the historical path is redundant but
|
|
50
|
+
harmless, and this module's guarantee holds no matter who calls it.
|
|
51
|
+
|
|
52
|
+
Circuit breaker
|
|
53
|
+
---------------
|
|
54
|
+
Demotion (above) bounds head-of-line blocking, but not TOTAL work: a
|
|
55
|
+
chronically failing entry is retried on every run forever. That was
|
|
56
|
+
tolerable when the only drains were a 4-second hook and a supervised manual
|
|
57
|
+
sweep. It is not tolerable for the unattended sidecar, whose backlog budget
|
|
58
|
+
is 3600s against a 900s cooldown — a wedged agent would spend ~80% of
|
|
59
|
+
wall-clock re-POSTing the same unsaveable entries into a shared lane, for
|
|
60
|
+
good. So an entry past ``_attempt_ceiling()`` (default ``MAX_ATTEMPTS`` x 4
|
|
61
|
+
= 20 attempts, ``HINDSIGHT_DRAIN_ATTEMPT_CEILING``) is PARKED: skipped by
|
|
62
|
+
the retain pass, counted in ``summary["parked"]``, still on disk, still
|
|
63
|
+
swept for free by the reconcile pass, and still retried in full under
|
|
64
|
+
``--force``. Parking destroys nothing; it stops paying for the same failure
|
|
65
|
+
without end. HOW MUCH WALL-CLOCK 20 ATTEMPTS BUYS IS QUEUE-SIZE DEPENDENT,
|
|
66
|
+
and is shorter than it looks — the measured table is at
|
|
67
|
+
``ATTEMPT_CEILING_MULTIPLE``, and the trade it records is deliberate.
|
|
68
|
+
|
|
26
69
|
Boundaries
|
|
27
70
|
----------
|
|
28
71
|
* Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s), but
|
|
@@ -118,6 +161,8 @@ Standalone usage::
|
|
|
118
161
|
from __future__ import annotations
|
|
119
162
|
|
|
120
163
|
import argparse
|
|
164
|
+
import contextlib
|
|
165
|
+
import fcntl
|
|
121
166
|
import os
|
|
122
167
|
import subprocess
|
|
123
168
|
import sys
|
|
@@ -136,6 +181,7 @@ from lib.pending import (
|
|
|
136
181
|
is_permanent_failure,
|
|
137
182
|
iter_entries,
|
|
138
183
|
mark_dead,
|
|
184
|
+
pending_dir,
|
|
139
185
|
resplit_over_bound_entries,
|
|
140
186
|
sweep_legacy_dead_markers,
|
|
141
187
|
update_attempt,
|
|
@@ -145,6 +191,179 @@ from lib.retain_split import retain_client_deadline, retain_content_limit
|
|
|
145
191
|
|
|
146
192
|
STALL_THRESHOLD = 3
|
|
147
193
|
|
|
194
|
+
#: Attempt ceiling, as a multiple of ``MAX_ATTEMPTS`` — see ``_attempt_ceiling``
|
|
195
|
+
#: and the "Circuit breaker" section of the module docstring. 4 x 5 = 20
|
|
196
|
+
#: attempts.
|
|
197
|
+
#:
|
|
198
|
+
#: HOW LONG THAT BUYS IS QUEUE-SIZE DEPENDENT, which is the non-obvious part
|
|
199
|
+
#: and the reason an earlier revision of this comment was wrong (it claimed 20
|
|
200
|
+
#: attempts was "most of a day … far past any transient upstream outage"). A
|
|
201
|
+
#: drain run attempts each live entry at most once, and ``pending.enqueue``
|
|
202
|
+
#: already records attempt 1, so the FLOOR is 19 further runs — on the
|
|
203
|
+
#: sidecar's 900s cooldown, **under 5 hours**, not most of a day. Above that
|
|
204
|
+
#: floor the only thing that slows parking down is ``STALL_THRESHOLD`` (3),
|
|
205
|
+
#: and it stops applying exactly when it would start to help: past
|
|
206
|
+
#: ``MAX_ATTEMPTS`` an entry abstains from the stall guard (``_over_budget``),
|
|
207
|
+
#: so once a queue is uniformly over budget EVERY entry is attempted on EVERY
|
|
208
|
+
#: run.
|
|
209
|
+
#:
|
|
210
|
+
#: Measured against this module by ``CeilingArithmeticTest`` — total upstream
|
|
211
|
+
#: outage, every attempt failing, counting drain runs until the whole queue is
|
|
212
|
+
#: parked:
|
|
213
|
+
#:
|
|
214
|
+
#: ==== ==== ==============
|
|
215
|
+
#: queue runs at 900s
|
|
216
|
+
#: ==== ==== ==============
|
|
217
|
+
#: 1 20 5.0 h
|
|
218
|
+
#: 4 24 6.0 h
|
|
219
|
+
#: 13 36 9.0 h
|
|
220
|
+
#: 30 56 14.0 h
|
|
221
|
+
#: ==== ==== ==============
|
|
222
|
+
#:
|
|
223
|
+
#: So the guarantee runs BACKWARDS from the intuition: the SMALLER and
|
|
224
|
+
#: healthier the queue, the SOONER a single overnight outage parks it whole.
|
|
225
|
+
#: A 4-entry queue is gone in 6 hours. That is a real cost and it is accepted
|
|
226
|
+
#: rather than papered over by a bigger number, because:
|
|
227
|
+
#:
|
|
228
|
+
#: * the ceiling is the ONLY bound on unattended lane waste, and it is a
|
|
229
|
+
#: per-run bound of ``len(queue)`` x ~168s once the stall guard abstains
|
|
230
|
+
#: (the n=30 run above retries all 30 entries every run for 14 hours).
|
|
231
|
+
#: Sizing the ceiling to survive a 24h outage means ~100 attempts, i.e.
|
|
232
|
+
#: 5x that window — weakening the very mechanism this change adds;
|
|
233
|
+
#: * parking destroys nothing. The entry stays on disk, the free reconcile
|
|
234
|
+
#: pass still sweeps it, ``--force`` replays it in full, and since the
|
|
235
|
+
#: summary-line fix below it is REPORTED on every drain path rather than
|
|
236
|
+
#: only the backlog one;
|
|
237
|
+
#: * no attempt count can make a wall-clock promise anyway. Attempts convert
|
|
238
|
+
#: to hours only via the queue size, so a larger constant would restate the
|
|
239
|
+
#: same category error with a different number. Stating the conversion is
|
|
240
|
+
#: the durable fix.
|
|
241
|
+
#:
|
|
242
|
+
#: An operator who knows the upstream will be down longer than the table
|
|
243
|
+
#: allows raises ``HINDSIGHT_DRAIN_ATTEMPT_CEILING`` for the duration, and
|
|
244
|
+
#: replays with ``--force`` afterwards; ``switchroom doctor``'s backlog
|
|
245
|
+
#: remediation names both.
|
|
246
|
+
ATTEMPT_CEILING_MULTIPLE = 4
|
|
247
|
+
|
|
248
|
+
#: Basename of the drain lock, inside the same directory that holds
|
|
249
|
+
#: ``pending-retains/``. NOT ``drain.lock`` — see the module docstring:
|
|
250
|
+
#: the interim host cron wraps its ``docker exec`` in ``flock -n
|
|
251
|
+
#: $HOME/.hindsight/drain.lock``, and sharing that path would make that
|
|
252
|
+
#: wrapper starve the very ``drain_pending.py`` it launches.
|
|
253
|
+
DRAIN_LOCK_BASENAME = "drain-pending.lock"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _drain_lock_path() -> str:
|
|
257
|
+
"""Absolute path of the per-agent drain lock.
|
|
258
|
+
|
|
259
|
+
Derived from ``pending_dir()`` rather than ``$HOME`` so it follows the
|
|
260
|
+
queue it protects: one lock per queue, including under
|
|
261
|
+
``HINDSIGHT_PENDING_DIR``. In production that resolves to
|
|
262
|
+
``$HOME/.hindsight/drain-pending.lock``.
|
|
263
|
+
"""
|
|
264
|
+
override = os.environ.get("HINDSIGHT_DRAIN_LOCK")
|
|
265
|
+
if override:
|
|
266
|
+
return override
|
|
267
|
+
return os.path.join(
|
|
268
|
+
os.path.dirname(os.path.abspath(pending_dir())), DRAIN_LOCK_BASENAME
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@contextlib.contextmanager
|
|
273
|
+
def _exclusive_drain():
|
|
274
|
+
"""Hold the drain lock for the duration of a run.
|
|
275
|
+
|
|
276
|
+
Yields ``True`` if this process owns the drain, ``False`` if another
|
|
277
|
+
drain already holds the lock (caller must do nothing).
|
|
278
|
+
|
|
279
|
+
``fcntl.flock`` and not a pidfile: the kernel releases it when the fd
|
|
280
|
+
closes OR the process dies, so a SIGKILLed drain cannot wedge the queue
|
|
281
|
+
and there is no stale-lock reaper to get wrong. ``LOCK_NB`` and not a
|
|
282
|
+
blocking wait, because every caller has somewhere better to be — the
|
|
283
|
+
SessionStart hook has a ~9s budget, and the sidecar has another tick in
|
|
284
|
+
900s.
|
|
285
|
+
|
|
286
|
+
FAILING TO OPEN the lock file is NOT contention and must not stop the
|
|
287
|
+
drain: an unwritable ``.hindsight/`` (read-only mount, ENOSPC) would
|
|
288
|
+
otherwise silently disable memory replay altogether, which is strictly
|
|
289
|
+
worse than an unserialised drain. That path warns and proceeds.
|
|
290
|
+
"""
|
|
291
|
+
path = _drain_lock_path()
|
|
292
|
+
fd = None
|
|
293
|
+
try:
|
|
294
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
295
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
296
|
+
except OSError as e:
|
|
297
|
+
if fd is not None:
|
|
298
|
+
os.close(fd)
|
|
299
|
+
print(
|
|
300
|
+
f"[Hindsight] drain_pending: cannot open drain lock {path} ({e}); "
|
|
301
|
+
f"draining WITHOUT serialisation",
|
|
302
|
+
file=sys.stderr,
|
|
303
|
+
)
|
|
304
|
+
yield True
|
|
305
|
+
return
|
|
306
|
+
try:
|
|
307
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
308
|
+
except OSError:
|
|
309
|
+
os.close(fd)
|
|
310
|
+
yield False
|
|
311
|
+
return
|
|
312
|
+
try:
|
|
313
|
+
yield True
|
|
314
|
+
finally:
|
|
315
|
+
try:
|
|
316
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
317
|
+
finally:
|
|
318
|
+
os.close(fd)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _attempt_ceiling() -> int:
|
|
322
|
+
"""Attempts after which an entry is PARKED rather than retried again.
|
|
323
|
+
|
|
324
|
+
Floored at ``MAX_ATTEMPTS``: a ceiling below the attempt budget would
|
|
325
|
+
park entries the demotion logic is still trying to drain normally.
|
|
326
|
+
"""
|
|
327
|
+
return _env_num(
|
|
328
|
+
"HINDSIGHT_DRAIN_ATTEMPT_CEILING",
|
|
329
|
+
MAX_ATTEMPTS * ATTEMPT_CEILING_MULTIPLE,
|
|
330
|
+
int,
|
|
331
|
+
lo=MAX_ATTEMPTS,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _circuit_broken(entry: dict) -> bool:
|
|
336
|
+
"""Has this entry failed so many times that retrying it is just waste?"""
|
|
337
|
+
try:
|
|
338
|
+
return int(entry.get("attempt_count", 0)) >= _attempt_ceiling()
|
|
339
|
+
except (TypeError, ValueError):
|
|
340
|
+
# Same rule as ``_over_budget``: a corrupt counter must never decide
|
|
341
|
+
# policy and must never raise on the drain path. Treat it as live.
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _park_broken(
|
|
346
|
+
entries: list[tuple[str, dict]], summary: dict, force: bool
|
|
347
|
+
) -> list[tuple[str, dict]]:
|
|
348
|
+
"""Drop circuit-broken entries from a RETAIN pass, recording the count.
|
|
349
|
+
|
|
350
|
+
Applied to the POST paths only. The free reconcile pass
|
|
351
|
+
(``_reconcile_phase``) still sweeps every entry on disk, so a parked
|
|
352
|
+
entry whose document did land is still retired without a POST — parking
|
|
353
|
+
withholds the expensive retry, not the cheap proof.
|
|
354
|
+
"""
|
|
355
|
+
if force:
|
|
356
|
+
return entries
|
|
357
|
+
live: list[tuple[str, dict]] = []
|
|
358
|
+
parked = 0
|
|
359
|
+
for path, entry in entries:
|
|
360
|
+
if _circuit_broken(entry):
|
|
361
|
+
parked += 1
|
|
362
|
+
else:
|
|
363
|
+
live.append((path, entry))
|
|
364
|
+
summary["parked"] = parked
|
|
365
|
+
return live
|
|
366
|
+
|
|
148
367
|
|
|
149
368
|
def _clamp(timeout: int, budget: float, started: float) -> int:
|
|
150
369
|
"""Per-REQUEST HTTP timeout: ``timeout`` capped by the budget LEFT NOW.
|
|
@@ -381,6 +600,10 @@ def _retry_one(entry: dict, timeout: int) -> None:
|
|
|
381
600
|
tags=entry.get("tags"),
|
|
382
601
|
timeout=timeout,
|
|
383
602
|
async_processing=False,
|
|
603
|
+
# `.get` (not `[...]`): entries queued by a pre-#observation_scopes build
|
|
604
|
+
# are on disk RIGHT NOW and carry no such key. They must drain, not
|
|
605
|
+
# KeyError — a crash here loses the queue entry's only copy of a turn.
|
|
606
|
+
observation_scopes=entry.get("observation_scopes"),
|
|
384
607
|
)
|
|
385
608
|
|
|
386
609
|
|
|
@@ -552,6 +775,14 @@ def _new_summary() -> dict:
|
|
|
552
775
|
"drained": 0,
|
|
553
776
|
"retried": 0,
|
|
554
777
|
"dead": 0,
|
|
778
|
+
# Entries past `_attempt_ceiling()` — skipped by the retain pass so an
|
|
779
|
+
# unattended sidecar cannot spend a shared LLM lane re-POSTing the same
|
|
780
|
+
# unsaveable entry forever. Still queued, still reconciled for free,
|
|
781
|
+
# still drained in full under `--force`. See the module docstring.
|
|
782
|
+
"parked": 0,
|
|
783
|
+
# True when another drain already held the lock, so THIS run did
|
|
784
|
+
# nothing at all. Distinguishes "no work" from "did not run".
|
|
785
|
+
"skipped_locked": False,
|
|
555
786
|
"reconciled": 0,
|
|
556
787
|
"unknown": 0,
|
|
557
788
|
# Presence WAS established, but the entry could not be moved into
|
|
@@ -566,6 +797,14 @@ def _new_summary() -> dict:
|
|
|
566
797
|
"dead_relocated": 0,
|
|
567
798
|
"resplit": 0,
|
|
568
799
|
"resplit_parts": 0,
|
|
800
|
+
# Labels of the pre-drain phases ("0"/"0b"/"0c") that raised and were
|
|
801
|
+
# stepped over this run — see `_phase_failed`. A LIST, not a count,
|
|
802
|
+
# because which phase broke is what an operator needs. IN THE GATE,
|
|
803
|
+
# not only in the summary line, for exactly the reason `parked` is
|
|
804
|
+
# (see `main()`): every pre-drain counter reads 0 both on a run whose
|
|
805
|
+
# phases all blew up and on a clean, empty, healthy queue, so without
|
|
806
|
+
# this key the two runs are byte-identical from outside.
|
|
807
|
+
"phase_failures": [],
|
|
569
808
|
"stalled": False,
|
|
570
809
|
"budget_exceeded": False,
|
|
571
810
|
}
|
|
@@ -587,17 +826,28 @@ def drain(
|
|
|
587
826
|
backlog: bool = False,
|
|
588
827
|
phase: str = "both",
|
|
589
828
|
dry_run: bool = False,
|
|
829
|
+
force: bool = False,
|
|
590
830
|
) -> dict:
|
|
591
831
|
"""Walk the pending-retains directory and retry each entry.
|
|
592
832
|
|
|
593
833
|
``backlog=False`` (default) is the bounded in-hook drain.
|
|
594
834
|
``backlog=True`` is the operator backlog replay — see ``drain_backlog()``.
|
|
835
|
+
``force=True`` ignores the per-entry circuit breaker (module docstring).
|
|
836
|
+
|
|
837
|
+
THE SERIALISATION POINT for every drain path — the SessionStart hook, the
|
|
838
|
+
`hindsight-drain` sidecar and the operator's out-of-band replay all reach
|
|
839
|
+
the queue through this function, so the lock is taken here rather than in
|
|
840
|
+
any one caller. A run that finds the lock held does NOTHING and returns a
|
|
841
|
+
zero summary with ``skipped_locked=True``; it never queues behind the
|
|
842
|
+
holder, because every caller has a next tick and none has time to wait.
|
|
595
843
|
|
|
596
844
|
Returns a summary dict::
|
|
597
845
|
|
|
598
846
|
{"drained": int, # successful retries (entries archived)
|
|
599
847
|
"retried": int, # failures kept for next session
|
|
600
848
|
"dead": int, # entries promoted to .dead this run
|
|
849
|
+
"parked": int, # past the attempt ceiling: not retried this run
|
|
850
|
+
"skipped_locked": bool, # another drain held the lock; did nothing
|
|
601
851
|
"reconciled": int,# already durable, archived without a POST
|
|
602
852
|
"unknown": int, # presence unknown, left queued
|
|
603
853
|
"archive_failed": int, # durable, but the archive was unwritable
|
|
@@ -606,12 +856,32 @@ def drain(
|
|
|
606
856
|
"dead_relocated": int, # legacy .dead markers moved out of the queue dir
|
|
607
857
|
"resplit": int, # over-bound entries split into drainable parts
|
|
608
858
|
"resplit_parts": int, # parts those entries became
|
|
859
|
+
"phase_failures": list[str], # pre-drain phases that raised and
|
|
860
|
+
# were stepped over (backlog only)
|
|
609
861
|
"stalled": bool, # stall guard tripped
|
|
610
862
|
"budget_exceeded": bool}
|
|
611
863
|
"""
|
|
612
864
|
config = config or load_config()
|
|
613
|
-
|
|
614
|
-
|
|
865
|
+
with _exclusive_drain() as acquired:
|
|
866
|
+
if not acquired:
|
|
867
|
+
summary = _new_summary()
|
|
868
|
+
summary["skipped_locked"] = True
|
|
869
|
+
print(
|
|
870
|
+
f"[Hindsight] drain_pending: another drain holds "
|
|
871
|
+
f"{_drain_lock_path()} — skipping this run so the same entry "
|
|
872
|
+
f"is not retained twice.",
|
|
873
|
+
file=sys.stderr,
|
|
874
|
+
)
|
|
875
|
+
return summary
|
|
876
|
+
if backlog:
|
|
877
|
+
return _drain_backlog_impl(
|
|
878
|
+
config, phase=phase, dry_run=dry_run, force=force
|
|
879
|
+
)
|
|
880
|
+
return _drain_inhook_impl(config, force=force)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _drain_inhook_impl(config: dict, force: bool = False) -> dict:
|
|
884
|
+
"""The bounded SessionStart drain. The caller holds the drain lock."""
|
|
615
885
|
timeout = _per_entry_timeout()
|
|
616
886
|
budget = _budget_seconds()
|
|
617
887
|
started = time.monotonic()
|
|
@@ -622,9 +892,12 @@ def drain(
|
|
|
622
892
|
# behind them — see ``_drain_order``. Matters even more here than in the
|
|
623
893
|
# backlog drain: the in-hook budget is ~4s, so a single entry at the head
|
|
624
894
|
# that always burns its clamped timeout consumes the entire run.
|
|
625
|
-
entries = _drain_order(iter_entries())
|
|
895
|
+
entries = _park_broken(_drain_order(iter_entries()), summary, force)
|
|
626
896
|
if not entries:
|
|
627
|
-
debug_log(
|
|
897
|
+
debug_log(
|
|
898
|
+
config,
|
|
899
|
+
f"drain_pending: nothing to retry (parked={summary['parked']})",
|
|
900
|
+
)
|
|
628
901
|
return summary
|
|
629
902
|
|
|
630
903
|
debug_log(config, f"drain_pending: {len(entries)} entries to retry")
|
|
@@ -742,6 +1015,47 @@ def _blog(msg: str) -> None:
|
|
|
742
1015
|
print(f"[Hindsight] drain_pending(backlog): {msg}", file=sys.stderr)
|
|
743
1016
|
|
|
744
1017
|
|
|
1018
|
+
def _phase_failed(summary: dict, phase: str, e: BaseException, cost: str) -> None:
|
|
1019
|
+
"""Record a pre-drain phase that raised, and let the drain continue.
|
|
1020
|
+
|
|
1021
|
+
ONE mechanism for phases 0, 0b and 0c (#3894 / #3895). They sit in the same
|
|
1022
|
+
``if not dry_run:`` block and run BEFORE the phases that actually
|
|
1023
|
+
drain, so a raise out of any of them takes phases 1 and 2 with it and
|
|
1024
|
+
the backlog becomes immortal — for every OTHER entry too, none of
|
|
1025
|
+
which had anything to do with the failure. Phases 0 and 0c PARSE
|
|
1026
|
+
queued entries, so a semantically-malformed-but-valid-JSON entry
|
|
1027
|
+
reaches both — precisely the class ``iter_entries``' quarantine cannot
|
|
1028
|
+
catch. Phase 0b parses nothing and its guard is defence-in-depth on
|
|
1029
|
+
the caller contract; the comment at its call site says so plainly.
|
|
1030
|
+
None of the three is loss-bearing to skip — a collapse, a sweep and a
|
|
1031
|
+
re-split only MOVE or REWRITE bytes already on disk, and none of them
|
|
1032
|
+
deletes — so all three are logged and stepped over, not fatal.
|
|
1033
|
+
|
|
1034
|
+
Reported in TWO places on purpose. The stderr line is for whoever is
|
|
1035
|
+
watching the run; ``summary["phase_failures"]`` is what makes the
|
|
1036
|
+
failure survive into the return value, into ``main()``'s summary line,
|
|
1037
|
+
and into its "anything to report?" gate. Without the second, a run
|
|
1038
|
+
where every pre-drain phase blew up prints exactly what an empty,
|
|
1039
|
+
healthy queue prints — every pre-drain counter is 0 either way — and a
|
|
1040
|
+
permanently broken phase is invisible to anything not tailing stderr.
|
|
1041
|
+
Same argument, same shape, as ``parked`` (#3893).
|
|
1042
|
+
|
|
1043
|
+
``cost`` states what skipping this phase actually costs, in the same
|
|
1044
|
+
voice as the phase's own log lines: the point of the message is that
|
|
1045
|
+
the operator can tell at a glance that no memory was lost.
|
|
1046
|
+
|
|
1047
|
+
The message format is deliberately the one #3894 wrote inline for its
|
|
1048
|
+
phase-0 guard, so folding that guard onto this helper changed no
|
|
1049
|
+
output at all. Three hand-rolled ``except`` blocks in one block is
|
|
1050
|
+
exactly the shape that drifts.
|
|
1051
|
+
"""
|
|
1052
|
+
summary["phase_failures"].append(phase)
|
|
1053
|
+
_blog(
|
|
1054
|
+
f"phase {phase} FAILED ({type(e).__name__}: {e}) — continuing to the "
|
|
1055
|
+
f"phases that actually drain. Nothing was lost: {cost}"
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
745
1059
|
def _reconcile_phase(config: dict, summary: dict, dry_run: bool) -> None:
|
|
746
1060
|
"""PHASE 1 — free pass: drop entries whose document already exists.
|
|
747
1061
|
|
|
@@ -822,7 +1136,7 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
|
|
|
822
1136
|
|
|
823
1137
|
|
|
824
1138
|
def _drain_backlog_impl(
|
|
825
|
-
config: dict, phase: str = "both", dry_run: bool = False
|
|
1139
|
+
config: dict, phase: str = "both", dry_run: bool = False, force: bool = False
|
|
826
1140
|
) -> dict:
|
|
827
1141
|
"""Concurrent-capable, long-budget, three-phase backlog replay."""
|
|
828
1142
|
summary = _new_summary()
|
|
@@ -840,8 +1154,30 @@ def _drain_backlog_impl(
|
|
|
840
1154
|
# the SessionStart drain's whole contract is a hard wall-clock ceiling
|
|
841
1155
|
# on hook latency. New duplicates cannot accumulate there anyway:
|
|
842
1156
|
# `pending.enqueue`'s filename-keyed guard stops those at the producer.
|
|
1157
|
+
#
|
|
1158
|
+
# GUARDED, because phase 0 runs BEFORE the phases that actually drain
|
|
1159
|
+
# (#3688 review R1-M1). `iter_entries()` quarantines an unparsable entry
|
|
1160
|
+
# precisely so no single corrupt file can make the whole queue immortal;
|
|
1161
|
+
# an unguarded collapse re-creates that failure one level up, where a
|
|
1162
|
+
# semantically-malformed-but-valid-JSON entry raises and takes phases 1
|
|
1163
|
+
# and 2 with it — a strict regression against the pre-#3688 drain, where
|
|
1164
|
+
# the same entry drains. A collapse failure costs duplicated work, never
|
|
1165
|
+
# a memory, so it is logged and stepped over, exactly as every other
|
|
1166
|
+
# phase treats a per-entry failure. `_attempt_count` closes the one
|
|
1167
|
+
# measured raise; this guard is what keeps the NEXT one from being a
|
|
1168
|
+
# fleet-wide drain stall.
|
|
843
1169
|
if not dry_run:
|
|
844
|
-
|
|
1170
|
+
try:
|
|
1171
|
+
summary["collapsed"] = collapse_duplicates()
|
|
1172
|
+
except Exception as e: # noqa: BLE001 — see comment above
|
|
1173
|
+
_phase_failed(
|
|
1174
|
+
summary,
|
|
1175
|
+
"0",
|
|
1176
|
+
e,
|
|
1177
|
+
"a collapse only ever MOVES a byte-identical copy into "
|
|
1178
|
+
"pending-duplicate/, so the cost of skipping it is duplicated "
|
|
1179
|
+
"work, not a memory.",
|
|
1180
|
+
)
|
|
845
1181
|
if summary["collapsed"]:
|
|
846
1182
|
_blog(
|
|
847
1183
|
f"phase 0: collapsed {summary['collapsed']} duplicate entries "
|
|
@@ -853,7 +1189,29 @@ def _drain_backlog_impl(
|
|
|
853
1189
|
# queue directory. Free, local, and an UPGRADE step: markers written
|
|
854
1190
|
# by an older build sit in the directory external janitors sweep, and
|
|
855
1191
|
# a marker is the only remaining copy of its memory.
|
|
856
|
-
|
|
1192
|
+
#
|
|
1193
|
+
# GUARDED (#3895) because it runs BEFORE the phases that drain, but
|
|
1194
|
+
# be honest about WHY: unlike phase 0c this one parses no entry — it
|
|
1195
|
+
# walks filenames — and it already catches `OSError` per marker,
|
|
1196
|
+
# which covers `shutil.Error` too (an `OSError` subclass). No queue
|
|
1197
|
+
# state reachable today makes it raise, and none was found looking.
|
|
1198
|
+
# The guard is on the CALLER contract rather than a live defect:
|
|
1199
|
+
# nothing running before the drain may be able to stop the drain,
|
|
1200
|
+
# and any future line inside that loop re-opens the hole. Skipping
|
|
1201
|
+
# the sweep is free — the markers stay exactly where every earlier
|
|
1202
|
+
# build left them.
|
|
1203
|
+
try:
|
|
1204
|
+
moved = sweep_legacy_dead_markers()
|
|
1205
|
+
except Exception as e: # noqa: BLE001 — see `_phase_failed`
|
|
1206
|
+
moved = 0
|
|
1207
|
+
_phase_failed(
|
|
1208
|
+
summary,
|
|
1209
|
+
"0b",
|
|
1210
|
+
e,
|
|
1211
|
+
"a sweep only ever MOVES a `.dead` marker out of the live "
|
|
1212
|
+
"queue directory, so the cost of skipping it is that the "
|
|
1213
|
+
"markers stay where they already were, not a memory.",
|
|
1214
|
+
)
|
|
857
1215
|
if moved:
|
|
858
1216
|
summary["dead_relocated"] = moved
|
|
859
1217
|
_blog(
|
|
@@ -871,7 +1229,30 @@ def _drain_backlog_impl(
|
|
|
871
1229
|
# part drainable, which is the difference between a lost memory and a
|
|
872
1230
|
# slow one. Runs after the duplicate collapse so a duplicated
|
|
873
1231
|
# over-bound entry is split ONCE, not once per copy.
|
|
874
|
-
|
|
1232
|
+
#
|
|
1233
|
+
# GUARDED (#3895), and this is the most exposed of the pre-drain
|
|
1234
|
+
# phases: it PARSES every queued entry to measure it, so a
|
|
1235
|
+
# semantically-malformed-but-valid-JSON entry — the exact class
|
|
1236
|
+
# `iter_entries`' quarantine hands over rather than quarantines —
|
|
1237
|
+
# reaches it. Measured: one entry whose `metadata` is a string
|
|
1238
|
+
# instead of a mapping raises `ValueError` out of the payload
|
|
1239
|
+
# rebuild. Unguarded, that ONE entry stops phases 1 and 2 for every
|
|
1240
|
+
# other entry in the queue, on every run. Skipping the split costs
|
|
1241
|
+
# the over-bound entries only: they stay exactly as undrainable as
|
|
1242
|
+
# they were before phase 0c existed, and nothing is deleted.
|
|
1243
|
+
try:
|
|
1244
|
+
entries_split, parts_written = resplit_over_bound_entries()
|
|
1245
|
+
except Exception as e: # noqa: BLE001 — see `_phase_failed`
|
|
1246
|
+
entries_split, parts_written = 0, 0
|
|
1247
|
+
_phase_failed(
|
|
1248
|
+
summary,
|
|
1249
|
+
"0c",
|
|
1250
|
+
e,
|
|
1251
|
+
"a re-split only ever REWRITES an over-bound entry into "
|
|
1252
|
+
"drainable parts and archives the original, so the cost of "
|
|
1253
|
+
"skipping it is that those entries stay as undrainable as "
|
|
1254
|
+
"they were before phase 0c existed. Nothing is deleted.",
|
|
1255
|
+
)
|
|
875
1256
|
if entries_split:
|
|
876
1257
|
summary["resplit"] = entries_split
|
|
877
1258
|
summary["resplit_parts"] = parts_written
|
|
@@ -897,8 +1278,18 @@ def _drain_backlog_impl(
|
|
|
897
1278
|
|
|
898
1279
|
# See ``_drain_order``: without this, three budget-exhausted entries at the
|
|
899
1280
|
# head trip the stall guard on every run forever and the drain never makes
|
|
900
|
-
# progress again.
|
|
901
|
-
|
|
1281
|
+
# progress again. Then the CIRCUIT BREAKER: demotion bounds head-of-line
|
|
1282
|
+
# blocking but not total work, and this pass is the unattended one — a
|
|
1283
|
+
# 3600s budget every 900s means a wedged agent would otherwise re-POST the
|
|
1284
|
+
# same unsaveable entries into a 4-slot shared lane indefinitely.
|
|
1285
|
+
entries = _park_broken(_drain_order(iter_entries()), summary, force)
|
|
1286
|
+
if summary["parked"]:
|
|
1287
|
+
_blog(
|
|
1288
|
+
f"parked {summary['parked']} entries past the attempt ceiling "
|
|
1289
|
+
f"({_attempt_ceiling()} attempts) — still queued and still "
|
|
1290
|
+
f"reconciled for free, but not retained again. `--force` to retry "
|
|
1291
|
+
f"them anyway."
|
|
1292
|
+
)
|
|
902
1293
|
if not entries:
|
|
903
1294
|
_blog("phase 2: nothing left to retain")
|
|
904
1295
|
return summary
|
|
@@ -1045,6 +1436,13 @@ def _parse_args(argv: list[str] | None):
|
|
|
1045
1436
|
action="store_true",
|
|
1046
1437
|
help="with --backlog: report what would happen, issue no writes",
|
|
1047
1438
|
)
|
|
1439
|
+
ap.add_argument(
|
|
1440
|
+
"--force",
|
|
1441
|
+
action="store_true",
|
|
1442
|
+
help="retry entries past the attempt ceiling too (they are skipped by "
|
|
1443
|
+
"default so an unattended drain cannot spend the shared retain lane "
|
|
1444
|
+
"on the same permanently-failing entry forever)",
|
|
1445
|
+
)
|
|
1048
1446
|
return ap.parse_args(argv)
|
|
1049
1447
|
|
|
1050
1448
|
|
|
@@ -1058,7 +1456,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1058
1456
|
return 2
|
|
1059
1457
|
config = load_config()
|
|
1060
1458
|
summary = drain(
|
|
1061
|
-
config,
|
|
1459
|
+
config,
|
|
1460
|
+
backlog=args.backlog,
|
|
1461
|
+
phase=args.phase,
|
|
1462
|
+
dry_run=args.dry_run,
|
|
1463
|
+
force=args.force,
|
|
1062
1464
|
)
|
|
1063
1465
|
if any(
|
|
1064
1466
|
summary[k]
|
|
@@ -1072,6 +1474,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1072
1474
|
"reconciled",
|
|
1073
1475
|
"unknown",
|
|
1074
1476
|
"archive_failed",
|
|
1477
|
+
# PARKED IS IN THE GATE, not only in the line below. A run whose
|
|
1478
|
+
# entire queue is past the attempt ceiling parks everything and
|
|
1479
|
+
# does nothing else, so every other counter is 0 — without this
|
|
1480
|
+
# key the gate stays shut and the run is completely silent: rc=0,
|
|
1481
|
+
# empty stdout, empty stderr, byte-identical to "queue empty".
|
|
1482
|
+
#
|
|
1483
|
+
# The backlog path narrates its own parking (`_drain_backlog_impl`
|
|
1484
|
+
# logs "parked N entries past the attempt ceiling"). The IN-HOOK
|
|
1485
|
+
# path only `debug_log`s it, so it says nothing unless debug is on
|
|
1486
|
+
# — and that is the path that runs on every session boot. Nothing
|
|
1487
|
+
# else can supply the signal either: `switchroom doctor` reads the
|
|
1488
|
+
# queue DIRECTORY, where a parked entry and a backlogged entry are
|
|
1489
|
+
# the same file. An operator watching a queue that will not shrink
|
|
1490
|
+
# could not tell "parked by the circuit breaker, needs --force"
|
|
1491
|
+
# from "ordinary backlog, will drain on its own".
|
|
1492
|
+
"parked",
|
|
1493
|
+
# Same argument as `parked` directly above, one stage earlier in
|
|
1494
|
+
# the run (#3895). A run whose pre-drain phases all raised has
|
|
1495
|
+
# every pre-drain counter at 0 — identical to a clean, empty
|
|
1496
|
+
# queue — so without this key a permanently broken phase
|
|
1497
|
+
# 0/0b/0c prints nothing at all from the CLI.
|
|
1498
|
+
"phase_failures",
|
|
1075
1499
|
)
|
|
1076
1500
|
):
|
|
1077
1501
|
print(
|
|
@@ -1083,6 +1507,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1083
1507
|
f"retried={summary['retried']} dead={summary['dead']} "
|
|
1084
1508
|
f"unknown={summary['unknown']} "
|
|
1085
1509
|
f"archive_failed={summary['archive_failed']} "
|
|
1510
|
+
f"parked={summary['parked']} "
|
|
1511
|
+
f"phase_failures={','.join(summary['phase_failures']) or 'none'} "
|
|
1086
1512
|
f"stalled={summary['stalled']} budget_exceeded={summary['budget_exceeded']}",
|
|
1087
1513
|
file=sys.stderr,
|
|
1088
1514
|
)
|
|
@@ -177,9 +177,17 @@ class HindsightClient:
|
|
|
177
177
|
tags: Optional[list] = None,
|
|
178
178
|
timeout: int = 15,
|
|
179
179
|
async_processing: bool = True,
|
|
180
|
+
observation_scopes: Optional[str] = None,
|
|
180
181
|
) -> dict:
|
|
181
182
|
"""Retain content into a bank's memory.
|
|
182
183
|
|
|
184
|
+
``observation_scopes`` is a per-row Hindsight field controlling which
|
|
185
|
+
observation scope consolidation writes this item's observations into.
|
|
186
|
+
``"shared"`` puts them in ONE global untagged scope instead of a scope
|
|
187
|
+
per tag. ``None`` (the default) omits the field entirely, so the
|
|
188
|
+
engine's own default stands and the wire body is byte-identical to a
|
|
189
|
+
pre-``observation_scopes`` client.
|
|
190
|
+
|
|
183
191
|
By default posts with ``async=true`` so the server processes extraction
|
|
184
192
|
in the background (a 200 is an ack-of-receipt, not proof of durable
|
|
185
193
|
persistence). The context field helps Hindsight cluster memories by
|
|
@@ -253,6 +261,7 @@ class HindsightClient:
|
|
|
253
261
|
tags=tags,
|
|
254
262
|
timeout=part_timeout,
|
|
255
263
|
async_processing=async_processing,
|
|
264
|
+
observation_scopes=observation_scopes,
|
|
256
265
|
)
|
|
257
266
|
if total > 1 and isinstance(response, dict):
|
|
258
267
|
response = dict(response)
|
|
@@ -269,6 +278,7 @@ class HindsightClient:
|
|
|
269
278
|
tags: Optional[list],
|
|
270
279
|
timeout: int,
|
|
271
280
|
async_processing: bool,
|
|
281
|
+
observation_scopes: Optional[str] = None,
|
|
272
282
|
) -> dict:
|
|
273
283
|
"""POST exactly one retain item. Raises on any HTTP/transport error."""
|
|
274
284
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
|
|
@@ -281,6 +291,10 @@ class HindsightClient:
|
|
|
281
291
|
item["context"] = context
|
|
282
292
|
if tags:
|
|
283
293
|
item["tags"] = tags
|
|
294
|
+
# Omitted entirely when unset — the engine default must stay in force
|
|
295
|
+
# and the body must match a pre-observation_scopes client byte for byte.
|
|
296
|
+
if observation_scopes:
|
|
297
|
+
item["observation_scopes"] = observation_scopes
|
|
284
298
|
body = {
|
|
285
299
|
"items": [item],
|
|
286
300
|
"async": bool(async_processing),
|