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.
Files changed (44) hide show
  1. execforge-0.1.0.dist-info/METADATA +367 -0
  2. execforge-0.1.0.dist-info/RECORD +44 -0
  3. execforge-0.1.0.dist-info/WHEEL +5 -0
  4. execforge-0.1.0.dist-info/entry_points.txt +5 -0
  5. execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. execforge-0.1.0.dist-info/top_level.txt +1 -0
  7. orchestrator/__init__.py +4 -0
  8. orchestrator/__main__.py +5 -0
  9. orchestrator/backends/__init__.py +1 -0
  10. orchestrator/backends/base.py +29 -0
  11. orchestrator/backends/factory.py +53 -0
  12. orchestrator/backends/llm_cli_backend.py +87 -0
  13. orchestrator/backends/mock_backend.py +34 -0
  14. orchestrator/backends/shell_backend.py +49 -0
  15. orchestrator/cli/__init__.py +1 -0
  16. orchestrator/cli/main.py +971 -0
  17. orchestrator/config.py +272 -0
  18. orchestrator/domain/__init__.py +1 -0
  19. orchestrator/domain/types.py +77 -0
  20. orchestrator/exceptions.py +18 -0
  21. orchestrator/git/__init__.py +1 -0
  22. orchestrator/git/service.py +202 -0
  23. orchestrator/logging_setup.py +53 -0
  24. orchestrator/prompts/__init__.py +1 -0
  25. orchestrator/prompts/parser.py +91 -0
  26. orchestrator/reporting/__init__.py +1 -0
  27. orchestrator/reporting/console.py +197 -0
  28. orchestrator/reporting/events.py +44 -0
  29. orchestrator/reporting/selection_result.py +15 -0
  30. orchestrator/services/__init__.py +1 -0
  31. orchestrator/services/agent_runner.py +831 -0
  32. orchestrator/services/agent_service.py +122 -0
  33. orchestrator/services/project_service.py +47 -0
  34. orchestrator/services/prompt_source_service.py +65 -0
  35. orchestrator/services/run_service.py +42 -0
  36. orchestrator/services/step_executor.py +100 -0
  37. orchestrator/services/task_service.py +155 -0
  38. orchestrator/storage/__init__.py +1 -0
  39. orchestrator/storage/db.py +29 -0
  40. orchestrator/storage/models.py +95 -0
  41. orchestrator/utils/__init__.py +1 -0
  42. orchestrator/utils/process.py +44 -0
  43. orchestrator/validation/__init__.py +1 -0
  44. 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
+ )