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,271 @@
1
+ """Prepare approved plans and branches for the automated sprint lifecycle (Sprint 12, task 12.4).
2
+
3
+ Advances an approved new run through planning, validated spec materialization,
4
+ atomic tracker import and creation/reconciliation of its exact sprint branch.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+ import sprint_auto_preflight as sap
16
+ import sprint_auto_state as sas
17
+ import tasks # noqa: E402
18
+ import tasks_plan_import as tpi # noqa: E402
19
+
20
+ RUNNER_DEFAULT = "codex"
21
+
22
+
23
+ def _run_git(root: Path, *args: str) -> str:
24
+ try:
25
+ r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30)
26
+ return r.stdout.strip() if r.returncode == 0 else ""
27
+ except (OSError, subprocess.TimeoutExpired):
28
+ return ""
29
+
30
+
31
+ def _main_sha(root: Path) -> str:
32
+ return _run_git(root, "rev-parse", "HEAD") or _run_git(root, "rev-parse", "main")
33
+
34
+
35
+ def prepare_run(root: Path, request: dict, answers: dict, run_name: str,
36
+ plan_path: Path | None = None) -> dict:
37
+ """Prepare a run: preflight recheck, plan, import, branch.
38
+
39
+ Returns a dict with keys: status, preflight, state, plan, import_receipt, branch.
40
+ Does not run agents or create artifacts before approval.
41
+ """
42
+ root = Path(root).resolve()
43
+ result: dict = {"status": "started", "run_name": run_name}
44
+
45
+ # 1. Preflight recheck (read-only, no writes)
46
+ preflight = sap.collect_preflight(root, request, answers)
47
+ result["preflight"] = preflight
48
+ if preflight["blockers"]:
49
+ result["status"] = "blocked"
50
+ return result
51
+ if preflight["questions"]:
52
+ result["status"] = "needs_answers"
53
+ return result
54
+
55
+ # 2. Obtain exclusive lock
56
+ lock = sas.acquire_lock(root, run_name)
57
+ with lock:
58
+ # Recheck preflight under lock
59
+ preflight2 = sap.collect_preflight(root, request, answers)
60
+ if preflight2["blockers"] or preflight2["questions"]:
61
+ result["status"] = "blocked_after_lock"
62
+ result["preflight"] = preflight2
63
+ return result
64
+
65
+ # 3. Bind state
66
+ git_common = Path(_run_git(root, "rev-parse", "--git-common-dir") or (root / ".git"))
67
+ if not git_common.is_absolute():
68
+ git_common = root / git_common
69
+ main_sha = _main_sha(root)
70
+ answers_fp = json.dumps(answers, sort_keys=True)
71
+ state = sas.bind_state(
72
+ root, git_common, run_name, request.get("goal", ""),
73
+ answers_fp, answers.get("allowed_scopes", []),
74
+ main_sha, RUNNER_DEFAULT, "", 120,
75
+ )
76
+ state.plan_fingerprint = ""
77
+ result["state"] = state.to_dict()
78
+
79
+ # 4. Plan: use existing plan if provided, else invoke planner
80
+ if plan_path and Path(plan_path).exists():
81
+ plan = json.loads(Path(plan_path).read_text())
82
+ else:
83
+ # Fresh planning attempt — invoke plan-sprint.sh
84
+ slug = run_name.lower().replace(" ", "-")
85
+ goal = request.get("goal", "")
86
+ plan_file = root / "tmp" / "plans" / f"{run_name}.json"
87
+ if not plan_file.exists():
88
+ # No existing plan — would invoke planner here
89
+ result["status"] = "needs_plan"
90
+ result["message"] = "No plan found; run plan-sprint.sh first"
91
+ return result
92
+ plan = json.loads(plan_file.read_text())
93
+
94
+ # Validate plan structure
95
+ sprint_info = plan.get("sprint", {})
96
+ if not isinstance(sprint_info, dict):
97
+ result["status"] = "invalid_plan"
98
+ result["message"] = "plan.sprint must be an object"
99
+ return result
100
+ if not sprint_info.get("slug") or not sprint_info.get("title"):
101
+ result["status"] = "invalid_plan"
102
+ result["message"] = "plan missing sprint slug/title"
103
+ return result
104
+ # slug format: lowercase words joined by single hyphens
105
+ import re
106
+ if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", sprint_info["slug"]):
107
+ result["status"] = "invalid_plan"
108
+ result["message"] = f"invalid sprint slug: {sprint_info['slug']!r}"
109
+ return result
110
+ tasks_list = plan.get("tasks", [])
111
+ if not isinstance(tasks_list, list) or not tasks_list:
112
+ result["status"] = "invalid_plan"
113
+ result["message"] = "plan has no tasks"
114
+ return result
115
+
116
+ # Validate each task
117
+ keys = set()
118
+ for i, t in enumerate(tasks_list):
119
+ if not isinstance(t, dict):
120
+ result["status"] = "invalid_plan"
121
+ result["message"] = f"task {i} is not an object"
122
+ return result
123
+ if not t.get("key") or not t.get("title"):
124
+ result["status"] = "invalid_plan"
125
+ result["message"] = f"task {i} missing key/title"
126
+ return result
127
+ key = t["key"]
128
+ if key in keys:
129
+ result["status"] = "invalid_plan"
130
+ result["message"] = f"duplicate task key: {key}"
131
+ return result
132
+ keys.add(key)
133
+ # spec_name format
134
+ spec_name = t.get("spec_name", "")
135
+ if spec_name and not re.match(r"^[a-z0-9][a-z0-9-]*$", spec_name):
136
+ result["status"] = "invalid_plan"
137
+ result["message"] = f"invalid spec_name: {spec_name!r}"
138
+ return result
139
+ # scope validation: no forbidden paths, no generated tracker paths
140
+ for scope in t.get("scope", []):
141
+ if not isinstance(scope, str) or ".." in scope or scope.startswith("/"):
142
+ result["status"] = "invalid_plan"
143
+ result["message"] = f"invalid scope: {scope!r}"
144
+ return result
145
+ if scope in ("tasks/tasks.db", "tasks/.lock", "kilo.json", ".mcp.json"):
146
+ result["status"] = "invalid_plan"
147
+ result["message"] = f"forbidden scope: {scope!r}"
148
+ return result
149
+ if scope.startswith("tasks/") and (scope.endswith(".md") or scope.endswith(".json")):
150
+ result["status"] = "invalid_plan"
151
+ result["message"] = f"generated tracker path in scope: {scope!r}"
152
+ return result
153
+
154
+ # Validate dependencies: must reference earlier keys
155
+ seen_keys = set()
156
+ for t in tasks_list:
157
+ for dep in t.get("depends_on", []):
158
+ if dep not in seen_keys:
159
+ result["status"] = "invalid_plan"
160
+ result["message"] = f"task {t['key']} depends on unknown/forward key: {dep}"
161
+ return result
162
+ seen_keys.add(t["key"])
163
+
164
+ # Check overlapping scopes (tasks with overlapping scopes must be ordered by dependency)
165
+ scope_map = {t["key"]: set(t.get("scope", [])) for t in tasks_list}
166
+ for i, t1 in enumerate(tasks_list):
167
+ for t2 in tasks_list[i + 1:]:
168
+ if t1["key"] in t2.get("depends_on", []):
169
+ continue
170
+ if t2["key"] in t1.get("depends_on", []):
171
+ continue
172
+ overlap = scope_map[t1["key"]] & scope_map[t2["key"]]
173
+ if overlap:
174
+ result["status"] = "invalid_plan"
175
+ result["message"] = f"overlapping scopes between unordered tasks {t1['key']} and {t2['key']}: {overlap}"
176
+ return result
177
+
178
+ # Bind plan fingerprint
179
+ state.plan_fingerprint = tpi.canonical_fingerprint(plan)
180
+ result["plan"] = plan
181
+
182
+ # 5. Validate scopes against approval
183
+ allowed = set(answers.get("allowed_scopes", []))
184
+ for t in plan.get("tasks", []):
185
+ for scope in t.get("scope", []):
186
+ if allowed and not any(scope.startswith(a) or a.startswith(scope) for a in allowed):
187
+ result["status"] = "scope_violation"
188
+ result["message"] = f"scope {scope!r} not in allowed scopes"
189
+ return result
190
+
191
+ # 6. Atomic import
192
+ run_id = f"{run_name}"
193
+ receipt_holder = {}
194
+
195
+ def action(conn):
196
+ r = tpi.import_plan(conn, plan, run_id, root)
197
+ receipt_holder.update(r)
198
+ return ""
199
+
200
+ tasks.mutate(action)
201
+ result["import_receipt"] = receipt_holder
202
+
203
+ # 7. Sprint branch creation/reconciliation
204
+ sprint_info = plan["sprint"]
205
+ sprint_n = None
206
+ # Try to extract sprint number from existing tracker
207
+ # For new sprints, we need to determine the number
208
+ sprint_id = receipt_holder.get("sprint_id", "")
209
+ # Extract number from sprint_id (e.g., "14" -> 14)
210
+ try:
211
+ sprint_n = int(sprint_id.split(".")[0])
212
+ except (ValueError, AttributeError):
213
+ sprint_n = 1
214
+
215
+ slug = sprint_info["slug"]
216
+ branch_name = f"sprint/{sprint_n}-{slug}"
217
+ existing_branch = _run_git(root, "rev-parse", "--verify", branch_name)
218
+ if existing_branch:
219
+ # Reconcile: verify ownership (branch must point to approved main SHA or descendant)
220
+ branch_sha = _run_git(root, "rev-parse", branch_name)
221
+ # Check if branch is at or ahead of main_sha
222
+ merge_base = _run_git(root, "merge-base", main_sha, branch_sha)
223
+ if merge_base == main_sha:
224
+ result["branch"] = {"name": branch_name, "action": "reconciled", "sha": branch_sha}
225
+ else:
226
+ result["status"] = "branch_ownership_unproven"
227
+ result["message"] = f"branch {branch_name} does not descend from approved main SHA"
228
+ return result
229
+ else:
230
+ # Create new branch from approved main SHA
231
+ create_result = subprocess.run(
232
+ ["bash", str(root / "scripts" / "worktree.sh"), "sprint", "start",
233
+ str(sprint_n), slug, "--from", main_sha],
234
+ capture_output=True, text=True, cwd=str(root),
235
+ )
236
+ if create_result.returncode != 0:
237
+ result["status"] = "branch_creation_failed"
238
+ result["message"] = create_result.stderr or create_result.stdout
239
+ return result
240
+ result["branch"] = {"name": branch_name, "action": "created", "sha": main_sha}
241
+
242
+ # Write checkpoint
243
+ sas.write_checkpoint(sas.state_path(root, run_name), state)
244
+ result["status"] = "prepared"
245
+ return result
246
+
247
+
248
+ def main() -> int:
249
+ import argparse
250
+ parser = argparse.ArgumentParser(description="Prepare approved plans and branches")
251
+ parser.add_argument("--root", default=".")
252
+ parser.add_argument("--run-name", required=True)
253
+ parser.add_argument("--goal", required=True)
254
+ parser.add_argument("--answers", required=True)
255
+ parser.add_argument("--plan", default=None)
256
+ args = parser.parse_args()
257
+
258
+ root = Path(args.root).resolve()
259
+ answers = json.loads(Path(args.answers).read_text())
260
+ request = {"goal": args.goal}
261
+ result = prepare_run(root, request, answers, args.run_name, args.plan)
262
+ print(json.dumps(result, indent=2))
263
+ if result["status"] in ("blocked", "blocked_after_lock", "needs_answers"):
264
+ return 4 if "blocked" in result["status"] else 2
265
+ if result["status"] in ("needs_plan", "invalid_plan", "scope_violation"):
266
+ return 1
267
+ return 0
268
+
269
+
270
+ if __name__ == "__main__":
271
+ sys.exit(main())
@@ -0,0 +1,153 @@
1
+ """Resume and finish owned sprints for the automated sprint lifecycle (Sprint 12, task 12.7).
2
+
3
+ Reconciles interrupted operations from checkpoint, tracker receipts and Git history.
4
+ Never recovers by resetting, cleaning, abandoning or blindly rerunning the agent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ import sprint_auto_state as sas
15
+
16
+
17
+ def _run_git(root: Path, *args: str) -> str:
18
+ try:
19
+ r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30)
20
+ return r.stdout.strip() if r.returncode == 0 else ""
21
+ except (OSError, subprocess.TimeoutExpired):
22
+ return ""
23
+
24
+
25
+ def reconcile_checkpoint(root: Path, run_name: str) -> dict:
26
+ """Reconcile interrupted operations from checkpoint, tracker, and git history.
27
+
28
+ Returns a dict with: status, state, child_alive, merge_exists, actions.
29
+ """
30
+ root = Path(root).resolve()
31
+ state = sas.read_checkpoint(sas.state_path(root, run_name))
32
+ if state is None:
33
+ return {"status": "no_checkpoint", "actions": ["create new state"]}
34
+
35
+ actions = []
36
+ result = {"state": state.to_dict(), "actions": actions}
37
+
38
+ # Check child process
39
+ child_alive = sas.is_child_alive(state)
40
+ result["child_alive"] = child_alive
41
+ if child_alive:
42
+ actions.append("child still running — wait or forward signal")
43
+ result["status"] = "child_running"
44
+ return result
45
+
46
+ # Check if main merge already exists
47
+ sprint_branch = None
48
+ if state.expected_refs:
49
+ sprint_branch = state.expected_refs.get("sprint_branch")
50
+ merge_exists = False
51
+ if sprint_branch:
52
+ # Check if sprint branch is merged into main
53
+ main_sha = _run_git(root, "rev-parse", "main")
54
+ merge_base = _run_git(root, "merge-base", "main", sprint_branch)
55
+ if merge_base and main_sha:
56
+ # If merge-base equals sprint branch HEAD, it's merged
57
+ sprint_head = _run_git(root, "rev-parse", sprint_branch)
58
+ if merge_base == sprint_head:
59
+ merge_exists = True
60
+ actions.append("main merge already exists — skip merge")
61
+ result["merge_exists"] = merge_exists
62
+
63
+ # Determine next action based on operation_intent
64
+ intent = state.operation_intent
65
+ if intent.startswith("execute_task:"):
66
+ actions.append(f"resume task execution from: {intent}")
67
+ elif intent == "merge_sprint":
68
+ if merge_exists:
69
+ actions.append("post-merge verification needed")
70
+ else:
71
+ actions.append("merge sprint to main needed")
72
+ elif intent == "finish":
73
+ if merge_exists:
74
+ actions.append("post-merge verification needed")
75
+ else:
76
+ actions.append("finish: merge + verify needed")
77
+
78
+ # Check operation results
79
+ if state.command_results:
80
+ last = state.command_results[-1]
81
+ if last.get("exit_code", 0) != 0:
82
+ actions.append(f"last command failed: {last.get('error', 'unknown')}")
83
+
84
+ # Expected-ref finish guard
85
+ if state.expected_refs:
86
+ expected_sprint = state.expected_refs.get("sprint_sha")
87
+ if expected_sprint:
88
+ actual = _run_git(root, "rev-parse", sprint_branch or "main")
89
+ if actual and actual != expected_sprint and not merge_exists:
90
+ actions.append("WARNING: sprint ref mismatch — expected %s got %s" % (expected_sprint[:8], actual[:8]))
91
+
92
+ result["status"] = "reconciled"
93
+ return result
94
+
95
+
96
+ def finish_guard(root: Path, run_name: str, expected_sprint_sha: str,
97
+ expected_main_sha: str) -> tuple[bool, str]:
98
+ """Guard before finishing: verify refs match expectations.
99
+
100
+ Returns (ok, reason).
101
+ """
102
+ root = Path(root).resolve()
103
+ sprint_sha = _run_git(root, "rev-parse", "main") # after merge, main should have sprint
104
+ if expected_main_sha and sprint_sha != expected_main_sha:
105
+ # main moved — check if it's the sprint merge
106
+ merge_base = _run_git(root, "merge-base", "main", expected_sprint_sha) if expected_sprint_sha else ""
107
+ if merge_base != expected_sprint_sha:
108
+ return False, f"main ref mismatch: expected merge of {expected_sprint_sha[:8]}"
109
+ return True, "finish guard passed"
110
+
111
+
112
+ def recognize_existing_merge(root: Path, sprint_branch: str) -> bool:
113
+ """Check if sprint branch is already merged into main."""
114
+ merge_base = _run_git(root, "merge-base", "main", sprint_branch)
115
+ sprint_head = _run_git(root, "rev-parse", sprint_branch)
116
+ return merge_base == sprint_head and merge_base != ""
117
+
118
+
119
+ def main() -> int:
120
+ import argparse
121
+ parser = argparse.ArgumentParser(description="Resume and finish owned sprints")
122
+ sub = parser.add_subparsers(dest="command")
123
+
124
+ rec = sub.add_parser("reconcile", help="Reconcile interrupted run")
125
+ rec.add_argument("--root", default=".")
126
+ rec.add_argument("--run-name", required=True)
127
+
128
+ fg = sub.add_parser("finish-guard", help="Guard before finishing")
129
+ fg.add_argument("--root", default=".")
130
+ fg.add_argument("--run-name", required=True)
131
+ fg.add_argument("--expected-sprint-sha", default="")
132
+ fg.add_argument("--expected-main-sha", default="")
133
+
134
+ args = parser.parse_args()
135
+
136
+ if args.command == "reconcile":
137
+ root = Path(args.root).resolve()
138
+ result = reconcile_checkpoint(root, args.run_name)
139
+ print(json.dumps(result, indent=2))
140
+ return 0 if result["status"] in ("reconciled", "no_checkpoint") else 1
141
+
142
+ if args.command == "finish-guard":
143
+ root = Path(args.root).resolve()
144
+ ok, reason = finish_guard(root, args.run_name, args.expected_sprint_sha, args.expected_main_sha)
145
+ print(json.dumps({"ok": ok, "reason": reason}))
146
+ return 0 if ok else 1
147
+
148
+ parser.print_help()
149
+ return 1
150
+
151
+
152
+ if __name__ == "__main__":
153
+ sys.exit(main())
@@ -0,0 +1,191 @@
1
+ """Durable state, checkpoint and process-supervision primitives (Sprint 12, task 12.3).
2
+
3
+ Runtime-generated state lives under tmp/plans/NAME.auto.json with an associated
4
+ exclusive lock. Importing this module writes nothing.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from dataclasses import dataclass, field, asdict
16
+ from pathlib import Path
17
+
18
+ STATE_VERSION = 1
19
+
20
+
21
+ @dataclass
22
+ class RunState:
23
+ """Durable lifecycle checkpoint for an automated sprint run."""
24
+ version: int = STATE_VERSION
25
+ repo_root: str = ""
26
+ git_common_dir: str = ""
27
+ run_name: str = ""
28
+ goal: str = ""
29
+ answers_fingerprint: str = ""
30
+ allowed_scopes: list = field(default_factory=list)
31
+ initial_main_sha: str = ""
32
+ runner: str = "junie"
33
+ model: str = ""
34
+ timeout: int = 120
35
+ plan_fingerprint: str = ""
36
+ spec_fingerprints: dict = field(default_factory=dict)
37
+ tracker_uids: dict = field(default_factory=dict)
38
+ expected_refs: dict = field(default_factory=dict)
39
+ owned_artifacts: list = field(default_factory=list)
40
+ operation_intent: str = ""
41
+ command_results: list = field(default_factory=list)
42
+ verified_commit: str = ""
43
+ child_pid: int = 0
44
+ child_pgid: int = 0
45
+ heartbeat_ts: str = ""
46
+
47
+ def to_dict(self) -> dict:
48
+ return asdict(self)
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict) -> "RunState":
52
+ if data.get("version") != STATE_VERSION:
53
+ raise ValueError(f"unknown state version: {data.get('version')}")
54
+ known = {f.name for f in cls.__dataclass_fields__.values()}
55
+ filtered = {k: v for k, v in data.items() if k in known}
56
+ return cls(**filtered)
57
+
58
+
59
+ def state_path(root: Path, run_name: str) -> Path:
60
+ """Path to the runtime state file (under tmp/plans/)."""
61
+ return root / "tmp" / "plans" / f"{run_name}.auto.json"
62
+
63
+
64
+ def lock_path(root: Path, run_name: str) -> Path:
65
+ """Path to the exclusive lock file."""
66
+ return root / "tmp" / "plans" / f"{run_name}.auto.lock"
67
+
68
+
69
+ class StateLock:
70
+ """Exclusive file lock for a run. Refuses symlinked paths."""
71
+
72
+ def __init__(self, path: Path):
73
+ if path.is_symlink():
74
+ raise ValueError(f"symlinked lock path refused: {path}")
75
+ self.path = path
76
+ self._fh = None
77
+
78
+ def __enter__(self):
79
+ self.path.parent.mkdir(parents=True, exist_ok=True)
80
+ # refuse if path is a symlink (TOCTOU guard)
81
+ if self.path.is_symlink():
82
+ raise ValueError(f"symlinked lock path refused: {self.path}")
83
+ self._fh = open(self.path, "w")
84
+ fcntl.flock(self._fh, fcntl.LOCK_EX)
85
+ return self
86
+
87
+ def __exit__(self, *exc):
88
+ if self._fh:
89
+ fcntl.flock(self._fh, fcntl.LOCK_UN)
90
+ self._fh.close()
91
+ self._fh = None
92
+
93
+
94
+ def write_checkpoint(path: Path, state: RunState) -> None:
95
+ """Atomically write state to path (tmp + rename + fsync). Refuses symlinks."""
96
+ if path.is_symlink():
97
+ raise ValueError(f"symlinked state path refused: {path}")
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ if path.is_symlink():
100
+ raise ValueError(f"symlinked state path refused: {path}")
101
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
102
+ try:
103
+ with os.fdopen(fd, "w") as fh:
104
+ json.dump(state.to_dict(), fh, indent=2, sort_keys=True)
105
+ fh.flush()
106
+ os.fsync(fh.fileno())
107
+ os.replace(tmp, path)
108
+ except BaseException:
109
+ try:
110
+ os.unlink(tmp)
111
+ except OSError:
112
+ pass
113
+ raise
114
+
115
+
116
+ def read_checkpoint(path: Path) -> RunState | None:
117
+ """Read state from path. Returns None if missing or unreadable."""
118
+ if not path.exists() or path.is_symlink():
119
+ return None
120
+ try:
121
+ data = json.loads(path.read_text(encoding="utf-8"))
122
+ return RunState.from_dict(data)
123
+ except (json.JSONDecodeError, OSError, ValueError, TypeError):
124
+ return None
125
+
126
+
127
+ def acquire_lock(root: Path, run_name: str) -> StateLock:
128
+ return StateLock(lock_path(root, run_name))
129
+
130
+
131
+ def is_child_alive(state: RunState) -> bool:
132
+ """Check if the child process may still be running (PID-based, no stale heartbeat)."""
133
+ if state.child_pid <= 0:
134
+ return False
135
+ try:
136
+ os.kill(state.child_pid, 0)
137
+ return True
138
+ except (OSError, ProcessLookupError):
139
+ return False
140
+
141
+
142
+ def supervised_run(cmd: list[str], cwd: Path, timeout: int,
143
+ on_output=None) -> tuple[int, str]:
144
+ """Run a command in its own process group. Returns (exit_code, combined_output).
145
+
146
+ Diagnostics go to stderr (captured separately); result stdout is clean.
147
+ Forwards SIGTERM/SIGINT to the child process group.
148
+ """
149
+ proc = subprocess.Popen(
150
+ cmd, cwd=str(cwd),
151
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
152
+ start_new_session=True,
153
+ )
154
+ pgid = proc.pid
155
+ try:
156
+ stdout, stderr = proc.communicate(timeout=timeout)
157
+ except subprocess.TimeoutExpired:
158
+ try:
159
+ os.killpg(pgid, signal.SIGTERM)
160
+ except (OSError, ProcessLookupError):
161
+ pass
162
+ try:
163
+ stdout, stderr = proc.communicate(timeout=10)
164
+ except subprocess.TimeoutExpired:
165
+ try:
166
+ os.killpg(pgid, signal.SIGKILL)
167
+ except (OSError, ProcessLookupError):
168
+ pass
169
+ stdout, stderr = proc.communicate()
170
+ output = stdout.decode("utf-8", errors="replace")
171
+ if stderr:
172
+ sys.stderr.write(stderr.decode("utf-8", errors="replace"))
173
+ return proc.returncode, output
174
+
175
+
176
+ def bind_state(root: Path, git_common: Path, run_name: str, goal: str,
177
+ answers_fp: str, scopes: list, main_sha: str,
178
+ runner: str, model: str, timeout: int) -> RunState:
179
+ """Create a new RunState bound to identity. Does not write to disk."""
180
+ return RunState(
181
+ repo_root=str(root.resolve()),
182
+ git_common_dir=str(git_common.resolve()),
183
+ run_name=run_name,
184
+ goal=goal,
185
+ answers_fingerprint=answers_fp,
186
+ allowed_scopes=list(scopes),
187
+ initial_main_sha=main_sha,
188
+ runner=runner,
189
+ model=model,
190
+ timeout=timeout,
191
+ )