switchroom 0.19.40 → 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.
- package/dist/agent-scheduler/index.js +22 -2
- package/dist/auth-broker/index.js +22 -2
- package/dist/cli/notion-write-pretool.mjs +22 -2
- package/dist/cli/switchroom.js +810 -241
- package/dist/host-control/main.js +94 -19
- package/dist/vault/approvals/kernel-server.js +22 -2
- package/dist/vault/broker/server.js +22 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +332 -124
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +76 -76
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/stream-render.ts +57 -0
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
- 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
|
@@ -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
|
|
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:
|