test_work_graph_execute.py 9.75 KB
"""U-006/U-012 tests: c_work_graph_execute's dynamic-workflow wrapper (U-006)
plus CLASSIFIER_BLOCKED / USAGE_LIMIT_REACHED detection and the
p_usage_limit_wait retry loop (U-012), mocked at the subprocess boundary
(CLAUDE.md Testing constraint). Real end-to-end coverage is in
src/test_work_graph_execute_e2e.py (RUN_REAL_AGENT_E2E=1), reusing the
exact spike script the A-1 assumption was validated against.

Run: python3 -m unittest src.test_work_graph_execute -v
"""

import json
import os
import subprocess
import tempfile
import unittest
from unittest.mock import patch

from src import work_graph_execute


def _completed_process(returncode=0, stdout_obj=None, stderr=""):
    stdout = json.dumps(stdout_obj) if stdout_obj is not None else ""
    return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr)


class WriteWorkflowScriptTest(unittest.TestCase):
    def test_writes_into_target_claude_workflows_not_graphlemon_repo(self):
        with tempfile.TemporaryDirectory() as tmp:
            path = work_graph_execute.write_workflow_script(tmp, "run-123", "export const meta = {}")
            self.assertEqual(path, os.path.join(tmp, ".claude", "workflows", "run-123.js"))
            with open(path, encoding="utf-8") as f:
                self.assertEqual(f.read(), "export const meta = {}")


class ExecuteWorkGraphTest(unittest.TestCase):
    SCHEMA = {"type": "object", "required": ["sum"], "properties": {"sum": {"type": "number"}}}

    def test_happy_path_returns_structured_output(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": False, "structured_output": {"sum": 29}}
            )
            output = work_graph_execute.execute_work_graph(
                "/tmp/some-project", "run-123", {"numbers": [2, 3, 4]}, self.SCHEMA
            )
        self.assertEqual(output, {"sum": 29})

    def test_invokes_claude_with_expected_args_and_cwd(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False, "structured_output": {"sum": 1}})
            work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {"n": 1}, self.SCHEMA)

        args, kwargs = run.call_args
        argv = args[0]
        self.assertEqual(argv[0], "claude")
        self.assertIn("-p", argv)
        self.assertIn("Run /run-123 with args {\"n\": 1}", argv)
        self.assertIn("--json-schema", argv)
        self.assertIn(json.dumps(self.SCHEMA), argv)
        self.assertIn("--permission-mode", argv)
        self.assertIn("auto", argv)
        self.assertNotIn("--bare", argv)
        self.assertNotIn("--dangerously-skip-permissions", argv)
        self.assertNotIn("bypassPermissions", argv)
        self.assertEqual(kwargs["cwd"], "/tmp/some-project")
        self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)

    def test_nonzero_exit_raises(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom")
            with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
                work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)

    def test_is_error_true_raises(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": True})
            with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
                work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)

    def test_missing_structured_output_raises(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(stdout_obj={"is_error": False})
            with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
                work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)


class ClassifierBlockedDetectionTest(unittest.TestCase):
    SCHEMA = {"type": "object"}

    def test_three_or_more_permission_denials_raises_classifier_blocked(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "permission_denials": ["a", "b", "c"]}
            )
            with self.assertRaises(work_graph_execute.ClassifierBlockedError):
                work_graph_execute.execute_work_graph("/tmp/p", "run-1", {}, self.SCHEMA)

    def test_fewer_than_three_denials_is_generic_error_not_classifier_blocked(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "permission_denials": ["a"]}
            )
            with self.assertRaises(work_graph_execute.WorkGraphExecuteError) as ctx:
                work_graph_execute.execute_work_graph("/tmp/p", "run-1", {}, self.SCHEMA)
            self.assertNotIsInstance(ctx.exception, work_graph_execute.ClassifierBlockedError)


class UsageLimitDetectionTest(unittest.TestCase):
    SCHEMA = {"type": "object"}

    def test_session_limit_phrase_in_stderr_on_nonzero_exit(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                returncode=1, stdout_obj=None, stderr="Error: You've hit your session limit"
            )
            with self.assertRaises(work_graph_execute.UsageLimitReachedError) as ctx:
                work_graph_execute.execute_work_graph("/tmp/p", "run-1", {}, self.SCHEMA)
            self.assertEqual(ctx.exception.quota_limit_type, "session")

    def test_weekly_limit_phrase_in_error_result(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "result": "You've hit your weekly limit"}
            )
            with self.assertRaises(work_graph_execute.UsageLimitReachedError) as ctx:
                work_graph_execute.execute_work_graph("/tmp/p", "run-1", {}, self.SCHEMA)
            self.assertEqual(ctx.exception.quota_limit_type, "weekly")


class RetryWrapperTest(unittest.TestCase):
    SCHEMA = {"type": "object"}

    def setUp(self):
        self.sleeps = []
        self.clock = [0.0]

    def _sleep(self, seconds):
        self.sleeps.append(seconds)
        self.clock[0] += seconds

    def _now(self):
        return self.clock[0]

    def test_recovers_after_backoff_no_timeout_hit(self):
        responses = [
            _completed_process(returncode=1, stdout_obj=None, stderr="hit your session limit"),
            _completed_process(stdout_obj={"is_error": False, "structured_output": {"ok": True}}),
        ]
        with patch("src.work_graph_execute.subprocess.run", side_effect=responses):
            result = work_graph_execute.execute_work_graph_with_usage_limit_retry(
                "/tmp/p", "run-1", {}, self.SCHEMA, sleep=self._sleep, now=self._now
            )
        self.assertEqual(result, {"ok": True})
        self.assertEqual(self.sleeps, [5 * 60])  # first backoff step

    def test_backoff_schedule_escalates_then_holds_at_30(self):
        # 5 failures before success -> backoff steps 5,10,20,30,30 minutes
        responses = [
            _completed_process(returncode=1, stdout_obj=None, stderr="hit your session limit")
        ] * 5 + [_completed_process(stdout_obj={"is_error": False, "structured_output": {"ok": True}})]
        with patch("src.work_graph_execute.subprocess.run", side_effect=responses):
            work_graph_execute.execute_work_graph_with_usage_limit_retry(
                "/tmp/p", "run-1", {}, self.SCHEMA, sleep=self._sleep, now=self._now
            )
        self.assertEqual(self.sleeps, [5 * 60, 10 * 60, 20 * 60, 30 * 60, 30 * 60])

    def test_session_timeout_after_8_hours_raises_timeout_error(self):
        def always_fails(*args, **kwargs):
            return _completed_process(returncode=1, stdout_obj=None, stderr="hit your session limit")

        with patch("src.work_graph_execute.subprocess.run", side_effect=always_fails):
            with self.assertRaises(work_graph_execute.UsageLimitWaitTimeoutError):
                work_graph_execute.execute_work_graph_with_usage_limit_retry(
                    "/tmp/p", "run-1", {}, self.SCHEMA, sleep=self._sleep, now=self._now
                )
        self.assertGreaterEqual(self.clock[0], 8 * 3600)

    def test_opus_quota_raises_timeout_error_immediately_no_backoff(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                returncode=1, stdout_obj=None, stderr="opus model quota exhausted"
            )
            with self.assertRaises(work_graph_execute.UsageLimitWaitTimeoutError):
                work_graph_execute.execute_work_graph_with_usage_limit_retry(
                    "/tmp/p", "run-1", {}, self.SCHEMA, sleep=self._sleep, now=self._now
                )
        self.assertEqual(self.sleeps, [])

    def test_classifier_blocked_is_not_retried(self):
        with patch("src.work_graph_execute.subprocess.run") as run:
            run.return_value = _completed_process(
                stdout_obj={"is_error": True, "permission_denials": ["a", "b", "c"]}
            )
            with self.assertRaises(work_graph_execute.ClassifierBlockedError):
                work_graph_execute.execute_work_graph_with_usage_limit_retry(
                    "/tmp/p", "run-1", {}, self.SCHEMA, sleep=self._sleep, now=self._now
                )
        self.assertEqual(self.sleeps, [])
        self.assertEqual(run.call_count, 1)


if __name__ == "__main__":
    unittest.main()