switchroom 0.19.41 → 0.19.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -193,6 +193,7 @@ def _is_dynamic_bank(config: dict) -> bool:
|
|
|
193
193
|
|
|
194
194
|
_STALL_THRESHOLD = 3 # consecutive failures → circuit-breaker cooldown
|
|
195
195
|
_BACKOFF_CAP_S = 60.0 # exponential backoff ceiling
|
|
196
|
+
_POST_MAX_ATTEMPTS = 3 # bounded per-slice retry before giving up (#3291)
|
|
196
197
|
|
|
197
198
|
|
|
198
199
|
def agents_dir() -> str:
|
|
@@ -453,38 +454,59 @@ class Backfill:
|
|
|
453
454
|
self._consecutive_failures = 0
|
|
454
455
|
|
|
455
456
|
def _post_slice(self, bank_id: str, built: dict) -> bool:
|
|
456
|
-
"""Serial, paced, confirmed POST of one slice
|
|
457
|
-
|
|
458
|
-
|
|
457
|
+
"""Serial, paced, confirmed POST of one slice with bounded retry.
|
|
458
|
+
|
|
459
|
+
Returns True on confirmed persistence, False after exhausting
|
|
460
|
+
``_POST_MAX_ATTEMPTS`` transient failures. NEVER concurrent — one in
|
|
461
|
+
flight fleet-wide via the shared inflight lock.
|
|
462
|
+
|
|
463
|
+
The bounded retry (#3291) matters because every POST is
|
|
464
|
+
``async_processing=False`` (commit-before-ack): a slice that lands is
|
|
465
|
+
durable immediately, so a *single*-attempt failure on slice ``k`` used to
|
|
466
|
+
strand slices ``0..k-1`` as a committed prefix while the session was
|
|
467
|
+
marked ``failed`` and — via the session-membership skip — never revisited.
|
|
468
|
+
Retrying each slice in place lets a transient blip resolve without leaving
|
|
469
|
+
that partial-commit gap. Persistent failure still returns False so the
|
|
470
|
+
caller records ``failed`` and the gap-completion path (routed from the
|
|
471
|
+
progress record, not the membership skip) fills the remainder on re-run."""
|
|
459
472
|
client = self._ensure_client()
|
|
460
473
|
if client is None:
|
|
461
474
|
return False
|
|
462
475
|
payload = built["payload"]
|
|
463
476
|
self._pace_before_post()
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
477
|
+
last_exc = None
|
|
478
|
+
for _attempt in range(_POST_MAX_ATTEMPTS):
|
|
479
|
+
with inflight_lock(blocking=True) as acquired:
|
|
480
|
+
if not acquired: # pragma: no cover - blocking acquire fails open
|
|
481
|
+
return False
|
|
482
|
+
try:
|
|
483
|
+
client.retain(
|
|
484
|
+
bank_id=bank_id,
|
|
485
|
+
content=payload["content"],
|
|
486
|
+
document_id=built["document_id"],
|
|
487
|
+
context=payload["context"],
|
|
488
|
+
metadata=payload["metadata"],
|
|
489
|
+
tags=payload["tags"],
|
|
490
|
+
timeout=15,
|
|
491
|
+
async_processing=False, # commit-before-ack (daemon contract 2)
|
|
492
|
+
observation_scopes=payload.get("observation_scopes"),
|
|
493
|
+
)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
last_exc = e
|
|
496
|
+
debug_log(self.config, f"backfill: POST failed for {built['document_id']}: {e}")
|
|
497
|
+
self._posted_count += 1
|
|
498
|
+
self._on_failure() # backoff before the next attempt
|
|
499
|
+
continue
|
|
500
|
+
self._posted_count += 1
|
|
501
|
+
self._post_times.append(time.monotonic())
|
|
502
|
+
self._consecutive_failures = 0
|
|
503
|
+
return True
|
|
504
|
+
debug_log(
|
|
505
|
+
self.config,
|
|
506
|
+
f"backfill: POST gave up for {built['document_id']} after "
|
|
507
|
+
f"{_POST_MAX_ATTEMPTS} attempts: {last_exc}",
|
|
508
|
+
)
|
|
509
|
+
return False
|
|
488
510
|
|
|
489
511
|
# -- scanning ----------------------------------------------------------- #
|
|
490
512
|
def _list_agents(self, agent_filter: Optional[set]) -> list:
|
|
@@ -678,12 +700,49 @@ class Backfill:
|
|
|
678
700
|
"sessions_already_done": 0,
|
|
679
701
|
"sessions_restored": 0,
|
|
680
702
|
"slices_restored": 0,
|
|
703
|
+
# Slice-level gap completion (#3291): a session this backfill is the
|
|
704
|
+
# sole writer of that was previously left partially committed.
|
|
705
|
+
"sessions_gap_completed": 0,
|
|
706
|
+
"sessions_gap_incomplete": 0,
|
|
707
|
+
"slices_gap_filled": 0,
|
|
708
|
+
"slices_gap_present": 0,
|
|
709
|
+
# Convergence visibility (#3291 self-heal): sessions that STILL are
|
|
710
|
+
# not fully recovered after this run (a restore or gap-completion
|
|
711
|
+
# POST failed). Non-zero ⇒ another run is needed; surfaced so a
|
|
712
|
+
# non-converging session can never be a SILENT permanent trap.
|
|
713
|
+
"sessions_incomplete": 0,
|
|
681
714
|
"turns_recovered": 0,
|
|
682
715
|
"posts_ok": 0,
|
|
683
716
|
"posts_failed": 0,
|
|
684
717
|
"sessions": [],
|
|
685
718
|
})
|
|
686
719
|
|
|
720
|
+
def _slice_exists(self, bank_id: str, document_id: str) -> Optional[bool]:
|
|
721
|
+
"""Paced tri-state presence check for ONE deterministic slice document.
|
|
722
|
+
|
|
723
|
+
``True`` — already durable (skip, no re-cost). ``False`` — confirmed 404
|
|
724
|
+
(missing → must post). ``None`` — unknown (transport/5xx): the caller
|
|
725
|
+
(re)posts, because the daemon UPSERTs on ``document_id`` so a needless
|
|
726
|
+
re-post of an already-present slice is a safe idempotent no-op, whereas
|
|
727
|
+
SKIPPING a genuinely-missing slice would re-open the #3291 silent gap. The
|
|
728
|
+
GET is paced through the shared inflight lock like every other op."""
|
|
729
|
+
client = self._ensure_client()
|
|
730
|
+
if client is None:
|
|
731
|
+
return None
|
|
732
|
+
self._pace_before_post()
|
|
733
|
+
try:
|
|
734
|
+
with inflight_lock(blocking=True) as acquired:
|
|
735
|
+
if not acquired: # pragma: no cover - blocking acquire fails open
|
|
736
|
+
return None
|
|
737
|
+
return client.document_exists(bank_id, document_id)
|
|
738
|
+
except Exception as e: # pragma: no cover - defensive
|
|
739
|
+
debug_log(self.config, f"backfill(gap): document_exists failed "
|
|
740
|
+
f"for {document_id}: {e}")
|
|
741
|
+
return None
|
|
742
|
+
finally:
|
|
743
|
+
self._posted_count += 1
|
|
744
|
+
self._post_times.append(time.monotonic())
|
|
745
|
+
|
|
687
746
|
def _session_membership(self, bank_id: str, session_id: str) -> str:
|
|
688
747
|
"""SESSION-LEVEL, id-scheme-AGNOSTIC membership (BLOCKER-1 fix).
|
|
689
748
|
|
|
@@ -692,18 +751,22 @@ class Backfill:
|
|
|
692
751
|
``"error"`` on any query failure (caller fails CLOSED — treats it as
|
|
693
752
|
present ⇒ skip, never restores on uncertainty).
|
|
694
753
|
|
|
695
|
-
We deliberately do NOT compare against the tool's ``-r{uuid}`` slice ids
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
``
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
for a session with ZERO documents — so partial-within
|
|
704
|
-
|
|
705
|
-
legacy epoch-ms id covers)
|
|
706
|
-
|
|
754
|
+
We deliberately do NOT compare against the tool's ``-r{uuid}`` slice ids
|
|
755
|
+
here: production banks are a MIX of the deterministic ``-r{uuid}`` scheme
|
|
756
|
+
(``retain.py``'s ``slice_document_id``, live since #3246) and the older
|
|
757
|
+
``{session_id}-{epoch_ms}`` docs written before it, so an exact slice-id
|
|
758
|
+
set would never match a legacy doc → every slice looks "missing" → every
|
|
759
|
+
intact session gets duplicated. Presence is decided by the id PREFIX
|
|
760
|
+
``{session_id}`` which every scheme shares (``{session_id}-...`` legacy or
|
|
761
|
+
``-r{uuid}``, or the bare ``{session_id}`` chunk-0 id). Restore fires ONLY
|
|
762
|
+
for a session with ZERO documents — so partial-tail recovery *within a
|
|
763
|
+
legacy-populated session* stays OUT OF SCOPE (we cannot tell WHICH turns a
|
|
764
|
+
legacy epoch-ms id covers). The one partial case we CAN complete safely is
|
|
765
|
+
a session this backfill is the SOLE writer of — its docs are all
|
|
766
|
+
deterministic ``-r{uuid}`` ids, so ``_gap_complete_session`` recomputes
|
|
767
|
+
them and posts only the missing ones; that path is routed from the
|
|
768
|
+
progress ``failed`` record (a session this tool already classified
|
|
769
|
+
total-loss), never from this session-membership check.
|
|
707
770
|
|
|
708
771
|
The GET is paced through the shared inflight lock so must-fix #1's broad
|
|
709
772
|
candidate set can never create a read storm (should-fix 4b)."""
|
|
@@ -792,6 +855,21 @@ class Backfill:
|
|
|
792
855
|
"action": "skipped_recent", "idle_s": round(idle, 1)})
|
|
793
856
|
continue
|
|
794
857
|
|
|
858
|
+
# #3291: a session this backfill previously classified total-loss but
|
|
859
|
+
# left PARTIALLY committed (a slice POST failed ⇒ recorded ``failed``
|
|
860
|
+
# after slices 0..k-1 landed durably). The session-membership check
|
|
861
|
+
# below would now see that committed prefix, classify it ``present``,
|
|
862
|
+
# and skip it FOREVER — silently under-recovering the exact total-loss
|
|
863
|
+
# sessions this mode exists to rescue. Route it instead into
|
|
864
|
+
# slice-level gap completion: this tool is the sole writer, so every
|
|
865
|
+
# doc is a deterministic ``-r{uuid}`` id we can recompute and fill only
|
|
866
|
+
# the missing slices. Guarded by ``ensure_slice_turns`` (a resume with
|
|
867
|
+
# a different slice size aborts before any write), so recomputed ids
|
|
868
|
+
# are guaranteed to match the committed prefix.
|
|
869
|
+
if self.progress.status(agent, session) == "failed":
|
|
870
|
+
self._gap_complete_session(agent, session, bank_id, path, info, ar)
|
|
871
|
+
continue
|
|
872
|
+
|
|
795
873
|
# SESSION-LEVEL, scheme-agnostic membership (BLOCKER-1 fix): restore
|
|
796
874
|
# ONLY a session the bank has ZERO documents for (true total loss).
|
|
797
875
|
# A session with ANY doc (legacy epoch-ms OR new -r{uuid} scheme) is
|
|
@@ -876,9 +954,122 @@ class Backfill:
|
|
|
876
954
|
self.progress.record(agent, session, "done", slices=total, turns=n_turns)
|
|
877
955
|
else:
|
|
878
956
|
session_entry["action"] = "restore_failed"
|
|
957
|
+
ar["sessions_incomplete"] += 1
|
|
879
958
|
self.progress.record(agent, session, "failed")
|
|
880
959
|
ar["sessions"].append(session_entry)
|
|
881
960
|
|
|
961
|
+
# Convergence visibility: if any session is still short after this run,
|
|
962
|
+
# tell the operator plainly (and how to converge it) so a session that
|
|
963
|
+
# keeps failing can never be a silent permanent trap. The mechanism
|
|
964
|
+
# guarantees forward progress — each re-run posts only what is still
|
|
965
|
+
# missing — but a persistently-unpostable session needs a human.
|
|
966
|
+
if self.commit and ar["sessions_incomplete"] > 0:
|
|
967
|
+
stuck = [s.get("session") for s in ar["sessions"]
|
|
968
|
+
if s.get("action") in ("restore_failed", "gap_incomplete")]
|
|
969
|
+
print(
|
|
970
|
+
f"[Hindsight] backfill(from-logs): {agent} — "
|
|
971
|
+
f"{ar['sessions_incomplete']} session(s) still INCOMPLETE after this "
|
|
972
|
+
f"run (POST failures): {', '.join(str(s) for s in stuck)}. Re-run "
|
|
973
|
+
f"`--from-logs --commit --agent {agent}` to fill the remaining "
|
|
974
|
+
f"slices (gap-completion posts ONLY what is still missing).",
|
|
975
|
+
file=sys.stderr,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
def _gap_complete_session(self, agent, session, bank_id, path, info, ar) -> None:
|
|
979
|
+
"""Fill the missing slices of a backfill-sole-writer session left
|
|
980
|
+
partially committed by a prior run (#3291).
|
|
981
|
+
|
|
982
|
+
Precondition (enforced by the caller): the session's progress record is
|
|
983
|
+
``failed`` — this tool already classified it total-loss and committed a
|
|
984
|
+
deterministic ``-r{uuid}`` prefix before a POST failed. Because the
|
|
985
|
+
backfill is the SOLE writer of such a session, EVERY doc it holds is a
|
|
986
|
+
deterministic slice id we can recompute (``ensure_slice_turns`` guarantees
|
|
987
|
+
the same boundaries as the prior run). We check each slice's presence and
|
|
988
|
+
POST ONLY the ones the bank is missing, so the committed prefix is never
|
|
989
|
+
duplicated and the session always makes forward progress toward complete
|
|
990
|
+
recovery rather than being skipped forever by session-membership."""
|
|
991
|
+
messages = read_transcript(path)
|
|
992
|
+
if not messages:
|
|
993
|
+
return
|
|
994
|
+
slices = chunk_by_human_turns(messages, self.slice_turns)
|
|
995
|
+
if not slices:
|
|
996
|
+
return
|
|
997
|
+
built_slices = []
|
|
998
|
+
for sl in slices:
|
|
999
|
+
built = build_retain_payload(
|
|
1000
|
+
self.config, session, sl, messages,
|
|
1001
|
+
bank_id=bank_id, api_url=self._api_url or "http://backfill-client",
|
|
1002
|
+
api_token=self._api_token, retain_full_window=True, document_id=None,
|
|
1003
|
+
)
|
|
1004
|
+
if built is not None:
|
|
1005
|
+
built_slices.append((built, sl))
|
|
1006
|
+
if not built_slices:
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
total = len(built_slices)
|
|
1010
|
+
n_turns = sum(len(_human_turn_indices(sl)) for _, sl in built_slices)
|
|
1011
|
+
session_entry = {
|
|
1012
|
+
"session": session, "class": "gap_completion",
|
|
1013
|
+
"join": info["join"], "bank_id": bank_id,
|
|
1014
|
+
"slices": total, "turns": n_turns,
|
|
1015
|
+
"document_ids": [b["document_id"] for b, _ in built_slices],
|
|
1016
|
+
"action": None,
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if not self.commit:
|
|
1020
|
+
# Dry-run: report the whole session as the gap it WOULD complete
|
|
1021
|
+
# (per-slice presence probing is itself a network read, elided here).
|
|
1022
|
+
session_entry["action"] = "would_gap_complete"
|
|
1023
|
+
ar["sessions"].append(session_entry)
|
|
1024
|
+
return
|
|
1025
|
+
|
|
1026
|
+
# POST only the slices the bank is actually missing. ``True`` ⇒ already
|
|
1027
|
+
# durable (skip); ``False``/``None`` ⇒ (re)post — the daemon UPSERTs on
|
|
1028
|
+
# document_id so a needless re-post is a safe no-op, while skipping a
|
|
1029
|
+
# genuinely-missing slice would re-open the gap.
|
|
1030
|
+
filled = 0
|
|
1031
|
+
present = 0
|
|
1032
|
+
failed = 0
|
|
1033
|
+
for built, _ in built_slices:
|
|
1034
|
+
if self._slice_exists(bank_id, built["document_id"]) is True:
|
|
1035
|
+
present += 1
|
|
1036
|
+
continue
|
|
1037
|
+
if self._post_slice(bank_id, built):
|
|
1038
|
+
filled += 1
|
|
1039
|
+
ar["posts_ok"] += 1
|
|
1040
|
+
else:
|
|
1041
|
+
failed += 1
|
|
1042
|
+
ar["posts_failed"] += 1
|
|
1043
|
+
|
|
1044
|
+
ar["slices_gap_filled"] += filled
|
|
1045
|
+
ar["slices_gap_present"] += present
|
|
1046
|
+
session_entry["slices_filled"] = filled
|
|
1047
|
+
session_entry["slices_present"] = present
|
|
1048
|
+
|
|
1049
|
+
if failed == 0:
|
|
1050
|
+
# Every slice is now durable → the session is fully recovered.
|
|
1051
|
+
ar["sessions_gap_completed"] += 1
|
|
1052
|
+
ar["turns_recovered"] += n_turns
|
|
1053
|
+
session_entry["action"] = "gap_completed"
|
|
1054
|
+
last_built = built_slices[-1][0]
|
|
1055
|
+
try:
|
|
1056
|
+
watermark.commit(
|
|
1057
|
+
session, last_built["last_uuid"], last_built["document_id"],
|
|
1058
|
+
transcript_path=path, ordered_uuids=last_built["ordered_uuids"],
|
|
1059
|
+
)
|
|
1060
|
+
except Exception: # pragma: no cover - defensive
|
|
1061
|
+
pass
|
|
1062
|
+
self.progress.record(agent, session, "done", slices=total, turns=n_turns)
|
|
1063
|
+
else:
|
|
1064
|
+
# Still short — stay ``failed`` so the NEXT run re-enters this same
|
|
1065
|
+
# gap-completion path (never the membership skip) and fills whatever
|
|
1066
|
+
# remains. Convergent: each run strictly reduces the missing set.
|
|
1067
|
+
ar["sessions_gap_incomplete"] += 1
|
|
1068
|
+
ar["sessions_incomplete"] += 1
|
|
1069
|
+
session_entry["action"] = "gap_incomplete"
|
|
1070
|
+
self.progress.record(agent, session, "failed")
|
|
1071
|
+
ar["sessions"].append(session_entry)
|
|
1072
|
+
|
|
882
1073
|
def _finalize_report_from_logs(self) -> None:
|
|
883
1074
|
roll = {
|
|
884
1075
|
"mode": "from_logs",
|
|
@@ -895,6 +1086,11 @@ class Backfill:
|
|
|
895
1086
|
"sessions_already_done": 0,
|
|
896
1087
|
"sessions_restored": 0,
|
|
897
1088
|
"slices_restored": 0,
|
|
1089
|
+
"sessions_gap_completed": 0,
|
|
1090
|
+
"sessions_gap_incomplete": 0,
|
|
1091
|
+
"slices_gap_filled": 0,
|
|
1092
|
+
"slices_gap_present": 0,
|
|
1093
|
+
"sessions_incomplete": 0,
|
|
898
1094
|
"turns_recovered": 0,
|
|
899
1095
|
"posts_ok": 0,
|
|
900
1096
|
"posts_failed": 0,
|
|
@@ -909,6 +1105,8 @@ class Backfill:
|
|
|
909
1105
|
"unmatched_turns", "sessions_has_documents", "sessions_total_loss",
|
|
910
1106
|
"sessions_membership_error", "sessions_active_skipped",
|
|
911
1107
|
"sessions_already_done", "sessions_restored", "slices_restored",
|
|
1108
|
+
"sessions_gap_completed", "sessions_gap_incomplete",
|
|
1109
|
+
"slices_gap_filled", "slices_gap_present", "sessions_incomplete",
|
|
912
1110
|
"turns_recovered", "posts_ok", "posts_failed",
|
|
913
1111
|
):
|
|
914
1112
|
roll[k] += ar.get(k, 0)
|
|
@@ -965,11 +1163,15 @@ def format_report_from_logs(report: dict) -> str:
|
|
|
965
1163
|
f"already_done={ar['sessions_already_done']} "
|
|
966
1164
|
f"restored={ar['sessions_restored']} "
|
|
967
1165
|
f"slices_restored={ar['slices_restored']} "
|
|
1166
|
+
f"gap_completed={ar.get('sessions_gap_completed', 0)} "
|
|
1167
|
+
f"gap_filled={ar.get('slices_gap_filled', 0)} "
|
|
1168
|
+
f"incomplete={ar.get('sessions_incomplete', 0)} "
|
|
968
1169
|
f"turns={ar['turns_recovered']} "
|
|
969
1170
|
f"posts_ok={ar['posts_ok']} posts_failed={ar['posts_failed']}"
|
|
970
1171
|
)
|
|
971
1172
|
for s in ar.get("sessions", []):
|
|
972
|
-
if s.get("class") in ("total_loss", "
|
|
1173
|
+
if s.get("class") in ("total_loss", "gap_completion",
|
|
1174
|
+
"membership_error", "ambiguous"):
|
|
973
1175
|
lines.append(
|
|
974
1176
|
f" - {s.get('session', s.get('turn_key'))} [{s.get('class')}] "
|
|
975
1177
|
f"join={s.get('join', '-')} "
|
|
@@ -987,9 +1189,21 @@ def format_report_from_logs(report: dict) -> str:
|
|
|
987
1189
|
f"membership_err={roll.get('sessions_membership_error', 0)} "
|
|
988
1190
|
f"restored={roll.get('sessions_restored', 0)} "
|
|
989
1191
|
f"slices_restored={roll.get('slices_restored', 0)} "
|
|
1192
|
+
f"gap_completed={roll.get('sessions_gap_completed', 0)} "
|
|
1193
|
+
f"gap_filled={roll.get('slices_gap_filled', 0)} "
|
|
1194
|
+
f"incomplete={roll.get('sessions_incomplete', 0)} "
|
|
990
1195
|
f"turns_would_recover={roll.get('turns_recovered', 0)} "
|
|
991
1196
|
f"posts_ok={roll.get('posts_ok', 0)} posts_failed={roll.get('posts_failed', 0)}"
|
|
992
1197
|
)
|
|
1198
|
+
incomplete = roll.get("sessions_incomplete", 0)
|
|
1199
|
+
if incomplete:
|
|
1200
|
+
lines.append(
|
|
1201
|
+
f" ⚠ CONVERGENCE: {incomplete} session(s) still INCOMPLETE (a POST "
|
|
1202
|
+
f"failed). Re-run `--from-logs --commit` — gap-completion posts ONLY "
|
|
1203
|
+
f"the still-missing slices, so each run strictly reduces the gap. A "
|
|
1204
|
+
f"count that never reaches 0 across runs means a persistently-"
|
|
1205
|
+
f"unpostable session that needs a human."
|
|
1206
|
+
)
|
|
993
1207
|
lines.append(" NOTE: recovers only ZERO-document (total-loss) sessions. "
|
|
994
1208
|
"Partial-tail recovery within a populated session is OUT OF SCOPE "
|
|
995
1209
|
"on legacy epoch-ms banks (needs the -r{uuid} id scheme deployed).")
|
|
@@ -466,11 +466,20 @@ def shape_recall_query(
|
|
|
466
466
|
for index, token in enumerate(tokens):
|
|
467
467
|
first_seen.setdefault(token, index)
|
|
468
468
|
|
|
469
|
-
|
|
469
|
+
# Stopword removal is the PRIMARY filter; the length guard lives only in the
|
|
470
|
+
# fallback below (#3766). Filtering ``len(t) > 1`` here would silently drop a
|
|
471
|
+
# single-char SUBJECT — a language name ('C', 'R'), a single-digit version
|
|
472
|
+
# ('v3' tokenizes fine, but a bare '9' in "python 9077" or "Angular 9" does
|
|
473
|
+
# not) — before the fallback ever runs, leaving the query with only its
|
|
474
|
+
# multi-char stopwords. Single-char stopwords ('a', 'i', 's', 't') are still
|
|
475
|
+
# removed because they are in ``stop``; a single-char content word survives.
|
|
476
|
+
candidates = [t for t in first_seen if t not in stop]
|
|
470
477
|
if not candidates:
|
|
471
478
|
# Every term was a stopword. Fall back to the unfiltered token set so a
|
|
472
479
|
# short conversational prompt ("what did you say about it?") still
|
|
473
|
-
# searches for something.
|
|
480
|
+
# searches for something. The ``len(t) > 1`` guard applies HERE — with
|
|
481
|
+
# every term a stopword there is no subject to protect, so dropping the
|
|
482
|
+
# remaining single-char noise is safe.
|
|
474
483
|
candidates = [t for t in first_seen if len(t) > 1] or list(first_seen)
|
|
475
484
|
|
|
476
485
|
def weight(token):
|
|
@@ -472,15 +472,30 @@ _OVERLAP_STOPWORDS = frozenset({
|
|
|
472
472
|
def _overlap_tokens(text) -> set:
|
|
473
473
|
"""Tokenize text into a stop-word-stripped, lowercased set of terms.
|
|
474
474
|
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
Tokens are maximal runs of ALPHANUMERIC characters (``str.isalnum``);
|
|
476
|
+
everything else (whitespace, punctuation) is a separator. Short fragments
|
|
477
|
+
(<= 1 char) and stop-words are dropped. Returns an empty set on non-string /
|
|
478
|
+
empty input.
|
|
479
|
+
|
|
480
|
+
#3578 — DIGITS CARRY SIGNAL. This fallback tokenizer feeds the #3369
|
|
481
|
+
transcript-grep fallback's keyword match (``_build_transcript_fallback``):
|
|
482
|
+
a recent transcript turn is kept only if it shares a token with the recall
|
|
483
|
+
query. This fleet talks in identifiers — "did PR 3993 land", "the :9077
|
|
484
|
+
port", "v0.19.24" — and the previous ``ch.isalpha()`` accumulation dropped
|
|
485
|
+
every digit on the floor, so a turn whose ONLY tie to the query was the
|
|
486
|
+
issue number '3993' shared no token and was filtered out. Accumulating
|
|
487
|
+
``isalnum`` runs keeps pure-digit and mixed identifiers ('3993', '9077',
|
|
488
|
+
'v0') as tokens, so issue numbers, ports and versions match symmetrically on
|
|
489
|
+
both the query and transcript sides. A lone digit (a bare '9') is still
|
|
490
|
+
dropped by the ``> 1`` length guard, same as a lone letter — it is noise, not
|
|
491
|
+
an identifier. See ``tests/test_overlap_tokens.py`` for the characterisation.
|
|
477
492
|
"""
|
|
478
493
|
if not isinstance(text, str) or not text:
|
|
479
494
|
return set()
|
|
480
495
|
out = set()
|
|
481
496
|
cur = []
|
|
482
497
|
for ch in text:
|
|
483
|
-
if ch.
|
|
498
|
+
if ch.isalnum():
|
|
484
499
|
cur.append(ch.lower())
|
|
485
500
|
else:
|
|
486
501
|
if cur:
|
|
@@ -37,9 +37,9 @@ Flow:
|
|
|
37
37
|
2. Resolve the sidechain transcript (agent_transcript_path → derived
|
|
38
38
|
subagents/ dir → project-dir scan).
|
|
39
39
|
3. Volume gate: skip sub-agents below the floor (< 6 human turns OR
|
|
40
|
-
< 2,000 chars of
|
|
41
|
-
Task including 10-second forks, and
|
|
42
|
-
extraction.
|
|
40
|
+
< 2,000 chars of RETAINED text — the chars the text-only path keeps,
|
|
41
|
+
#3994) — SubagentStop fires for every Task including 10-second forks, and
|
|
42
|
+
each retain is an LLM-backed extraction.
|
|
43
43
|
4. Retain a bounded window (last N=40 human turns), tagged ``sidechain`` +
|
|
44
44
|
``parent_session:<id>``, with a deterministic content-derived document_id
|
|
45
45
|
so re-fires upsert instead of duplicating.
|
|
@@ -62,7 +62,7 @@ from lib.bank import derive_bank_id, ensure_bank_mission
|
|
|
62
62
|
from lib.client import HindsightClient
|
|
63
63
|
from lib.config import debug_log, load_config
|
|
64
64
|
from lib.content import (
|
|
65
|
-
|
|
65
|
+
_extract_text_content,
|
|
66
66
|
_is_tool_result_only_user_message,
|
|
67
67
|
slice_last_turns_by_user_boundary,
|
|
68
68
|
transcript_first_line_is_sidechain,
|
|
@@ -78,8 +78,9 @@ from retain import build_retain_payload, read_transcript
|
|
|
78
78
|
# Retain the last N human turns of the sidechain. The window is formatted on the
|
|
79
79
|
# TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
|
|
80
80
|
# ``run_subagent_retain``), so tool_use inputs and tool_result bodies are dropped
|
|
81
|
-
# entirely rather than passed through / truncated. ``
|
|
82
|
-
#
|
|
81
|
+
# entirely rather than passed through / truncated. ``_extract_text_content`` is
|
|
82
|
+
# imported here so the volume gate's char count measures the SAME text the retain
|
|
83
|
+
# path keeps (#3994) — gate and payload never diverge.
|
|
83
84
|
SIDECHAIN_WINDOW_TURNS = 40
|
|
84
85
|
|
|
85
86
|
# Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
|
|
@@ -262,59 +263,63 @@ def count_human_turns(messages: list) -> int:
|
|
|
262
263
|
return n
|
|
263
264
|
|
|
264
265
|
|
|
265
|
-
def
|
|
266
|
-
"""Total chars of
|
|
266
|
+
def retained_text_char_count(messages: list, stop_at: int | None = None) -> int:
|
|
267
|
+
"""Total chars of the content the TEXT-ONLY retain path actually keeps.
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
#3994 — GATE COUNTS WHAT IS RETAINED. The sidechain payload is built with
|
|
270
|
+
``retainToolCalls = False`` (``run_subagent_retain``), so what reaches memory
|
|
271
|
+
is exactly ``_extract_text_content``: assistant ``text`` blocks, channel-
|
|
272
|
+
message tool_use text, and plain-string user turns — and NOTHING else. This
|
|
273
|
+
gate counts the same thing, per message, so "cleared the floor" now means
|
|
274
|
+
"has >= N chars of RETAINABLE prose", not "emitted N chars of tool traffic
|
|
275
|
+
the payload then drops".
|
|
274
276
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
``
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
The previous revision counted ``tool_use`` name+input serialized size on top
|
|
278
|
+
of text. Those chars are no longer in the payload, so the old gate could
|
|
279
|
+
clear on tool volume that contributed zero to the stored memory (a tool-heavy
|
|
280
|
+
/ prose-light fork passed, then retained a near-empty document). Counting via
|
|
281
|
+
``_extract_text_content`` closes that mismatch: gate and payload measure the
|
|
282
|
+
identical char set. The 2,000-char floor was re-measured against this metric
|
|
283
|
+
— see ``docs/measurements/subagent-volume-gate-3994.md`` and the replay
|
|
284
|
+
harness ``scripts/tests/data/replay_volume_gate_3994.py``.
|
|
282
285
|
|
|
283
286
|
``stop_at`` (review finding 4 — early short-circuit): return as soon as the
|
|
284
287
|
running total reaches this many chars. The gate only needs to know whether
|
|
285
|
-
the floor is CLEARED, not the exact size — so on a large worker transcript
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
the floor is CLEARED, not the exact size — so on a large worker transcript we
|
|
289
|
+
stop the walk the moment the floor is met (the returned value is then a
|
|
290
|
+
floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the skip
|
|
291
|
+
log's "chars>=N" read).
|
|
289
292
|
"""
|
|
290
293
|
total = 0
|
|
291
294
|
for m in messages:
|
|
292
295
|
if not isinstance(m, dict):
|
|
293
296
|
continue
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if b.get("type") == "text":
|
|
299
|
-
total += len(b.get("text", ""))
|
|
300
|
-
elif b.get("type") == "tool_use":
|
|
301
|
-
# Command / input is a process fact; count its serialized size.
|
|
302
|
-
total += len(b.get("name", "")) + len(json.dumps(b.get("input", {}), ensure_ascii=False))
|
|
297
|
+
# Count exactly the chars the text-only retain path keeps for this
|
|
298
|
+
# message — identical extraction to ``prepare_retention_transcript``'s
|
|
299
|
+
# ``include_tool_calls=False`` branch, so gate and payload never diverge.
|
|
300
|
+
total += len(_extract_text_content(m.get("content", ""), role=m.get("role", "")))
|
|
303
301
|
if stop_at is not None and total >= stop_at:
|
|
304
302
|
return total
|
|
305
303
|
return total
|
|
306
304
|
|
|
307
305
|
|
|
306
|
+
# Back-compat alias: the previous name measured a superset (text + tool_use).
|
|
307
|
+
# Kept so an out-of-tree caller does not break, but it now returns the
|
|
308
|
+
# recalibrated text-only count (#3994).
|
|
309
|
+
non_tool_result_char_count = retained_text_char_count
|
|
310
|
+
|
|
311
|
+
|
|
308
312
|
def passes_volume_gate(messages: list, config: dict) -> tuple:
|
|
309
313
|
"""Return ``(passed, human_turns, char_count)`` for the volume gate.
|
|
310
314
|
|
|
311
315
|
Skip sub-agents below EITHER floor: < ``MIN_HUMAN_TURNS`` human turns OR
|
|
312
|
-
< ``MIN_NON_TOOL_RESULT_CHARS`` chars of
|
|
313
|
-
|
|
314
|
-
|
|
316
|
+
< ``MIN_NON_TOOL_RESULT_CHARS`` chars of RETAINED text (the chars the
|
|
317
|
+
text-only path actually keeps, #3994). The char walk short-circuits at the
|
|
318
|
+
floor (finding 4) — ``char_count`` is exact when below the floor and a lower
|
|
319
|
+
bound (``>= floor``) once cleared.
|
|
315
320
|
"""
|
|
316
321
|
turns = count_human_turns(messages)
|
|
317
|
-
chars =
|
|
322
|
+
chars = retained_text_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_CHARS)
|
|
318
323
|
passed = turns >= MIN_HUMAN_TURNS and chars >= MIN_NON_TOOL_RESULT_CHARS
|
|
319
324
|
return passed, turns, chars
|
|
320
325
|
|
|
@@ -521,7 +526,23 @@ def run_subagent_retain(hook_input: dict) -> dict:
|
|
|
521
526
|
document_id=None, # content-derived from the slice's first/last uuids
|
|
522
527
|
)
|
|
523
528
|
if built is None:
|
|
529
|
+
# #4001 — this must NOT be silent. It is the "should never happen" branch
|
|
530
|
+
# (the volume gate guarantees >= MIN_NON_TOOL_RESULT_CHARS of retainable
|
|
531
|
+
# text, so a window that cleared the gate should always format to
|
|
532
|
+
# something), so if it fires it means the gate and the formatter disagree
|
|
533
|
+
# about what counts as retainable — a real regression that would
|
|
534
|
+
# otherwise vanish with debug off in the shipped settings. Emit
|
|
535
|
+
# unconditionally on stderr so it surfaces in the gateway log regardless
|
|
536
|
+
# of the debug flag; keep the debug_log for the verbose trace.
|
|
524
537
|
debug_log(config, "SubagentStop: empty transcript after formatting, skipping")
|
|
538
|
+
print(
|
|
539
|
+
"[Hindsight] SubagentStop: sidechain window formatted to EMPTY on the "
|
|
540
|
+
f"text-only path despite clearing the volume gate "
|
|
541
|
+
f"(session={session_id} agent={agent_id} turns={turns} chars>={chars}) "
|
|
542
|
+
"— retain skipped. This should not happen; if it recurs the volume "
|
|
543
|
+
"gate and the text-only formatter have diverged (investigate #3994).",
|
|
544
|
+
file=sys.stderr,
|
|
545
|
+
)
|
|
525
546
|
return {"status": "skipped", "reason": "empty transcript after formatting"}
|
|
526
547
|
|
|
527
548
|
payload = built["payload"]
|