-
Notifications
You must be signed in to change notification settings - Fork 1
fix(daemon): persist judgment scheduler receipts (#3896) #3896
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
47cf896
121a2d3
972a901
51fe7bf
1e232b5
2815bbb
e572dd4
d1c28c7
549b3be
f71ebdf
2dc51d7
af9768e
a1d93e9
9b6c7b7
1acc4a8
9611816
6891626
7eaa33c
6b083b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1508,6 +1508,10 @@ def _archive_assertion_candidate_queue_health( | |
| ) -> AssertionCandidateQueueHealthPayload: | ||
| """Project queue depth, retention, producer telemetry, and scheduler health.""" | ||
|
|
||
| from polylogue.daemon.judgment_automation import ( | ||
| is_valid_judgment_automation_receipt_payload, | ||
| judgment_automation_receipt_freshness_window_ms, | ||
| ) | ||
| from polylogue.daemon.lifecycle import DAEMON_HEARTBEAT_STALE_AFTER_SECONDS | ||
| from polylogue.storage.sqlite.archive_tiers.user_write import ( | ||
| ASSERTION_CANDIDATE_JUDGMENT_KINDS, | ||
|
|
@@ -1516,6 +1520,14 @@ def _archive_assertion_candidate_queue_health( | |
| from polylogue.surfaces.payloads import AssertionCandidateQueueHealthPayload | ||
|
|
||
| observed_at_ms = int(datetime.now(UTC).timestamp() * 1000) if now_ms is None else now_ms | ||
| interval_s = getattr(config, "judgment_automation_interval_s", None) | ||
| if isinstance(interval_s, bool) or not isinstance(interval_s, int): | ||
| return AssertionCandidateQueueHealthPayload( | ||
| state="unavailable", | ||
| observed_at_ms=observed_at_ms, | ||
| pending_count=0, | ||
| caveats=("judgment scheduler interval authority is unavailable; freshness is unverified",), | ||
| ) | ||
| archive_root = _active_archive_root(config) | ||
| user_db = archive_root / "user.db" | ||
| if not user_db.exists(): | ||
|
|
@@ -1607,6 +1619,9 @@ def _archive_assertion_candidate_queue_health( | |
| producer_debt_count = 0 | ||
| scheduler_state: Literal["fresh", "stale", "stopped", "unknown"] = "unknown" | ||
| scheduler_heartbeat_at_ms: int | None = None | ||
| judgment_scheduler_receipt_status: Literal["completed", "parked", "failed", "unknown"] = "unknown" | ||
| judgment_scheduler_receipt_at_ms: int | None = None | ||
| judgment_scheduler_receipt_reason: str | None = None | ||
| caveats: list[str] = [] | ||
| ops_db = archive_root / "ops.db" | ||
| if not ops_db.exists(): | ||
|
|
@@ -1661,6 +1676,38 @@ def _archive_assertion_candidate_queue_health( | |
| scheduler_state = "fresh" | ||
| else: | ||
| scheduler_state = "stale" | ||
| if ops_conn.execute( | ||
| "SELECT 1 FROM sqlite_master WHERE type='table' AND name='daemon_events'" | ||
| ).fetchone(): | ||
| receipt_row = ops_conn.execute( | ||
| """ | ||
| SELECT ts_ms, payload_json | ||
| FROM daemon_events | ||
| WHERE kind = 'judgment-automation' | ||
| ORDER BY id DESC | ||
| LIMIT 1 | ||
| """ | ||
| ).fetchone() | ||
| if receipt_row is not None: | ||
| try: | ||
| receipt_payload = json.loads(str(receipt_row[1])) | ||
| except (TypeError, ValueError): | ||
| receipt_payload = None | ||
| receipt_is_valid = is_valid_judgment_automation_receipt_payload(receipt_payload) | ||
| raw_status = ( | ||
| str(receipt_payload.get("status", "unknown")) | ||
| if receipt_is_valid and isinstance(receipt_payload, dict) | ||
| else "unknown" | ||
| ) | ||
| if not receipt_is_valid: | ||
| caveats.append("latest judgment scheduler receipt is malformed") | ||
| if raw_status in {"completed", "parked", "failed"}: | ||
| judgment_scheduler_receipt_status = cast( | ||
| Literal["completed", "parked", "failed"], raw_status | ||
| ) | ||
| judgment_scheduler_receipt_at_ms = int(receipt_row[0]) | ||
| if receipt_is_valid and isinstance(receipt_payload, dict): | ||
| judgment_scheduler_receipt_reason = str(receipt_payload["reason"]) | ||
| finally: | ||
| ops_conn.close() | ||
| except sqlite3.Error as exc: | ||
|
|
@@ -1675,12 +1722,43 @@ def _archive_assertion_candidate_queue_health( | |
| and producer_age_ms is not None | ||
| and producer_age_ms <= 24 * 60 * 60 * 1000 | ||
| ) | ||
| judgment_receipt_age_ms = ( | ||
| None if judgment_scheduler_receipt_at_ms is None else max(0, observed_at_ms - judgment_scheduler_receipt_at_ms) | ||
| ) | ||
| receipt_freshness_window_ms = judgment_automation_receipt_freshness_window_ms(interval_s) | ||
| parked_receipt_freshness_window_ms = judgment_automation_receipt_freshness_window_ms(interval_s, parked=True) | ||
| judgment_receipt_fresh = ( | ||
| judgment_scheduler_receipt_status == "completed" | ||
| and judgment_receipt_age_ms is not None | ||
| and judgment_receipt_age_ms <= receipt_freshness_window_ms | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| judgment_parked_receipt_fresh = ( | ||
| judgment_scheduler_receipt_status == "parked" | ||
| and judgment_receipt_age_ms is not None | ||
| and judgment_receipt_age_ms <= parked_receipt_freshness_window_ms | ||
| ) | ||
|
|
||
| state: AssertionCandidateQueueState | ||
| if producer_debt_count or producer_status in failed_producer_statuses or scheduler_state in {"stale", "stopped"}: | ||
| state = "producer-stalled" | ||
| elif stale_pending_count: | ||
| state = "stale-pending" | ||
| elif pending_count and judgment_scheduler_receipt_status == "parked" and not judgment_parked_receipt_fresh: | ||
| state = "scheduler-stalled" | ||
| caveats.append( | ||
| "judgment scheduler has no fresh parked receipt; the bounded retry route is the next daemon tick" | ||
| ) | ||
| elif pending_count and judgment_scheduler_receipt_status in {"parked", "unknown"}: | ||
| state = "parked-pending" | ||
| if judgment_scheduler_receipt_status == "parked": | ||
| caveats.append("judgment scheduler is parked; the bounded retry route is the next enabled daemon tick") | ||
|
Comment on lines
+1751
to
+1754
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 the latest parked receipt becomes old while the daemon heartbeat remains fresh—for example, after the capability is enabled but every subsequent scheduler receipt fails—this branch continues reporting Useful? React with 👍 / 👎.
Owner
Author
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. Repaired in
Owner
Author
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. Disposition: satisfied at the pushed exact head. Parked queue health expires after the same cadence plus bounded grace used for receipt coalescing. |
||
| else: | ||
| caveats.append("no judgment scheduler receipt is observable; pending candidates are not converged") | ||
| elif pending_count and (judgment_scheduler_receipt_status == "failed" or not judgment_receipt_fresh): | ||
| state = "scheduler-stalled" | ||
| caveats.append( | ||
| "judgment scheduler has no fresh successful receipt; the bounded retry route is the next daemon tick" | ||
| ) | ||
| elif pending_count: | ||
| state = "pending" | ||
| elif producer_fresh and scheduler_state == "fresh": | ||
|
|
@@ -1710,6 +1788,10 @@ def _archive_assertion_candidate_queue_health( | |
| scheduler_state=scheduler_state, | ||
| scheduler_heartbeat_at_ms=scheduler_heartbeat_at_ms, | ||
| scheduler_heartbeat_age_ms=heartbeat_age_ms, | ||
| judgment_scheduler_receipt_status=judgment_scheduler_receipt_status, | ||
| judgment_scheduler_receipt_at_ms=judgment_scheduler_receipt_at_ms, | ||
| judgment_scheduler_receipt_age_ms=judgment_receipt_age_ms, | ||
| judgment_scheduler_receipt_reason=judgment_scheduler_receipt_reason, | ||
| producer_debt_count=producer_debt_count, | ||
| caveats=tuple(dict.fromkeys(caveats)), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import sqlite3 | ||
| import time | ||
| from collections.abc import Sequence | ||
| from contextlib import suppress | ||
| from datetime import UTC, datetime | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
@@ -1339,6 +1340,10 @@ def _show_daemon_status(env: AppEnv, status: dict[str, Any], *, compact: bool = | |
| if isinstance(raw_frontier, dict): | ||
| _render_raw_frontier_integrity(env, raw_frontier) | ||
|
|
||
| assertion_candidate_queue = status.get("assertion_candidate_queue") | ||
| if isinstance(assertion_candidate_queue, dict): | ||
| _render_assertion_candidate_queue(env, assertion_candidate_queue) | ||
|
|
||
| # Sizes | ||
| db_bytes = status.get("db_size_bytes", 0) | ||
| disk_free = status.get("disk_free_bytes", 0) | ||
|
|
@@ -1446,6 +1451,10 @@ def _compact_status_payload(status: dict[str, Any], *, source: str) -> dict[str, | |
| if archive_debt: | ||
| payload["archive_debt"] = archive_debt | ||
|
|
||
| assertion_candidate_queue = status.get("assertion_candidate_queue") | ||
| if isinstance(assertion_candidate_queue, dict): | ||
| payload["assertion_candidate_queue"] = assertion_candidate_queue | ||
|
|
||
| raw_materialization = _compact_mapping_without( | ||
| status.get("raw_materialization_readiness"), | ||
| {"sampled_rows"}, | ||
|
|
@@ -1702,6 +1711,28 @@ def _show_direct_json( | |
| raw_materialization_readiness = _direct_raw_materialization_readiness(active_root) | ||
| raw_frontier_integrity = _direct_raw_frontier_integrity(active_root, raw_materialization_readiness) | ||
| raw_failure_status = _direct_raw_failure_status(root) | ||
| from polylogue.config import Config, resolve_runtime_config | ||
| from polylogue.daemon.status import assertion_candidate_queue_status_summary | ||
| from polylogue.paths import render_root | ||
|
|
||
| try: | ||
| resolved_runtime_config = resolve_runtime_config().as_config() | ||
| except Exception as exc: | ||
| assertion_candidate_queue = { | ||
| "mode": "assertion-candidate-queue-health", | ||
| "state": "unavailable", | ||
| "pending_count": 0, | ||
| "caveats": [f"queue health configuration unavailable: {exc}"], | ||
| } | ||
| else: | ||
| queue_config = Config( | ||
| archive_root=active_root, | ||
| render_root=render_root(), | ||
| sources=[], | ||
| db_path=active_db if active_db is not None else active_root / "index.db", | ||
| judgment_automation_interval_s=resolved_runtime_config.judgment_automation_interval_s, | ||
| ) | ||
| assertion_candidate_queue = assertion_candidate_queue_status_summary(config=queue_config) | ||
| component_readiness = _direct_component_readiness( | ||
| env, | ||
| active_root=active_root, | ||
|
|
@@ -1736,6 +1767,7 @@ def _show_direct_json( | |
| "archive_facade_routes": _archive_facade_route_status(), | ||
| "archive_cli_routes": _archive_cli_route_status(), | ||
| "archive_runtime_paths": _archive_runtime_path_status(), | ||
| "assertion_candidate_queue": assertion_candidate_queue, | ||
| "raw_materialization_readiness": raw_materialization_readiness, | ||
| "raw_frontier_integrity": raw_frontier_integrity, | ||
| "component_readiness": component_readiness, | ||
|
|
@@ -2471,13 +2503,32 @@ def _render_assertion_candidate_queue(env: AppEnv, queue: dict[str, Any]) -> Non | |
| state = str(queue.get("state") or "unavailable") | ||
| pending = _safe_int(queue.get("pending_count")) | ||
| color = "green" if state == "healthy-empty" else "yellow" | ||
| if state in {"producer-stalled", "stale-pending", "unavailable"}: | ||
| if state in {"producer-stalled", "scheduler-stalled", "parked-pending", "stale-pending", "unavailable"}: | ||
| color = "red" | ||
| line = f" Assertion candidate queue: [{color}]{state}, {pending} pending[/{color}]" | ||
| oldest_age = queue.get("oldest_pending_age_ms") | ||
| if isinstance(oldest_age, int | float): | ||
| line += f", oldest={float(oldest_age) / (24 * 60 * 60 * 1000):.1f}d" | ||
| env.ui.console.print(line) | ||
| receipt_status = queue.get("judgment_scheduler_receipt_status") | ||
|
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 the daemon is running—the normal AGENTS.md reference: AGENTS.md:L480-L482 Useful? React with 👍 / 👎.
Owner
Author
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. Already satisfied before the final repair. The daemon-backed status route renders assertion-candidate queue health from its response payload.
Owner
Author
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. Disposition: satisfied at the pushed exact head. The daemon-backed status route renders assertion-candidate queue health from its response payload. |
||
| if receipt_status is not None: | ||
| receipt_details = [str(receipt_status)] | ||
| receipt_at_ms = queue.get("judgment_scheduler_receipt_at_ms") | ||
| if isinstance(receipt_at_ms, int | float) and not isinstance(receipt_at_ms, bool): | ||
| with suppress(OverflowError, OSError, ValueError): | ||
| receipt_timestamp = datetime.fromtimestamp(float(receipt_at_ms) / 1000, tz=UTC).isoformat() | ||
| receipt_details.append(f"at={receipt_timestamp}") | ||
| receipt_age_ms = queue.get("judgment_scheduler_receipt_age_ms") | ||
| if isinstance(receipt_age_ms, int | float) and not isinstance(receipt_age_ms, bool): | ||
| receipt_age_s = float(receipt_age_ms) / 1000 | ||
| if receipt_age_s >= 24 * 60 * 60: | ||
| receipt_details.append(f"age={receipt_age_s / (24 * 60 * 60):.1f}d") | ||
| else: | ||
| receipt_details.append(f"age={receipt_age_s:.1f}s") | ||
| receipt_reason = queue.get("judgment_scheduler_receipt_reason") | ||
| if receipt_reason: | ||
| receipt_details.append(f"reason={receipt_reason}") | ||
| env.ui.console.print(f" judgment scheduler receipt: {', '.join(receipt_details)}") | ||
|
|
||
|
|
||
| def _render_sqlite_maintenance(env: AppEnv, status: dict[str, Any]) -> None: | ||
|
|
||
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.
If the system clock moves backward between scheduler attempts, a newly inserted receipt can have a lower
ts_msthan the preceding receipt, so this query continues projecting the older state even though the event ledger contains a newer outcome. The newget_latest_daemon_eventhelper already defines latest by monotonically increasingid; use the same ordering here so a later completed or parked transition cannot remain hidden behind an earlier failed receipt.Useful? React with 👍 / 👎.
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.
False positive against the current code. Queue health selects
ORDER BY id DESC, and the reversed-clock regression proves ledger order wins over wall-clock order.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.
Disposition: satisfied at the pushed exact head. Queue health selects the latest daemon event by ledger id, and the reversed-clock regression proves it does not use wall-clock ordering.