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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. 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