switchroom 0.19.41 → 0.19.42

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 (29) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +772 -234
  5. package/dist/host-control/main.js +23 -3
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/dist/gateway/gateway.js +97 -83
  10. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  11. package/telegram-plugin/gateway/gateway.ts +7 -7
  12. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  13. package/telegram-plugin/gateway/model-command.ts +21 -109
  14. package/telegram-plugin/gateway/stream-render.ts +57 -0
  15. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  16. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  17. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  18. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  19. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  20. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  21. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  22. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  23. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  29. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,84 @@
1
+ # Sub-agent volume-gate recalibration — #3994
2
+
3
+ ## What changed
4
+
5
+ `subagent_retain.py`'s volume gate decides whether a terminating sub-agent's
6
+ sidechain window is worth an LLM-backed retain. Its char floor
7
+ (`MIN_NON_TOOL_RESULT_CHARS = 2000`) used to be measured by a metric
8
+ (`non_tool_result_char_count`) that summed assistant/user **text** PLUS every
9
+ `tool_use` block's `name` + serialized `input`.
10
+
11
+ But the sidechain retain runs on the **text-only** path (`retainToolCalls =
12
+ False`), so `tool_use` inputs are **not** in the stored payload. The gate and
13
+ the payload measured different things: a tool-heavy / prose-light fork could
14
+ clear the gate on tool-command volume and then retain a near-empty document.
15
+
16
+ The gate now counts exactly what the text-only path keeps, via
17
+ `retained_text_char_count` → `lib.content._extract_text_content` (assistant
18
+ `text` blocks, channel-message tool_use text, plain-string user turns). Gate and
19
+ payload now measure the identical char set.
20
+
21
+ ## Re-measuring the 2,000-char floor
22
+
23
+ Because the metric shrank (it no longer counts tool chars), the floor had to be
24
+ re-measured against the corrected count — not carried over blind.
25
+
26
+ **Replay harness:** `scripts/tests/data/replay_volume_gate_3994.py`
27
+ (re-runnable; `--dir <path>` replays real `*.jsonl` sidechain transcripts,
28
+ default is a deterministic synthetic corpus parameterized by the empirical
29
+ distributions already measured and cited in the code).
30
+
31
+ **Data source note.** Fleet sidechain transcripts are private agent memory and
32
+ are not committed to this repo, so the committed run uses the
33
+ distribution-parameterized synthetic corpus (seed 1994): 120 real workers drawn
34
+ across the measured klanker text-only size range (p10 5,035 / p50 10,058 / p90
35
+ 26,040; 0 empty / 0 < 500 over 200 real transcripts — see
36
+ `test_subagent_retain_learnings.py::TextOnlyPathIsNotEmpty`), 60 trivial 10-second
37
+ forks, and 40 tool-heavy / prose-light forks (the exact shape this change exists
38
+ to reject). An operator re-runs it against ground truth with `--dir`.
39
+
40
+ ## Result (committed run, `python3 scripts/tests/data/replay_volume_gate_3994.py --json`)
41
+
42
+ | metric | value |
43
+ |---|---|
44
+ | corpus | 220 transcripts (120 real / 60 trivial / 40 tool-heavy-prose-light) |
45
+ | old gate passed but actual payload < 500 chars | **40** |
46
+ | new gate passed but actual payload < 500 chars | **0** |
47
+ | gate-passers' payload size (new, floor 2000) | min 5,591 / p10 8,293 / p50 13,968 / p90 24,672 |
48
+
49
+ Floor sweep (real workers newly skipped vs. the old 2000 gate ; empty-ish forks
50
+ now correctly skipped):
51
+
52
+ | floor | real workers newly skipped | empty-ish forks now skipped |
53
+ |---|---|---|
54
+ | 500 | 0 | 40 |
55
+ | 1000 | 0 | 40 |
56
+ | 1500 | 0 | 40 |
57
+ | **2000** | **0** | **40** |
58
+ | 3000 | 0 | 40 |
59
+ | 5000 | **2** | 40 |
60
+
61
+ ## Decision: keep the floor at 2,000
62
+
63
+ The two populations separate cleanly under the corrected metric. Every
64
+ gate-passer has a text-only payload of **≥ 5,591 chars** (p10 8,293) — real
65
+ workers cluster an order of magnitude above the floor — while the entire
66
+ trivial / tool-heavy-prose-light fork population falls below it. Any floor in
67
+ `[500, 3000]` yields the same decision on this corpus, so 2,000 is not a knife
68
+ edge: it sits in the wide flat middle. Pushing to 5,000 begins clipping genuine
69
+ short-but-real workers (2 lost), so 2,000 stays as the conservative,
70
+ well-separated choice.
71
+
72
+ The headline win: **40 forks the old gate passed to a near-empty (< 500 char)
73
+ retain are now correctly skipped, and zero real workers are lost.**
74
+
75
+ ## Guarding tests
76
+
77
+ - `test_subagent_retain.py::VolumeGate::test_tool_heavy_prose_light_fork_now_skips`
78
+ — a tool-heavy / prose-light fork that PASSED the old gate now FAILS (RED if
79
+ the metric reverts to counting `tool_use` inputs).
80
+ - `test_subagent_retain.py::VolumeGate::test_char_count_is_text_only`
81
+ — `retained_text_char_count` counts only `_extract_text_content` chars.
82
+ - `test_subagent_retain_learnings.py::ProseFreeSidechainIsSkipped`
83
+ — the prose-free degenerate case is now SKIPPED by the gate (the retain path's
84
+ own guarantee that gate-passers always carry retainable prose).
@@ -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: