From 192e70dec812cb2e5f2cb075a12edd6178c695d0 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Mon, 11 May 2026 06:38:28 +0000 Subject: [PATCH] fix(workspace): skip idle prompt when delegation results are pending Issue #381: the idle loop fired every idle_interval_seconds (default 10 min) and sent the idle prompt even when delegation results were pending. The heartbeat sends a self-message to notify the agent when results arrive, but the idle prompt could arrive first, causing the agent to compose a stale tick before processing the pending delegation notification. Fix: before sending the idle prompt, read _DELEGATION_RESULTS_FILE. If the file exists and is non-empty, skip this idle tick and log why. The heartbeat will notify the agent when it next runs and sees the pending results. Added test_idle_loop_pending_check.py covering: no file, empty file, whitespace-only, single result, multiple results, newline-only. 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)) -- 2.45.2