task-harbor 0.7.0__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.
- opus_manager/__init__.py +3 -0
- opus_manager/__main__.py +3 -0
- opus_manager/agent.py +17 -0
- opus_manager/backlog.py +128 -0
- opus_manager/claude.py +138 -0
- opus_manager/cli.py +305 -0
- opus_manager/codex.py +119 -0
- opus_manager/comparison.py +162 -0
- opus_manager/config.py +134 -0
- opus_manager/contracts.py +58 -0
- opus_manager/desktop_actions.py +22 -0
- opus_manager/evidence.py +50 -0
- opus_manager/gitops.py +218 -0
- opus_manager/help.py +59 -0
- opus_manager/models.py +110 -0
- opus_manager/offline.py +142 -0
- opus_manager/operator_commands.py +86 -0
- opus_manager/orchestrator.py +1267 -0
- opus_manager/preflight.py +92 -0
- opus_manager/prompts.py +259 -0
- opus_manager/providers.py +11 -0
- opus_manager/reporting.py +82 -0
- opus_manager/state.py +37 -0
- opus_manager/tui.py +388 -0
- opus_manager/usage.py +77 -0
- opus_manager/verification.py +39 -0
- task_harbor/__init__.py +1 -0
- task_harbor/__main__.py +3 -0
- task_harbor-0.7.0.dist-info/METADATA +294 -0
- task_harbor-0.7.0.dist-info/RECORD +34 -0
- task_harbor-0.7.0.dist-info/WHEEL +5 -0
- task_harbor-0.7.0.dist-info/entry_points.txt +4 -0
- task_harbor-0.7.0.dist-info/licenses/LICENSE +21 -0
- task_harbor-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .backlog import parse_backlog, render_backlog
|
|
17
|
+
from .claude import ClaudeClient, extract_json
|
|
18
|
+
from .codex import CodexClient
|
|
19
|
+
from .providers import create_client
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .gitops import (
|
|
22
|
+
branch_exists,
|
|
23
|
+
changed_paths,
|
|
24
|
+
checkpoint,
|
|
25
|
+
current_branch,
|
|
26
|
+
detached_worktree_add,
|
|
27
|
+
diff_range,
|
|
28
|
+
ensure_local_exclude,
|
|
29
|
+
ensure_repo,
|
|
30
|
+
git,
|
|
31
|
+
head,
|
|
32
|
+
meaningful_dirty,
|
|
33
|
+
matches_protected,
|
|
34
|
+
merge_branch,
|
|
35
|
+
remote_block_env,
|
|
36
|
+
repo_snapshot,
|
|
37
|
+
restore_paths,
|
|
38
|
+
worktree_add,
|
|
39
|
+
worktree_remove,
|
|
40
|
+
)
|
|
41
|
+
from .models import BacklogTask, ManagerState, PRIORITY_ORDER, TaskStatus
|
|
42
|
+
from .prompts import (
|
|
43
|
+
classify_escalation,
|
|
44
|
+
code_review,
|
|
45
|
+
diagnose,
|
|
46
|
+
fresh_implementer,
|
|
47
|
+
implement,
|
|
48
|
+
plan_rework,
|
|
49
|
+
plan_review,
|
|
50
|
+
planner,
|
|
51
|
+
qa,
|
|
52
|
+
review_fix,
|
|
53
|
+
review_qa,
|
|
54
|
+
same_implementer_fix,
|
|
55
|
+
select_work,
|
|
56
|
+
)
|
|
57
|
+
from .state import append_event, load_state, save_state
|
|
58
|
+
from .usage import RunStopped, UsageLedger
|
|
59
|
+
from .verification import run_checks
|
|
60
|
+
from .preflight import baseline, execute_checks, verification_mutations, collect_artifacts
|
|
61
|
+
from .contracts import load_contracts, validate_contract, unanswered, fast_path
|
|
62
|
+
from .evidence import change_evidence, review_chunks, failure_signature
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ManagerError(RuntimeError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Paths:
|
|
71
|
+
repo: Path
|
|
72
|
+
manager_root: Path
|
|
73
|
+
run_dir: Path
|
|
74
|
+
state_file: Path
|
|
75
|
+
events_file: Path
|
|
76
|
+
plans_dir: Path
|
|
77
|
+
qa_dir: Path
|
|
78
|
+
reviews_dir: Path
|
|
79
|
+
diagnostics_dir: Path
|
|
80
|
+
logs_dir: Path
|
|
81
|
+
hooks_dir: Path
|
|
82
|
+
lock_file: Path
|
|
83
|
+
worktrees_root: Path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Orchestrator:
|
|
87
|
+
def __init__(self, repo: Path, config: Config, run_id: str, *, state: ManagerState | None = None):
|
|
88
|
+
self.repo = repo.resolve()
|
|
89
|
+
self.config = config
|
|
90
|
+
manager_root = self.repo / config.manager_dir
|
|
91
|
+
run_dir = manager_root / "runs" / run_id
|
|
92
|
+
external_root = self.repo.parent / f".{self.repo.name}-opus-worktrees" / run_id
|
|
93
|
+
self.paths = Paths(
|
|
94
|
+
repo=self.repo,
|
|
95
|
+
manager_root=manager_root,
|
|
96
|
+
run_dir=run_dir,
|
|
97
|
+
state_file=run_dir / "state.json",
|
|
98
|
+
events_file=run_dir / "events.jsonl",
|
|
99
|
+
plans_dir=run_dir / "plans",
|
|
100
|
+
qa_dir=run_dir / "qa",
|
|
101
|
+
reviews_dir=run_dir / "reviews",
|
|
102
|
+
diagnostics_dir=run_dir / "diagnostics",
|
|
103
|
+
logs_dir=run_dir / "logs",
|
|
104
|
+
hooks_dir=run_dir / "hooks",
|
|
105
|
+
lock_file=run_dir / "manager.lock",
|
|
106
|
+
worktrees_root=external_root,
|
|
107
|
+
)
|
|
108
|
+
for p in [run_dir, self.paths.plans_dir, self.paths.qa_dir, self.paths.reviews_dir, self.paths.diagnostics_dir, self.paths.logs_dir]:
|
|
109
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
self.state = state
|
|
111
|
+
self.agent = create_client(config, self.paths.hooks_dir)
|
|
112
|
+
self.agent_env = remote_block_env(self.paths.hooks_dir)
|
|
113
|
+
self._persist_lock = threading.Lock()
|
|
114
|
+
self._run_lock: Any | None = None
|
|
115
|
+
self._resuming = False
|
|
116
|
+
self.usage = UsageLedger(run_dir / "usage.json", config)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def claude(self):
|
|
120
|
+
"""Compatibility alias; new integrations should use agent."""
|
|
121
|
+
return self.agent
|
|
122
|
+
|
|
123
|
+
@claude.setter
|
|
124
|
+
def claude(self, value):
|
|
125
|
+
self.agent = value
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def start(cls, repo: Path, config: Config, backlog_path: Path | None = None) -> "Orchestrator":
|
|
129
|
+
repo = repo.resolve()
|
|
130
|
+
ensure_repo(repo)
|
|
131
|
+
ensure_local_exclude(repo, config.manager_dir)
|
|
132
|
+
dirty = meaningful_dirty(repo, config.manager_dir, config.backlog_file)
|
|
133
|
+
if dirty and not config.allow_dirty_repo:
|
|
134
|
+
raise ManagerError(
|
|
135
|
+
"Repository has pre-existing changes outside manager-owned files. "
|
|
136
|
+
"Commit/stash them or set allow_dirty_repo=true.\n" + "\n".join(dirty)
|
|
137
|
+
)
|
|
138
|
+
backlog = backlog_path or (repo / config.backlog_file)
|
|
139
|
+
tasks = parse_backlog(backlog)
|
|
140
|
+
validate_tasks(tasks)
|
|
141
|
+
# A fresh manager run re-establishes runtime state from durable backlog intent.
|
|
142
|
+
for t in tasks.values():
|
|
143
|
+
if t.status == TaskStatus.COMPLETE:
|
|
144
|
+
continue
|
|
145
|
+
if t.plan_path and (repo / t.plan_path).exists():
|
|
146
|
+
t.status = TaskStatus.PLAN_REVIEW
|
|
147
|
+
else:
|
|
148
|
+
t.status = TaskStatus.UNPLANNED
|
|
149
|
+
t.owner = None
|
|
150
|
+
t.blocked_reason = None
|
|
151
|
+
t.qa_failures = 0
|
|
152
|
+
t.generation = 0
|
|
153
|
+
t.plan_reworks = 0
|
|
154
|
+
t.checkpoint_commit = None
|
|
155
|
+
t.last_qa_path = None
|
|
156
|
+
t.metadata = {}
|
|
157
|
+
|
|
158
|
+
contracts = load_contracts(repo, config.manager_dir)
|
|
159
|
+
for task_id, contract in contracts.items():
|
|
160
|
+
if task_id not in tasks:
|
|
161
|
+
raise ManagerError(f"Contract refers to unknown task: {task_id}")
|
|
162
|
+
tasks[task_id].metadata["contract"] = validate_contract(contract, len(tasks[task_id].acceptance_criteria))
|
|
163
|
+
|
|
164
|
+
now = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
165
|
+
run_id = f"{now}_{head(repo)[:8]}"
|
|
166
|
+
base_branch = current_branch(repo)
|
|
167
|
+
base_commit = head(repo)
|
|
168
|
+
integration_branch = f"{config.integration_branch_prefix}/{run_id}"
|
|
169
|
+
external_root = repo.parent / f".{repo.name}-opus-worktrees" / run_id
|
|
170
|
+
integration_path = external_root / "integration"
|
|
171
|
+
if branch_exists(repo, integration_branch):
|
|
172
|
+
raise ManagerError(f"Integration branch already exists: {integration_branch}")
|
|
173
|
+
worktree_add(repo, integration_path, integration_branch, base_commit, create_branch=True)
|
|
174
|
+
state = ManagerState(
|
|
175
|
+
run_id=run_id,
|
|
176
|
+
provider=config.provider,
|
|
177
|
+
repo=str(repo),
|
|
178
|
+
integration_branch=integration_branch,
|
|
179
|
+
integration_worktree=str(integration_path),
|
|
180
|
+
base_branch=base_branch,
|
|
181
|
+
base_commit=base_commit,
|
|
182
|
+
tasks=tasks,
|
|
183
|
+
last_review_commit=base_commit,
|
|
184
|
+
)
|
|
185
|
+
obj = cls(repo, config, run_id, state=state)
|
|
186
|
+
obj._sync_backlog(commit_integration=True)
|
|
187
|
+
obj._event("RUN_STARTED", base_branch=base_branch, base_commit=base_commit, integration_branch=integration_branch)
|
|
188
|
+
obj._persist()
|
|
189
|
+
return obj
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def resume(cls, repo: Path, config: Config, run_id: str) -> "Orchestrator":
|
|
193
|
+
state_path = repo.resolve() / config.manager_dir / "runs" / run_id / "state.json"
|
|
194
|
+
if not state_path.exists():
|
|
195
|
+
raise ManagerError(f"Run not found: {run_id}")
|
|
196
|
+
state = load_state(state_path)
|
|
197
|
+
if state.provider != config.provider:
|
|
198
|
+
raise ManagerError("Cannot change provider while resuming a run; start a new run instead")
|
|
199
|
+
obj = cls(repo, config, run_id, state=state)
|
|
200
|
+
obj._resuming = True
|
|
201
|
+
return obj
|
|
202
|
+
|
|
203
|
+
def _recover_interrupted_tasks(self) -> None:
|
|
204
|
+
"""Move in-flight states to explicit, idempotent recovery transitions.
|
|
205
|
+
|
|
206
|
+
Claude invocations cannot be resumed until the harness knows whether the prior
|
|
207
|
+
process returned. Durable checkpoint/session fields let the next manager turn
|
|
208
|
+
repeat the smallest safe unit instead of deadlocking on a transient status.
|
|
209
|
+
"""
|
|
210
|
+
assert self.state is not None
|
|
211
|
+
self.state.active_planners = []
|
|
212
|
+
recovered: list[dict[str, str]] = []
|
|
213
|
+
for t in self.state.tasks.values():
|
|
214
|
+
previous = t.status
|
|
215
|
+
mode: str | None = None
|
|
216
|
+
if previous == TaskStatus.PLANNING:
|
|
217
|
+
t.status = TaskStatus.UNPLANNED
|
|
218
|
+
t.owner = None
|
|
219
|
+
t.next_action = "Restart planning after interruption"
|
|
220
|
+
elif previous == TaskStatus.IMPLEMENTING:
|
|
221
|
+
t.status = TaskStatus.READY
|
|
222
|
+
t.owner = None
|
|
223
|
+
mode = t.metadata.get("resume_mode", "implementation")
|
|
224
|
+
t.next_action = "Resume implementation after interruption"
|
|
225
|
+
elif previous == TaskStatus.QA:
|
|
226
|
+
t.status = TaskStatus.READY
|
|
227
|
+
t.owner = None
|
|
228
|
+
mode = "qa" if t.checkpoint_commit else "implementation"
|
|
229
|
+
t.next_action = "Repeat interrupted independent QA"
|
|
230
|
+
elif previous == TaskStatus.QA_FAIL:
|
|
231
|
+
t.status = TaskStatus.READY
|
|
232
|
+
t.owner = None
|
|
233
|
+
mode = "repair"
|
|
234
|
+
t.next_action = "Resume repair from persisted QA evidence"
|
|
235
|
+
elif previous == TaskStatus.QA_PASSED:
|
|
236
|
+
t.status = TaskStatus.READY
|
|
237
|
+
t.owner = None
|
|
238
|
+
mode = "merge"
|
|
239
|
+
t.next_action = "Resume merge of QA-passed checkpoint"
|
|
240
|
+
if mode:
|
|
241
|
+
t.metadata["resume_mode"] = mode
|
|
242
|
+
if t.status != previous:
|
|
243
|
+
recovered.append({"task_id": t.task_id, "from": previous.value, "to": t.status.value})
|
|
244
|
+
if recovered:
|
|
245
|
+
self._event("INTERRUPTED_TRANSITIONS_RECOVERED", tasks=recovered)
|
|
246
|
+
|
|
247
|
+
def _persist(self) -> None:
|
|
248
|
+
assert self.state is not None
|
|
249
|
+
with self._persist_lock:
|
|
250
|
+
save_state(self.paths.state_file, self.state)
|
|
251
|
+
|
|
252
|
+
def _event(self, kind: str, **data: Any) -> None:
|
|
253
|
+
assert self.state is not None
|
|
254
|
+
with self._persist_lock:
|
|
255
|
+
self.state.event_seq += 1
|
|
256
|
+
event = {
|
|
257
|
+
"seq": self.state.event_seq,
|
|
258
|
+
"time": datetime.now(timezone.utc).isoformat(),
|
|
259
|
+
"kind": kind,
|
|
260
|
+
**data,
|
|
261
|
+
}
|
|
262
|
+
append_event(self.paths.events_file, event)
|
|
263
|
+
save_state(self.paths.state_file, self.state)
|
|
264
|
+
|
|
265
|
+
def _write_artifact(self, path: Path, content: str) -> None:
|
|
266
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
path.write_text(content.rstrip() + "\n", encoding="utf-8")
|
|
268
|
+
|
|
269
|
+
def _sync_backlog(self, *, commit_integration: bool = False) -> None:
|
|
270
|
+
assert self.state is not None
|
|
271
|
+
# BACKLOG.md is manager-owned. Mirror state to the user's checkout and integration worktree.
|
|
272
|
+
render_backlog(self.repo / self.config.backlog_file, self.state.tasks)
|
|
273
|
+
integration = Path(self.state.integration_worktree)
|
|
274
|
+
if integration.exists():
|
|
275
|
+
render_backlog(integration / self.config.backlog_file, self.state.tasks)
|
|
276
|
+
if commit_integration:
|
|
277
|
+
checkpoint(integration, "manager: synchronize backlog state")
|
|
278
|
+
|
|
279
|
+
def _call(self, prompt: str, **kwargs: Any):
|
|
280
|
+
task_match = re.search(r'Task:\s*\{\s*"id":\s*"([^"\n]+)"', prompt)
|
|
281
|
+
if task_match is None:
|
|
282
|
+
task_match = re.search(r"# Clean restart packet [^\w]+ ([A-Za-z0-9_.-]+)", prompt)
|
|
283
|
+
call_id, cap = self.usage.reserve(kwargs["role"], task_match.group(1) if task_match else None)
|
|
284
|
+
if isinstance(self.agent, (ClaudeClient, CodexClient)):
|
|
285
|
+
kwargs["budget_usd"] = cap
|
|
286
|
+
try:
|
|
287
|
+
result = self.agent.run(prompt, **kwargs)
|
|
288
|
+
except BaseException as exc:
|
|
289
|
+
self.usage.finish(call_id, getattr(exc, "raw", None), type(exc).__name__)
|
|
290
|
+
raise
|
|
291
|
+
self.usage.finish(call_id, result.raw)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def probe(self) -> None:
|
|
295
|
+
result = self.agent.probe(Path(self.state.integration_worktree))
|
|
296
|
+
if result.text.strip().upper() != "OK":
|
|
297
|
+
raise ManagerError(f"Agent probe returned unexpected response: {result.text}")
|
|
298
|
+
self._event("AGENT_PROBE_OK")
|
|
299
|
+
|
|
300
|
+
def run(self) -> None:
|
|
301
|
+
self._acquire_run_lock()
|
|
302
|
+
try:
|
|
303
|
+
if self._resuming:
|
|
304
|
+
self.state = load_state(self.paths.state_file)
|
|
305
|
+
if self.state.provider != self.config.provider:
|
|
306
|
+
raise ManagerError("Cannot change provider while resuming a run")
|
|
307
|
+
if not Path(self.state.integration_worktree).exists():
|
|
308
|
+
worktree_add(self.repo, Path(self.state.integration_worktree), self.state.integration_branch,
|
|
309
|
+
self.state.integration_branch, create_branch=False)
|
|
310
|
+
self._recover_interrupted_tasks()
|
|
311
|
+
self.state.paused = False
|
|
312
|
+
self.state.stop_reason = None
|
|
313
|
+
(self.paths.run_dir / "stop-request.json").unlink(missing_ok=True)
|
|
314
|
+
self._event("RUN_RESUMED")
|
|
315
|
+
self._run_locked()
|
|
316
|
+
except (RunStopped, KeyboardInterrupt) as exc:
|
|
317
|
+
self.state.paused = True
|
|
318
|
+
self.state.stop_reason = str(exc) or "Interrupted by operator"
|
|
319
|
+
self._event("RUN_PAUSED", reason=self.state.stop_reason)
|
|
320
|
+
self._sync_backlog()
|
|
321
|
+
finally:
|
|
322
|
+
self._release_run_lock()
|
|
323
|
+
|
|
324
|
+
def _acquire_run_lock(self) -> None:
|
|
325
|
+
self.paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
handle = self.paths.lock_file.open("a+b")
|
|
327
|
+
if self.paths.lock_file.stat().st_size == 0:
|
|
328
|
+
handle.write(b" ")
|
|
329
|
+
handle.flush()
|
|
330
|
+
handle.seek(0)
|
|
331
|
+
try:
|
|
332
|
+
if os.name == "nt":
|
|
333
|
+
import msvcrt
|
|
334
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
335
|
+
else:
|
|
336
|
+
import fcntl
|
|
337
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
338
|
+
except OSError as e:
|
|
339
|
+
handle.close()
|
|
340
|
+
raise ManagerError(f"Run {self.state.run_id} is already active in another manager process") from e
|
|
341
|
+
handle.seek(0)
|
|
342
|
+
handle.truncate()
|
|
343
|
+
handle.write(json.dumps({"pid": os.getpid(), "run_id": self.state.run_id}).encode("utf-8"))
|
|
344
|
+
handle.flush()
|
|
345
|
+
self._run_lock = handle
|
|
346
|
+
|
|
347
|
+
def _release_run_lock(self) -> None:
|
|
348
|
+
handle = self._run_lock
|
|
349
|
+
if handle is None:
|
|
350
|
+
return
|
|
351
|
+
try:
|
|
352
|
+
handle.seek(0)
|
|
353
|
+
if os.name == "nt":
|
|
354
|
+
import msvcrt
|
|
355
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
356
|
+
else:
|
|
357
|
+
import fcntl
|
|
358
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
359
|
+
finally:
|
|
360
|
+
handle.close()
|
|
361
|
+
self._run_lock = None
|
|
362
|
+
|
|
363
|
+
def _run_locked(self) -> None:
|
|
364
|
+
assert self.state is not None
|
|
365
|
+
baseline_path = self.paths.run_dir / "baseline.json"
|
|
366
|
+
evidence = json.loads(baseline_path.read_text(encoding="utf-8")) if baseline_path.exists() else {}
|
|
367
|
+
# Environment fixes and changed recipes must be rechecked on resume.
|
|
368
|
+
recipe = {"setup": self.config.setup_commands, "checks": self.config.verification_commands,
|
|
369
|
+
"env": self.config.required_env, "executables": self.config.required_executables,
|
|
370
|
+
"artifacts": self.config.verification_artifacts, "timeout": self.config.verification_timeout_seconds}
|
|
371
|
+
if evidence.get("recipe") != recipe or evidence.get("status") in {"ENVIRONMENT_BLOCKED", "FAIL"}:
|
|
372
|
+
evidence = baseline(self.repo, self.config, self.state.base_commit, self.agent_env)
|
|
373
|
+
evidence["recipe"] = recipe
|
|
374
|
+
self._write_artifact(baseline_path, json.dumps(evidence, indent=2))
|
|
375
|
+
if evidence["status"] == "ENVIRONMENT_BLOCKED":
|
|
376
|
+
raise RunStopped("Preflight environment blocked; inspect baseline.json and fix the environment recipe")
|
|
377
|
+
if self.config.require_verification and evidence["status"] != "PASS":
|
|
378
|
+
raise RunStopped("Preflight requires passing baseline checks; inspect baseline.json")
|
|
379
|
+
cycles = 0
|
|
380
|
+
while not self.state.paused:
|
|
381
|
+
if (self.paths.run_dir / "stop-request.json").exists():
|
|
382
|
+
raise RunStopped("Operator requested a stop")
|
|
383
|
+
cycles += 1
|
|
384
|
+
if cycles > self.config.max_manager_cycles:
|
|
385
|
+
self.state.paused = True
|
|
386
|
+
self.state.stop_reason = "max_manager_cycles reached"
|
|
387
|
+
self._event("RUN_PAUSED", reason=self.state.stop_reason)
|
|
388
|
+
break
|
|
389
|
+
|
|
390
|
+
self._refresh_blocking()
|
|
391
|
+
if all(t.status == TaskStatus.COMPLETE for t in self.state.tasks.values()):
|
|
392
|
+
if self.config.final_code_review and self.state.qa_passes_since_review:
|
|
393
|
+
if not self._run_code_review():
|
|
394
|
+
return
|
|
395
|
+
self.state.stop_reason = "all tasks complete"
|
|
396
|
+
self._sync_backlog(commit_integration=True)
|
|
397
|
+
self._event("RUN_COMPLETE")
|
|
398
|
+
self._persist()
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
progressed = False
|
|
402
|
+
if self._plan_available_work():
|
|
403
|
+
progressed = True
|
|
404
|
+
|
|
405
|
+
self._refresh_blocking()
|
|
406
|
+
reviewing = [t for t in self.state.tasks.values() if t.status == TaskStatus.CODE_REVIEW and not t.blocked]
|
|
407
|
+
if reviewing:
|
|
408
|
+
self._post_merge(reviewing[0])
|
|
409
|
+
progressed = True
|
|
410
|
+
if self.state.paused:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
ready = [t for t in self.state.tasks.values() if t.status == TaskStatus.READY and not t.blocked]
|
|
414
|
+
ready.sort(key=lambda t: (PRIORITY_ORDER[t.priority], t.task_id))
|
|
415
|
+
if ready:
|
|
416
|
+
# Deliberately serialize write-capable implementation. Planners remain parallel.
|
|
417
|
+
# This avoids hidden cross-task integration races while preserving safe task worktrees.
|
|
418
|
+
if self._ensure_plan_current(ready[0]):
|
|
419
|
+
self._execute_task(ready[0])
|
|
420
|
+
progressed = True
|
|
421
|
+
|
|
422
|
+
if not progressed:
|
|
423
|
+
actionable = [t for t in self.state.tasks.values() if t.status != TaskStatus.COMPLETE and not t.blocked]
|
|
424
|
+
if actionable:
|
|
425
|
+
self.state.paused = True
|
|
426
|
+
self.state.stop_reason = "no executable transition for current state"
|
|
427
|
+
else:
|
|
428
|
+
self.state.stop_reason = "no actionable work; remaining tasks are blocked"
|
|
429
|
+
self._sync_backlog(commit_integration=True)
|
|
430
|
+
self._event("RUN_STOPPED", reason=self.state.stop_reason)
|
|
431
|
+
self._persist()
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
def _refresh_blocking(self) -> None:
|
|
435
|
+
assert self.state is not None
|
|
436
|
+
complete = {tid for tid, t in self.state.tasks.items() if t.status == TaskStatus.COMPLETE}
|
|
437
|
+
for t in self.state.tasks.values():
|
|
438
|
+
if t.status == TaskStatus.COMPLETE:
|
|
439
|
+
t.blocked_reason = None
|
|
440
|
+
t.metadata.pop("blocked_kind", None)
|
|
441
|
+
continue
|
|
442
|
+
questions = unanswered(t.metadata.get("contract", {}))
|
|
443
|
+
if questions:
|
|
444
|
+
t.blocked_reason = "Clarification required: " + ", ".join(questions)
|
|
445
|
+
t.metadata["blocked_kind"] = "clarification"
|
|
446
|
+
continue
|
|
447
|
+
missing = [d for d in t.dependencies if d not in complete]
|
|
448
|
+
if missing:
|
|
449
|
+
t.blocked_reason = "Waiting for dependencies: " + ", ".join(missing)
|
|
450
|
+
t.metadata["blocked_kind"] = "dependency"
|
|
451
|
+
elif not t.acceptance_criteria:
|
|
452
|
+
t.blocked_reason = "Acceptance criteria are required before planning"
|
|
453
|
+
t.metadata["blocked_kind"] = "acceptance"
|
|
454
|
+
elif t.metadata.get("blocked_kind") in {"dependency", "acceptance", "clarification"}:
|
|
455
|
+
t.blocked_reason = None
|
|
456
|
+
t.metadata.pop("blocked_kind", None)
|
|
457
|
+
self._persist()
|
|
458
|
+
|
|
459
|
+
def _planning_candidates(self) -> list[BacklogTask]:
|
|
460
|
+
assert self.state is not None
|
|
461
|
+
items = [t for t in self.state.tasks.values() if t.status in {TaskStatus.UNPLANNED, TaskStatus.PLAN_REVIEW} and not t.blocked]
|
|
462
|
+
items.sort(key=lambda t: (PRIORITY_ORDER[t.priority], t.task_id))
|
|
463
|
+
return items
|
|
464
|
+
|
|
465
|
+
def _plan_available_work(self) -> bool:
|
|
466
|
+
assert self.state is not None
|
|
467
|
+
candidates = self._planning_candidates()
|
|
468
|
+
if not candidates:
|
|
469
|
+
return False
|
|
470
|
+
slots = min(self.config.max_active_planners, len(candidates))
|
|
471
|
+
# If there are existing plans awaiting review, review them first without spawning planners.
|
|
472
|
+
existing = [t for t in candidates if t.status == TaskStatus.PLAN_REVIEW and t.plan_path]
|
|
473
|
+
for t in existing:
|
|
474
|
+
plan_file = self.repo / t.plan_path
|
|
475
|
+
if not plan_file.exists():
|
|
476
|
+
t.status = TaskStatus.UNPLANNED
|
|
477
|
+
t.plan_path = None
|
|
478
|
+
continue
|
|
479
|
+
plan = plan_file.read_text(encoding="utf-8")
|
|
480
|
+
self._review_plan(t, plan)
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
candidates = [t for t in candidates if t.status == TaskStatus.UNPLANNED]
|
|
484
|
+
if not candidates:
|
|
485
|
+
return False
|
|
486
|
+
for t in candidates:
|
|
487
|
+
if fast_path(t, self.config):
|
|
488
|
+
plan = "Implement the operator-approved task agreement. Preserve scope and run all acceptance checks.\n" + json.dumps(t.metadata["contract"], indent=2)
|
|
489
|
+
path = self.paths.plans_dir / f"{t.task_id}.md"
|
|
490
|
+
self._write_artifact(path, plan)
|
|
491
|
+
t.plan_path = str(path.relative_to(self.repo))
|
|
492
|
+
t.plan_base_commit = head(Path(self.state.integration_worktree))
|
|
493
|
+
t.status = TaskStatus.READY
|
|
494
|
+
t.metadata["workflow"] = "adaptive_low"
|
|
495
|
+
self._event("ADAPTIVE_PLAN", task_id=t.task_id)
|
|
496
|
+
self._sync_backlog()
|
|
497
|
+
return True
|
|
498
|
+
completed = [tid for tid, t in self.state.tasks.items() if t.status == TaskStatus.COMPLETE]
|
|
499
|
+
decision = {"task_ids": [t.task_id for t in candidates[:slots]], "reason": "priority and dependency order"}
|
|
500
|
+
if self.config.scheduler == "model":
|
|
501
|
+
result = self._call(
|
|
502
|
+
select_work(candidates, slots, completed),
|
|
503
|
+
cwd=Path(self.state.integration_worktree),
|
|
504
|
+
role="analysis",
|
|
505
|
+
max_turns=self.config.max_turns_manager,
|
|
506
|
+
env=self.agent_env,
|
|
507
|
+
)
|
|
508
|
+
decision = extract_json(result.text)
|
|
509
|
+
chosen = []
|
|
510
|
+
seen = set()
|
|
511
|
+
allowed = {t.task_id: t for t in candidates}
|
|
512
|
+
for tid in decision.get("task_ids", []):
|
|
513
|
+
if tid in allowed and tid not in seen and len(chosen) < slots:
|
|
514
|
+
chosen.append(allowed[tid])
|
|
515
|
+
seen.add(tid)
|
|
516
|
+
if not chosen:
|
|
517
|
+
# Deterministic safety fallback: manager cannot deadlock planning by returning malformed/empty selection.
|
|
518
|
+
chosen = candidates[:slots]
|
|
519
|
+
self._event("PLANNER_BATCH_SELECTED", task_ids=[t.task_id for t in chosen], rationale=decision.get("reason", ""))
|
|
520
|
+
integration = Path(self.state.integration_worktree)
|
|
521
|
+
snapshot = repo_snapshot(integration)
|
|
522
|
+
for t in chosen:
|
|
523
|
+
t.status = TaskStatus.PLANNING
|
|
524
|
+
t.owner = f"planner:{t.task_id}"
|
|
525
|
+
self.state.active_planners.append(t.task_id)
|
|
526
|
+
self._sync_backlog()
|
|
527
|
+
self._persist()
|
|
528
|
+
|
|
529
|
+
plans: dict[str, str] = {}
|
|
530
|
+
errors: dict[str, str] = {}
|
|
531
|
+
stop_reason = None
|
|
532
|
+
with ThreadPoolExecutor(max_workers=len(chosen)) as pool:
|
|
533
|
+
futs = {
|
|
534
|
+
pool.submit(
|
|
535
|
+
self._call,
|
|
536
|
+
planner(t, snapshot),
|
|
537
|
+
cwd=integration,
|
|
538
|
+
role="analysis",
|
|
539
|
+
max_turns=self.config.max_turns_planner,
|
|
540
|
+
env=self.agent_env,
|
|
541
|
+
): t
|
|
542
|
+
for t in chosen
|
|
543
|
+
}
|
|
544
|
+
for fut in as_completed(futs):
|
|
545
|
+
t = futs[fut]
|
|
546
|
+
try:
|
|
547
|
+
plans[t.task_id] = fut.result().text
|
|
548
|
+
except RunStopped as exc:
|
|
549
|
+
stop_reason = str(exc)
|
|
550
|
+
errors[t.task_id] = str(exc)
|
|
551
|
+
except Exception as e: # isolated agent failure; preserve other planner results
|
|
552
|
+
errors[t.task_id] = str(e)
|
|
553
|
+
|
|
554
|
+
for t in chosen:
|
|
555
|
+
if t.task_id in self.state.active_planners:
|
|
556
|
+
self.state.active_planners.remove(t.task_id)
|
|
557
|
+
if t.task_id in errors:
|
|
558
|
+
t.status = TaskStatus.UNPLANNED
|
|
559
|
+
t.owner = None
|
|
560
|
+
t.last_error = errors[t.task_id]
|
|
561
|
+
self._event("PLANNER_FAILED", task_id=t.task_id, error=errors[t.task_id])
|
|
562
|
+
continue
|
|
563
|
+
plan = plans[t.task_id]
|
|
564
|
+
path = self.paths.plans_dir / f"{t.task_id}.md"
|
|
565
|
+
self._write_artifact(path, plan)
|
|
566
|
+
t.plan_path = str(path.relative_to(self.repo)) if path.is_relative_to(self.repo) else str(path)
|
|
567
|
+
t.plan_base_commit = head(integration)
|
|
568
|
+
t.status = TaskStatus.PLAN_REVIEW
|
|
569
|
+
t.owner = "manager"
|
|
570
|
+
self._event("PLAN_WRITTEN", task_id=t.task_id, path=str(path))
|
|
571
|
+
self._sync_backlog()
|
|
572
|
+
self._persist()
|
|
573
|
+
if stop_reason:
|
|
574
|
+
raise RunStopped(stop_reason)
|
|
575
|
+
for t in chosen:
|
|
576
|
+
if t.status == TaskStatus.PLAN_REVIEW:
|
|
577
|
+
self._review_plan(t, self._task_plan(t))
|
|
578
|
+
self._sync_backlog()
|
|
579
|
+
self._persist()
|
|
580
|
+
return True
|
|
581
|
+
|
|
582
|
+
def _review_plan(self, t: BacklogTask, plan: str) -> None:
|
|
583
|
+
assert self.state is not None
|
|
584
|
+
integration = Path(self.state.integration_worktree)
|
|
585
|
+
snapshot = repo_snapshot(integration)
|
|
586
|
+
while True:
|
|
587
|
+
result = self._call(
|
|
588
|
+
plan_review(t, plan, snapshot),
|
|
589
|
+
cwd=integration,
|
|
590
|
+
role="analysis",
|
|
591
|
+
max_turns=self.config.max_turns_manager,
|
|
592
|
+
env=self.agent_env,
|
|
593
|
+
)
|
|
594
|
+
review = extract_json(result.text)
|
|
595
|
+
verdict = str(review.get("verdict", "REWORK")).upper()
|
|
596
|
+
review_path = self.paths.plans_dir / f"{t.task_id}.review.{t.plan_reworks}.json"
|
|
597
|
+
self._write_artifact(review_path, json.dumps(review, indent=2))
|
|
598
|
+
if verdict == "NEEDS_CLARIFICATION":
|
|
599
|
+
questions = review.get("questions", [])
|
|
600
|
+
if not isinstance(questions, list) or not questions or any(not isinstance(q, str) or not q.strip() for q in questions):
|
|
601
|
+
raise ManagerError("Clarification response must contain nonempty questions")
|
|
602
|
+
contract = t.metadata.setdefault("contract", {})
|
|
603
|
+
stored = contract.setdefault("questions", {})
|
|
604
|
+
for question in questions:
|
|
605
|
+
index = 1
|
|
606
|
+
while f"Q-{index}" in stored:
|
|
607
|
+
index += 1
|
|
608
|
+
stored[f"Q-{index}"] = question
|
|
609
|
+
t.blocked_reason = "Plan needs clarification; use questions and answer commands"
|
|
610
|
+
t.metadata["blocked_kind"] = "clarification"
|
|
611
|
+
self._event("CLARIFICATION_REQUESTED", task_id=t.task_id, questions=stored)
|
|
612
|
+
return
|
|
613
|
+
if verdict == "APPROVED":
|
|
614
|
+
t.status = TaskStatus.READY
|
|
615
|
+
t.owner = None
|
|
616
|
+
t.next_action = "Implement approved plan"
|
|
617
|
+
t.plan_base_commit = head(integration)
|
|
618
|
+
t.plan_reworks = 0
|
|
619
|
+
self._event("PLAN_APPROVED", task_id=t.task_id, review=str(review_path))
|
|
620
|
+
return
|
|
621
|
+
t.plan_reworks += 1
|
|
622
|
+
if t.plan_reworks > self.config.max_plan_reworks:
|
|
623
|
+
t.status = TaskStatus.UNPLANNED
|
|
624
|
+
t.owner = None
|
|
625
|
+
t.blocked_reason = "Plan rework limit reached; manager intervention required"
|
|
626
|
+
t.metadata["blocked_kind"] = "plan_rework_limit"
|
|
627
|
+
t.last_error = review.get("feedback", "Plan not approved")
|
|
628
|
+
self._event("PLAN_REWORK_LIMIT", task_id=t.task_id, feedback=t.last_error)
|
|
629
|
+
return
|
|
630
|
+
revised = self._call(
|
|
631
|
+
plan_rework(t, plan, review.get("feedback", "Address material review gaps.")),
|
|
632
|
+
cwd=integration,
|
|
633
|
+
role="analysis",
|
|
634
|
+
max_turns=self.config.max_turns_planner,
|
|
635
|
+
env=self.agent_env,
|
|
636
|
+
)
|
|
637
|
+
plan = revised.text
|
|
638
|
+
path = self.paths.plans_dir / f"{t.task_id}.md"
|
|
639
|
+
self._write_artifact(path, plan)
|
|
640
|
+
self._event("PLAN_REWORKED", task_id=t.task_id, attempt=t.plan_reworks)
|
|
641
|
+
|
|
642
|
+
def _ensure_plan_current(self, t: BacklogTask) -> bool:
|
|
643
|
+
"""Re-review an approved plan when integration advanced after planning."""
|
|
644
|
+
assert self.state is not None
|
|
645
|
+
integration = Path(self.state.integration_worktree)
|
|
646
|
+
current = head(integration)
|
|
647
|
+
if not t.plan_base_commit or t.plan_base_commit == current:
|
|
648
|
+
return True
|
|
649
|
+
source_changes = git(integration, "diff", "--name-only", t.plan_base_commit, current, "--", ".",
|
|
650
|
+
f":(exclude){self.config.backlog_file}", f":(exclude){self.config.manager_dir}/**").stdout
|
|
651
|
+
if not source_changes:
|
|
652
|
+
t.plan_base_commit = current
|
|
653
|
+
self._persist()
|
|
654
|
+
return True
|
|
655
|
+
old_base = t.plan_base_commit
|
|
656
|
+
t.status = TaskStatus.PLAN_REVIEW
|
|
657
|
+
t.owner = "manager"
|
|
658
|
+
t.next_action = "Revalidate plan against current integration branch"
|
|
659
|
+
self._event("STALE_PLAN_REVIEW_STARTED", task_id=t.task_id, plan_base=old_base, current=current)
|
|
660
|
+
self._review_plan(t, self._task_plan(t))
|
|
661
|
+
return t.status == TaskStatus.READY
|
|
662
|
+
|
|
663
|
+
def _task_plan(self, t: BacklogTask) -> str:
|
|
664
|
+
if not t.plan_path:
|
|
665
|
+
raise ManagerError(f"{t.task_id} has no approved plan")
|
|
666
|
+
p = Path(t.plan_path)
|
|
667
|
+
if not p.is_absolute():
|
|
668
|
+
p = self.repo / p
|
|
669
|
+
if not p.exists():
|
|
670
|
+
# Current runs normally store plans under .manager/runs, which is absolute in state on some platforms.
|
|
671
|
+
alt = self.paths.plans_dir / f"{t.task_id}.md"
|
|
672
|
+
if alt.exists():
|
|
673
|
+
p = alt
|
|
674
|
+
else:
|
|
675
|
+
raise ManagerError(f"Plan file missing for {t.task_id}: {t.plan_path}")
|
|
676
|
+
return p.read_text(encoding="utf-8")
|
|
677
|
+
|
|
678
|
+
def _prepare_task_worktree(self, t: BacklogTask) -> Path:
|
|
679
|
+
assert self.state is not None
|
|
680
|
+
if t.worktree_path and Path(t.worktree_path).exists():
|
|
681
|
+
return Path(t.worktree_path)
|
|
682
|
+
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", t.task_id)
|
|
683
|
+
branch = f"{self.config.task_branch_prefix}/{self.state.run_id}/{safe_id}-g{t.generation}"
|
|
684
|
+
path = self.paths.worktrees_root / "tasks" / f"{safe_id}-g{t.generation}"
|
|
685
|
+
start = head(Path(self.state.integration_worktree))
|
|
686
|
+
if branch_exists(self.repo, branch):
|
|
687
|
+
worktree_add(self.repo, path, branch, branch, create_branch=False)
|
|
688
|
+
else:
|
|
689
|
+
worktree_add(self.repo, path, branch, start, create_branch=True)
|
|
690
|
+
t.worktree_path = str(path)
|
|
691
|
+
t.branch_name = branch
|
|
692
|
+
return path
|
|
693
|
+
|
|
694
|
+
def _execute_task(self, t: BacklogTask) -> None:
|
|
695
|
+
assert self.state is not None
|
|
696
|
+
plan = self._task_plan(t)
|
|
697
|
+
worktree = self._prepare_task_worktree(t)
|
|
698
|
+
t.metadata.setdefault("implementation_base", head(worktree))
|
|
699
|
+
reproduction = t.metadata.get("contract", {}).get("reproduction_commands", [])
|
|
700
|
+
if reproduction and "reproduction_before" not in t.metadata:
|
|
701
|
+
t.metadata["reproduction_before"] = baseline(self.repo, replace(self.config, verification_commands=reproduction),
|
|
702
|
+
t.metadata["implementation_base"], self.agent_env)
|
|
703
|
+
self._persist()
|
|
704
|
+
resume_mode = str(t.metadata.get("resume_mode", "implementation"))
|
|
705
|
+
checkpoint_commit = t.checkpoint_commit
|
|
706
|
+
|
|
707
|
+
if resume_mode == "merge" and checkpoint_commit:
|
|
708
|
+
merged = self._merge_task(t, worktree, plan)
|
|
709
|
+
if merged:
|
|
710
|
+
self._count_qa_pass(t, t.checkpoint_commit or checkpoint_commit)
|
|
711
|
+
self._post_merge(t)
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
if resume_mode == "qa" and checkpoint_commit:
|
|
715
|
+
self._event("QA_RESTARTED_AFTER_INTERRUPTION", task_id=t.task_id, commit=checkpoint_commit)
|
|
716
|
+
else:
|
|
717
|
+
t.status = TaskStatus.IMPLEMENTING
|
|
718
|
+
t.owner = f"implementer:{t.task_id}:g{t.generation}"
|
|
719
|
+
self._sync_backlog()
|
|
720
|
+
self._event("IMPLEMENTATION_STARTED", task_id=t.task_id, generation=t.generation, branch=t.branch_name, recovery=resume_mode)
|
|
721
|
+
|
|
722
|
+
if resume_mode == "repair" and t.qa_failures > self.config.max_fix_attempts_before_restart:
|
|
723
|
+
checkpoint_commit = self._clean_restart(t, worktree, plan, self._last_qa_evidence(t))
|
|
724
|
+
t.checkpoint_commit = checkpoint_commit
|
|
725
|
+
t.metadata["resume_mode"] = "qa"
|
|
726
|
+
self._persist()
|
|
727
|
+
return self._execute_task(t)
|
|
728
|
+
if resume_mode == "repair" and t.implementer_session_id:
|
|
729
|
+
evidence = self._last_qa_evidence(t)
|
|
730
|
+
result = self._call(
|
|
731
|
+
same_implementer_fix(t, evidence, max(1, t.qa_failures)),
|
|
732
|
+
cwd=worktree,
|
|
733
|
+
role="implementer",
|
|
734
|
+
max_turns=self.config.max_turns_implementer,
|
|
735
|
+
resume_session=t.implementer_session_id,
|
|
736
|
+
env=self.agent_env,
|
|
737
|
+
)
|
|
738
|
+
elif t.implementer_session_id:
|
|
739
|
+
result = self._call(
|
|
740
|
+
"Resume the assigned implementation after a manager interruption. Re-read the approved plan and current worktree, finish the task, validate it, and report what remains.",
|
|
741
|
+
cwd=worktree,
|
|
742
|
+
role="implementer",
|
|
743
|
+
max_turns=self.config.max_turns_implementer,
|
|
744
|
+
resume_session=t.implementer_session_id,
|
|
745
|
+
env=self.agent_env,
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
packet_path = t.metadata.get("restart_packet")
|
|
749
|
+
initial_prompt = fresh_implementer(t, Path(packet_path).read_text(encoding="utf-8")) if resume_mode == "fresh" and packet_path else implement(t, plan)
|
|
750
|
+
result = self._call(
|
|
751
|
+
initial_prompt,
|
|
752
|
+
cwd=worktree,
|
|
753
|
+
role="implementer",
|
|
754
|
+
max_turns=self.config.max_turns_implementer,
|
|
755
|
+
env=self.agent_env,
|
|
756
|
+
)
|
|
757
|
+
t.implementer_session_id = result.session_id
|
|
758
|
+
self._write_artifact(self.paths.logs_dir / f"{t.task_id}.g{t.generation}.implement.md", result.text)
|
|
759
|
+
self._guard_agent_changes(t, worktree)
|
|
760
|
+
checkpoint_commit = checkpoint(worktree, f"manager checkpoint: {t.task_id} implementation g{t.generation}")
|
|
761
|
+
t.checkpoint_commit = checkpoint_commit
|
|
762
|
+
self._event("IMPLEMENTATION_CHECKPOINT", task_id=t.task_id, commit=checkpoint_commit, session=t.implementer_session_id)
|
|
763
|
+
|
|
764
|
+
while True:
|
|
765
|
+
assert checkpoint_commit is not None
|
|
766
|
+
qa_result = self._run_qa(t, plan, checkpoint_commit)
|
|
767
|
+
verdict = str(qa_result.get("verdict", "UNVERIFIED")).upper()
|
|
768
|
+
if verdict == "PASS":
|
|
769
|
+
t.status = TaskStatus.QA_PASSED
|
|
770
|
+
t.owner = "manager"
|
|
771
|
+
self._sync_backlog()
|
|
772
|
+
self._event("QA_PASSED", task_id=t.task_id, commit=checkpoint_commit)
|
|
773
|
+
merged = self._merge_task(t, worktree, plan)
|
|
774
|
+
if not merged:
|
|
775
|
+
# _merge_task put the task back through repair/QA or paused it.
|
|
776
|
+
return
|
|
777
|
+
self._count_qa_pass(t, t.checkpoint_commit or checkpoint_commit)
|
|
778
|
+
self._post_merge(t)
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
t.status = TaskStatus.QA_FAIL
|
|
782
|
+
t.qa_failures += 1
|
|
783
|
+
tree = git(worktree, "rev-parse", f"{checkpoint_commit}^{{tree}}").stdout
|
|
784
|
+
signature = failure_signature(qa_result) + tree
|
|
785
|
+
prior = t.metadata.get("failure_signature")
|
|
786
|
+
t.metadata["identical_failures"] = t.metadata.get("identical_failures", 0) + 1 if prior == signature else 1
|
|
787
|
+
t.metadata["failure_signature"] = signature
|
|
788
|
+
if t.metadata["identical_failures"] >= self.config.repeated_failure_limit:
|
|
789
|
+
t.blocked_reason = "Repeated failure with no code changes; inspect evidence before retrying"
|
|
790
|
+
t.metadata["blocked_kind"] = "no_progress"
|
|
791
|
+
self._sync_backlog()
|
|
792
|
+
raise RunStopped(f"{t.task_id}: {t.blocked_reason}")
|
|
793
|
+
evidence = json.dumps(qa_result, indent=2)
|
|
794
|
+
self._event("QA_FAILED", task_id=t.task_id, verdict=verdict, failures=t.qa_failures)
|
|
795
|
+
if t.qa_failures <= self.config.max_fix_attempts_before_restart:
|
|
796
|
+
t.status = TaskStatus.IMPLEMENTING
|
|
797
|
+
t.metadata["resume_mode"] = "repair"
|
|
798
|
+
self._event("REPAIR_STARTED", task_id=t.task_id, attempt=t.qa_failures)
|
|
799
|
+
fix = self._call(
|
|
800
|
+
same_implementer_fix(t, evidence, t.qa_failures),
|
|
801
|
+
cwd=worktree,
|
|
802
|
+
role="implementer",
|
|
803
|
+
max_turns=self.config.max_turns_implementer,
|
|
804
|
+
resume_session=t.implementer_session_id,
|
|
805
|
+
env=self.agent_env,
|
|
806
|
+
)
|
|
807
|
+
self._write_artifact(self.paths.logs_dir / f"{t.task_id}.g{t.generation}.fix{t.qa_failures}.md", fix.text)
|
|
808
|
+
self._guard_agent_changes(t, worktree)
|
|
809
|
+
checkpoint_commit = checkpoint(worktree, f"manager checkpoint: {t.task_id} repair {t.qa_failures}")
|
|
810
|
+
t.checkpoint_commit = checkpoint_commit
|
|
811
|
+
continue
|
|
812
|
+
|
|
813
|
+
if t.generation + 1 >= self.config.max_fresh_implementer_generations:
|
|
814
|
+
t.blocked_reason = "Repeated QA failures exceeded fresh-implementer generation limit"
|
|
815
|
+
t.metadata["blocked_kind"] = "qa_generation_limit"
|
|
816
|
+
t.owner = None
|
|
817
|
+
self.state.paused = True
|
|
818
|
+
self.state.stop_reason = f"{t.task_id} requires human escalation after repeated clean restarts"
|
|
819
|
+
self._event("TASK_ESCALATED_TO_HUMAN", task_id=t.task_id, generations=t.generation + 1)
|
|
820
|
+
self._sync_backlog()
|
|
821
|
+
self._persist()
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
checkpoint_commit = self._clean_restart(t, worktree, plan, evidence)
|
|
825
|
+
t.checkpoint_commit = checkpoint_commit
|
|
826
|
+
|
|
827
|
+
def _last_qa_evidence(self, t: BacklogTask) -> str:
|
|
828
|
+
if t.last_qa_path:
|
|
829
|
+
path = Path(t.last_qa_path)
|
|
830
|
+
if path.exists():
|
|
831
|
+
return path.read_text(encoding="utf-8")
|
|
832
|
+
return json.dumps({
|
|
833
|
+
"verdict": "UNVERIFIED",
|
|
834
|
+
"summary": "The prior QA turn was interrupted or its artifact is unavailable. Re-inspect the current worktree and acceptance criteria.",
|
|
835
|
+
}, indent=2)
|
|
836
|
+
|
|
837
|
+
def _count_qa_pass(self, t: BacklogTask, commit: str) -> None:
|
|
838
|
+
assert self.state is not None
|
|
839
|
+
if t.metadata.get("counted_qa_commit") == commit:
|
|
840
|
+
return
|
|
841
|
+
self.state.qa_passes_since_review += 1
|
|
842
|
+
t.metadata["counted_qa_commit"] = commit
|
|
843
|
+
self._event("QA_PASS_COUNTED", task_id=t.task_id, commit=commit, pass_counter=self.state.qa_passes_since_review)
|
|
844
|
+
|
|
845
|
+
def _guard_agent_changes(self, t: BacklogTask, worktree: Path) -> None:
|
|
846
|
+
paths = changed_paths(worktree)
|
|
847
|
+
violations = [p for p in paths if matches_protected(p, self.config.protected_paths, self.config.backlog_file)]
|
|
848
|
+
if violations:
|
|
849
|
+
restore_paths(worktree, violations)
|
|
850
|
+
raise ManagerError(f"{t.task_id}: agent attempted to modify protected paths: {violations}")
|
|
851
|
+
|
|
852
|
+
def _run_qa(self, t: BacklogTask, plan: str, commit: str) -> dict[str, Any]:
|
|
853
|
+
assert self.state is not None
|
|
854
|
+
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", t.task_id)
|
|
855
|
+
qa_path = self.paths.worktrees_root / "qa" / f"{safe_id}-{int(time.time() * 1000)}"
|
|
856
|
+
detached_worktree_add(self.repo, qa_path, commit)
|
|
857
|
+
try:
|
|
858
|
+
t.metadata.pop("resume_mode", None)
|
|
859
|
+
t.status = TaskStatus.QA
|
|
860
|
+
t.owner = f"qa:{t.task_id}"
|
|
861
|
+
t.checkpoint_commit = commit
|
|
862
|
+
self._sync_backlog()
|
|
863
|
+
self._event("QA_STARTED", task_id=t.task_id, commit=commit)
|
|
864
|
+
verification = execute_checks(self.config, qa_path, self.agent_env)
|
|
865
|
+
checks = list(verification["checks"])
|
|
866
|
+
contract = t.metadata.get("contract", {})
|
|
867
|
+
mapped = {}
|
|
868
|
+
reproduction_after = []
|
|
869
|
+
if verification["status"] not in {"FAIL", "ENVIRONMENT_BLOCKED"}:
|
|
870
|
+
for criterion, commands in contract.get("acceptance_checks", {}).items():
|
|
871
|
+
mapped[criterion] = run_checks(commands, qa_path, timeout=self.config.verification_timeout_seconds,
|
|
872
|
+
limit=self.config.evidence_max_chars, env=self.agent_env)
|
|
873
|
+
checks.extend(mapped[criterion])
|
|
874
|
+
if contract.get("reproduction_commands"):
|
|
875
|
+
reproduction_after = run_checks(contract["reproduction_commands"], qa_path,
|
|
876
|
+
timeout=self.config.verification_timeout_seconds,
|
|
877
|
+
limit=self.config.evidence_max_chars, env=self.agent_env)
|
|
878
|
+
checks.extend(reproduction_after)
|
|
879
|
+
failed = any(c["exit_code"] != 0 or c["timed_out"] for c in checks)
|
|
880
|
+
if verification["status"] == "ENVIRONMENT_BLOCKED":
|
|
881
|
+
self._write_artifact(self.paths.qa_dir / f"{t.task_id}.environment.json", json.dumps(verification, indent=2))
|
|
882
|
+
raise RunStopped(f"{t.task_id}: verification environment blocked; no repair attempted")
|
|
883
|
+
if self.config.require_verification and not checks:
|
|
884
|
+
raise RunStopped("Verification commands are required before spending QA calls")
|
|
885
|
+
model_qa_ran = False
|
|
886
|
+
if failed:
|
|
887
|
+
obj = {"verdict": "FAIL", "summary": "Configured verification failed before model QA",
|
|
888
|
+
"failures": [c for c in checks if c["exit_code"] != 0 or c["timed_out"]]}
|
|
889
|
+
elif verification_mutations(self.config, qa_path):
|
|
890
|
+
obj = {"verdict": "UNVERIFIED", "summary": "Configured checks modified the verification worktree"}
|
|
891
|
+
else:
|
|
892
|
+
evidence = json.dumps(checks, indent=2)
|
|
893
|
+
result = self._call(
|
|
894
|
+
qa(t, plan, []) + "\nHarness verification at this exact commit (do not repeat successful commands without a reason):\n" + evidence,
|
|
895
|
+
cwd=qa_path, role="read_only", max_turns=self.config.max_turns_qa, env=self.agent_env,
|
|
896
|
+
)
|
|
897
|
+
obj = extract_json(result.text)
|
|
898
|
+
self._normalize_task_qa(t, obj)
|
|
899
|
+
model_qa_ran = True
|
|
900
|
+
obj["harness_checks"] = checks
|
|
901
|
+
obj["acceptance_checks"] = mapped
|
|
902
|
+
obj["reproduction"] = {"before": t.metadata.get("reproduction_before"), "after": reproduction_after}
|
|
903
|
+
obj["environment"] = verification
|
|
904
|
+
obj["verification_level"] = ("executed_checks_and_model" if checks else "model_only") if model_qa_ran else "deterministic_checks_only"
|
|
905
|
+
obj["artifacts"] = collect_artifacts(self.config, qa_path, self.paths.run_dir / "artifacts")
|
|
906
|
+
changes = change_evidence(self.repo, t.metadata.get("implementation_base", t.plan_base_commit or self.state.base_commit), commit, self.config, contract)
|
|
907
|
+
obj["changes"] = changes
|
|
908
|
+
if any("outside_declared_scope" in f["warnings"] for f in changes["files"]):
|
|
909
|
+
obj["verdict"] = "FAIL"
|
|
910
|
+
obj.setdefault("failures", []).append("Changes exceed operator-declared allowed_paths")
|
|
911
|
+
if obj.get("verdict") == "PASS" and (t.metadata.get("workflow") == "adaptive_low" or contract.get("risk") == "high"):
|
|
912
|
+
focused = self._review_changes(qa_path, changes["base_commit"], commit, [t.task_id])
|
|
913
|
+
obj["focused_review"] = focused
|
|
914
|
+
if self._review_requires_changes(focused):
|
|
915
|
+
obj["verdict"] = "FAIL"
|
|
916
|
+
obj.setdefault("failures", []).append(focused)
|
|
917
|
+
obj["baseline"] = json.loads((self.paths.run_dir / "baseline.json").read_text(encoding="utf-8")) if (self.paths.run_dir / "baseline.json").exists() else None
|
|
918
|
+
baseline_failures = {c["command"] for c in (obj["baseline"] or {}).get("checks", []) if c["exit_code"] or c["timed_out"]}
|
|
919
|
+
obj["failure_classification"] = [{"command": c["command"],
|
|
920
|
+
"classification": "also_failed_on_baseline" if c["command"] in baseline_failures else "new_failure_or_environment_change"}
|
|
921
|
+
for c in checks if c["exit_code"] or c["timed_out"]]
|
|
922
|
+
obj["checkpoint_commit"] = commit
|
|
923
|
+
# QA is disposable. Record if it mutated anything, but discard regardless.
|
|
924
|
+
mutation = verification_mutations(self.config, qa_path)
|
|
925
|
+
if mutation:
|
|
926
|
+
obj.setdefault("risks", []).append(f"QA agent mutated disposable worktree paths (discarded): {mutation}")
|
|
927
|
+
if str(obj.get("verdict", "")).upper() == "PASS":
|
|
928
|
+
obj["verdict"] = "UNVERIFIED"
|
|
929
|
+
obj["summary"] = "QA mutated its supposedly read-only verification worktree; pass rejected."
|
|
930
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
|
|
931
|
+
artifact = self.paths.qa_dir / f"{t.task_id}.{stamp}.json"
|
|
932
|
+
self._write_artifact(artifact, json.dumps(obj, indent=2))
|
|
933
|
+
t.last_qa_path = str(artifact)
|
|
934
|
+
return obj
|
|
935
|
+
finally:
|
|
936
|
+
worktree_remove(self.repo, qa_path, force=True)
|
|
937
|
+
|
|
938
|
+
def _normalize_task_qa(self, t: BacklogTask, obj: dict[str, Any]) -> None:
|
|
939
|
+
"""Reject optimistic PASS wrappers that do not support every criterion."""
|
|
940
|
+
verdict = str(obj.get("verdict", "UNVERIFIED")).upper()
|
|
941
|
+
if verdict not in {"PASS", "FAIL", "UNVERIFIED"}:
|
|
942
|
+
obj["verdict"] = "UNVERIFIED"
|
|
943
|
+
obj["summary"] = f"QA returned unsupported verdict {verdict!r}."
|
|
944
|
+
return
|
|
945
|
+
obj["verdict"] = verdict
|
|
946
|
+
if verdict != "PASS":
|
|
947
|
+
return
|
|
948
|
+
acceptance = obj.get("acceptance")
|
|
949
|
+
expected = {f"AC-{i+1}": criterion for i, criterion in enumerate(t.acceptance_criteria)}
|
|
950
|
+
seen = set()
|
|
951
|
+
supported = isinstance(acceptance, list) and len(acceptance) == len(expected)
|
|
952
|
+
for item in acceptance if isinstance(acceptance, list) else []:
|
|
953
|
+
if not isinstance(item, dict):
|
|
954
|
+
supported = False
|
|
955
|
+
continue
|
|
956
|
+
criterion_id = item.get("criterion_id")
|
|
957
|
+
if criterion_id is None:
|
|
958
|
+
matches = [key for key, text in expected.items() if text == item.get("criterion")]
|
|
959
|
+
criterion_id = matches[0] if len(matches) == 1 else None
|
|
960
|
+
if (not isinstance(criterion_id, str) or criterion_id not in expected or criterion_id in seen
|
|
961
|
+
or str(item.get("status", "")).upper() != "PASS"
|
|
962
|
+
or not isinstance(item.get("evidence"), str) or not item["evidence"].strip()):
|
|
963
|
+
supported = False
|
|
964
|
+
seen.add(criterion_id if isinstance(criterion_id, str) else None)
|
|
965
|
+
supported = supported and seen == set(expected)
|
|
966
|
+
if not supported:
|
|
967
|
+
obj["verdict"] = "UNVERIFIED"
|
|
968
|
+
obj["summary"] = "QA claimed PASS without PASS evidence for every acceptance criterion."
|
|
969
|
+
obj.setdefault("failures", []).append("Acceptance evidence was missing, incomplete, or non-passing.")
|
|
970
|
+
|
|
971
|
+
def _clean_restart(self, t: BacklogTask, worktree: Path, plan: str, evidence: str) -> str:
|
|
972
|
+
assert self.state is not None
|
|
973
|
+
if t.generation + 1 >= self.config.max_fresh_implementer_generations:
|
|
974
|
+
t.blocked_reason = "Fresh-implementer generation limit reached"
|
|
975
|
+
t.metadata["blocked_kind"] = "qa_generation_limit"
|
|
976
|
+
raise RunStopped(f"{t.task_id}: {t.blocked_reason}")
|
|
977
|
+
classification = extract_json(self._call(
|
|
978
|
+
classify_escalation(t, evidence),
|
|
979
|
+
cwd=worktree,
|
|
980
|
+
role="analysis",
|
|
981
|
+
max_turns=self.config.max_turns_manager,
|
|
982
|
+
env=self.agent_env,
|
|
983
|
+
).text)
|
|
984
|
+
role = str(classification.get("role", "INVESTIGATOR")).upper()
|
|
985
|
+
if role not in {"INVESTIGATOR", "AUDITOR"}:
|
|
986
|
+
role = "INVESTIGATOR"
|
|
987
|
+
question = str(classification.get("question", "Establish the concrete cause and scope of the repeated QA failure."))
|
|
988
|
+
diagnosis = extract_json(self._call(
|
|
989
|
+
diagnose(t, role, question, evidence),
|
|
990
|
+
cwd=worktree,
|
|
991
|
+
role="analysis",
|
|
992
|
+
max_turns=self.config.max_turns_diagnosis,
|
|
993
|
+
env=self.agent_env,
|
|
994
|
+
).text)
|
|
995
|
+
diag_path = self.paths.diagnostics_dir / f"{t.task_id}.g{t.generation}.{role.lower()}.json"
|
|
996
|
+
self._write_artifact(diag_path, json.dumps({"classification": classification, "diagnosis": diagnosis}, indent=2))
|
|
997
|
+
packet = self._restart_packet(t, plan, worktree, evidence, diagnosis)
|
|
998
|
+
packet_path = self.paths.diagnostics_dir / f"{t.task_id}.g{t.generation}.restart_packet.md"
|
|
999
|
+
self._write_artifact(packet_path, packet)
|
|
1000
|
+
self._event("CLEAN_RESTART_PACKET", task_id=t.task_id, role=role, path=str(packet_path))
|
|
1001
|
+
|
|
1002
|
+
t.generation += 1
|
|
1003
|
+
t.qa_failures = 0
|
|
1004
|
+
t.implementer_session_id = None
|
|
1005
|
+
t.owner = f"implementer:{t.task_id}:g{t.generation}"
|
|
1006
|
+
t.status = TaskStatus.IMPLEMENTING
|
|
1007
|
+
t.metadata["resume_mode"] = "fresh"
|
|
1008
|
+
t.metadata["restart_packet"] = str(packet_path)
|
|
1009
|
+
self._event("FRESH_IMPLEMENTER_STARTING", task_id=t.task_id, generation=t.generation)
|
|
1010
|
+
# Keep code as evidence, but retire the previous conversation.
|
|
1011
|
+
result = self._call(
|
|
1012
|
+
fresh_implementer(t, packet),
|
|
1013
|
+
cwd=worktree,
|
|
1014
|
+
role="implementer",
|
|
1015
|
+
max_turns=self.config.max_turns_implementer,
|
|
1016
|
+
env=self.agent_env,
|
|
1017
|
+
)
|
|
1018
|
+
t.implementer_session_id = result.session_id
|
|
1019
|
+
self._write_artifact(self.paths.logs_dir / f"{t.task_id}.g{t.generation}.fresh_implement.md", result.text)
|
|
1020
|
+
self._guard_agent_changes(t, worktree)
|
|
1021
|
+
commit = checkpoint(worktree, f"manager checkpoint: {t.task_id} clean restart g{t.generation}")
|
|
1022
|
+
self._event("FRESH_IMPLEMENTER_STARTED", task_id=t.task_id, generation=t.generation, session=t.implementer_session_id)
|
|
1023
|
+
return commit
|
|
1024
|
+
|
|
1025
|
+
def _restart_packet(self, t: BacklogTask, plan: str, worktree: Path, evidence: str, diagnosis: dict[str, Any]) -> str:
|
|
1026
|
+
return f"""# Clean restart packet — {t.task_id}\n\n## Original objective\n{t.title}\n\n## Approved plan\n{plan}\n\n## Acceptance criteria\n""" + "\n".join(f"- {c}" for c in t.acceptance_criteria) + f"""\n\n## Current repository state\n```text\n{repo_snapshot(worktree)}\n```\n\n## Concrete failure evidence\n```json\n{evidence}\n```\n\n## Independent diagnostic evidence\n```json\n{json.dumps(diagnosis, indent=2)}\n```\n\n## Neutral failure note\n{diagnosis.get('neutral_failure_note', 'Repeated QA evidence indicates the current implementation does not yet satisfy verification.')}\n\nThis packet intentionally omits the prior implementer's conversation and solution strategy. Evidence is context, not an instruction to preserve the previous approach.\n"""
|
|
1027
|
+
|
|
1028
|
+
def _merge_task(self, t: BacklogTask, worktree: Path, plan: str) -> bool:
|
|
1029
|
+
assert self.state is not None
|
|
1030
|
+
integration = Path(self.state.integration_worktree)
|
|
1031
|
+
ok, detail = merge_branch(integration, t.branch_name or "", t.task_id)
|
|
1032
|
+
if ok:
|
|
1033
|
+
t.completed_commit = detail
|
|
1034
|
+
self._event("TASK_MERGED", task_id=t.task_id, integration_commit=detail)
|
|
1035
|
+
return True
|
|
1036
|
+
|
|
1037
|
+
# Integration moved since this task branched and a real conflict exists. Put latest integration into
|
|
1038
|
+
# the task worktree and let the same implementer resolve with context, then QA again.
|
|
1039
|
+
self._event("MERGE_CONFLICT", task_id=t.task_id, detail=detail)
|
|
1040
|
+
merge = git(worktree, "merge", self.state.integration_branch, check=False, timeout=300)
|
|
1041
|
+
conflict_paths = git(worktree, "diff", "--name-only", "--diff-filter=U", check=False).stdout
|
|
1042
|
+
evidence = json.dumps({
|
|
1043
|
+
"verdict": "FAIL",
|
|
1044
|
+
"summary": "Task passed isolated QA but conflicts with the current integration branch.",
|
|
1045
|
+
"failures": [f"Merge conflict paths: {conflict_paths or detail}"],
|
|
1046
|
+
}, indent=2)
|
|
1047
|
+
if merge.code == 0:
|
|
1048
|
+
checkpoint_commit = head(worktree)
|
|
1049
|
+
else:
|
|
1050
|
+
t.qa_failures += 1
|
|
1051
|
+
if t.qa_failures > self.config.max_fix_attempts_before_restart:
|
|
1052
|
+
# Integration conflicts can poison assumptions too; use the same clean-restart policy.
|
|
1053
|
+
checkpoint_commit = self._clean_restart(t, worktree, plan, evidence)
|
|
1054
|
+
else:
|
|
1055
|
+
fix = self._call(
|
|
1056
|
+
same_implementer_fix(t, evidence, t.qa_failures),
|
|
1057
|
+
cwd=worktree,
|
|
1058
|
+
role="implementer",
|
|
1059
|
+
max_turns=self.config.max_turns_implementer,
|
|
1060
|
+
resume_session=t.implementer_session_id,
|
|
1061
|
+
env=self.agent_env,
|
|
1062
|
+
)
|
|
1063
|
+
self._write_artifact(self.paths.logs_dir / f"{t.task_id}.merge_fix{t.qa_failures}.md", fix.text)
|
|
1064
|
+
self._guard_agent_changes(t, worktree)
|
|
1065
|
+
checkpoint_commit = checkpoint(worktree, f"manager checkpoint: {t.task_id} integration conflict repair")
|
|
1066
|
+
t.checkpoint_commit = checkpoint_commit
|
|
1067
|
+
qa_result = self._run_qa(t, plan, checkpoint_commit)
|
|
1068
|
+
if str(qa_result.get("verdict", "")).upper() != "PASS":
|
|
1069
|
+
# Re-enter the normal execution loop with the concrete recheck evidence.
|
|
1070
|
+
t.qa_failures += 1
|
|
1071
|
+
t.status = TaskStatus.READY
|
|
1072
|
+
t.owner = None
|
|
1073
|
+
t.metadata["resume_mode"] = "repair"
|
|
1074
|
+
t.next_action = "Re-run implementation/repair after integration recheck failure"
|
|
1075
|
+
self._event("INTEGRATION_RECHECK_FAILED", task_id=t.task_id, qa=qa_result)
|
|
1076
|
+
return False
|
|
1077
|
+
ok2, detail2 = merge_branch(integration, t.branch_name or "", t.task_id)
|
|
1078
|
+
if not ok2:
|
|
1079
|
+
t.blocked_reason = f"Repeated integration conflict requires manager intervention: {detail2}"
|
|
1080
|
+
t.metadata["blocked_kind"] = "integration_conflict"
|
|
1081
|
+
self.state.paused = True
|
|
1082
|
+
self.state.stop_reason = f"Integration conflict on {t.task_id}"
|
|
1083
|
+
self._event("INTEGRATION_CONFLICT_ESCALATED", task_id=t.task_id, detail=detail2)
|
|
1084
|
+
return False
|
|
1085
|
+
t.completed_commit = detail2
|
|
1086
|
+
self._event("TASK_MERGED", task_id=t.task_id, integration_commit=detail2)
|
|
1087
|
+
return True
|
|
1088
|
+
|
|
1089
|
+
def _post_merge(self, t: BacklogTask) -> None:
|
|
1090
|
+
assert self.state is not None
|
|
1091
|
+
if self.state.qa_passes_since_review >= self.config.qa_passes_per_code_review:
|
|
1092
|
+
t.status = TaskStatus.CODE_REVIEW
|
|
1093
|
+
self._sync_backlog(commit_integration=True)
|
|
1094
|
+
if not self._run_code_review():
|
|
1095
|
+
return
|
|
1096
|
+
t.status = TaskStatus.COMPLETE
|
|
1097
|
+
t.owner = None
|
|
1098
|
+
t.next_action = "Complete"
|
|
1099
|
+
t.blocked_reason = None
|
|
1100
|
+
self._sync_backlog(commit_integration=True)
|
|
1101
|
+
self._event("TASK_COMPLETE", task_id=t.task_id, commit=t.completed_commit)
|
|
1102
|
+
if not self.config.keep_task_worktrees and t.worktree_path:
|
|
1103
|
+
worktree_remove(self.repo, Path(t.worktree_path), force=True)
|
|
1104
|
+
|
|
1105
|
+
def _review_changes(self, repo: Path, base: str, tip: str, task_ids: list[str]) -> dict:
|
|
1106
|
+
evidence = change_evidence(repo, base, tip, self.config)
|
|
1107
|
+
chunks = list(review_chunks(evidence, self.config.evidence_max_chars))
|
|
1108
|
+
coverage, findings = [], []
|
|
1109
|
+
passed = True
|
|
1110
|
+
for chunk in chunks:
|
|
1111
|
+
identity = hashlib.sha256(json.dumps({"base": base, "tip": tip, "chunk": chunk,
|
|
1112
|
+
"provider": self.config.provider, "model": self.config.model, "effort": self.config.effort}, sort_keys=True).encode()).hexdigest()
|
|
1113
|
+
cached = self.paths.reviews_dir / f"chunk-{identity}.json"
|
|
1114
|
+
prompt = code_review(task_ids, base, json.dumps(chunk))
|
|
1115
|
+
prompt += f"\nExact tip: {tip}. This is one complete chunk of a file, not the entire change. Inspect related files at this tip as needed."
|
|
1116
|
+
if cached.exists():
|
|
1117
|
+
result = json.loads(cached.read_text(encoding="utf-8"))
|
|
1118
|
+
else:
|
|
1119
|
+
result = extract_json(self._call(prompt, cwd=repo, role="analysis",
|
|
1120
|
+
max_turns=self.config.max_turns_reviewer, env=self.agent_env).text)
|
|
1121
|
+
self._write_artifact(cached, json.dumps(result, indent=2))
|
|
1122
|
+
passed = passed and not self._review_requires_changes(result)
|
|
1123
|
+
findings.extend(result.get("findings", []) if isinstance(result.get("findings"), list) else [])
|
|
1124
|
+
coverage.append({k: v for k, v in chunk.items() if k != "diff"})
|
|
1125
|
+
return {"verdict": "PASS" if passed else "CHANGES_REQUIRED", "findings": findings,
|
|
1126
|
+
"summary": "All diff chunks submitted for review", "base_commit": base, "checkpoint_commit": tip,
|
|
1127
|
+
"coverage": coverage, "files": [f["path"] for f in evidence["files"]]}
|
|
1128
|
+
|
|
1129
|
+
def _run_code_review(self) -> bool:
|
|
1130
|
+
assert self.state is not None
|
|
1131
|
+
integration = Path(self.state.integration_worktree)
|
|
1132
|
+
base = self.state.last_review_commit or self.state.base_commit
|
|
1133
|
+
current = head(integration)
|
|
1134
|
+
batch = [tid for tid, task in self.state.tasks.items() if task.completed_commit and git(integration, "merge-base", "--is-ancestor", base, task.completed_commit, check=False).code == 0]
|
|
1135
|
+
review_obj = self._review_changes(integration, base, current, batch)
|
|
1136
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
1137
|
+
review_path = self.paths.reviews_dir / f"code_review_{stamp}.json"
|
|
1138
|
+
self._write_artifact(review_path, json.dumps(review_obj, indent=2))
|
|
1139
|
+
self._event("CODE_REVIEW_PROVIDED", verdict=review_obj.get("verdict"), path=str(review_path))
|
|
1140
|
+
if not self._review_requires_changes(review_obj):
|
|
1141
|
+
self.state.last_review_commit = current
|
|
1142
|
+
self.state.qa_passes_since_review = 0
|
|
1143
|
+
self._event("CODE_REVIEW_COMPLETED", commit=current)
|
|
1144
|
+
return True
|
|
1145
|
+
|
|
1146
|
+
if not self._fix_code_review(review_obj):
|
|
1147
|
+
return False
|
|
1148
|
+
self.state.last_review_commit = head(integration)
|
|
1149
|
+
self.state.qa_passes_since_review = 0
|
|
1150
|
+
self._event("CODE_REVIEW_COMPLETED", commit=self.state.last_review_commit)
|
|
1151
|
+
return True
|
|
1152
|
+
|
|
1153
|
+
def _review_requires_changes(self, review_obj: dict[str, Any]) -> bool:
|
|
1154
|
+
verdict = str(review_obj.get("verdict", "CHANGES_REQUIRED")).upper()
|
|
1155
|
+
findings = review_obj.get("findings", [])
|
|
1156
|
+
if not isinstance(findings, list):
|
|
1157
|
+
return True
|
|
1158
|
+
blocking = any(
|
|
1159
|
+
isinstance(finding, dict)
|
|
1160
|
+
and str(finding.get("severity", "")).upper() in {"BLOCKER", "BUG", "CRITICAL"}
|
|
1161
|
+
for finding in findings
|
|
1162
|
+
)
|
|
1163
|
+
return verdict != "PASS" or blocking
|
|
1164
|
+
|
|
1165
|
+
def _fix_code_review(self, review_obj: dict[str, Any]) -> bool:
|
|
1166
|
+
assert self.state is not None
|
|
1167
|
+
integration = Path(self.state.integration_worktree)
|
|
1168
|
+
safe = f"review-fix-{self.state.event_seq}"
|
|
1169
|
+
branch = f"{self.config.task_branch_prefix}/{self.state.run_id}/{safe}"
|
|
1170
|
+
worktree = self.paths.worktrees_root / "review-fixes" / safe
|
|
1171
|
+
worktree_add(self.repo, worktree, branch, head(integration), create_branch=True)
|
|
1172
|
+
session: str | None = None
|
|
1173
|
+
payload = json.dumps(review_obj, indent=2)
|
|
1174
|
+
try:
|
|
1175
|
+
for attempt in range(self.config.max_code_review_fix_attempts + 1):
|
|
1176
|
+
if attempt == 0:
|
|
1177
|
+
result = self._call(
|
|
1178
|
+
review_fix(payload), cwd=worktree, role="implementer",
|
|
1179
|
+
max_turns=self.config.max_turns_implementer, env=self.agent_env,
|
|
1180
|
+
)
|
|
1181
|
+
session = result.session_id
|
|
1182
|
+
else:
|
|
1183
|
+
result = self._call(
|
|
1184
|
+
review_fix(payload),
|
|
1185
|
+
cwd=worktree, role="implementer", max_turns=self.config.max_turns_implementer,
|
|
1186
|
+
resume_session=session, env=self.agent_env,
|
|
1187
|
+
)
|
|
1188
|
+
violations = [p for p in changed_paths(worktree) if matches_protected(p, self.config.protected_paths, self.config.backlog_file)]
|
|
1189
|
+
if violations:
|
|
1190
|
+
restore_paths(worktree, violations)
|
|
1191
|
+
raise ManagerError(f"Code-review fixer modified protected paths: {violations}")
|
|
1192
|
+
commit = checkpoint(worktree, f"manager checkpoint: cumulative code review fix {attempt + 1}")
|
|
1193
|
+
qa_path = self.paths.worktrees_root / "qa" / f"review-{int(time.time()*1000)}"
|
|
1194
|
+
detached_worktree_add(self.repo, qa_path, commit)
|
|
1195
|
+
try:
|
|
1196
|
+
verification = execute_checks(self.config, qa_path, self.agent_env)
|
|
1197
|
+
if verification["status"] in {"FAIL", "ENVIRONMENT_BLOCKED"} or (self.config.require_verification and not verification["checks"]):
|
|
1198
|
+
qa_obj = {"verdict": "UNVERIFIED", "summary": "Deterministic review-fix verification did not pass"}
|
|
1199
|
+
else:
|
|
1200
|
+
qa_obj = extract_json(self._call(
|
|
1201
|
+
review_qa(payload, []) + "\nExecuted checks at this commit:\n" + json.dumps(verification),
|
|
1202
|
+
cwd=qa_path, role="read_only", max_turns=self.config.max_turns_qa, env=self.agent_env,
|
|
1203
|
+
).text)
|
|
1204
|
+
qa_obj["harness_checks"] = verification["checks"]
|
|
1205
|
+
qa_obj["environment"] = verification
|
|
1206
|
+
qa_obj["checkpoint_commit"] = commit
|
|
1207
|
+
mutation = verification_mutations(self.config, qa_path)
|
|
1208
|
+
if mutation:
|
|
1209
|
+
qa_obj.setdefault("failures", []).append(f"Review QA mutated disposable paths: {mutation}")
|
|
1210
|
+
qa_obj["verdict"] = "UNVERIFIED"
|
|
1211
|
+
qa_obj["summary"] = "Review QA mutated its disposable worktree; verdict rejected."
|
|
1212
|
+
finally:
|
|
1213
|
+
worktree_remove(self.repo, qa_path, force=True)
|
|
1214
|
+
self._write_artifact(self.paths.reviews_dir / f"review_fix_qa_{attempt}.json", json.dumps(qa_obj, indent=2))
|
|
1215
|
+
if str(qa_obj.get("verdict", "")).upper() != "PASS":
|
|
1216
|
+
payload += "\n\nLatest QA evidence:\n" + json.dumps(qa_obj, indent=2)
|
|
1217
|
+
continue
|
|
1218
|
+
ok, detail = merge_branch(integration, branch, "CODE_REVIEW_FIX")
|
|
1219
|
+
if not ok:
|
|
1220
|
+
payload += f"\n\nIntegration merge conflict after fix: {detail}"
|
|
1221
|
+
continue
|
|
1222
|
+
# Re-review after QA recheck, matching the intended REVIEW_PROVIDED -> FIX -> QA_RECHECK -> review loop.
|
|
1223
|
+
base = self.state.last_review_commit or self.state.base_commit
|
|
1224
|
+
rereview = self._review_changes(integration, base, head(integration), [])
|
|
1225
|
+
self._write_artifact(self.paths.reviews_dir / f"review_rereview_{attempt}.json", json.dumps(rereview, indent=2))
|
|
1226
|
+
if not self._review_requires_changes(rereview):
|
|
1227
|
+
return True
|
|
1228
|
+
payload = json.dumps(rereview, indent=2)
|
|
1229
|
+
self.state.paused = True
|
|
1230
|
+
self.state.stop_reason = "Cumulative code-review fixes exceeded bounded retry limit"
|
|
1231
|
+
self._event("CODE_REVIEW_ESCALATED", reason=self.state.stop_reason)
|
|
1232
|
+
return False
|
|
1233
|
+
finally:
|
|
1234
|
+
if not self.config.keep_task_worktrees:
|
|
1235
|
+
worktree_remove(self.repo, worktree, force=True)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def validate_tasks(tasks: dict[str, BacklogTask]) -> None:
|
|
1239
|
+
errors: list[str] = []
|
|
1240
|
+
for tid, t in tasks.items():
|
|
1241
|
+
for dep in t.dependencies:
|
|
1242
|
+
if dep not in tasks:
|
|
1243
|
+
errors.append(f"{tid}: unknown dependency {dep}")
|
|
1244
|
+
if tid in t.dependencies:
|
|
1245
|
+
errors.append(f"{tid}: task cannot depend on itself")
|
|
1246
|
+
# DFS cycle detection
|
|
1247
|
+
visiting: set[str] = set()
|
|
1248
|
+
visited: set[str] = set()
|
|
1249
|
+
def visit(tid: str, stack: list[str]) -> None:
|
|
1250
|
+
if tid in visited:
|
|
1251
|
+
return
|
|
1252
|
+
if tid in visiting:
|
|
1253
|
+
cycle = stack[stack.index(tid):] + [tid] if tid in stack else stack + [tid]
|
|
1254
|
+
errors.append("dependency cycle: " + " -> ".join(cycle))
|
|
1255
|
+
return
|
|
1256
|
+
visiting.add(tid)
|
|
1257
|
+
stack.append(tid)
|
|
1258
|
+
for dep in tasks[tid].dependencies:
|
|
1259
|
+
if dep in tasks:
|
|
1260
|
+
visit(dep, stack)
|
|
1261
|
+
stack.pop()
|
|
1262
|
+
visiting.remove(tid)
|
|
1263
|
+
visited.add(tid)
|
|
1264
|
+
for tid in tasks:
|
|
1265
|
+
visit(tid, [])
|
|
1266
|
+
if errors:
|
|
1267
|
+
raise ManagerError("Invalid backlog:\n- " + "\n- ".join(errors))
|