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.
Files changed (89) hide show
  1. ralphception/__init__.py +8 -0
  2. ralphception/_version.py +34 -0
  3. ralphception/actor_runtime/__init__.py +20 -0
  4. ralphception/actor_runtime/intake_actor.py +116 -0
  5. ralphception/actor_runtime/merge_actor.py +88 -0
  6. ralphception/actor_runtime/orchestrator.py +110 -0
  7. ralphception/actor_runtime/planner_actor.py +47 -0
  8. ralphception/actor_runtime/scheduler_actor.py +65 -0
  9. ralphception/actor_runtime/worker_actor.py +59 -0
  10. ralphception/app.py +467 -0
  11. ralphception/audit/__init__.py +5 -0
  12. ralphception/audit/service.py +193 -0
  13. ralphception/bootstrap/__init__.py +25 -0
  14. ralphception/bootstrap/contracts.py +252 -0
  15. ralphception/bootstrap/prompting.py +110 -0
  16. ralphception/bootstrap/runner.py +406 -0
  17. ralphception/bootstrap/script.py +56 -0
  18. ralphception/bootstrap/storage.py +320 -0
  19. ralphception/bundled_skills/__init__.py +1 -0
  20. ralphception/bundled_skills/audit.review.md +3 -0
  21. ralphception/bundled_skills/bootstrap.loop.md +104 -0
  22. ralphception/bundled_skills/brainstorm.default.md +3 -0
  23. ralphception/bundled_skills/execute.task.md +3 -0
  24. ralphception/bundled_skills/merge.coordinator.md +3 -0
  25. ralphception/bundled_skills/plan.write.md +3 -0
  26. ralphception/bundled_skills/run.summary.md +3 -0
  27. ralphception/bundled_skills/spec.write.md +3 -0
  28. ralphception/cli.py +100 -0
  29. ralphception/codex/__init__.py +33 -0
  30. ralphception/codex/client.py +714 -0
  31. ralphception/codex/session_manager.py +411 -0
  32. ralphception/config.py +337 -0
  33. ralphception/git/__init__.py +52 -0
  34. ralphception/git/identity.py +46 -0
  35. ralphception/git/merge.py +1185 -0
  36. ralphception/git/worktrees.py +643 -0
  37. ralphception/headless.py +1715 -0
  38. ralphception/models/__init__.py +103 -0
  39. ralphception/models/artifacts.py +32 -0
  40. ralphception/models/commit_intents.py +38 -0
  41. ralphception/models/events.py +167 -0
  42. ralphception/models/mission.py +55 -0
  43. ralphception/models/runtime.py +272 -0
  44. ralphception/models/session.py +47 -0
  45. ralphception/models/todos.py +44 -0
  46. ralphception/models/verification.py +168 -0
  47. ralphception/models/worktree.py +34 -0
  48. ralphception/paths.py +83 -0
  49. ralphception/runtime/__init__.py +23 -0
  50. ralphception/runtime/frontends.py +121 -0
  51. ralphception/runtime/supervisor.py +694 -0
  52. ralphception/runtime/terminal.py +193 -0
  53. ralphception/skills/__init__.py +41 -0
  54. ralphception/skills/contracts.py +148 -0
  55. ralphception/skills/registry.py +177 -0
  56. ralphception/storage/__init__.py +31 -0
  57. ralphception/storage/artifacts.py +74 -0
  58. ralphception/storage/bootstrap.py +34 -0
  59. ralphception/storage/checkpoints.py +52 -0
  60. ralphception/storage/commit_intents.py +47 -0
  61. ralphception/storage/events.py +186 -0
  62. ralphception/storage/files.py +75 -0
  63. ralphception/storage/lease.py +211 -0
  64. ralphception/storage/mission_store.py +54 -0
  65. ralphception/storage/todos.py +31 -0
  66. ralphception/testing/__init__.py +1 -0
  67. ralphception/testing/fakes.py +353 -0
  68. ralphception/ui/__init__.py +3 -0
  69. ralphception/ui/app.py +666 -0
  70. ralphception/ui/screens/__init__.py +24 -0
  71. ralphception/ui/screens/review.py +584 -0
  72. ralphception/ui/screens/startup.py +232 -0
  73. ralphception/ui/widgets/__init__.py +16 -0
  74. ralphception/ui/widgets/command_bar.py +16 -0
  75. ralphception/ui/widgets/detail_pane.py +37 -0
  76. ralphception/ui/widgets/event_feed.py +27 -0
  77. ralphception/ui/widgets/workflow_tree.py +56 -0
  78. ralphception/verification/__init__.py +15 -0
  79. ralphception/verification/service.py +348 -0
  80. ralphception/workflow/__init__.py +1 -0
  81. ralphception/workflow/_identifiers.py +12 -0
  82. ralphception/workflow/approvals.py +170 -0
  83. ralphception/workflow/bundles.py +424 -0
  84. ralphception/workflow/engine.py +322 -0
  85. ralphception/workflow/recovery.py +240 -0
  86. ralphception-0.1.0.dist-info/METADATA +381 -0
  87. ralphception-0.1.0.dist-info/RECORD +89 -0
  88. ralphception-0.1.0.dist-info/WHEEL +4 -0
  89. ralphception-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,8 @@
1
+ """Ralphception package metadata."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ try:
6
+ from ._version import __version__
7
+ except ImportError:
8
+ __version__ = "0.0.0"
@@ -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
+ )