From 0547d405bf2f4ae627b1072435cebba92e644881 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Mon, 11 May 2026 08:31:03 +0000 Subject: [PATCH] fix(workspace): skip idle prompt when delegation results are pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #381: agent tick generators producing stale-repo state. Root cause: the idle loop fires every idle_interval_seconds (default 10 min) and sends an idle prompt regardless of pending delegation results. If a delegation completes just before the idle tick fires, the heartbeat writes results to DELEGATION_RESULTS_FILE and sends a self-message — but the idle prompt arrives first and the agent composes a stale tick before processing the results notification. Peers receive repeated identical asks. Fix: before sending the idle prompt, read DELEGATION_RESULTS_FILE. If it contains unconsumed results, skip this idle tick. The heartbeat's own self-message (sent when results arrive) will wake the agent, which then sees the results in _prepare_prompt() and processes them before composing. Companion to wsr PR (runtime-runtime mirror). Changes: - workspace/main.py: pending-results check in _run_idle_loop() (+25 lines) - workspace/tests/test_idle_loop_pending_check.py: 6-case unit test Co-Authored-By: Claude Opus 4.7 --- workspace/main.py | 25 ++++++ .../tests/test_idle_loop_pending_check.py | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 workspace/tests/test_idle_loop_pending_check.py diff --git a/workspace/main.py b/workspace/main.py index 77c2d2d6..8c569309 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -668,6 +668,31 @@ async def main(): # pragma: no cover if heartbeat.active_tasks > 0: continue + # Issue #381 fix: skip the idle prompt if there are unconsumed + # delegation results waiting. The heartbeat sends a self-message + # for every new result batch, so sending the idle prompt here would + # race: the agent would compose a stale tick BEFORE processing the + # results notification, producing repeated identical asks (peer sends + # correction, we respond with stale state, peer asks again). + # By skipping the idle prompt when results are pending, we let the + # heartbeat's own self-message wake the agent after results are + # written. The agent then sees the results in _prepare_prompt() + # and processes them before composing. + from heartbeat import DELEGATION_RESULTS_FILE as _DRF + try: + with open(_DRF) as _rf: + _rf.seek(0) + _content = _rf.read().strip() + if _content: + print( + f"Idle loop: skipping — {len(_content)} bytes of unconsumed " + f"delegation results pending (heartbeat will notify agent)", + flush=True, + ) + continue + except FileNotFoundError: + pass # No results file — normal, proceed with idle prompt + # Self-post the idle prompt via the platform A2A proxy (same # path as initial_prompt). The agent's own concurrency control # rejects if the workspace becomes busy between this check and diff --git a/workspace/tests/test_idle_loop_pending_check.py b/workspace/tests/test_idle_loop_pending_check.py new file mode 100644 index 00000000..6699bf8f --- /dev/null +++ b/workspace/tests/test_idle_loop_pending_check.py @@ -0,0 +1,80 @@ +"""Tests for issue #381: idle loop must not fire when delegation results are pending. + +The idle loop skips sending the idle prompt when DELEGATION_RESULTS_FILE +contains unconsumed results, preventing the agent from composing a stale tick +before processing pending delegation notifications from the heartbeat. + +Source: workspace/main.py:_run_idle_loop() pending-results guard. +""" +from __future__ import annotations + +import json + +import pytest + + +def check_results_pending(file_path: str) -> bool: + """Mirror the guard logic from workspace/main.py:_run_idle_loop(). + + Returns True if the results file exists and is non-empty, + meaning the idle loop should skip this tick. + """ + try: + with open(file_path) as rf: + rf.seek(0) + content = rf.read().strip() + return bool(content) + except FileNotFoundError: + return False + + +class TestIdleLoopPendingCheck: + """Tests for the idle-loop pending-delegation-results guard.""" + + def test_no_file_means_proceed(self, tmp_path): + """No delegation results file → idle loop fires normally.""" + results_file = tmp_path / "delegation_results.jsonl" + assert not check_results_pending(str(results_file)) + + def test_empty_file_means_proceed(self, tmp_path): + """Empty file → no pending results → idle loop fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text("", encoding="utf-8") + assert not check_results_pending(str(results_file)) + + def test_whitespace_only_file_means_proceed(self, tmp_path): + """File with only whitespace → treated as empty → idle loop fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text(" \n ", encoding="utf-8") + assert not check_results_pending(str(results_file)) + + def test_single_result_means_skip(self, tmp_path): + """File with one delegation result → skip idle tick.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text( + json.dumps({ + "status": "completed", + "delegation_id": "del-abc", + "summary": "Done", + }) + "\n", + encoding="utf-8", + ) + assert check_results_pending(str(results_file)) + + def test_multiple_results_means_skip(self, tmp_path): + """File with multiple delegation results → skip idle tick.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text( + json.dumps({"status": "completed", "delegation_id": "del-1", "summary": "A"}) + + "\n" + + json.dumps({"status": "failed", "delegation_id": "del-2", "summary": "B"}) + + "\n", + encoding="utf-8", + ) + assert check_results_pending(str(results_file)) + + def test_file_with_only_newline_means_proceed(self, tmp_path): + """File with only a newline character → stripped to empty → fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text("\n", encoding="utf-8") + assert not check_results_pending(str(results_file))