roundtable-cli 0.4.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.
roundtable/engine.py ADDED
@@ -0,0 +1,714 @@
1
+ """Orchestration engine: the deterministic control flow tying agents together.
2
+
3
+ Run shape (after the plan is approved)::
4
+
5
+ Main.kickoff -> docs/OVERVIEW.md
6
+ for each phase (in order, resumable):
7
+ Phase Orchestrator (FRESH context):
8
+ define each task -> tasks/.../TASK.md
9
+ schedule tasks by dependency, run Task Agents in concurrent waves
10
+ -> tasks/.../result.md (+ output/)
11
+ summarize phase -> phase-summary.md
12
+ Main.integrate_phase(summary only) -> docs/PROGRESS.md # context clean
13
+ Main.finalize -> docs/FINAL.md
14
+
15
+ Context-cleaning invariant: a Phase Orchestrator and its Task Agents are created
16
+ per phase and dropped after the summary; the Main Orchestrator only ever receives
17
+ the phase *summary*, never task transcripts.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+
26
+ from .agents import MainOrchestrator, PhaseOrchestrator, TaskAgent
27
+ from .config import Config
28
+ from .errors import RoundtableError, TaskFailed
29
+ from .llm import CLIProvider, LLMProvider
30
+ from .models import AgentRef, Phase, Plan, Status, Task
31
+ from .store import Store
32
+
33
+ logger = logging.getLogger("roundtable")
34
+
35
+
36
+ def normalize_runner_refs(plan: Plan, config: Config) -> bool:
37
+ """Normalize runner refs for providers where ``model`` is the whole selector.
38
+
39
+ CLI refs use ``agent`` for the command and ``model`` for that command's model
40
+ token. Direct providers (pi/litellm) do not have a separate agent command, so
41
+ a plan-produced ``{agent: opencode-go, model: glm-5.2}`` must become
42
+ ``{model: opencode-go/glm-5.2}`` before it reaches the provider.
43
+ """
44
+ if config.provider not in ("pi", "litellm"):
45
+ return False
46
+ changed = False
47
+
48
+ def normalize(ref: AgentRef) -> None:
49
+ nonlocal changed
50
+ if ref.agent and ref.model:
51
+ # Fold {agent, model} into a single provider/id model selector.
52
+ if "/" not in ref.model:
53
+ ref.model = f"{ref.agent}/{ref.model}"
54
+ ref.agent = ""
55
+ changed = True
56
+ elif ref.agent and "/" in ref.agent:
57
+ ref.model = ref.agent
58
+ ref.agent = ""
59
+ changed = True
60
+
61
+ normalize(plan.main_runner)
62
+ for key in list(plan.runners):
63
+ normalize(plan.runners[key])
64
+ for phase in plan.phases:
65
+ normalize(phase.runner)
66
+ for task in phase.tasks:
67
+ normalize(task.runner)
68
+ return changed
69
+
70
+
71
+ def validate_runners(plan: Plan, config: Config) -> None:
72
+ """Fail fast (before any agent runs) on runners that cannot execute.
73
+
74
+ Only meaningful for ``provider: cli``: every runner's agent must name an
75
+ entry in the config ``agents`` map. A runner with only a ``model`` falls
76
+ back to the model naming the agent (CLIProvider back-compat).
77
+ """
78
+ if config.provider != "cli":
79
+ return
80
+ problems: list[str] = []
81
+
82
+ def check(label: str, ref: AgentRef) -> None:
83
+ key = ref.agent or ref.model
84
+ if not key:
85
+ problems.append(f"{label} has no runner (agent/model) assigned")
86
+ elif key not in config.agents:
87
+ problems.append(f"{label} references agent {key!r} not defined under `agents:`")
88
+
89
+ check("main orchestrator", plan.main_runner)
90
+ for p in plan.phases:
91
+ check(f"phase {p.id}", p.runner)
92
+ for t in p.tasks:
93
+ check(f"task {t.id}", t.runner)
94
+ if problems:
95
+ raise RoundtableError(
96
+ "plan cannot run with the current config:\n - "
97
+ + "\n - ".join(problems)
98
+ + f"\n available agents: {', '.join(sorted(config.agents)) or '(none)'}"
99
+ )
100
+
101
+
102
+ class Engine:
103
+ def __init__(self, store: Store, config: Config, provider: LLMProvider):
104
+ self.store = store
105
+ self.config = config
106
+ self.provider = provider
107
+ self.temp = config.defaults.temperature
108
+ self._save_lock = asyncio.Lock()
109
+
110
+ async def run(self) -> Plan:
111
+ plan = self.store.load_plan()
112
+ if normalize_runner_refs(plan, self.config):
113
+ self.store.save_plan(plan)
114
+ if not plan.approved:
115
+ raise RoundtableError("plan is not approved; run `roundtable approve` first")
116
+ if isinstance(self.provider, CLIProvider):
117
+ # Agent lookups only happen on the CLI backend; a scripted/litellm
118
+ # provider ignores the agents map entirely.
119
+ validate_runners(plan, self.config)
120
+
121
+ try:
122
+ return await self._run(plan)
123
+ except asyncio.CancelledError:
124
+ # Ctrl-C / SIGTERM: leave a resumable manifest and a truthful log —
125
+ # nothing is running anymore, so nothing may stay "in_progress".
126
+ self._mark_interrupted(plan)
127
+ raise
128
+ except Exception as e:
129
+ # Any engine crash must be visible to pollers (dashboard/app) —
130
+ # otherwise plan.json says "in_progress" forever.
131
+ plan.status = Status.failed
132
+ self.store.save_plan(plan)
133
+ self.store.record_event("run_error", message=f"run crashed: {e}")
134
+ raise
135
+
136
+ def _mark_interrupted(self, plan: Plan) -> None:
137
+ changed = False
138
+ for phase in plan.phases:
139
+ for task in phase.tasks:
140
+ if task.status in (Status.in_progress, Status.waiting):
141
+ task.status = Status.pending
142
+ changed = True
143
+ if phase.status == Status.in_progress:
144
+ phase.status = Status.pending
145
+ changed = True
146
+ if plan.status == Status.in_progress:
147
+ plan.status = Status.pending
148
+ changed = True
149
+ if changed:
150
+ self.store.save_plan(plan)
151
+ self.store.record_event(
152
+ "run_interrupted", message="run interrupted — re-run `roundtable run` to resume"
153
+ )
154
+
155
+ async def _run(self, plan: Plan) -> Plan:
156
+ self.store.scaffold_plan_tree(plan)
157
+ plan.status = Status.in_progress
158
+ self.store.save_plan(plan)
159
+ self.store.record_event(
160
+ "run_started", message="run started", goal=plan.goal,
161
+ phases=len(plan.phases), tasks=sum(len(p.tasks) for p in plan.phases),
162
+ )
163
+
164
+ main = MainOrchestrator(self.provider, plan.main_runner, self.temp)
165
+
166
+ if not self.store.read_doc("OVERVIEW.md"):
167
+ overview = await main.kickoff(plan)
168
+ self.store.write_doc("OVERVIEW.md", overview)
169
+ self.store.record_event("kickoff", message="main kickoff -> docs/OVERVIEW.md")
170
+
171
+ has_failure = False
172
+ for phase in plan.phases:
173
+ if phase.status == Status.done:
174
+ self.store.record_event(
175
+ "phase_skipped", message=f"phase {phase.id} already done; skipping",
176
+ phase_id=phase.id,
177
+ )
178
+ continue
179
+ await self._run_phase(plan, phase, main)
180
+ if phase.status == Status.failed:
181
+ has_failure = True
182
+
183
+ if has_failure:
184
+ plan.status = Status.failed
185
+ self.store.save_plan(plan)
186
+ self.store.record_event(
187
+ "run_failed", message="run failed — one or more phases failed",
188
+ phases=len(plan.phases), tasks=sum(len(p.tasks) for p in plan.phases),
189
+ )
190
+ else:
191
+ plan.status = Status.done
192
+ self.store.save_plan(plan)
193
+ final = await main.finalize(plan)
194
+ self.store.write_doc("FINAL.md", final)
195
+ self.store.record_event(
196
+ "run_done", message="run complete -> docs/FINAL.md",
197
+ phases=len(plan.phases), tasks=sum(len(p.tasks) for p in plan.phases),
198
+ )
199
+
200
+ # Record accumulated provider usage stats (token counts, call counts).
201
+ if hasattr(self.provider, "stats"):
202
+ self.store.record_event("usage", message="provider usage stats", **self.provider.stats.snapshot())
203
+ return plan
204
+
205
+ # ------------------------------------------------------------------ #
206
+ async def _run_phase(self, plan: Plan, phase: Phase, main: MainOrchestrator) -> None:
207
+ phase.status = Status.in_progress
208
+ await self._save(plan)
209
+ self.store.write_phase_md(phase, _phase_md(phase))
210
+ self.store.record_event(
211
+ "phase_started", message=f"phase {phase.id} started",
212
+ phase_id=phase.id, index=phase.index, title=phase.title, runner=str(phase.runner),
213
+ )
214
+
215
+ # Fresh Phase Orchestrator — its context lives only for this phase.
216
+ po = PhaseOrchestrator(self.provider, phase.runner, self.temp)
217
+
218
+ results, failed_ids, skipped_ids = await self._schedule(plan, phase, po)
219
+
220
+ # Phase completion gate: even when every task succeeded, an optional
221
+ # phase-level validate_command must pass for the phase to count as done
222
+ # (e.g. the phase's test suite). Skipped when tasks already failed.
223
+ validation_error: str | None = None
224
+ if not failed_ids and not skipped_ids and phase.validate_command:
225
+ validation_error = await self._validate_phase(phase)
226
+
227
+ ordered = [(t, results.get(t.id, "")) for t in phase.tasks]
228
+ if failed_ids or skipped_ids or validation_error is not None:
229
+ summary = _failure_summary(phase, failed_ids, skipped_ids, validation_error)
230
+ else:
231
+ summary = await po.summarize(phase, ordered)
232
+ self.store.write_phase_summary(phase, summary)
233
+ phase.summary_path = str(self.store.phase_dir(phase) / "phase-summary.md")
234
+ self.store.record_event(
235
+ "phase_summarized", message=f"phase {phase.id} summarized", phase_id=phase.id,
236
+ )
237
+
238
+ if not (failed_ids or skipped_ids or validation_error is not None):
239
+ # Context clean: Main receives ONLY the summary string.
240
+ entry = await main.integrate_phase(plan.goal, phase, summary)
241
+ self._append_progress(phase, entry)
242
+
243
+ # A phase is done only if every task completed AND the phase-level
244
+ # validation (when configured) passed; failed OR skipped tasks (the
245
+ # latter possibly blocked by a cross-phase failure) mean it did not.
246
+ if failed_ids or skipped_ids or validation_error is not None:
247
+ phase.status = Status.failed
248
+ detail = (
249
+ f"validation failed: {validation_error[:200]}"
250
+ if validation_error is not None
251
+ else f"failed={sorted(failed_ids)}, skipped={sorted(skipped_ids)}"
252
+ )
253
+ self.store.record_event(
254
+ "phase_failed",
255
+ message=f"phase {phase.id} failed; {detail}",
256
+ phase_id=phase.id, index=phase.index, title=phase.title,
257
+ failed=sorted(failed_ids), skipped=sorted(skipped_ids),
258
+ validation_error=validation_error,
259
+ )
260
+ else:
261
+ phase.status = Status.done
262
+ self.store.record_event(
263
+ "phase_done", message=f"phase {phase.id} done; docs updated",
264
+ phase_id=phase.id, index=phase.index, title=phase.title,
265
+ )
266
+ await self._save(plan)
267
+ del po # explicit: phase orchestrator + its task agents are discarded here
268
+
269
+ async def _schedule(self, plan: Plan, phase: Phase, po: PhaseOrchestrator) -> tuple[dict[str, str], set[str], set[str]]:
270
+ """Run tasks in dependency order, concurrently within each ready wave.
271
+
272
+ Returns (results, failed_ids, skipped_ids).
273
+ """
274
+ all_task_ids = {t.id for ph in plan.phases for t in ph.tasks}
275
+ phase.topological_order(external_ids=all_task_ids) # validate graph (cross-phase aware)
276
+ sem = asyncio.Semaphore(self.config.defaults.max_concurrency)
277
+
278
+ if self.config.defaults.max_concurrency > 1:
279
+ logger.warning(
280
+ "max_concurrency > 1 with provider: cli — concurrent agents edit "
281
+ "the same directory; ensure tasks touch disjoint files."
282
+ )
283
+
284
+ by_id = {t.id: t for t in phase.tasks}
285
+ results: dict[str, str] = {}
286
+ remaining: set[str] = set()
287
+ failed_ids: set[str] = set()
288
+ skipped_ids: set[str] = set()
289
+ failure_detail: dict[str, str] = {} # task_id -> error string, for replanning
290
+
291
+ # A dependency id may name a task in THIS phase (intra) or in an already
292
+ # completed EARLIER phase (cross-phase). Since phases run in order, a
293
+ # cross-phase dep is always in a terminal state by the time we get here.
294
+ def _dep_blocked(dep_id: str) -> bool:
295
+ if dep_id in by_id:
296
+ return dep_id in failed_ids or dep_id in skipped_ids
297
+ found = plan.task_by_id(dep_id) # cross-phase: an earlier phase's task
298
+ return found is not None and found[1].status in (Status.failed, Status.skipped)
299
+
300
+ def _dep_ready(dep_id: str) -> bool:
301
+ if dep_id in by_id:
302
+ return dep_id in results
303
+ found = plan.task_by_id(dep_id)
304
+ return found is not None and found[1].status == Status.done
305
+
306
+ def _dep_context(task: Task) -> list[tuple[Task, str]]:
307
+ """Upstream (task, result) pairs, pulled from this phase or an earlier one."""
308
+ ctx: list[tuple[Task, str]] = []
309
+ for d in task.depends_on:
310
+ if d in by_id:
311
+ ctx.append((by_id[d], results.get(d, "")))
312
+ else:
313
+ found = plan.task_by_id(d)
314
+ if found is not None:
315
+ dep_phase, dep_task = found
316
+ ctx.append((dep_task, _read_result(self.store, dep_phase, dep_task)))
317
+ return ctx
318
+
319
+ for t in phase.tasks:
320
+ if t.status == Status.done:
321
+ results[t.id] = _read_result(self.store, phase, t)
322
+ elif t.status in (Status.failed, Status.skipped):
323
+ # Already terminated from a previous run — respect the state.
324
+ if t.status == Status.failed:
325
+ failed_ids.add(t.id)
326
+ else:
327
+ skipped_ids.add(t.id)
328
+ else:
329
+ remaining.add(t.id)
330
+
331
+ while remaining:
332
+ # Block dependents of failed/skipped tasks — mark them skipped.
333
+ newly_skipped = set()
334
+ for tid in list(remaining):
335
+ task = by_id[tid]
336
+ if any(_dep_blocked(d) for d in task.depends_on):
337
+ task.status = Status.skipped
338
+ skipped_ids.add(tid)
339
+ newly_skipped.add(tid)
340
+ self.store.record_event(
341
+ "task_skipped",
342
+ message=f"task {tid} skipped (depends on failed/skipped task)",
343
+ phase_id=phase.id, task_id=tid, title=task.title,
344
+ )
345
+ remaining -= newly_skipped
346
+
347
+ if not remaining:
348
+ break
349
+
350
+ ready = [tid for tid in remaining if all(_dep_ready(d) for d in by_id[tid].depends_on)]
351
+ if not ready:
352
+ # All remaining tasks have unmet dependencies (all blocked by failures).
353
+ for tid in remaining:
354
+ by_id[tid].status = Status.skipped
355
+ skipped_ids.add(tid)
356
+ self.store.record_event(
357
+ "task_skipped",
358
+ message=f"task {tid} skipped (unmet dependencies among failed tasks)",
359
+ phase_id=phase.id, task_id=tid, title=by_id[tid].title,
360
+ )
361
+ remaining.clear()
362
+ break
363
+ ready.sort() # deterministic ordering within a wave
364
+
365
+ async def run_one(tid: str) -> tuple[str, str | TaskFailed]:
366
+ task = by_id[tid]
367
+ deps = _dep_context(task)
368
+ try:
369
+ res = await self._run_task(plan, phase, task, po, deps, sem)
370
+ return tid, res
371
+ except TaskFailed as e:
372
+ return tid, e
373
+
374
+ wave_results = await asyncio.gather(*(run_one(t) for t in ready))
375
+
376
+ wave_failed: list[str] = []
377
+ for tid, res in wave_results:
378
+ if isinstance(res, TaskFailed):
379
+ failed_ids.add(tid)
380
+ wave_failed.append(tid)
381
+ failure_detail[tid] = res.detail
382
+ # Defensive: ensure the task is marked failed even for paths
383
+ # that raised without setting status (persisted at phase end).
384
+ if by_id[tid].status != Status.failed:
385
+ by_id[tid].status = Status.failed
386
+ remaining.discard(tid)
387
+ else:
388
+ results[tid] = res
389
+ remaining.discard(tid)
390
+
391
+ # After each wave: if any task failed and non-failed/non-skipped tasks
392
+ # remain, ask the PhaseOrchestrator to adapt remaining task descriptions.
393
+ if wave_failed and remaining:
394
+ # Filter out tasks that are going to be skipped.
395
+ replannable = [
396
+ by_id[t] for t in remaining
397
+ if not any(_dep_blocked(d) for d in by_id[t].depends_on)
398
+ ]
399
+ if replannable and wave_failed:
400
+ first_failed = by_id[wave_failed[0]]
401
+ updates = await po.replan(
402
+ phase,
403
+ first_failed,
404
+ failure_detail.get(wave_failed[0], first_failed.status.value),
405
+ replannable,
406
+ )
407
+ for task_id, new_desc in updates.items():
408
+ if task_id in by_id:
409
+ by_id[task_id].description = new_desc
410
+ self.store.record_event(
411
+ "task_replanned",
412
+ message=f"task {task_id} description updated after failure of {wave_failed[0]}",
413
+ phase_id=phase.id, task_id=task_id,
414
+ )
415
+
416
+ return results, failed_ids, skipped_ids
417
+
418
+ async def _run_task(
419
+ self,
420
+ plan: Plan,
421
+ phase: Phase,
422
+ task: Task,
423
+ po: PhaseOrchestrator,
424
+ deps: list[tuple[Task, str]],
425
+ sem: asyncio.Semaphore,
426
+ ) -> str:
427
+ if task.requires_approval:
428
+ await self._wait_for_approval(plan, task)
429
+
430
+ async with sem:
431
+ task.status = Status.in_progress
432
+ await self._save(plan)
433
+ self.store.record_event(
434
+ "task_started", message=f"task {task.id} started",
435
+ phase_id=phase.id, task_id=task.id, title=task.title,
436
+ agent=task.runner.agent, model=task.runner.model,
437
+ )
438
+
439
+ # Phase Orchestrator defines the task's work -> TASK.md
440
+ project_ctx = self.config.project_context
441
+ try:
442
+ task_def = await po.define_task(
443
+ plan.goal, phase, task, project_context=project_ctx
444
+ )
445
+ except (RoundtableError, RuntimeError, TimeoutError) as e:
446
+ detail = f"task definition failed: {e}"
447
+ task.status = Status.failed
448
+ self.store.write_result(phase, task, f"error: {detail}")
449
+ await self._save(plan)
450
+ self.store.record_event(
451
+ "task_failed",
452
+ message=f"task {task.id} failed while defining work: {str(e)[:200]}",
453
+ phase_id=phase.id, task_id=task.id, title=task.title,
454
+ )
455
+ raise TaskFailed(task.id, detail)
456
+ self.store.write_task_def(phase, task, task_def)
457
+
458
+ # Task Agent (its own choosable agent + model) executes -> result.md
459
+ agent = TaskAgent(self.provider, task.runner, self.temp)
460
+
461
+ def _on_output(chunk: str) -> None:
462
+ # Streamed agent output — recorded (truncated) for the live dashboard/watch.
463
+ # May be called from the PTY drain thread; record_event is lock-guarded.
464
+ text = chunk.strip()
465
+ if text:
466
+ self.store.record_event(
467
+ "task_output", message=text[-500:],
468
+ phase_id=phase.id, task_id=task.id,
469
+ )
470
+
471
+ max_attempts = self.config.defaults.max_retries + 1
472
+ last_error: str = ""
473
+ result: str | None = None
474
+ for attempt in range(max_attempts):
475
+ try:
476
+ result = await agent.execute(
477
+ plan.goal, phase, task, task_def, deps,
478
+ project_context=project_ctx, on_output=_on_output,
479
+ )
480
+ break # success — exit retry loop
481
+ except RoundtableError as e:
482
+ # Configuration problems (unknown agent, pty unsupported, …)
483
+ # are not transient — fail the task immediately, no retry.
484
+ last_error = str(e)
485
+ self.store.record_event(
486
+ "task_retry",
487
+ message=f"task {task.id} failed (not retryable): {last_error[:200]}",
488
+ phase_id=phase.id, task_id=task.id, attempt=attempt + 1,
489
+ )
490
+ break
491
+ except (RuntimeError, TimeoutError) as e:
492
+ last_error = str(e)
493
+ self.store.record_event(
494
+ "task_retry",
495
+ message=f"task {task.id} attempt {attempt + 1}/{max_attempts} failed: {last_error[:200]}",
496
+ phase_id=phase.id, task_id=task.id, attempt=attempt + 1,
497
+ )
498
+ if attempt < max_attempts - 1:
499
+ await asyncio.sleep(min(2.0 * (attempt + 1), 8.0))
500
+ if result is None:
501
+ # Retries exhausted (or non-retryable) — mark failed and raise.
502
+ task.status = Status.failed
503
+ self.store.write_result(phase, task, f"error: {last_error}")
504
+ await self._save(plan)
505
+ self.store.record_event(
506
+ "task_failed",
507
+ message=f"task {task.id} failed after {max_attempts} attempt(s): {last_error[:200]}",
508
+ phase_id=phase.id, task_id=task.id, title=task.title,
509
+ )
510
+ raise TaskFailed(task.id, last_error)
511
+
512
+ # Post-success validation (optional) — a non-zero exit fails the task.
513
+ if task.validate_command:
514
+ try:
515
+ result = await self._validate_task(task, result)
516
+ except TaskFailed as e:
517
+ task.status = Status.failed
518
+ self.store.write_result(phase, task, f"error: {e.detail}\n\n{result}")
519
+ await self._save(plan)
520
+ raise
521
+
522
+ self.store.write_result(phase, task, result)
523
+
524
+ task.status = Status.done
525
+ task.result_path = str(self.store.task_dir(phase, task) / "result.md")
526
+ await self._save(plan)
527
+ self.store.record_event(
528
+ "task_done", message=f"task {task.id} done",
529
+ phase_id=phase.id, task_id=task.id, title=task.title,
530
+ agent=task.runner.agent, model=task.runner.model,
531
+ )
532
+ # Emit a running usage snapshot so the dashboards update cost live,
533
+ # not just once at the end of the run.
534
+ if hasattr(self.provider, "stats"):
535
+ self.store.record_event(
536
+ "usage", message="provider usage stats",
537
+ **self.provider.stats.snapshot(),
538
+ )
539
+ return result
540
+
541
+ # ------------------------------------------------------------------ #
542
+ async def _save(self, plan: Plan) -> None:
543
+ async with self._save_lock:
544
+ self.store.save_plan(plan)
545
+
546
+ async def _wait_for_approval(self, plan: Plan, task: Task) -> None:
547
+ """Write a HITL checkpoint and block until `roundtable resume` approves it."""
548
+ checkpoint = self.store.hitl_path(task.id)
549
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
550
+ checkpoint.write_text(
551
+ json.dumps({"status": "waiting", "task_id": task.id, "title": task.title})
552
+ )
553
+
554
+ task.status = Status.waiting
555
+ await self._save(plan)
556
+
557
+ self.store.record_event(
558
+ "task_waiting",
559
+ message=f"task {task.id} awaiting human approval",
560
+ task_id=task.id, title=task.title,
561
+ )
562
+ logger.info(
563
+ "Task [%s] '%s' requires approval. "
564
+ "Run: roundtable resume --task %s --project %s",
565
+ task.id, task.title, task.id, self.store.root,
566
+ )
567
+ print(
568
+ f"\n[roundtable] Task [{task.id}] '{task.title}' requires approval.\n"
569
+ f" Run: roundtable resume --task {task.id} --project {self.store.root}\n"
570
+ f" Waiting…\n",
571
+ flush=True,
572
+ )
573
+
574
+ timeout = self.config.defaults.hitl_timeout
575
+ elapsed = 0.0
576
+ while True:
577
+ try:
578
+ data = json.loads(checkpoint.read_text())
579
+ if data.get("status") == "approved":
580
+ break
581
+ except (json.JSONDecodeError, OSError):
582
+ pass
583
+
584
+ if timeout > 0 and elapsed >= timeout:
585
+ task.status = Status.failed
586
+ await self._save(plan)
587
+ self.store.record_event(
588
+ "task_timeout",
589
+ message=f"task {task.id} timed out waiting for approval after {timeout}s",
590
+ task_id=task.id,
591
+ )
592
+ try:
593
+ checkpoint.unlink()
594
+ except OSError:
595
+ pass
596
+ raise TaskFailed(task.id, f"HITL approval timed out after {timeout}s")
597
+
598
+ await asyncio.sleep(2.0)
599
+ elapsed += 2.0
600
+
601
+ try:
602
+ checkpoint.unlink()
603
+ except OSError:
604
+ pass
605
+ self.store.record_event(
606
+ "task_approved", message=f"task {task.id} approved", task_id=task.id
607
+ )
608
+
609
+ async def _run_validate_command(self, argv: list[str]) -> str | None:
610
+ """Run a validate_command in the project root; return failure detail or None."""
611
+ timeout = self.config.defaults.validate_timeout
612
+ try:
613
+ proc = await asyncio.create_subprocess_exec(
614
+ *argv,
615
+ cwd=str(self.store.root),
616
+ stdout=asyncio.subprocess.PIPE,
617
+ stderr=asyncio.subprocess.PIPE,
618
+ )
619
+ except OSError as e: # missing/unrunnable binary
620
+ return f"validate_command could not run: {e}"
621
+ try:
622
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
623
+ except asyncio.TimeoutError:
624
+ proc.kill()
625
+ await proc.wait()
626
+ return f"validate_command timed out after {timeout}s"
627
+ if proc.returncode != 0:
628
+ detail = (
629
+ err.decode("utf-8", "replace") + out.decode("utf-8", "replace")
630
+ ).strip()[-400:]
631
+ return f"validate_command exited {proc.returncode}: {detail}"
632
+ return None
633
+
634
+ async def _validate_task(self, task: Task, result: str) -> str:
635
+ """Run task.validate_command; raise TaskFailed on non-zero exit."""
636
+ assert task.validate_command # caller guarantees this
637
+ error = await self._run_validate_command(task.validate_command)
638
+ if error is not None:
639
+ self.store.record_event(
640
+ "task_validation_failed",
641
+ message=f"task {task.id} {error[:200]}",
642
+ task_id=task.id,
643
+ )
644
+ raise TaskFailed(task.id, error)
645
+ self.store.record_event(
646
+ "task_validated", message=f"task {task.id} validate_command passed", task_id=task.id
647
+ )
648
+ return result
649
+
650
+ async def _validate_phase(self, phase: Phase) -> str | None:
651
+ """Run phase.validate_command; return failure detail or None on success.
652
+
653
+ This is the completion gate for otherwise-successful phases: every task
654
+ finished, so a non-zero exit means their combined output does not
655
+ satisfy the phase (e.g. its test suite still fails).
656
+ """
657
+ assert phase.validate_command # caller guarantees this
658
+ error = await self._run_validate_command(phase.validate_command)
659
+ if error is not None:
660
+ self.store.record_event(
661
+ "phase_validation_failed",
662
+ message=f"phase {phase.id} {error[:200]}",
663
+ phase_id=phase.id,
664
+ )
665
+ return error
666
+ self.store.record_event(
667
+ "phase_validated",
668
+ message=f"phase {phase.id} validate_command passed",
669
+ phase_id=phase.id,
670
+ )
671
+ return None
672
+
673
+ def _append_progress(self, phase: Phase, entry: str) -> None:
674
+ prior = self.store.read_doc("PROGRESS.md") or "# Progress\n\n"
675
+ block = f"## Phase {phase.index}: {phase.title}\n\n{entry.strip()}\n\n"
676
+ self.store.write_doc("PROGRESS.md", prior + block)
677
+
678
+
679
+ def _phase_md(phase: Phase) -> str:
680
+ lines = [f"# Phase {phase.index}: {phase.title}", "", phase.objective, "",
681
+ f"- Orchestrator: `{phase.runner}`"]
682
+ if phase.validate_command:
683
+ lines.append(f"- Validation: `{' '.join(phase.validate_command)}`")
684
+ lines += ["", "## Tasks", ""]
685
+ for i, t in enumerate(phase.tasks, start=1):
686
+ dep = f" (depends on {', '.join(t.depends_on)})" if t.depends_on else ""
687
+ lines.append(f"{i}. **{t.title}** `[{t.id}]` — `{t.runner}`{dep}")
688
+ return "\n".join(lines) + "\n"
689
+
690
+
691
+ def _failure_summary(
692
+ phase: Phase,
693
+ failed_ids: set[str],
694
+ skipped_ids: set[str],
695
+ validation_error: str | None,
696
+ ) -> str:
697
+ lines = [
698
+ f"# Phase {phase.index}: {phase.title} did not complete",
699
+ "",
700
+ "Roundtable stopped this phase after a task or validation failure.",
701
+ "",
702
+ ]
703
+ if failed_ids:
704
+ lines.append(f"- Failed tasks: {', '.join(sorted(failed_ids))}")
705
+ if skipped_ids:
706
+ lines.append(f"- Skipped tasks: {', '.join(sorted(skipped_ids))}")
707
+ if validation_error is not None:
708
+ lines.append(f"- Validation error: {validation_error}")
709
+ return "\n".join(lines) + "\n"
710
+
711
+
712
+ def _read_result(store: Store, phase: Phase, task: Task) -> str:
713
+ p = store.task_dir(phase, task) / "result.md"
714
+ return p.read_text() if p.exists() else ""