spec-runner 2.4.0__tar.gz → 2.4.1__tar.gz
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.
- {spec_runner-2.4.0/src/spec_runner.egg-info → spec_runner-2.4.1}/PKG-INFO +1 -1
- {spec_runner-2.4.0 → spec_runner-2.4.1}/pyproject.toml +1 -1
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli.py +67 -47
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/doctor.py +4 -2
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/review.py +37 -29
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/state.py +7 -3
- {spec_runner-2.4.0 → spec_runner-2.4.1/src/spec_runner.egg-info}/PKG-INFO +1 -1
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_doctor.py +15 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_execution.py +1 -1
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_hooks.py +29 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_state.py +19 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/LICENSE +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/README.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/setup.cfg +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/__init__.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/audit.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/audit_log.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli_info.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli_plan.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/config.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/errors.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/events.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/execution.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/executor.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/git_ops.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/github_sync.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/hooks.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/init_cmd.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/logging.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/mcp_server.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/notifications.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/obs.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/plugins.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/prompt.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/py.typed +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/report.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/runner.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/SKILL.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/Makefile.template +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/design.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.config.yaml +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-design.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-requirements.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-tasks.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-implementer/SKILL.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-reviewer/SKILL.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-tester/SKILL.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/spec-runner.pi.config.yaml +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.claude.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/requirements.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/task.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/tasks.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/workflow.template.md +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/stages.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/task.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/task_commands.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/tui.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/validate.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/verify.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/SOURCES.txt +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/dependency_links.txt +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/entry_points.txt +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/requires.txt +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/top_level.txt +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_audit.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_audit_log.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_flags.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_info.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_run_reset.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_config.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_costs.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_e2e.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_errors.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_events.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_gh_sync.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_json_result_contract.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_logging.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_mcp.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_notifications.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_obs.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_obs_contract.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_plan_full.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_plugins.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_prompt.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_report.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_runner.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_spec_prefix.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_stages.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_subdir_detection.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_task.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_task_diff.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_tui.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_validate.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_verify.py +0 -0
- {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_watch.py +0 -0
|
@@ -117,6 +117,27 @@ def _print_dry_run(tasks_to_run: list[Task], config: ExecutorConfig, state: Exec
|
|
|
117
117
|
print(json.dumps({"dry_run": True, "tasks": data}, indent=2))
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def _acquire_run_lock(config: ExecutorConfig) -> ExecutorLock:
|
|
121
|
+
"""Acquire the exclusive executor lock, or exit(1) if another run holds it."""
|
|
122
|
+
lock = ExecutorLock(config.state_file.with_suffix(".lock"))
|
|
123
|
+
if not lock.acquire():
|
|
124
|
+
held_by = getattr(lock, "_held_by", {})
|
|
125
|
+
alive = held_by.get("alive", "true")
|
|
126
|
+
logger.error(
|
|
127
|
+
"Another executor is already running",
|
|
128
|
+
lock_file=str(config.state_file.with_suffix(".lock")),
|
|
129
|
+
held_by_pid=held_by.get("pid", "unknown"),
|
|
130
|
+
started=held_by.get("started", "unknown"),
|
|
131
|
+
process_alive=alive,
|
|
132
|
+
)
|
|
133
|
+
if alive == "false":
|
|
134
|
+
logger.error(
|
|
135
|
+
"Lock holder is dead. Use --force to override, or delete the lock file manually."
|
|
136
|
+
)
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
return lock
|
|
139
|
+
|
|
140
|
+
|
|
120
141
|
def cmd_run(args: argparse.Namespace, config: ExecutorConfig) -> None:
|
|
121
142
|
"""Execute tasks."""
|
|
122
143
|
# HITL review incompatible with TUI mode
|
|
@@ -124,61 +145,52 @@ def cmd_run(args: argparse.Namespace, config: ExecutorConfig) -> None:
|
|
|
124
145
|
logger.warning("--hitl-review ignored in TUI mode (TUI owns the screen)")
|
|
125
146
|
config.hitl_review = False
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
148
|
+
# Acquire the exclusive lock unless --force. TUI mode also holds it (one
|
|
149
|
+
# executor per project) — when held, stale-task recovery can safely reset all
|
|
150
|
+
# orphaned 'running' tasks; with --force a concurrent runner may exist, so we
|
|
151
|
+
# fall back to the age-based heuristic.
|
|
152
|
+
if getattr(args, "force", False):
|
|
153
|
+
logger.warning("Skipping lock check (--force)")
|
|
154
|
+
lock = None
|
|
155
|
+
else:
|
|
156
|
+
lock = _acquire_run_lock(config)
|
|
157
|
+
lock_held = lock is not None
|
|
137
158
|
|
|
138
|
-
|
|
159
|
+
try:
|
|
160
|
+
if getattr(args, "tui", False):
|
|
161
|
+
import threading
|
|
139
162
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
t.start()
|
|
163
|
+
from .logging import setup_logging
|
|
164
|
+
from .tui import SpecRunnerApp
|
|
143
165
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
# TUI mode: log to file, TUI owns screen
|
|
167
|
+
log_file = config.logs_dir / f"run-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
|
|
168
|
+
config.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
setup_logging(level=config.log_level, tui_mode=True, log_file=log_file)
|
|
147
170
|
|
|
148
|
-
|
|
149
|
-
logger.warning("Skipping lock check (--force)")
|
|
150
|
-
_run_tasks(args, config)
|
|
151
|
-
else:
|
|
152
|
-
# Acquire lock to prevent concurrent runs
|
|
153
|
-
lock = ExecutorLock(config.state_file.with_suffix(".lock"))
|
|
154
|
-
if not lock.acquire():
|
|
155
|
-
held_by = getattr(lock, "_held_by", {})
|
|
156
|
-
pid = held_by.get("pid", "unknown")
|
|
157
|
-
started = held_by.get("started", "unknown")
|
|
158
|
-
alive = held_by.get("alive", "true")
|
|
171
|
+
app = SpecRunnerApp(config=config)
|
|
159
172
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
held_by_pid=pid,
|
|
164
|
-
started=started,
|
|
165
|
-
process_alive=alive,
|
|
166
|
-
)
|
|
167
|
-
if alive == "false":
|
|
168
|
-
logger.error(
|
|
169
|
-
"Lock holder is dead. Use --force to override, "
|
|
170
|
-
"or delete the lock file manually."
|
|
173
|
+
def _start_execution() -> None:
|
|
174
|
+
t = threading.Thread(
|
|
175
|
+
target=lambda: _run_tasks(args, config, lock_held=lock_held), daemon=True
|
|
171
176
|
)
|
|
172
|
-
|
|
177
|
+
t.start()
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
app.call_later(_start_execution)
|
|
180
|
+
app.run()
|
|
181
|
+
else:
|
|
182
|
+
_run_tasks(args, config, lock_held=lock_held)
|
|
183
|
+
finally:
|
|
184
|
+
if lock is not None:
|
|
177
185
|
lock.release()
|
|
178
186
|
|
|
179
187
|
|
|
180
|
-
def _run_tasks(args, config: ExecutorConfig):
|
|
181
|
-
"""Internal task execution logic.
|
|
188
|
+
def _run_tasks(args, config: ExecutorConfig, *, lock_held: bool = False):
|
|
189
|
+
"""Internal task execution logic.
|
|
190
|
+
|
|
191
|
+
lock_held: True when the caller holds the exclusive executor lock, so any
|
|
192
|
+
orphaned 'running' task can be safely reset regardless of age.
|
|
193
|
+
"""
|
|
182
194
|
# Clear any leftover stop file from previous runs
|
|
183
195
|
clear_stop_file(config)
|
|
184
196
|
|
|
@@ -194,9 +206,17 @@ def _run_tasks(args, config: ExecutorConfig):
|
|
|
194
206
|
task_filter=getattr(args, "task", None),
|
|
195
207
|
)
|
|
196
208
|
|
|
197
|
-
# Recover tasks stuck in 'running' from previous
|
|
209
|
+
# Recover tasks stuck in 'running' from a previous crashed/interrupted run.
|
|
210
|
+
# When we hold the exclusive lock (lock_held), no other runner exists — any
|
|
211
|
+
# 'running' task is orphaned and is reset regardless of age (otherwise a
|
|
212
|
+
# session interruption, e.g. a dropped remote shell, leaves a half-done
|
|
213
|
+
# task that the next run re-picks first and hangs re-doing it). Without the
|
|
214
|
+
# lock (--force), a concurrent runner may be active, so fall back to the
|
|
215
|
+
# age-based heuristic (2x the task timeout).
|
|
198
216
|
stale_timeout = config.task_timeout_minutes * 2
|
|
199
|
-
recovered = recover_stale_tasks(
|
|
217
|
+
recovered = recover_stale_tasks(
|
|
218
|
+
state, stale_timeout, config.tasks_file, recover_all=lock_held
|
|
219
|
+
)
|
|
200
220
|
if recovered:
|
|
201
221
|
logger.warning("Recovered stale tasks", task_ids=recovered)
|
|
202
222
|
tasks = parse_tasks(config.tasks_file)
|
|
@@ -65,8 +65,10 @@ class DoctorReport:
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _not_in_path(error: str) -> bool:
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
# Match the FileNotFoundError for a missing executable specifically. A broad
|
|
69
|
+
# "not found" also appears in API/auth errors (e.g. "API Key not found"),
|
|
70
|
+
# which must not be misreported as command-not-in-PATH.
|
|
71
|
+
return "no such file or directory" in error.lower()
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def extract(attempt: TaskAttempt, scratch_root: Path, with_review: bool) -> DoctorReport:
|
|
@@ -70,36 +70,44 @@ def build_review_prompt(
|
|
|
70
70
|
lint_output: Lint check output to include in review context
|
|
71
71
|
previous_error: Error from previous attempt (retry context)
|
|
72
72
|
"""
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
# Gather the task diff via `git diff HEAD~1` ONLY when this project does
|
|
74
|
+
# git-based task isolation (a branch and/or commit per task). When git
|
|
75
|
+
# automation is off — a subdir of a larger repo, or `--no-branch --no-commit`
|
|
76
|
+
# — `git diff HEAD~1` runs against the PARENT repo and yields a huge, unrelated
|
|
77
|
+
# diff that makes the reviewer slow or hang. In that case skip it.
|
|
78
|
+
if config.create_git_branch or config.auto_commit:
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "diff", "--name-only", "HEAD~1"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
cwd=config.project_root,
|
|
84
|
+
)
|
|
85
|
+
changed_files = (
|
|
86
|
+
result.stdout.strip() if result.returncode == 0 else "Unable to get changed files"
|
|
87
|
+
)
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", "diff", "HEAD~1", "--stat"],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
cwd=config.project_root,
|
|
94
|
+
)
|
|
95
|
+
git_diff_stat = result.stdout.strip() if result.returncode == 0 else ""
|
|
96
|
+
|
|
97
|
+
# Full diff for review context (truncated to 30KB)
|
|
98
|
+
diff_p_result = subprocess.run(
|
|
99
|
+
["git", "diff", "-p", "HEAD~1"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
cwd=config.project_root,
|
|
103
|
+
)
|
|
104
|
+
full_diff = diff_p_result.stdout[:30_000]
|
|
105
|
+
if len(diff_p_result.stdout) > 30_000:
|
|
106
|
+
full_diff += "\n... (diff truncated)"
|
|
107
|
+
else:
|
|
108
|
+
changed_files = "(git diff unavailable: git automation disabled for this project)"
|
|
109
|
+
git_diff_stat = ""
|
|
110
|
+
full_diff = ""
|
|
103
111
|
|
|
104
112
|
# Try to load CLI-specific or custom template
|
|
105
113
|
template = load_prompt_template("review", cli_name=cli_name)
|
|
@@ -799,11 +799,15 @@ def recover_stale_tasks(
|
|
|
799
799
|
state: ExecutorState,
|
|
800
800
|
timeout_minutes: float,
|
|
801
801
|
tasks_file: Path,
|
|
802
|
+
*,
|
|
803
|
+
recover_all: bool = False,
|
|
802
804
|
) -> list[str]:
|
|
803
805
|
"""Detect and recover tasks stuck in 'running' status.
|
|
804
806
|
|
|
805
|
-
A task is considered stale if it has been 'running' for longer
|
|
806
|
-
|
|
807
|
+
A task is considered stale if it has been 'running' for longer than
|
|
808
|
+
timeout_minutes (typically 2x the task timeout). When ``recover_all`` is True
|
|
809
|
+
(the caller holds the exclusive executor lock, so any 'running' task is
|
|
810
|
+
orphaned from a dead run) every running task is recovered regardless of age.
|
|
807
811
|
|
|
808
812
|
Returns list of recovered task IDs.
|
|
809
813
|
"""
|
|
@@ -819,7 +823,7 @@ def recover_stale_tasks(
|
|
|
819
823
|
started = datetime.fromisoformat(ts.started_at)
|
|
820
824
|
elapsed_minutes = (now - started).total_seconds() / 60
|
|
821
825
|
|
|
822
|
-
if elapsed_minutes <= timeout_minutes:
|
|
826
|
+
if not recover_all and elapsed_minutes <= timeout_minutes:
|
|
823
827
|
continue
|
|
824
828
|
|
|
825
829
|
# Stale task — recover it
|
|
@@ -150,6 +150,21 @@ def test_extract_command_not_found(tmp_path):
|
|
|
150
150
|
assert rep.verdict == "broken"
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
def test_extract_not_found_in_message_is_not_path_error(tmp_path):
|
|
154
|
+
# An auth/API error whose TEXT contains "not found" (e.g. Google's
|
|
155
|
+
# "API Key not found") must NOT be misreported as command-not-in-PATH.
|
|
156
|
+
att = _attempt(
|
|
157
|
+
success=False,
|
|
158
|
+
error='{"error": {"message": "API Key not found. Please pass a valid API key."}}',
|
|
159
|
+
error_kind="auth",
|
|
160
|
+
claude_output=None,
|
|
161
|
+
)
|
|
162
|
+
rep = extract(att, tmp_path, with_review=False)
|
|
163
|
+
assert rep.checks["invocation"].status == CHECK_FAIL
|
|
164
|
+
assert "PATH" not in rep.checks["invocation"].detail
|
|
165
|
+
assert "auth" in rep.checks["invocation"].detail
|
|
166
|
+
|
|
167
|
+
|
|
153
168
|
def test_extract_auth_failure_classified(tmp_path):
|
|
154
169
|
att = _attempt(
|
|
155
170
|
success=False,
|
|
@@ -1323,7 +1323,7 @@ class TestCrashRecovery:
|
|
|
1323
1323
|
recover_calls = []
|
|
1324
1324
|
monkeypatch.setattr(
|
|
1325
1325
|
"spec_runner.cli.recover_stale_tasks",
|
|
1326
|
-
lambda state, timeout_minutes, tasks_file: recover_calls.append(True) or [],
|
|
1326
|
+
lambda state, timeout_minutes, tasks_file, **kw: recover_calls.append(True) or [],
|
|
1327
1327
|
)
|
|
1328
1328
|
|
|
1329
1329
|
args = type(
|
|
@@ -361,6 +361,35 @@ class TestBuildReviewPrompt:
|
|
|
361
361
|
prompt = build_review_prompt(task, config)
|
|
362
362
|
assert "Constitution" not in prompt
|
|
363
363
|
|
|
364
|
+
def test_skips_git_diff_when_git_automation_off(self):
|
|
365
|
+
# Subdir project / --no-branch --no-commit: `git diff HEAD~1` would hit the
|
|
366
|
+
# PARENT repo (huge, unrelated diff → reviewer slow/hangs). Skip it.
|
|
367
|
+
task = _make_task()
|
|
368
|
+
config = _make_config(create_git_branch=False, auto_commit=False)
|
|
369
|
+
with (
|
|
370
|
+
patch("spec_runner.review.subprocess.run") as mock_run,
|
|
371
|
+
patch("spec_runner.review.load_prompt_template", return_value=None),
|
|
372
|
+
):
|
|
373
|
+
build_review_prompt(task, config)
|
|
374
|
+
git_diff_calls = [
|
|
375
|
+
c for c in mock_run.call_args_list if c.args and c.args[0][:2] == ["git", "diff"]
|
|
376
|
+
]
|
|
377
|
+
assert git_diff_calls == []
|
|
378
|
+
|
|
379
|
+
def test_runs_git_diff_when_auto_commit_on(self):
|
|
380
|
+
task = _make_task()
|
|
381
|
+
config = _make_config(create_git_branch=False, auto_commit=True)
|
|
382
|
+
with (
|
|
383
|
+
patch("spec_runner.review.subprocess.run") as mock_run,
|
|
384
|
+
patch("spec_runner.review.load_prompt_template", return_value=None),
|
|
385
|
+
):
|
|
386
|
+
mock_run.return_value = MagicMock(stdout="", stderr="", returncode=0)
|
|
387
|
+
build_review_prompt(task, config)
|
|
388
|
+
git_diff_calls = [
|
|
389
|
+
c for c in mock_run.call_args_list if c.args and c.args[0][:2] == ["git", "diff"]
|
|
390
|
+
]
|
|
391
|
+
assert git_diff_calls
|
|
392
|
+
|
|
364
393
|
|
|
365
394
|
class TestRunCodeReview:
|
|
366
395
|
"""Tests for run_code_review returning ReviewVerdict."""
|
|
@@ -887,6 +887,25 @@ class TestRecoverStaleTasks:
|
|
|
887
887
|
recovered = recover_stale_tasks(state, timeout_minutes=60, tasks_file=tasks_file)
|
|
888
888
|
assert recovered == []
|
|
889
889
|
|
|
890
|
+
def test_recover_all_resets_orphaned_running_regardless_of_age(self, tmp_path):
|
|
891
|
+
# Under the exclusive lock any 'running' task is orphaned from a dead run.
|
|
892
|
+
# recover_all=True must reset even a just-started one (no age check), so an
|
|
893
|
+
# interrupted session doesn't leave a half-done task the next run re-hangs on.
|
|
894
|
+
from spec_runner.state import recover_stale_tasks
|
|
895
|
+
|
|
896
|
+
config = _make_config(tmp_path)
|
|
897
|
+
(tmp_path / "spec").mkdir(exist_ok=True)
|
|
898
|
+
tasks_file = tmp_path / "spec" / "tasks.md"
|
|
899
|
+
tasks_file.write_text("### TASK-001: thing\nP0 | IN_PROGRESS\n")
|
|
900
|
+
|
|
901
|
+
with ExecutorState(config) as state:
|
|
902
|
+
state.mark_running("TASK-001")
|
|
903
|
+
# Large timeout would normally NOT recover a fresh task; recover_all overrides.
|
|
904
|
+
recovered = recover_stale_tasks(
|
|
905
|
+
state, timeout_minutes=999, tasks_file=tasks_file, recover_all=True
|
|
906
|
+
)
|
|
907
|
+
assert recovered == ["TASK-001"]
|
|
908
|
+
|
|
890
909
|
def test_does_not_recover_completed_tasks(self, tmp_path):
|
|
891
910
|
from spec_runner.state import recover_stale_tasks
|
|
892
911
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|