Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions polylogue/archive/session/attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
".snapshot",
".snapshots",
)
_AGENT_TMP_SPOOL_RE = re.compile(
r"^(?:claude|codex)-(?:[0-9]+|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$",
re.IGNORECASE,
)


def _lexical_expanduser(value: str) -> str:
Expand All @@ -77,7 +81,7 @@ def _is_ignored_absolute_path(path: PurePosixPath) -> bool:
return True
if parts[:2] == ("nix", "store"):
return True
if parts[0] == "tmp" and any(part.startswith("claude-") or part.startswith("codex-") for part in parts[1:]):
if len(parts) >= 2 and parts[0] == "tmp" and _AGENT_TMP_SPOOL_RE.fullmatch(parts[1]):
return True
if parts[:2] == ("home", Path.home().name):
if parts[2:3] in ((".claude",), (".codex",)):
Expand All @@ -93,6 +97,34 @@ def _is_ignored_absolute_path(path: PurePosixPath) -> bool:
return False


def _ambient_noise_root(path: PurePosixPath) -> PurePosixPath | None:
"""Return the infrastructure root that makes an absolute path noisy.

This deliberately excludes names such as ``tool-results``: a checkout may
own a directory with that name. The roots returned here identify ambient
agent/runtime trees where a stray Git marker in an ancestor must not make
every descendant look repository-owned.
"""
parts = tuple(part for part in path.parts if part != "/")
if len(parts) >= 2 and parts[0] == "tmp" and _AGENT_TMP_SPOOL_RE.fullmatch(parts[1]):
return PurePosixPath("/", *parts[:2])
if parts[:2] == ("nix", "store"):
return PurePosixPath("/nix/store")
if parts[:2] == ("home", Path.home().name):
if parts[2:3] in ((".claude",), (".codex",)):
return PurePosixPath("/", *parts[:3])
if parts[2:4] in ((".config", "claude"), (".config", "codex")):
return PurePosixPath("/", *parts[:4])
return None


def _repo_root_is_broad_noise_ancestor(repo_root: str, path: PurePosixPath) -> bool:
noise_root = _ambient_noise_root(path)
if noise_root is None:
return False
return PurePosixPath(repo_root) in noise_root.parents
Comment thread
Sinity marked this conversation as resolved.


def _repo_root_from_path(path: str) -> str | None:
"""Derive a likely repository root from a file path."""
return normalize_repo_path(path)
Expand Down Expand Up @@ -126,8 +158,15 @@ def _clean_attributed_path(path: str) -> str | None:
return None
return candidate
expanded = _lexical_expanduser(candidate)
pure_path = PurePosixPath(expanded)
ignored_absolute = _is_ignored_absolute_path(pure_path)
repo_root = _repo_root_from_path(expanded)
if repo_root is not None:
# Checkout ownership outranks global transcript-noise names even after a
# file has been deleted or renamed. A Git marker *above* an ambient agent
# tree does not grant that ownership: for example, ``/tmp/.git`` must not
# authorize every ``/tmp/claude-*`` spool path, while a real checkout
# rooted inside that spool remains valid.
if repo_root is not None and not _repo_root_is_broad_noise_ancestor(repo_root, pure_path):
try:
repo_relative = PurePosixPath(PurePosixPath(expanded).relative_to(PurePosixPath(repo_root)).as_posix())
except ValueError:
Expand All @@ -136,8 +175,7 @@ def _clean_attributed_path(path: str) -> str | None:
return None
return expanded

pure_path = PurePosixPath(expanded)
if _is_ignored_absolute_path(pure_path):
if ignored_absolute:
return None

parts = [part for part in pure_path.parts if part != "/"]
Expand Down
37 changes: 35 additions & 2 deletions polylogue/daemon/convergence_stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ def execute(path: Path) -> StageExecuteReturn:
session_ids=session_ids,
page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE,
)
_record_fts_freshness_after_insights(conn)
conn.commit()
logger.info(
"insights: refreshed sessions=%d profiles=%d work_events=%d phases=%d threads=%d",
Expand Down Expand Up @@ -632,6 +633,7 @@ def execute_many(paths: Sequence[Path]) -> StageExecuteReturn:
session_ids=session_ids,
page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE,
)
_record_fts_freshness_after_insights(conn)
conn.commit()
logger.info(
"insights: batch refreshed paths=%d sessions=%d profiles=%d work_events=%d phases=%d threads=%d",
Expand Down Expand Up @@ -700,6 +702,7 @@ def execute_sessions(session_ids: Sequence[str]) -> StageExecuteReturn:
session_ids=ids,
page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE,
)
_record_fts_freshness_after_insights(conn)
conn.commit()
remaining = _stale_session_profile_ids(conn, ids)
logger.info(
Expand Down Expand Up @@ -1434,6 +1437,34 @@ def _mark_message_fts_ready_after_targeted_repair(conn: sqlite3.Connection) -> N
)


def _record_fts_freshness_after_insights(conn: sqlite3.Connection) -> None:
"""Publish exact FTS readiness after insight rows have changed.

The insights materializer writes ``session_work_events`` after the message
FTS convergence stage has run. Its triggers keep the derived FTS rows in
sync, but readiness consumers use the durable freshness snapshot rather
than inferring health from trigger presence. Record the exact post-write
invariant here so every production insights route owns the same final
state; test harnesses must not repair the ledger themselves.
"""
from polylogue.storage.fts.freshness import READY, STALE, record_fts_surface_state_sync
from polylogue.storage.fts.fts_lifecycle import session_work_events_fts_invariant_sync

surface = session_work_events_fts_invariant_sync(conn)
record_fts_surface_state_sync(
conn,
surface=surface.name,
state=READY if surface.ready else STALE,
source_rows=surface.source_rows,
indexed_rows=surface.indexed_rows,
missing_rows=surface.missing_rows,
excess_rows=surface.excess_rows,
duplicate_rows=surface.duplicate_rows,
identity_mismatch_rows=surface.identity_mismatch_rows,
detail=None if surface.ready else "exact invariant failed after insights refresh",
)


def _session_ids_missing_profiles(conn: sqlite3.Connection) -> list[str]:
"""Sessions whose session_profile is missing or stale (#1620)."""
from polylogue.storage.insights.session.status import SESSION_PROFILE_REPAIR_CANDIDATES_SQL
Expand Down Expand Up @@ -2351,8 +2382,10 @@ def _archive_insights_execute_ids(
stage_timings_s=stage_timings_s,
stage_timing_prefix="insights",
)
# rebuild_session_insights_sync commits internally when session_ids is
# not None; no explicit conn.commit() needed here.
# The rebuild commits its own rows. Publish and commit the final exact FTS
# state in the same production stage before reporting success.
_record_fts_freshness_after_insights(conn)
conn.commit()
remaining = _archive_stale_session_profile_ids(conn, list(session_ids))
logger.info(
"insights: archive refreshed sessions=%d profiles=%d work_events=%d phases=%d threads=%d remaining=%d",
Expand Down
6 changes: 6 additions & 0 deletions polylogue/storage/fts/fts_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,11 @@ def _optional_session_work_events_fts_invariant_sync(conn: sqlite3.Connection) -
)


def session_work_events_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvariant:
"""Return the exact work-event FTS invariant without rescanning messages."""
return _optional_session_work_events_fts_invariant_sync(conn)


def _messages_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvariant:
"""Return the block-backed message FTS invariant."""
return _trigger_invariant_sync(
Expand Down Expand Up @@ -1120,6 +1125,7 @@ def _messages_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvarian
"replace_fts_rows_for_messages_sync",
"restore_message_fts_triggers_sync",
"restore_fts_triggers_sync",
"session_work_events_fts_invariant_sync",
"suspend_message_fts_triggers_sync",
"suspend_fts_triggers_sync",
]
20 changes: 4 additions & 16 deletions tests/benchmarks/test_archive_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
from devtools.archive_space_report import build_space_report
from polylogue.cli.commands.maintenance._backup_plan import _backup_plan_payload
from polylogue.storage.blob_gc import run_blob_gc
from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS
from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS, initialize_archive_database
from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier
from tests.benchmarks.helpers import BenchmarkFixture


Expand All @@ -33,20 +34,7 @@ def _seed_archive_tiers(root: Path) -> None:


def _seed_gc_db(path: Path) -> None:
with sqlite3.connect(path) as conn:
conn.executescript(
"""
CREATE TABLE raw_sessions(raw_id TEXT PRIMARY KEY, blob_hash BLOB);
CREATE TABLE blob_refs(blob_hash BLOB PRIMARY KEY);
CREATE TABLE gc_generations(
generation_id TEXT PRIMARY KEY,
started_at_ms INTEGER NOT NULL,
completed_at_ms INTEGER,
reclaimed_count INTEGER NOT NULL DEFAULT 0,
reclaimed_bytes INTEGER NOT NULL DEFAULT 0
);
"""
)
initialize_archive_database(path, ArchiveTier.SOURCE)


def _seed_sharded_blobs(blob_root: Path, count: int) -> None:
Expand Down Expand Up @@ -107,7 +95,7 @@ def test_bench_blob_gc_dry_run_candidate_scan(
) -> None:
"""Scan sharded blob candidates through GC dry-run without deleting files."""
monkeypatch.chdir(tmp_path)
archive_db = tmp_path / "index.db"
archive_db = tmp_path / "source.db"
blob_root = tmp_path / "blob"
blob_count = 256
_seed_gc_db(archive_db)
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"anchors":{"claude-code-session:demo-session-1":{"kind":"session","line_index":0,"segment_index":-1,"seq":0,"sha256":"3656df86001083b55b3e65b468fc08ea498e727538bb01eac4dbec8fb58de2a4"},"claude-code-session:demo-session-1:0":{"kind":"session_event","line_index":3,"segment_index":2,"seq":11,"sha256":"4f3c604b2faf5f668177e8d53caeaf5065ab092213140c285b865f249beca7c1"},"claude-code-session:demo-session-1:lineage:claude-code-session:parent-session-0:resume":{"kind":"lineage","line_index":1,"segment_index":-1,"seq":1,"sha256":"a6d10e714a92b9efcce461ab4cd9ab42e8815165e869f116d7ae8612054f06dc"},"claude-code-session:demo-session-1:msg-1":{"kind":"message","line_index":0,"segment_index":0,"seq":0,"sha256":"90b5f5b31d2468639687ebd3909bdc386cf8808ed67945f6a46d1ffad0486710"},"claude-code-session:demo-session-1:msg-2":{"kind":"message","line_index":1,"segment_index":0,"seq":1,"sha256":"126a4b1325d9bb3c623377c3ff87cb62b37bd2731a0b62d602bffdeffc6582f5"},"claude-code-session:demo-session-1:msg-2:0":{"kind":"block","line_index":2,"segment_index":0,"seq":2,"sha256":"b3ba53ce532071e3b3cffb8e6696533c4ef01bf5821497b599f2dd7682ca099a"},"claude-code-session:demo-session-1:msg-2:attachment:0":{"kind":"attachment","line_index":3,"segment_index":0,"seq":3,"sha256":"ece401d0007873a9f06c46a05457a2e7dd8c818fcbb66832a2b7222d192cd0fe"},"claude-code-session:demo-session-1:msg-3":{"kind":"message","line_index":0,"segment_index":1,"seq":4,"sha256":"28a7e8c51df4e1e8b333b6f9a5d17ee6a9aaa86ed86abd10a7b9012cc65aeb49"},"claude-code-session:demo-session-1:msg-3:0":{"kind":"block","line_index":1,"segment_index":1,"seq":5,"sha256":"fb915f9075ef516854f9c385ea746c0ee11b27a7645401f931e80c20bd04d4bd"},"claude-code-session:demo-session-1:msg-4":{"kind":"message","line_index":2,"segment_index":1,"seq":6,"sha256":"25647e627867d2af1cdff6e75f5fbafa8230750cf25e1132970a3d48e1077438"},"claude-code-session:demo-session-1:msg-4:0":{"kind":"block","line_index":3,"segment_index":1,"seq":7,"sha256":"510d64c86f36e5e6041b4b928a5cde0082ff178e3c972b059f19fce922526f76"},"claude-code-session:demo-session-1:msg-5":{"kind":"message","line_index":0,"segment_index":2,"seq":8,"sha256":"b586b12c920af3f00674babdd9a7393ea1ae78d45964678a8c0362e9b7fbb408"},"claude-code-session:demo-session-1:msg-5:0":{"kind":"block","line_index":1,"segment_index":2,"seq":9,"sha256":"c015b305a2a9431e6f54a4a86a48c3e018b703147a51d5f2a39523098277e19b"},"claude-code-session:demo-session-1:msg-6":{"kind":"message","line_index":2,"segment_index":2,"seq":10,"sha256":"c6e5a803533cfb90514747573ae95e0d705e1c6595b92b56fa7cf38fd00c9505"},"claude-code-session:demo-session-1:usage:claude-sonnet-5":{"kind":"usage","line_index":2,"segment_index":-1,"seq":2,"sha256":"b9dbdbd29c3c32ab7121f4de63c025c1608f2aab23e81210cf5bf742cadadabf"}},"completeness":"complete","content_digest":{"canonicalizer_version":1,"media_type":"application/x-ndjson; charset=utf-8","polylogue_sha256":"ad26730bdc12350fc2a9f18849afabc5c0119a5de739d257f019823bc650ef5d","provider_digest":null,"sinex_cas_digest":null,"size_bytes":7465},"expected_record_counts":{"attachment":1,"block":4,"lineage":1,"message":6,"session":1,"session_event":1,"usage":1},"fidelity_gaps":[{"detail":"attachment referenced by the provider export but bytes were never fetched","gap_kind":"unavailable_attachment_bytes","record_id":"claude-code-session:demo-session-1:msg-2:attachment:0","scope":"attachment"},{"detail":"provider omitted occurred_at; position ordinal is authoritative","gap_kind":"missing_timestamp","record_id":"claude-code-session:demo-session-1:msg-3","scope":"message"}],"head_segment":{"filename":"head.ndjson","first_seq":0,"index":-1,"last_seq":2,"record_count":3,"sha256":"2c96a8d9186a28b9bb7ad26620161dbd1af32d11c8f1f62b418469100ed3765e","size_bytes":1280},"native_id":"demo-session-1","origin":"claude-code-session","origin_vocabulary_digest":"f05126b022becf8fcebe9622919465b5e1f86163c25ecdda9d7e1259caba3512","origin_vocabulary_version":3,"protocol_version":"polylogue.material-protocol/v1","revision_created_at":"2026-07-12T00:00:00Z","revision_id":"ad26730bdc12350fc2a9f18849afabc5c0119a5de739d257f019823bc650ef5d","segments":[{"filename":"seg-00000.ndjson","first_seq":0,"index":0,"last_seq":3,"record_count":4,"sha256":"d28b7c6d7611fac862fbb04253604802a5fe5c8fe9028b0f9d8e1e7ec11df3b2","size_bytes":2118},{"filename":"seg-00001.ndjson","first_seq":4,"index":1,"last_seq":7,"record_count":4,"sha256":"a45ba4c44c34b9565f091fec7b67ad4f88b4f6d2f8204d5e2ab000f0304ba7d8","size_bytes":2077},{"filename":"seg-00002.ndjson","first_seq":8,"index":2,"last_seq":11,"record_count":4,"sha256":"655425db624f522a781f3e3e164fd718dd24dfab1f56f5af53b7e7693910a296","size_bytes":1990}],"semantics_version":2,"sequence_rule":"two seq spaces: head (session, sorted lineage, sorted usage; re-encoded every revision, never byte-reused) and transcript (strictly-increasing seq from 0; per-message(transcript order): message, blocks(position), attachments(position), owned session_events(position); trailing unowned session_events last; append-only, byte-reuse gated on canonical-byte prefix equality)","session_id":"claude-code-session:demo-session-1","superseded_revision_id":null}
{"anchors":{"claude-code-session:demo-session-1":{"kind":"session","line_index":0,"segment_index":-1,"seq":0,"sha256":"3656df86001083b55b3e65b468fc08ea498e727538bb01eac4dbec8fb58de2a4"},"claude-code-session:demo-session-1:0":{"kind":"session_event","line_index":3,"segment_index":2,"seq":11,"sha256":"d947cd8890e1d9813773bc6910e43b15bc1a9a0c7092d5499217ddd3f904617d"},"claude-code-session:demo-session-1:lineage:claude-code-session:parent-session-0:resume":{"kind":"lineage","line_index":1,"segment_index":-1,"seq":1,"sha256":"a6d10e714a92b9efcce461ab4cd9ab42e8815165e869f116d7ae8612054f06dc"},"claude-code-session:demo-session-1:n:msg-1":{"kind":"message","line_index":0,"segment_index":0,"seq":0,"sha256":"338b6ddbce2d03422d164ae1323b0797d4abe1224726258fe7d35ac5b09cbf92"},"claude-code-session:demo-session-1:n:msg-2":{"kind":"message","line_index":1,"segment_index":0,"seq":1,"sha256":"bfe3c18c89e7995c75f44ee97501c1d606f899eb452c278c302acdbb4378e41b"},"claude-code-session:demo-session-1:n:msg-2:0":{"kind":"block","line_index":2,"segment_index":0,"seq":2,"sha256":"9db24cf0bc6d1d290eef1af0fce4143ae22ac323a95f4397483cb2154444a41d"},"claude-code-session:demo-session-1:n:msg-2:attachment:0":{"kind":"attachment","line_index":3,"segment_index":0,"seq":3,"sha256":"48c0722589dcc70afe9c78fcd4b22fb1767e100a6aa05948591e6ea12275c7f1"},"claude-code-session:demo-session-1:n:msg-3":{"kind":"message","line_index":0,"segment_index":1,"seq":4,"sha256":"309844dccc55c4005395d381dbb7190f864fcb4fd01b8c063fed84c187f73018"},"claude-code-session:demo-session-1:n:msg-3:0":{"kind":"block","line_index":1,"segment_index":1,"seq":5,"sha256":"904edae0796ffe9b41f628136b26782b69bfebf782c5a54bdb3f4f5a6677cf80"},"claude-code-session:demo-session-1:n:msg-4":{"kind":"message","line_index":2,"segment_index":1,"seq":6,"sha256":"d3ade67d035bd664f2e2104cfae809620ca9ad27881785f601b3cd6173313480"},"claude-code-session:demo-session-1:n:msg-4:0":{"kind":"block","line_index":3,"segment_index":1,"seq":7,"sha256":"91b85cfe37f7d67cde23ef95962dea75078d703ba6e9930782e2f13ec3965bfb"},"claude-code-session:demo-session-1:n:msg-5":{"kind":"message","line_index":0,"segment_index":2,"seq":8,"sha256":"851a1d2ce36084b4a38150369bbf316ea4b2c622d062997d66f5cef803fbb969"},"claude-code-session:demo-session-1:n:msg-5:0":{"kind":"block","line_index":1,"segment_index":2,"seq":9,"sha256":"1d4e37220f4015dc87cfea35d1f1ae8e573b38f0c5ea81c3ff7141ef9f53754d"},"claude-code-session:demo-session-1:n:msg-6":{"kind":"message","line_index":2,"segment_index":2,"seq":10,"sha256":"e457095a327e2561712157bcd59692f439267db06d5a94f95fbabf757cf1f578"},"claude-code-session:demo-session-1:usage:claude-sonnet-5":{"kind":"usage","line_index":2,"segment_index":-1,"seq":2,"sha256":"b9dbdbd29c3c32ab7121f4de63c025c1608f2aab23e81210cf5bf742cadadabf"}},"completeness":"complete","content_digest":{"canonicalizer_version":1,"media_type":"application/x-ndjson; charset=utf-8","polylogue_sha256":"714d25c56b3a18ba8674703eea775f940e07d1fb87bed02331fafae57f25af06","provider_digest":null,"sinex_cas_digest":null,"size_bytes":7523},"expected_record_counts":{"attachment":1,"block":4,"lineage":1,"message":6,"session":1,"session_event":1,"usage":1},"fidelity_gaps":[{"detail":"attachment referenced by the provider export but bytes were never fetched","gap_kind":"unavailable_attachment_bytes","record_id":"claude-code-session:demo-session-1:n:msg-2:attachment:0","scope":"attachment"},{"detail":"provider omitted occurred_at; position ordinal is authoritative","gap_kind":"missing_timestamp","record_id":"claude-code-session:demo-session-1:n:msg-3","scope":"message"}],"head_segment":{"filename":"head.ndjson","first_seq":0,"index":-1,"last_seq":2,"record_count":3,"sha256":"2c96a8d9186a28b9bb7ad26620161dbd1af32d11c8f1f62b418469100ed3765e","size_bytes":1280},"native_id":"demo-session-1","origin":"claude-code-session","origin_vocabulary_digest":"f05126b022becf8fcebe9622919465b5e1f86163c25ecdda9d7e1259caba3512","origin_vocabulary_version":3,"protocol_version":"polylogue.material-protocol/v1","revision_created_at":"2026-07-12T00:00:00Z","revision_id":"714d25c56b3a18ba8674703eea775f940e07d1fb87bed02331fafae57f25af06","segments":[{"filename":"seg-00000.ndjson","first_seq":0,"index":0,"last_seq":3,"record_count":4,"sha256":"d3a9f9a0052c30e84004de594931f4e0575206a7065d8c6fb7b5e4a195d8619d","size_bytes":2138},{"filename":"seg-00001.ndjson","first_seq":4,"index":1,"last_seq":7,"record_count":4,"sha256":"4da302c60e9d516b2277519d6486f218b0a9e8891a4496dbb15ff992ef060722","size_bytes":2099},{"filename":"seg-00002.ndjson","first_seq":8,"index":2,"last_seq":11,"record_count":4,"sha256":"e83142d8e514189441a571fd3b418b6dad35026348a04d54c6c1d609b312f2f2","size_bytes":2006}],"semantics_version":2,"sequence_rule":"two seq spaces: head (session, sorted lineage, sorted usage; re-encoded every revision, never byte-reused) and transcript (strictly-increasing seq from 0; per-message(transcript order): message, blocks(position), attachments(position), owned session_events(position); trailing unowned session_events last; append-only, byte-reuse gated on canonical-byte prefix equality)","session_id":"claude-code-session:demo-session-1","superseded_revision_id":null}
Loading