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.
Files changed (44) hide show
  1. package/dist/agent-scheduler/index.js +16 -2
  2. package/dist/auth-broker/index.js +141 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +18 -2
  5. package/dist/cli/switchroom.js +864 -43
  6. package/dist/host-control/main.js +144 -9
  7. package/dist/vault/approvals/kernel-server.js +146 -9
  8. package/dist/vault/broker/server.js +146 -9
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +79 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +585 -50
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/CHANGELOG.md +50 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
  29. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  30. package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
  31. package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
  32. package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
  33. package/vendor/hindsight-memory/scripts/retain.py +41 -1
  34. package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
  36. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
  42. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
  43. package/vendor/hindsight-memory/settings.json +1 -1
  44. 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
- if backlog:
614
- return _drain_backlog_impl(config, phase=phase, dry_run=dry_run)
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(config, "drain_pending: queue empty")
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
- summary["collapsed"] = collapse_duplicates()
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
- moved = sweep_legacy_dead_markers()
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
- entries_split, parts_written = resplit_over_bound_entries()
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
- entries = _drain_order(iter_entries())
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, backlog=args.backlog, phase=args.phase, dry_run=args.dry_run
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),