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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -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/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- 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.
|
|
@@ -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
|
-
|
|
614
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|