execforge 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.
- execforge-0.1.0.dist-info/METADATA +367 -0
- execforge-0.1.0.dist-info/RECORD +44 -0
- execforge-0.1.0.dist-info/WHEEL +5 -0
- execforge-0.1.0.dist-info/entry_points.txt +5 -0
- execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- execforge-0.1.0.dist-info/top_level.txt +1 -0
- orchestrator/__init__.py +4 -0
- orchestrator/__main__.py +5 -0
- orchestrator/backends/__init__.py +1 -0
- orchestrator/backends/base.py +29 -0
- orchestrator/backends/factory.py +53 -0
- orchestrator/backends/llm_cli_backend.py +87 -0
- orchestrator/backends/mock_backend.py +34 -0
- orchestrator/backends/shell_backend.py +49 -0
- orchestrator/cli/__init__.py +1 -0
- orchestrator/cli/main.py +971 -0
- orchestrator/config.py +272 -0
- orchestrator/domain/__init__.py +1 -0
- orchestrator/domain/types.py +77 -0
- orchestrator/exceptions.py +18 -0
- orchestrator/git/__init__.py +1 -0
- orchestrator/git/service.py +202 -0
- orchestrator/logging_setup.py +53 -0
- orchestrator/prompts/__init__.py +1 -0
- orchestrator/prompts/parser.py +91 -0
- orchestrator/reporting/__init__.py +1 -0
- orchestrator/reporting/console.py +197 -0
- orchestrator/reporting/events.py +44 -0
- orchestrator/reporting/selection_result.py +15 -0
- orchestrator/services/__init__.py +1 -0
- orchestrator/services/agent_runner.py +831 -0
- orchestrator/services/agent_service.py +122 -0
- orchestrator/services/project_service.py +47 -0
- orchestrator/services/prompt_source_service.py +65 -0
- orchestrator/services/run_service.py +42 -0
- orchestrator/services/step_executor.py +100 -0
- orchestrator/services/task_service.py +155 -0
- orchestrator/storage/__init__.py +1 -0
- orchestrator/storage/db.py +29 -0
- orchestrator/storage/models.py +95 -0
- orchestrator/utils/__init__.py +1 -0
- orchestrator/utils/process.py +44 -0
- orchestrator/validation/__init__.py +1 -0
- orchestrator/validation/pipeline.py +52 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from orchestrator.backends.factory import (
|
|
14
|
+
build_backend_registry,
|
|
15
|
+
default_backend_priority,
|
|
16
|
+
)
|
|
17
|
+
from orchestrator.config import AppConfig, AppPaths
|
|
18
|
+
from orchestrator.domain.types import BackendContext, TaskGitPolicy
|
|
19
|
+
from orchestrator.exceptions import BackendError, OrchestratorError, RepoError
|
|
20
|
+
from orchestrator.git.service import GitService
|
|
21
|
+
from orchestrator.logging_setup import ContextAdapter
|
|
22
|
+
from orchestrator.reporting.console import ConsoleReporter, NullReporter
|
|
23
|
+
from orchestrator.reporting.events import LogEvent
|
|
24
|
+
from orchestrator.reporting.selection_result import SelectionOutcome
|
|
25
|
+
from orchestrator.services.prompt_source_service import PromptSourceService
|
|
26
|
+
from orchestrator.services.run_service import RunService
|
|
27
|
+
from orchestrator.services.step_executor import StepExecutor
|
|
28
|
+
from orchestrator.services.task_service import TaskService
|
|
29
|
+
from orchestrator.storage.models import AgentORM, ProjectRepoORM, PromptSourceORM
|
|
30
|
+
from orchestrator.utils.process import run_command
|
|
31
|
+
from orchestrator.validation.pipeline import (
|
|
32
|
+
run_validation_pipeline,
|
|
33
|
+
validation_results_to_dict,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentRunner:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
session: Session,
|
|
41
|
+
paths: AppPaths,
|
|
42
|
+
config: AppConfig,
|
|
43
|
+
git: GitService,
|
|
44
|
+
reporter: ConsoleReporter | None = None,
|
|
45
|
+
log_path: str | None = None,
|
|
46
|
+
):
|
|
47
|
+
self.session = session
|
|
48
|
+
self.paths = paths
|
|
49
|
+
self.config = config
|
|
50
|
+
self.git = git
|
|
51
|
+
self.prompt_service = PromptSourceService(session, paths, git)
|
|
52
|
+
self.task_service = TaskService(session)
|
|
53
|
+
self.run_service = RunService(session)
|
|
54
|
+
self.reporter = reporter or NullReporter()
|
|
55
|
+
self.log_path = log_path
|
|
56
|
+
|
|
57
|
+
def run_once(
|
|
58
|
+
self, agent: AgentORM, exclude_task_ids: set[int] | None = None
|
|
59
|
+
) -> dict:
|
|
60
|
+
project = self.session.get(ProjectRepoORM, agent.project_repo_id)
|
|
61
|
+
if not project:
|
|
62
|
+
raise OrchestratorError(f"Project repo not found for agent {agent.name}")
|
|
63
|
+
|
|
64
|
+
source = self.session.get(PromptSourceORM, agent.prompt_source_id)
|
|
65
|
+
if not source:
|
|
66
|
+
raise OrchestratorError(f"Prompt source not found for agent {agent.name}")
|
|
67
|
+
|
|
68
|
+
run = self.run_service.create(agent.id, None)
|
|
69
|
+
logger = ContextAdapter(
|
|
70
|
+
logging.getLogger("orchestrator.runner"),
|
|
71
|
+
{
|
|
72
|
+
"run_id": run.id,
|
|
73
|
+
"agent": agent.name,
|
|
74
|
+
"task": "",
|
|
75
|
+
"base_branch": "",
|
|
76
|
+
"branch": "",
|
|
77
|
+
"step": "",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._emit(
|
|
82
|
+
logger,
|
|
83
|
+
LogEvent(
|
|
84
|
+
name="run_started",
|
|
85
|
+
context={
|
|
86
|
+
"run_id": run.id,
|
|
87
|
+
"time": datetime.now(),
|
|
88
|
+
"agent": agent.name,
|
|
89
|
+
"project": project.name,
|
|
90
|
+
"prompt_source": source.name,
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
task = None
|
|
96
|
+
parsed_task = None
|
|
97
|
+
task_ref = ""
|
|
98
|
+
base_branch = project.default_branch
|
|
99
|
+
active_branch = ""
|
|
100
|
+
try:
|
|
101
|
+
self._emit(
|
|
102
|
+
logger,
|
|
103
|
+
LogEvent(
|
|
104
|
+
name="prompt_sync_started",
|
|
105
|
+
phase_index=1,
|
|
106
|
+
phase_total=6,
|
|
107
|
+
title="Syncing prompt source",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
self.prompt_service.sync(source)
|
|
111
|
+
discovered = self.task_service.discover_and_upsert(source)
|
|
112
|
+
self._emit(
|
|
113
|
+
logger,
|
|
114
|
+
LogEvent(
|
|
115
|
+
name="prompt_synced", context={"discovered_tasks": discovered}
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self._emit(
|
|
120
|
+
logger,
|
|
121
|
+
LogEvent(
|
|
122
|
+
name="repo_validate_started",
|
|
123
|
+
phase_index=2,
|
|
124
|
+
phase_total=6,
|
|
125
|
+
title="Validating project repo",
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
self._refresh_project_repo(project, logger)
|
|
129
|
+
|
|
130
|
+
self._emit(
|
|
131
|
+
logger,
|
|
132
|
+
LogEvent(
|
|
133
|
+
name="task_select_started",
|
|
134
|
+
phase_index=3,
|
|
135
|
+
phase_total=6,
|
|
136
|
+
title="Selecting task",
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
eligible = self.task_service.eligible_for_agent(
|
|
140
|
+
agent,
|
|
141
|
+
project_name=project.name,
|
|
142
|
+
exclude_task_ids=exclude_task_ids,
|
|
143
|
+
)
|
|
144
|
+
task = eligible[0] if eligible else None
|
|
145
|
+
run.task_id = task.id if task else None
|
|
146
|
+
task_ref = (task.external_id or f"task-{task.id}") if task else ""
|
|
147
|
+
source_tasks = [
|
|
148
|
+
t
|
|
149
|
+
for t in self.task_service.list(status=None)
|
|
150
|
+
if t.prompt_source_id == source.id
|
|
151
|
+
]
|
|
152
|
+
eligible_unfiltered = self.task_service.eligible_for_agent(
|
|
153
|
+
agent,
|
|
154
|
+
project_name=project.name,
|
|
155
|
+
exclude_task_ids=None,
|
|
156
|
+
)
|
|
157
|
+
selection = self._build_selection_outcome(
|
|
158
|
+
selected_task_ref=task_ref or None,
|
|
159
|
+
discovered_count=discovered,
|
|
160
|
+
source_tasks=source_tasks,
|
|
161
|
+
eligible_filtered=eligible,
|
|
162
|
+
eligible_unfiltered=eligible_unfiltered,
|
|
163
|
+
excluded_count=len(exclude_task_ids or set()),
|
|
164
|
+
project_name=project.name,
|
|
165
|
+
)
|
|
166
|
+
self._emit(
|
|
167
|
+
logger,
|
|
168
|
+
LogEvent(
|
|
169
|
+
name="task_selection_completed",
|
|
170
|
+
context={
|
|
171
|
+
"code": selection.code,
|
|
172
|
+
"selected_task_id": selection.selected_task_id,
|
|
173
|
+
"reason": selection.reason,
|
|
174
|
+
"next_hint": selection.next_hint,
|
|
175
|
+
"eligible_count": selection.eligible_count,
|
|
176
|
+
"excluded_count": selection.excluded_count,
|
|
177
|
+
"discovered_count": selection.discovered_count,
|
|
178
|
+
},
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
if not task:
|
|
182
|
+
self.run_service.complete(
|
|
183
|
+
run, status="noop", summary="No eligible tasks"
|
|
184
|
+
)
|
|
185
|
+
self._emit(
|
|
186
|
+
logger,
|
|
187
|
+
LogEvent(
|
|
188
|
+
name="run_noop",
|
|
189
|
+
context={
|
|
190
|
+
"code": selection.code,
|
|
191
|
+
"reason": selection.reason,
|
|
192
|
+
"next_hint": selection.next_hint,
|
|
193
|
+
"project": project.name,
|
|
194
|
+
"warnings": self.reporter.warnings_in_run,
|
|
195
|
+
},
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
return {
|
|
199
|
+
"status": "noop",
|
|
200
|
+
"discovered": discovered,
|
|
201
|
+
"eligible_count": len(eligible),
|
|
202
|
+
"reason": selection.reason,
|
|
203
|
+
"run_id": run.id,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
parsed_task = self.task_service.parse_raw_task(task)
|
|
207
|
+
|
|
208
|
+
self._emit(
|
|
209
|
+
logger,
|
|
210
|
+
LogEvent(
|
|
211
|
+
name="branch_prepare_started",
|
|
212
|
+
phase_index=4,
|
|
213
|
+
phase_total=6,
|
|
214
|
+
title="Preparing branch",
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
base_branch, active_branch = self._prepare_repo(
|
|
218
|
+
agent, project, task, parsed_task.git, logger
|
|
219
|
+
)
|
|
220
|
+
self._emit(
|
|
221
|
+
logger,
|
|
222
|
+
LogEvent(
|
|
223
|
+
name="branch_prepared",
|
|
224
|
+
context={"base_branch": base_branch, "branch": active_branch},
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
started = time.monotonic()
|
|
229
|
+
self.task_service.mark_status(task, "in_progress")
|
|
230
|
+
|
|
231
|
+
if not parsed_task.steps:
|
|
232
|
+
raise BackendError(f"Task '{task.source_path}' has no executable steps")
|
|
233
|
+
|
|
234
|
+
safety = json.loads(agent.safety_settings_json or "{}")
|
|
235
|
+
context = BackendContext(
|
|
236
|
+
run_id=run.id,
|
|
237
|
+
timeout_seconds=int(
|
|
238
|
+
safety.get("timeout_seconds", self.config.default_timeout_seconds)
|
|
239
|
+
),
|
|
240
|
+
max_steps=agent.max_steps,
|
|
241
|
+
safety_settings=safety,
|
|
242
|
+
)
|
|
243
|
+
commit_after_each_step = bool(safety.get("commit_after_each_step", True))
|
|
244
|
+
task_push_override = parsed_task.git.push_on_success
|
|
245
|
+
should_push = (
|
|
246
|
+
task_push_override
|
|
247
|
+
if task_push_override is not None
|
|
248
|
+
else (
|
|
249
|
+
agent.push_policy == "on-success"
|
|
250
|
+
and safety.get("allow_push", self.config.default_allow_push)
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
push_reason = (
|
|
254
|
+
"task override"
|
|
255
|
+
if task_push_override is not None
|
|
256
|
+
else "agent push_policy + allow_push"
|
|
257
|
+
)
|
|
258
|
+
if self.reporter.mode in {"verbose", "debug"}:
|
|
259
|
+
self._emit(
|
|
260
|
+
logger,
|
|
261
|
+
LogEvent(
|
|
262
|
+
name="warning",
|
|
263
|
+
message=f"push setting resolved: enabled={should_push} ({push_reason})",
|
|
264
|
+
context={"branch": active_branch, "task_id": task_ref},
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
backend_registry = build_backend_registry(agent)
|
|
268
|
+
if self.reporter.mode in {"verbose", "debug"}:
|
|
269
|
+
self._emit(
|
|
270
|
+
logger,
|
|
271
|
+
LogEvent(
|
|
272
|
+
name="warning",
|
|
273
|
+
message=f"enabled backends: {list(backend_registry.keys())}",
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self._emit(
|
|
278
|
+
logger,
|
|
279
|
+
LogEvent(
|
|
280
|
+
name="steps_started",
|
|
281
|
+
phase_index=5,
|
|
282
|
+
phase_total=6,
|
|
283
|
+
title="Executing steps",
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
step_executor = StepExecutor(
|
|
287
|
+
backend_registry, default_backend_priority(agent)
|
|
288
|
+
)
|
|
289
|
+
step_results = []
|
|
290
|
+
|
|
291
|
+
tool_invocations: list[dict] = []
|
|
292
|
+
for idx, step in enumerate(parsed_task.steps, start=1):
|
|
293
|
+
step_result = step_executor.execute_step(
|
|
294
|
+
step=step,
|
|
295
|
+
task=task,
|
|
296
|
+
project_path=Path(project.local_path),
|
|
297
|
+
prompt_root=Path(source.local_clone_path),
|
|
298
|
+
context=context,
|
|
299
|
+
)
|
|
300
|
+
step_results.append(step_result)
|
|
301
|
+
self._emit(
|
|
302
|
+
logger,
|
|
303
|
+
LogEvent(
|
|
304
|
+
name="step_completed",
|
|
305
|
+
context={
|
|
306
|
+
"step_index": idx,
|
|
307
|
+
"step_total": len(parsed_task.steps),
|
|
308
|
+
"step": step_result.step_id,
|
|
309
|
+
"backend": step_result.backend,
|
|
310
|
+
"symbol": "✓" if step_result.success else "✗",
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
tool_invocations.extend(step_result.tool_invocations)
|
|
315
|
+
|
|
316
|
+
if not safety.get("dry_run", False) and commit_after_each_step:
|
|
317
|
+
step_message_template = json.loads(
|
|
318
|
+
agent.commit_policy_json or "{}"
|
|
319
|
+
).get(
|
|
320
|
+
"step_message_template",
|
|
321
|
+
"chore(agent): {task_ref} step {step_id}",
|
|
322
|
+
)
|
|
323
|
+
step_message = step_message_template.format(
|
|
324
|
+
task_ref=task_ref,
|
|
325
|
+
title=task.title.lower(),
|
|
326
|
+
step_id=step_result.step_id,
|
|
327
|
+
)
|
|
328
|
+
self.git.commit_all(Path(project.local_path), step_message)
|
|
329
|
+
if should_push:
|
|
330
|
+
self.git.push(Path(project.local_path), active_branch)
|
|
331
|
+
|
|
332
|
+
validations = json.loads(agent.validation_policy_json or "[]")
|
|
333
|
+
validation_results = run_validation_pipeline(
|
|
334
|
+
Path(project.local_path),
|
|
335
|
+
validations,
|
|
336
|
+
timeout=int(
|
|
337
|
+
safety.get("timeout_seconds", self.config.default_timeout_seconds)
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
any_failed = any(not v.success for v in validation_results)
|
|
341
|
+
if any_failed and safety.get("stop_on_validation_failure", True):
|
|
342
|
+
self.task_service.mark_status(task, "failed")
|
|
343
|
+
self.run_service.complete(
|
|
344
|
+
run,
|
|
345
|
+
status="failed",
|
|
346
|
+
summary="Validation failed",
|
|
347
|
+
tool_invocations=tool_invocations,
|
|
348
|
+
validation_results=validation_results_to_dict(validation_results),
|
|
349
|
+
)
|
|
350
|
+
self._emit(
|
|
351
|
+
logger,
|
|
352
|
+
LogEvent(
|
|
353
|
+
name="run_completed",
|
|
354
|
+
context={
|
|
355
|
+
"status": "failed",
|
|
356
|
+
"task_id": task_ref,
|
|
357
|
+
"branch": active_branch,
|
|
358
|
+
},
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
return {
|
|
362
|
+
"status": "failed",
|
|
363
|
+
"reason": "validation_failed",
|
|
364
|
+
"run_id": run.id,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
commit_sha = None
|
|
368
|
+
if not safety.get("dry_run", False):
|
|
369
|
+
if commit_after_each_step:
|
|
370
|
+
commit_sha = self.git.commit_all(
|
|
371
|
+
Path(project.local_path), f"chore(agent): {task_ref} finalize"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
template = json.loads(agent.commit_policy_json or "{}").get(
|
|
375
|
+
"message_template", "feat(agent): complete {task_ref} {title}"
|
|
376
|
+
)
|
|
377
|
+
message = template.format(
|
|
378
|
+
task_ref=task_ref, title=task.title.lower()
|
|
379
|
+
)
|
|
380
|
+
commit_sha = self.git.commit_all(Path(project.local_path), message)
|
|
381
|
+
if should_push:
|
|
382
|
+
self.git.push(Path(project.local_path), active_branch)
|
|
383
|
+
|
|
384
|
+
self.task_service.mark_status(task, "done")
|
|
385
|
+
elapsed = time.monotonic() - started
|
|
386
|
+
self.run_service.complete(
|
|
387
|
+
run,
|
|
388
|
+
status="success",
|
|
389
|
+
summary=f"Completed in {elapsed:.1f}s",
|
|
390
|
+
tool_invocations=tool_invocations,
|
|
391
|
+
validation_results=validation_results_to_dict(validation_results),
|
|
392
|
+
commit_sha=commit_sha,
|
|
393
|
+
branch_name=active_branch,
|
|
394
|
+
)
|
|
395
|
+
self._emit(
|
|
396
|
+
logger,
|
|
397
|
+
LogEvent(
|
|
398
|
+
name="run_completed",
|
|
399
|
+
phase_index=6,
|
|
400
|
+
phase_total=6,
|
|
401
|
+
title="Finalizing run",
|
|
402
|
+
context={
|
|
403
|
+
"status": "success",
|
|
404
|
+
"task_id": task_ref,
|
|
405
|
+
"branch": active_branch,
|
|
406
|
+
"steps_total": len(step_results),
|
|
407
|
+
"steps_passed": len([s for s in step_results if s.success]),
|
|
408
|
+
"warnings": self.reporter.warnings_in_run,
|
|
409
|
+
"log_path": self.log_path,
|
|
410
|
+
"push_enabled": should_push,
|
|
411
|
+
},
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
return {
|
|
415
|
+
"status": "success",
|
|
416
|
+
"run_id": run.id,
|
|
417
|
+
"task": task.title,
|
|
418
|
+
"commit": commit_sha,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
except (OrchestratorError, Exception) as exc:
|
|
422
|
+
step_id = self._extract_step_id(str(exc))
|
|
423
|
+
if task is not None:
|
|
424
|
+
self.task_service.mark_status(task, "failed")
|
|
425
|
+
self.run_service.complete(run, status="failed", summary=str(exc))
|
|
426
|
+
self._emit(
|
|
427
|
+
logger,
|
|
428
|
+
LogEvent(
|
|
429
|
+
name="step_failed",
|
|
430
|
+
level="error",
|
|
431
|
+
context={
|
|
432
|
+
"step_index": "?",
|
|
433
|
+
"step_total": "?",
|
|
434
|
+
"step": step_id or "unknown",
|
|
435
|
+
"backend": "runtime",
|
|
436
|
+
"base_branch": base_branch,
|
|
437
|
+
"branch": active_branch,
|
|
438
|
+
"task_id": task_ref,
|
|
439
|
+
"error": str(exc),
|
|
440
|
+
},
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
self._emit(
|
|
444
|
+
logger,
|
|
445
|
+
LogEvent(
|
|
446
|
+
name="run_failed",
|
|
447
|
+
context={
|
|
448
|
+
"task_id": task_ref,
|
|
449
|
+
"branch": active_branch,
|
|
450
|
+
"reason": str(exc),
|
|
451
|
+
},
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
self._emit(
|
|
455
|
+
logger,
|
|
456
|
+
LogEvent(
|
|
457
|
+
name="run_completed",
|
|
458
|
+
context={
|
|
459
|
+
"status": "failed",
|
|
460
|
+
"reason": str(exc),
|
|
461
|
+
"task_id": task_ref,
|
|
462
|
+
"branch": active_branch,
|
|
463
|
+
"warnings": self.reporter.warnings_in_run,
|
|
464
|
+
"log_path": self.log_path,
|
|
465
|
+
},
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
logging.getLogger("orchestrator.runner").debug(traceback.format_exc())
|
|
469
|
+
return {
|
|
470
|
+
"status": "failed",
|
|
471
|
+
"run_id": run.id,
|
|
472
|
+
"error": str(exc),
|
|
473
|
+
"task_id": task_ref,
|
|
474
|
+
"base_branch": base_branch,
|
|
475
|
+
"active_branch": active_branch,
|
|
476
|
+
"step_id": step_id,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
def run_loop(
|
|
480
|
+
self,
|
|
481
|
+
agent: AgentORM,
|
|
482
|
+
interval_seconds: int = 30,
|
|
483
|
+
max_iterations: int | None = None,
|
|
484
|
+
only_new_prompts: bool = True,
|
|
485
|
+
reset_only_new_baseline: bool = False,
|
|
486
|
+
) -> None:
|
|
487
|
+
count = 0
|
|
488
|
+
project = self.session.get(ProjectRepoORM, agent.project_repo_id)
|
|
489
|
+
source = self.session.get(PromptSourceORM, agent.prompt_source_id)
|
|
490
|
+
safety = json.loads(agent.safety_settings_json or "{}")
|
|
491
|
+
exclude_task_ids: set[int] = set()
|
|
492
|
+
initial_excluded = 0
|
|
493
|
+
reset_applied = False
|
|
494
|
+
if only_new_prompts:
|
|
495
|
+
if reset_only_new_baseline:
|
|
496
|
+
exclude_task_ids = set()
|
|
497
|
+
else:
|
|
498
|
+
existing = self.task_service.list(status=None)
|
|
499
|
+
exclude_task_ids = {
|
|
500
|
+
t.id
|
|
501
|
+
for t in existing
|
|
502
|
+
if t.prompt_source_id == agent.prompt_source_id
|
|
503
|
+
}
|
|
504
|
+
initial_excluded = len(exclude_task_ids)
|
|
505
|
+
self._emit(
|
|
506
|
+
ContextAdapter(
|
|
507
|
+
logging.getLogger("orchestrator.runner"),
|
|
508
|
+
{"run_id": "", "agent": agent.name},
|
|
509
|
+
),
|
|
510
|
+
LogEvent(
|
|
511
|
+
name="loop_started",
|
|
512
|
+
context={
|
|
513
|
+
"time": datetime.now(),
|
|
514
|
+
"agent": agent.name,
|
|
515
|
+
"project": project.name if project else "(missing)",
|
|
516
|
+
"prompt_source": source.name if source else "(missing)",
|
|
517
|
+
"interval_seconds": interval_seconds,
|
|
518
|
+
"only_new_prompts": only_new_prompts,
|
|
519
|
+
"reset_only_new_baseline": reset_only_new_baseline,
|
|
520
|
+
"initial_excluded": initial_excluded,
|
|
521
|
+
"allow_dirty_worktree": not safety.get(
|
|
522
|
+
"require_clean_working_tree",
|
|
523
|
+
self.config.default_require_clean_tree,
|
|
524
|
+
),
|
|
525
|
+
"branch_strategy": "agent/<agent-name>/<task-id>",
|
|
526
|
+
},
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
while True:
|
|
530
|
+
self.run_once(agent, exclude_task_ids=exclude_task_ids)
|
|
531
|
+
count += 1
|
|
532
|
+
|
|
533
|
+
# Reset baseline once, then continue in only-new mode from that point forward.
|
|
534
|
+
if only_new_prompts and reset_only_new_baseline and not reset_applied:
|
|
535
|
+
current = self.task_service.list(status=None)
|
|
536
|
+
exclude_task_ids = {
|
|
537
|
+
t.id
|
|
538
|
+
for t in current
|
|
539
|
+
if t.prompt_source_id == agent.prompt_source_id
|
|
540
|
+
}
|
|
541
|
+
reset_applied = True
|
|
542
|
+
self._emit(
|
|
543
|
+
ContextAdapter(
|
|
544
|
+
logging.getLogger("orchestrator.runner"),
|
|
545
|
+
{"run_id": "", "agent": agent.name},
|
|
546
|
+
),
|
|
547
|
+
LogEvent(
|
|
548
|
+
name="warning",
|
|
549
|
+
message=(
|
|
550
|
+
"reset-only-new-baseline applied for first run; "
|
|
551
|
+
f"continuing with only-new mode and excluded_tasks={len(exclude_task_ids)}"
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if max_iterations and count >= max_iterations:
|
|
557
|
+
return
|
|
558
|
+
self._emit(
|
|
559
|
+
ContextAdapter(
|
|
560
|
+
logging.getLogger("orchestrator.runner"),
|
|
561
|
+
{"run_id": "", "agent": agent.name},
|
|
562
|
+
),
|
|
563
|
+
LogEvent(
|
|
564
|
+
name="loop_waiting",
|
|
565
|
+
context={
|
|
566
|
+
"interval_seconds": interval_seconds,
|
|
567
|
+
"next_run_at": datetime.now()
|
|
568
|
+
+ timedelta(seconds=interval_seconds),
|
|
569
|
+
},
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
time.sleep(interval_seconds)
|
|
573
|
+
|
|
574
|
+
def _prepare_repo(
|
|
575
|
+
self,
|
|
576
|
+
agent: AgentORM,
|
|
577
|
+
project: ProjectRepoORM,
|
|
578
|
+
task,
|
|
579
|
+
task_git: TaskGitPolicy,
|
|
580
|
+
logger: ContextAdapter,
|
|
581
|
+
) -> tuple[str, str]:
|
|
582
|
+
repo_path = Path(project.local_path)
|
|
583
|
+
self.git.ensure_git_repo(repo_path)
|
|
584
|
+
|
|
585
|
+
safety = json.loads(agent.safety_settings_json or "{}")
|
|
586
|
+
require_clean = safety.get(
|
|
587
|
+
"require_clean_working_tree", self.config.default_require_clean_tree
|
|
588
|
+
)
|
|
589
|
+
is_clean = self.git.is_clean(repo_path)
|
|
590
|
+
has_commits = self.git.has_commits(repo_path)
|
|
591
|
+
if require_clean and not is_clean:
|
|
592
|
+
if not has_commits:
|
|
593
|
+
self._emit(
|
|
594
|
+
logger,
|
|
595
|
+
LogEvent(
|
|
596
|
+
name="warning",
|
|
597
|
+
message="working tree dirty but no commits yet; allowing bootstrap",
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
else:
|
|
601
|
+
status = run_command(
|
|
602
|
+
["git", "status", "--short"],
|
|
603
|
+
cwd=repo_path,
|
|
604
|
+
timeout=self.config.default_timeout_seconds,
|
|
605
|
+
)
|
|
606
|
+
details = status.stdout.strip().splitlines()
|
|
607
|
+
preview = (
|
|
608
|
+
"; ".join(details[:8]) if details else "(unable to list changes)"
|
|
609
|
+
)
|
|
610
|
+
raise RepoError(f"Project repo working tree is not clean: {preview}")
|
|
611
|
+
if not require_clean and not is_clean:
|
|
612
|
+
self._emit(
|
|
613
|
+
logger,
|
|
614
|
+
LogEvent(
|
|
615
|
+
name="warning",
|
|
616
|
+
message="working tree dirty but allowed by safety settings",
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
base_branch = task_git.base_branch or project.default_branch
|
|
621
|
+
task_ref = task.external_id or f"task-{task.id}"
|
|
622
|
+
work_branch = task_git.work_branch or self.git.make_agent_branch_name(
|
|
623
|
+
agent.name, task_ref
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# If dirty worktrees are allowed and we are already on the intended task branch,
|
|
627
|
+
# keep working there instead of forcing a base checkout that would fail.
|
|
628
|
+
if not require_clean and not is_clean:
|
|
629
|
+
try:
|
|
630
|
+
current = self.git.current_branch(repo_path)
|
|
631
|
+
except RepoError:
|
|
632
|
+
current = ""
|
|
633
|
+
if current == work_branch:
|
|
634
|
+
self._emit(
|
|
635
|
+
logger,
|
|
636
|
+
LogEvent(
|
|
637
|
+
name="warning",
|
|
638
|
+
message=(
|
|
639
|
+
"continuing on existing dirty task branch; "
|
|
640
|
+
"skipping base branch checkout/pull for this run"
|
|
641
|
+
),
|
|
642
|
+
context={
|
|
643
|
+
"branch": work_branch,
|
|
644
|
+
"base_branch": base_branch,
|
|
645
|
+
"task_id": task_ref,
|
|
646
|
+
},
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
return base_branch, work_branch
|
|
650
|
+
|
|
651
|
+
# If switching branches with dirty state, checkpoint current branch first.
|
|
652
|
+
if current and current != "HEAD":
|
|
653
|
+
checkpoint_message = f"chore(agent): checkpoint before switching to {work_branch} for {task_ref}"
|
|
654
|
+
checkpoint_sha = self.git.commit_all(repo_path, checkpoint_message)
|
|
655
|
+
if checkpoint_sha:
|
|
656
|
+
self._emit(
|
|
657
|
+
logger,
|
|
658
|
+
LogEvent(
|
|
659
|
+
name="warning",
|
|
660
|
+
message=(
|
|
661
|
+
"dirty working tree checkpointed on current branch before branch switch; "
|
|
662
|
+
f"branch={current} commit={checkpoint_sha[:8]}"
|
|
663
|
+
),
|
|
664
|
+
context={"branch": current, "task_id": task_ref},
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if self.git.local_branch_exists(repo_path, base_branch):
|
|
669
|
+
self.git.checkout_branch(repo_path, base_branch)
|
|
670
|
+
else:
|
|
671
|
+
self.git.checkout_or_create_tracking_branch(
|
|
672
|
+
repo_path, base_branch, create_and_push_if_missing=False
|
|
673
|
+
)
|
|
674
|
+
if safety.get("pull_project_before_run", True):
|
|
675
|
+
try:
|
|
676
|
+
self.git.pull(
|
|
677
|
+
repo_path,
|
|
678
|
+
strategy="ff-only",
|
|
679
|
+
branch=base_branch,
|
|
680
|
+
bootstrap_missing_branch=False,
|
|
681
|
+
)
|
|
682
|
+
except RepoError as exc:
|
|
683
|
+
if "No git remote configured" not in str(exc):
|
|
684
|
+
raise
|
|
685
|
+
self._emit(
|
|
686
|
+
logger,
|
|
687
|
+
LogEvent(
|
|
688
|
+
name="warning",
|
|
689
|
+
message=(
|
|
690
|
+
"project repo has no remote configured; skipping pull before run"
|
|
691
|
+
),
|
|
692
|
+
),
|
|
693
|
+
)
|
|
694
|
+
allow_branch_create = safety.get("allow_branch_create", True)
|
|
695
|
+
self.git.checkout_or_create_branch(
|
|
696
|
+
repo_path,
|
|
697
|
+
work_branch,
|
|
698
|
+
start_point=base_branch,
|
|
699
|
+
allow_create=allow_branch_create,
|
|
700
|
+
)
|
|
701
|
+
return base_branch, work_branch
|
|
702
|
+
|
|
703
|
+
def _refresh_project_repo(
|
|
704
|
+
self, project: ProjectRepoORM, logger: ContextAdapter
|
|
705
|
+
) -> None:
|
|
706
|
+
repo_path = Path(project.local_path)
|
|
707
|
+
self.git.ensure_git_repo(repo_path)
|
|
708
|
+
try:
|
|
709
|
+
current = self.git.current_branch(repo_path)
|
|
710
|
+
except RepoError:
|
|
711
|
+
current = "(unborn)"
|
|
712
|
+
self._emit(
|
|
713
|
+
logger, LogEvent(name="repo_validated", context={"current_branch": current})
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
def _extract_step_id(self, error_message: str) -> str:
|
|
717
|
+
match = re.search(r"Step '([^']+)'", error_message)
|
|
718
|
+
if match:
|
|
719
|
+
return match.group(1)
|
|
720
|
+
return ""
|
|
721
|
+
|
|
722
|
+
def _build_selection_outcome(
|
|
723
|
+
self,
|
|
724
|
+
selected_task_ref: str | None,
|
|
725
|
+
discovered_count: int,
|
|
726
|
+
source_tasks: list,
|
|
727
|
+
eligible_filtered: list,
|
|
728
|
+
eligible_unfiltered: list,
|
|
729
|
+
excluded_count: int,
|
|
730
|
+
project_name: str,
|
|
731
|
+
) -> SelectionOutcome:
|
|
732
|
+
status_counts: dict[str, int] = {}
|
|
733
|
+
for task in source_tasks:
|
|
734
|
+
status = getattr(task, "status", "unknown")
|
|
735
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
736
|
+
|
|
737
|
+
if selected_task_ref:
|
|
738
|
+
return SelectionOutcome(
|
|
739
|
+
code="selected",
|
|
740
|
+
reason="task selected for execution",
|
|
741
|
+
next_hint=None,
|
|
742
|
+
selected_task_id=selected_task_ref,
|
|
743
|
+
eligible_count=len(eligible_filtered),
|
|
744
|
+
excluded_count=excluded_count,
|
|
745
|
+
discovered_count=discovered_count,
|
|
746
|
+
total_tasks_for_source=len(source_tasks),
|
|
747
|
+
)
|
|
748
|
+
if discovered_count == 0 and len(source_tasks) == 0:
|
|
749
|
+
return SelectionOutcome(
|
|
750
|
+
code="no_tasks_discovered",
|
|
751
|
+
reason="prompt sync succeeded but no task files were found",
|
|
752
|
+
next_hint="add task files to your prompt source folder, then run: execforge prompt-source sync <source-name>",
|
|
753
|
+
eligible_count=0,
|
|
754
|
+
excluded_count=excluded_count,
|
|
755
|
+
discovered_count=discovered_count,
|
|
756
|
+
total_tasks_for_source=0,
|
|
757
|
+
)
|
|
758
|
+
if len(eligible_unfiltered) > 0 and len(eligible_filtered) == 0:
|
|
759
|
+
return SelectionOutcome(
|
|
760
|
+
code="baseline_filtered",
|
|
761
|
+
reason="all discovered tasks are already part of the current baseline",
|
|
762
|
+
next_hint="run with --all-eligible-prompts or --reset-only-new-baseline",
|
|
763
|
+
eligible_count=0,
|
|
764
|
+
excluded_count=excluded_count,
|
|
765
|
+
discovered_count=discovered_count,
|
|
766
|
+
total_tasks_for_source=len(source_tasks),
|
|
767
|
+
)
|
|
768
|
+
if source_tasks and status_counts.get("failed", 0) == len(source_tasks):
|
|
769
|
+
return SelectionOutcome(
|
|
770
|
+
code="all_failed",
|
|
771
|
+
reason="all discovered tasks are currently failed",
|
|
772
|
+
next_hint="retry one task with: execforge task retry <task-id>",
|
|
773
|
+
eligible_count=0,
|
|
774
|
+
excluded_count=excluded_count,
|
|
775
|
+
discovered_count=discovered_count,
|
|
776
|
+
total_tasks_for_source=len(source_tasks),
|
|
777
|
+
)
|
|
778
|
+
if source_tasks and status_counts.get("blocked", 0) == len(source_tasks):
|
|
779
|
+
return SelectionOutcome(
|
|
780
|
+
code="all_blocked",
|
|
781
|
+
reason="all discovered tasks are blocked",
|
|
782
|
+
next_hint="inspect dependencies with: execforge task inspect <task-id>",
|
|
783
|
+
eligible_count=0,
|
|
784
|
+
excluded_count=excluded_count,
|
|
785
|
+
discovered_count=discovered_count,
|
|
786
|
+
total_tasks_for_source=len(source_tasks),
|
|
787
|
+
)
|
|
788
|
+
if source_tasks and all(
|
|
789
|
+
getattr(t, "status", "") == "done" for t in source_tasks
|
|
790
|
+
):
|
|
791
|
+
return SelectionOutcome(
|
|
792
|
+
code="all_completed",
|
|
793
|
+
reason="all discovered tasks are already complete",
|
|
794
|
+
next_hint="add new todo tasks in the prompt source and sync again",
|
|
795
|
+
eligible_count=0,
|
|
796
|
+
excluded_count=excluded_count,
|
|
797
|
+
discovered_count=discovered_count,
|
|
798
|
+
total_tasks_for_source=len(source_tasks),
|
|
799
|
+
)
|
|
800
|
+
if (
|
|
801
|
+
source_tasks
|
|
802
|
+
and (status_counts.get("todo", 0) + status_counts.get("ready", 0)) > 0
|
|
803
|
+
and len(eligible_unfiltered) == 0
|
|
804
|
+
):
|
|
805
|
+
return SelectionOutcome(
|
|
806
|
+
code="tasks_not_actionable",
|
|
807
|
+
reason=(
|
|
808
|
+
"tasks are present but none are actionable for this agent "
|
|
809
|
+
f"(check target_repo and dependency rules for project '{project_name}')"
|
|
810
|
+
),
|
|
811
|
+
next_hint="inspect a task with: execforge task inspect <task-id>",
|
|
812
|
+
eligible_count=0,
|
|
813
|
+
excluded_count=excluded_count,
|
|
814
|
+
discovered_count=discovered_count,
|
|
815
|
+
total_tasks_for_source=len(source_tasks),
|
|
816
|
+
)
|
|
817
|
+
return SelectionOutcome(
|
|
818
|
+
code="no_eligible_tasks",
|
|
819
|
+
reason="no eligible task matched current execution rules",
|
|
820
|
+
next_hint="inspect tasks with: execforge task list and check dependencies/status",
|
|
821
|
+
eligible_count=0,
|
|
822
|
+
excluded_count=excluded_count,
|
|
823
|
+
discovered_count=discovered_count,
|
|
824
|
+
total_tasks_for_source=len(source_tasks),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def _emit(self, logger: ContextAdapter, event: LogEvent) -> None:
|
|
828
|
+
self.reporter.render(event)
|
|
829
|
+
logging.getLogger("orchestrator.runner").debug(
|
|
830
|
+
"event=%s", json.dumps(event.to_dict(), default=str)
|
|
831
|
+
)
|