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.
- scripts/__init__.py +0 -0
- scripts/agents_sync.py +61 -0
- scripts/apply-plan.sh +51 -0
- scripts/build.sh +7 -0
- scripts/codex-task.sh +198 -0
- scripts/install-hooks.sh +9 -0
- scripts/junie-status.sh +52 -0
- scripts/junie-task.sh +183 -0
- scripts/kilo-task.sh +226 -0
- scripts/needs-dotnet-checks.sh +24 -0
- scripts/plan-sprint.sh +92 -0
- scripts/run-task.sh +79 -0
- scripts/run.sh +7 -0
- scripts/run_stats.py +141 -0
- scripts/run_summary.py +94 -0
- scripts/spec-lint.sh +139 -0
- scripts/sprint-auto.sh +79 -0
- scripts/sprint_auto_execute.py +160 -0
- scripts/sprint_auto_preflight.py +309 -0
- scripts/sprint_auto_prepare.py +271 -0
- scripts/sprint_auto_resume.py +153 -0
- scripts/sprint_auto_state.py +191 -0
- scripts/sprint_plan.py +238 -0
- scripts/task-report.sh +45 -0
- scripts/tasks.py +1813 -0
- scripts/tasks_cli.py +14 -0
- scripts/tasks_commands.py +11 -0
- scripts/tasks_init.py +140 -0
- scripts/tasks_mcp.py +309 -0
- scripts/tasks_model.py +173 -0
- scripts/tasks_plan_import.py +269 -0
- scripts/tasks_render.py +148 -0
- scripts/tasks_storage.py +373 -0
- scripts/tasks_watch.py +159 -0
- scripts/test.sh +43 -0
- scripts/test_agent_config.py +97 -0
- scripts/test_plan_sprint.sh +172 -0
- scripts/test_python_fast.sh +26 -0
- scripts/test_python_lint.sh +22 -0
- scripts/test_python_slow.sh +14 -0
- scripts/test_run_stats.py +64 -0
- scripts/test_runners.sh +588 -0
- scripts/test_spec_lint.sh +438 -0
- scripts/test_sprint_auto.py +50 -0
- scripts/test_sprint_auto_execute.py +112 -0
- scripts/test_sprint_auto_preflight.py +143 -0
- scripts/test_sprint_auto_prepare.py +150 -0
- scripts/test_sprint_auto_resume.py +104 -0
- scripts/test_sprint_auto_state.py +157 -0
- scripts/test_tasks.py +1678 -0
- scripts/test_tasks_mcp.py +233 -0
- scripts/test_tasks_plan_import.py +129 -0
- scripts/test_tasks_storage.py +67 -0
- scripts/test_tasks_watch.py +239 -0
- scripts/test_worktree.sh +2112 -0
- scripts/worktree.sh +992 -0
- scripts/worktree_mr.py +133 -0
- scripts/worktree_path.py +70 -0
- scripts/worktree_sprint.py +70 -0
- tasks_toolkit-0.1.3.dist-info/METADATA +121 -0
- tasks_toolkit-0.1.3.dist-info/RECORD +65 -0
- tasks_toolkit-0.1.3.dist-info/WHEEL +5 -0
- tasks_toolkit-0.1.3.dist-info/entry_points.txt +4 -0
- tasks_toolkit-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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()
|