sdxloop 0.1.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.
- sdxloop/__init__.py +26 -0
- sdxloop/_vendor/sdxloop_worker-0.1.0-py3-none-any.whl +0 -0
- sdxloop/cli/__init__.py +1 -0
- sdxloop/cli/app.py +359 -0
- sdxloop/cli/doctor.py +145 -0
- sdxloop/cli/tui.py +129 -0
- sdxloop/config.py +180 -0
- sdxloop/engine/__init__.py +26 -0
- sdxloop/engine/engine.py +380 -0
- sdxloop/engine/model.py +151 -0
- sdxloop/engine/phases.py +223 -0
- sdxloop/engine/prompts/__init__.py +31 -0
- sdxloop/engine/prompts/decompose.md +41 -0
- sdxloop/engine/prompts/execute.md +31 -0
- sdxloop/engine/prompts/plan.md +35 -0
- sdxloop/engine/prompts/scrutinize.md +43 -0
- sdxloop/engine/prompts/validate.md +38 -0
- sdxloop/engine/store.py +257 -0
- sdxloop/errors.py +73 -0
- sdxloop/events.py +87 -0
- sdxloop/gh/__init__.py +6 -0
- sdxloop/gh/ops.py +140 -0
- sdxloop/gh/reporter.py +75 -0
- sdxloop/ids.py +35 -0
- sdxloop/py.typed +0 -0
- sdxloop/sbx/__init__.py +6 -0
- sdxloop/sbx/cli.py +210 -0
- sdxloop/sbx/models.py +83 -0
- sdxloop/sbx/pair.py +125 -0
- sdxloop/sbx/parse.py +67 -0
- sdxloop/sbx/provision.py +209 -0
- sdxloop/sbx/sandbox.py +70 -0
- sdxloop/worker/__init__.py +6 -0
- sdxloop/worker/client.py +260 -0
- sdxloop/worker/wheel.py +73 -0
- sdxloop-0.1.0.dist-info/METADATA +32 -0
- sdxloop-0.1.0.dist-info/RECORD +39 -0
- sdxloop-0.1.0.dist-info/WHEEL +4 -0
- sdxloop-0.1.0.dist-info/entry_points.txt +2 -0
sdxloop/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Configuration loading with layered precedence.
|
|
2
|
+
|
|
3
|
+
Precedence (highest wins): ``SDXLOOP_*`` environment variables >
|
|
4
|
+
``./sdxloop.toml`` > ``pyproject.toml [tool.sdxloop]`` > built-in defaults.
|
|
5
|
+
|
|
6
|
+
Nested keys use ``__`` in environment variables, e.g.
|
|
7
|
+
``SDXLOOP_BUDGETS__MAX_TASKS=5``. Values are parsed as TOML scalars where
|
|
8
|
+
possible (so ``true``, ``42``, ``1.5`` get real types) and fall back to
|
|
9
|
+
strings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import tomllib
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
21
|
+
|
|
22
|
+
from sdxloop.errors import ConfigError
|
|
23
|
+
|
|
24
|
+
ENV_PREFIX = "SDXLOOP_"
|
|
25
|
+
|
|
26
|
+
# SDXLOOP_-prefixed variables consumed by the *worker process* rather than
|
|
27
|
+
# host configuration; the env config layer must not treat them as settings.
|
|
28
|
+
RESERVED_ENV_KEYS = frozenset({"worker_backend", "echo_script"})
|
|
29
|
+
|
|
30
|
+
WorkerTransport = Literal["stream", "poll"]
|
|
31
|
+
SecretStrategy = Literal["proxy", "plain-env"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ConfigModel(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SandboxConfig(_ConfigModel):
|
|
39
|
+
template: str | None = None
|
|
40
|
+
workspace: Path | None = None
|
|
41
|
+
extra_allow_domains: list[str] = Field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GithubConfig(_ConfigModel):
|
|
45
|
+
report_repo: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Budgets(_ConfigModel):
|
|
49
|
+
max_revisions_per_task: int = 2
|
|
50
|
+
max_replans_per_task: int = 1
|
|
51
|
+
max_tasks: int = 20
|
|
52
|
+
max_wall_clock_s: float = 7200.0
|
|
53
|
+
per_job_timeout_s: float = 900.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Config(_ConfigModel):
|
|
57
|
+
model: str = "auto"
|
|
58
|
+
app_name: str = "sdxloop"
|
|
59
|
+
state_dir: Path = Path(".sdxloop")
|
|
60
|
+
keep_sandboxes: bool = False
|
|
61
|
+
worker_transport: WorkerTransport = "stream"
|
|
62
|
+
secret_strategy: SecretStrategy = "proxy"
|
|
63
|
+
# Advanced: in-sandbox interpreter for the worker, and whether to run the
|
|
64
|
+
# install flow. Overridden in tests/e2e via SDXLOOP_WORKER_PYTHON /
|
|
65
|
+
# SDXLOOP_INSTALL_WORKERS.
|
|
66
|
+
worker_python: str = "/home/agent/.sdxloop/venv/bin/python"
|
|
67
|
+
install_workers: bool = True
|
|
68
|
+
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
69
|
+
github: GithubConfig = Field(default_factory=GithubConfig)
|
|
70
|
+
budgets: Budgets = Field(default_factory=Budgets)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
74
|
+
try:
|
|
75
|
+
with path.open("rb") as f:
|
|
76
|
+
return tomllib.load(f)
|
|
77
|
+
except tomllib.TOMLDecodeError as exc:
|
|
78
|
+
raise ConfigError(f"invalid TOML in {path}: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pyproject_layer(cwd: Path) -> dict[str, Any]:
|
|
82
|
+
path = cwd / "pyproject.toml"
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
return {}
|
|
85
|
+
data = _read_toml(path)
|
|
86
|
+
tool = data.get("tool", {})
|
|
87
|
+
if not isinstance(tool, dict):
|
|
88
|
+
return {}
|
|
89
|
+
section = tool.get("sdxloop", {})
|
|
90
|
+
return section if isinstance(section, dict) else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sdxloop_toml_layer(cwd: Path) -> dict[str, Any]:
|
|
94
|
+
path = cwd / "sdxloop.toml"
|
|
95
|
+
if not path.is_file():
|
|
96
|
+
return {}
|
|
97
|
+
return _read_toml(path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_env_value(raw: str) -> Any:
|
|
101
|
+
"""Parse an env var as a TOML scalar; fall back to the raw string."""
|
|
102
|
+
try:
|
|
103
|
+
return tomllib.loads(f"v = {raw}")["v"]
|
|
104
|
+
except tomllib.TOMLDecodeError:
|
|
105
|
+
return raw
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_layer(env: Mapping[str, str]) -> dict[str, Any]:
|
|
109
|
+
layer: dict[str, Any] = {}
|
|
110
|
+
for key, raw in env.items():
|
|
111
|
+
if not key.startswith(ENV_PREFIX):
|
|
112
|
+
continue
|
|
113
|
+
path = key[len(ENV_PREFIX) :].lower().split("__")
|
|
114
|
+
if not all(path) or path[0] in RESERVED_ENV_KEYS:
|
|
115
|
+
continue
|
|
116
|
+
node = layer
|
|
117
|
+
for part in path[:-1]:
|
|
118
|
+
node = node.setdefault(part, {})
|
|
119
|
+
if not isinstance(node, dict):
|
|
120
|
+
raise ConfigError(f"conflicting env overrides at {key}")
|
|
121
|
+
node[path[-1]] = _parse_env_value(raw)
|
|
122
|
+
return layer
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
merged = dict(base)
|
|
127
|
+
for key, value in override.items():
|
|
128
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
129
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
130
|
+
else:
|
|
131
|
+
merged[key] = value
|
|
132
|
+
return merged
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
|
136
|
+
flat: dict[str, Any] = {}
|
|
137
|
+
for key, value in data.items():
|
|
138
|
+
dotted = f"{prefix}{key}"
|
|
139
|
+
if isinstance(value, dict):
|
|
140
|
+
flat.update(_flatten(value, f"{dotted}."))
|
|
141
|
+
else:
|
|
142
|
+
flat[dotted] = value
|
|
143
|
+
return flat
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_config_with_sources(
|
|
147
|
+
cwd: Path | None = None,
|
|
148
|
+
env: Mapping[str, str] | None = None,
|
|
149
|
+
) -> tuple[Config, dict[str, str]]:
|
|
150
|
+
"""Load config and report, per dotted key, which layer supplied it."""
|
|
151
|
+
cwd = cwd or Path.cwd()
|
|
152
|
+
env = os.environ if env is None else env
|
|
153
|
+
|
|
154
|
+
layers: list[tuple[str, dict[str, Any]]] = [
|
|
155
|
+
("pyproject.toml", _pyproject_layer(cwd)),
|
|
156
|
+
("sdxloop.toml", _sdxloop_toml_layer(cwd)),
|
|
157
|
+
("env", _env_layer(env)),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
merged: dict[str, Any] = {}
|
|
161
|
+
sources: dict[str, str] = {}
|
|
162
|
+
for name, layer in layers:
|
|
163
|
+
merged = _deep_merge(merged, layer)
|
|
164
|
+
for dotted in _flatten(layer):
|
|
165
|
+
sources[dotted] = name
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
config = Config.model_validate(merged)
|
|
169
|
+
except ValidationError as exc:
|
|
170
|
+
raise ConfigError(f"invalid sdxloop configuration: {exc}") from exc
|
|
171
|
+
|
|
172
|
+
for dotted in _flatten(config.model_dump()):
|
|
173
|
+
sources.setdefault(dotted, "default")
|
|
174
|
+
return config, sources
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_config(cwd: Path | None = None, env: Mapping[str, str] | None = None) -> Config:
|
|
178
|
+
"""Load the effective sdxloop configuration for ``cwd``."""
|
|
179
|
+
config, _ = load_config_with_sources(cwd=cwd, env=env)
|
|
180
|
+
return config
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The sdxloop loop engine."""
|
|
2
|
+
|
|
3
|
+
from sdxloop.engine.engine import LoopEngine, run_outcome
|
|
4
|
+
from sdxloop.engine.model import (
|
|
5
|
+
PlanModel,
|
|
6
|
+
RunRecord,
|
|
7
|
+
RunResult,
|
|
8
|
+
TaskGraph,
|
|
9
|
+
TaskRecord,
|
|
10
|
+
TaskSpec,
|
|
11
|
+
Verdict,
|
|
12
|
+
)
|
|
13
|
+
from sdxloop.engine.store import StateStore
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"LoopEngine",
|
|
17
|
+
"PlanModel",
|
|
18
|
+
"RunRecord",
|
|
19
|
+
"RunResult",
|
|
20
|
+
"StateStore",
|
|
21
|
+
"TaskGraph",
|
|
22
|
+
"TaskRecord",
|
|
23
|
+
"TaskSpec",
|
|
24
|
+
"Verdict",
|
|
25
|
+
"run_outcome",
|
|
26
|
+
]
|
sdxloop/engine/engine.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
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 sdxloop.config import Config, load_config
|
|
22
|
+
from sdxloop.engine.model import (
|
|
23
|
+
RESUMABLE_RUN_STATES,
|
|
24
|
+
RunResult,
|
|
25
|
+
RunState,
|
|
26
|
+
TaskRecord,
|
|
27
|
+
TaskState,
|
|
28
|
+
)
|
|
29
|
+
from sdxloop.engine.phases import PhaseRunner, clip
|
|
30
|
+
from sdxloop.engine.store import StateStore
|
|
31
|
+
from sdxloop.errors import BudgetExceededError, SdxloopError, StateError
|
|
32
|
+
from sdxloop.events import EventBus, Hook, HostEventTypes
|
|
33
|
+
from sdxloop.gh.ops import GithubOps
|
|
34
|
+
from sdxloop.gh.reporter import GithubReporterHook
|
|
35
|
+
from sdxloop.ids import new_run_id
|
|
36
|
+
from sdxloop.sbx.cli import SbxCLI
|
|
37
|
+
from sdxloop.sbx.provision import Provisioner
|
|
38
|
+
from sdxloop.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
|
+
self.config = config or load_config()
|
|
55
|
+
self.store = store or StateStore(self.config.state_dir / "state.db")
|
|
56
|
+
self.bus = bus or EventBus()
|
|
57
|
+
self.sbx = sbx or SbxCLI(app_name=self.config.app_name or None)
|
|
58
|
+
self.worker_python = (
|
|
59
|
+
worker_python if worker_python is not None else (self.config.worker_python)
|
|
60
|
+
)
|
|
61
|
+
self.install_workers = (
|
|
62
|
+
install_workers if install_workers is not None else self.config.install_workers
|
|
63
|
+
)
|
|
64
|
+
self.clock = clock
|
|
65
|
+
for hook in hooks:
|
|
66
|
+
self.bus.attach_hook(hook)
|
|
67
|
+
self.bus.subscribe(self._persist_event)
|
|
68
|
+
|
|
69
|
+
def _persist_event(self, event: object) -> None:
|
|
70
|
+
from sdxloop_worker.protocol import Event
|
|
71
|
+
|
|
72
|
+
assert isinstance(event, Event)
|
|
73
|
+
self.store.append_event(event)
|
|
74
|
+
|
|
75
|
+
# -- public API --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
|
|
78
|
+
run_id = run_id or new_run_id()
|
|
79
|
+
self.store.create_run(run_id, outcome, self.config.model_dump_json())
|
|
80
|
+
self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=outcome)
|
|
81
|
+
return self._drive(run_id, outcome)
|
|
82
|
+
|
|
83
|
+
def resume(self, run_id: str) -> RunResult:
|
|
84
|
+
run = self.store.get_run(run_id)
|
|
85
|
+
if run.state not in RESUMABLE_RUN_STATES:
|
|
86
|
+
raise StateError(f"run {run_id} is {run.state}; only unfinished runs can resume")
|
|
87
|
+
self.bus.emit(HostEventTypes.RUN_START, run_id, outcome=run.outcome, resumed=True)
|
|
88
|
+
return self._drive(run_id, run.outcome)
|
|
89
|
+
|
|
90
|
+
def cancel(self, run_id: str) -> None:
|
|
91
|
+
self.store.get_run(run_id) # raises for unknown runs
|
|
92
|
+
self.store.set_run_state(run_id, "cancelled")
|
|
93
|
+
|
|
94
|
+
# -- run driver --------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _drive(self, run_id: str, outcome: str) -> RunResult:
|
|
97
|
+
deadline = self.clock() + self.config.budgets.max_wall_clock_s
|
|
98
|
+
self._set_run_state(run_id, "provisioning")
|
|
99
|
+
provisioner = Provisioner(self.sbx, self.config, self.bus)
|
|
100
|
+
pair = provisioner.ensure_pair(run_id)
|
|
101
|
+
try:
|
|
102
|
+
with pair:
|
|
103
|
+
agent = WorkerClient(
|
|
104
|
+
pair.agent,
|
|
105
|
+
self.bus,
|
|
106
|
+
transport=self.config.worker_transport,
|
|
107
|
+
python=self.worker_python,
|
|
108
|
+
)
|
|
109
|
+
github = WorkerClient(
|
|
110
|
+
pair.github,
|
|
111
|
+
self.bus,
|
|
112
|
+
transport=self.config.worker_transport,
|
|
113
|
+
python=self.worker_python,
|
|
114
|
+
)
|
|
115
|
+
if self.install_workers:
|
|
116
|
+
agent.install(extras="copilot")
|
|
117
|
+
github.install(extras="")
|
|
118
|
+
detach = self._attach_reporter(github, run_id)
|
|
119
|
+
try:
|
|
120
|
+
phases = PhaseRunner(agent, self.config, run_id, outcome)
|
|
121
|
+
state = self._run_phases(run_id, phases, deadline)
|
|
122
|
+
finally:
|
|
123
|
+
detach()
|
|
124
|
+
except SdxloopError:
|
|
125
|
+
# State is already persisted; the exception is the kill signal.
|
|
126
|
+
raise
|
|
127
|
+
self._set_run_state(run_id, state)
|
|
128
|
+
tasks = self.store.get_tasks(run_id)
|
|
129
|
+
self.bus.emit(HostEventTypes.RUN_END, run_id, state=state)
|
|
130
|
+
return RunResult(run_id=run_id, state=state, tasks=tasks)
|
|
131
|
+
|
|
132
|
+
def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
|
|
133
|
+
repo = self.config.github.report_repo
|
|
134
|
+
if not repo:
|
|
135
|
+
return lambda: None
|
|
136
|
+
hook = GithubReporterHook(GithubOps(github, run_id), repo)
|
|
137
|
+
return self.bus.attach_hook(hook)
|
|
138
|
+
|
|
139
|
+
def _run_phases(self, run_id: str, phases: PhaseRunner, deadline: float) -> RunState:
|
|
140
|
+
tasks = self.store.get_tasks(run_id)
|
|
141
|
+
if not tasks:
|
|
142
|
+
self._set_run_state(run_id, "decomposing")
|
|
143
|
+
started = time.time()
|
|
144
|
+
graph = phases.decompose()
|
|
145
|
+
if len(graph.tasks) > self.config.budgets.max_tasks:
|
|
146
|
+
raise BudgetExceededError(
|
|
147
|
+
f"decomposition produced {len(graph.tasks)} tasks "
|
|
148
|
+
f"(max {self.config.budgets.max_tasks})"
|
|
149
|
+
)
|
|
150
|
+
ordered = graph.topo_order()
|
|
151
|
+
self.store.save_tasks(run_id, ordered)
|
|
152
|
+
self.store.record_phase(
|
|
153
|
+
run_id,
|
|
154
|
+
"decompose",
|
|
155
|
+
task_id=None,
|
|
156
|
+
attempt=1,
|
|
157
|
+
status="ok",
|
|
158
|
+
output_json=graph.model_dump_json(),
|
|
159
|
+
started_at=started,
|
|
160
|
+
)
|
|
161
|
+
tasks = self.store.get_tasks(run_id)
|
|
162
|
+
|
|
163
|
+
self._set_run_state(run_id, "running")
|
|
164
|
+
failed_ids: set[str] = {t.spec.id for t in tasks if t.state == "failed"}
|
|
165
|
+
skipped_ids: set[str] = {t.spec.id for t in tasks if t.state == "skipped"}
|
|
166
|
+
for task in tasks:
|
|
167
|
+
if task.terminal:
|
|
168
|
+
failed_ids |= {task.spec.id} if task.state == "failed" else set()
|
|
169
|
+
continue
|
|
170
|
+
blocked = [d for d in task.spec.depends_on if d in failed_ids | skipped_ids]
|
|
171
|
+
if blocked:
|
|
172
|
+
task.state = "skipped"
|
|
173
|
+
skipped_ids.add(task.spec.id)
|
|
174
|
+
self.store.update_task(run_id, task)
|
|
175
|
+
self._emit_task_end(run_id, task)
|
|
176
|
+
continue
|
|
177
|
+
self._run_task(run_id, phases, task, deadline)
|
|
178
|
+
if task.state == "failed":
|
|
179
|
+
failed_ids.add(task.spec.id)
|
|
180
|
+
|
|
181
|
+
self._set_run_state(run_id, "finalizing")
|
|
182
|
+
return "failed" if failed_ids or skipped_ids else "completed"
|
|
183
|
+
|
|
184
|
+
# -- task state machine ------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _run_task(
|
|
187
|
+
self,
|
|
188
|
+
run_id: str,
|
|
189
|
+
phases: PhaseRunner,
|
|
190
|
+
task: TaskRecord,
|
|
191
|
+
deadline: float,
|
|
192
|
+
) -> None:
|
|
193
|
+
budgets = self.config.budgets
|
|
194
|
+
if task.state == "pending":
|
|
195
|
+
self._set_task_state(run_id, task, "planning")
|
|
196
|
+
self.bus.emit(
|
|
197
|
+
HostEventTypes.TASK_START, run_id, task_id=task.spec.id, title=task.spec.title
|
|
198
|
+
)
|
|
199
|
+
# Resume mapping: executing/scrutinizing restart at executing (the
|
|
200
|
+
# plan is persisted); a missing plan always restarts at planning.
|
|
201
|
+
if task.state in ("executing", "scrutinizing") and task.plan is None:
|
|
202
|
+
self._set_task_state(run_id, task, "planning")
|
|
203
|
+
if task.state in ("verifying", "validating") and task.plan is None:
|
|
204
|
+
self._set_task_state(run_id, task, "planning")
|
|
205
|
+
if task.state == "scrutinizing":
|
|
206
|
+
self._set_task_state(run_id, task, "executing")
|
|
207
|
+
|
|
208
|
+
while not task.terminal:
|
|
209
|
+
self._check_cancelled_and_clock(run_id, deadline)
|
|
210
|
+
if task.state == "planning":
|
|
211
|
+
self._phase_plan(run_id, phases, task)
|
|
212
|
+
elif task.state == "executing":
|
|
213
|
+
self._phase_execute_and_scrutinize(run_id, phases, task, budgets)
|
|
214
|
+
elif task.state == "verifying":
|
|
215
|
+
self._phase_verify(run_id, phases, task, budgets)
|
|
216
|
+
elif task.state == "validating":
|
|
217
|
+
self._phase_validate(run_id, phases, task, budgets)
|
|
218
|
+
else: # pragma: no cover - defensive
|
|
219
|
+
raise StateError(f"task {task.spec.id} in unexpected state {task.state}")
|
|
220
|
+
|
|
221
|
+
self._emit_task_end(run_id, task)
|
|
222
|
+
|
|
223
|
+
def _phase_plan(self, run_id: str, phases: PhaseRunner, task: TaskRecord) -> None:
|
|
224
|
+
started = time.time()
|
|
225
|
+
plan = phases.plan(task)
|
|
226
|
+
task.plan = plan
|
|
227
|
+
self.store.record_phase(
|
|
228
|
+
run_id,
|
|
229
|
+
"plan",
|
|
230
|
+
task_id=task.spec.id,
|
|
231
|
+
attempt=task.replans + 1,
|
|
232
|
+
status="ok",
|
|
233
|
+
output_json=plan.model_dump_json(),
|
|
234
|
+
started_at=started,
|
|
235
|
+
)
|
|
236
|
+
self._set_task_state(run_id, task, "executing")
|
|
237
|
+
|
|
238
|
+
def _phase_execute_and_scrutinize(
|
|
239
|
+
self,
|
|
240
|
+
run_id: str,
|
|
241
|
+
phases: PhaseRunner,
|
|
242
|
+
task: TaskRecord,
|
|
243
|
+
budgets: object,
|
|
244
|
+
) -> None:
|
|
245
|
+
assert task.plan is not None
|
|
246
|
+
started = time.time()
|
|
247
|
+
result = phases.execute(task, task.plan)
|
|
248
|
+
task.session_id = result.session_id
|
|
249
|
+
executor_report = clip(result.output_text)
|
|
250
|
+
self.store.record_phase(
|
|
251
|
+
run_id,
|
|
252
|
+
"execute",
|
|
253
|
+
task_id=task.spec.id,
|
|
254
|
+
attempt=task.revisions + 1,
|
|
255
|
+
status="ok",
|
|
256
|
+
output_json=json.dumps({"report": executor_report, "session_id": result.session_id}),
|
|
257
|
+
started_at=started,
|
|
258
|
+
)
|
|
259
|
+
self._set_task_state(run_id, task, "scrutinizing")
|
|
260
|
+
|
|
261
|
+
started = time.time()
|
|
262
|
+
verdict = phases.scrutinize(task, task.plan, executor_report)
|
|
263
|
+
self.store.record_phase(
|
|
264
|
+
run_id,
|
|
265
|
+
"scrutinize",
|
|
266
|
+
task_id=task.spec.id,
|
|
267
|
+
attempt=task.revisions + 1,
|
|
268
|
+
status=verdict.verdict,
|
|
269
|
+
output_json=verdict.model_dump_json(),
|
|
270
|
+
started_at=started,
|
|
271
|
+
)
|
|
272
|
+
if verdict.verdict == "revise":
|
|
273
|
+
self._register_revision(run_id, task, verdict.feedback or "scrutiny found issues")
|
|
274
|
+
else:
|
|
275
|
+
task.last_feedback = ""
|
|
276
|
+
self._set_task_state(run_id, task, "verifying")
|
|
277
|
+
|
|
278
|
+
def _phase_verify(
|
|
279
|
+
self,
|
|
280
|
+
run_id: str,
|
|
281
|
+
phases: PhaseRunner,
|
|
282
|
+
task: TaskRecord,
|
|
283
|
+
budgets: object,
|
|
284
|
+
) -> None:
|
|
285
|
+
assert task.plan is not None
|
|
286
|
+
started = time.time()
|
|
287
|
+
passed, feedback = phases.verify(task, task.plan)
|
|
288
|
+
self.store.record_phase(
|
|
289
|
+
run_id,
|
|
290
|
+
"verify",
|
|
291
|
+
task_id=task.spec.id,
|
|
292
|
+
attempt=task.revisions + 1,
|
|
293
|
+
status="ok" if passed else "failed",
|
|
294
|
+
output_json=json.dumps({"passed": passed, "feedback": clip(feedback)}),
|
|
295
|
+
started_at=started,
|
|
296
|
+
)
|
|
297
|
+
if passed:
|
|
298
|
+
self._set_task_state(run_id, task, "validating")
|
|
299
|
+
else:
|
|
300
|
+
self._register_revision(run_id, task, feedback)
|
|
301
|
+
|
|
302
|
+
def _phase_validate(
|
|
303
|
+
self,
|
|
304
|
+
run_id: str,
|
|
305
|
+
phases: PhaseRunner,
|
|
306
|
+
task: TaskRecord,
|
|
307
|
+
budgets: object,
|
|
308
|
+
) -> None:
|
|
309
|
+
started = time.time()
|
|
310
|
+
verdict = phases.validate(task)
|
|
311
|
+
self.store.record_phase(
|
|
312
|
+
run_id,
|
|
313
|
+
"validate",
|
|
314
|
+
task_id=task.spec.id,
|
|
315
|
+
attempt=task.replans + 1,
|
|
316
|
+
status=verdict.verdict,
|
|
317
|
+
output_json=verdict.model_dump_json(),
|
|
318
|
+
started_at=started,
|
|
319
|
+
)
|
|
320
|
+
if verdict.verdict == "accept":
|
|
321
|
+
self._set_task_state(run_id, task, "done")
|
|
322
|
+
return
|
|
323
|
+
task.replans += 1
|
|
324
|
+
if task.replans > self.config.budgets.max_replans_per_task:
|
|
325
|
+
task.last_feedback = verdict.feedback
|
|
326
|
+
self._set_task_state(run_id, task, "failed")
|
|
327
|
+
return
|
|
328
|
+
task.last_feedback = verdict.feedback or "validation rejected the result"
|
|
329
|
+
task.plan = None
|
|
330
|
+
task.revisions = 0
|
|
331
|
+
self._set_task_state(run_id, task, "planning")
|
|
332
|
+
|
|
333
|
+
def _register_revision(self, run_id: str, task: TaskRecord, feedback: str) -> None:
|
|
334
|
+
task.revisions += 1
|
|
335
|
+
task.last_feedback = feedback
|
|
336
|
+
if task.revisions > self.config.budgets.max_revisions_per_task:
|
|
337
|
+
self._set_task_state(run_id, task, "failed")
|
|
338
|
+
else:
|
|
339
|
+
self._set_task_state(run_id, task, "executing")
|
|
340
|
+
|
|
341
|
+
# -- bookkeeping -------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
|
|
344
|
+
if self.store.get_run(run_id).state == "cancelled":
|
|
345
|
+
raise StateError(f"run {run_id} was cancelled")
|
|
346
|
+
if self.clock() > deadline:
|
|
347
|
+
self._set_run_state(run_id, "failed")
|
|
348
|
+
raise BudgetExceededError(
|
|
349
|
+
f"run {run_id} exceeded max_wall_clock_s={self.config.budgets.max_wall_clock_s}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _set_run_state(self, run_id: str, state: RunState) -> None:
|
|
353
|
+
self.store.set_run_state(run_id, state)
|
|
354
|
+
self.bus.emit(HostEventTypes.RUN_STATE, run_id, state=state)
|
|
355
|
+
|
|
356
|
+
def _set_task_state(self, run_id: str, task: TaskRecord, state: TaskState) -> None:
|
|
357
|
+
task.state = state
|
|
358
|
+
self.store.update_task(run_id, task)
|
|
359
|
+
self.bus.emit(
|
|
360
|
+
HostEventTypes.TASK_STATE,
|
|
361
|
+
run_id,
|
|
362
|
+
task_id=task.spec.id,
|
|
363
|
+
state=state,
|
|
364
|
+
revisions=task.revisions,
|
|
365
|
+
replans=task.replans,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _emit_task_end(self, run_id: str, task: TaskRecord) -> None:
|
|
369
|
+
self.bus.emit(
|
|
370
|
+
HostEventTypes.TASK_END,
|
|
371
|
+
run_id,
|
|
372
|
+
task_id=task.spec.id,
|
|
373
|
+
title=task.spec.title,
|
|
374
|
+
state=task.state,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run_outcome(outcome: str, config: Config | None = None) -> RunResult:
|
|
379
|
+
"""Convenience one-shot API: run an outcome with default wiring."""
|
|
380
|
+
return LoopEngine(config or load_config()).start(outcome)
|