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/__init__.py +10 -0
- roundtable/agents.py +218 -0
- roundtable/cli.py +722 -0
- roundtable/config.py +265 -0
- roundtable/dashboard.py +533 -0
- roundtable/discovery.py +71 -0
- roundtable/engine.py +714 -0
- roundtable/errors.py +20 -0
- roundtable/insights.py +210 -0
- roundtable/llm.py +1048 -0
- roundtable/mcp.py +248 -0
- roundtable/modelpick.py +309 -0
- roundtable/models.py +202 -0
- roundtable/prompts.py +205 -0
- roundtable/runctl.py +212 -0
- roundtable/scan.py +187 -0
- roundtable/store.py +339 -0
- roundtable_cli-0.4.0.dist-info/METADATA +570 -0
- roundtable_cli-0.4.0.dist-info/RECORD +22 -0
- roundtable_cli-0.4.0.dist-info/WHEEL +4 -0
- roundtable_cli-0.4.0.dist-info/entry_points.txt +4 -0
- roundtable_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
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 ""
|