diff options
Diffstat (limited to 'tests/test_orch.py')
| -rw-r--r-- | tests/test_orch.py | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/tests/test_orch.py b/tests/test_orch.py new file mode 100644 index 0000000..83216b2 --- /dev/null +++ b/tests/test_orch.py @@ -0,0 +1,227 @@ +import json +import os +import subprocess +import unittest + +from test_cli import TOML, Cli +from test_claims import git +from test_merge import IDENT + +TASKS = """\ +# Tasks โ demo + +## Awaiting your decision + +## Pending + +- **t-one** [P1] (<1h): One. + - Done: one works + - Model: sonnet + +- **t-two** [P2] (<1h): Two. + - Done: two works + +- **t-raw** [P2] (<1h): Raw, no Done line. + +- **t-big** [P2] (1h): Big. + - Done: big works + +## Needs human + +## Deferred +""" + + +class OrchTest(Cli): + tasks_text = TASKS + toml = TOML.replace('verify = ["make test"]\n', "") + + def setUp(self): + super().setUp() + git(self.root, "init", "-q", "-b", "master") + (self.root / ".gitignore").write_text(".worktrees/\n.wf/\nout/\n") + git(self.root, "add", "-A") + git(self.root, "commit", "-qm", "init") + + def orch(self, *args, code=0): + got, out, err = self.wf("orch", *args, env=IDENT) + self.assertEqual(got, code, out + err) + self.assertEqual(err, "") + return out + + def run_wf(self, cwd, *args): + got, out, err = self.wf(*args, project=False, cwd=cwd, env=IDENT) + self.assertEqual(got, 0, out + err) + return out + + def record(self, id): + return json.loads((self.root / ".wf" / "orch" / f"{id}.json").read_text()) + + def worker_done(self, id="t-one", lane="fast", merge=True): + """What a worker does: wf start, code, wf done/finish (finish merges, done alone leaves the branch).""" + wt = self.root / ".worktrees" / lane + self.run_wf(self.root, "start", id, "--worktree", str(wt), "--branch", f"{lane}/{id}") + (wt / "code.txt").write_text("x\n") + if merge: + self.run_wf(wt, "finish", id, "-m", "ok", "--commit", "impl", "code.txt", "--no-push") + else: + git(wt, "add", "code.txt") + git(wt, "commit", "-qm", "impl") + self.run_wf(wt, "done", id, "-m", "ok") + return wt + + def log(self): + return (self.root / "out" / "wf-orch.log").read_text() + + def test_pick_claims_and_prints_prompt(self): + out = self.orch("pick", "fast") + wt = self.root / ".worktrees" / "fast" + self.assertEqual(out, "pick: t-one (lane fast, model sonnet, effort <1h) ยท claimed (in progress: worker)\n" + "agent: subagent_type wf-worker, model sonnet, no isolation; prompt:\n" + "Task: t-one Lane: fast Model: sonnet\n" + f"Main tree: {self.root} Worktree: {wt} Branch: fast/t-one\n" + "Final message: the 4 report lines only.\n") + self.assertIn("- **t-one** [P1] (<1h) (in progress: worker): One.", self.tasks()) + self.assertEqual(self.record("t-one")["worktree"], str(wt)) + + def test_second_pick_skips_in_progress_and_busy_worktree(self): + self.orch("pick", "fast") + out = self.orch("pick", "fast") + self.assertIn("pick: t-two (lane fast, model opus", out) + self.assertIn(f"Worktree: {self.root / '.worktrees' / 'fast-2'} Branch: fast/t-two\n", out) + + def test_worktree_on_other_branch_not_reused(self): + git(self.root, "worktree", "add", "-q", str(self.root / ".worktrees" / "fast"), "-b", "fast/t-old") + self.assertIn(".worktrees/fast-2 Branch: fast/t-one", self.orch("pick", "fast")) + + def test_clean_detached_worktree_reused(self): + git(self.root, "worktree", "add", "-q", "--detach", str(self.root / ".worktrees" / "fast"), "master") + self.assertIn(".worktrees/fast Branch: fast/t-one", self.orch("pick", "fast")) + + def test_worktree_with_live_session_not_reused(self): + wt = self.root / ".worktrees" / "fast" + git(self.root, "worktree", "add", "-q", "--detach", str(wt), "master") + folder = self.root.parent / "no-claude" / "sessions" + folder.mkdir(parents=True) + (folder / "1.json").write_text(json.dumps({"pid": os.getpid(), "cwd": str(wt / "docs")})) + self.assertIn(".worktrees/fast-2 Branch: fast/t-one", self.orch("pick", "fast")) + + def test_stop_file_spawns_nothing(self): + (self.root / "out").mkdir() + (self.root / "out" / "wf-batch.stop").write_text("") + self.assertEqual(self.orch("pick", "fast"), "stop: out/wf-batch.stop exists: spawn nothing (let running workers finish)\n") + self.assertNotIn("in progress", self.tasks()) + + def test_none_pickable(self): + self.orch("pick", "fast") + self.orch("pick", "fast") + self.assertTrue(self.orch("pick", "fast").startswith("none: lane fast has no runner-ready task")) + + def test_explicit_id_and_recovery_line(self): + out = self.orch("pick", "slow", "--id", "t-big", "--recovery", "crashed") + self.assertIn("Branch: slow/t-big\nRecovery: crashed\nFinal message", out) + + def test_post_done_logs_and_picks_next(self): + self.orch("pick", "fast") + self.worker_done() + out = self.orch("post", "t-one", "fast", "--result", "done", "--commit", "abc1234", "--duration", "75") + self.assertTrue(out.startswith("post: t-one done\n\npick: t-two"), out) + self.assertRegex(self.log(), r"^\S+ fast sonnet t-one done abc1234 1m15s\n$") + self.assertFalse((self.root / ".wf" / "orch" / "t-one.json").exists()) + + def test_post_no_next_claims_nothing(self): + self.orch("pick", "fast") + self.worker_done() + out = self.orch("post", "t-one", "fast", "--result", "done", "--no-next", "--duration", "5") + self.assertTrue(out.startswith("post: t-one done"), out) + self.assertNotIn("pick:", out) + self.assertEqual([f.name for f in (self.root / ".wf" / "orch").glob("*.json")], []) + + def test_post_done_without_record_takes_model_from_history(self): + self.orch("pick", "fast") + self.worker_done() + (self.root / ".wf" / "orch" / "t-one.json").unlink() + self.orch("post", "t-one", "fast", "--result", "done", "--no-pick", "--duration", "5") + self.assertRegex(self.log(), r"^\S+ fast sonnet t-one done ") + + def test_post_done_merges_unmerged_branch(self): + self.orch("pick", "fast") + wt = self.worker_done(merge=False) + out = self.orch("post", "t-one", "fast", "--result", "done", "--no-pick", "--no-push") + self.assertEqual(out, "post: t-one done\n merged worktree HEAD: merged fast/t-one into master\n") + self.assertEqual(subprocess.run(["git", "-C", str(self.root), "show", "master:code.txt"], + capture_output=True, text=True).stdout, "x\n") + self.assertEqual(subprocess.run(["git", "-C", str(wt), "branch", "--show-current"], + capture_output=True, text=True).stdout, "") + + def test_post_done_without_archive_is_red(self): + self.orch("pick", "fast") + out = self.orch("post", "t-one", "fast", "--result", "done") + self.assertEqual(out, "post: t-one post-check-red\n no archive line for t-one\n" + "stop lane fast: post-check-red โ tell the owner\n") + self.assertIn(" post-check-red ", self.log()) + + def backdate_pick(self, id="t-one", secs=125): + f = self.root / ".wf" / "orch" / f"{id}.json" + rec = json.loads(f.read_text()) + rec["at"] -= secs + f.write_text(json.dumps(rec) + "\n") + + def test_post_zero_duration_falls_back_to_pick_time(self): + self.orch("pick", "fast") + self.backdate_pick() + self.worker_done() + self.orch("post", "t-one", "fast", "--result", "done", "--no-pick", "--duration", "0") + self.assertRegex(self.log(), r" t-one done \S+ 2m0[5-9]s\n$") + + def test_post_red_keeps_pick_time_for_the_repost(self): + self.orch("pick", "fast") + self.backdate_pick() + self.orch("post", "t-one", "fast", "--result", "done") + self.assertTrue((self.root / ".wf" / "orch" / "t-one.json").exists()) + self.worker_done() + self.orch("post", "t-one", "fast", "--result", "done", "--no-pick") + self.assertRegex(self.log().splitlines()[-1], r" t-one done \S+ 2m0[5-9]s$") + self.assertFalse((self.root / ".wf" / "orch" / "t-one.json").exists()) + + def test_post_handback_commits_leftovers_and_stops(self): + self.orch("pick", "fast") + out = self.orch("post", "t-one", "fast", "--result", "handback unclear spec") + self.assertEqual(out, "post: t-one handback\n committed leftover TASKS.md tasks/archive.md\n" + "stop lane fast: handback โ tell the owner\n") + self.assertEqual(subprocess.run(["git", "-C", str(self.root), "log", "-1", "--format=%s"], + capture_output=True, text=True).stdout, "t-one handback (orchestrator)\n") + + def test_post_done_on_slice_job_counts_as_sliced(self): + self.orch("pick", "slow", "--id", "t-big") + self.run_wf(self.root, "add", "--parent", "t-big", "-e", "<1h", "--done", "x", "Slice one") + out = self.orch("post", "t-big", "slow", "--result", "done", "--no-next", "--duration", "5") + self.assertTrue(out.startswith("post: t-big sliced"), out) + self.assertNotIn("post-check-red", self.log()) + self.assertIn(" t-big sliced ", self.log()) + + def test_post_model_raised_continues(self): + self.orch("pick", "fast") + self.wf("set", "t-one", "--model", "opus") + self.wf("status", "t-one", "clear") + out = self.orch("post", "t-one", "fast", "--result", "handback beyond sonnet") + self.assertTrue(out.startswith("post: t-one model-raised\n"), out) + self.assertIn("pick: t-one (lane fast, model opus", out) + + def test_post_no_report_suggests_recovery(self): + self.orch("pick", "fast") + self.assertIn('wf orch pick fast --id t-one --recovery "<why>"', self.orch("post", "t-one", "fast")) + + def test_post_agent_without_transcript_still_logs(self): + self.orch("pick", "fast") + out = self.orch("post", "t-one", "fast", "--result", "wip", "--agent", "abc") + self.assertIn(" cost: not logged (no transcript for agent abc)\n", out) + + def test_refused_in_linked_worktree(self): + git(self.root, "worktree", "add", "-q", "--detach", str(self.root / ".worktrees" / "x"), "master") + got, _, err = self.wf("orch", "pick", "fast", project=False, cwd=self.root / ".worktrees" / "x") + self.assertEqual((got, err), (1, "wf: orch runs in the main tree (the orchestrator's)\n")) + + +if __name__ == "__main__": + unittest.main() |
