Skip to content

Commit ab70949

Browse files
lwgrayclaude
andcommitted
fix(experiments): wire progress + context-request heartbeats into the thrash signal
Addresses two PR #704 review findings: the `activity` tally the spawn-thrash detector reads leaned on signals that never moved during the window that matters. 1. context_requests was dead. get_task_context emitted a "context_requested" event but never called LiveExperimentMonitor.record_context_request, so the counter stayed 0. Wire the call (mirrors log_decision). 2. report_task_progress (the 25/50/75% heartbeat) was not counted at all. get_experiment_status exposes only task counts + logged-work counters, so an agent advancing via progress reports left `activity` flat. Add a progress_updates counter + record_progress() to the monitor, expose it in get_status (plus the mlflow metrics + text summary), and call it from report_task_progress. progress_updates is the earliest of these signals -- it fires as soon as a claimed agent reports, well before the first artifact -- closing the claim->first-artifact gap that could otherwise fast-fail a healthy run. Both are folded into the runner's activity tally. Tests: monitor heartbeat counters + get_status exposure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ffe15a3 commit ab70949

5 files changed

Lines changed: 99 additions & 5 deletions

File tree

dev-tools/experiments/runners/spawn_agents.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,13 +1985,19 @@ def _emit(message: str) -> None:
19851985
# "Real progress" signal for the spawn-thrash detector:
19861986
# a monotonic, cumulative tally of the signals an agent
19871987
# emits while genuinely working a task — completions
1988-
# plus logged work (context requests, artifacts,
1989-
# decisions, blockers). It rises on real work and stays
1990-
# flat on claim-and-exit churn; unlike in_progress it
1991-
# never flickers back down, so it cannot mask thrash.
1992-
# See SpawnThrashDetector.observe.
1988+
# plus the report_task_progress heartbeat (25/50/75%)
1989+
# and logged work (context requests, artifacts,
1990+
# decisions, blockers). progress_updates is the earliest
1991+
# of these: it fires as soon as a claimed agent reports,
1992+
# well before the first artifact lands, closing the
1993+
# claim->first-artifact gap that would otherwise let the
1994+
# detector fast-fail a healthy run. It rises on real work
1995+
# and stays flat on claim-and-exit churn; unlike
1996+
# in_progress it never flickers back down, so it cannot
1997+
# mask thrash. See SpawnThrashDetector.observe.
19931998
activity = (
19941999
done
2000+
+ int(status.get("progress_updates", 0))
19952001
+ int(status.get("context_requests", 0))
19962002
+ int(status.get("artifacts_created", 0))
19972003
+ int(status.get("decisions_logged", 0))

src/experiments/live_experiment_monitor.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def __init__(
9696
self.artifacts_created = 0
9797
self.decisions_logged = 0
9898
self.context_requests = 0
99+
self.progress_updates = 0
99100

100101
# Previous completed-task count, used to derive per-interval velocity
101102
self._last_completed_count = 0
@@ -227,6 +228,7 @@ async def stop(self) -> Dict[str, Any]:
227228
"total_artifacts": float(self.artifacts_created),
228229
"total_decisions": float(self.decisions_logged),
229230
"total_context_requests": float(self.context_requests),
231+
"total_progress_updates": float(self.progress_updates),
230232
}
231233

232234
# Generate summary
@@ -556,6 +558,29 @@ def record_context_request(
556558

557559
logger.debug(f"Recorded context request: {agent_id} for {task_id}")
558560

561+
def record_progress(
562+
self,
563+
agent_id: str,
564+
task_id: str,
565+
progress: int = 0,
566+
status: str = "in_progress",
567+
) -> None:
568+
"""Record a task-progress heartbeat (a report_task_progress call).
569+
570+
Counts every progress report — including intermediate 25/50/75%
571+
updates — as a sign the agent is alive and working its claimed
572+
task. The runner's spawn-thrash detector reads this (via
573+
``progress_updates`` in :meth:`get_status`) so it does not tear
574+
down a healthy-but-slow run that is advancing without a
575+
completion yet.
576+
"""
577+
self.progress_updates += 1
578+
579+
logger.debug(
580+
f"Recorded progress update: {agent_id} on {task_id} "
581+
f"({progress}% {status})"
582+
)
583+
559584
async def get_status(self) -> Dict[str, Any]:
560585
"""
561586
Get current experiment status.
@@ -598,6 +623,7 @@ async def get_status(self) -> Dict[str, Any]:
598623
"artifacts_created": self.artifacts_created,
599624
"decisions_logged": self.decisions_logged,
600625
"context_requests": self.context_requests,
626+
"progress_updates": self.progress_updates,
601627
}
602628

603629
# Ground truth from the kanban backend. This is what
@@ -657,6 +683,7 @@ def _generate_summary(self) -> str:
657683
- Artifacts Created: {self.artifacts_created}
658684
- Decisions Logged: {self.decisions_logged}
659685
- Context Requests: {self.context_requests}
686+
- Progress Updates: {self.progress_updates}
660687
661688
Top Agents by Completions:
662689
"""

src/marcus_mcp/tools/context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,16 @@ async def get_task_context(task_id: str, state: Any) -> Dict[str, Any]:
181181
},
182182
)
183183

184+
# Record in the active experiment if one is running (mirrors
185+
# log_decision above). Without this, context_requests stays 0 and the
186+
# runner's spawn-thrash detector cannot see a claimed agent pulling
187+
# context as a sign of forward progress.
188+
from src.experiments.live_experiment_monitor import get_active_monitor
189+
190+
_monitor = get_active_monitor()
191+
if _monitor and _monitor.is_running:
192+
_monitor.record_context_request(agent_id=agent_id, task_id=task_id)
193+
184194
try:
185195
# Check if this is a subtask
186196
if hasattr(state, "subtask_manager") and state.subtask_manager:

src/marcus_mcp/tools/task.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3732,6 +3732,22 @@ async def report_task_progress(
37323732
},
37333733
)
37343734

3735+
# Record the progress heartbeat in the active experiment monitor so
3736+
# the runner's spawn-thrash detector sees a claimed task advancing
3737+
# (report_task_progress at 25/50/75%) as real progress, not only
3738+
# completions. Counted on every report — a report arriving at all
3739+
# proves the agent is alive and working its task.
3740+
from src.experiments.live_experiment_monitor import get_active_monitor
3741+
3742+
_progress_monitor = get_active_monitor()
3743+
if _progress_monitor and _progress_monitor.is_running:
3744+
_progress_monitor.record_progress(
3745+
agent_id=agent_id,
3746+
task_id=task_id,
3747+
progress=progress,
3748+
status=status,
3749+
)
3750+
37353751
# Escalation flag — set when the retry ceiling is hit during
37363752
# validation. The task is routed through the normal completion
37373753
# path (kanban update, lease cleanup, branch merge) but the

tests/unit/experiments/test_live_experiment_monitor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,44 @@ def _make_monitor(kanban_client: Any = None) -> LiveExperimentMonitor:
4343
monitor.artifacts_created = 0
4444
monitor.decisions_logged = 0
4545
monitor.context_requests = 0
46+
monitor.progress_updates = 0
4647
return monitor
4748

4849

50+
class TestProgressHeartbeat:
51+
"""record_progress + progress_updates surface the report_task_progress
52+
heartbeat (PR #704 review).
53+
54+
The spawn-thrash detector reads ``progress_updates`` so a claimed
55+
agent reporting 25/50/75% counts as forward progress before any task
56+
completes — closing the claim->first-artifact gap that could
57+
otherwise fast-fail a healthy run.
58+
"""
59+
60+
def test_record_progress_increments_counter(self) -> None:
61+
"""Each report_task_progress call bumps the counter."""
62+
monitor = _make_monitor()
63+
assert monitor.progress_updates == 0
64+
monitor.record_progress(agent_id="a1", task_id="t1", progress=25)
65+
monitor.record_progress(agent_id="a1", task_id="t1", progress=50)
66+
assert monitor.progress_updates == 2
67+
68+
@pytest.mark.asyncio
69+
async def test_get_status_exposes_progress_updates(self) -> None:
70+
"""get_status surfaces progress_updates for the runner to read."""
71+
monitor = _make_monitor()
72+
monitor.record_progress(agent_id="a1", task_id="t1", progress=75)
73+
status = await monitor.get_status()
74+
assert status["progress_updates"] == 1
75+
76+
def test_record_context_request_increments_counter(self) -> None:
77+
"""record_context_request (now wired into get_task_context) counts."""
78+
monitor = _make_monitor()
79+
assert monitor.context_requests == 0
80+
monitor.record_context_request(agent_id="a1", task_id="t1")
81+
assert monitor.context_requests == 1
82+
83+
4984
class TestGetStatusKanbanTruth:
5085
"""get_status must expose kanban-truth task counts."""
5186

0 commit comments

Comments
 (0)