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.
@@ -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))