tasks-toolkit 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. scripts/__init__.py +0 -0
  2. scripts/agents_sync.py +61 -0
  3. scripts/apply-plan.sh +51 -0
  4. scripts/build.sh +7 -0
  5. scripts/codex-task.sh +198 -0
  6. scripts/install-hooks.sh +9 -0
  7. scripts/junie-status.sh +52 -0
  8. scripts/junie-task.sh +183 -0
  9. scripts/kilo-task.sh +226 -0
  10. scripts/needs-dotnet-checks.sh +24 -0
  11. scripts/plan-sprint.sh +92 -0
  12. scripts/run-task.sh +79 -0
  13. scripts/run.sh +7 -0
  14. scripts/run_stats.py +141 -0
  15. scripts/run_summary.py +94 -0
  16. scripts/spec-lint.sh +139 -0
  17. scripts/sprint-auto.sh +79 -0
  18. scripts/sprint_auto_execute.py +160 -0
  19. scripts/sprint_auto_preflight.py +309 -0
  20. scripts/sprint_auto_prepare.py +271 -0
  21. scripts/sprint_auto_resume.py +153 -0
  22. scripts/sprint_auto_state.py +191 -0
  23. scripts/sprint_plan.py +238 -0
  24. scripts/task-report.sh +45 -0
  25. scripts/tasks.py +1813 -0
  26. scripts/tasks_cli.py +14 -0
  27. scripts/tasks_commands.py +11 -0
  28. scripts/tasks_init.py +140 -0
  29. scripts/tasks_mcp.py +309 -0
  30. scripts/tasks_model.py +173 -0
  31. scripts/tasks_plan_import.py +269 -0
  32. scripts/tasks_render.py +148 -0
  33. scripts/tasks_storage.py +373 -0
  34. scripts/tasks_watch.py +159 -0
  35. scripts/test.sh +43 -0
  36. scripts/test_agent_config.py +97 -0
  37. scripts/test_plan_sprint.sh +172 -0
  38. scripts/test_python_fast.sh +26 -0
  39. scripts/test_python_lint.sh +22 -0
  40. scripts/test_python_slow.sh +14 -0
  41. scripts/test_run_stats.py +64 -0
  42. scripts/test_runners.sh +588 -0
  43. scripts/test_spec_lint.sh +438 -0
  44. scripts/test_sprint_auto.py +50 -0
  45. scripts/test_sprint_auto_execute.py +112 -0
  46. scripts/test_sprint_auto_preflight.py +143 -0
  47. scripts/test_sprint_auto_prepare.py +150 -0
  48. scripts/test_sprint_auto_resume.py +104 -0
  49. scripts/test_sprint_auto_state.py +157 -0
  50. scripts/test_tasks.py +1678 -0
  51. scripts/test_tasks_mcp.py +233 -0
  52. scripts/test_tasks_plan_import.py +129 -0
  53. scripts/test_tasks_storage.py +67 -0
  54. scripts/test_tasks_watch.py +239 -0
  55. scripts/test_worktree.sh +2112 -0
  56. scripts/worktree.sh +992 -0
  57. scripts/worktree_mr.py +133 -0
  58. scripts/worktree_path.py +70 -0
  59. scripts/worktree_sprint.py +70 -0
  60. tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
  61. tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
  62. tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
  63. tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
  64. tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
  65. tasks_toolkit-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,143 @@
1
+ """Tests for scripts/sprint_auto_preflight.py.
2
+
3
+ Run: PYTHONPATH=scripts python3 -m unittest test_sprint_auto_preflight
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ import sprint_auto_preflight as sap
17
+
18
+
19
+ class TestCollectPreflight(unittest.TestCase):
20
+
21
+ def setUp(self):
22
+ self.tmp = Path(tempfile.mkdtemp())
23
+ (self.tmp / "tasks").mkdir()
24
+ self.root = self.tmp
25
+
26
+ def tearDown(self):
27
+ import shutil
28
+ shutil.rmtree(self.tmp, ignore_errors=True)
29
+
30
+ def test_unreviewed_goal_returns_question(self):
31
+ result = sap.collect_preflight(self.root, {"goal": "test"}, {})
32
+ ids = [q["id"] for q in result["questions"]]
33
+ self.assertIn("goal_review", ids)
34
+
35
+ def test_missing_answers_exits_2_probe(self):
36
+ proc = subprocess.run(
37
+ [sys.executable, "-B", str(Path(__file__).resolve().parent / "sprint_auto_preflight.py"),
38
+ "preflight-probe", "--goal", "Inspect hashing behavior"],
39
+ capture_output=True, text=True, cwd=str(Path(__file__).resolve().parent.parent),
40
+ )
41
+ self.assertEqual(proc.returncode, 2)
42
+ data = json.loads(proc.stdout)
43
+ self.assertTrue(len(data["questions"]) > 0)
44
+ # no writes: stdout contains the full unresolved batch
45
+ self.assertIn("goal_review", [q["id"] for q in data["questions"]])
46
+
47
+ def test_forbidden_env_spec_lint_off(self):
48
+ os.environ["SPEC_LINT"] = "off"
49
+ try:
50
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {})
51
+ self.assertTrue(any("SPEC_LINT" in b for b in result["blockers"]))
52
+ finally:
53
+ del os.environ["SPEC_LINT"]
54
+
55
+ def test_forbidden_env_accept_checks(self):
56
+ os.environ["ACCEPT_CHECKS_CMD"] = "exit 0"
57
+ try:
58
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {})
59
+ self.assertTrue(any("ACCEPT_CHECKS_CMD" in b for b in result["blockers"]))
60
+ finally:
61
+ del os.environ["ACCEPT_CHECKS_CMD"]
62
+
63
+ def test_forbidden_env_verify_cmds(self):
64
+ for var in ("JUNIE_VERIFY_CMD", "KILO_VERIFY_CMD", "CODEX_VERIFY_CMD"):
65
+ os.environ[var] = "true"
66
+ try:
67
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {})
68
+ self.assertTrue(any(var in b for b in result["blockers"]), var)
69
+ finally:
70
+ del os.environ[var]
71
+
72
+ def test_allowed_scopes_empty_returns_question(self):
73
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {"goal_reviewed": True})
74
+ ids = [q["id"] for q in result["questions"]]
75
+ self.assertIn("allowed_scopes", ids)
76
+
77
+ def test_invalid_scope_blocker(self):
78
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {
79
+ "goal_reviewed": True,
80
+ "allowed_scopes": ["../escape", "/abs"],
81
+ })
82
+ self.assertTrue(any("invalid scope" in b for b in result["blockers"]))
83
+
84
+ def test_delegated_default(self):
85
+ answers = {
86
+ "goal_reviewed": True,
87
+ "allowed_scopes": ["src/"],
88
+ "decisions": [{"id": "runner", "delegated": True, "safe_default": "junie", "rationale": "default"}],
89
+ }
90
+ result = sap.collect_preflight(self.root, {"goal": "g"}, answers)
91
+ self.assertTrue(any(d["id"] == "runner" for d in result["delegated_defaults"]))
92
+
93
+ def test_missing_policy_returns_questions(self):
94
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {
95
+ "goal_reviewed": True,
96
+ "allowed_scopes": ["src/"],
97
+ })
98
+ ids = [q["id"] for q in result["questions"]]
99
+ self.assertIn("runner", ids)
100
+
101
+ def test_approval_scope_structure(self):
102
+ result = sap.collect_preflight(self.root, {"goal": "my goal"}, {
103
+ "goal_reviewed": True,
104
+ "allowed_scopes": ["src/"],
105
+ "policy": {"runner": "junie", "serial": True, "push": False},
106
+ })
107
+ scope = result["approval_scope"]
108
+ self.assertEqual(scope["goal"], "my goal")
109
+ self.assertTrue(scope["goal_reviewed"])
110
+ self.assertEqual(scope["allowed_scopes"], ["src/"])
111
+
112
+ def test_no_writes_to_root(self):
113
+ before = set(self.root.rglob("*"))
114
+ sap.collect_preflight(self.root, {"goal": "g"}, {})
115
+ after = set(self.root.rglob("*"))
116
+ self.assertEqual(before, after)
117
+
118
+ def test_git_staged_generated_blocker(self):
119
+ # create a git repo with staged generated file
120
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=self.root, check=True)
121
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=self.root, check=True)
122
+ subprocess.run(["git", "config", "user.name", "t"], cwd=self.root, check=True)
123
+ (self.root / "tasks").mkdir(exist_ok=True)
124
+ (self.root / "tasks" / "tasks.snapshot.json").write_text("{}")
125
+ subprocess.run(["git", "add", "tasks/tasks.snapshot.json"], cwd=self.root, check=True)
126
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {})
127
+ self.assertTrue(any("staged generated" in b for b in result["blockers"]))
128
+
129
+ def test_sqlite_ro_mode(self):
130
+ db = self.root / "tasks" / "tasks.db"
131
+ db.parent.mkdir(exist_ok=True)
132
+ import sqlite3
133
+ conn = sqlite3.connect(str(db))
134
+ conn.execute("CREATE TABLE nodes (id TEXT)")
135
+ conn.commit()
136
+ conn.close()
137
+ # should not raise
138
+ result = sap.collect_preflight(self.root, {"goal": "g"}, {})
139
+ self.assertIsInstance(result, dict)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main()
@@ -0,0 +1,150 @@
1
+ """Tests for scripts/sprint_auto_prepare.py.
2
+
3
+ Run: PYTHONPATH=scripts python3 -m unittest test_sprint_auto_prepare
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ import sprint_auto_prepare as sap
17
+ import tasks
18
+ import tasks_storage
19
+ import tasks_plan_import as tpi
20
+
21
+
22
+ class PrepareTests(unittest.TestCase):
23
+
24
+ def setUp(self):
25
+ self.tmp = Path(tempfile.mkdtemp())
26
+ os.environ["VIBE_TASKS_ROOT"] = str(self.tmp)
27
+ subprocess.run(
28
+ [sys.executable, str(Path(__file__).resolve().parent / "tasks_init.py"),
29
+ "--path", str(self.tmp / "tasks" / "tasks.db"), "--project", "T"],
30
+ check=True, capture_output=True,
31
+ )
32
+ # init git repo
33
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=self.tmp, check=True)
34
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=self.tmp, check=True)
35
+ subprocess.run(["git", "config", "user.name", "t"], cwd=self.tmp, check=True)
36
+ (self.tmp / "README.md").write_text("init")
37
+ (self.tmp / ".gitignore").write_text("tmp/\ntasks/*.db\ntasks/.lock\ntasks/removed/\n")
38
+ # copy worktree.sh for branch creation
39
+ scripts_dir = self.tmp / "scripts"
40
+ scripts_dir.mkdir(parents=True, exist_ok=True)
41
+ import shutil
42
+ shutil.copy(str(Path(__file__).resolve().parent.parent / "scripts" / "worktree.sh"), str(scripts_dir / "worktree.sh"))
43
+ shutil.copy(str(Path(__file__).resolve().parent.parent / "scripts" / "needs-dotnet-checks.sh"), str(scripts_dir / "needs-dotnet-checks.sh"))
44
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp, check=True)
45
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=self.tmp, check=True)
46
+ self.root = self.tmp
47
+ self.answers = {
48
+ "goal_reviewed": True,
49
+ "allowed_scopes": ["scripts/"],
50
+ "policy": {"runner": "codex", "serial": True, "push": False,
51
+ "fallback": "none", "retries": 0, "accept_flags": False,
52
+ "reset": False, "clean": False},
53
+ }
54
+ self.plan = {
55
+ "sprint": {"slug": "test-prep", "title": "Test prep sprint", "goal": "Test"},
56
+ "tasks": [
57
+ {"key": "a", "title": "Add a", "description": "first",
58
+ "depends_on": [], "scope": ["scripts/a.sh"],
59
+ "spec_name": "test-a",
60
+ "spec": "# Task: a\n\n## Goal\nDo a.\n\n## Files\n- EDIT: scripts/a.sh\n\n## Tests\nscripts/test_a.sh\n\n## Acceptance\n`bash scripts/test_a.sh`\n\n## Live check\n`bash scripts/a.sh`\n"},
61
+ ],
62
+ }
63
+ self.plan_path = self.tmp / "tmp" / "plan.json"
64
+ self.plan_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self.plan_path.write_text(json.dumps(self.plan))
66
+
67
+ def tearDown(self):
68
+ import shutil
69
+ if "VIBE_TASKS_ROOT" in os.environ:
70
+ del os.environ["VIBE_TASKS_ROOT"]
71
+ shutil.rmtree(self.tmp, ignore_errors=True)
72
+
73
+ def _run_prepare(self, run_name="run-1", plan_path=None, goal="Test goal"):
74
+ pp = plan_path or self.plan_path
75
+ ans_path = self.tmp / "tmp" / "answers.json"
76
+ ans_path.parent.mkdir(parents=True, exist_ok=True)
77
+ ans_path.write_text(json.dumps(self.answers))
78
+ return subprocess.run(
79
+ [sys.executable, str(Path(__file__).resolve().parent / "sprint_auto_prepare.py"),
80
+ "--root", str(self.tmp), "--run-name", run_name, "--goal", goal,
81
+ "--answers", str(ans_path), "--plan", str(pp)],
82
+ capture_output=True, text=True,
83
+ cwd=str(Path(__file__).resolve().parent.parent),
84
+ )
85
+
86
+ def test_valid_prepare(self):
87
+ proc = self._run_prepare()
88
+ self.assertEqual(proc.returncode, 0, proc.stderr)
89
+ result = json.loads(proc.stdout)
90
+ self.assertEqual(result["status"], "prepared")
91
+ self.assertIn("import_receipt", result)
92
+ self.assertFalse(result["import_receipt"]["replay"])
93
+
94
+ def test_resume_after_import_before_branch(self):
95
+ # First prepare creates sprint + branch
96
+ proc = self._run_prepare()
97
+ self.assertEqual(proc.returncode, 0, proc.stderr)
98
+ result1 = json.loads(proc.stdout)
99
+ sprint_id = result1["import_receipt"]["sprint_id"]
100
+ # Verify tracker has the sprint
101
+ tasks.DATA_FILE = self.tmp / "tasks" / "tasks.db"
102
+ tasks_storage.DATA_FILE = tasks.DATA_FILE
103
+ conn = tasks.connect()
104
+ node = conn.execute("SELECT id, title FROM nodes WHERE id=?", (sprint_id,)).fetchone()
105
+ self.assertIsNotNone(node)
106
+ conn.close()
107
+
108
+ def test_invalid_plan_missing_title(self):
109
+ bad_plan = json.loads(self.plan_path.read_text())
110
+ bad_plan["sprint"]["title"] = ""
111
+ bad_path = self.tmp / "tmp" / "bad_plan.json"
112
+ bad_path.parent.mkdir(parents=True, exist_ok=True)
113
+ bad_path.write_text(json.dumps(bad_plan))
114
+ proc = self._run_prepare(plan_path=bad_path)
115
+ self.assertNotEqual(proc.returncode, 0)
116
+ result = json.loads(proc.stdout)
117
+ self.assertEqual(result["status"], "invalid_plan")
118
+
119
+ def test_scope_violation(self):
120
+ bad_plan = json.loads(self.plan_path.read_text())
121
+ bad_plan["tasks"][0]["scope"] = ["/etc/passwd"]
122
+ bad_path = self.tmp / "tmp" / "bad_scope.json"
123
+ bad_path.parent.mkdir(parents=True, exist_ok=True)
124
+ bad_path.write_text(json.dumps(bad_plan))
125
+ proc = self._run_prepare(plan_path=bad_path)
126
+ # scope violation should be caught (path validation in preflight or scope check)
127
+ # The scope /etc/passwd starts with / which is invalid
128
+ self.assertNotEqual(proc.returncode, 0)
129
+
130
+ def test_missing_answers(self):
131
+ self.answers = {} # no goal_reviewed
132
+ proc = self._run_prepare()
133
+ self.assertNotEqual(proc.returncode, 0)
134
+ result = json.loads(proc.stdout)
135
+ self.assertIn("needs_answers", result["status"])
136
+
137
+ def test_no_plan(self):
138
+ proc = subprocess.run(
139
+ [sys.executable, str(Path(__file__).resolve().parent / "sprint_auto_prepare.py"),
140
+ "--root", str(self.tmp), "--run-name", "run-x", "--goal", "g",
141
+ "--answers", str(self.tmp / "answers.json")],
142
+ capture_output=True, text=True,
143
+ cwd=str(Path(__file__).resolve().parent.parent),
144
+ )
145
+ # no --plan and no existing plan file → needs_plan
146
+ self.assertNotEqual(proc.returncode, 0)
147
+
148
+
149
+ if __name__ == "__main__":
150
+ unittest.main()
@@ -0,0 +1,104 @@
1
+ """Tests for scripts/sprint_auto_resume.py.
2
+
3
+ Run: PYTHONPATH=scripts python3 -m unittest test_sprint_auto_resume
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ import sprint_auto_resume as sar
17
+ import sprint_auto_state as sas
18
+
19
+
20
+ class ResumeTests(unittest.TestCase):
21
+
22
+ def setUp(self):
23
+ self.tmp = Path(tempfile.mkdtemp())
24
+
25
+ def tearDown(self):
26
+ import shutil
27
+ shutil.rmtree(self.tmp, ignore_errors=True)
28
+
29
+ def test_reconcile_no_checkpoint(self):
30
+ result = sar.reconcile_checkpoint(self.tmp, "nonexistent")
31
+ self.assertEqual(result["status"], "no_checkpoint")
32
+
33
+ def test_reconcile_basic(self):
34
+ state = sas.RunState(run_name="r1", operation_intent="execute_task:a")
35
+ sas.write_checkpoint(self.tmp / "state.json", state)
36
+ # Need state at the right path
37
+ sp = sas.state_path(self.tmp, "r1")
38
+ sp.parent.mkdir(parents=True, exist_ok=True)
39
+ sas.write_checkpoint(sp, state)
40
+ result = sar.reconcile_checkpoint(self.tmp, "r1")
41
+ self.assertEqual(result["status"], "reconciled")
42
+ self.assertIn("resume task execution", result["actions"][0] if result["actions"] else "")
43
+
44
+ def test_reconcile_child_running(self):
45
+ import time
46
+ proc = subprocess.Popen(["sleep", "30"], start_new_session=True)
47
+ state = sas.RunState(run_name="r2", child_pid=proc.pid)
48
+ sp = sas.state_path(self.tmp, "r2")
49
+ sp.parent.mkdir(parents=True, exist_ok=True)
50
+ sas.write_checkpoint(sp, state)
51
+ result = sar.reconcile_checkpoint(self.tmp, "r2")
52
+ self.assertEqual(result["status"], "child_running")
53
+ self.assertTrue(result["child_alive"])
54
+ proc.kill()
55
+ proc.wait()
56
+
57
+ def test_finish_guard_ok(self):
58
+ ok, reason = sar.finish_guard(self.tmp, "r1", "", "")
59
+ self.assertTrue(ok)
60
+
61
+ def test_recognize_existing_merge_no_branch(self):
62
+ # No git repo → False
63
+ self.assertFalse(sar.recognize_existing_merge(self.tmp, "nonexistent"))
64
+
65
+ def test_recognize_existing_merge_with_git(self):
66
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=self.tmp, check=True)
67
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=self.tmp, check=True)
68
+ subprocess.run(["git", "config", "user.name", "t"], cwd=self.tmp, check=True)
69
+ (self.tmp / "f.txt").write_text("x")
70
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp, check=True)
71
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=self.tmp, check=True)
72
+ # Create and merge a branch
73
+ subprocess.run(["git", "checkout", "-q", "-b", "sprint/1-test"], cwd=self.tmp, check=True)
74
+ (self.tmp / "s.txt").write_text("sprint")
75
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp, check=True)
76
+ subprocess.run(["git", "commit", "-q", "-m", "sprint work"], cwd=self.tmp, check=True)
77
+ subprocess.run(["git", "checkout", "-q", "main"], cwd=self.tmp, check=True)
78
+ subprocess.run(["git", "merge", "--no-ff", "sprint/1-test", "-m", "merge"], cwd=self.tmp, check=True)
79
+ self.assertTrue(sar.recognize_existing_merge(self.tmp, "sprint/1-test"))
80
+
81
+ def test_reconcile_merge_exists(self):
82
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=self.tmp, check=True)
83
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=self.tmp, check=True)
84
+ subprocess.run(["git", "config", "user.name", "t"], cwd=self.tmp, check=True)
85
+ (self.tmp / "f.txt").write_text("x")
86
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp, check=True)
87
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=self.tmp, check=True)
88
+ subprocess.run(["git", "checkout", "-q", "-b", "sprint/1-test"], cwd=self.tmp, check=True)
89
+ (self.tmp / "s.txt").write_text("sprint")
90
+ subprocess.run(["git", "add", "-A"], cwd=self.tmp, check=True)
91
+ subprocess.run(["git", "commit", "-q", "-m", "sprint work"], cwd=self.tmp, check=True)
92
+ subprocess.run(["git", "checkout", "-q", "main"], cwd=self.tmp, check=True)
93
+ subprocess.run(["git", "merge", "--no-ff", "sprint/1-test", "-m", "merge"], cwd=self.tmp, check=True)
94
+ state = sas.RunState(run_name="r3", operation_intent="finish", expected_refs={"sprint_branch": "sprint/1-test"})
95
+ sp = sas.state_path(self.tmp, "r3")
96
+ sp.parent.mkdir(parents=True, exist_ok=True)
97
+ sas.write_checkpoint(sp, state)
98
+ result = sar.reconcile_checkpoint(self.tmp, "r3")
99
+ self.assertTrue(result["merge_exists"])
100
+ self.assertIn("post-merge verification", " ".join(result["actions"]))
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -0,0 +1,157 @@
1
+ """Tests for scripts/sprint_auto_state.py.
2
+
3
+ Run: PYTHONPATH=scripts python3 -m unittest test_sprint_auto_state
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ import sprint_auto_state as sas
19
+
20
+
21
+ class StateTests(unittest.TestCase):
22
+
23
+ def setUp(self):
24
+ self.tmp = Path(tempfile.mkdtemp())
25
+
26
+ def tearDown(self):
27
+ import shutil
28
+ shutil.rmtree(self.tmp, ignore_errors=True)
29
+
30
+ def test_write_read_roundtrip(self):
31
+ st = sas.RunState(run_name="r1", goal="g")
32
+ p = self.tmp / "state.json"
33
+ sas.write_checkpoint(p, st)
34
+ loaded = sas.read_checkpoint(p)
35
+ self.assertIsNotNone(loaded)
36
+ self.assertEqual(loaded.run_name, "r1")
37
+ self.assertEqual(loaded.goal, "g")
38
+
39
+ def test_atomic_replacement(self):
40
+ p = self.tmp / "state.json"
41
+ sas.write_checkpoint(p, sas.RunState(run_name="v1"))
42
+ # interrupt mid-write by writing partial then replacing
43
+ sas.write_checkpoint(p, sas.RunState(run_name="v2"))
44
+ loaded = sas.read_checkpoint(p)
45
+ self.assertEqual(loaded.run_name, "v2")
46
+
47
+ def test_truncated_state_returns_none(self):
48
+ p = self.tmp / "state.json"
49
+ p.write_text("{truncated")
50
+ self.assertIsNone(sas.read_checkpoint(p))
51
+
52
+ def test_unknown_version_returns_none(self):
53
+ p = self.tmp / "state.json"
54
+ p.write_text(json.dumps({"version": 999, "run_name": "x"}))
55
+ self.assertIsNone(sas.read_checkpoint(p))
56
+
57
+ def test_symlink_state_refused(self):
58
+ target = self.tmp / "real.json"
59
+ target.write_text("{}")
60
+ link = self.tmp / "link.json"
61
+ link.symlink_to(target)
62
+ with self.assertRaises(ValueError):
63
+ sas.write_checkpoint(link, sas.RunState())
64
+
65
+ def test_symlink_lock_refused(self):
66
+ target = self.tmp / "real.lock"
67
+ target.write_text("")
68
+ link = self.tmp / "link.lock"
69
+ link.symlink_to(target)
70
+ with self.assertRaises(ValueError):
71
+ with sas.StateLock(link):
72
+ pass
73
+
74
+ def test_exclusive_lock(self):
75
+ lock = self.tmp / "test.lock"
76
+ with sas.StateLock(lock):
77
+ # second acquire should block; use non-blocking
78
+ fh = open(lock, "w")
79
+ try:
80
+ fcntl_import = __import__("fcntl")
81
+ with self.assertRaises((BlockingIOError, OSError)):
82
+ fcntl_import.flock(fh, fcntl_import.LOCK_EX | fcntl_import.LOCK_NB)
83
+ finally:
84
+ fh.close()
85
+
86
+ def test_child_alive_false_no_pid(self):
87
+ st = sas.RunState(child_pid=0)
88
+ self.assertFalse(sas.is_child_alive(st))
89
+
90
+ def test_child_alive_true(self):
91
+ proc = subprocess.Popen(["sleep", "10"])
92
+ st = sas.RunState(child_pid=proc.pid)
93
+ self.assertTrue(sas.is_child_alive(st))
94
+ proc.kill()
95
+ proc.wait()
96
+
97
+ def test_supervised_run_success(self):
98
+ code, out = sas.supervised_run(["echo", "hello"], self.tmp, 10)
99
+ self.assertEqual(code, 0)
100
+ self.assertIn("hello", out)
101
+
102
+ def test_supervised_run_timeout(self):
103
+ code, out = sas.supervised_run(["sleep", "60"], self.tmp, 1)
104
+ self.assertNotEqual(code, 0)
105
+
106
+ def test_import_no_write(self):
107
+ # importing module should not create files
108
+ before = set(self.tmp.rglob("*"))
109
+ # re-import
110
+ import importlib
111
+ importlib.reload(sas)
112
+ after = set(self.tmp.rglob("*"))
113
+ self.assertEqual(before, after)
114
+
115
+ def test_parent_interruption_preserves_exclusive_child_ownership(self):
116
+ # spawn a child that holds a lock and sleeps
117
+ lock = self.tmp / "child.lock"
118
+ script = self.tmp / "child.py"
119
+ script.write_text(
120
+ "import fcntl, time, sys\n"
121
+ "fh = open(sys.argv[1], 'w')\n"
122
+ "fcntl.flock(fh, fcntl.LOCK_EX)\n"
123
+ "time.sleep(30)\n"
124
+ )
125
+ proc = subprocess.Popen(
126
+ [sys.executable, str(script), str(lock)],
127
+ start_new_session=True,
128
+ )
129
+ time.sleep(0.5)
130
+ # child should be alive and holding lock
131
+ self.assertTrue(sas.is_child_alive(sas.RunState(child_pid=proc.pid)))
132
+ # try to acquire lock non-blocking -> should fail
133
+ fh = open(lock, "w")
134
+ import fcntl
135
+ try:
136
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
137
+ self.fail("lock not held by child")
138
+ except (BlockingIOError, OSError):
139
+ pass
140
+ finally:
141
+ fh.close()
142
+ proc.kill()
143
+ proc.wait()
144
+
145
+ def test_bind_state(self):
146
+ st = sas.bind_state(
147
+ self.tmp, self.tmp, "run1", "goal", "fp", ["src/"], "abc123",
148
+ "junie", "model", 120,
149
+ )
150
+ self.assertEqual(st.run_name, "run1")
151
+ self.assertEqual(st.initial_main_sha, "abc123")
152
+ self.assertEqual(st.allowed_scopes, ["src/"])
153
+ self.assertEqual(st.version, sas.STATE_VERSION)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ unittest.main()