Repository navigation
fix(mcp): heartbeat activity during long synchronous MCP/hive tool calls (t_cc4a1b4f) - #44
Conversation
📝 WalkthroughWalkthroughThe PR adds activity heartbeat tracking to prevent timeout during long-running MCP tool calls. ChangesActivity heartbeat during long MCP calls
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔎 Lint report:
|
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tests/tools/test_mcp_tool.py (2)
469-517: ⚡ Quick winConsider verifying multiple callback invocations to confirm polling behavior.
The test currently asserts
assert recorded, verifying the callback fired at least once. Since the coroutine sleeps for 0.35s and the poll loop iterates every 0.1s, we should expect approximately 3-4 invocations oftouch_activity_if_due. Strengthening the assertion to verify multiple invocations would better confirm that the poll loop calls the activity callback on each iteration, not just once.🔍 Suggested assertion enhancement
assert result == "done" # The callback must have been invoked at least once while waiting. assert recorded, "activity callback never fired during the MCP wait" + # With 0.35s sleep and 0.1s poll interval, expect 3-4 invocations + assert len(recorded) >= 2, f"Expected multiple callback invocations, got {len(recorded)}" # State carries the heartbeat cadence and start bookkeeping. first_state, first_label = recorded[0]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/tools/test_mcp_tool.py` around lines 469 - 517, Update the test_activity_callback_fires_during_long_call to assert multiple invocations of the activity callback: after calling mcp._run_on_mcp_loop and confirming result == "done", add a stronger assertion like assert len(recorded) >= 3 (or another small integer reflecting the expected 3–4 poll iterations) to ensure touch_activity_if_due was called repeatedly; keep the existing checks for first_state/first_label and their contents and reference the recorded list, the _run_on_mcp_loop call, and the patched touch_activity_if_due to locate where to insert the new assertion.
469-517: ⚡ Quick winConsider adding test coverage for the guarded import fallback.
The PR description mentions "The import is guarded so the MCP loop still works if tools.environments.base is unavailable." However, there's no test verifying this graceful degradation behavior. A complementary test could verify that when
tools.environments.baseis unavailable, the MCP loop still completes calls successfully without crashing, even though activity callbacks won't fire.Would you like me to generate a test that verifies graceful degradation when the import fails?
💡 Suggested test structure
def test_activity_callback_degrades_gracefully_when_import_unavailable(self): """MCP loop completes successfully even when touch_activity_if_due unavailable.""" import tools.mcp_tool as mcp loop = asyncio.new_event_loop() t = threading.Thread(target=loop.run_forever, daemon=True) t.start() async def _fast(): return "done" try: with patch.object(mcp, "_mcp_loop", loop): # Simulate the guarded import failing by making the module unavailable with patch.dict('sys.modules', {'tools.environments.base': None}): result = mcp._run_on_mcp_loop(_fast, timeout=10) finally: loop.call_soon_threadsafe(loop.stop) t.join(timeout=2) loop.close() # Should complete successfully without crashing assert result == "done"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/tools/test_mcp_tool.py` around lines 469 - 517, Add a test ensuring the guarded import fallback for tools.environments.base doesn't break the MCP loop: create a new test (e.g., test_activity_callback_degrades_gracefully_when_import_unavailable) that imports tools.mcp_tool, starts a real asyncio loop and thread like test_activity_callback_fires_during_long_call, then patch.object(mcp, "_mcp_loop", loop) and use patch.dict('sys.modules', {'tools.environments.base': None}) to simulate the missing module, call mcp._run_on_mcp_loop with a simple coroutine and assert it returns successfully; this verifies the guarded import around touch_activity_if_due (and related logic) lets _run_on_mcp_loop complete even when the activity callback module is absent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/tools/test_mcp_tool.py`:
- Around line 469-517: Update the test_activity_callback_fires_during_long_call
to assert multiple invocations of the activity callback: after calling
mcp._run_on_mcp_loop and confirming result == "done", add a stronger assertion
like assert len(recorded) >= 3 (or another small integer reflecting the expected
3–4 poll iterations) to ensure touch_activity_if_due was called repeatedly; keep
the existing checks for first_state/first_label and their contents and reference
the recorded list, the _run_on_mcp_loop call, and the patched
touch_activity_if_due to locate where to insert the new assertion.
- Around line 469-517: Add a test ensuring the guarded import fallback for
tools.environments.base doesn't break the MCP loop: create a new test (e.g.,
test_activity_callback_degrades_gracefully_when_import_unavailable) that imports
tools.mcp_tool, starts a real asyncio loop and thread like
test_activity_callback_fires_during_long_call, then patch.object(mcp,
"_mcp_loop", loop) and use patch.dict('sys.modules', {'tools.environments.base':
None}) to simulate the missing module, call mcp._run_on_mcp_loop with a simple
coroutine and assert it returns successfully; this verifies the guarded import
around touch_activity_if_due (and related logic) lets _run_on_mcp_loop complete
even when the activity callback module is absent.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4477dfc7-6c41-47ca-8f01-da77754f3e19
📥 Commits
Reviewing files that changed from the base of the PR and between 62a2d5886d1b0dd0b76d3589066dd560dd03df3f and 0cdcd2973eab73740fa13a6efc3487606e380a2c.
📒 Files selected for processing (2)
tests/tools/test_mcp_tool.pytools/mcp_tool.py
|
auto-review: approved, awaiting human merge + kanban_approve. Matrix checks (U1–U5, C1–C5): pass.
Code-quality judgment (role-reviewer): pass.
|
0cdcd29 to
5ccfd69
Compare
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait.
5ccfd69 to
749accb
Compare
|
auto-review: approved, awaiting human merge + kanban_approve. Matrix checks (U1–U6, C1–C6): all pass.
Code-quality judgment (role-reviewer): APPROVED. Single commit, clean rebase onto current main, black-box tests at public boundaries, dependencies injected (activity-state dict / notifier+clock), defensive guards sit on optional liveness/result paths (not required-value fallbacks). |
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…artbeat enforcement (#46) test_dispatch_once_stale_disabled_when_timeout_zero stored os.getpid() as worker_pid with a 5h-old started_at and no heartbeat, then called dispatch_once(stale_timeout_seconds=0). dispatch_once runs enforce_missing_heartbeat independently of stale_timeout_seconds, which os.kill(SIGTERM)'d the stored PID = the pytest process itself, killing pytest before it printed its summary (raw RC=143). The parallel harness then scraped 0 passed/0 failed and bucketed the file as 'no tests ran', turning test(4) red on #43/#44/#45 — broken-main from the upstream rebase. Fix: set a recent last_heartbeat_at on the run so enforce_missing_heartbeat skips the task. This test isolates STALE detection, not heartbeat enforcement. Also hardened test_enforce_max_runtime_integrates_with_dispatch (same os.getpid() footgun on its real-os.kill dispatch_once call). Test-only change. Verified: raw pytest RC=0, 166 passed 1 skipped. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
…e tool calls (#44) A single synchronous MCP tool call (e.g. ns_runreport against the NetSuite hive) blocks _run_on_mcp_loop for many minutes. The poll loop honored user interrupts but never fired the thread-local activity callback the agent sets before dispatch, so the agent's last-activity timestamp went stale. For dispatcher-spawned kanban workers that timestamp is bridged to the board heartbeat via _touch_activity, so a long hive call starved the heartbeat and the stuck-worker watchdog killed the worker mid-call (17 consecutive stuck-kills observed on one report-heavy task). Mirror the terminal tool's _wait_for_process heartbeat: fire touch_activity_if_due() on each poll iteration at a 30s cadence (well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one write per 60s). Adds a regression test exercising a real background loop + blocking coroutine that asserts the callback fires during the wait. Co-authored-by: Sahil (AI) <266772320+sahilm-ai@users.noreply.github.com>
Problem
A single synchronous MCP/hive tool call — e.g. `ns_runreport` against the NetSuite hive — blocks the agent's tool-execution thread inside `_run_on_mcp_loop` for many minutes. The poll loop already honors user interrupts, but it never fired the thread-local activity callback the agent registers before dispatching a tool.
For dispatcher-spawned kanban workers, the agent's last-activity timestamp is bridged to the board heartbeat via `AIAgent._touch_activity` → `heartbeat_current_worker_from_env`. So a long hive call left the heartbeat stale, the dispatcher's stuck-worker watchdog (`stuck_after_seconds`, default 900s) classified the live worker as stale, and killed + re-queued it mid-call.
Observed impact (task t_c90d83c9): 17 consecutive stuck-kills (runs 803–821, heartbeat_age 1174–3077s). The task only completed by abandoning live hive fetches.
Diagnosis — which layer
The hang is in the Hermes worker transport, not the proxy or the NetSuite report engine. Evidence:
So the terminal path was survivable and the MCP path was not, for the same wall-clock duration. That asymmetry is the bug.
Fix
Mirror the terminal path: fire `touch_activity_if_due()` on each poll iteration at a 30s cadence. 30s is well inside both the gateway inactivity window and the 15-min kanban stuck default; the underlying heartbeat bridge is itself rate-limited to one DB write per 60s, so an over-eager cadence is harmless. The import is guarded so the MCP loop still works if `tools.environments.base` is unavailable on a niche surface.
This makes one long `ns_runreport` (or any slow hive call) survivable without a per-task `stuck_after_seconds` bump. The proxy/per-tool `timeout` (default 120s, configurable per server) still fires and returns a clean `TimeoutError` — that path is unchanged.
Test
`tests/tools/test_mcp_tool.py::TestRunOnMcpLoop::test_activity_callback_fires_during_long_call` — real background event loop + a coroutine that blocks across several poll iterations; asserts the activity callback fires during the wait with the expected label and 30s cadence.
```
209 tests passed (test_mcp_tool.py + test_mcp_tool_401_handling.py + test_mcp_structured_content.py)
```
Notes / boundary
AC: a kanban worker can make one long `ns_runreport` hive call without being stuck-killed; the MCP poll loop heartbeats throughout the wait.
Summary by CodeRabbit
Bug Fixes
Tests