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.
Files changed (102) hide show
  1. {spec_runner-2.4.0/src/spec_runner.egg-info → spec_runner-2.4.1}/PKG-INFO +1 -1
  2. {spec_runner-2.4.0 → spec_runner-2.4.1}/pyproject.toml +1 -1
  3. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli.py +67 -47
  4. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/doctor.py +4 -2
  5. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/review.py +37 -29
  6. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/state.py +7 -3
  7. {spec_runner-2.4.0 → spec_runner-2.4.1/src/spec_runner.egg-info}/PKG-INFO +1 -1
  8. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_doctor.py +15 -0
  9. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_execution.py +1 -1
  10. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_hooks.py +29 -0
  11. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_state.py +19 -0
  12. {spec_runner-2.4.0 → spec_runner-2.4.1}/LICENSE +0 -0
  13. {spec_runner-2.4.0 → spec_runner-2.4.1}/README.md +0 -0
  14. {spec_runner-2.4.0 → spec_runner-2.4.1}/setup.cfg +0 -0
  15. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/__init__.py +0 -0
  16. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/audit.py +0 -0
  17. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/audit_log.py +0 -0
  18. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli_info.py +0 -0
  19. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/cli_plan.py +0 -0
  20. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/config.py +0 -0
  21. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/errors.py +0 -0
  22. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/events.py +0 -0
  23. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/execution.py +0 -0
  24. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/executor.py +0 -0
  25. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/git_ops.py +0 -0
  26. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/github_sync.py +0 -0
  27. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/hooks.py +0 -0
  28. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/init_cmd.py +0 -0
  29. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/logging.py +0 -0
  30. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/mcp_server.py +0 -0
  31. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/notifications.py +0 -0
  32. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/obs.py +0 -0
  33. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/plugins.py +0 -0
  34. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/prompt.py +0 -0
  35. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/py.typed +0 -0
  36. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/report.py +0 -0
  37. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/runner.py +0 -0
  38. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/SKILL.md +0 -0
  39. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/Makefile.template +0 -0
  40. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/design.template.md +0 -0
  41. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.config.yaml +0 -0
  42. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.py +0 -0
  43. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-design.template.md +0 -0
  44. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-requirements.template.md +0 -0
  45. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-tasks.template.md +0 -0
  46. {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
  47. {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
  48. {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
  49. {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
  50. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.claude.md +0 -0
  51. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md +0 -0
  52. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md +0 -0
  53. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md +0 -0
  54. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md +0 -0
  55. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md +0 -0
  56. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md +0 -0
  57. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/requirements.template.md +0 -0
  58. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/task.py +0 -0
  59. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/tasks.template.md +0 -0
  60. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/workflow.template.md +0 -0
  61. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/stages.py +0 -0
  62. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/task.py +0 -0
  63. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/task_commands.py +0 -0
  64. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/tui.py +0 -0
  65. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/validate.py +0 -0
  66. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner/verify.py +0 -0
  67. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/SOURCES.txt +0 -0
  68. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/dependency_links.txt +0 -0
  69. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/entry_points.txt +0 -0
  70. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/requires.txt +0 -0
  71. {spec_runner-2.4.0 → spec_runner-2.4.1}/src/spec_runner.egg-info/top_level.txt +0 -0
  72. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_audit.py +0 -0
  73. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_audit_log.py +0 -0
  74. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_flags.py +0 -0
  75. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_info.py +0 -0
  76. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_cli_run_reset.py +0 -0
  77. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_config.py +0 -0
  78. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_costs.py +0 -0
  79. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_e2e.py +0 -0
  80. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_errors.py +0 -0
  81. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_events.py +0 -0
  82. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_gh_sync.py +0 -0
  83. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_json_result_contract.py +0 -0
  84. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_logging.py +0 -0
  85. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_mcp.py +0 -0
  86. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_notifications.py +0 -0
  87. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_obs.py +0 -0
  88. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_obs_contract.py +0 -0
  89. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_plan_full.py +0 -0
  90. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_plugins.py +0 -0
  91. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_prompt.py +0 -0
  92. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_report.py +0 -0
  93. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_runner.py +0 -0
  94. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_spec_prefix.py +0 -0
  95. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_stages.py +0 -0
  96. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_subdir_detection.py +0 -0
  97. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_task.py +0 -0
  98. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_task_diff.py +0 -0
  99. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_tui.py +0 -0
  100. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_validate.py +0 -0
  101. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_verify.py +0 -0
  102. {spec_runner-2.4.0 → spec_runner-2.4.1}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec-runner
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Task automation from markdown specs via Claude CLI
5
5
  Author: Andrei
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spec-runner"
7
- version = "2.4.0"
7
+ version = "2.4.1"
8
8
  description = "Task automation from markdown specs via Claude CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- if getattr(args, "tui", False):
128
- import threading
129
-
130
- from .logging import setup_logging
131
- from .tui import SpecRunnerApp
132
-
133
- # TUI mode: log to file, TUI owns screen
134
- log_file = config.logs_dir / f"run-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
135
- config.logs_dir.mkdir(parents=True, exist_ok=True)
136
- setup_logging(level=config.log_level, tui_mode=True, log_file=log_file)
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
- app = SpecRunnerApp(config=config)
159
+ try:
160
+ if getattr(args, "tui", False):
161
+ import threading
139
162
 
140
- def _start_execution() -> None:
141
- t = threading.Thread(target=lambda: _run_tasks(args, config), daemon=True)
142
- t.start()
163
+ from .logging import setup_logging
164
+ from .tui import SpecRunnerApp
143
165
 
144
- app.call_later(_start_execution)
145
- app.run()
146
- return
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
- if getattr(args, "force", False):
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
- logger.error(
161
- "Another executor is already running",
162
- lock_file=str(config.state_file.with_suffix(".lock")),
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
- sys.exit(1)
177
+ t.start()
173
178
 
174
- try:
175
- _run_tasks(args, config)
176
- finally:
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 crash
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(state, stale_timeout, config.tasks_file)
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
- e = error.lower()
69
- return "no such file" in e or "not found" in e
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
- # Get changed files from git
74
- result = subprocess.run(
75
- ["git", "diff", "--name-only", "HEAD~1"],
76
- capture_output=True,
77
- text=True,
78
- cwd=config.project_root,
79
- )
80
- changed_files = (
81
- result.stdout.strip() if result.returncode == 0 else "Unable to get changed files"
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
- # Get git diff stat
85
- result = subprocess.run(
86
- ["git", "diff", "HEAD~1", "--stat"],
87
- capture_output=True,
88
- text=True,
89
- cwd=config.project_root,
90
- )
91
- git_diff_stat = result.stdout.strip() if result.returncode == 0 else ""
92
-
93
- # Full diff for review context (truncated to 30KB)
94
- diff_p_result = subprocess.run(
95
- ["git", "diff", "-p", "HEAD~1"],
96
- capture_output=True,
97
- text=True,
98
- cwd=config.project_root,
99
- )
100
- full_diff = diff_p_result.stdout[:30_000]
101
- if len(diff_p_result.stdout) > 30_000:
102
- full_diff += "\n... (diff truncated)"
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
- than timeout_minutes (typically 2x the task timeout).
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec-runner
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Task automation from markdown specs via Claude CLI
5
5
  Author: Andrei
6
6
  License-Expression: MIT
@@ -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