-
Notifications
You must be signed in to change notification settings - Fork 2
fix(daemon): a fresh daemon converges its backlog on a live host #4683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ced15ec
daffa45
163a59b
57c6f4f
feb577a
5021b09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -198,6 +198,7 @@ | |
|
|
||
| if TYPE_CHECKING: | ||
| from polylogue.api import Polylogue | ||
| from polylogue.storage.raw_retention import RawFrontierBlockedPaths | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
@@ -585,6 +586,7 @@ def __init__( | |
| sync_runner: LiveBatchSyncRunner | None = None, | ||
| parse_stage: LiveParseStage | None = None, | ||
| ) -> None: | ||
| self._refused_paths: frozenset[Path] = frozenset() | ||
| self._polylogue = polylogue | ||
| self._sources = tuple(sources) | ||
| self._cursor = cursor | ||
|
|
@@ -700,8 +702,55 @@ def require_cursor_authority(self, paths: Iterable[Path] | None = None) -> Curso | |
| for path in paths | ||
| ): | ||
| return None | ||
| # The proof names the paths it refuses. Refusing only those keeps one | ||
| # anomalous file from stalling the other 45,000; a refusal that no | ||
| # path explains still blocks everything. | ||
| blocked = self._blocked_source_paths() | ||
| if blocked.unattributed_reason is None: | ||
| if paths is None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When catch-up or a debounce flush contains only a cursor-ahead path, both watcher routes first call Useful? React with 👍 / 👎. |
||
| logger.warning( | ||
| "live.watcher: cursor authority refuses %d source path(s); path-less route proceeds: %s", | ||
| len(blocked.source_paths), | ||
| reason, | ||
| ) | ||
| return None | ||
| selected = [Path(path) for path in paths] | ||
| refused = frozenset( | ||
| path | ||
| for path in selected | ||
| if str(path) in blocked.source_paths or str(path.resolve()) in blocked.source_paths | ||
| ) | ||
|
Comment on lines
+718
to
+722
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cursor-ahead violation was recorded through a symlinked watch root and the daemon is restarted with the same directory configured through its real path, the projection contains the stored symlink spelling while the selected candidate uses the real spelling. This comparison resolves only the selected path, not the strings in Useful? React with 👍 / 👎. |
||
| if len(refused) < len(selected): | ||
| self._refused_paths = refused | ||
| if refused: | ||
| logger.warning( | ||
| "live.watcher: cursor authority refused %d of %d path(s) in this batch: %s", | ||
| len(refused), | ||
| len(selected), | ||
| reason, | ||
| ) | ||
| return None | ||
| raise CursorAuthorityBlockedError(f"live watcher source-selection gate blocked: {reason}") | ||
|
|
||
| def admit_paths(self, paths: Iterable[Path]) -> list[Path]: | ||
| """The subset of ``paths`` the frontier proof admits, in order. | ||
|
|
||
| Raises when nothing may proceed: every path is refused, or the refusal | ||
| is one no path explains. | ||
| """ | ||
| selected = list(paths) | ||
| self.require_cursor_authority(selected) | ||
| refused = self._refused_paths | ||
| self._refused_paths = frozenset() | ||
| return [path for path in selected if path not in refused] | ||
|
|
||
| def _blocked_source_paths(self) -> RawFrontierBlockedPaths: | ||
| archive_root = Path(getattr(self._polylogue, "archive_root", self._cursor._db_path.parent)) | ||
| from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot | ||
| from polylogue.storage.raw_retention import raw_frontier_blocked_source_paths | ||
|
|
||
| return raw_frontier_blocked_source_paths(archive_root, raw_materialization_readiness_snapshot(archive_root)) | ||
|
|
||
| async def ingest_files( | ||
| self, | ||
| paths: list[Path], | ||
|
|
@@ -713,6 +762,11 @@ async def ingest_files( | |
| ) -> LiveBatchMetrics: | ||
| """Ingest files in batch, run post-ingest convergence, and return metrics.""" | ||
| authorization = self.require_cursor_authority(paths) | ||
| refused_paths = self._refused_paths | ||
| self._refused_paths = frozenset() | ||
| if refused_paths: | ||
| paths = [path for path in paths if path not in refused_paths] | ||
| skipped_file_count += len(refused_paths) | ||
|
Comment on lines
764
to
+769
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a direct Useful? React with 👍 / 👎. |
||
| if is_fully_degraded(): | ||
| # The daemon has been marked structurally unable to ingest (e.g. | ||
| # schema mismatch detected at preflight or on the first batch). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -266,6 +266,12 @@ class CatchUpPlan: | |
| needed_bytes: int | ||
|
|
||
|
|
||
| def _is_retryable_lock_error(exc: sqlite3.OperationalError) -> bool: | ||
| """SQLite lock contention, as opposed to a broken database.""" | ||
| message = str(exc).lower() | ||
| return "database is locked" in message or "database table is locked" in message or "busy" in message | ||
|
Comment on lines
+269
to
+272
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When SQLite reports transient AGENTS.md reference: AGENTS.md:L109-L115 Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| class LiveWatcher: | ||
| """Async watcher that ingests grown JSONL files in batches. | ||
|
|
||
|
|
@@ -598,11 +604,13 @@ async def prepare_catch_up() -> None: | |
| # planning pass. ``_plan_catch_up`` can reconcile missing cursors | ||
| # and rebase matching filesystem observations before ingestion has | ||
| # a chance to apply its own gate. | ||
| self._batch_processor.require_cursor_authority() | ||
| admitted = self._batch_processor.admit_paths([candidate.path for candidate in candidates]) | ||
| await self._run_writer_sync("watcher.catch_up.cursor_initialize", self._cursor.initialize) | ||
| self._batch_processor.require_cursor_authority() | ||
| logger.info("live.watcher: catch-up scan over %d file(s)", len(candidates)) | ||
| plan_holder.append(self._plan_catch_up(candidates)) | ||
| admitted = self._batch_processor.admit_paths(admitted) | ||
| admitted_set = set(admitted) | ||
| planned = tuple(candidate for candidate in candidates if candidate.path in admitted_set) | ||
| logger.info("live.watcher: catch-up scan over %d file(s)", len(planned)) | ||
| plan_holder.append(self._plan_catch_up(planned)) | ||
|
|
||
| try: | ||
| await self._run_coordinated("watcher.catch_up.prefilter", prepare_catch_up) | ||
|
|
@@ -688,7 +696,23 @@ async def ingest_chunk( | |
| ): | ||
| self._defer_unaccounted_failed_retries(chunk_paths) | ||
|
|
||
| await self._run_coordinated("watcher.catch_up.chunk", ingest_chunk) | ||
| try: | ||
| await self._run_coordinated("watcher.catch_up.chunk", ingest_chunk) | ||
| except sqlite3.OperationalError as exc: | ||
| if not _is_retryable_lock_error(exc): | ||
| raise | ||
| # A write that lost a lock race is this chunk's failure, | ||
| # never the daemon's death: the cursor retry policy brings | ||
| # the chunk back. Rehearsal 2026-09-05 died here while the | ||
| # Drive catch-up held source.db for 112 s. | ||
| failed += len(chunk_paths) | ||
| logger.warning( | ||
| "live.watcher: catch-up chunk %d/%d deferred, archive write lost a lock race: %s", | ||
| chunk_index, | ||
| len(chunks), | ||
| exc, | ||
| ) | ||
| self._defer_unaccounted_failed_retries(chunk_paths) | ||
| if self._stop.is_set(): | ||
| await self._emit_catch_up_terminal( | ||
| operation_id, "stopped", plan, attempted, ingested, failed, stage_timings_s, cycle_started | ||
|
|
@@ -1008,12 +1032,13 @@ async def _flush_pending(self) -> bool: | |
| self._forced_reparse_paths.difference_update(paths) | ||
|
|
||
| async def flush_batch() -> None: | ||
| nonlocal paths | ||
| # Filtering a changed-file batch invokes cursor reconciliation and | ||
| # lifecycle actuators, so the source-selection proof must be | ||
| # consumed before initialization or any stateful decision. | ||
| self._batch_processor.require_cursor_authority() | ||
| paths = self._batch_processor.admit_paths(paths) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Claude tool-result event enqueues an unchanged owner transcript that is currently a path-attributed refusal together with an admitted sidecar, AGENTS.md reference: AGENTS.md:L113-L115 Useful? React with 👍 / 👎. |
||
| await self._run_writer_sync("watcher.live_batch.cursor_initialize", self._cursor.initialize) | ||
| self._batch_processor.require_cursor_authority() | ||
| paths = self._batch_processor.admit_paths(paths) | ||
| # Filter to files that actually need work. | ||
| cursor_records = self._cursor.get_records(paths) | ||
| needed = [] | ||
|
|
@@ -1032,11 +1057,18 @@ async def flush_batch() -> None: | |
| return | ||
|
|
||
| logger.info("live.watcher: batching %d changed file(s)", len(needed)) | ||
| metrics = await self._ingest_files( | ||
| needed, | ||
| queued_file_count=len(paths), | ||
| skipped_file_count=len(paths) - len(needed), | ||
| ) | ||
| try: | ||
| metrics = await self._ingest_files( | ||
| needed, | ||
| queued_file_count=len(paths), | ||
| skipped_file_count=len(paths) - len(needed), | ||
| ) | ||
| except sqlite3.OperationalError as exc: | ||
| if not _is_retryable_lock_error(exc): | ||
| raise | ||
| logger.warning("live.watcher: changed-file batch deferred, archive write lost a lock race: %s", exc) | ||
| self._defer_unaccounted_failed_retries(needed) | ||
| return | ||
|
Comment on lines
+1070
to
+1071
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L109-L115 Useful? React with 👍 / 👎. |
||
| if metrics is not None: | ||
| _log_ingest_metrics("live.watcher: changed-file batch", metrics) | ||
| if ( | ||
|
|
@@ -1671,7 +1703,7 @@ async def _ingest_files( | |
| skipped_file_count: int = 0, | ||
| ) -> LiveBatchMetrics: | ||
| """Ingest files through the reusable daemon live batch processor.""" | ||
| self._batch_processor.require_cursor_authority() | ||
| self._batch_processor.require_cursor_authority(paths) | ||
| async with self._ingest_lock: | ||
|
|
||
| async def ingest() -> LiveBatchMetrics: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this serialized field changes both
raw_frontier_integrityand the component-readinesscountsobject emitted bypolylogue ops status --format json, buttests/unit/cli/__snapshots__/test_plain_cli_snapshots.ambrstill pins the old shapes withoutcursor_authority_deferred_count. Consequentlytest_json_status_snapshotfails on every healthy seeded archive, so the required affected-pytest verification cannot pass until the reviewed new field is added to the snapshot baseline.AGENTS.md reference: AGENTS.md:L172-L173
Useful? React with 👍 / 👎.