sibyl-cli 0.2.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.
- parallel_developer/__init__.py +5 -0
- parallel_developer/cli.py +649 -0
- parallel_developer/controller/__init__.py +1398 -0
- parallel_developer/controller/commands.py +132 -0
- parallel_developer/controller/events.py +17 -0
- parallel_developer/controller/flow.py +43 -0
- parallel_developer/controller/history.py +70 -0
- parallel_developer/controller/pause.py +94 -0
- parallel_developer/controller/workflow_runner.py +135 -0
- parallel_developer/orchestrator.py +1234 -0
- parallel_developer/services/__init__.py +14 -0
- parallel_developer/services/codex_monitor.py +627 -0
- parallel_developer/services/log_manager.py +161 -0
- parallel_developer/services/tmux_manager.py +245 -0
- parallel_developer/services/worktree_manager.py +119 -0
- parallel_developer/stores/__init__.py +20 -0
- parallel_developer/stores/session_manifest.py +165 -0
- parallel_developer/stores/settings_store.py +242 -0
- parallel_developer/ui/widgets.py +269 -0
- sibyl_cli-0.2.0.dist-info/METADATA +15 -0
- sibyl_cli-0.2.0.dist-info/RECORD +23 -0
- sibyl_cli-0.2.0.dist-info/WHEEL +4 -0
- sibyl_cli-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
"""High-level orchestration logic for parallel Codex sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Literal
|
|
15
|
+
|
|
16
|
+
from .stores import default_config_dir
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BossMode(str, Enum):
|
|
20
|
+
SKIP = "skip"
|
|
21
|
+
SCORE = "score"
|
|
22
|
+
REWRITE = "rewrite"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MergeMode(str, Enum):
|
|
26
|
+
MANUAL = "manual"
|
|
27
|
+
AUTO = "auto"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class OrchestrationResult:
|
|
32
|
+
"""Return value for a full orchestration cycle."""
|
|
33
|
+
|
|
34
|
+
selected_session: str
|
|
35
|
+
sessions_summary: Mapping[str, Any] = field(default_factory=dict)
|
|
36
|
+
artifact: Optional["CycleArtifact"] = None
|
|
37
|
+
continue_requested: bool = False
|
|
38
|
+
merge_outcome: Optional["MergeOutcome"] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class CandidateInfo:
|
|
43
|
+
key: str
|
|
44
|
+
label: str
|
|
45
|
+
session_id: Optional[str]
|
|
46
|
+
branch: str
|
|
47
|
+
worktree: Path
|
|
48
|
+
pane_id: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class WorkerDecision:
|
|
53
|
+
action: Literal["continue", "done"]
|
|
54
|
+
instruction: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class SelectionDecision:
|
|
59
|
+
selected_key: str
|
|
60
|
+
scores: Dict[str, float]
|
|
61
|
+
comments: Dict[str, str] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class CycleLayout:
|
|
66
|
+
"""Resolved tmux layout with worker metadata."""
|
|
67
|
+
|
|
68
|
+
main_pane: str
|
|
69
|
+
boss_pane: str
|
|
70
|
+
worker_panes: List[str]
|
|
71
|
+
worker_names: List[str]
|
|
72
|
+
pane_to_worker: Dict[str, str]
|
|
73
|
+
pane_to_path: Dict[str, Path]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class CycleArtifact:
|
|
78
|
+
main_session_id: str
|
|
79
|
+
worker_sessions: Dict[str, str]
|
|
80
|
+
boss_session_id: Optional[str]
|
|
81
|
+
worker_paths: Dict[str, Path]
|
|
82
|
+
boss_path: Optional[Path]
|
|
83
|
+
instruction: str
|
|
84
|
+
tmux_session: str
|
|
85
|
+
log_paths: Dict[str, Path] = field(default_factory=dict)
|
|
86
|
+
selected_session_id: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class SignalPaths:
|
|
91
|
+
cycle_id: str
|
|
92
|
+
root: Path
|
|
93
|
+
worker_flags: Dict[str, Path]
|
|
94
|
+
boss_flag: Path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class MergeOutcome:
|
|
99
|
+
strategy: MergeMode
|
|
100
|
+
status: Literal["skipped", "merged", "delegate", "failed"]
|
|
101
|
+
branch: Optional[str] = None
|
|
102
|
+
error: Optional[str] = None
|
|
103
|
+
reason: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Orchestrator:
|
|
107
|
+
"""Coordinates tmux, git worktrees, Codex monitoring, and Boss evaluation."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
tmux_manager: Any,
|
|
113
|
+
worktree_manager: Any,
|
|
114
|
+
monitor: Any,
|
|
115
|
+
log_manager: Any,
|
|
116
|
+
worker_count: int,
|
|
117
|
+
session_name: str,
|
|
118
|
+
main_session_hook: Optional[Callable[[str], None]] = None,
|
|
119
|
+
worker_decider: Optional[Callable[[Mapping[str, str], Mapping[str, Any], "CycleLayout"], WorkerDecision]] = None,
|
|
120
|
+
boss_mode: BossMode = BossMode.SCORE,
|
|
121
|
+
log_hook: Optional[Callable[[str], None]] = None,
|
|
122
|
+
merge_mode: MergeMode = MergeMode.MANUAL,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._tmux = tmux_manager
|
|
125
|
+
self._worktree = worktree_manager
|
|
126
|
+
self._monitor = monitor
|
|
127
|
+
self._log = log_manager
|
|
128
|
+
self._worker_count = worker_count
|
|
129
|
+
self._session_name = session_name
|
|
130
|
+
self._boss_mode = boss_mode if isinstance(boss_mode, BossMode) else BossMode(str(boss_mode))
|
|
131
|
+
self._active_worker_sessions: List[str] = []
|
|
132
|
+
self._main_session_hook: Optional[Callable[[str], None]] = main_session_hook
|
|
133
|
+
self._worker_decider = worker_decider
|
|
134
|
+
self._active_signals: Optional[SignalPaths] = None
|
|
135
|
+
self._log_hook = log_hook
|
|
136
|
+
self._merge_mode = merge_mode if isinstance(merge_mode, MergeMode) else MergeMode(str(merge_mode))
|
|
137
|
+
|
|
138
|
+
def set_main_session_hook(self, hook: Optional[Callable[[str], None]]) -> None:
|
|
139
|
+
self._main_session_hook = hook
|
|
140
|
+
|
|
141
|
+
def set_worker_decider(
|
|
142
|
+
self,
|
|
143
|
+
decider: Optional[Callable[[Mapping[str, str], Mapping[str, Any], CycleLayout], WorkerDecision]],
|
|
144
|
+
) -> None:
|
|
145
|
+
self._worker_decider = decider
|
|
146
|
+
|
|
147
|
+
def run_cycle(
|
|
148
|
+
self,
|
|
149
|
+
instruction: str,
|
|
150
|
+
selector: Optional[Callable[[List[CandidateInfo]], SelectionDecision]] = None,
|
|
151
|
+
resume_session_id: Optional[str] = None,
|
|
152
|
+
) -> OrchestrationResult:
|
|
153
|
+
"""Execute a single orchestrated instruction cycle."""
|
|
154
|
+
|
|
155
|
+
worker_roots = self._worktree.prepare()
|
|
156
|
+
boss_path = self._worktree.boss_path
|
|
157
|
+
|
|
158
|
+
self._tmux.set_boss_path(boss_path)
|
|
159
|
+
self._cleanup_signal_paths()
|
|
160
|
+
signal_paths: Optional[SignalPaths] = None
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
layout, baseline = self._prepare_layout(worker_roots)
|
|
164
|
+
signal_paths = self._prepare_signal_paths(layout.worker_names)
|
|
165
|
+
main_session_id, formatted_instruction = self._start_main_session(
|
|
166
|
+
layout=layout,
|
|
167
|
+
instruction=instruction,
|
|
168
|
+
baseline=baseline,
|
|
169
|
+
resume_session_id=resume_session_id,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
baseline = self._monitor.snapshot_rollouts()
|
|
173
|
+
fork_map = self._fork_worker_sessions(
|
|
174
|
+
layout=layout,
|
|
175
|
+
main_session_id=main_session_id,
|
|
176
|
+
baseline=baseline,
|
|
177
|
+
)
|
|
178
|
+
worker_flag_map: Dict[str, Path] = signal_paths.worker_flags if signal_paths else {}
|
|
179
|
+
self._dispatch_worker_instructions(
|
|
180
|
+
layout=layout,
|
|
181
|
+
user_instruction=instruction,
|
|
182
|
+
signal_flags=worker_flag_map,
|
|
183
|
+
)
|
|
184
|
+
self._active_worker_sessions = [session_id for session_id in fork_map.values() if session_id]
|
|
185
|
+
session_signal_map: Dict[str, Path] = {}
|
|
186
|
+
for pane_id, session_id in fork_map.items():
|
|
187
|
+
worker_name = layout.pane_to_worker.get(pane_id)
|
|
188
|
+
flag_path = worker_flag_map.get(worker_name) if worker_name else None
|
|
189
|
+
if session_id and flag_path is not None:
|
|
190
|
+
session_signal_map[session_id] = flag_path
|
|
191
|
+
completion_info = self._await_worker_completion(fork_map, session_signal_map)
|
|
192
|
+
|
|
193
|
+
while True:
|
|
194
|
+
if self._worker_decider:
|
|
195
|
+
try:
|
|
196
|
+
worker_decision = self._worker_decider(fork_map, completion_info, layout)
|
|
197
|
+
except Exception:
|
|
198
|
+
worker_decision = WorkerDecision(action="done")
|
|
199
|
+
else:
|
|
200
|
+
worker_decision = WorkerDecision(action="done")
|
|
201
|
+
|
|
202
|
+
if worker_decision.action == "continue":
|
|
203
|
+
continuation_text = (worker_decision.instruction or "").strip()
|
|
204
|
+
if not continuation_text:
|
|
205
|
+
raise RuntimeError("/continue が選択されましたが追加指示が取得できませんでした。")
|
|
206
|
+
self._dispatch_worker_continuation(
|
|
207
|
+
layout=layout,
|
|
208
|
+
user_instruction=continuation_text,
|
|
209
|
+
signal_flags=worker_flag_map,
|
|
210
|
+
)
|
|
211
|
+
completion_info = self._await_worker_completion(fork_map, session_signal_map)
|
|
212
|
+
continue
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
worker_sessions = {
|
|
216
|
+
layout.pane_to_worker[pane_id]: session_id
|
|
217
|
+
for pane_id, session_id in fork_map.items()
|
|
218
|
+
}
|
|
219
|
+
worker_paths = {
|
|
220
|
+
layout.pane_to_worker[pane_id]: layout.pane_to_path[pane_id]
|
|
221
|
+
for pane_id in layout.worker_panes
|
|
222
|
+
if pane_id in fork_map
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
artifact = CycleArtifact(
|
|
226
|
+
main_session_id=main_session_id,
|
|
227
|
+
worker_sessions=worker_sessions,
|
|
228
|
+
boss_session_id=None,
|
|
229
|
+
worker_paths=worker_paths,
|
|
230
|
+
boss_path=boss_path,
|
|
231
|
+
instruction=formatted_instruction,
|
|
232
|
+
tmux_session=self._session_name,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if self._boss_mode == BossMode.SKIP:
|
|
236
|
+
boss_session_id = None
|
|
237
|
+
boss_metrics: Dict[str, Dict[str, Any]] = {}
|
|
238
|
+
else:
|
|
239
|
+
if not signal_paths:
|
|
240
|
+
raise RuntimeError("Signal paths were not initialized for boss phase.")
|
|
241
|
+
boss_session_id, boss_metrics = self._run_boss_phase(
|
|
242
|
+
layout=layout,
|
|
243
|
+
main_session_id=main_session_id,
|
|
244
|
+
user_instruction=instruction.rstrip(),
|
|
245
|
+
completion_info=completion_info,
|
|
246
|
+
boss_flag=signal_paths.boss_flag,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
candidates = self._build_candidates(layout, fork_map, boss_session_id, boss_path)
|
|
250
|
+
artifact.boss_session_id = boss_session_id
|
|
251
|
+
artifact.boss_path = boss_path if boss_session_id else None
|
|
252
|
+
|
|
253
|
+
if not candidates:
|
|
254
|
+
scoreboard = {
|
|
255
|
+
"main": {
|
|
256
|
+
"score": None,
|
|
257
|
+
"comment": "",
|
|
258
|
+
"session_id": main_session_id,
|
|
259
|
+
"branch": None,
|
|
260
|
+
"worktree": str(self._worktree.root),
|
|
261
|
+
"selected": True,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
result = OrchestrationResult(
|
|
265
|
+
selected_session=main_session_id,
|
|
266
|
+
sessions_summary=scoreboard,
|
|
267
|
+
artifact=artifact,
|
|
268
|
+
)
|
|
269
|
+
log_paths = self._log.record_cycle(
|
|
270
|
+
instruction=formatted_instruction,
|
|
271
|
+
layout={
|
|
272
|
+
"main": layout.main_pane,
|
|
273
|
+
"boss": layout.boss_pane,
|
|
274
|
+
"workers": list(layout.worker_panes),
|
|
275
|
+
},
|
|
276
|
+
fork_map=fork_map,
|
|
277
|
+
completion=completion_info,
|
|
278
|
+
result=result,
|
|
279
|
+
)
|
|
280
|
+
artifact.log_paths = log_paths
|
|
281
|
+
artifact.selected_session_id = main_session_id
|
|
282
|
+
self._active_worker_sessions = []
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
decision, scoreboard = self._auto_or_select(
|
|
286
|
+
candidates,
|
|
287
|
+
completion_info,
|
|
288
|
+
selector,
|
|
289
|
+
boss_metrics,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
selected_info = self._validate_selection(decision, candidates)
|
|
293
|
+
self._phase_log(f"候補 {selected_info.key} を採択しました。", status="マージ処理中")
|
|
294
|
+
if self._merge_mode == MergeMode.AUTO:
|
|
295
|
+
merge_outcome = self._auto_commit_and_finalize(
|
|
296
|
+
selected=selected_info,
|
|
297
|
+
layout=layout,
|
|
298
|
+
signal_paths=signal_paths,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
merge_outcome = self._finalize_selection(selected=selected_info, main_pane=layout.main_pane)
|
|
302
|
+
artifact.selected_session_id = selected_info.session_id
|
|
303
|
+
|
|
304
|
+
result = OrchestrationResult(
|
|
305
|
+
selected_session=selected_info.session_id,
|
|
306
|
+
sessions_summary=scoreboard,
|
|
307
|
+
artifact=artifact,
|
|
308
|
+
merge_outcome=merge_outcome,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
log_paths = self._log.record_cycle(
|
|
312
|
+
instruction=formatted_instruction,
|
|
313
|
+
layout={
|
|
314
|
+
"main": layout.main_pane,
|
|
315
|
+
"boss": layout.boss_pane,
|
|
316
|
+
"workers": list(layout.worker_panes),
|
|
317
|
+
},
|
|
318
|
+
fork_map=fork_map,
|
|
319
|
+
completion=completion_info,
|
|
320
|
+
result=result,
|
|
321
|
+
)
|
|
322
|
+
artifact.log_paths = log_paths
|
|
323
|
+
self._active_worker_sessions = []
|
|
324
|
+
|
|
325
|
+
return result
|
|
326
|
+
finally:
|
|
327
|
+
self._cleanup_signal_paths()
|
|
328
|
+
|
|
329
|
+
def force_complete_workers(self) -> int:
|
|
330
|
+
if not self._active_worker_sessions:
|
|
331
|
+
return 0
|
|
332
|
+
sessions = list(self._active_worker_sessions)
|
|
333
|
+
self._monitor.force_completion(sessions)
|
|
334
|
+
self._active_worker_sessions = []
|
|
335
|
+
return len(sessions)
|
|
336
|
+
|
|
337
|
+
# --------------------------------------------------------------------- #
|
|
338
|
+
# Layout preparation
|
|
339
|
+
# --------------------------------------------------------------------- #
|
|
340
|
+
|
|
341
|
+
def _prepare_layout(self, worker_roots: Mapping[str, Path]) -> tuple[CycleLayout, Mapping[Path, float]]:
|
|
342
|
+
baseline = self._monitor.snapshot_rollouts()
|
|
343
|
+
layout_map = self._ensure_layout()
|
|
344
|
+
cycle_layout = self._build_cycle_layout(layout_map, worker_roots)
|
|
345
|
+
return cycle_layout, baseline
|
|
346
|
+
|
|
347
|
+
def _build_cycle_layout(
|
|
348
|
+
self,
|
|
349
|
+
layout_map: Mapping[str, Any],
|
|
350
|
+
worker_roots: Mapping[str, Path],
|
|
351
|
+
) -> CycleLayout:
|
|
352
|
+
main_pane = layout_map["main"]
|
|
353
|
+
boss_pane = layout_map["boss"]
|
|
354
|
+
worker_panes = list(layout_map["workers"])
|
|
355
|
+
|
|
356
|
+
worker_names = [f"worker-{idx + 1}" for idx in range(len(worker_panes))]
|
|
357
|
+
pane_to_worker = dict(zip(worker_panes, worker_names))
|
|
358
|
+
pane_to_path: Dict[str, Path] = {}
|
|
359
|
+
|
|
360
|
+
for pane_id, worker_name in pane_to_worker.items():
|
|
361
|
+
if worker_name not in worker_roots:
|
|
362
|
+
raise RuntimeError(
|
|
363
|
+
f"Worktree for {worker_name} not prepared; aborting fork sequence."
|
|
364
|
+
)
|
|
365
|
+
pane_to_path[pane_id] = Path(worker_roots[worker_name])
|
|
366
|
+
|
|
367
|
+
return CycleLayout(
|
|
368
|
+
main_pane=main_pane,
|
|
369
|
+
boss_pane=boss_pane,
|
|
370
|
+
worker_panes=worker_panes,
|
|
371
|
+
worker_names=worker_names,
|
|
372
|
+
pane_to_worker=pane_to_worker,
|
|
373
|
+
pane_to_path=pane_to_path,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _prepare_signal_paths(self, worker_names: Sequence[str]) -> SignalPaths:
|
|
377
|
+
raw_namespace = getattr(self._worktree, "session_namespace", None)
|
|
378
|
+
if isinstance(raw_namespace, str) and raw_namespace.strip():
|
|
379
|
+
namespace = raw_namespace
|
|
380
|
+
else:
|
|
381
|
+
namespace = "default"
|
|
382
|
+
cycle_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S-%f")
|
|
383
|
+
root = self._signal_base_dir(namespace) / cycle_id
|
|
384
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
worker_flags = {name: root / f"{name}.done" for name in worker_names}
|
|
386
|
+
boss_flag = root / "boss.done"
|
|
387
|
+
bundle = SignalPaths(
|
|
388
|
+
cycle_id=cycle_id,
|
|
389
|
+
root=root,
|
|
390
|
+
worker_flags=worker_flags,
|
|
391
|
+
boss_flag=boss_flag,
|
|
392
|
+
)
|
|
393
|
+
self._active_signals = bundle
|
|
394
|
+
return bundle
|
|
395
|
+
|
|
396
|
+
def _signal_base_dir(self, namespace: str) -> Path:
|
|
397
|
+
signals_root = default_config_dir() / "sessions" / namespace / "signals"
|
|
398
|
+
signals_root.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
return signals_root
|
|
400
|
+
|
|
401
|
+
def _cleanup_signal_paths(self) -> None:
|
|
402
|
+
bundle = self._active_signals
|
|
403
|
+
if not bundle:
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
shutil.rmtree(bundle.root)
|
|
407
|
+
except FileNotFoundError:
|
|
408
|
+
pass
|
|
409
|
+
except OSError:
|
|
410
|
+
pass
|
|
411
|
+
finally:
|
|
412
|
+
self._active_signals = None
|
|
413
|
+
|
|
414
|
+
def _start_main_session(
|
|
415
|
+
self,
|
|
416
|
+
*,
|
|
417
|
+
layout: CycleLayout,
|
|
418
|
+
instruction: str,
|
|
419
|
+
baseline: Mapping[Path, float],
|
|
420
|
+
resume_session_id: Optional[str],
|
|
421
|
+
) -> tuple[str, str]:
|
|
422
|
+
if resume_session_id:
|
|
423
|
+
self._monitor.bind_existing_session(
|
|
424
|
+
pane_id=layout.main_pane,
|
|
425
|
+
session_id=resume_session_id,
|
|
426
|
+
)
|
|
427
|
+
main_session_id = resume_session_id
|
|
428
|
+
else:
|
|
429
|
+
self._tmux.launch_main_session(pane_id=layout.main_pane)
|
|
430
|
+
main_session_id = self._monitor.register_new_rollout(
|
|
431
|
+
pane_id=layout.main_pane,
|
|
432
|
+
baseline=baseline,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if self._main_session_hook:
|
|
436
|
+
self._main_session_hook(main_session_id)
|
|
437
|
+
|
|
438
|
+
user_instruction = instruction.rstrip()
|
|
439
|
+
formatted_instruction = self._ensure_done_directive(user_instruction)
|
|
440
|
+
fork_prompt = self._build_main_fork_prompt()
|
|
441
|
+
|
|
442
|
+
self._tmux.send_instruction_to_pane(
|
|
443
|
+
pane_id=layout.main_pane,
|
|
444
|
+
instruction=fork_prompt,
|
|
445
|
+
)
|
|
446
|
+
self._monitor.wait_for_rollout_activity(
|
|
447
|
+
main_session_id,
|
|
448
|
+
timeout_seconds=10.0,
|
|
449
|
+
)
|
|
450
|
+
self._tmux.prepare_for_instruction(pane_id=layout.main_pane)
|
|
451
|
+
self._monitor.capture_instruction(
|
|
452
|
+
pane_id=layout.main_pane,
|
|
453
|
+
instruction=fork_prompt,
|
|
454
|
+
)
|
|
455
|
+
return main_session_id, formatted_instruction
|
|
456
|
+
|
|
457
|
+
# --------------------------------------------------------------------- #
|
|
458
|
+
# Worker handling
|
|
459
|
+
# --------------------------------------------------------------------- #
|
|
460
|
+
|
|
461
|
+
def _fork_worker_sessions(
|
|
462
|
+
self,
|
|
463
|
+
*,
|
|
464
|
+
layout: CycleLayout,
|
|
465
|
+
main_session_id: str,
|
|
466
|
+
baseline: Mapping[Path, float],
|
|
467
|
+
) -> Dict[str, str]:
|
|
468
|
+
worker_paths = {pane_id: layout.pane_to_path[pane_id] for pane_id in layout.worker_panes}
|
|
469
|
+
worker_pane_list = self._tmux.fork_workers(
|
|
470
|
+
workers=layout.worker_panes,
|
|
471
|
+
base_session_id=main_session_id,
|
|
472
|
+
pane_paths=worker_paths,
|
|
473
|
+
)
|
|
474
|
+
self._maybe_pause(
|
|
475
|
+
"PARALLEL_DEV_PAUSE_AFTER_RESUME",
|
|
476
|
+
"[parallel-dev] Debug pause after worker resume. Inspect tmux panes and press Enter to continue...",
|
|
477
|
+
)
|
|
478
|
+
fork_map = self._monitor.register_worker_rollouts(
|
|
479
|
+
worker_panes=worker_pane_list,
|
|
480
|
+
baseline=baseline,
|
|
481
|
+
)
|
|
482
|
+
return fork_map
|
|
483
|
+
|
|
484
|
+
def _dispatch_worker_instructions(
|
|
485
|
+
self,
|
|
486
|
+
*,
|
|
487
|
+
layout: CycleLayout,
|
|
488
|
+
user_instruction: str,
|
|
489
|
+
signal_flags: Mapping[str, Path],
|
|
490
|
+
) -> None:
|
|
491
|
+
for pane_id in layout.worker_panes:
|
|
492
|
+
worker_name = layout.pane_to_worker[pane_id]
|
|
493
|
+
worker_path = layout.pane_to_path.get(pane_id)
|
|
494
|
+
if worker_path is None:
|
|
495
|
+
continue
|
|
496
|
+
completion_flag = signal_flags.get(worker_name)
|
|
497
|
+
self._tmux.prepare_for_instruction(pane_id=pane_id)
|
|
498
|
+
location_notice = self._worktree_location_notice(custom_path=worker_path)
|
|
499
|
+
base_message = (
|
|
500
|
+
f"You are {worker_name}. Your dedicated worktree is `{worker_path}`.\n"
|
|
501
|
+
"Task:\n"
|
|
502
|
+
f"{user_instruction.rstrip()}"
|
|
503
|
+
)
|
|
504
|
+
message = self._ensure_done_directive(
|
|
505
|
+
base_message,
|
|
506
|
+
location_notice=location_notice,
|
|
507
|
+
completion_flag=completion_flag,
|
|
508
|
+
)
|
|
509
|
+
self._tmux.send_instruction_to_pane(
|
|
510
|
+
pane_id=pane_id,
|
|
511
|
+
instruction=message,
|
|
512
|
+
)
|
|
513
|
+
self._phase_log("ワーカー実行を開始しました。", status="ワーカー実行中")
|
|
514
|
+
|
|
515
|
+
def _dispatch_worker_continuation(
|
|
516
|
+
self,
|
|
517
|
+
*,
|
|
518
|
+
layout: CycleLayout,
|
|
519
|
+
user_instruction: str,
|
|
520
|
+
signal_flags: Mapping[str, Path],
|
|
521
|
+
) -> None:
|
|
522
|
+
trimmed = user_instruction.rstrip("\n")
|
|
523
|
+
for pane_id in layout.worker_panes:
|
|
524
|
+
worker_name = layout.pane_to_worker.get(pane_id)
|
|
525
|
+
worker_path = layout.pane_to_path.get(pane_id)
|
|
526
|
+
if not worker_name or worker_path is None:
|
|
527
|
+
continue
|
|
528
|
+
self._tmux.send_instruction_to_pane(
|
|
529
|
+
pane_id=pane_id,
|
|
530
|
+
instruction=trimmed,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _await_worker_completion(
|
|
534
|
+
self,
|
|
535
|
+
fork_map: Mapping[str, str],
|
|
536
|
+
signal_map: Mapping[str, Path],
|
|
537
|
+
) -> Dict[str, Any]:
|
|
538
|
+
completion_info = self._monitor.await_completion(
|
|
539
|
+
session_ids=list(fork_map.values()),
|
|
540
|
+
signal_paths=signal_map,
|
|
541
|
+
)
|
|
542
|
+
if os.getenv("PARALLEL_DEV_DEBUG_STATE") == "1":
|
|
543
|
+
print("[parallel-dev] Worker completion status:", completion_info)
|
|
544
|
+
self._phase_log("ワーカー処理が完了しました。", status="採点準備中")
|
|
545
|
+
return completion_info
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# --------------------------------------------------------------------- #
|
|
549
|
+
# Boss handling
|
|
550
|
+
# --------------------------------------------------------------------- #
|
|
551
|
+
|
|
552
|
+
def _run_boss_phase(
|
|
553
|
+
self,
|
|
554
|
+
*,
|
|
555
|
+
layout: CycleLayout,
|
|
556
|
+
main_session_id: str,
|
|
557
|
+
user_instruction: str,
|
|
558
|
+
completion_info: Dict[str, Any],
|
|
559
|
+
boss_flag: Path,
|
|
560
|
+
) -> tuple[Optional[str], Dict[str, Dict[str, Any]]]:
|
|
561
|
+
if not layout.worker_panes:
|
|
562
|
+
return None, {}
|
|
563
|
+
baseline = self._monitor.snapshot_rollouts()
|
|
564
|
+
self._tmux.fork_boss(
|
|
565
|
+
pane_id=layout.boss_pane,
|
|
566
|
+
base_session_id=main_session_id,
|
|
567
|
+
boss_path=self._worktree.boss_path,
|
|
568
|
+
)
|
|
569
|
+
boss_session_id = self._monitor.register_new_rollout(
|
|
570
|
+
pane_id=layout.boss_pane,
|
|
571
|
+
baseline=baseline,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
self._maybe_pause(
|
|
575
|
+
"PARALLEL_DEV_PAUSE_BEFORE_BOSS",
|
|
576
|
+
"[parallel-dev] All workers reported completion. Inspect boss pane, then press Enter to send boss instructions...",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
boss_instruction = self._build_boss_instruction(
|
|
580
|
+
layout.worker_names,
|
|
581
|
+
user_instruction,
|
|
582
|
+
)
|
|
583
|
+
self._phase_log("採点フェーズを開始します。", status="採点中")
|
|
584
|
+
self._tmux.send_instruction_to_pane(
|
|
585
|
+
pane_id=layout.boss_pane,
|
|
586
|
+
instruction=boss_instruction,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
boss_metrics = self._wait_for_boss_scores(boss_session_id)
|
|
590
|
+
if not boss_metrics:
|
|
591
|
+
boss_metrics = self._extract_boss_scores(boss_session_id)
|
|
592
|
+
|
|
593
|
+
if self._boss_mode == BossMode.REWRITE:
|
|
594
|
+
followup = self._build_boss_rewrite_followup(boss_flag=boss_flag)
|
|
595
|
+
if followup:
|
|
596
|
+
self._tmux.send_instruction_to_pane(
|
|
597
|
+
pane_id=layout.boss_pane,
|
|
598
|
+
instruction=followup,
|
|
599
|
+
)
|
|
600
|
+
boss_completion = self._monitor.await_completion(
|
|
601
|
+
session_ids=[boss_session_id],
|
|
602
|
+
signal_paths={boss_session_id: boss_flag},
|
|
603
|
+
)
|
|
604
|
+
completion_info.update(boss_completion)
|
|
605
|
+
else:
|
|
606
|
+
completion_info[boss_session_id] = {"done": True, "scores_detected": True}
|
|
607
|
+
|
|
608
|
+
self._phase_log("採点フェーズが完了しました。", status="採択待ち")
|
|
609
|
+
return boss_session_id, boss_metrics
|
|
610
|
+
|
|
611
|
+
# --------------------------------------------------------------------- #
|
|
612
|
+
# Candidate selection
|
|
613
|
+
# --------------------------------------------------------------------- #
|
|
614
|
+
|
|
615
|
+
def _build_candidates(
|
|
616
|
+
self,
|
|
617
|
+
layout: CycleLayout,
|
|
618
|
+
fork_map: Mapping[str, str],
|
|
619
|
+
boss_session_id: Optional[str],
|
|
620
|
+
boss_path: Path,
|
|
621
|
+
) -> List[CandidateInfo]:
|
|
622
|
+
candidates: List[CandidateInfo] = []
|
|
623
|
+
for pane_id, session_id in fork_map.items():
|
|
624
|
+
worker_name = layout.pane_to_worker[pane_id]
|
|
625
|
+
branch_name = self._worktree.worker_branch(worker_name)
|
|
626
|
+
worktree_path = layout.pane_to_path[pane_id]
|
|
627
|
+
resolved_session = self._resolve_session_id(session_id) or session_id
|
|
628
|
+
candidates.append(
|
|
629
|
+
CandidateInfo(
|
|
630
|
+
key=worker_name,
|
|
631
|
+
label=f"{worker_name} (session {resolved_session})",
|
|
632
|
+
session_id=resolved_session,
|
|
633
|
+
branch=branch_name,
|
|
634
|
+
worktree=worktree_path,
|
|
635
|
+
pane_id=pane_id,
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
include_boss = (
|
|
640
|
+
boss_session_id
|
|
641
|
+
and self._boss_mode == BossMode.REWRITE
|
|
642
|
+
)
|
|
643
|
+
if include_boss:
|
|
644
|
+
resolved_boss = self._resolve_session_id(boss_session_id) or boss_session_id
|
|
645
|
+
candidates.append(
|
|
646
|
+
CandidateInfo(
|
|
647
|
+
key="boss",
|
|
648
|
+
label=f"boss (session {resolved_boss})",
|
|
649
|
+
session_id=resolved_boss,
|
|
650
|
+
branch=self._worktree.boss_branch,
|
|
651
|
+
worktree=boss_path,
|
|
652
|
+
pane_id=layout.boss_pane,
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
return candidates
|
|
656
|
+
|
|
657
|
+
def _validate_selection(
|
|
658
|
+
self,
|
|
659
|
+
decision: SelectionDecision,
|
|
660
|
+
candidates: Iterable[CandidateInfo],
|
|
661
|
+
) -> CandidateInfo:
|
|
662
|
+
candidate_map = {candidate.key: candidate for candidate in candidates}
|
|
663
|
+
if decision.selected_key not in candidate_map:
|
|
664
|
+
raise ValueError(
|
|
665
|
+
f"Selector returned unknown candidate '{decision.selected_key}'. "
|
|
666
|
+
f"Known candidates: {sorted(candidate_map)}"
|
|
667
|
+
)
|
|
668
|
+
selected_info = candidate_map[decision.selected_key]
|
|
669
|
+
if selected_info.session_id is None:
|
|
670
|
+
raise RuntimeError("Selected candidate has no session id; cannot resume main session.")
|
|
671
|
+
if selected_info.key == "boss" and self._boss_mode != BossMode.REWRITE:
|
|
672
|
+
raise ValueError("Boss candidate is only available in rewrite mode.")
|
|
673
|
+
return selected_info
|
|
674
|
+
|
|
675
|
+
def _finalize_selection(
|
|
676
|
+
self,
|
|
677
|
+
*,
|
|
678
|
+
selected: CandidateInfo,
|
|
679
|
+
main_pane: str,
|
|
680
|
+
delegate_reason: Optional[str] = None,
|
|
681
|
+
) -> MergeOutcome:
|
|
682
|
+
self._tmux.interrupt_pane(pane_id=main_pane)
|
|
683
|
+
reason = delegate_reason
|
|
684
|
+
if reason is None:
|
|
685
|
+
if self._merge_mode == MergeMode.AUTO:
|
|
686
|
+
reason = "agent_auto"
|
|
687
|
+
else:
|
|
688
|
+
reason = "manual_user"
|
|
689
|
+
merge_outcome = MergeOutcome(
|
|
690
|
+
strategy=self._merge_mode,
|
|
691
|
+
status="delegate",
|
|
692
|
+
branch=selected.branch,
|
|
693
|
+
reason=reason,
|
|
694
|
+
)
|
|
695
|
+
if selected.session_id:
|
|
696
|
+
self._tmux.promote_to_main(session_id=selected.session_id, pane_id=main_pane)
|
|
697
|
+
bind_existing = getattr(self._monitor, "bind_existing_session", None)
|
|
698
|
+
if callable(bind_existing):
|
|
699
|
+
try:
|
|
700
|
+
bind_existing(pane_id=main_pane, session_id=selected.session_id)
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
self._phase_log("メインセッションを再開しました。", status="再開中")
|
|
704
|
+
consume = getattr(self._monitor, "consume_session_until_eof", None)
|
|
705
|
+
if callable(consume):
|
|
706
|
+
try:
|
|
707
|
+
consume(selected.session_id)
|
|
708
|
+
except Exception:
|
|
709
|
+
pass
|
|
710
|
+
return merge_outcome
|
|
711
|
+
|
|
712
|
+
def _auto_commit_and_finalize(
|
|
713
|
+
self,
|
|
714
|
+
*,
|
|
715
|
+
selected: CandidateInfo,
|
|
716
|
+
layout: CycleLayout,
|
|
717
|
+
signal_paths: Optional[SignalPaths],
|
|
718
|
+
) -> MergeOutcome:
|
|
719
|
+
pane_id = selected.pane_id or self._resolve_candidate_pane(selected.key, layout)
|
|
720
|
+
if pane_id is None:
|
|
721
|
+
return self._finalize_selection(
|
|
722
|
+
selected=selected,
|
|
723
|
+
main_pane=layout.main_pane,
|
|
724
|
+
delegate_reason="manual_user",
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
flag_path = self._resolve_flag_path(selected.key, signal_paths)
|
|
728
|
+
if flag_path:
|
|
729
|
+
try:
|
|
730
|
+
flag_path.unlink()
|
|
731
|
+
except FileNotFoundError:
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
instruction = self._build_agent_commit_instruction(selected, flag_path)
|
|
735
|
+
self._phase_log("採択エージェントへコミット/統合手順を送信しました。", status="コミット指示中")
|
|
736
|
+
self._tmux.prepare_for_instruction(pane_id=pane_id)
|
|
737
|
+
self._tmux.send_instruction_to_pane(pane_id=pane_id, instruction=instruction)
|
|
738
|
+
|
|
739
|
+
if flag_path:
|
|
740
|
+
self._phase_log("コミット結果を待っています。", status="コミット待ち")
|
|
741
|
+
self._wait_for_flag(flag_path)
|
|
742
|
+
|
|
743
|
+
self._phase_log("コミット報告を受け取りました。", status="マージ処理中")
|
|
744
|
+
self._pull_main_after_auto()
|
|
745
|
+
return self._finalize_selection(
|
|
746
|
+
selected=selected,
|
|
747
|
+
main_pane=layout.main_pane,
|
|
748
|
+
delegate_reason="agent_auto",
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
def _resolve_candidate_pane(self, key: str, layout: CycleLayout) -> Optional[str]:
|
|
752
|
+
if key == "boss":
|
|
753
|
+
return layout.boss_pane
|
|
754
|
+
for pane_id, worker_name in layout.pane_to_worker.items():
|
|
755
|
+
if worker_name == key:
|
|
756
|
+
return pane_id
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
def _resolve_flag_path(self, key: str, signal_paths: Optional[SignalPaths]) -> Optional[Path]:
|
|
760
|
+
if not signal_paths:
|
|
761
|
+
return None
|
|
762
|
+
if key == "boss":
|
|
763
|
+
return signal_paths.boss_flag
|
|
764
|
+
return signal_paths.worker_flags.get(key)
|
|
765
|
+
|
|
766
|
+
def _build_agent_commit_instruction(self, selected: CandidateInfo, flag_path: Optional[Path]) -> str:
|
|
767
|
+
worktree = str(selected.worktree)
|
|
768
|
+
branch = selected.branch
|
|
769
|
+
commit_message = f"Auto update from {selected.key}"
|
|
770
|
+
flag_text = str(flag_path) if flag_path else None
|
|
771
|
+
|
|
772
|
+
lines = [
|
|
773
|
+
"追加タスク: あなたが編集した成果物を本レポジトリへ統合してください。",
|
|
774
|
+
f"- 作業ディレクトリ: {worktree}",
|
|
775
|
+
f"- ブランチ: {branch}",
|
|
776
|
+
"",
|
|
777
|
+
"【目的】",
|
|
778
|
+
"- 必要なファイルだけをコミットし、main を fast-forward で最新化する",
|
|
779
|
+
"- 進捗や問題があれば必ずログに記録する",
|
|
780
|
+
"",
|
|
781
|
+
"【禁止事項】",
|
|
782
|
+
"- `git push --force` や `git reset --hard origin/main` などの破壊的操作",
|
|
783
|
+
"- main 以外のブランチを削除・上書きすること",
|
|
784
|
+
"- エラーを黙って無視すること",
|
|
785
|
+
"",
|
|
786
|
+
"【推奨フロー(状況に応じて調整可)】",
|
|
787
|
+
f"1. cd {worktree} && git status -sb で変更内容を確認",
|
|
788
|
+
"2. コミット対象のみ git add (例: dev_test, docs/experiment.yaml など)",
|
|
789
|
+
f"3. git commit -m \"{commit_message}\" (既存コミットを使い回さない)",
|
|
790
|
+
f"4. main を最新化して fast-forward で統合(例: git checkout main && git pull --ff-only && git merge --ff-only {branch} && git checkout {branch})",
|
|
791
|
+
"5. 衝突が発生した場合は安全な方法で解消し、解決できなければエラー内容とともに報告",
|
|
792
|
+
]
|
|
793
|
+
if flag_text:
|
|
794
|
+
lines.append(f"6. コミット/統合が完了したら `touch {flag_text}` で完了を通知してください。")
|
|
795
|
+
lines.append("※ 進め方に迷った場合やエラーが発生した場合は状況とログを共有してください。")
|
|
796
|
+
return "\n".join(lines)
|
|
797
|
+
|
|
798
|
+
def _pull_main_after_auto(self) -> None:
|
|
799
|
+
root = getattr(self._worktree, "root", None)
|
|
800
|
+
if not root:
|
|
801
|
+
return
|
|
802
|
+
ff_cmd = ["git", "-C", str(root), "pull", "--ff-only"]
|
|
803
|
+
try:
|
|
804
|
+
subprocess.run(
|
|
805
|
+
ff_cmd,
|
|
806
|
+
check=True,
|
|
807
|
+
stdout=subprocess.DEVNULL,
|
|
808
|
+
stderr=subprocess.PIPE,
|
|
809
|
+
text=True,
|
|
810
|
+
)
|
|
811
|
+
self._log_merge_sync("git pull --ff-only")
|
|
812
|
+
return
|
|
813
|
+
except subprocess.CalledProcessError as exc: # noqa: PERF203
|
|
814
|
+
self._log_git_failure("git pull --ff-only", exc)
|
|
815
|
+
|
|
816
|
+
remote, branch = self._resolve_upstream_tracking(root)
|
|
817
|
+
rebase_cmd = ["git", "-C", str(root), "pull", "--rebase", remote, branch]
|
|
818
|
+
try:
|
|
819
|
+
subprocess.run(
|
|
820
|
+
rebase_cmd,
|
|
821
|
+
check=True,
|
|
822
|
+
stdout=subprocess.DEVNULL,
|
|
823
|
+
stderr=subprocess.PIPE,
|
|
824
|
+
text=True,
|
|
825
|
+
)
|
|
826
|
+
self._log_merge_sync(f"git pull --rebase {remote} {branch}")
|
|
827
|
+
return
|
|
828
|
+
except subprocess.CalledProcessError as exc: # noqa: PERF203
|
|
829
|
+
conflict_msg = (
|
|
830
|
+
"[merge] ホスト側 git pull --rebase {remote} {branch} が失敗しました: {detail}. "
|
|
831
|
+
"衝突を解消し `git rebase --continue` したら /done で次へ進めてください。"
|
|
832
|
+
).format(remote=remote, branch=branch, detail=exc.stderr or exc.stdout or str(exc))
|
|
833
|
+
if self._log_hook:
|
|
834
|
+
try:
|
|
835
|
+
self._log_hook(conflict_msg)
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
def _resolve_upstream_tracking(self, root: Path) -> tuple[str, str]:
|
|
840
|
+
try:
|
|
841
|
+
result = subprocess.run(
|
|
842
|
+
["git", "-C", str(root), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
843
|
+
check=True,
|
|
844
|
+
stdout=subprocess.PIPE,
|
|
845
|
+
stderr=subprocess.PIPE,
|
|
846
|
+
text=True,
|
|
847
|
+
)
|
|
848
|
+
upstream = result.stdout.strip()
|
|
849
|
+
if upstream and "/" in upstream:
|
|
850
|
+
remote, branch = upstream.split("/", 1)
|
|
851
|
+
return remote, branch
|
|
852
|
+
except subprocess.CalledProcessError:
|
|
853
|
+
pass
|
|
854
|
+
return "origin", "main"
|
|
855
|
+
|
|
856
|
+
def _log_git_failure(self, action: str, exc: subprocess.CalledProcessError) -> None:
|
|
857
|
+
if not self._log_hook:
|
|
858
|
+
return
|
|
859
|
+
message = f"[merge] ホスト側 {action} が失敗しました: {exc.stderr or exc.stdout or exc}"
|
|
860
|
+
try:
|
|
861
|
+
self._log_hook(message)
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
def _log_merge_sync(self, action: str) -> None:
|
|
866
|
+
if not self._log_hook:
|
|
867
|
+
return
|
|
868
|
+
message = f"[merge] ホスト側 {action} で main を同期しました。"
|
|
869
|
+
try:
|
|
870
|
+
self._log_hook(message)
|
|
871
|
+
except Exception:
|
|
872
|
+
pass
|
|
873
|
+
|
|
874
|
+
def _wait_for_flag(self, flag_path: Path, timeout: float = 600.0) -> None:
|
|
875
|
+
deadline = time.time() + timeout
|
|
876
|
+
flag_path.parent.mkdir(parents=True, exist_ok=True)
|
|
877
|
+
while time.time() < deadline:
|
|
878
|
+
if flag_path.exists():
|
|
879
|
+
return
|
|
880
|
+
time.sleep(1.0)
|
|
881
|
+
if self._log_hook:
|
|
882
|
+
try:
|
|
883
|
+
self._log_hook(f"[merge] 完了フラグ {flag_path} が {timeout}s 以内に検出できませんでした。")
|
|
884
|
+
except Exception:
|
|
885
|
+
pass
|
|
886
|
+
|
|
887
|
+
def _phase_log(self, message: str, status: Optional[str] = None) -> None:
|
|
888
|
+
if not self._log_hook:
|
|
889
|
+
return
|
|
890
|
+
payload = f"[phase] {message}"
|
|
891
|
+
if status:
|
|
892
|
+
payload = f"{payload} ::status::{status}"
|
|
893
|
+
try:
|
|
894
|
+
self._log_hook(payload)
|
|
895
|
+
except Exception:
|
|
896
|
+
pass
|
|
897
|
+
|
|
898
|
+
def _resolve_session_id(self, session_id: Optional[str]) -> Optional[str]:
|
|
899
|
+
if not session_id:
|
|
900
|
+
return session_id
|
|
901
|
+
refresher = getattr(self._monitor, "refresh_session_id", None)
|
|
902
|
+
if callable(refresher):
|
|
903
|
+
try:
|
|
904
|
+
resolved = refresher(session_id)
|
|
905
|
+
except Exception:
|
|
906
|
+
return session_id
|
|
907
|
+
return resolved or session_id
|
|
908
|
+
return session_id
|
|
909
|
+
|
|
910
|
+
# --------------------------------------------------------------------- #
|
|
911
|
+
# Existing helper utilities
|
|
912
|
+
# --------------------------------------------------------------------- #
|
|
913
|
+
|
|
914
|
+
def _ensure_layout(self) -> MutableMapping[str, Any]:
|
|
915
|
+
layout = self._tmux.ensure_layout(
|
|
916
|
+
session_name=self._session_name,
|
|
917
|
+
worker_count=self._worker_count,
|
|
918
|
+
)
|
|
919
|
+
self._validate_layout(layout)
|
|
920
|
+
return layout
|
|
921
|
+
|
|
922
|
+
def _validate_layout(self, layout: Mapping[str, Any]) -> None:
|
|
923
|
+
if "main" not in layout or "boss" not in layout or "workers" not in layout:
|
|
924
|
+
raise ValueError(
|
|
925
|
+
"tmux_manager.ensure_layout must return mapping with "
|
|
926
|
+
"'main', 'boss', and 'workers' keys"
|
|
927
|
+
)
|
|
928
|
+
workers = layout["workers"]
|
|
929
|
+
if not isinstance(workers, Sequence):
|
|
930
|
+
raise ValueError("layout['workers'] must be a sequence")
|
|
931
|
+
if len(workers) != self._worker_count:
|
|
932
|
+
raise ValueError(
|
|
933
|
+
"tmux_manager.ensure_layout returned "
|
|
934
|
+
f"{len(workers)} workers but {self._worker_count} expected"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _ensure_done_directive(
|
|
938
|
+
self,
|
|
939
|
+
instruction: str,
|
|
940
|
+
*,
|
|
941
|
+
location_notice: Optional[str] = None,
|
|
942
|
+
completion_flag: Optional[Path] = None,
|
|
943
|
+
) -> str:
|
|
944
|
+
if completion_flag is not None:
|
|
945
|
+
flag_text = str(completion_flag)
|
|
946
|
+
directive = (
|
|
947
|
+
"\n\nCompletion protocol:\n"
|
|
948
|
+
f"- When the entire task is complete, run `touch {flag_text}` (no markdown, single command).\n"
|
|
949
|
+
"- The host watches that file and will automatically continue once it exists—no `/done` line is required.\n"
|
|
950
|
+
f"- If you signaled completion too early, remove the flag with `rm -f {flag_text}` and keep working."
|
|
951
|
+
)
|
|
952
|
+
else:
|
|
953
|
+
directive = (
|
|
954
|
+
"\n\nCompletion protocol:\n"
|
|
955
|
+
"- After you finish the requested work and share any summary, you MUST send a new line containing only `/done`.\n"
|
|
956
|
+
"- Do not describe completion in prose or embed `/done` inside a sentence; the standalone `/done` line is mandatory.\n"
|
|
957
|
+
"Tasks are treated as unfinished until that literal `/done` line is sent."
|
|
958
|
+
)
|
|
959
|
+
notice = location_notice or self._worktree_location_notice()
|
|
960
|
+
|
|
961
|
+
parts = [instruction.rstrip()]
|
|
962
|
+
if notice and notice.strip() not in instruction:
|
|
963
|
+
parts.append(notice.rstrip())
|
|
964
|
+
if directive.strip() not in instruction:
|
|
965
|
+
parts.append(directive)
|
|
966
|
+
|
|
967
|
+
return "".join(parts)
|
|
968
|
+
|
|
969
|
+
def _auto_or_select(
|
|
970
|
+
self,
|
|
971
|
+
candidates: List[CandidateInfo],
|
|
972
|
+
completion_info: Mapping[str, Any],
|
|
973
|
+
selector: Optional[Callable[[List[CandidateInfo]], SelectionDecision]],
|
|
974
|
+
metrics: Optional[Mapping[str, Mapping[str, Any]]],
|
|
975
|
+
) -> tuple[SelectionDecision, Dict[str, Dict[str, Any]]]:
|
|
976
|
+
base_scoreboard = self._build_scoreboard(candidates, completion_info, metrics)
|
|
977
|
+
candidate_map = {candidate.key: candidate for candidate in candidates}
|
|
978
|
+
if selector is None:
|
|
979
|
+
raise RuntimeError(
|
|
980
|
+
"Selection requires a selector; automatic boss scoring is not available."
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
decision = selector(candidates, base_scoreboard)
|
|
985
|
+
except TypeError:
|
|
986
|
+
decision = selector(candidates)
|
|
987
|
+
|
|
988
|
+
selected_candidate = candidate_map.get(decision.selected_key)
|
|
989
|
+
if selected_candidate and selected_candidate.session_id:
|
|
990
|
+
refresh = getattr(self._monitor, "refresh_session_id", None)
|
|
991
|
+
if callable(refresh):
|
|
992
|
+
try:
|
|
993
|
+
resolved_id = refresh(selected_candidate.session_id)
|
|
994
|
+
except Exception:
|
|
995
|
+
resolved_id = selected_candidate.session_id
|
|
996
|
+
else:
|
|
997
|
+
if resolved_id and resolved_id != selected_candidate.session_id:
|
|
998
|
+
selected_candidate.session_id = resolved_id
|
|
999
|
+
entry = base_scoreboard.get(decision.selected_key)
|
|
1000
|
+
if entry is not None:
|
|
1001
|
+
entry["session_id"] = resolved_id
|
|
1002
|
+
|
|
1003
|
+
scoreboard = self._apply_selection(base_scoreboard, decision)
|
|
1004
|
+
return decision, scoreboard
|
|
1005
|
+
|
|
1006
|
+
def _build_scoreboard(
|
|
1007
|
+
self,
|
|
1008
|
+
candidates: List[CandidateInfo],
|
|
1009
|
+
completion_info: Mapping[str, Any],
|
|
1010
|
+
metrics: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
1011
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
1012
|
+
scoreboard: Dict[str, Dict[str, Any]] = {}
|
|
1013
|
+
for candidate in candidates:
|
|
1014
|
+
entry: Dict[str, Any] = {
|
|
1015
|
+
"score": None,
|
|
1016
|
+
"comment": "",
|
|
1017
|
+
"session_id": candidate.session_id,
|
|
1018
|
+
"branch": candidate.branch,
|
|
1019
|
+
"worktree": str(candidate.worktree),
|
|
1020
|
+
}
|
|
1021
|
+
if candidate.session_id and candidate.session_id in completion_info:
|
|
1022
|
+
entry.update(completion_info[candidate.session_id])
|
|
1023
|
+
if metrics and candidate.key in metrics:
|
|
1024
|
+
metric_entry = metrics[candidate.key]
|
|
1025
|
+
if "score" in metric_entry:
|
|
1026
|
+
try:
|
|
1027
|
+
entry["score"] = float(metric_entry["score"])
|
|
1028
|
+
except (TypeError, ValueError):
|
|
1029
|
+
entry["score"] = metric_entry.get("score")
|
|
1030
|
+
comment_text = metric_entry.get("comment")
|
|
1031
|
+
if comment_text:
|
|
1032
|
+
entry["comment"] = comment_text
|
|
1033
|
+
scoreboard[candidate.key] = entry
|
|
1034
|
+
return scoreboard
|
|
1035
|
+
|
|
1036
|
+
def _apply_selection(
|
|
1037
|
+
self,
|
|
1038
|
+
scoreboard: Dict[str, Dict[str, Any]],
|
|
1039
|
+
decision: SelectionDecision,
|
|
1040
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
1041
|
+
for key, comment in decision.comments.items():
|
|
1042
|
+
entry = scoreboard.setdefault(key, {})
|
|
1043
|
+
if comment:
|
|
1044
|
+
entry["comment"] = comment
|
|
1045
|
+
for key in scoreboard:
|
|
1046
|
+
entry = scoreboard[key]
|
|
1047
|
+
entry["selected"] = key == decision.selected_key
|
|
1048
|
+
return scoreboard
|
|
1049
|
+
|
|
1050
|
+
def _extract_boss_scores(self, boss_session_id: str) -> Dict[str, Dict[str, Any]]:
|
|
1051
|
+
raw = self._monitor.get_last_assistant_message(boss_session_id)
|
|
1052
|
+
if not raw:
|
|
1053
|
+
return {}
|
|
1054
|
+
|
|
1055
|
+
def _parse_json_from(raw_text: str) -> Optional[Dict[str, Any]]:
|
|
1056
|
+
# Try a direct parse first (handles single-line JSON responses).
|
|
1057
|
+
cleaned = raw_text.strip()
|
|
1058
|
+
if cleaned and cleaned != "/done":
|
|
1059
|
+
try:
|
|
1060
|
+
return json.loads(cleaned)
|
|
1061
|
+
except json.JSONDecodeError:
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
def _sanitize(line: str) -> str:
|
|
1065
|
+
stripped = line.strip()
|
|
1066
|
+
if stripped.startswith("• "):
|
|
1067
|
+
return stripped[2:]
|
|
1068
|
+
if stripped.startswith(('-', '*')) and len(stripped) > 1 and stripped[1] == ' ':
|
|
1069
|
+
return stripped[2:]
|
|
1070
|
+
return stripped
|
|
1071
|
+
|
|
1072
|
+
lines = [_sanitize(line) for line in raw_text.splitlines() if line.strip() and line.strip() != "/done"]
|
|
1073
|
+
buffer: List[str] = []
|
|
1074
|
+
depth = 0
|
|
1075
|
+
capturing = False
|
|
1076
|
+
for line in lines:
|
|
1077
|
+
if not capturing:
|
|
1078
|
+
if line.startswith('{'):
|
|
1079
|
+
capturing = True
|
|
1080
|
+
depth = 0
|
|
1081
|
+
buffer = []
|
|
1082
|
+
else:
|
|
1083
|
+
continue
|
|
1084
|
+
buffer.append(line)
|
|
1085
|
+
depth += line.count('{') - line.count('}')
|
|
1086
|
+
if depth <= 0:
|
|
1087
|
+
candidate = "\n".join(buffer)
|
|
1088
|
+
try:
|
|
1089
|
+
return json.loads(candidate)
|
|
1090
|
+
except json.JSONDecodeError:
|
|
1091
|
+
capturing = False
|
|
1092
|
+
buffer = []
|
|
1093
|
+
continue
|
|
1094
|
+
return None
|
|
1095
|
+
|
|
1096
|
+
data = _parse_json_from(raw)
|
|
1097
|
+
if data is None:
|
|
1098
|
+
return {
|
|
1099
|
+
"boss": {
|
|
1100
|
+
"score": None,
|
|
1101
|
+
"comment": f"Failed to parse boss output as JSON: {raw[:80]}...",
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
scores = data.get("scores")
|
|
1106
|
+
if not isinstance(scores, dict):
|
|
1107
|
+
return {}
|
|
1108
|
+
|
|
1109
|
+
metrics: Dict[str, Dict[str, Any]] = {}
|
|
1110
|
+
for key, value in scores.items():
|
|
1111
|
+
if not isinstance(value, dict):
|
|
1112
|
+
continue
|
|
1113
|
+
metrics[key] = {
|
|
1114
|
+
"score": value.get("score"),
|
|
1115
|
+
"comment": value.get("comment", ""),
|
|
1116
|
+
}
|
|
1117
|
+
return metrics
|
|
1118
|
+
|
|
1119
|
+
def _worktree_location_hint(self, role: Optional[str] = None) -> str:
|
|
1120
|
+
base_dir = getattr(self._worktree, "worktrees_dir", None)
|
|
1121
|
+
base_path: Optional[Path] = None
|
|
1122
|
+
if base_dir is not None:
|
|
1123
|
+
try:
|
|
1124
|
+
base_path = Path(base_dir)
|
|
1125
|
+
except TypeError:
|
|
1126
|
+
base_path = None
|
|
1127
|
+
if base_path is not None:
|
|
1128
|
+
target_path = base_path / role if role else base_path
|
|
1129
|
+
return str(target_path)
|
|
1130
|
+
namespace = getattr(self._worktree, "session_namespace", None)
|
|
1131
|
+
if namespace:
|
|
1132
|
+
base = f".parallel-dev/sessions/{namespace}/worktrees"
|
|
1133
|
+
else:
|
|
1134
|
+
base = ".parallel-dev/worktrees"
|
|
1135
|
+
if role:
|
|
1136
|
+
return f"{base}/{role}"
|
|
1137
|
+
return base
|
|
1138
|
+
|
|
1139
|
+
def _worktree_location_notice(self, role: Optional[str] = None, custom_path: Optional[Path] = None) -> str:
|
|
1140
|
+
hint = str(custom_path) if custom_path is not None else self._worktree_location_hint(role)
|
|
1141
|
+
target_path = str(custom_path) if custom_path is not None else hint
|
|
1142
|
+
return (
|
|
1143
|
+
"\n\nBefore you make any edits:\n"
|
|
1144
|
+
f"1. Run `pwd`. If the path does not contain `{hint}`, run `cd {target_path}`.\n"
|
|
1145
|
+
"2. Run `pwd` again to confirm you are now in the correct worktree.\n"
|
|
1146
|
+
"Keep every edit within this worktree and do not `cd` outside it.\n"
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def _build_main_fork_prompt(self) -> str:
|
|
1150
|
+
return "Fork"
|
|
1151
|
+
|
|
1152
|
+
def _build_boss_instruction(
|
|
1153
|
+
self,
|
|
1154
|
+
worker_names: Sequence[str],
|
|
1155
|
+
user_instruction: str,
|
|
1156
|
+
) -> str:
|
|
1157
|
+
worker_paths: Dict[str, Path] = getattr(self._worktree, "_worker_paths", {})
|
|
1158
|
+
worker_lines: List[str] = []
|
|
1159
|
+
for name in worker_names:
|
|
1160
|
+
path = worker_paths.get(name)
|
|
1161
|
+
if path is None:
|
|
1162
|
+
path = Path(self._worktree_location_hint(role=name))
|
|
1163
|
+
worker_lines.append(f"- {name} (worktree: {path})")
|
|
1164
|
+
worker_section = "\n".join(worker_lines)
|
|
1165
|
+
|
|
1166
|
+
instruction = (
|
|
1167
|
+
"Boss evaluation phase:\n"
|
|
1168
|
+
"You are the reviewer. The original user instruction was:\n"
|
|
1169
|
+
f"{user_instruction}\n\n"
|
|
1170
|
+
"Candidates:\n"
|
|
1171
|
+
f"{worker_section}\n\n"
|
|
1172
|
+
"Tasks:\n"
|
|
1173
|
+
"- Review each worker proposal and assess its quality.\n"
|
|
1174
|
+
"- For each candidate, assign a numeric score between 0 and 100 and provide a short comment.\n\n"
|
|
1175
|
+
"Evaluation checklist:\n"
|
|
1176
|
+
"- Confirm whether each worker obeyed the user instruction exactly.\n"
|
|
1177
|
+
"- Penalize answers that responded only with `/done` or omitted required content.\n"
|
|
1178
|
+
"- Note formatting mistakes or any extra/unrequested text.\n\n"
|
|
1179
|
+
"Respond with JSON only, using the structure:\n"
|
|
1180
|
+
"{\n"
|
|
1181
|
+
' "scores": {\n'
|
|
1182
|
+
' "worker-1": {"score": <number>, "comment": "<string>"},\n'
|
|
1183
|
+
" ... other candidates ...\n"
|
|
1184
|
+
" }\n"
|
|
1185
|
+
"}\n\n"
|
|
1186
|
+
"Output only the JSON object for the evaluation—do NOT return Markdown or prose at this stage.\n"
|
|
1187
|
+
)
|
|
1188
|
+
if self._boss_mode == BossMode.REWRITE:
|
|
1189
|
+
instruction += (
|
|
1190
|
+
"After you emit the JSON scoreboard, wait for the follow-up instructions to perform the final integration."
|
|
1191
|
+
)
|
|
1192
|
+
else:
|
|
1193
|
+
instruction += "After the JSON response, stop and wait for the host to continue."
|
|
1194
|
+
|
|
1195
|
+
notice = self._worktree_location_notice(role="boss", custom_path=self._worktree.boss_path)
|
|
1196
|
+
parts = [instruction.rstrip()]
|
|
1197
|
+
if notice and notice.strip() not in instruction:
|
|
1198
|
+
parts.append(notice)
|
|
1199
|
+
return "".join(parts)
|
|
1200
|
+
|
|
1201
|
+
def _build_boss_rewrite_followup(self, *, boss_flag: Path) -> str:
|
|
1202
|
+
if self._boss_mode != BossMode.REWRITE:
|
|
1203
|
+
return ""
|
|
1204
|
+
flag_text = str(boss_flag)
|
|
1205
|
+
return (
|
|
1206
|
+
"Boss integration phase:\n"
|
|
1207
|
+
"You have already produced the JSON scoreboard for the workers.\n"
|
|
1208
|
+
"Now stay in this boss workspace and deliver the final merged implementation.\n"
|
|
1209
|
+
"- Review the worker outputs you just scored and decide how to combine or refine them.\n"
|
|
1210
|
+
"- If one worker result is already ideal, copy it into this boss workspace; otherwise, refactor or merge the strongest parts.\n"
|
|
1211
|
+
f"When the integration is completely finished, run `touch {flag_text}` to signal completion.\n"
|
|
1212
|
+
f"If you need to continue editing after signaling, remove the flag with `rm -f {flag_text}` and keep working."
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
def _wait_for_boss_scores(self, boss_session_id: str, timeout: float = 120.0) -> Dict[str, Dict[str, Any]]:
|
|
1216
|
+
start = time.time()
|
|
1217
|
+
poll = getattr(self._monitor, "poll_interval", 1.0)
|
|
1218
|
+
try:
|
|
1219
|
+
interval = float(poll)
|
|
1220
|
+
if interval <= 0:
|
|
1221
|
+
interval = 1.0
|
|
1222
|
+
except (TypeError, ValueError):
|
|
1223
|
+
interval = 1.0
|
|
1224
|
+
metrics: Dict[str, Dict[str, Any]] = {}
|
|
1225
|
+
while time.time() - start < timeout:
|
|
1226
|
+
metrics = self._extract_boss_scores(boss_session_id)
|
|
1227
|
+
if metrics:
|
|
1228
|
+
break
|
|
1229
|
+
time.sleep(interval)
|
|
1230
|
+
return metrics
|
|
1231
|
+
|
|
1232
|
+
def _maybe_pause(self, env_var: str, message: str) -> None:
|
|
1233
|
+
if os.getenv(env_var) == "1":
|
|
1234
|
+
input(message)
|