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