sbxloop 0.2.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.
- sbxloop/__init__.py +26 -0
- sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- sbxloop/cli/__init__.py +1 -0
- sbxloop/cli/app.py +365 -0
- sbxloop/cli/doctor.py +172 -0
- sbxloop/cli/tui.py +206 -0
- sbxloop/config.py +205 -0
- sbxloop/engine/__init__.py +26 -0
- sbxloop/engine/engine.py +383 -0
- sbxloop/engine/model.py +151 -0
- sbxloop/engine/phases.py +223 -0
- sbxloop/engine/prompts/__init__.py +31 -0
- sbxloop/engine/prompts/decompose.md +41 -0
- sbxloop/engine/prompts/execute.md +31 -0
- sbxloop/engine/prompts/plan.md +35 -0
- sbxloop/engine/prompts/scrutinize.md +43 -0
- sbxloop/engine/prompts/validate.md +38 -0
- sbxloop/engine/store.py +257 -0
- sbxloop/errors.py +73 -0
- sbxloop/events.py +87 -0
- sbxloop/gh/__init__.py +6 -0
- sbxloop/gh/ops.py +140 -0
- sbxloop/gh/reporter.py +75 -0
- sbxloop/ids.py +35 -0
- sbxloop/py.typed +0 -0
- sbxloop/sbx/__init__.py +6 -0
- sbxloop/sbx/cli.py +236 -0
- sbxloop/sbx/models.py +83 -0
- sbxloop/sbx/pair.py +125 -0
- sbxloop/sbx/parse.py +67 -0
- sbxloop/sbx/provision.py +361 -0
- sbxloop/sbx/sandbox.py +75 -0
- sbxloop/worker/__init__.py +6 -0
- sbxloop/worker/client.py +397 -0
- sbxloop/worker/wheel.py +73 -0
- sbxloop-0.2.0.dist-info/METADATA +33 -0
- sbxloop-0.2.0.dist-info/RECORD +39 -0
- sbxloop-0.2.0.dist-info/WHEEL +4 -0
- sbxloop-0.2.0.dist-info/entry_points.txt +2 -0
sbxloop/engine/engine.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""LoopEngine: drives DECOMPOSE → (PLAN → EXECUTE → SCRUTINIZE → VERIFY →
|
|
2
|
+
VALIDATE)* under budgets, with SQLite checkpointing after every transition.
|
|
3
|
+
|
|
4
|
+
Failure semantics:
|
|
5
|
+
|
|
6
|
+
- Budget exhaustion (revisions/replans) fails the *task*; dependents are
|
|
7
|
+
skipped and the run continues, finishing ``failed`` if any task failed.
|
|
8
|
+
- Infrastructure errors (worker/sbx crashes) propagate after state is
|
|
9
|
+
persisted — equivalent to a kill. ``resume()`` re-provisions a fresh
|
|
10
|
+
sandbox pair (sandboxes are cattle; the workspace and SQLite state
|
|
11
|
+
persist on the host) and continues from the last committed transition:
|
|
12
|
+
a phase whose result was never committed re-runs from its start.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
|
|
21
|
+
from sbxloop.config import Config, load_config, load_dotenv_file
|
|
22
|
+
from sbxloop.engine.model import (
|
|
23
|
+
RESUMABLE_RUN_STATES,
|
|
24
|
+
RunResult,
|
|
25
|
+
RunState,
|
|
26
|
+
TaskRecord,
|
|
27
|
+
TaskState,
|
|
28
|
+
)
|
|
29
|
+
from sbxloop.engine.phases import PhaseRunner, clip
|
|
30
|
+
from sbxloop.engine.store import StateStore
|
|
31
|
+
from sbxloop.errors import BudgetExceededError, SdxloopError, StateError
|
|
32
|
+
from sbxloop.events import EventBus, Hook, HostEventTypes
|
|
33
|
+
from sbxloop.gh.ops import GithubOps
|
|
34
|
+
from sbxloop.gh.reporter import GithubReporterHook
|
|
35
|
+
from sbxloop.ids import new_run_id
|
|
36
|
+
from sbxloop.sbx.cli import SbxCLI
|
|
37
|
+
from sbxloop.sbx.provision import Provisioner
|
|
38
|
+
from sbxloop.worker.client import WorkerClient
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoopEngine:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
config: Config | None = None,
|
|
45
|
+
*,
|
|
46
|
+
store: StateStore | None = None,
|
|
47
|
+
bus: EventBus | None = None,
|
|
48
|
+
hooks: Sequence[Hook] = (),
|
|
49
|
+
sbx: SbxCLI | None = None,
|
|
50
|
+
worker_python: str | None = None,
|
|
51
|
+
install_workers: bool | None = None,
|
|
52
|
+
clock: Callable[[], float] = time.monotonic,
|
|
53
|
+
) -> None:
|
|
54
|
+
# Library parity with the CLI: a ./.env supplies tokens/settings even
|
|
55
|
+
# when the caller passes a prebuilt Config (real env vars still win).
|
|
56
|
+
load_dotenv_file()
|
|
57
|
+
self.config = config or load_config()
|
|
58
|
+
self.store = store or StateStore(self.config.state_dir / "state.db")
|
|
59
|
+
self.bus = bus or EventBus()
|
|
60
|
+
self.sbx = sbx or SbxCLI(app_name=self.config.app_name or None)
|
|
61
|
+
self.worker_python = (
|
|
62
|
+
worker_python if worker_python is not None else (self.config.worker_python)
|
|
63
|
+
)
|
|
64
|
+
self.install_workers = (
|
|
65
|
+
install_workers if install_workers is not None else self.config.install_workers
|
|
66
|
+
)
|
|
67
|
+
self.clock = clock
|
|
68
|
+
for hook in hooks:
|
|
69
|
+
self.bus.attach_hook(hook)
|
|
70
|
+
self.bus.subscribe(self._persist_event)
|
|
71
|
+
|
|
72
|
+
def _persist_event(self, event: object) -> None:
|
|
73
|
+
from sbxloop_worker.protocol import Event
|
|
74
|
+
|
|
75
|
+
assert isinstance(event, Event)
|
|
76
|
+
self.store.append_event(event)
|
|
77
|
+
|
|
78
|
+
# -- public API --------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
|
|
81
|
+
run_id = run_id or new_run_id()
|
|
82
|
+
self.store.create_run(run_id, outcome, self.config.model_dump_json())
|
|
83
|
+
self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=outcome)
|
|
84
|
+
return self._drive(run_id, outcome)
|
|
85
|
+
|
|
86
|
+
def resume(self, run_id: str) -> RunResult:
|
|
87
|
+
run = self.store.get_run(run_id)
|
|
88
|
+
if run.state not in RESUMABLE_RUN_STATES:
|
|
89
|
+
raise StateError(f"run {run_id} is {run.state}; only unfinished runs can resume")
|
|
90
|
+
self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=run.outcome, resumed=True)
|
|
91
|
+
return self._drive(run_id, run.outcome)
|
|
92
|
+
|
|
93
|
+
def cancel(self, run_id: str) -> None:
|
|
94
|
+
self.store.get_run(run_id) # raises for unknown runs
|
|
95
|
+
self.store.set_run_state(run_id, "cancelled")
|
|
96
|
+
|
|
97
|
+
# -- run driver --------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _drive(self, run_id: str, outcome: str) -> RunResult:
|
|
100
|
+
deadline = self.clock() + self.config.budgets.max_wall_clock_s
|
|
101
|
+
self._set_run_state(run_id, "provisioning")
|
|
102
|
+
provisioner = Provisioner(self.sbx, self.config, self.bus)
|
|
103
|
+
pair = provisioner.ensure_pair(run_id)
|
|
104
|
+
try:
|
|
105
|
+
with pair:
|
|
106
|
+
agent = WorkerClient(
|
|
107
|
+
pair.agent,
|
|
108
|
+
self.bus,
|
|
109
|
+
transport=self.config.worker_transport,
|
|
110
|
+
python=self.worker_python,
|
|
111
|
+
)
|
|
112
|
+
github = WorkerClient(
|
|
113
|
+
pair.github,
|
|
114
|
+
self.bus,
|
|
115
|
+
transport=self.config.worker_transport,
|
|
116
|
+
python=self.worker_python,
|
|
117
|
+
)
|
|
118
|
+
if self.install_workers:
|
|
119
|
+
agent.install(extras="copilot")
|
|
120
|
+
github.install(extras="")
|
|
121
|
+
detach = self._attach_reporter(github, run_id)
|
|
122
|
+
try:
|
|
123
|
+
phases = PhaseRunner(agent, self.config, run_id, outcome)
|
|
124
|
+
state = self._run_phases(run_id, phases, deadline)
|
|
125
|
+
finally:
|
|
126
|
+
detach()
|
|
127
|
+
except SdxloopError:
|
|
128
|
+
# State is already persisted; the exception is the kill signal.
|
|
129
|
+
raise
|
|
130
|
+
self._set_run_state(run_id, state)
|
|
131
|
+
tasks = self.store.get_tasks(run_id)
|
|
132
|
+
self.bus.emit(HostEventTypes.RUN_END, run_id, state=state)
|
|
133
|
+
return RunResult(run_id=run_id, state=state, tasks=tasks)
|
|
134
|
+
|
|
135
|
+
def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
|
|
136
|
+
repo = self.config.github.report_repo
|
|
137
|
+
if not repo:
|
|
138
|
+
return lambda: None
|
|
139
|
+
hook = GithubReporterHook(GithubOps(github, run_id), repo)
|
|
140
|
+
return self.bus.attach_hook(hook)
|
|
141
|
+
|
|
142
|
+
def _run_phases(self, run_id: str, phases: PhaseRunner, deadline: float) -> RunState:
|
|
143
|
+
tasks = self.store.get_tasks(run_id)
|
|
144
|
+
if not tasks:
|
|
145
|
+
self._set_run_state(run_id, "decomposing")
|
|
146
|
+
started = time.time()
|
|
147
|
+
graph = phases.decompose()
|
|
148
|
+
if len(graph.tasks) > self.config.budgets.max_tasks:
|
|
149
|
+
raise BudgetExceededError(
|
|
150
|
+
f"decomposition produced {len(graph.tasks)} tasks "
|
|
151
|
+
f"(max {self.config.budgets.max_tasks})"
|
|
152
|
+
)
|
|
153
|
+
ordered = graph.topo_order()
|
|
154
|
+
self.store.save_tasks(run_id, ordered)
|
|
155
|
+
self.store.record_phase(
|
|
156
|
+
run_id,
|
|
157
|
+
"decompose",
|
|
158
|
+
task_id=None,
|
|
159
|
+
attempt=1,
|
|
160
|
+
status="ok",
|
|
161
|
+
output_json=graph.model_dump_json(),
|
|
162
|
+
started_at=started,
|
|
163
|
+
)
|
|
164
|
+
tasks = self.store.get_tasks(run_id)
|
|
165
|
+
|
|
166
|
+
self._set_run_state(run_id, "running")
|
|
167
|
+
failed_ids: set[str] = {t.spec.id for t in tasks if t.state == "failed"}
|
|
168
|
+
skipped_ids: set[str] = {t.spec.id for t in tasks if t.state == "skipped"}
|
|
169
|
+
for task in tasks:
|
|
170
|
+
if task.terminal:
|
|
171
|
+
failed_ids |= {task.spec.id} if task.state == "failed" else set()
|
|
172
|
+
continue
|
|
173
|
+
blocked = [d for d in task.spec.depends_on if d in failed_ids | skipped_ids]
|
|
174
|
+
if blocked:
|
|
175
|
+
task.state = "skipped"
|
|
176
|
+
skipped_ids.add(task.spec.id)
|
|
177
|
+
self.store.update_task(run_id, task)
|
|
178
|
+
self._emit_task_end(run_id, task)
|
|
179
|
+
continue
|
|
180
|
+
self._run_task(run_id, phases, task, deadline)
|
|
181
|
+
if task.state == "failed":
|
|
182
|
+
failed_ids.add(task.spec.id)
|
|
183
|
+
|
|
184
|
+
self._set_run_state(run_id, "finalizing")
|
|
185
|
+
return "failed" if failed_ids or skipped_ids else "completed"
|
|
186
|
+
|
|
187
|
+
# -- task state machine ------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _run_task(
|
|
190
|
+
self,
|
|
191
|
+
run_id: str,
|
|
192
|
+
phases: PhaseRunner,
|
|
193
|
+
task: TaskRecord,
|
|
194
|
+
deadline: float,
|
|
195
|
+
) -> None:
|
|
196
|
+
budgets = self.config.budgets
|
|
197
|
+
if task.state == "pending":
|
|
198
|
+
self._set_task_state(run_id, task, "planning")
|
|
199
|
+
self.bus.emit(
|
|
200
|
+
HostEventTypes.TASK_START, run_id, task_id=task.spec.id, title=task.spec.title
|
|
201
|
+
)
|
|
202
|
+
# Resume mapping: executing/scrutinizing restart at executing (the
|
|
203
|
+
# plan is persisted); a missing plan always restarts at planning.
|
|
204
|
+
if task.state in ("executing", "scrutinizing") and task.plan is None:
|
|
205
|
+
self._set_task_state(run_id, task, "planning")
|
|
206
|
+
if task.state in ("verifying", "validating") and task.plan is None:
|
|
207
|
+
self._set_task_state(run_id, task, "planning")
|
|
208
|
+
if task.state == "scrutinizing":
|
|
209
|
+
self._set_task_state(run_id, task, "executing")
|
|
210
|
+
|
|
211
|
+
while not task.terminal:
|
|
212
|
+
self._check_cancelled_and_clock(run_id, deadline)
|
|
213
|
+
if task.state == "planning":
|
|
214
|
+
self._phase_plan(run_id, phases, task)
|
|
215
|
+
elif task.state == "executing":
|
|
216
|
+
self._phase_execute_and_scrutinize(run_id, phases, task, budgets)
|
|
217
|
+
elif task.state == "verifying":
|
|
218
|
+
self._phase_verify(run_id, phases, task, budgets)
|
|
219
|
+
elif task.state == "validating":
|
|
220
|
+
self._phase_validate(run_id, phases, task, budgets)
|
|
221
|
+
else: # pragma: no cover - defensive
|
|
222
|
+
raise StateError(f"task {task.spec.id} in unexpected state {task.state}")
|
|
223
|
+
|
|
224
|
+
self._emit_task_end(run_id, task)
|
|
225
|
+
|
|
226
|
+
def _phase_plan(self, run_id: str, phases: PhaseRunner, task: TaskRecord) -> None:
|
|
227
|
+
started = time.time()
|
|
228
|
+
plan = phases.plan(task)
|
|
229
|
+
task.plan = plan
|
|
230
|
+
self.store.record_phase(
|
|
231
|
+
run_id,
|
|
232
|
+
"plan",
|
|
233
|
+
task_id=task.spec.id,
|
|
234
|
+
attempt=task.replans + 1,
|
|
235
|
+
status="ok",
|
|
236
|
+
output_json=plan.model_dump_json(),
|
|
237
|
+
started_at=started,
|
|
238
|
+
)
|
|
239
|
+
self._set_task_state(run_id, task, "executing")
|
|
240
|
+
|
|
241
|
+
def _phase_execute_and_scrutinize(
|
|
242
|
+
self,
|
|
243
|
+
run_id: str,
|
|
244
|
+
phases: PhaseRunner,
|
|
245
|
+
task: TaskRecord,
|
|
246
|
+
budgets: object,
|
|
247
|
+
) -> None:
|
|
248
|
+
assert task.plan is not None
|
|
249
|
+
started = time.time()
|
|
250
|
+
result = phases.execute(task, task.plan)
|
|
251
|
+
task.session_id = result.session_id
|
|
252
|
+
executor_report = clip(result.output_text)
|
|
253
|
+
self.store.record_phase(
|
|
254
|
+
run_id,
|
|
255
|
+
"execute",
|
|
256
|
+
task_id=task.spec.id,
|
|
257
|
+
attempt=task.revisions + 1,
|
|
258
|
+
status="ok",
|
|
259
|
+
output_json=json.dumps({"report": executor_report, "session_id": result.session_id}),
|
|
260
|
+
started_at=started,
|
|
261
|
+
)
|
|
262
|
+
self._set_task_state(run_id, task, "scrutinizing")
|
|
263
|
+
|
|
264
|
+
started = time.time()
|
|
265
|
+
verdict = phases.scrutinize(task, task.plan, executor_report)
|
|
266
|
+
self.store.record_phase(
|
|
267
|
+
run_id,
|
|
268
|
+
"scrutinize",
|
|
269
|
+
task_id=task.spec.id,
|
|
270
|
+
attempt=task.revisions + 1,
|
|
271
|
+
status=verdict.verdict,
|
|
272
|
+
output_json=verdict.model_dump_json(),
|
|
273
|
+
started_at=started,
|
|
274
|
+
)
|
|
275
|
+
if verdict.verdict == "revise":
|
|
276
|
+
self._register_revision(run_id, task, verdict.feedback or "scrutiny found issues")
|
|
277
|
+
else:
|
|
278
|
+
task.last_feedback = ""
|
|
279
|
+
self._set_task_state(run_id, task, "verifying")
|
|
280
|
+
|
|
281
|
+
def _phase_verify(
|
|
282
|
+
self,
|
|
283
|
+
run_id: str,
|
|
284
|
+
phases: PhaseRunner,
|
|
285
|
+
task: TaskRecord,
|
|
286
|
+
budgets: object,
|
|
287
|
+
) -> None:
|
|
288
|
+
assert task.plan is not None
|
|
289
|
+
started = time.time()
|
|
290
|
+
passed, feedback = phases.verify(task, task.plan)
|
|
291
|
+
self.store.record_phase(
|
|
292
|
+
run_id,
|
|
293
|
+
"verify",
|
|
294
|
+
task_id=task.spec.id,
|
|
295
|
+
attempt=task.revisions + 1,
|
|
296
|
+
status="ok" if passed else "failed",
|
|
297
|
+
output_json=json.dumps({"passed": passed, "feedback": clip(feedback)}),
|
|
298
|
+
started_at=started,
|
|
299
|
+
)
|
|
300
|
+
if passed:
|
|
301
|
+
self._set_task_state(run_id, task, "validating")
|
|
302
|
+
else:
|
|
303
|
+
self._register_revision(run_id, task, feedback)
|
|
304
|
+
|
|
305
|
+
def _phase_validate(
|
|
306
|
+
self,
|
|
307
|
+
run_id: str,
|
|
308
|
+
phases: PhaseRunner,
|
|
309
|
+
task: TaskRecord,
|
|
310
|
+
budgets: object,
|
|
311
|
+
) -> None:
|
|
312
|
+
started = time.time()
|
|
313
|
+
verdict = phases.validate(task)
|
|
314
|
+
self.store.record_phase(
|
|
315
|
+
run_id,
|
|
316
|
+
"validate",
|
|
317
|
+
task_id=task.spec.id,
|
|
318
|
+
attempt=task.replans + 1,
|
|
319
|
+
status=verdict.verdict,
|
|
320
|
+
output_json=verdict.model_dump_json(),
|
|
321
|
+
started_at=started,
|
|
322
|
+
)
|
|
323
|
+
if verdict.verdict == "accept":
|
|
324
|
+
self._set_task_state(run_id, task, "done")
|
|
325
|
+
return
|
|
326
|
+
task.replans += 1
|
|
327
|
+
if task.replans > self.config.budgets.max_replans_per_task:
|
|
328
|
+
task.last_feedback = verdict.feedback
|
|
329
|
+
self._set_task_state(run_id, task, "failed")
|
|
330
|
+
return
|
|
331
|
+
task.last_feedback = verdict.feedback or "validation rejected the result"
|
|
332
|
+
task.plan = None
|
|
333
|
+
task.revisions = 0
|
|
334
|
+
self._set_task_state(run_id, task, "planning")
|
|
335
|
+
|
|
336
|
+
def _register_revision(self, run_id: str, task: TaskRecord, feedback: str) -> None:
|
|
337
|
+
task.revisions += 1
|
|
338
|
+
task.last_feedback = feedback
|
|
339
|
+
if task.revisions > self.config.budgets.max_revisions_per_task:
|
|
340
|
+
self._set_task_state(run_id, task, "failed")
|
|
341
|
+
else:
|
|
342
|
+
self._set_task_state(run_id, task, "executing")
|
|
343
|
+
|
|
344
|
+
# -- bookkeeping -------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
|
|
347
|
+
if self.store.get_run(run_id).state == "cancelled":
|
|
348
|
+
raise StateError(f"run {run_id} was cancelled")
|
|
349
|
+
if self.clock() > deadline:
|
|
350
|
+
self._set_run_state(run_id, "failed")
|
|
351
|
+
raise BudgetExceededError(
|
|
352
|
+
f"run {run_id} exceeded max_wall_clock_s={self.config.budgets.max_wall_clock_s}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _set_run_state(self, run_id: str, state: RunState) -> None:
|
|
356
|
+
self.store.set_run_state(run_id, state)
|
|
357
|
+
self.bus.emit(HostEventTypes.RUN_STATE, run_id, state=state)
|
|
358
|
+
|
|
359
|
+
def _set_task_state(self, run_id: str, task: TaskRecord, state: TaskState) -> None:
|
|
360
|
+
task.state = state
|
|
361
|
+
self.store.update_task(run_id, task)
|
|
362
|
+
self.bus.emit(
|
|
363
|
+
HostEventTypes.TASK_STATE,
|
|
364
|
+
run_id,
|
|
365
|
+
task_id=task.spec.id,
|
|
366
|
+
state=state,
|
|
367
|
+
revisions=task.revisions,
|
|
368
|
+
replans=task.replans,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _emit_task_end(self, run_id: str, task: TaskRecord) -> None:
|
|
372
|
+
self.bus.emit(
|
|
373
|
+
HostEventTypes.TASK_END,
|
|
374
|
+
run_id,
|
|
375
|
+
task_id=task.spec.id,
|
|
376
|
+
title=task.spec.title,
|
|
377
|
+
state=task.state,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def run_outcome(outcome: str, config: Config | None = None) -> RunResult:
|
|
382
|
+
"""Convenience one-shot API: run an outcome with default wiring."""
|
|
383
|
+
return LoopEngine(config or load_config()).start(outcome)
|
sbxloop/engine/model.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Engine domain models: runs, tasks, plans, verdicts, the task graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from graphlib import CycleError, TopologicalSorter
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
RunState = Literal[
|
|
11
|
+
"created",
|
|
12
|
+
"provisioning",
|
|
13
|
+
"decomposing",
|
|
14
|
+
"running",
|
|
15
|
+
"finalizing",
|
|
16
|
+
"completed",
|
|
17
|
+
"failed",
|
|
18
|
+
"cancelled",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
TaskState = Literal[
|
|
22
|
+
"pending",
|
|
23
|
+
"planning",
|
|
24
|
+
"executing",
|
|
25
|
+
"scrutinizing",
|
|
26
|
+
"verifying",
|
|
27
|
+
"validating",
|
|
28
|
+
"done",
|
|
29
|
+
"failed",
|
|
30
|
+
"skipped",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
TERMINAL_TASK_STATES: frozenset[str] = frozenset({"done", "failed", "skipped"})
|
|
34
|
+
RESUMABLE_RUN_STATES: frozenset[str] = frozenset(
|
|
35
|
+
{"created", "provisioning", "decomposing", "running", "finalizing", "failed"}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
Phase = Literal["decompose", "plan", "execute", "scrutinize", "verify", "validate"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Model(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TaskSpec(_Model):
|
|
46
|
+
"""One unit of the decomposed outcome (agent-authored, engine-validated)."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
title: str
|
|
50
|
+
description: str = ""
|
|
51
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
52
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
53
|
+
verify_commands: list[str] = Field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskGraph(_Model):
|
|
57
|
+
tasks: list[TaskSpec]
|
|
58
|
+
|
|
59
|
+
@model_validator(mode="after")
|
|
60
|
+
def _check_graph(self) -> TaskGraph:
|
|
61
|
+
if not self.tasks:
|
|
62
|
+
raise ValueError("task graph must contain at least one task")
|
|
63
|
+
ids = [t.id for t in self.tasks]
|
|
64
|
+
if len(set(ids)) != len(ids):
|
|
65
|
+
raise ValueError(f"duplicate task ids: {ids}")
|
|
66
|
+
known = set(ids)
|
|
67
|
+
for task in self.tasks:
|
|
68
|
+
unknown = [d for d in task.depends_on if d not in known]
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ValueError(f"task {task.id} depends on unknown tasks: {unknown}")
|
|
71
|
+
if task.id in task.depends_on:
|
|
72
|
+
raise ValueError(f"task {task.id} depends on itself")
|
|
73
|
+
try:
|
|
74
|
+
self.topo_order()
|
|
75
|
+
except CycleError as exc:
|
|
76
|
+
raise ValueError(f"task graph contains a cycle: {exc.args}") from exc
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def topo_order(self) -> list[TaskSpec]:
|
|
80
|
+
"""Dependency order; ties broken by authored order (depth, position)."""
|
|
81
|
+
by_id = {t.id: t for t in self.tasks}
|
|
82
|
+
# TopologicalSorter validates the cycle; the actual ordering is
|
|
83
|
+
# deterministic: dependency depth first, authored position second.
|
|
84
|
+
list(TopologicalSorter({t.id: set(t.depends_on) for t in self.tasks}).static_order())
|
|
85
|
+
position = {t.id: i for i, t in enumerate(self.tasks)}
|
|
86
|
+
return sorted(
|
|
87
|
+
self.tasks,
|
|
88
|
+
key=lambda t: (self._depth(t, by_id), position[t.id]),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _depth(self, task: TaskSpec, by_id: dict[str, TaskSpec]) -> int:
|
|
92
|
+
if not task.depends_on:
|
|
93
|
+
return 0
|
|
94
|
+
return 1 + max(self._depth(by_id[d], by_id) for d in task.depends_on)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PlanModel(_Model):
|
|
98
|
+
"""The agent's plan for one task."""
|
|
99
|
+
|
|
100
|
+
steps: list[str]
|
|
101
|
+
expected_artifacts: list[str] = Field(default_factory=list)
|
|
102
|
+
verify_commands: list[str] = Field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Issue(_Model):
|
|
106
|
+
severity: Literal["low", "medium", "high"] = "medium"
|
|
107
|
+
detail: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Verdict(_Model):
|
|
111
|
+
"""Critic output: scrutinize uses pass/revise, validate uses accept/reject."""
|
|
112
|
+
|
|
113
|
+
verdict: Literal["pass", "revise", "accept", "reject"]
|
|
114
|
+
issues: list[Issue] = Field(default_factory=list)
|
|
115
|
+
feedback: str = ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TaskRecord(_Model):
|
|
119
|
+
"""Persisted per-task state."""
|
|
120
|
+
|
|
121
|
+
spec: TaskSpec
|
|
122
|
+
state: TaskState = "pending"
|
|
123
|
+
revisions: int = 0
|
|
124
|
+
replans: int = 0
|
|
125
|
+
last_feedback: str = ""
|
|
126
|
+
session_id: str | None = None
|
|
127
|
+
plan: PlanModel | None = None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def terminal(self) -> bool:
|
|
131
|
+
return self.state in TERMINAL_TASK_STATES
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RunRecord(_Model):
|
|
135
|
+
run_id: str
|
|
136
|
+
outcome: str
|
|
137
|
+
state: RunState
|
|
138
|
+
created_at: float
|
|
139
|
+
updated_at: float
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RunResult(_Model):
|
|
143
|
+
"""What LoopEngine.start()/resume() returns to callers."""
|
|
144
|
+
|
|
145
|
+
run_id: str
|
|
146
|
+
state: RunState
|
|
147
|
+
tasks: list[TaskRecord] = Field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def succeeded(self) -> bool:
|
|
151
|
+
return self.state == "completed"
|