From 31eb8686e710943ed9da2adee2d1ff2610c5df5e Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 06:09:47 +0200 Subject: [PATCH 1/8] test(devtools): declare a tier version on the smoke probe's index fixture The browser-capture probe opens the index through the tier-guarded read profile, which refuses an unstamped database. The hand-built fixtures declare the version that profile expects, read from the tier table. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- tests/unit/devtools/test_deployment_smoke.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/devtools/test_deployment_smoke.py b/tests/unit/devtools/test_deployment_smoke.py index c63e7a094..8545ea1f3 100644 --- a/tests/unit/devtools/test_deployment_smoke.py +++ b/tests/unit/devtools/test_deployment_smoke.py @@ -11,6 +11,8 @@ import pytest from devtools import deployment_smoke +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier def _create_browser_source_db( @@ -116,6 +118,9 @@ def _create_browser_index_db( message_count, ), ) + # The probe opens the index through the tier-guarded read profile, so + # a hand-built index must declare the version that profile expects. + conn.execute(f"PRAGMA user_version = {ARCHIVE_VERSION_BY_TIER[ArchiveTier.INDEX]}") class _FakeResponse: @@ -561,6 +566,9 @@ def test_deployment_smoke_reports_latest_browser_capture_missing_index_row(tmp_p ) """ ) + # The probe opens the index through the tier-guarded read profile, so + # a hand-built index must declare the version that profile expects. + conn.execute(f"PRAGMA user_version = {ARCHIVE_VERSION_BY_TIER[ArchiveTier.INDEX]}") probe = deployment_smoke._probe_browser_capture_archive(archive_root=tmp_path) From 42bcda98ae54e01498fd0aaef5b3fbbdf4cd47fd Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 06:18:37 +0200 Subject: [PATCH 2/8] test(cli): bootstrap the embed backfill fixture's index tier The backfill route opens the index through the tier-guarded read profile, which refuses an empty file. The fixture builds the tier through the production bootstrap instead of touching a bare SQLite database. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- tests/unit/cli/test_embed_activation.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/unit/cli/test_embed_activation.py b/tests/unit/cli/test_embed_activation.py index bd2133361..657727594 100644 --- a/tests/unit/cli/test_embed_activation.py +++ b/tests/unit/cli/test_embed_activation.py @@ -36,6 +36,8 @@ message_window_for_cost, read_pending_message_count, ) +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier # --------------------------------------------------------------------------- # Splicer @@ -413,7 +415,7 @@ def test_backfill_runs_against_stub_provider( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [ PendingSession(session_id="conv-1", title="A", message_count=2), PendingSession(session_id="conv-2", title="B", message_count=2), @@ -457,7 +459,7 @@ def test_backfill_passes_bounded_window_options( fake_select = MagicMock(return_value=[]) fake_preflight = MagicMock(return_value=report) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) with ( patch("polylogue.cli.commands.embed._build_preflight_report", fake_preflight), patch( @@ -497,7 +499,7 @@ def test_backfill_routes_archive_to_materializer( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [PendingSession(session_id="codex-session:v1", title="v1", message_count=2)] fake_provider = MagicMock() fake_embed = MagicMock( @@ -545,7 +547,7 @@ def test_backfill_json_outputs_structured_result( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [PendingSession(session_id="codex-session:v1", title="v1", message_count=2)] with ( _patch_preflight(_make_report(pending_sessions=1, pending_messages=2, max_messages=2)), @@ -631,7 +633,7 @@ def test_backfill_stop_after_seconds_stops_before_next_session( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) # Event-driven fake clock: scripted read sequences broke twice (a # StopIteration when the command read the clock an extra time, then a # second embed when an extra read consumed the past-deadline value). @@ -692,7 +694,7 @@ def test_backfill_max_errors_stops_after_provider_error( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) fake_embed = MagicMock( side_effect=[ EmbedSessionOutcome(status="error", session_id="conv-1", error="provider 429"), @@ -740,7 +742,7 @@ def test_backfill_run_cost_cap_stops_before_provider_call( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) fake_embed = MagicMock( side_effect=[ EmbedSessionOutcome(status="embedded", session_id="conv-1", embedded_message_count=2), From f6c3be65d9c988cbda2aee609afa6723c735e92b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 07:02:02 +0200 Subject: [PATCH 3/8] fix(storage): derive attachment provenance on the relink route attachment_refs.direction is NOT NULL, and only the main write route derived it from the owning turn. Blob-reference closure relinked attachments straight from the parsed record, so any record parsed before attachments carried direction failed the constraint. The relink path uses the same shared derivation, reading the owning turn from the index when the append payload's own message map does not carry it. The session's exact provider dollar total lives on one model row. A merge-append that switches models left the superseded row holding it, so every sum over the session counted it twice. The direction-refusal test named a user turn, whose direction the derivation supplies; only a role outside the user/model pair leaves the column underivable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- polylogue/storage/attachment_relink.py | 34 +++++++++++++++++-- .../storage/sqlite/archive_tiers/write.py | 13 ++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/polylogue/storage/attachment_relink.py b/polylogue/storage/attachment_relink.py index 45ba3ca4a..6d0791e7d 100644 --- a/polylogue/storage/attachment_relink.py +++ b/polylogue/storage/attachment_relink.py @@ -43,12 +43,14 @@ from polylogue.pipeline.ids import attachment_message_owner_key, message_owner_resolution from polylogue.pipeline.services.ingest_worker import IngestRecordResult, SessionWritePayload, ingest_record from polylogue.sources.parsers.base import ParsedAttachment, ParsedMessage +from polylogue.sources.parsers.base_support import derive_attachment_provenance from polylogue.storage.runtime.raw.records import RawSessionRecord from polylogue.storage.sqlite.archive_tiers.write import ( _attachment_caption, _attachment_id, _attachment_message_id_maps, _attachment_native_id_values, + _attachment_provenance, _attachment_reference_positions, _attachment_source_url, _duplicate_message_native_ids, @@ -149,6 +151,23 @@ def _message_exists(index_conn: sqlite3.Connection, message_id: str) -> bool: return index_conn.execute("SELECT 1 FROM messages WHERE message_id = ?", (message_id,)).fetchone() is not None +def _persisted_message_provenance( + index_conn: sqlite3.Connection, + message_id: str, +) -> tuple[str | None, str | None]: + """Derive an attachment's provenance from the persisted owning turn. + + An append payload's owning message can already be materialized, so it is + absent from the payload's own message map. Its role is in the index. + """ + + row = index_conn.execute("SELECT role, native_id FROM messages WHERE message_id = ?", (message_id,)).fetchone() + if row is None: + return None, None + direction, producer_ref = derive_attachment_provenance(str(row[0]), row[1] or message_id) + return direction, producer_ref + + def _append_materialized_message_ids( index_conn: sqlite3.Connection, session_id: str, @@ -341,7 +360,7 @@ def _match_session_payload( session_id = payload.session_id messages = payload.parsed_session.messages position_offset = _next_message_position(index_conn, session_id) if payload.append_only else 0 - owner_resolution, by_owner_key, _owning_messages = _attachment_message_id_maps( + owner_resolution, by_owner_key, owning_messages = _attachment_message_id_maps( session_id, messages, position_offset=position_offset, @@ -394,14 +413,23 @@ def _match_session_payload( message_id = resolved_message_ids.get(id(attachment)) if message_id is None: continue + # Provenance goes through the same derivation the writer uses, so a + # record parsed before attachments carried direction relinks with the + # owning turn's direction rather than a null the column refuses. + owning_message = owning_messages.get(message_id) + if owning_message is None and attachment.direction is None: + direction, producer_ref = _persisted_message_provenance(index_conn, message_id) + producer_ref = attachment.producer_ref or producer_ref + else: + direction, producer_ref = _attachment_provenance(attachment, owning_message, resolved_message_id=message_id) recovered[attachment_id] = RelinkableAttachment( attachment_id=attachment_id, session_id=session_id, message_id=message_id, position=attachment_positions[id(attachment)], upload_origin=attachment.upload_origin, - direction=attachment.direction, - producer_ref=attachment.producer_ref, + direction=direction, + producer_ref=producer_ref, source_url=_attachment_source_url(attachment), caption=_attachment_caption(attachment), raw_id=raw_id, diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index 8ceae30be..ae0bbc5ff 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -114,7 +114,13 @@ class ProviderCost: def _write_provider_cost(conn: sqlite3.Connection, session_id: str, model_name: str, cost: ProviderCost) -> None: - """Pass through a provider dollar value without catalog computation.""" + """Pass through a provider dollar value without catalog computation. + + The value is one exact dollar total for the whole session, so it lives on + exactly one model row. A merge-append that switches models carries the + total to the new row; leaving it on the superseded one would double-count + it in every sum over the session. + """ if not isinstance(cost, ProviderCost): raise TypeError("provider cost writes require ProviderCost") conn.execute( @@ -122,6 +128,11 @@ def _write_provider_cost(conn: sqlite3.Connection, session_id: str, model_name: WHERE session_id = ? AND model_name = ?""", (cost.value, session_id, model_name), ) + conn.execute( + """UPDATE session_model_usage SET provider_cost_usd = NULL + WHERE session_id = ? AND model_name <> ?""", + (session_id, model_name), + ) logger = get_logger(__name__) From 352a52ed0b61a143c738ce559d723cfa36c1fd91 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 07:06:59 +0200 Subject: [PATCH 4/8] test(storage): keep the pragma probes off archive tier filenames The connection factories assert the declared schema version of a database named for an archive tier. These tests probe the pragmas the factories apply, so their scratch databases carry names that claim no tier. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- tests/unit/storage/test_wal_journal_size_limit.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/storage/test_wal_journal_size_limit.py b/tests/unit/storage/test_wal_journal_size_limit.py index a10d21e97..ca5d96552 100644 --- a/tests/unit/storage/test_wal_journal_size_limit.py +++ b/tests/unit/storage/test_wal_journal_size_limit.py @@ -77,7 +77,7 @@ def test_daemon_write_profile_uses_bounded_cache_and_mmap() -> None: def test_open_connection_applies_journal_size_limit_on_disk(tmp_path: Path) -> None: """The factory ``open_connection`` propagates the cap to SQLite.""" - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" conn = open_connection(db_path) try: limit = _pragma_int(conn, "journal_size_limit") @@ -90,7 +90,7 @@ def test_open_connection_applies_journal_size_limit_on_disk(tmp_path: Path) -> N def test_open_readonly_connection_applies_query_only(tmp_path: Path) -> None: """The read factory turns on query_only at the SQL parser level.""" - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" # Bootstrap a tiny schema so the read connection has something to attach to. seed = sqlite3.connect(str(db_path)) try: @@ -110,7 +110,7 @@ def test_open_readonly_connection_applies_query_only(tmp_path: Path) -> None: def test_open_daemon_connection_applies_bounded_cache_and_mmap(tmp_path: Path) -> None: - db_path = tmp_path / "ops.db" + db_path = tmp_path / "daemon-pragmas.db" conn = open_daemon_connection(db_path) try: assert _pragma_int(conn, "cache_size") == -DAEMON_WRITE_CACHE_SIZE_KIB @@ -133,7 +133,7 @@ def test_wal_file_truncates_back_to_size_limit_after_checkpoint(tmp_path: Path) TRUNCATE checkpoint at the end is the only mechanism that can shrink the WAL, and that's exactly what the limit enforces. """ - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" writer = open_connection(db_path) try: writer.execute("CREATE TABLE blob (id INTEGER PRIMARY KEY, payload BLOB)") @@ -191,7 +191,7 @@ def test_wal_stays_bounded_under_concurrent_reader_and_writer(tmp_path: Path) -> """ import threading - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" # Seed the schema on a separate connection so the worker threads # don't race the table creation. From 7a821f4d1a205a31c5719341f94f9eca5d4af8d3 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 08:14:18 +0200 Subject: [PATCH 5/8] test: give hand-built blocks tables the tool_outcome column The insight-status and readiness queries read blocks.tool_outcome, which #4539 made the canonical structural outcome. Their hand-built index fixtures predate it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- tests/unit/core/test_insight_readiness.py | 1 + .../unit/storage/test_session_insight_status_descriptors.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/tests/unit/core/test_insight_readiness.py b/tests/unit/core/test_insight_readiness.py index de0a11cac..cd2f44561 100644 --- a/tests/unit/core/test_insight_readiness.py +++ b/tests/unit/core/test_insight_readiness.py @@ -206,6 +206,7 @@ async def test_insight_readiness_report_marks_missing_insight_tables(tmp_path: P tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); INSERT INTO sessions (session_id, parent_session_id, source_name, sort_key, updated_at) diff --git a/tests/unit/storage/test_session_insight_status_descriptors.py b/tests/unit/storage/test_session_insight_status_descriptors.py index 1d1721bc0..8a3eaad0e 100644 --- a/tests/unit/storage/test_session_insight_status_descriptors.py +++ b/tests/unit/storage/test_session_insight_status_descriptors.py @@ -246,6 +246,7 @@ def test_profile_repair_candidates_ignore_hot_recent_sources() -> None: tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -297,6 +298,7 @@ def test_session_insight_status_requires_latency_rows_for_ready_profiles() -> No tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -412,6 +414,7 @@ def test_status_treats_run_projection_materialization_as_optional_cache() -> Non tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_runs (session_id TEXT NOT NULL); @@ -525,6 +528,7 @@ def test_status_tracks_work_and_phase_staleness_from_materialization_ledger() -> tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -619,6 +623,7 @@ async def test_status_sync_and_async_match_when_product_tables_are_absent(tmp_pa tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); INSERT INTO sessions (session_id, parent_session_id, sort_key_ms, updated_at_ms) @@ -672,6 +677,7 @@ async def test_lightweight_status_sync_and_async_match_with_freshness_tables(tmp tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( From 082d82165855cbed2249c86db4b2e562bc5e547b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 08:24:46 +0200 Subject: [PATCH 6/8] test: read the interpolated shell on the routes that still serve it The root and /s/ routes serve the typed WebUI. The interpolated reader shell these tests pin answers on the workspace routes, so they read it there. This restores the search-shell DOM evidence #4579 deleted: the shell it tests is still served, just not at the root. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- tests/unit/daemon/test_web_shell_reader.py | 16 +++- .../visual/test_evidence_cockpit_ia_smoke.py | 4 +- tests/visual/test_reader_attachments.py | 8 +- tests/visual/test_reader_dom_smoke.py | 83 +++++++++++++++++++ tests/visual/test_reader_paste_spans.py | 4 +- tests/visual/test_reader_semantic_cards.py | 4 +- .../test_route_state_interaction_smoke.py | 4 +- 7 files changed, 113 insertions(+), 10 deletions(-) diff --git a/tests/unit/daemon/test_web_shell_reader.py b/tests/unit/daemon/test_web_shell_reader.py index a3be0ec70..8b8944744 100644 --- a/tests/unit/daemon/test_web_shell_reader.py +++ b/tests/unit/daemon/test_web_shell_reader.py @@ -167,7 +167,9 @@ def test_js_declares_copy_message_anchor(self) -> None: def test_assembled_html_contains_message_anchor_markup(self, workspace_env: dict[str, Path]) -> None: """The full assembled shell HTML must contain msg-anchor-link.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "msg-anchor-link" in body assert "msg-anchor-target" in body @@ -222,7 +224,9 @@ def test_text_fold_renders_fold_bar_with_metadata(self) -> None: def test_assembled_html_contains_text_fold_markup(self, workspace_env: dict[str, Path]) -> None: """The assembled shell HTML must contain text-fold CSS classes.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "msg-text-fold" in body @@ -268,7 +272,9 @@ def test_density_defaults_to_comfortable(self) -> None: def test_assembled_html_contains_density_toggle_markup(self, workspace_env: dict[str, Path]) -> None: """The assembled shell HTML must contain the density-toggle class.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "density-toggle" in body @@ -304,7 +310,9 @@ def test_double_g_timer_resets_on_single_wait(self) -> None: def test_assembled_footer_lists_new_shortcuts(self, workspace_env: dict[str, Path]) -> None: """The footer hint strip must include g g and G.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "g g" in body assert "top" in body assert "bottom" in body diff --git a/tests/visual/test_evidence_cockpit_ia_smoke.py b/tests/visual/test_evidence_cockpit_ia_smoke.py index 96e4e5b52..ec1d57c49 100644 --- a/tests/visual/test_evidence_cockpit_ia_smoke.py +++ b/tests/visual/test_evidence_cockpit_ia_smoke.py @@ -53,7 +53,9 @@ def test_verb_nav_and_ia_contract_in_served_shell(reader_workspace: ReaderWorkspace) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type diff --git a/tests/visual/test_reader_attachments.py b/tests/visual/test_reader_attachments.py index 09aebefc5..adf96201d 100644 --- a/tests/visual/test_reader_attachments.py +++ b/tests/visual/test_reader_attachments.py @@ -92,7 +92,9 @@ class _Stub: def test_reader_attachment_surface_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): seed_reader_attachments(reader_workspace) - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") conv_payload = get_json(base_url, f"/api/sessions/{READER_C1}") per_conv = get_json(base_url, f"/api/sessions/{READER_C1}/attachments") library = get_json(base_url, "/api/attachments?limit=100") @@ -268,7 +270,9 @@ def test_raw_html_attachment_renders_no_inline_script( with running_reader_server(reader_workspace) as (_, base_url): seed_reader_attachments(reader_workspace) - _status, _ctype, shell_body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + _status, _ctype, shell_body = get_text(base_url, "/w/stack") _ls, _lc, library_body = get_text(base_url, "/a") payload = get_json(base_url, "/api/attachments?limit=200") diff --git a/tests/visual/test_reader_dom_smoke.py b/tests/visual/test_reader_dom_smoke.py index 3b0dfc1de..13907e3f5 100644 --- a/tests/visual/test_reader_dom_smoke.py +++ b/tests/visual/test_reader_dom_smoke.py @@ -16,6 +16,7 @@ assert_no_private_paths, get_json, get_text, + parse_dom, running_reader_server, seed_reader_assertion_claims, write_evidence_manifest, @@ -30,6 +31,88 @@ def _send_json(base_url: str, method: str, path: str, payload: dict[str, object] return resp.status, json.loads(resp.read()) +def test_reader_search_shell_dom_evidence(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: + with running_reader_server(reader_workspace) as (_, base_url): + # The interpolated shell answers on the workspace routes; the root + # serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") + + assert status == 200 + assert "text/html" in content_type + assert len(body) > 20_000 + assert "https://cdn" not in body + assert_no_private_paths(body, context="reader shell HTML") + + dom = parse_dom(body) + expected_ids = { + "app", + "status-strip", + "status-dot", + "status-browser-capture", + "sidebar", + "search", + "facet-bar", + "conv-list", + "main", + "conv-header", + "msg-list", + "inspector", + "inspector-tabs", + "workspace-toolbar", + "workspace-mode-switcher", + "workspace-save-btn", + "workspace-restore-select", + "workspace-create-recall-pack-btn", + "footer", + "help-overlay", + } + assert expected_ids <= dom.ids + assert dom.meta_viewport is True + assert dom.scripts == 1 + assert dom.styles == 1 + for phrase in ( + "Select a session", + "Keyboard Shortcuts", + "Focus search", + "Local", + "/api/user/marks", + "/api/user/annotations", + "toggleMark", + "saveAnnotation", + "No annotations on this session", + "Save current view", + "Saved Views", + "Save workspace", + "Restore workspace", + "Recall pack", + "/api/user/workspaces", + "/api/user/recall-packs", + "/api/stack", + "/api/compare", + ): + assert phrase in body + + checks = { + "status": status, + "content_type": content_type, + "html_bytes": len(body.encode()), + "required_ids": sorted(expected_ids), + "script_tags": dom.scripts, + "style_tags": dom.styles, + "viewport_meta": dom.meta_viewport, + "private_path_safe": True, + "runtime_cdn_free": True, + } + manifest = write_evidence_manifest( + tmp_path / "reader-search-dom-evidence.json", + artifact_id="polylogue.local_reader.search", + route="/w/stack", + fixture_id="reader-visual-synthetic-v1", + checks=checks, + ) + assert manifest["evidence_kind"] == "browserless-dom" + + def test_reader_stack_workspace_dom_evidence(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): status, content_type, shell = get_text( diff --git a/tests/visual/test_reader_paste_spans.py b/tests/visual/test_reader_paste_spans.py index aa4e8d9e1..7060a2d08 100644 --- a/tests/visual/test_reader_paste_spans.py +++ b/tests/visual/test_reader_paste_spans.py @@ -39,7 +39,9 @@ def test_reader_paste_spans_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): seed_reader_diff_paste(reader_workspace) - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") # Session envelope must carry ``paste_spans``. conv_payload = get_json(base_url, f"/api/sessions/{READER_C3}") assert isinstance(conv_payload, dict) diff --git a/tests/visual/test_reader_semantic_cards.py b/tests/visual/test_reader_semantic_cards.py index 2acc727b5..e8782f85b 100644 --- a/tests/visual/test_reader_semantic_cards.py +++ b/tests/visual/test_reader_semantic_cards.py @@ -156,7 +156,9 @@ def test_semantic_card_web_json_contract(_semantic_card_session: tuple[str, dict def test_semantic_card_web_dom_shape_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type diff --git a/tests/visual/test_route_state_interaction_smoke.py b/tests/visual/test_route_state_interaction_smoke.py index e99f7fc57..986b22832 100644 --- a/tests/visual/test_route_state_interaction_smoke.py +++ b/tests/visual/test_route_state_interaction_smoke.py @@ -72,7 +72,9 @@ def test_truthful_route_state_contract_in_served_shell( reader_workspace: ReaderWorkspace, ) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell whose route-state behaviour this test pins + # answers on the workspace routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type From f42dfe430aece59ffeead7d2cf2ec8732c34c062 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 08:24:58 +0200 Subject: [PATCH 7/8] test(schemas): build the wire receipt for the providers each test reads build_wire_support_receipt runs the synthetic corpus and the production parser over every catalogued package element: 27s per full-catalog call in a quiet process, and 106-110s of call time under -n 2, inside a 120s timeout with no margin. #4412 added two providers and the margin went. Tests that assert about one provider's entry, or that a receipt changed, name the providers they read. Catalog-wide construction stays covered by test_support_receipt_is_deterministic, test_support_receipt_does_not_substitute_a_default_selection, and test_wire_support_receipt_is_canonical_across_catalog_reordering. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- .../unit/core/test_synthetic_wire_support.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/unit/core/test_synthetic_wire_support.py b/tests/unit/core/test_synthetic_wire_support.py index 90565f1ad..6d734d731 100644 --- a/tests/unit/core/test_synthetic_wire_support.py +++ b/tests/unit/core/test_synthetic_wire_support.py @@ -167,7 +167,7 @@ def drop_first_coverage_witness( ) monkeypatch.setattr(dispatch_module, "parse_payload", drop_first_coverage_witness) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") dropped = next(witness for witness in entry.parser_witnesses if witness.index == 0) @@ -751,7 +751,7 @@ def fail_baseline( ) monkeypatch.setattr(dispatch_module, "parse_payload", fail_baseline) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") baseline = next(item for item in entry.parser_witnesses if item.artifact_kind == "baseline") @@ -775,7 +775,7 @@ def fail_chatgpt_baseline(self: SchemaValidator, payload: JSONValue) -> validato return original_validate(self, payload) monkeypatch.setattr(validator_module.SchemaValidator, "validate", fail_chatgpt_baseline) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") baseline = next(item for item in entry.parser_witnesses if item.artifact_kind == "baseline") @@ -892,11 +892,19 @@ def test_antigravity_metadata_only_source_has_no_language_server_session(tmp_pat assert sessions == [] +#: Building the receipt runs the full synthetic corpus and parser for every +#: catalogued package element, so a test that asserts a receipt *changed* +#: names the providers whose entries carry the change rather than paying for +#: the whole catalog. Catalog-wide construction stays covered by +#: ``test_support_receipt_is_deterministic``. +_COVERAGE_PROVIDERS = ("chatgpt", "codex") + + def test_construct_handler_removal_changes_coverage_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) monkeypatch.delitem(SCHEMA_CONSTRUCT_HANDLERS, "array") - after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) assert before.to_dict() != after.to_dict() assert not after.complete @@ -1032,7 +1040,7 @@ def __init__( super().__init__(schema, strict=strict, provider=provider) monkeypatch.setattr(validator_module, "SchemaValidator", CapturingValidator) - receipt = wire_formats.build_wire_support_receipt(registry=registry) + receipt = wire_formats.build_wire_support_receipt(registry=registry, providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") assert entry.schema_valid is True @@ -1045,7 +1053,7 @@ def __init__( def test_claude_code_route_only_waives_unrepresentable_nested_content() -> None: - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("claude-code",)) entry = next(item for item in receipt.entries if item.provider == "claude-code") assert entry.construct_coverage is not None @@ -1147,10 +1155,10 @@ def test_missing_required_and_additional_property_evidence_makes_receipt_incompl def test_removed_provider_route_changes_explicit_support_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) monkeypatch.delitem(wire_formats.PROVIDER_WIRE_ROUTES, "codex") - after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) assert before.to_dict() != after.to_dict() assert after.missing_routes == ("codex",) From 231039032b6a406a936b4e1732c3a0885fc8ebc4 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 3 Sep 2026 08:32:46 +0200 Subject: [PATCH 8/8] fix(devtools): reap the pytest slot task when its waiter is killed The waiter blocks on a task it queued, and the task outlives it. The slot's parallelism is one, so a task left with no waiter starves every other checkout on the host until someone reads the queue and kills it by hand. The waiter now reaps the task it added when it is signalled or unwound: kill, then remove, then drop the launch file, which carries a resolved environment and must not survive either. The reap is best effort by construction -- it runs while the process is dying and must not replace the cause of death with its own error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YGi8wLWGR2HYBh8p8fXFXz --- devtools/pytest_slot.py | 59 ++++++++++++++++++-- tests/unit/devtools/test_pytest_slot.py | 74 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/devtools/pytest_slot.py b/devtools/pytest_slot.py index 2c2e23cee..64b21da5f 100644 --- a/devtools/pytest_slot.py +++ b/devtools/pytest_slot.py @@ -25,10 +25,11 @@ import json import os import shutil +import signal import subprocess import sys import time -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import IO, Any, Final @@ -38,6 +39,7 @@ __all__ = [ "BASETEMP_ROOT_ENV", "INHERITED_ENVIRONMENT_KEYS", + "REAPED_SIGNALS", "PYTEST_GROUP", "PytestSlotUnavailableError", "SlotOutcome", @@ -59,6 +61,11 @@ #: The host group whose parallelism is one. PYTEST_GROUP: Final = "pytest" +#: Signals that end this process while it waits on a task it owns. The task +#: outlives the waiter, and the slot's parallelism is one, so an unreaped task +#: starves every other checkout on the host until someone notices. +REAPED_SIGNALS: Final[tuple[signal.Signals, ...]] = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) + #: The only keys the ``pueue add`` client inherits. Everything pytest needs #: travels in the launch file, because pueue persists the adder's environment #: into shared state. @@ -212,6 +219,49 @@ def _task_result(status_json: str, task_id: str) -> int: raise PytestSlotUnavailableError(REFUSAL.format(reason=f"pueue task {task_id} ended as {result!r}")) +def _reap_task(task_id: str, *, env: Mapping[str, str], launch_path: Path | None = None) -> None: + """End a task this process owns and drop it from the queue. + + Best effort by construction: the reason we are here is that the waiter is + being killed, so a failing reap must not replace the original cause of + death with its own error. ``kill`` first because ``remove`` refuses a + running task; ``remove`` after because a killed task still occupies the + listing. The launch file carries a resolved environment and must not + outlive the run either way. + """ + + for verb in ("kill", "remove"): + with contextlib.suppress(PytestSlotUnavailableError): + _pueue([verb, task_id], env=env) + if launch_path is not None: + with contextlib.suppress(OSError): + launch_path.unlink(missing_ok=True) + + +@contextlib.contextmanager +def _reaping(task_id: str, *, env: Mapping[str, str], launch_path: Path | None = None) -> Iterator[None]: + """Reap ``task_id`` if this process is signalled or unwound while waiting.""" + + def handle(signal_number: int, frame: object) -> None: + _reap_task(task_id, env=env, launch_path=launch_path) + signal.signal(signal_number, previous.get(signal.Signals(signal_number), signal.SIG_DFL)) + os.kill(os.getpid(), signal_number) + + previous: dict[signal.Signals, Any] = {} + for number in REAPED_SIGNALS: + with contextlib.suppress(ValueError, OSError): + previous[number] = signal.signal(number, handle) + try: + yield + except BaseException: + _reap_task(task_id, env=env, launch_path=launch_path) + raise + finally: + for number, handler in previous.items(): + with contextlib.suppress(ValueError, OSError): + signal.signal(number, handler) + + def _write_launch(path: Path, *, argv: Sequence[str], cwd: str, env: Mapping[str, str], log_path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) document = { @@ -269,9 +319,10 @@ def _queue( raise PytestSlotUnavailableError(REFUSAL.format(reason=f"`pueue add` printed no task id: {added.stdout!r}")) sys.stderr.write(f" waiting for the host pytest slot (pueue task {task_id}, group {PYTEST_GROUP}) ...\n") sys.stderr.flush() - _pueue(["wait", task_id], env=adder) - status = _pueue(["status", "--json"], env=adder) - returncode = _task_result(status.stdout, task_id) + with _reaping(task_id, env=adder, launch_path=launch_path): + _pueue(["wait", task_id], env=adder) + status = _pueue(["status", "--json"], env=adder) + returncode = _task_result(status.stdout, task_id) sys.stderr.write(f" pytest slot released; output: {log_path}\n") sys.stderr.flush() return SlotOutcome(returncode=returncode, slot=f"pueue task {task_id}", log_path=log_path) diff --git a/tests/unit/devtools/test_pytest_slot.py b/tests/unit/devtools/test_pytest_slot.py index 6cdaa534b..bfe94f613 100644 --- a/tests/unit/devtools/test_pytest_slot.py +++ b/tests/unit/devtools/test_pytest_slot.py @@ -13,6 +13,7 @@ import json import os +import signal import stat import sys from pathlib import Path @@ -294,3 +295,76 @@ def test_the_leaked_cloud_basetemp_sentinel_is_declined(tmp_path: Path, monkeypa sentinel = cloud_sentinels.CLOUD_SENTINELS[BASETEMP_ROOT_ENV] assert basetemp_root({BASETEMP_ROOT_ENV: sentinel}, root=tmp_path) == tmp_path / ".cache" / "verify" + + +#: A ``pueue`` whose ``wait`` kills the process waiting on it, the way a +#: session or a wrapper being killed leaves a queued task with no waiter. +FAKE_PUEUE_KILLS_ITS_WAITER = """#!/usr/bin/env python3 +import json, os, signal, sys, time + +with open(sys.argv[0] + ".calls.jsonl", "a", encoding="utf-8") as handle: + handle.write(json.dumps({"argv": sys.argv[1:]}) + "\\n") + +command = sys.argv[1] if len(sys.argv) > 1 else "" +if command == "add": + print("11") +elif command == "wait": + os.kill(os.getppid(), signal.SIGTERM) + time.sleep(2) +sys.exit(0) +""" + +_WAITER = """ +import os, sys +sys.path.insert(0, {repo!r}) +from devtools.pytest_slot import run_pytest + +run_pytest( + [sys.executable, "-c", "pass"], + cwd={cwd!r}, + env={{"PATH": os.environ["PATH"], "HOME": os.environ["HOME"]}}, + root={root!r}, + label="polylogue:test:signalled", +) +""" + + +def test_a_killed_waiter_reaps_the_task_it_queued(tmp_path: Path) -> None: + """A task outlives its waiter, and the slot's parallelism is one. + + Anti-vacuity: dropping the ``_reaping`` context leaves the recorded calls + at ``add``/``wait`` -- the task stays queued with nothing left to wait on + it, which is exactly the starvation this reap exists to prevent. + """ + import subprocess + + directory = tmp_path / "fakebin" + directory.mkdir() + script = directory / "pueue" + script.write_text(FAKE_PUEUE_KILLS_ITS_WAITER, encoding="utf-8") + script.chmod(script.stat().st_mode | stat.S_IXUSR) + record = Path(str(script) + ".calls.jsonl") + repo = str(Path(pytest_slot.__file__).resolve().parents[1]) + + completed = subprocess.run( + [ + sys.executable, + "-c", + _WAITER.format(repo=repo, cwd=str(tmp_path), root=str(tmp_path)), + ], + env={ + "PATH": f"{directory}{os.pathsep}{os.environ['PATH']}", + "HOME": os.environ.get("HOME", "/home/nobody"), + }, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == -int(signal.SIGTERM), completed.stderr + verbs = [call["argv"][0] for call in _calls(record)] + assert verbs == ["add", "wait", "kill", "remove"], verbs + leftover = list((tmp_path / "verify").glob("pytest-slot-*.json")) + list( + (tmp_path / ".cache" / "verify").glob("pytest-slot-*.json") + ) + assert leftover == [], "the launch file carries a resolved environment and must not survive the reap"