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.
Files changed (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. 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. Returns True on confirmed
457
- persistence. NEVER concurrent — one in flight fleet-wide via the shared
458
- inflight lock."""
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
- with inflight_lock(blocking=True) as acquired:
465
- if not acquired: # pragma: no cover - blocking acquire fails open
466
- return False
467
- try:
468
- client.retain(
469
- bank_id=bank_id,
470
- content=payload["content"],
471
- document_id=built["document_id"],
472
- context=payload["context"],
473
- metadata=payload["metadata"],
474
- tags=payload["tags"],
475
- timeout=15,
476
- async_processing=False, # commit-before-ack (daemon contract 2)
477
- observation_scopes=payload.get("observation_scopes"),
478
- )
479
- except Exception as e:
480
- debug_log(self.config, f"backfill: POST failed for {built['document_id']}: {e}")
481
- self._posted_count += 1
482
- self._on_failure()
483
- return False
484
- self._posted_count += 1
485
- self._post_times.append(time.monotonic())
486
- self._consecutive_failures = 0
487
- return True
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
- the deployed production banks are keyed with the LEGACY
697
- ``{session_id}-{epoch_ms}`` scheme (``retain.py``'s not-yet-live
698
- ``slice_document_id`` at line ~430 is the replacement), so an exact slice
699
- id set would never match a legacy doc → every slice looks "missing" →
700
- every intact session gets duplicated. Presence is decided by the id
701
- PREFIX ``{session_id}`` which BOTH schemes share (``{session_id}-...``
702
- legacy/new, or the bare ``{session_id}`` chunk-0 id). Restore fires ONLY
703
- for a session with ZERO documents — so partial-within-a-populated-session
704
- recovery is OUT OF SCOPE on legacy banks (we cannot tell WHICH turns a
705
- legacy epoch-ms id covers) and can only be added once the ``-r{uuid}``
706
- scheme is deployed and banks are re-keyed.
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", "membership_error", "ambiguous"):
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
- candidates = [t for t in first_seen if len(t) > 1 and t not in stop]
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
- Punctuation, digits, and short fragments (<= 1 char) are dropped.
476
- Returns an empty set on non-string / empty input.
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.isalpha():
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 non-tool-result text)SubagentStop fires for every
41
- Task including 10-second forks, and each retain is an LLM-backed
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
- _extract_message_blocks,
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. ``_extract_message_blocks`` is
82
- # still imported here, but only for the volume gate's char count.
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 non_tool_result_char_count(messages: list, stop_at: int | None = None) -> int:
266
- """Total chars of non-tool-result content across the transcript.
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
- Sums the extracted text + tool_use (command/input) blocks and EXCLUDES
269
- tool_result bodies the same "text vs tool output" split
270
- ``_extract_message_blocks`` already draws. This is the signal the volume
271
- gate wants: a 10-second fork with almost no narration/commands falls under
272
- the floor even if it emitted a large tool_result, while a real worker's
273
- commands and decisions count.
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
- KNOWN MISMATCH (accepted, tracked as a follow-up): this counts ``tool_use``
276
- inputs, but those are no longer RETAINED the sidechain payload is built on
277
- the text-only path. So the gate can clear on tool volume that contributes
278
- nothing to the stored memory. It cannot produce an EMPTY retain (the
279
- ``MIN_HUMAN_TURNS`` floor guarantees prose-bearing user turns see the
280
- module docstring), so this is a precision issue in the gate, not a
281
- correctness bug. Tightening it to count only text is a separate change.
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
- we stop the block walk the moment the floor is met (the returned value is
287
- then a floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the
288
- skip log's "chars>=N" read).
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
- blocks = _extract_message_blocks(m.get("content", ""), role=m.get("role", ""))
295
- for b in blocks:
296
- if not isinstance(b, dict) or b.get("type") == "tool_result":
297
- continue
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 non-tool-result text. The char walk
313
- short-circuits at the floor (finding 4) ``char_count`` is exact when below
314
- the floor and a lower bound (``>= floor``) once cleared.
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 = non_tool_result_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_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"]