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,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
|
+
)
|