switchroom 0.19.26 → 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.
Files changed (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. 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.
@@ -552,6 +771,14 @@ def _new_summary() -> dict:
552
771
  "drained": 0,
553
772
  "retried": 0,
554
773
  "dead": 0,
774
+ # Entries past `_attempt_ceiling()` — skipped by the retain pass so an
775
+ # unattended sidecar cannot spend a shared LLM lane re-POSTing the same
776
+ # unsaveable entry forever. Still queued, still reconciled for free,
777
+ # still drained in full under `--force`. See the module docstring.
778
+ "parked": 0,
779
+ # True when another drain already held the lock, so THIS run did
780
+ # nothing at all. Distinguishes "no work" from "did not run".
781
+ "skipped_locked": False,
555
782
  "reconciled": 0,
556
783
  "unknown": 0,
557
784
  # Presence WAS established, but the entry could not be moved into
@@ -566,6 +793,14 @@ def _new_summary() -> dict:
566
793
  "dead_relocated": 0,
567
794
  "resplit": 0,
568
795
  "resplit_parts": 0,
796
+ # Labels of the pre-drain phases ("0"/"0b"/"0c") that raised and were
797
+ # stepped over this run — see `_phase_failed`. A LIST, not a count,
798
+ # because which phase broke is what an operator needs. IN THE GATE,
799
+ # not only in the summary line, for exactly the reason `parked` is
800
+ # (see `main()`): every pre-drain counter reads 0 both on a run whose
801
+ # phases all blew up and on a clean, empty, healthy queue, so without
802
+ # this key the two runs are byte-identical from outside.
803
+ "phase_failures": [],
569
804
  "stalled": False,
570
805
  "budget_exceeded": False,
571
806
  }
@@ -587,17 +822,28 @@ def drain(
587
822
  backlog: bool = False,
588
823
  phase: str = "both",
589
824
  dry_run: bool = False,
825
+ force: bool = False,
590
826
  ) -> dict:
591
827
  """Walk the pending-retains directory and retry each entry.
592
828
 
593
829
  ``backlog=False`` (default) is the bounded in-hook drain.
594
830
  ``backlog=True`` is the operator backlog replay — see ``drain_backlog()``.
831
+ ``force=True`` ignores the per-entry circuit breaker (module docstring).
832
+
833
+ THE SERIALISATION POINT for every drain path — the SessionStart hook, the
834
+ `hindsight-drain` sidecar and the operator's out-of-band replay all reach
835
+ the queue through this function, so the lock is taken here rather than in
836
+ any one caller. A run that finds the lock held does NOTHING and returns a
837
+ zero summary with ``skipped_locked=True``; it never queues behind the
838
+ holder, because every caller has a next tick and none has time to wait.
595
839
 
596
840
  Returns a summary dict::
597
841
 
598
842
  {"drained": int, # successful retries (entries archived)
599
843
  "retried": int, # failures kept for next session
600
844
  "dead": int, # entries promoted to .dead this run
845
+ "parked": int, # past the attempt ceiling: not retried this run
846
+ "skipped_locked": bool, # another drain held the lock; did nothing
601
847
  "reconciled": int,# already durable, archived without a POST
602
848
  "unknown": int, # presence unknown, left queued
603
849
  "archive_failed": int, # durable, but the archive was unwritable
@@ -606,12 +852,32 @@ def drain(
606
852
  "dead_relocated": int, # legacy .dead markers moved out of the queue dir
607
853
  "resplit": int, # over-bound entries split into drainable parts
608
854
  "resplit_parts": int, # parts those entries became
855
+ "phase_failures": list[str], # pre-drain phases that raised and
856
+ # were stepped over (backlog only)
609
857
  "stalled": bool, # stall guard tripped
610
858
  "budget_exceeded": bool}
611
859
  """
612
860
  config = config or load_config()
613
- if backlog:
614
- return _drain_backlog_impl(config, phase=phase, dry_run=dry_run)
861
+ with _exclusive_drain() as acquired:
862
+ if not acquired:
863
+ summary = _new_summary()
864
+ summary["skipped_locked"] = True
865
+ print(
866
+ f"[Hindsight] drain_pending: another drain holds "
867
+ f"{_drain_lock_path()} — skipping this run so the same entry "
868
+ f"is not retained twice.",
869
+ file=sys.stderr,
870
+ )
871
+ return summary
872
+ if backlog:
873
+ return _drain_backlog_impl(
874
+ config, phase=phase, dry_run=dry_run, force=force
875
+ )
876
+ return _drain_inhook_impl(config, force=force)
877
+
878
+
879
+ def _drain_inhook_impl(config: dict, force: bool = False) -> dict:
880
+ """The bounded SessionStart drain. The caller holds the drain lock."""
615
881
  timeout = _per_entry_timeout()
616
882
  budget = _budget_seconds()
617
883
  started = time.monotonic()
@@ -622,9 +888,12 @@ def drain(
622
888
  # behind them — see ``_drain_order``. Matters even more here than in the
623
889
  # backlog drain: the in-hook budget is ~4s, so a single entry at the head
624
890
  # that always burns its clamped timeout consumes the entire run.
625
- entries = _drain_order(iter_entries())
891
+ entries = _park_broken(_drain_order(iter_entries()), summary, force)
626
892
  if not entries:
627
- debug_log(config, "drain_pending: queue empty")
893
+ debug_log(
894
+ config,
895
+ f"drain_pending: nothing to retry (parked={summary['parked']})",
896
+ )
628
897
  return summary
629
898
 
630
899
  debug_log(config, f"drain_pending: {len(entries)} entries to retry")
@@ -742,6 +1011,47 @@ def _blog(msg: str) -> None:
742
1011
  print(f"[Hindsight] drain_pending(backlog): {msg}", file=sys.stderr)
743
1012
 
744
1013
 
1014
+ def _phase_failed(summary: dict, phase: str, e: BaseException, cost: str) -> None:
1015
+ """Record a pre-drain phase that raised, and let the drain continue.
1016
+
1017
+ ONE mechanism for phases 0, 0b and 0c (#3894 / #3895). They sit in the same
1018
+ ``if not dry_run:`` block and run BEFORE the phases that actually
1019
+ drain, so a raise out of any of them takes phases 1 and 2 with it and
1020
+ the backlog becomes immortal — for every OTHER entry too, none of
1021
+ which had anything to do with the failure. Phases 0 and 0c PARSE
1022
+ queued entries, so a semantically-malformed-but-valid-JSON entry
1023
+ reaches both — precisely the class ``iter_entries``' quarantine cannot
1024
+ catch. Phase 0b parses nothing and its guard is defence-in-depth on
1025
+ the caller contract; the comment at its call site says so plainly.
1026
+ None of the three is loss-bearing to skip — a collapse, a sweep and a
1027
+ re-split only MOVE or REWRITE bytes already on disk, and none of them
1028
+ deletes — so all three are logged and stepped over, not fatal.
1029
+
1030
+ Reported in TWO places on purpose. The stderr line is for whoever is
1031
+ watching the run; ``summary["phase_failures"]`` is what makes the
1032
+ failure survive into the return value, into ``main()``'s summary line,
1033
+ and into its "anything to report?" gate. Without the second, a run
1034
+ where every pre-drain phase blew up prints exactly what an empty,
1035
+ healthy queue prints — every pre-drain counter is 0 either way — and a
1036
+ permanently broken phase is invisible to anything not tailing stderr.
1037
+ Same argument, same shape, as ``parked`` (#3893).
1038
+
1039
+ ``cost`` states what skipping this phase actually costs, in the same
1040
+ voice as the phase's own log lines: the point of the message is that
1041
+ the operator can tell at a glance that no memory was lost.
1042
+
1043
+ The message format is deliberately the one #3894 wrote inline for its
1044
+ phase-0 guard, so folding that guard onto this helper changed no
1045
+ output at all. Three hand-rolled ``except`` blocks in one block is
1046
+ exactly the shape that drifts.
1047
+ """
1048
+ summary["phase_failures"].append(phase)
1049
+ _blog(
1050
+ f"phase {phase} FAILED ({type(e).__name__}: {e}) — continuing to the "
1051
+ f"phases that actually drain. Nothing was lost: {cost}"
1052
+ )
1053
+
1054
+
745
1055
  def _reconcile_phase(config: dict, summary: dict, dry_run: bool) -> None:
746
1056
  """PHASE 1 — free pass: drop entries whose document already exists.
747
1057
 
@@ -822,7 +1132,7 @@ def _wait_for_upstream(backoff_ms: int, started: float, budget: float) -> bool:
822
1132
 
823
1133
 
824
1134
  def _drain_backlog_impl(
825
- config: dict, phase: str = "both", dry_run: bool = False
1135
+ config: dict, phase: str = "both", dry_run: bool = False, force: bool = False
826
1136
  ) -> dict:
827
1137
  """Concurrent-capable, long-budget, three-phase backlog replay."""
828
1138
  summary = _new_summary()
@@ -840,8 +1150,30 @@ def _drain_backlog_impl(
840
1150
  # the SessionStart drain's whole contract is a hard wall-clock ceiling
841
1151
  # on hook latency. New duplicates cannot accumulate there anyway:
842
1152
  # `pending.enqueue`'s filename-keyed guard stops those at the producer.
1153
+ #
1154
+ # GUARDED, because phase 0 runs BEFORE the phases that actually drain
1155
+ # (#3688 review R1-M1). `iter_entries()` quarantines an unparsable entry
1156
+ # precisely so no single corrupt file can make the whole queue immortal;
1157
+ # an unguarded collapse re-creates that failure one level up, where a
1158
+ # semantically-malformed-but-valid-JSON entry raises and takes phases 1
1159
+ # and 2 with it — a strict regression against the pre-#3688 drain, where
1160
+ # the same entry drains. A collapse failure costs duplicated work, never
1161
+ # a memory, so it is logged and stepped over, exactly as every other
1162
+ # phase treats a per-entry failure. `_attempt_count` closes the one
1163
+ # measured raise; this guard is what keeps the NEXT one from being a
1164
+ # fleet-wide drain stall.
843
1165
  if not dry_run:
844
- summary["collapsed"] = collapse_duplicates()
1166
+ try:
1167
+ summary["collapsed"] = collapse_duplicates()
1168
+ except Exception as e: # noqa: BLE001 — see comment above
1169
+ _phase_failed(
1170
+ summary,
1171
+ "0",
1172
+ e,
1173
+ "a collapse only ever MOVES a byte-identical copy into "
1174
+ "pending-duplicate/, so the cost of skipping it is duplicated "
1175
+ "work, not a memory.",
1176
+ )
845
1177
  if summary["collapsed"]:
846
1178
  _blog(
847
1179
  f"phase 0: collapsed {summary['collapsed']} duplicate entries "
@@ -853,7 +1185,29 @@ def _drain_backlog_impl(
853
1185
  # queue directory. Free, local, and an UPGRADE step: markers written
854
1186
  # by an older build sit in the directory external janitors sweep, and
855
1187
  # a marker is the only remaining copy of its memory.
856
- moved = sweep_legacy_dead_markers()
1188
+ #
1189
+ # GUARDED (#3895) because it runs BEFORE the phases that drain, but
1190
+ # be honest about WHY: unlike phase 0c this one parses no entry — it
1191
+ # walks filenames — and it already catches `OSError` per marker,
1192
+ # which covers `shutil.Error` too (an `OSError` subclass). No queue
1193
+ # state reachable today makes it raise, and none was found looking.
1194
+ # The guard is on the CALLER contract rather than a live defect:
1195
+ # nothing running before the drain may be able to stop the drain,
1196
+ # and any future line inside that loop re-opens the hole. Skipping
1197
+ # the sweep is free — the markers stay exactly where every earlier
1198
+ # build left them.
1199
+ try:
1200
+ moved = sweep_legacy_dead_markers()
1201
+ except Exception as e: # noqa: BLE001 — see `_phase_failed`
1202
+ moved = 0
1203
+ _phase_failed(
1204
+ summary,
1205
+ "0b",
1206
+ e,
1207
+ "a sweep only ever MOVES a `.dead` marker out of the live "
1208
+ "queue directory, so the cost of skipping it is that the "
1209
+ "markers stay where they already were, not a memory.",
1210
+ )
857
1211
  if moved:
858
1212
  summary["dead_relocated"] = moved
859
1213
  _blog(
@@ -871,7 +1225,30 @@ def _drain_backlog_impl(
871
1225
  # part drainable, which is the difference between a lost memory and a
872
1226
  # slow one. Runs after the duplicate collapse so a duplicated
873
1227
  # over-bound entry is split ONCE, not once per copy.
874
- entries_split, parts_written = resplit_over_bound_entries()
1228
+ #
1229
+ # GUARDED (#3895), and this is the most exposed of the pre-drain
1230
+ # phases: it PARSES every queued entry to measure it, so a
1231
+ # semantically-malformed-but-valid-JSON entry — the exact class
1232
+ # `iter_entries`' quarantine hands over rather than quarantines —
1233
+ # reaches it. Measured: one entry whose `metadata` is a string
1234
+ # instead of a mapping raises `ValueError` out of the payload
1235
+ # rebuild. Unguarded, that ONE entry stops phases 1 and 2 for every
1236
+ # other entry in the queue, on every run. Skipping the split costs
1237
+ # the over-bound entries only: they stay exactly as undrainable as
1238
+ # they were before phase 0c existed, and nothing is deleted.
1239
+ try:
1240
+ entries_split, parts_written = resplit_over_bound_entries()
1241
+ except Exception as e: # noqa: BLE001 — see `_phase_failed`
1242
+ entries_split, parts_written = 0, 0
1243
+ _phase_failed(
1244
+ summary,
1245
+ "0c",
1246
+ e,
1247
+ "a re-split only ever REWRITES an over-bound entry into "
1248
+ "drainable parts and archives the original, so the cost of "
1249
+ "skipping it is that those entries stay as undrainable as "
1250
+ "they were before phase 0c existed. Nothing is deleted.",
1251
+ )
875
1252
  if entries_split:
876
1253
  summary["resplit"] = entries_split
877
1254
  summary["resplit_parts"] = parts_written
@@ -897,8 +1274,18 @@ def _drain_backlog_impl(
897
1274
 
898
1275
  # See ``_drain_order``: without this, three budget-exhausted entries at the
899
1276
  # head trip the stall guard on every run forever and the drain never makes
900
- # progress again.
901
- entries = _drain_order(iter_entries())
1277
+ # progress again. Then the CIRCUIT BREAKER: demotion bounds head-of-line
1278
+ # blocking but not total work, and this pass is the unattended one — a
1279
+ # 3600s budget every 900s means a wedged agent would otherwise re-POST the
1280
+ # same unsaveable entries into a 4-slot shared lane indefinitely.
1281
+ entries = _park_broken(_drain_order(iter_entries()), summary, force)
1282
+ if summary["parked"]:
1283
+ _blog(
1284
+ f"parked {summary['parked']} entries past the attempt ceiling "
1285
+ f"({_attempt_ceiling()} attempts) — still queued and still "
1286
+ f"reconciled for free, but not retained again. `--force` to retry "
1287
+ f"them anyway."
1288
+ )
902
1289
  if not entries:
903
1290
  _blog("phase 2: nothing left to retain")
904
1291
  return summary
@@ -1045,6 +1432,13 @@ def _parse_args(argv: list[str] | None):
1045
1432
  action="store_true",
1046
1433
  help="with --backlog: report what would happen, issue no writes",
1047
1434
  )
1435
+ ap.add_argument(
1436
+ "--force",
1437
+ action="store_true",
1438
+ help="retry entries past the attempt ceiling too (they are skipped by "
1439
+ "default so an unattended drain cannot spend the shared retain lane "
1440
+ "on the same permanently-failing entry forever)",
1441
+ )
1048
1442
  return ap.parse_args(argv)
1049
1443
 
1050
1444
 
@@ -1058,7 +1452,11 @@ def main(argv: list[str] | None = None) -> int:
1058
1452
  return 2
1059
1453
  config = load_config()
1060
1454
  summary = drain(
1061
- config, backlog=args.backlog, phase=args.phase, dry_run=args.dry_run
1455
+ config,
1456
+ backlog=args.backlog,
1457
+ phase=args.phase,
1458
+ dry_run=args.dry_run,
1459
+ force=args.force,
1062
1460
  )
1063
1461
  if any(
1064
1462
  summary[k]
@@ -1072,6 +1470,28 @@ def main(argv: list[str] | None = None) -> int:
1072
1470
  "reconciled",
1073
1471
  "unknown",
1074
1472
  "archive_failed",
1473
+ # PARKED IS IN THE GATE, not only in the line below. A run whose
1474
+ # entire queue is past the attempt ceiling parks everything and
1475
+ # does nothing else, so every other counter is 0 — without this
1476
+ # key the gate stays shut and the run is completely silent: rc=0,
1477
+ # empty stdout, empty stderr, byte-identical to "queue empty".
1478
+ #
1479
+ # The backlog path narrates its own parking (`_drain_backlog_impl`
1480
+ # logs "parked N entries past the attempt ceiling"). The IN-HOOK
1481
+ # path only `debug_log`s it, so it says nothing unless debug is on
1482
+ # — and that is the path that runs on every session boot. Nothing
1483
+ # else can supply the signal either: `switchroom doctor` reads the
1484
+ # queue DIRECTORY, where a parked entry and a backlogged entry are
1485
+ # the same file. An operator watching a queue that will not shrink
1486
+ # could not tell "parked by the circuit breaker, needs --force"
1487
+ # from "ordinary backlog, will drain on its own".
1488
+ "parked",
1489
+ # Same argument as `parked` directly above, one stage earlier in
1490
+ # the run (#3895). A run whose pre-drain phases all raised has
1491
+ # every pre-drain counter at 0 — identical to a clean, empty
1492
+ # queue — so without this key a permanently broken phase
1493
+ # 0/0b/0c prints nothing at all from the CLI.
1494
+ "phase_failures",
1075
1495
  )
1076
1496
  ):
1077
1497
  print(
@@ -1083,6 +1503,8 @@ def main(argv: list[str] | None = None) -> int:
1083
1503
  f"retried={summary['retried']} dead={summary['dead']} "
1084
1504
  f"unknown={summary['unknown']} "
1085
1505
  f"archive_failed={summary['archive_failed']} "
1506
+ f"parked={summary['parked']} "
1507
+ f"phase_failures={','.join(summary['phase_failures']) or 'none'} "
1086
1508
  f"stalled={summary['stalled']} budget_exceeded={summary['budget_exceeded']}",
1087
1509
  file=sys.stderr,
1088
1510
  )
@@ -46,6 +46,20 @@ DEFAULTS = {
46
46
  # cap of 6 its fleet actually deploys.
47
47
  "recallOwnBankMinSlots": 0,
48
48
  "recallAdditionalBankMinSlots": 0,
49
+ # Switchroom #3837: absolute floor on a result's engine relevance score
50
+ # (`scores.final`) for it to be injected. 0.0 (default) DISABLES the floor
51
+ # — nothing is dropped and the injected set is byte-identical to the
52
+ # pre-#3837 behaviour. `recallMinScoreScope` decides which turns a
53
+ # non-zero floor binds on: "degraded" (default) = only turns where the
54
+ # agent's OWN bank timed out or was unreachable, which is the population
55
+ # where a below-floor score actually predicts noise (98.4% of degraded
56
+ # rows have a best injected score under 0.01, against 28.4% of healthy
57
+ # ones); "all" = every turn, which #3761's replay says empties ~28% of
58
+ # HEALTHY recalls at 0.01 and is not recommended as a fleet default. See
59
+ # the design note above `_filter_by_min_score` in recall.py. Env:
60
+ # HINDSIGHT_RECALL_MIN_SCORE / HINDSIGHT_RECALL_MIN_SCORE_SCOPE.
61
+ "recallMinScore": 0.0,
62
+ "recallMinScoreScope": "degraded",
49
63
  "recallTypes": ["world", "experience"],
50
64
  # Switchroom-local: when True (default; Ken-approved ON) recall biases
51
65
  # toward synthesized `observation`-tier facts. Escape hatch: pin off via
@@ -324,6 +338,12 @@ ENV_OVERRIDES = {
324
338
  # .additional_bank_min_slots (cascading through defaults). 0 = off.
325
339
  "HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS": ("recallOwnBankMinSlots", int),
326
340
  "HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS": ("recallAdditionalBankMinSlots", int),
341
+ # Switchroom #3837: absolute `scores.final` floor + the population it binds
342
+ # on. Set by start.sh from agents.<name>.memory.recall.min_score /
343
+ # .min_score_scope (cascading through defaults), exported only when the
344
+ # operator opted in. 0.0 = off (the default, and the shipped behaviour).
345
+ "HINDSIGHT_RECALL_MIN_SCORE": ("recallMinScore", float),
346
+ "HINDSIGHT_RECALL_MIN_SCORE_SCOPE": ("recallMinScoreScope", str),
327
347
  # Switchroom-local: recall fact types (comma-separated). Set by start.sh
328
348
  # from agents.<name>.memory.recall.types only when the operator overrode
329
349
  # the switchroom default (world,experience,observation) — i.e. the
@@ -333,6 +353,19 @@ ENV_OVERRIDES = {
333
353
  # from agents.<name>.memory.recall.skip_trivial only on override; the
334
354
  # switchroom default is on (recall.py falls back to True).
335
355
  "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
356
+ # Switchroom #3841: the last three recall settings that had a config key but
357
+ # no env channel at all, so switchroom.yaml could not reach them and a
358
+ # hand-edit of the installed plugin did not survive `switchroom apply`. Set
359
+ # by start.sh from agents.<name>.memory.recall.prefer_observations / .roles /
360
+ # .prompt_preamble (cascading through defaults), always exported at their
361
+ # existing effective values, so an operator who sets none of them sees no
362
+ # change. The other #3841 knobs (budget, max_tokens, context_turns,
363
+ # max_query_chars, transcript_tail_bytes, tags, tags_match, tag_groups,
364
+ # tag_weights, additional_bank_filters, transcript_fallback, parallel)
365
+ # already had entries in this table and only needed the yaml surface.
366
+ "HINDSIGHT_RECALL_PREFER_OBSERVATIONS": ("recallPreferObservations", bool),
367
+ "HINDSIGHT_RECALL_ROLES": ("recallRoles", list),
368
+ "HINDSIGHT_RECALL_PROMPT_PREAMBLE": ("recallPromptPreamble", str),
336
369
  # Switchroom #2848 Stage B: directive-capture nudge on/off. Set by
337
370
  # start.sh from agents.<name>.memory.directive_capture_nudge only when
338
371
  # the operator overrode it; the switchroom default is on (settings.json