test_work_node_run.py 10.9 KB
"""U-013 tests: c_work_node_run's contract (schema validation, retry state
machine, quota-wait, execution_log_file lifecycle) with a fake `run_fn`, plus
real git worktree isolation + merge -- REQ-03's boundary case (two independent
nodes, separate worktrees, merge without conflict) needs no LLM call at all,
so it's tested for real here, not mocked.

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

import json
import os
import subprocess
import tempfile
import unittest

from src import schema, work_node_run

_IN_SCHEMA = {"type": "object", "required": ["goal"]}
_OUT_SCHEMA = {"type": "object", "required": ["value"]}


def _init_git_repo(path):
    subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
    with open(os.path.join(path, "README.md"), "w") as f:
        f.write("seed\n")
    subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True)
    subprocess.run(
        ["git"] + work_node_run._GIT_IDENTITY + ["commit", "-m", "seed"],
        cwd=path, check=True, capture_output=True,
    )


class WorkNodeRunTestBase(unittest.TestCase):
    def setUp(self):
        self.conn = schema.connect(":memory:")
        schema.create_schema(self.conn)
        self.conn.execute(
            "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
            "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
        )
        self.conn.execute(
            "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
            "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')"
        )
        self.conn.execute(
            "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
            "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')"
        )
        self.conn.execute(
            "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
            "VALUES ('skill-1', 'coder', 'skills/skill-1/', 'sha', 'new', 'active')"
        )
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'pending')"
        )
        self.conn.commit()

    def tearDown(self):
        self.conn.close()


class InputValidationTest(WorkNodeRunTestBase):
    def test_invalid_input_rejected_before_execution_starts(self):
        with self.assertRaises(work_node_run.InvalidInputError):
            work_node_run.run_work_node(
                self.conn, "node-1", {}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd: {"value": 1}
            )
        status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
        self.assertEqual(status, "pending")
        count = self.conn.execute("SELECT COUNT(*) FROM execution_log_file").fetchone()[0]
        self.assertEqual(count, 0)


class HappyPathTest(WorkNodeRunTestBase):
    def test_valid_output_marks_node_done(self):
        output = work_node_run.run_work_node(
            self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd: {"value": 42}
        )
        self.assertEqual(output, {"value": 42})
        row = self.conn.execute(
            "SELECT status, output_payload, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(row[0], "done")
        self.assertEqual(json.loads(row[1]), {"value": 42})
        self.assertEqual(row[2], 0)

    def test_execution_log_file_created_active_then_closed(self):
        seen_status_during_run = []

        def run_fn(cwd):
            seen_status_during_run.append(
                self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0]
            )
            return {"value": 1}

        work_node_run.run_work_node(self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn)
        self.assertEqual(seen_status_during_run, ["active"])
        final_status = self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0]
        self.assertEqual(final_status, "closed")

    def test_log_file_closed_even_when_run_fn_raises(self):
        def boom(cwd):
            raise RuntimeError("node blew up")

        with self.assertRaises(RuntimeError):
            work_node_run.run_work_node(self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=boom)
        final_status = self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0]
        self.assertEqual(final_status, "closed")


class RetryOnSchemaFailureTest(WorkNodeRunTestBase):
    def test_retries_then_succeeds(self):
        attempts = {"n": 0}

        def run_fn(cwd):
            attempts["n"] += 1
            if attempts["n"] < 3:
                return {"wrong_key": True}
            return {"value": "ok"}

        output = work_node_run.run_work_node(
            self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn, max_retries=2
        )
        self.assertEqual(output, {"value": "ok"})
        row = self.conn.execute(
            "SELECT status, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(row, ("done", 2))

    def test_exceeding_max_retries_blocks_the_node(self):
        with self.assertRaises(work_node_run.SchemaValidationFailedError):
            work_node_run.run_work_node(
                self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA,
                run_fn=lambda cwd: {"wrong_key": True}, max_retries=2,
            )
        row = self.conn.execute(
            "SELECT status, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(row, ("blocked", 3))


class UsageLimitWaitTest(WorkNodeRunTestBase):
    def setUp(self):
        super().setUp()
        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_session_limit_then_recovers(self):
        attempts = {"n": 0}

        def run_fn(cwd):
            attempts["n"] += 1
            if attempts["n"] == 1:
                work_node_run.raise_if_usage_limited("Error: You've hit your session limit")
            return {"value": "recovered"}

        output = work_node_run.run_work_node(
            self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
            sleep=self._sleep, now=self._now,
        )
        self.assertEqual(output, {"value": "recovered"})
        self.assertEqual(self.sleeps, [5 * 60])

        row = self.conn.execute(
            "SELECT quota_limit_type FROM work_node_execution WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(row[0], "session")
        event = self.conn.execute(
            "SELECT event_type FROM execution_event WHERE node_exec_id = 'node-1'"
        ).fetchone()
        self.assertEqual(event[0], "quota_wait_started")

    def test_opus_quota_raises_immediately_no_backoff(self):
        def run_fn(cwd):
            work_node_run.raise_if_usage_limited("You've hit your Opus limit")

        with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError):
            work_node_run.run_work_node(
                self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
                sleep=self._sleep, now=self._now,
            )
        self.assertEqual(self.sleeps, [])

    def test_session_timeout_after_8_hours_blocks_node(self):
        def run_fn(cwd):
            work_node_run.raise_if_usage_limited("You've hit your session limit")

        with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError):
            work_node_run.run_work_node(
                self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
                sleep=self._sleep, now=self._now,
            )
        self.assertGreaterEqual(self.clock[0], 8 * 3600)
        status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
        self.assertEqual(status, "blocked")


class RealGitWorktreeIsolationTest(WorkNodeRunTestBase):
    """REQ-03 boundary: two independent nodes, each in its own git worktree,
    merged back with no conflict. Real git, no LLM call needed."""

    def setUp(self):
        super().setUp()
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        _init_git_repo(self.tmp.name)
        self.conn.execute(
            "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
            "input_schema_ref, output_schema_ref, input_payload, status) "
            "VALUES ('node-2', 'run-1', 'n2', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'pending')"
        )
        self.conn.commit()

    def _write_file_run_fn(self, filename, content):
        def run_fn(cwd):
            with open(os.path.join(cwd, filename), "w") as f:
                f.write(content)
            subprocess.run(["git", "add", filename], cwd=cwd, check=True, capture_output=True)
            subprocess.run(
                ["git"] + work_node_run._GIT_IDENTITY + ["commit", "-m", "add " + filename],
                cwd=cwd, check=True, capture_output=True,
            )
            return {"value": filename}

        return run_fn

    def test_two_independent_nodes_isolated_and_merged_without_conflict(self):
        work_node_run.run_work_node(
            self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA,
            run_fn=self._write_file_run_fn("module_a.txt", "module a content"),
            needs_worktree=True, repo_path=self.tmp.name,
        )
        work_node_run.run_work_node(
            self.conn, "node-2", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA,
            run_fn=self._write_file_run_fn("module_b.txt", "module b content"),
            needs_worktree=True, repo_path=self.tmp.name,
        )

        self.assertTrue(os.path.isfile(os.path.join(self.tmp.name, "module_a.txt")))
        self.assertTrue(os.path.isfile(os.path.join(self.tmp.name, "module_b.txt")))

        row1 = self.conn.execute(
            "SELECT git_worktree_ref FROM work_node_execution WHERE node_exec_id = 'node-1'"
        ).fetchone()[0]
        row2 = self.conn.execute(
            "SELECT git_worktree_ref FROM work_node_execution WHERE node_exec_id = 'node-2'"
        ).fetchone()[0]
        self.assertNotEqual(row1, row2)
        self.assertIn("node-1", row1)
        self.assertIn("node-2", row2)

        log = subprocess.run(
            ["git", "log", "--oneline"], cwd=self.tmp.name, check=True, capture_output=True, text=True
        )
        self.assertIn("module_a.txt", log.stdout)
        self.assertIn("module_b.txt", log.stdout)


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