forgex-cli 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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""FORGEX build engine (spec sections 1, 12, 34, 35, 36, 53, 57, 58).
|
|
2
|
+
|
|
3
|
+
Connects planning, per-task Builder + review, verification, supervision,
|
|
4
|
+
and the bounded self-healing repair loop.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from forgex.agents.architect import build_planner, plan_project
|
|
19
|
+
from forgex.agents.builder import build_builder, execute_task
|
|
20
|
+
from forgex.agents.expert_coder import build_coder, review_work
|
|
21
|
+
from forgex.agents.supervisor import build_supervisor, supervise
|
|
22
|
+
from forgex.models.router import ModelRouter
|
|
23
|
+
from forgex.orchestration.checkpoints import create_checkpoint
|
|
24
|
+
from forgex.orchestration.lockfile import RunLock
|
|
25
|
+
from forgex.orchestration.stores import ProjectStores
|
|
26
|
+
from forgex.orchestration.workflow import transition
|
|
27
|
+
from forgex.schemas.agent import SupervisorReport
|
|
28
|
+
from forgex.schemas.error import ProjectError
|
|
29
|
+
from forgex.schemas.event import EventType
|
|
30
|
+
from forgex.schemas.project import ProjectState, RunState, RunStatus, WorkflowStatus
|
|
31
|
+
from forgex.schemas.result import TestResults
|
|
32
|
+
from forgex.schemas.task import Task, TaskPriority, TaskStatus, ready_tasks
|
|
33
|
+
from forgex.tools.git import git_changed_files, git_diff
|
|
34
|
+
from forgex.tools.testing import detect_test_command
|
|
35
|
+
from forgex.tools.verification import detect_build_command
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("forgex.engine")
|
|
38
|
+
|
|
39
|
+
ProgressFn = Callable[[str], None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BuildRunResult(BaseModel):
|
|
43
|
+
"""Outcome of one build run (rendered per spec sections 57/58)."""
|
|
44
|
+
|
|
45
|
+
status: Literal["completed", "failed"]
|
|
46
|
+
verdict: Literal["pass", "fail", "needs_review"] | None = None
|
|
47
|
+
reason: str = ""
|
|
48
|
+
tasks_total: int = 0
|
|
49
|
+
tasks_completed: int = 0
|
|
50
|
+
remaining_tasks: list[str] = Field(default_factory=list)
|
|
51
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
52
|
+
tests: TestResults | None = None
|
|
53
|
+
build_ok: bool | None = None
|
|
54
|
+
iterations: int = 0
|
|
55
|
+
checkpoint: str | None = None
|
|
56
|
+
warnings: list[str] = Field(default_factory=list)
|
|
57
|
+
usage: dict[str, Any] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DryRunReport(BaseModel):
|
|
61
|
+
planned_tasks: list[dict[str, Any]] = Field(default_factory=list)
|
|
62
|
+
planned_files: list[str] = Field(default_factory=list)
|
|
63
|
+
libraries: list[str] = Field(default_factory=list)
|
|
64
|
+
detected_tests: list[str] | None = None
|
|
65
|
+
detected_build: list[str] | None = None
|
|
66
|
+
agent_sequence: list[str] = Field(default_factory=list)
|
|
67
|
+
max_iterations: int = 5
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BuildEngine:
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
stores: ProjectStores,
|
|
75
|
+
config: Any,
|
|
76
|
+
approval: Callable[[str], bool] | None = None,
|
|
77
|
+
progress: ProgressFn | None = None,
|
|
78
|
+
agents: dict[str, Any] | None = None,
|
|
79
|
+
max_iterations: int | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._stores = stores
|
|
82
|
+
self._config = config
|
|
83
|
+
self._approval = approval
|
|
84
|
+
self._progress_cb = progress
|
|
85
|
+
self._fakes = agents or {}
|
|
86
|
+
self._max_iterations = (
|
|
87
|
+
max_iterations
|
|
88
|
+
if max_iterations is not None
|
|
89
|
+
else getattr(getattr(config, "workflow", None), "max_iterations", 5)
|
|
90
|
+
)
|
|
91
|
+
self._router: ModelRouter | None = None
|
|
92
|
+
self._run: RunState | None = None
|
|
93
|
+
self._resuming: bool = False
|
|
94
|
+
|
|
95
|
+
def _progress(self, msg: str) -> None:
|
|
96
|
+
if self._progress_cb:
|
|
97
|
+
self._progress_cb(msg)
|
|
98
|
+
|
|
99
|
+
def _state(self) -> ProjectState:
|
|
100
|
+
st = self._stores.state.load()
|
|
101
|
+
if st is None:
|
|
102
|
+
raise ProjectError("Project is not initialized — run `forgex init` first.")
|
|
103
|
+
return st
|
|
104
|
+
|
|
105
|
+
async def _ensure_agents(self) -> None:
|
|
106
|
+
if self._router is None:
|
|
107
|
+
self._router = ModelRouter(self._config, usage=self._stores.usage)
|
|
108
|
+
|
|
109
|
+
async def _cleanup(self) -> None:
|
|
110
|
+
if self._router:
|
|
111
|
+
await self._router.aclose()
|
|
112
|
+
self._router = None
|
|
113
|
+
|
|
114
|
+
def _incomplete_tasks(self) -> list[str]:
|
|
115
|
+
tasks = self._stores.tasks.load()
|
|
116
|
+
# Non-repair incomplete tasks
|
|
117
|
+
return [
|
|
118
|
+
t.id
|
|
119
|
+
for t in tasks
|
|
120
|
+
if not t.id.startswith("REPAIR-") and t.status != TaskStatus.COMPLETED
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
async def _ensure_plan(self, request: str | None) -> None:
|
|
124
|
+
state = self._state()
|
|
125
|
+
if state.architecture_plan is not None and not request:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
effective_request = request or state.user_request
|
|
129
|
+
if not effective_request:
|
|
130
|
+
raise ProjectError("No user request provided and no plan exists. Specify a request.")
|
|
131
|
+
|
|
132
|
+
self._progress("Architect: analyzing and creating implementation plan...")
|
|
133
|
+
state.status = transition(state.status, WorkflowStatus.ANALYZING)
|
|
134
|
+
self._stores.save_state(state)
|
|
135
|
+
|
|
136
|
+
planner = self._fakes.get("planner") or build_planner(
|
|
137
|
+
config=self._config,
|
|
138
|
+
workspace=self._stores.root,
|
|
139
|
+
stores=self._stores,
|
|
140
|
+
router=self._router,
|
|
141
|
+
run_id=self._run.run_id if self._run else None,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
res = await plan_project(
|
|
145
|
+
planner,
|
|
146
|
+
stores=self._stores,
|
|
147
|
+
request=effective_request,
|
|
148
|
+
dry_run=False,
|
|
149
|
+
progress=self._progress,
|
|
150
|
+
)
|
|
151
|
+
self._progress(f"Plan created: {len(res.plan.tasks)} tasks.")
|
|
152
|
+
|
|
153
|
+
async def _task_loop(self) -> None:
|
|
154
|
+
builder = self._fakes.get("builder") or build_builder(
|
|
155
|
+
config=self._config,
|
|
156
|
+
workspace=self._stores.root,
|
|
157
|
+
stores=self._stores,
|
|
158
|
+
router=self._router,
|
|
159
|
+
approval=self._approval,
|
|
160
|
+
run_id=self._run.run_id if self._run else None,
|
|
161
|
+
)
|
|
162
|
+
coder = self._fakes.get("coder") or build_coder(
|
|
163
|
+
config=self._config,
|
|
164
|
+
workspace=self._stores.root,
|
|
165
|
+
stores=self._stores,
|
|
166
|
+
router=self._router,
|
|
167
|
+
approval=self._approval,
|
|
168
|
+
run_id=self._run.run_id if self._run else None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
while True:
|
|
172
|
+
tasks = self._stores.tasks.load()
|
|
173
|
+
ready = ready_tasks(tasks)
|
|
174
|
+
# Exclude repair tasks from normal task loop
|
|
175
|
+
ready = [t for t in ready if not t.id.startswith("REPAIR-")]
|
|
176
|
+
if not ready:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
task = ready[0]
|
|
180
|
+
label = f"Task {task.id}"
|
|
181
|
+
state = self._state()
|
|
182
|
+
|
|
183
|
+
# State transition to BUILDING
|
|
184
|
+
if state.status != WorkflowStatus.BUILDING:
|
|
185
|
+
state.status = transition(state.status, WorkflowStatus.BUILDING)
|
|
186
|
+
self._stores.save_state(state)
|
|
187
|
+
|
|
188
|
+
self._progress(f"{label}: {task.description}...")
|
|
189
|
+
try:
|
|
190
|
+
b_res = await execute_task(
|
|
191
|
+
task,
|
|
192
|
+
builder=builder,
|
|
193
|
+
stores=self._stores,
|
|
194
|
+
config=self._config,
|
|
195
|
+
plan=state.architecture_plan,
|
|
196
|
+
research=state.research,
|
|
197
|
+
)
|
|
198
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
199
|
+
raise
|
|
200
|
+
except Exception as exc: # noqa: BLE001
|
|
201
|
+
self._progress(f"{label} FAILED — {exc}")
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Review pass (Expert Coder)
|
|
205
|
+
state = self._state()
|
|
206
|
+
state.status = transition(state.status, WorkflowStatus.REVIEWING)
|
|
207
|
+
self._stores.save_state(state)
|
|
208
|
+
|
|
209
|
+
self._progress(f"{label}: reviewing changes...")
|
|
210
|
+
await review_work(
|
|
211
|
+
stores=self._stores,
|
|
212
|
+
config=self._config,
|
|
213
|
+
task_id=task.id,
|
|
214
|
+
coder=coder,
|
|
215
|
+
approval=self._approval,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def _verify_and_supervise(self) -> SupervisorReport:
|
|
219
|
+
state = self._state()
|
|
220
|
+
if state.status != WorkflowStatus.TESTING:
|
|
221
|
+
state.status = transition(state.status, WorkflowStatus.TESTING)
|
|
222
|
+
self._stores.save_state(state)
|
|
223
|
+
|
|
224
|
+
self._progress("Testing and quality verification...")
|
|
225
|
+
|
|
226
|
+
state = self._state()
|
|
227
|
+
state.status = transition(state.status, WorkflowStatus.SUPERVISING)
|
|
228
|
+
self._stores.save_state(state)
|
|
229
|
+
|
|
230
|
+
self._progress("Supervisor: auditing repository...")
|
|
231
|
+
supervisor = self._fakes.get("supervisor") or build_supervisor(
|
|
232
|
+
config=self._config,
|
|
233
|
+
workspace=self._stores.root,
|
|
234
|
+
stores=self._stores,
|
|
235
|
+
router=self._router,
|
|
236
|
+
approval=self._approval,
|
|
237
|
+
run_id=self._run.run_id if self._run else None,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
report = await supervise(
|
|
241
|
+
stores=self._stores,
|
|
242
|
+
config=self._config,
|
|
243
|
+
approval=self._approval,
|
|
244
|
+
supervisor=supervisor,
|
|
245
|
+
)
|
|
246
|
+
return report
|
|
247
|
+
|
|
248
|
+
async def _repair(self, report: SupervisorReport, incomplete: list[str]) -> None:
|
|
249
|
+
state = self._state()
|
|
250
|
+
state.status = transition(state.status, WorkflowStatus.REPAIRING)
|
|
251
|
+
state.iteration += 1
|
|
252
|
+
self._stores.save_state(state)
|
|
253
|
+
|
|
254
|
+
self._stores.recorder.record(
|
|
255
|
+
EventType.REPAIR_STARTED,
|
|
256
|
+
{"iteration": state.iteration, "required_changes": report.required_changes},
|
|
257
|
+
run_id=self._run.run_id if self._run else None,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
repair_id = f"REPAIR-{state.iteration:03d}"
|
|
261
|
+
criteria = list(report.required_changes)
|
|
262
|
+
if incomplete:
|
|
263
|
+
criteria.append(f"Incomplete tasks to finish: {', '.join(incomplete)}")
|
|
264
|
+
|
|
265
|
+
repair_task = Task(
|
|
266
|
+
id=repair_id,
|
|
267
|
+
description=f"Self-healing repair iteration {state.iteration}",
|
|
268
|
+
priority=TaskPriority.CRITICAL,
|
|
269
|
+
acceptance_criteria=criteria,
|
|
270
|
+
status=TaskStatus.PENDING,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
self._progress(f"Self-healing repair iteration {state.iteration}...")
|
|
274
|
+
coder = self._fakes.get("coder") or build_coder(
|
|
275
|
+
config=self._config,
|
|
276
|
+
workspace=self._stores.root,
|
|
277
|
+
stores=self._stores,
|
|
278
|
+
router=self._router,
|
|
279
|
+
approval=self._approval,
|
|
280
|
+
run_id=self._run.run_id if self._run else None,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
extra_ctx = (
|
|
284
|
+
f"<supervisor_report>\nStatus: {report.status}\nSummary: {report.summary}\n"
|
|
285
|
+
f"Required changes:\n" + "\n".join(f"- {c}" for c in criteria) + "\n</supervisor_report>"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
await coder.review(
|
|
289
|
+
task=repair_task,
|
|
290
|
+
plan=state.architecture_plan,
|
|
291
|
+
research=state.research,
|
|
292
|
+
diff=git_diff(self._stores.root),
|
|
293
|
+
changed_files=git_changed_files(self._stores.root),
|
|
294
|
+
errors=state.errors[-5:],
|
|
295
|
+
extra_context=extra_ctx,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _finish_success(self, report: SupervisorReport) -> BuildRunResult:
|
|
299
|
+
state = self._state()
|
|
300
|
+
state.status = transition(state.status, WorkflowStatus.COMPLETED)
|
|
301
|
+
self._stores.save_state(state)
|
|
302
|
+
|
|
303
|
+
cp = create_checkpoint(
|
|
304
|
+
f"completed-run-{self._run.run_id[:8]}" if self._run else "completed",
|
|
305
|
+
stores=self._stores,
|
|
306
|
+
description="Build completed successfully",
|
|
307
|
+
run_id=self._run.run_id if self._run else None,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if self._run:
|
|
311
|
+
self._run.status = RunStatus.COMPLETED
|
|
312
|
+
self._run.phase = WorkflowStatus.COMPLETED
|
|
313
|
+
self._run.finished_at = datetime.now(UTC)
|
|
314
|
+
self._run.summary = "Build completed successfully with supervisor approval"
|
|
315
|
+
if self._stores.runs is not None:
|
|
316
|
+
self._stores.runs.update(self._run)
|
|
317
|
+
|
|
318
|
+
self._stores.recorder.record(
|
|
319
|
+
EventType.RUN_COMPLETED,
|
|
320
|
+
{"status": "completed", "verdict": report.status},
|
|
321
|
+
run_id=self._run.run_id if self._run else None,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
tasks = [t for t in self._stores.tasks.load() if not t.id.startswith("REPAIR-")]
|
|
325
|
+
completed = [t for t in tasks if t.status == TaskStatus.COMPLETED]
|
|
326
|
+
|
|
327
|
+
usage_totals = None
|
|
328
|
+
if self._stores.usage is not None:
|
|
329
|
+
try:
|
|
330
|
+
usage_totals = self._stores.usage.totals()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
return BuildRunResult(
|
|
335
|
+
status="completed",
|
|
336
|
+
verdict=report.status,
|
|
337
|
+
reason="Build complete and verified by supervisor",
|
|
338
|
+
tasks_total=len(tasks),
|
|
339
|
+
tasks_completed=len(completed),
|
|
340
|
+
changed_files=state.changed_files,
|
|
341
|
+
tests=report.tests,
|
|
342
|
+
build_ok=report.build.ok if report.build else True,
|
|
343
|
+
iterations=state.iteration,
|
|
344
|
+
checkpoint=cp.git_commit if cp else None,
|
|
345
|
+
warnings=report.warnings,
|
|
346
|
+
usage=usage_totals,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def _finish_failure(self, reason: str, report: SupervisorReport | None = None) -> BuildRunResult:
|
|
350
|
+
state = self._state()
|
|
351
|
+
state.status = transition(state.status, WorkflowStatus.FAILED)
|
|
352
|
+
self._stores.save_state(state)
|
|
353
|
+
|
|
354
|
+
if self._run:
|
|
355
|
+
self._run.status = RunStatus.FAILED
|
|
356
|
+
self._run.phase = WorkflowStatus.FAILED
|
|
357
|
+
self._run.finished_at = datetime.now(UTC)
|
|
358
|
+
self._run.error = reason
|
|
359
|
+
if self._stores.runs is not None:
|
|
360
|
+
self._stores.runs.update(self._run)
|
|
361
|
+
|
|
362
|
+
self._stores.recorder.record(
|
|
363
|
+
EventType.RUN_FAILED,
|
|
364
|
+
{"status": "failed", "reason": reason},
|
|
365
|
+
run_id=self._run.run_id if self._run else None,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
tasks = [t for t in self._stores.tasks.load() if not t.id.startswith("REPAIR-")]
|
|
369
|
+
completed = [t for t in tasks if t.status == TaskStatus.COMPLETED]
|
|
370
|
+
remaining = [t.id for t in tasks if t.status != TaskStatus.COMPLETED]
|
|
371
|
+
|
|
372
|
+
usage_totals = None
|
|
373
|
+
if self._stores.usage is not None:
|
|
374
|
+
try:
|
|
375
|
+
usage_totals = self._stores.usage.totals()
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
return BuildRunResult(
|
|
380
|
+
status="failed",
|
|
381
|
+
verdict=report.status if report else "fail",
|
|
382
|
+
reason=reason,
|
|
383
|
+
tasks_total=len(tasks),
|
|
384
|
+
tasks_completed=len(completed),
|
|
385
|
+
remaining_tasks=remaining,
|
|
386
|
+
changed_files=state.changed_files,
|
|
387
|
+
tests=report.tests if report else None,
|
|
388
|
+
build_ok=report.build.ok if report and report.build else False,
|
|
389
|
+
iterations=state.iteration,
|
|
390
|
+
checkpoint=None,
|
|
391
|
+
warnings=report.warnings if report else [],
|
|
392
|
+
usage=usage_totals,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _handle_interrupt(self) -> None:
|
|
396
|
+
state = self._state()
|
|
397
|
+
state.status = WorkflowStatus.CANCELLED
|
|
398
|
+
self._stores.save_state(state)
|
|
399
|
+
|
|
400
|
+
if self._run:
|
|
401
|
+
self._run.status = RunStatus.CANCELLED
|
|
402
|
+
self._run.phase = WorkflowStatus.CANCELLED
|
|
403
|
+
self._run.finished_at = datetime.now(UTC)
|
|
404
|
+
self._run.error = "Interrupted by user"
|
|
405
|
+
if self._stores.runs is not None:
|
|
406
|
+
self._stores.runs.update(self._run)
|
|
407
|
+
|
|
408
|
+
self._stores.recorder.record(
|
|
409
|
+
EventType.INTERRUPTED,
|
|
410
|
+
{"stage": "engine_interrupted"},
|
|
411
|
+
run_id=self._run.run_id if self._run else None,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def run(
|
|
415
|
+
self,
|
|
416
|
+
request: str | None = None,
|
|
417
|
+
*,
|
|
418
|
+
dry_run: bool = False,
|
|
419
|
+
resume: bool = False,
|
|
420
|
+
) -> BuildRunResult | DryRunReport:
|
|
421
|
+
state = self._state()
|
|
422
|
+
await self._ensure_agents()
|
|
423
|
+
|
|
424
|
+
if dry_run:
|
|
425
|
+
# Plan without persisting
|
|
426
|
+
planner = self._fakes.get("planner") or build_planner(
|
|
427
|
+
config=self._config,
|
|
428
|
+
workspace=self._stores.root,
|
|
429
|
+
stores=self._stores,
|
|
430
|
+
router=self._router,
|
|
431
|
+
)
|
|
432
|
+
req = request or state.user_request or "Default project build"
|
|
433
|
+
res = await planner.plan(req)
|
|
434
|
+
return DryRunReport(
|
|
435
|
+
planned_tasks=[t.model_dump() for t in res.plan.tasks],
|
|
436
|
+
planned_files=list({f for t in res.plan.tasks for f in t.files}),
|
|
437
|
+
libraries=res.plan.libraries,
|
|
438
|
+
detected_tests=detect_test_command(self._stores.root),
|
|
439
|
+
detected_build=detect_build_command(self._stores.root),
|
|
440
|
+
agent_sequence=["architect", "builder", "expert_coder", "supervisor"],
|
|
441
|
+
max_iterations=self._config.workflow.max_iterations,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
lock = RunLock(self._stores.forgex_dir / "run.lock")
|
|
445
|
+
lock.acquire()
|
|
446
|
+
|
|
447
|
+
# Handle resume
|
|
448
|
+
if resume:
|
|
449
|
+
self._resuming = True
|
|
450
|
+
tasks = self._stores.tasks.load()
|
|
451
|
+
for t in tasks:
|
|
452
|
+
if t.status == TaskStatus.IN_PROGRESS:
|
|
453
|
+
t.status = TaskStatus.PENDING
|
|
454
|
+
self._stores.save_tasks(tasks, project_id=state.project_id)
|
|
455
|
+
if state.status in (WorkflowStatus.COMPLETED, WorkflowStatus.FAILED, WorkflowStatus.CANCELLED):
|
|
456
|
+
state.status = WorkflowStatus.BUILDING
|
|
457
|
+
self._stores.save_state(state)
|
|
458
|
+
|
|
459
|
+
run = RunState(
|
|
460
|
+
run_id=uuid.uuid4().hex,
|
|
461
|
+
project_id=state.project_id,
|
|
462
|
+
request=request or state.user_request or "build",
|
|
463
|
+
phase=state.status,
|
|
464
|
+
)
|
|
465
|
+
if self._stores.runs is not None:
|
|
466
|
+
self._stores.runs.create(run)
|
|
467
|
+
self._run = run
|
|
468
|
+
state.current_run_id = run.run_id
|
|
469
|
+
self._stores.save_state(state)
|
|
470
|
+
|
|
471
|
+
self._stores.recorder.record(
|
|
472
|
+
EventType.RUN_STARTED,
|
|
473
|
+
{"request": run.request, "run_id": run.run_id},
|
|
474
|
+
run_id=run.run_id,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
await self._ensure_plan(request)
|
|
479
|
+
await self._task_loop()
|
|
480
|
+
|
|
481
|
+
while True:
|
|
482
|
+
report = await self._verify_and_supervise()
|
|
483
|
+
incomplete = self._incomplete_tasks()
|
|
484
|
+
|
|
485
|
+
# Gate 0: Implementation complete check
|
|
486
|
+
if report.status == "pass" and not incomplete:
|
|
487
|
+
return self._finish_success(report)
|
|
488
|
+
|
|
489
|
+
if report.status == "needs_review":
|
|
490
|
+
return self._finish_failure(
|
|
491
|
+
"supervisor verdict: needs_review (cannot verify)", report
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
state = self._state()
|
|
495
|
+
if state.iteration >= self._max_iterations:
|
|
496
|
+
return self._finish_failure(
|
|
497
|
+
f"maximum repair iterations reached ({self._max_iterations})",
|
|
498
|
+
report,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
await self._repair(report, incomplete)
|
|
502
|
+
|
|
503
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
504
|
+
self._handle_interrupt()
|
|
505
|
+
raise
|
|
506
|
+
finally:
|
|
507
|
+
await self._cleanup()
|
|
508
|
+
lock.release()
|
|
509
|
+
|
|
510
|
+
async def fix(self) -> BuildRunResult:
|
|
511
|
+
"""§12/§22 `forgex fix`: on-demand repair loop without replanning."""
|
|
512
|
+
state = self._state()
|
|
513
|
+
await self._ensure_agents()
|
|
514
|
+
self._resuming = True
|
|
515
|
+
if state.status in (
|
|
516
|
+
WorkflowStatus.COMPLETED,
|
|
517
|
+
WorkflowStatus.FAILED,
|
|
518
|
+
WorkflowStatus.CANCELLED,
|
|
519
|
+
WorkflowStatus.INITIALIZING,
|
|
520
|
+
):
|
|
521
|
+
logger.debug("fix() from state %s — resetting to testing", state.status.value)
|
|
522
|
+
state.status = WorkflowStatus.TESTING
|
|
523
|
+
elif state.status != WorkflowStatus.TESTING:
|
|
524
|
+
state.status = WorkflowStatus.TESTING
|
|
525
|
+
state.iteration = 0
|
|
526
|
+
self._stores.save_state(state)
|
|
527
|
+
|
|
528
|
+
lock = RunLock(self._stores.forgex_dir / "run.lock")
|
|
529
|
+
lock.acquire()
|
|
530
|
+
|
|
531
|
+
run = RunState(
|
|
532
|
+
run_id=uuid.uuid4().hex,
|
|
533
|
+
project_id=state.project_id,
|
|
534
|
+
request="fix",
|
|
535
|
+
phase=state.status,
|
|
536
|
+
)
|
|
537
|
+
if self._stores.runs is not None:
|
|
538
|
+
self._stores.runs.create(run)
|
|
539
|
+
self._run = run
|
|
540
|
+
state.current_run_id = run.run_id
|
|
541
|
+
self._stores.save_state(state)
|
|
542
|
+
|
|
543
|
+
self._stores.recorder.record(
|
|
544
|
+
EventType.RUN_STARTED,
|
|
545
|
+
{"mode": "fix", "run_id": run.run_id},
|
|
546
|
+
run_id=run.run_id,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
while True:
|
|
551
|
+
report = await self._verify_and_supervise()
|
|
552
|
+
incomplete = self._incomplete_tasks()
|
|
553
|
+
if report.status == "pass":
|
|
554
|
+
return self._finish_success(report)
|
|
555
|
+
if report.status == "needs_review":
|
|
556
|
+
return self._finish_failure(
|
|
557
|
+
"supervisor verdict: needs_review (cannot verify)", report
|
|
558
|
+
)
|
|
559
|
+
state = self._state()
|
|
560
|
+
if state.iteration >= self._max_iterations:
|
|
561
|
+
return self._finish_failure(
|
|
562
|
+
f"maximum repair iterations reached ({self._max_iterations})",
|
|
563
|
+
report,
|
|
564
|
+
)
|
|
565
|
+
await self._repair(report, incomplete)
|
|
566
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
567
|
+
self._handle_interrupt()
|
|
568
|
+
raise
|
|
569
|
+
finally:
|
|
570
|
+
await self._cleanup()
|
|
571
|
+
lock.release()
|
|
572
|
+
|
|
573
|
+
build = run
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Event system (spec section 24)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from forgex.schemas.event import Event, EventType
|
|
12
|
+
from forgex.storage.repositories import EventRepository
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("forgex.events")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventRecorder:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
path: Path,
|
|
21
|
+
repository: EventRepository | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._path = Path(path)
|
|
24
|
+
self._repo = repository
|
|
25
|
+
self._subscribers: list[Callable[[Event], None]] = []
|
|
26
|
+
self.default_run_id: str | None = None
|
|
27
|
+
|
|
28
|
+
def subscribe(self, callback: Callable[[Event], None]) -> Callable[[], None]:
|
|
29
|
+
self._subscribers.append(callback)
|
|
30
|
+
|
|
31
|
+
def _unsubscribe() -> None:
|
|
32
|
+
if callback in self._subscribers:
|
|
33
|
+
self._subscribers.remove(callback)
|
|
34
|
+
|
|
35
|
+
return _unsubscribe
|
|
36
|
+
|
|
37
|
+
def record(
|
|
38
|
+
self,
|
|
39
|
+
type_: EventType,
|
|
40
|
+
data: dict[str, Any] | None = None,
|
|
41
|
+
*,
|
|
42
|
+
run_id: str | None = None,
|
|
43
|
+
) -> Event:
|
|
44
|
+
"""Append one event (durable first), index it, notify subscribers."""
|
|
45
|
+
event = Event(
|
|
46
|
+
type=type_,
|
|
47
|
+
data=data or {},
|
|
48
|
+
run_id=run_id or self.default_run_id,
|
|
49
|
+
)
|
|
50
|
+
# 1. Durable append (fsync — interrupt-safe, spec section 36).
|
|
51
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with self._path.open("a", encoding="utf-8") as fh:
|
|
53
|
+
fh.write(event.to_jsonl() + "\n")
|
|
54
|
+
fh.flush()
|
|
55
|
+
os.fsync(fh.fileno())
|
|
56
|
+
|
|
57
|
+
# 2. Query index (best-effort — rebuildable).
|
|
58
|
+
if self._repo is not None:
|
|
59
|
+
try:
|
|
60
|
+
self._repo.insert(event)
|
|
61
|
+
except Exception:
|
|
62
|
+
logger.warning("Event SQLite index write failed for %s", event.type.value, exc_info=True)
|
|
63
|
+
|
|
64
|
+
# 3. Subscribers (a broken subscriber must never kill a run).
|
|
65
|
+
for callback in list(self._subscribers):
|
|
66
|
+
try:
|
|
67
|
+
callback(event)
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.warning("Event subscriber raised; continuing", exc_info=True)
|
|
70
|
+
|
|
71
|
+
return event
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_events(
|
|
75
|
+
path: Path,
|
|
76
|
+
*,
|
|
77
|
+
limit: int | None = None,
|
|
78
|
+
event_type: EventType | None = None,
|
|
79
|
+
) -> list[Event]:
|
|
80
|
+
"""Read events from a JSONL file.
|
|
81
|
+
|
|
82
|
+
Malformed lines are skipped with a warning. `limit` returns the LAST n events.
|
|
83
|
+
"""
|
|
84
|
+
if not path.is_file():
|
|
85
|
+
return []
|
|
86
|
+
events: list[Event] = []
|
|
87
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
event = Event.from_jsonl(line)
|
|
93
|
+
except (KeyError, ValueError):
|
|
94
|
+
logger.warning("Skipping malformed event line in %s", path)
|
|
95
|
+
continue
|
|
96
|
+
if event_type is not None and event.type is not event_type:
|
|
97
|
+
continue
|
|
98
|
+
events.append(event)
|
|
99
|
+
if limit is not None:
|
|
100
|
+
return events[-limit:] if limit > 0 else []
|
|
101
|
+
return events
|