ralphception 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.
- ralphception/__init__.py +8 -0
- ralphception/_version.py +34 -0
- ralphception/actor_runtime/__init__.py +20 -0
- ralphception/actor_runtime/intake_actor.py +116 -0
- ralphception/actor_runtime/merge_actor.py +88 -0
- ralphception/actor_runtime/orchestrator.py +110 -0
- ralphception/actor_runtime/planner_actor.py +47 -0
- ralphception/actor_runtime/scheduler_actor.py +65 -0
- ralphception/actor_runtime/worker_actor.py +59 -0
- ralphception/app.py +467 -0
- ralphception/audit/__init__.py +5 -0
- ralphception/audit/service.py +193 -0
- ralphception/bootstrap/__init__.py +25 -0
- ralphception/bootstrap/contracts.py +252 -0
- ralphception/bootstrap/prompting.py +110 -0
- ralphception/bootstrap/runner.py +406 -0
- ralphception/bootstrap/script.py +56 -0
- ralphception/bootstrap/storage.py +320 -0
- ralphception/bundled_skills/__init__.py +1 -0
- ralphception/bundled_skills/audit.review.md +3 -0
- ralphception/bundled_skills/bootstrap.loop.md +104 -0
- ralphception/bundled_skills/brainstorm.default.md +3 -0
- ralphception/bundled_skills/execute.task.md +3 -0
- ralphception/bundled_skills/merge.coordinator.md +3 -0
- ralphception/bundled_skills/plan.write.md +3 -0
- ralphception/bundled_skills/run.summary.md +3 -0
- ralphception/bundled_skills/spec.write.md +3 -0
- ralphception/cli.py +100 -0
- ralphception/codex/__init__.py +33 -0
- ralphception/codex/client.py +714 -0
- ralphception/codex/session_manager.py +411 -0
- ralphception/config.py +337 -0
- ralphception/git/__init__.py +52 -0
- ralphception/git/identity.py +46 -0
- ralphception/git/merge.py +1185 -0
- ralphception/git/worktrees.py +643 -0
- ralphception/headless.py +1715 -0
- ralphception/models/__init__.py +103 -0
- ralphception/models/artifacts.py +32 -0
- ralphception/models/commit_intents.py +38 -0
- ralphception/models/events.py +167 -0
- ralphception/models/mission.py +55 -0
- ralphception/models/runtime.py +272 -0
- ralphception/models/session.py +47 -0
- ralphception/models/todos.py +44 -0
- ralphception/models/verification.py +168 -0
- ralphception/models/worktree.py +34 -0
- ralphception/paths.py +83 -0
- ralphception/runtime/__init__.py +23 -0
- ralphception/runtime/frontends.py +121 -0
- ralphception/runtime/supervisor.py +694 -0
- ralphception/runtime/terminal.py +193 -0
- ralphception/skills/__init__.py +41 -0
- ralphception/skills/contracts.py +148 -0
- ralphception/skills/registry.py +177 -0
- ralphception/storage/__init__.py +31 -0
- ralphception/storage/artifacts.py +74 -0
- ralphception/storage/bootstrap.py +34 -0
- ralphception/storage/checkpoints.py +52 -0
- ralphception/storage/commit_intents.py +47 -0
- ralphception/storage/events.py +186 -0
- ralphception/storage/files.py +75 -0
- ralphception/storage/lease.py +211 -0
- ralphception/storage/mission_store.py +54 -0
- ralphception/storage/todos.py +31 -0
- ralphception/testing/__init__.py +1 -0
- ralphception/testing/fakes.py +353 -0
- ralphception/ui/__init__.py +3 -0
- ralphception/ui/app.py +666 -0
- ralphception/ui/screens/__init__.py +24 -0
- ralphception/ui/screens/review.py +584 -0
- ralphception/ui/screens/startup.py +232 -0
- ralphception/ui/widgets/__init__.py +16 -0
- ralphception/ui/widgets/command_bar.py +16 -0
- ralphception/ui/widgets/detail_pane.py +37 -0
- ralphception/ui/widgets/event_feed.py +27 -0
- ralphception/ui/widgets/workflow_tree.py +56 -0
- ralphception/verification/__init__.py +15 -0
- ralphception/verification/service.py +348 -0
- ralphception/workflow/__init__.py +1 -0
- ralphception/workflow/_identifiers.py +12 -0
- ralphception/workflow/approvals.py +170 -0
- ralphception/workflow/bundles.py +424 -0
- ralphception/workflow/engine.py +322 -0
- ralphception/workflow/recovery.py +240 -0
- ralphception-0.1.0.dist-info/METADATA +381 -0
- ralphception-0.1.0.dist-info/RECORD +89 -0
- ralphception-0.1.0.dist-info/WHEEL +4 -0
- ralphception-0.1.0.dist-info/entry_points.txt +2 -0
ralphception/__init__.py
ADDED
ralphception/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Actor-runtime entrypoints."""
|
|
2
|
+
|
|
3
|
+
from .intake_actor import IntakeActor
|
|
4
|
+
from .merge_actor import MergeAgent, MergeDecision, WorkerCandidate
|
|
5
|
+
from .orchestrator import ActorOrchestrator
|
|
6
|
+
from .planner_actor import PlannerActor
|
|
7
|
+
from .scheduler_actor import SchedulerActor
|
|
8
|
+
from .worker_actor import WorkerActor, WorkerAssignment
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ActorOrchestrator",
|
|
12
|
+
"IntakeActor",
|
|
13
|
+
"MergeAgent",
|
|
14
|
+
"MergeDecision",
|
|
15
|
+
"PlannerActor",
|
|
16
|
+
"SchedulerActor",
|
|
17
|
+
"WorkerCandidate",
|
|
18
|
+
"WorkerActor",
|
|
19
|
+
"WorkerAssignment",
|
|
20
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Mission-start intake helpers for the actor runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ralphception.models.mission import MissionPhase, MissionSession
|
|
8
|
+
from ralphception.runtime.frontends import StartupPrompt
|
|
9
|
+
from ralphception.storage.mission_store import MissionStore
|
|
10
|
+
from ralphception.ui.screens.startup import StartupBootstrapController, load_startup_record
|
|
11
|
+
from ralphception.workflow.approvals import read_approval
|
|
12
|
+
|
|
13
|
+
_SPEC_ARTIFACT_PATH = Path(".ralphception/specs/mission-spec.md")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntakeActor:
|
|
17
|
+
"""Owns the initial mission brief capture and spec-approval phase state."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo_root: Path) -> None:
|
|
20
|
+
self.repo_root = repo_root
|
|
21
|
+
self._store = MissionStore(repo_root)
|
|
22
|
+
self._startup = StartupBootstrapController(repo_root)
|
|
23
|
+
|
|
24
|
+
def load_session(self) -> MissionSession | None:
|
|
25
|
+
session = self._store.read_session()
|
|
26
|
+
if session is None:
|
|
27
|
+
startup_record = load_startup_record(self.repo_root)
|
|
28
|
+
if startup_record is None:
|
|
29
|
+
return None
|
|
30
|
+
session = MissionSession(
|
|
31
|
+
mission_id=startup_record.outer_run_id,
|
|
32
|
+
repo_root=str(self.repo_root),
|
|
33
|
+
phase=self._phase_from_workspace(),
|
|
34
|
+
approved_spec_version=1 if self._has_spec_approval() else None,
|
|
35
|
+
)
|
|
36
|
+
self._store.write_session(session)
|
|
37
|
+
return session
|
|
38
|
+
|
|
39
|
+
desired_phase = self._phase_from_workspace()
|
|
40
|
+
approved_spec_version = 1 if self._has_spec_approval() else None
|
|
41
|
+
if session.phase == desired_phase and session.approved_spec_version == approved_spec_version:
|
|
42
|
+
return session
|
|
43
|
+
updated = session.model_copy(
|
|
44
|
+
update={
|
|
45
|
+
"phase": desired_phase,
|
|
46
|
+
"approved_spec_version": approved_spec_version,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
self._store.write_session(updated)
|
|
50
|
+
return updated
|
|
51
|
+
|
|
52
|
+
def next_prompt(self, session: MissionSession | None) -> StartupPrompt:
|
|
53
|
+
del session
|
|
54
|
+
return StartupPrompt(
|
|
55
|
+
repo_root=self.repo_root,
|
|
56
|
+
message=(
|
|
57
|
+
"Socratic intake\n\n"
|
|
58
|
+
"Describe the mission for this workspace and any source repositories Ralphception "
|
|
59
|
+
"should inspect before drafting the spec."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def apply_answer(self, *, seed_prompt: str, source_repos: tuple[str, ...]) -> MissionSession:
|
|
64
|
+
record = self._startup.start_first_run(
|
|
65
|
+
seed_prompt=seed_prompt,
|
|
66
|
+
source_repos=source_repos,
|
|
67
|
+
)
|
|
68
|
+
session = MissionSession(
|
|
69
|
+
mission_id=record.outer_run_id,
|
|
70
|
+
repo_root=str(self.repo_root),
|
|
71
|
+
phase="intake",
|
|
72
|
+
approved_spec_version=None,
|
|
73
|
+
)
|
|
74
|
+
self._store.write_session(session)
|
|
75
|
+
return session
|
|
76
|
+
|
|
77
|
+
def mark_ready_for_spec_review(self) -> MissionSession:
|
|
78
|
+
session = self._require_session()
|
|
79
|
+
updated = session.model_copy(
|
|
80
|
+
update={
|
|
81
|
+
"phase": "spec_review",
|
|
82
|
+
"approved_spec_version": None,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
self._store.write_session(updated)
|
|
86
|
+
return updated
|
|
87
|
+
|
|
88
|
+
def approve_spec(self, *, artifact_version: int = 1) -> MissionSession:
|
|
89
|
+
session = self._require_session()
|
|
90
|
+
updated = session.model_copy(
|
|
91
|
+
update={
|
|
92
|
+
"phase": "planning",
|
|
93
|
+
"approved_spec_version": artifact_version,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
self._store.write_session(updated)
|
|
97
|
+
return updated
|
|
98
|
+
|
|
99
|
+
def _require_session(self) -> MissionSession:
|
|
100
|
+
session = self.load_session()
|
|
101
|
+
if session is None:
|
|
102
|
+
raise RuntimeError("mission session is not initialized")
|
|
103
|
+
return session
|
|
104
|
+
|
|
105
|
+
def _phase_from_workspace(self) -> MissionPhase:
|
|
106
|
+
if self._has_spec_approval():
|
|
107
|
+
return "planning"
|
|
108
|
+
if (self.repo_root / _SPEC_ARTIFACT_PATH).exists():
|
|
109
|
+
return "spec_review"
|
|
110
|
+
return "intake"
|
|
111
|
+
|
|
112
|
+
def _has_spec_approval(self) -> bool:
|
|
113
|
+
approval_path = self.repo_root / ".ralphception" / "approvals" / "approval-spec-1.json"
|
|
114
|
+
if not approval_path.exists():
|
|
115
|
+
return False
|
|
116
|
+
return read_approval(self.repo_root, approval_id="approval-spec-1") is not None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Merge-agent candidate selection for commit intents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, field_validator
|
|
8
|
+
|
|
9
|
+
from ralphception.git.merge import normalize_final_commit_message
|
|
10
|
+
from ralphception.models.artifacts import DurableModel
|
|
11
|
+
from ralphception.models.commit_intents import CommitIntent
|
|
12
|
+
from ralphception.storage.commit_intents import update_commit_intent_winner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkerCandidate(DurableModel):
|
|
16
|
+
candidate_id: str
|
|
17
|
+
commit_intent_id: str
|
|
18
|
+
summary: str
|
|
19
|
+
touched_paths: list[str] = Field(default_factory=list)
|
|
20
|
+
verification_passed: bool = True
|
|
21
|
+
|
|
22
|
+
@field_validator("candidate_id", "commit_intent_id", "summary")
|
|
23
|
+
@classmethod
|
|
24
|
+
def _validate_non_blank(cls, value: str) -> str:
|
|
25
|
+
if not value.strip():
|
|
26
|
+
raise ValueError("worker candidate fields must not be blank")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MergeDecision(DurableModel):
|
|
31
|
+
intent_id: str
|
|
32
|
+
selected_candidate_id: str
|
|
33
|
+
rejected_candidate_ids: list[str]
|
|
34
|
+
commit_message: str
|
|
35
|
+
rationale: str
|
|
36
|
+
|
|
37
|
+
@field_validator("intent_id", "selected_candidate_id", "commit_message", "rationale")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _validate_non_blank(cls, value: str) -> str:
|
|
40
|
+
if not value.strip():
|
|
41
|
+
raise ValueError("merge decision fields must not be blank")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MergeAgent:
|
|
46
|
+
"""Select the strongest candidate for a commit intent and record the result."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, repo_root: Path | None = None) -> None:
|
|
49
|
+
self.repo_root = repo_root
|
|
50
|
+
|
|
51
|
+
def choose_candidate(self, intent: CommitIntent, candidates: list[WorkerCandidate]) -> MergeDecision:
|
|
52
|
+
matching = [candidate for candidate in candidates if candidate.commit_intent_id == intent.intent_id]
|
|
53
|
+
if not matching:
|
|
54
|
+
raise ValueError(f"no candidates matched commit intent {intent.intent_id!r}")
|
|
55
|
+
|
|
56
|
+
ordered = sorted(
|
|
57
|
+
matching,
|
|
58
|
+
key=lambda candidate: (
|
|
59
|
+
candidate.verification_passed,
|
|
60
|
+
len(candidate.touched_paths),
|
|
61
|
+
candidate.candidate_id,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
selected = ordered[-1]
|
|
65
|
+
rejected = [candidate.candidate_id for candidate in ordered[:-1]]
|
|
66
|
+
rationale = (
|
|
67
|
+
f"selected {selected.candidate_id} for {intent.intent_id} based on verification state "
|
|
68
|
+
f"and touched-path coverage"
|
|
69
|
+
)
|
|
70
|
+
return MergeDecision(
|
|
71
|
+
intent_id=intent.intent_id,
|
|
72
|
+
selected_candidate_id=selected.candidate_id,
|
|
73
|
+
rejected_candidate_ids=rejected,
|
|
74
|
+
commit_message=normalize_final_commit_message(intent.message),
|
|
75
|
+
rationale=rationale,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def record_decision(self, intent: CommitIntent, decision: MergeDecision) -> CommitIntent:
|
|
79
|
+
if self.repo_root is None:
|
|
80
|
+
raise RuntimeError("repo_root is required to record merge decisions")
|
|
81
|
+
return update_commit_intent_winner(
|
|
82
|
+
self.repo_root,
|
|
83
|
+
intent_id=intent.intent_id,
|
|
84
|
+
candidate_id=decision.selected_candidate_id,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def land_intent(self, decision: MergeDecision) -> str:
|
|
88
|
+
return normalize_final_commit_message(decision.commit_message)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Actor-runtime orchestrator for intake and spec approval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ralphception.actor_runtime.intake_actor import IntakeActor
|
|
8
|
+
from ralphception.headless import HeadlessMissionSupervisor, HeadlessRunResult
|
|
9
|
+
from ralphception.runtime.frontends import FrontendEvent, RuntimeFrontend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActorOrchestrator:
|
|
13
|
+
"""Shared orchestrator fronting the legacy downstream executor."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
repo_root: Path,
|
|
19
|
+
primary_repo_root: Path,
|
|
20
|
+
seed_prompt: str | None,
|
|
21
|
+
source_repos: tuple[str, ...],
|
|
22
|
+
approve_all: bool,
|
|
23
|
+
approver: str,
|
|
24
|
+
session_manager_factory,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.repo_root = repo_root
|
|
27
|
+
self.primary_repo_root = primary_repo_root
|
|
28
|
+
self.seed_prompt = seed_prompt
|
|
29
|
+
self.source_repos = source_repos
|
|
30
|
+
self.approve_all = approve_all
|
|
31
|
+
self.approver = approver
|
|
32
|
+
self.session_manager_factory = session_manager_factory
|
|
33
|
+
self.intake_actor = IntakeActor(repo_root)
|
|
34
|
+
self.actions: list[str] = []
|
|
35
|
+
|
|
36
|
+
def run_until_blocked(self, *, frontend: RuntimeFrontend | None = None) -> HeadlessRunResult:
|
|
37
|
+
session = self.intake_actor.load_session()
|
|
38
|
+
if session is None:
|
|
39
|
+
self._emit(frontend, "Socratic intake")
|
|
40
|
+
if self.seed_prompt is None or not self.seed_prompt.strip():
|
|
41
|
+
if frontend is None:
|
|
42
|
+
return HeadlessRunResult(
|
|
43
|
+
mode="startup",
|
|
44
|
+
actions=(),
|
|
45
|
+
authoritative_repo_root=str(self.repo_root),
|
|
46
|
+
)
|
|
47
|
+
decision = frontend.prompt_startup(self.intake_actor.next_prompt(None))
|
|
48
|
+
self.seed_prompt = decision.seed_prompt
|
|
49
|
+
self.source_repos = decision.source_repos
|
|
50
|
+
session = self.intake_actor.apply_answer(
|
|
51
|
+
seed_prompt=self.seed_prompt or "",
|
|
52
|
+
source_repos=self.source_repos,
|
|
53
|
+
)
|
|
54
|
+
self._emit(frontend, f"Bootstrapped {session.mission_id}")
|
|
55
|
+
elif session.phase in {"intake", "spec_review"}:
|
|
56
|
+
self._emit(frontend, "Socratic intake")
|
|
57
|
+
|
|
58
|
+
if session.phase in {"intake", "spec_review"}:
|
|
59
|
+
review_result = self._run_spec_gate(frontend=frontend)
|
|
60
|
+
if review_result is not None:
|
|
61
|
+
return self._with_accumulated_actions(review_result)
|
|
62
|
+
announce_resume = False
|
|
63
|
+
else:
|
|
64
|
+
announce_resume = True
|
|
65
|
+
|
|
66
|
+
downstream = self._build_downstream_supervisor(
|
|
67
|
+
approve_all=True,
|
|
68
|
+
announce_resume=announce_resume,
|
|
69
|
+
)
|
|
70
|
+
return self._with_accumulated_actions(downstream.run(frontend=frontend))
|
|
71
|
+
|
|
72
|
+
def _run_spec_gate(self, *, frontend: RuntimeFrontend | None) -> HeadlessRunResult | None:
|
|
73
|
+
supervisor = self._build_downstream_supervisor(approve_all=self.approve_all)
|
|
74
|
+
supervisor._frontend = frontend
|
|
75
|
+
supervisor._announce_stage("spec")
|
|
76
|
+
review_result = supervisor._run_spec_stage(frontend=frontend)
|
|
77
|
+
self.actions.extend(supervisor.actions)
|
|
78
|
+
if review_result is not None:
|
|
79
|
+
self.intake_actor.mark_ready_for_spec_review()
|
|
80
|
+
return review_result
|
|
81
|
+
self.intake_actor.approve_spec(artifact_version=1)
|
|
82
|
+
self._emit(frontend, "Spec approved. Proceeding autonomously.")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _build_downstream_supervisor(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
approve_all: bool,
|
|
89
|
+
announce_resume: bool = True,
|
|
90
|
+
) -> HeadlessMissionSupervisor:
|
|
91
|
+
return HeadlessMissionSupervisor(
|
|
92
|
+
repo_root=self.repo_root,
|
|
93
|
+
primary_repo_root=self.primary_repo_root,
|
|
94
|
+
seed_prompt=self.seed_prompt,
|
|
95
|
+
source_repos=self.source_repos,
|
|
96
|
+
approve_all=approve_all,
|
|
97
|
+
approver=self.approver,
|
|
98
|
+
session_manager_factory=self.session_manager_factory,
|
|
99
|
+
announce_resume=announce_resume,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _emit(self, frontend: RuntimeFrontend | None, message: str) -> None:
|
|
103
|
+
if frontend is None:
|
|
104
|
+
return
|
|
105
|
+
frontend.emit_event(FrontendEvent(message=message))
|
|
106
|
+
|
|
107
|
+
def _with_accumulated_actions(self, result: HeadlessRunResult) -> HeadlessRunResult:
|
|
108
|
+
if not self.actions:
|
|
109
|
+
return result
|
|
110
|
+
return result.model_copy(update={"actions": (*self.actions, *result.actions)})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Planner actor for durable plan outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ralphception.headless import _load_plan_contract, _preferred_artifact_path
|
|
8
|
+
from ralphception.models.mission import PlannerOutput
|
|
9
|
+
from ralphception.storage.mission_store import MissionStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlannerActor:
|
|
13
|
+
"""Persist todo and commit-intent outputs derived from the approved plan contract."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *, repo_root: Path, store: MissionStore | None = None) -> None:
|
|
16
|
+
self.repo_root = repo_root
|
|
17
|
+
self.store = store or MissionStore(repo_root)
|
|
18
|
+
|
|
19
|
+
def plan(self, *, approved_spec_path: Path) -> PlannerOutput:
|
|
20
|
+
if not approved_spec_path.exists():
|
|
21
|
+
fallback_spec_path = _preferred_artifact_path(
|
|
22
|
+
approved_spec_path.parent,
|
|
23
|
+
preferred_name=approved_spec_path.name,
|
|
24
|
+
)
|
|
25
|
+
if not fallback_spec_path.exists():
|
|
26
|
+
raise FileNotFoundError(f"approved spec is missing: {approved_spec_path}")
|
|
27
|
+
approved_spec_path = fallback_spec_path
|
|
28
|
+
|
|
29
|
+
plans_dir = self.repo_root / ".ralphception" / "plans"
|
|
30
|
+
plan_markdown_path = _preferred_artifact_path(plans_dir, preferred_name="mission-plan.md")
|
|
31
|
+
plan_json_path = plans_dir / "mission-plan.json"
|
|
32
|
+
contract = _load_plan_contract(
|
|
33
|
+
plan_json_path,
|
|
34
|
+
default_goal=f"Implement {approved_spec_path.stem}.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.store.write_todos(contract.todos)
|
|
38
|
+
self.store.write_commit_intents(contract.commit_intents)
|
|
39
|
+
output = PlannerOutput(
|
|
40
|
+
plan_markdown_path=str(plan_markdown_path.relative_to(self.repo_root)),
|
|
41
|
+
plan_json_path=str(plan_json_path.relative_to(self.repo_root)),
|
|
42
|
+
todo_ids=[todo.todo_id for todo in contract.todos],
|
|
43
|
+
commit_intent_ids=[intent.intent_id for intent in contract.commit_intents],
|
|
44
|
+
execution_policy=contract.execution_policy,
|
|
45
|
+
)
|
|
46
|
+
self.store.write_planner_output(output)
|
|
47
|
+
return output
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Scheduler batching logic for ready todo execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from ralphception.actor_runtime.worker_actor import WorkerAssignment
|
|
8
|
+
from ralphception.models.mission import ExecutionPolicy
|
|
9
|
+
from ralphception.models.todos import TodoNode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SchedulerActor:
|
|
13
|
+
"""Plans worker assignments for ready todo batches."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *, max_parallel_children: int) -> None:
|
|
16
|
+
self.max_parallel_children = max_parallel_children
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_execution_policy(cls, policy: ExecutionPolicy) -> "SchedulerActor":
|
|
20
|
+
return cls(max_parallel_children=policy.max_parallel_children)
|
|
21
|
+
|
|
22
|
+
def plan_assignments(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
todos: list[TodoNode],
|
|
26
|
+
active_workers: list[WorkerAssignment],
|
|
27
|
+
) -> list[WorkerAssignment]:
|
|
28
|
+
available_slots = max(0, self.max_parallel_children - len(active_workers))
|
|
29
|
+
if available_slots == 0:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
claimed_todo_ids = {todo_id for worker in active_workers for todo_id in worker.todo_ids}
|
|
33
|
+
ready_todos = [
|
|
34
|
+
todo
|
|
35
|
+
for todo in todos
|
|
36
|
+
if todo.status == "ready" and todo.todo_id not in claimed_todo_ids
|
|
37
|
+
]
|
|
38
|
+
grouped: dict[tuple[str, str], list[TodoNode]] = defaultdict(list)
|
|
39
|
+
for todo in ready_todos:
|
|
40
|
+
grouped[(todo.commit_intent_id, _scope_group(todo.scope_hints))].append(todo)
|
|
41
|
+
|
|
42
|
+
assignments: list[WorkerAssignment] = []
|
|
43
|
+
worker_index = len(active_workers) + 1
|
|
44
|
+
for key in sorted(grouped):
|
|
45
|
+
if len(assignments) >= available_slots:
|
|
46
|
+
break
|
|
47
|
+
batch = sorted(grouped[key], key=lambda todo: todo.todo_id)
|
|
48
|
+
assignments.append(
|
|
49
|
+
WorkerAssignment(
|
|
50
|
+
worker_id=f"worker-{worker_index}",
|
|
51
|
+
run_id=f"run-worker-{worker_index}",
|
|
52
|
+
goal="; ".join(todo.title for todo in batch),
|
|
53
|
+
todo_ids=[todo.todo_id for todo in batch],
|
|
54
|
+
claimed_commit_intent_ids=sorted({todo.commit_intent_id for todo in batch}),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
worker_index += 1
|
|
58
|
+
return assignments
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _scope_group(scope_hints: list[str]) -> str:
|
|
62
|
+
if not scope_hints:
|
|
63
|
+
return ""
|
|
64
|
+
first_hint = scope_hints[0].strip().replace("\\", "/")
|
|
65
|
+
return first_hint.split("/", 1)[0] if first_hint else ""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Worker assignment and worktree startup helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, field_validator
|
|
9
|
+
|
|
10
|
+
from ralphception.git.worktrees import WorktreeCoordinator
|
|
11
|
+
from ralphception.models.artifacts import DurableModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkerAssignment(DurableModel):
|
|
15
|
+
worker_id: str
|
|
16
|
+
run_id: str
|
|
17
|
+
goal: str
|
|
18
|
+
todo_ids: list[str] = Field(min_length=1)
|
|
19
|
+
claimed_commit_intent_ids: list[str] = Field(min_length=1)
|
|
20
|
+
worktree_path: str | None = None
|
|
21
|
+
status: Literal["planned", "running", "completed", "failed"] = "planned"
|
|
22
|
+
|
|
23
|
+
@field_validator("worker_id", "run_id", "goal")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _validate_non_blank(cls, value: str) -> str:
|
|
26
|
+
if not value.strip():
|
|
27
|
+
raise ValueError("worker assignment fields must not be blank")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
@field_validator("todo_ids", "claimed_commit_intent_ids")
|
|
31
|
+
@classmethod
|
|
32
|
+
def _validate_non_blank_items(cls, value: list[str]) -> list[str]:
|
|
33
|
+
if any(not item.strip() for item in value):
|
|
34
|
+
raise ValueError("worker assignment list fields must not contain blank values")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkerActor:
|
|
39
|
+
"""Starts worker assignments in git worktrees."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, repo_root: Path, base_ref: str, target_branch: str) -> None:
|
|
42
|
+
self.repo_root = repo_root
|
|
43
|
+
self.base_ref = base_ref
|
|
44
|
+
self.target_branch = target_branch
|
|
45
|
+
self.worktrees = WorktreeCoordinator(repo_root=repo_root)
|
|
46
|
+
|
|
47
|
+
def start(self, assignment: WorkerAssignment) -> WorkerAssignment:
|
|
48
|
+
created = self.worktrees.create_child_worktree(
|
|
49
|
+
run_id=assignment.run_id,
|
|
50
|
+
base_ref=self.base_ref,
|
|
51
|
+
target_branch=f"{self.target_branch}-{assignment.run_id}",
|
|
52
|
+
goal=assignment.goal,
|
|
53
|
+
)
|
|
54
|
+
return assignment.model_copy(
|
|
55
|
+
update={
|
|
56
|
+
"worktree_path": str(created.worktree_path),
|
|
57
|
+
"status": "running",
|
|
58
|
+
}
|
|
59
|
+
)
|