From 561221b7db4471bc003a31f036aaf2a9283a913b Mon Sep 17 00:00:00 2001 From: TZZheng Date: Sun, 12 Jul 2026 15:52:09 -0500 Subject: [PATCH] fix(nudge): throttle idle goal notification scans (#751) --- src/lingtai/kernel/ANATOMY.md | 2 +- src/lingtai/kernel/nudge/ANATOMY.md | 3 +- src/lingtai/kernel/nudge/goal.py | 7 ++++ tests/test_goal_notification.py | 64 +++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/lingtai/kernel/ANATOMY.md b/src/lingtai/kernel/ANATOMY.md index 11c85f9af..a85448cce 100644 --- a/src/lingtai/kernel/ANATOMY.md +++ b/src/lingtai/kernel/ANATOMY.md @@ -100,7 +100,7 @@ The kernel root holds the coordinator (`base_agent/`) plus a flat collection of - `base_agent/` — `BaseAgent`, the kernel coordinator (package of 7 modules). `base_agent/__init__.py` defines `BaseAgent` (lines 330–3228; 2,899 lines: constructor, properties, state machine, hooks, cross-cutting stubs including the `.notification/` sync trio, pass-throughs to submodules). Submodules: `lifecycle.py` (start/stop/heartbeat/signals/refresh — heartbeat tick now also applies the hidden fixed 24h IDLE→ASLEEP timeout before `_sync_notifications`; terminal refresh relaunch failure writes a bounded `logs/refresh_failed_permanent.json` artifact and a high-priority `.notification/system.json` event), `turn.py` (main loop/message dispatch/AED/response processing), `worker_recovery.py` (WorkerStillRunning poison guard, unfinished-turn artifacts, refresh recovery), `tools.py` (tool schemas/dispatch/registry), `identity.py` (naming/manifest/status), `prompt.py` (system prompt building/flushing), `messaging.py` (mail/notification producers/outbound). Soul-flow domain logic lives in `lingtai/tools/soul/flow.py`. See `base_agent/ANATOMY.md`. - `maintenance/` — explicit, operator-run maintenance reporters. `retention.py` implements the dry-run-only retention report for stale terminal daemon run dirs, old sent-mail copies, opt-in archive mail, rebuildable `logs/log.sqlite`, and read-only high-footprint observations for portal replay, agent logs, and history; it never deletes or rewrites files. See `maintenance/ANATOMY.md`. -- `nudge/` — **per-agent periodic checks** that publish read-only findings through ordinary Notification transport. `__init__.py:run_checks(agent)` is called once per heartbeat tick from `base_agent/lifecycle.py:_heartbeat_loop`; `effective_policy()` owns the shared `LINGTAI_NUDGE_ENABLED` (default `on`) and `LINGTAI_NUDGE_REPEAT_INTERVAL` (default `24h`) controls, and `record_dismissal()` records mute expiry without treating dismissal as resolution. Each check remains a small `check(agent) -> None`; `kernel_version.py` and `source_drift.py` report facts without product-level UTC/fingerprint cadence, while `goal.py` remains an IDLE-only protected-goal system reminder. Every emitted entry is self-describing and routes to the environment-variable catalogue. See `nudge/ANATOMY.md`. +- `nudge/` — **per-agent periodic checks** that publish read-only findings through ordinary Notification transport. `__init__.py:run_checks(agent)` is called once per heartbeat tick from `base_agent/lifecycle.py:_heartbeat_loop`; `effective_policy()` owns the shared `LINGTAI_NUDGE_ENABLED` (default `on`) and `LINGTAI_NUDGE_REPEAT_INTERVAL` (default `24h`) controls, and `record_dismissal()` records mute expiry without treating dismissal as resolution. Each check remains a small `check(agent) -> None`; `kernel_version.py` and `source_drift.py` report facts without product-level UTC/fingerprint cadence, while `goal.py` remains an IDLE-only protected-goal system reminder gated by an in-memory 10-second check cadence. Every emitted entry is self-describing and routes to the environment-variable catalogue. See `nudge/ANATOMY.md`. - `release_manifest.py` — shared, network-free `lingtai.kernel.release/v1` schema/semantic validator used by the runtime release-manifest consumer and the canonical `scripts/lib/release_manifest.py` script import path; it keeps producer, publisher, and consumer bytes on one strict contract. - `event_journal/` — Core-owned outbound Port for append-only structured events plus authoritative `JournalPosition` provenance. `BaseAgent` receives this Port; POSIX JSONL/SQLite composition lives outside Core. See `event_journal/ANATOMY.md` (`event_journal/__init__.py:9-26`). - `mail_transport/` — Core-owned outbound Port for fire-and-forget agent messaging (`MailTransportPort`: `send`/`listen`/`stop`/`address`). `BaseAgent` receives this Port as `mail_service`; the concrete POSIX filesystem mailbox (`PosixFilesystemMailAdapter`) lives outside Core and is wired by the CLI. See `mail_transport/ANATOMY.md` and `mail_transport/CONTRACT.md` (`mail_transport/__init__.py:16-62`). diff --git a/src/lingtai/kernel/nudge/ANATOMY.md b/src/lingtai/kernel/nudge/ANATOMY.md index 16d85c332..a0d3319cb 100644 --- a/src/lingtai/kernel/nudge/ANATOMY.md +++ b/src/lingtai/kernel/nudge/ANATOMY.md @@ -42,7 +42,8 @@ the ordinary Notification Store channel; it does not create a second transport. - `source_drift.py` — read-only runtime/source comparison, skipped for editable or source runtimes (`src/lingtai/kernel/nudge/source_drift.py:21-111`). - `goal.py` — IDLE-only protected-goal reminder projected into the ordinary - `system` notification channel (`src/lingtai/kernel/nudge/goal.py:20-75`). + `system` notification channel, gated by an in-memory 10-second check + cadence (`src/lingtai/kernel/nudge/goal.py:20-75`). - `prompts.py` — typed producer-fact to agent-facing payload renderer, including installer and mirror-mismatch guidance (`src/lingtai/kernel/nudge/prompts.py:17-154`). - `notifications.py` — ordinary Notification transport invokes Nudge's dismissal diff --git a/src/lingtai/kernel/nudge/goal.py b/src/lingtai/kernel/nudge/goal.py index 9b3308ec1..ec08e93c1 100644 --- a/src/lingtai/kernel/nudge/goal.py +++ b/src/lingtai/kernel/nudge/goal.py @@ -19,6 +19,7 @@ _DEFAULT_DELAY_SECONDS = 120.0 +_CHECK_INTERVAL_SECONDS = 10.0 _REMINDER_BODY = "Goal reminder: read .notification/goal.json and follow its instructions; see the goal manual under system-manual." @@ -28,6 +29,12 @@ def check(agent) -> None: if getattr(agent, "_state", None) != AgentState.IDLE: return + check_now = time.monotonic() + last_check = float(getattr(agent, "_goal_reminder_last_check_at", 0.0) or 0.0) + if last_check and check_now - last_check < _CHECK_INTERVAL_SECONDS: + return + agent._goal_reminder_last_check_at = check_now + store = agent._notification_store allow = _get_allow_predicate() notifications = store.snapshot(allow) diff --git a/tests/test_goal_notification.py b/tests/test_goal_notification.py index ac96a5829..07ff3f846 100644 --- a/tests/test_goal_notification.py +++ b/tests/test_goal_notification.py @@ -55,6 +55,14 @@ def _enqueue_system_notification( ) +@dataclass +class _Clock: + now: float = 100.0 + + def advance(self, seconds: float) -> None: + self.now += seconds + + def test_goal_reminder_publishes_short_system_event_after_idle_delay(tmp_path: Path) -> None: agent = _GoalAgent(tmp_path) publish_test_payload( @@ -87,6 +95,35 @@ def test_goal_reminder_does_not_duplicate_existing_event(tmp_path: Path) -> None assert len(snapshot_notifications(tmp_path)["system"]["data"]["events"]) == 1 +def test_goal_reminder_scans_disk_at_most_once_per_cadence(tmp_path: Path, monkeypatch) -> None: + from lingtai.kernel.nudge import goal as goal_mod + + agent = _GoalAgent(tmp_path) + clock = _Clock() + snapshots = 0 + real_snapshot = agent._notification_store.snapshot + + def counted_snapshot(allow): + nonlocal snapshots + snapshots += 1 + return real_snapshot(allow) + + monkeypatch.setattr(goal_mod.time, "monotonic", lambda: clock.now) + monkeypatch.setattr(agent._notification_store, "snapshot", counted_snapshot) + + check_goal(agent) + assert snapshots == 1 + + for _ in range(9): + clock.advance(1) + check_goal(agent) + assert snapshots == 1 + + clock.advance(1) + check_goal(agent) + assert snapshots == 2 + + def test_goal_reminder_skips_completed_goal(tmp_path: Path) -> None: agent = _GoalAgent(tmp_path) publish_test_payload(tmp_path, "goal", {"data": {"id": "demo", "status": "done", "reminder_delay_seconds": 1}}) @@ -103,8 +140,12 @@ def test_goal_reminder_requires_goal_json(tmp_path: Path) -> None: assert not (tmp_path / ".notification" / "system.json").exists() -def test_goal_reminder_republishes_after_whole_system_dismiss_and_fresh_delay(tmp_path: Path) -> None: +def test_goal_reminder_republishes_after_whole_system_dismiss_and_fresh_delay(tmp_path: Path, monkeypatch) -> None: + from lingtai.kernel.nudge import goal as goal_mod + agent = _GoalAgent(tmp_path) + clock = _Clock() + monkeypatch.setattr(goal_mod.time, "monotonic", lambda: clock.now) publish_test_payload(tmp_path, "goal", {"data": {"id": "demo", "status": "active", "reminder_delay_seconds": 1}}) check_goal(agent) assert "system" in snapshot_notifications(tmp_path) @@ -119,29 +160,46 @@ def test_goal_reminder_republishes_after_whole_system_dismiss_and_fresh_delay(tm assert "system" not in snapshot_notifications(tmp_path) agent._goal_reminder_last_dismissed_at = time.time() - 2 + clock.advance(10) check_goal(agent) assert snapshot_notifications(tmp_path)["system"]["data"]["events"][0]["ref_id"] == "goal:demo" -def test_goal_reminder_clears_when_goal_becomes_done(tmp_path: Path) -> None: +def test_goal_reminder_clears_when_goal_becomes_done(tmp_path: Path, monkeypatch) -> None: + from lingtai.kernel.nudge import goal as goal_mod + agent = _GoalAgent(tmp_path) + clock = _Clock() + monkeypatch.setattr(goal_mod.time, "monotonic", lambda: clock.now) publish_test_payload(tmp_path, "goal", {"data": {"id": "demo", "status": "active", "reminder_delay_seconds": 1}}) check_goal(agent) assert "system" in snapshot_notifications(tmp_path) publish_test_payload(tmp_path, "goal", {"data": {"id": "demo", "status": "done", "reminder_delay_seconds": 1}}) + clock.advance(10) check_goal(agent) assert "system" not in snapshot_notifications(tmp_path) -def test_goal_reminder_clears_when_goal_json_is_deleted(tmp_path: Path) -> None: +def test_goal_reminder_clears_when_goal_json_is_deleted(tmp_path: Path, monkeypatch) -> None: + from lingtai.kernel.nudge import goal as goal_mod + agent = _GoalAgent(tmp_path) + clock = _Clock() + monkeypatch.setattr(goal_mod.time, "monotonic", lambda: clock.now) publish_test_payload(tmp_path, "goal", {"data": {"id": "demo", "status": "active", "reminder_delay_seconds": 1}}) check_goal(agent) assert "system" in snapshot_notifications(tmp_path) assert agent._notification_store.clear("goal") is True + clock.advance(10) check_goal(agent) assert "system" not in snapshot_notifications(tmp_path) + + publish_test_payload(tmp_path, "goal", {"data": {"id": "replacement", "status": "active", "reminder_delay_seconds": 1}}) + clock.advance(10) + check_goal(agent) + + assert snapshot_notifications(tmp_path)["system"]["data"]["events"][0]["ref_id"] == "goal:replacement"