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,1398 @@
|
|
|
1
|
+
"""Controller and orchestration helpers for the Sibyl CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from concurrent.futures import Future
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Set, Mapping, Awaitable, Union
|
|
13
|
+
import platform
|
|
14
|
+
import subprocess
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
from subprocess import PIPE
|
|
18
|
+
|
|
19
|
+
import git
|
|
20
|
+
|
|
21
|
+
from .commands import CommandOption, CommandSpecEntry, CommandSuggestion, build_command_specs
|
|
22
|
+
from .events import ControllerEventType
|
|
23
|
+
from .flow import WorkerFlowHelper
|
|
24
|
+
from .pause import PauseHelper
|
|
25
|
+
from .history import HistoryManager
|
|
26
|
+
from ..orchestrator import (
|
|
27
|
+
BossMode,
|
|
28
|
+
CandidateInfo,
|
|
29
|
+
CycleLayout,
|
|
30
|
+
OrchestrationResult,
|
|
31
|
+
Orchestrator,
|
|
32
|
+
SelectionDecision,
|
|
33
|
+
WorkerDecision,
|
|
34
|
+
MergeMode,
|
|
35
|
+
MergeOutcome,
|
|
36
|
+
)
|
|
37
|
+
from ..services import CodexMonitor, LogManager, TmuxLayoutManager, WorktreeManager
|
|
38
|
+
from ..stores import (
|
|
39
|
+
ManifestStore,
|
|
40
|
+
PaneRecord,
|
|
41
|
+
SessionManifest,
|
|
42
|
+
SessionReference,
|
|
43
|
+
SettingsStore,
|
|
44
|
+
default_config_dir,
|
|
45
|
+
resolve_settings_path,
|
|
46
|
+
resolve_worktree_root,
|
|
47
|
+
)
|
|
48
|
+
from .workflow_runner import WorkflowRunner
|
|
49
|
+
|
|
50
|
+
def _ensure_logs_directory(identifier: str) -> Path:
|
|
51
|
+
"""Create and return the logs directory for the given identifier."""
|
|
52
|
+
base_dir = default_config_dir() / "logs"
|
|
53
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
target = base_dir / identifier
|
|
55
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
return target
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionMode(str, Enum):
|
|
60
|
+
PARALLEL = "parallel"
|
|
61
|
+
MAIN = "main"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FlowMode(str, Enum):
|
|
65
|
+
MANUAL = "manual"
|
|
66
|
+
AUTO_REVIEW = "auto_review"
|
|
67
|
+
AUTO_SELECT = "auto_select"
|
|
68
|
+
FULL_AUTO = "full_auto"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
FLOW_MODE_LABELS = {
|
|
72
|
+
FlowMode.MANUAL: "Manual",
|
|
73
|
+
FlowMode.AUTO_REVIEW: "Auto Review",
|
|
74
|
+
FlowMode.AUTO_SELECT: "Auto Select",
|
|
75
|
+
FlowMode.FULL_AUTO: "Full Auto",
|
|
76
|
+
}
|
|
77
|
+
MERGE_STRATEGY_LABELS = {
|
|
78
|
+
MergeMode.MANUAL: "Manual",
|
|
79
|
+
MergeMode.AUTO: "Auto",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TmuxAttachManager:
|
|
84
|
+
"""Launch an external terminal to attach to the tmux session."""
|
|
85
|
+
|
|
86
|
+
def attach(self, session_name: str, workdir: Optional[Path] = None) -> subprocess.CompletedProcess:
|
|
87
|
+
system = platform.system().lower()
|
|
88
|
+
command_string = self._build_command_string(session_name, workdir)
|
|
89
|
+
|
|
90
|
+
if "darwin" in system:
|
|
91
|
+
escaped_command = self._escape_for_applescript(command_string)
|
|
92
|
+
apple_script = (
|
|
93
|
+
'tell application "Terminal"\n'
|
|
94
|
+
f' do script "{escaped_command}"\n'
|
|
95
|
+
" activate\n"
|
|
96
|
+
"end tell"
|
|
97
|
+
)
|
|
98
|
+
command = ["osascript", "-e", apple_script]
|
|
99
|
+
try:
|
|
100
|
+
return subprocess.run(command, check=False)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
# Fall through to generic fallback below.
|
|
103
|
+
pass
|
|
104
|
+
elif "linux" in system:
|
|
105
|
+
command = ["gnome-terminal", "--", "bash", "-lc", command_string]
|
|
106
|
+
try:
|
|
107
|
+
return subprocess.run(command, check=False)
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
# gnome-terminal not available; fall back to shell attach.
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
fallback_command: List[str]
|
|
113
|
+
if shutil.which("bash"):
|
|
114
|
+
fallback_command = ["bash", "-lc", command_string]
|
|
115
|
+
else:
|
|
116
|
+
fallback_command = ["tmux", "attach", "-t", session_name]
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
return subprocess.run(fallback_command, check=False)
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return subprocess.CompletedProcess(fallback_command, returncode=127)
|
|
122
|
+
|
|
123
|
+
def is_attached(self, session_name: str) -> bool:
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
[
|
|
127
|
+
"tmux",
|
|
128
|
+
"display-message",
|
|
129
|
+
"-t",
|
|
130
|
+
session_name,
|
|
131
|
+
"-p",
|
|
132
|
+
"#{session_attached}",
|
|
133
|
+
],
|
|
134
|
+
check=False,
|
|
135
|
+
stdout=PIPE,
|
|
136
|
+
stderr=PIPE,
|
|
137
|
+
text=True,
|
|
138
|
+
)
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
return False
|
|
144
|
+
output = (result.stdout or "").strip().lower()
|
|
145
|
+
return output in {"1", "true"}
|
|
146
|
+
|
|
147
|
+
def session_exists(self, session_name: str) -> bool:
|
|
148
|
+
try:
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
[
|
|
151
|
+
"tmux",
|
|
152
|
+
"has-session",
|
|
153
|
+
"-t",
|
|
154
|
+
session_name,
|
|
155
|
+
],
|
|
156
|
+
check=False,
|
|
157
|
+
stdout=PIPE,
|
|
158
|
+
stderr=PIPE,
|
|
159
|
+
text=True,
|
|
160
|
+
)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return False
|
|
163
|
+
return result.returncode == 0
|
|
164
|
+
|
|
165
|
+
def _build_command_string(self, session_name: str, workdir: Optional[Path]) -> str:
|
|
166
|
+
return f"tmux attach -t {shlex.quote(session_name)}"
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _escape_for_applescript(command: str) -> str:
|
|
170
|
+
return command.replace("\\", "\\\\").replace('"', '\\"')
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class SessionConfig:
|
|
175
|
+
session_id: str
|
|
176
|
+
tmux_session: str
|
|
177
|
+
worker_count: int
|
|
178
|
+
mode: SessionMode
|
|
179
|
+
logs_root: Path
|
|
180
|
+
boss_mode: BossMode = BossMode.SCORE
|
|
181
|
+
flow_mode: FlowMode = FlowMode.MANUAL
|
|
182
|
+
reuse_existing_session: bool = False
|
|
183
|
+
merge_mode: MergeMode = MergeMode.MANUAL
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class SelectionContext:
|
|
187
|
+
future: Future
|
|
188
|
+
candidates: List[CandidateInfo]
|
|
189
|
+
scoreboard: Dict[str, Dict[str, object]]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CLIController:
|
|
193
|
+
"""Core orchestration controller decoupled from Textual UI."""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
event_handler: Callable[[str, Dict[str, object]], None],
|
|
199
|
+
orchestrator_builder: Callable[..., Orchestrator] = None,
|
|
200
|
+
manifest_store: Optional[ManifestStore] = None,
|
|
201
|
+
worktree_root: Optional[Path] = None,
|
|
202
|
+
settings_path: Optional[Path] = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
self._event_handler = event_handler
|
|
205
|
+
self._builder = orchestrator_builder or self._default_builder
|
|
206
|
+
self._manifest_store = manifest_store or ManifestStore()
|
|
207
|
+
self._worktree_root = Path(worktree_root or Path.cwd())
|
|
208
|
+
self._config = self._create_initial_config()
|
|
209
|
+
self._last_scoreboard: Dict[str, Dict[str, object]] = {}
|
|
210
|
+
self._last_instruction: Optional[str] = None
|
|
211
|
+
self._running: bool = False
|
|
212
|
+
self._selection_context: Optional[SelectionContext] = None
|
|
213
|
+
self._resume_options: List[SessionReference] = []
|
|
214
|
+
self._last_selected_session: Optional[str] = None
|
|
215
|
+
self._active_main_session_id: Optional[str] = None
|
|
216
|
+
self._paused: bool = False
|
|
217
|
+
self._history = HistoryManager()
|
|
218
|
+
self._cycle_counter: int = 0
|
|
219
|
+
self._current_cycle_id: Optional[int] = None
|
|
220
|
+
self._cancelled_cycles: Set[int] = set()
|
|
221
|
+
self._last_tmux_manager: Optional[TmuxLayoutManager] = None
|
|
222
|
+
self._active_orchestrator: Optional[Orchestrator] = None
|
|
223
|
+
self._queued_instruction: Optional[str] = None
|
|
224
|
+
self._continue_future: Optional[Future] = None
|
|
225
|
+
self._continuation_input_future: Optional[Future] = None
|
|
226
|
+
self._awaiting_continuation_input: bool = False
|
|
227
|
+
self._attach_manager = TmuxAttachManager()
|
|
228
|
+
explicit_settings_path = Path(settings_path).expanduser() if settings_path else None
|
|
229
|
+
resolved_settings_path = resolve_settings_path(explicit_settings_path)
|
|
230
|
+
self._settings_store = SettingsStore(resolved_settings_path)
|
|
231
|
+
self._worktree_storage_root = resolve_worktree_root(
|
|
232
|
+
self._settings_store.worktree_root,
|
|
233
|
+
self._worktree_root,
|
|
234
|
+
)
|
|
235
|
+
self._attach_mode: str = self._settings_store.attach
|
|
236
|
+
saved_boss_mode = self._settings_store.boss
|
|
237
|
+
try:
|
|
238
|
+
self._config.boss_mode = BossMode(saved_boss_mode)
|
|
239
|
+
except ValueError:
|
|
240
|
+
self._config.boss_mode = BossMode.SCORE
|
|
241
|
+
saved_flow_mode = self._settings_store.flow
|
|
242
|
+
try:
|
|
243
|
+
self._flow_mode = FlowMode(saved_flow_mode)
|
|
244
|
+
except ValueError:
|
|
245
|
+
self._flow_mode = FlowMode.FULL_AUTO
|
|
246
|
+
self._config.flow_mode = self._flow_mode
|
|
247
|
+
saved_merge_mode = self._settings_store.merge
|
|
248
|
+
try:
|
|
249
|
+
self._merge_mode = MergeMode(saved_merge_mode)
|
|
250
|
+
except ValueError:
|
|
251
|
+
self._merge_mode = MergeMode.AUTO
|
|
252
|
+
self._config.merge_mode = self._merge_mode
|
|
253
|
+
self._auto_commit_enabled: bool = self._settings_store.commit == "auto"
|
|
254
|
+
try:
|
|
255
|
+
self._config.worker_count = max(1, int(self._settings_store.parallel or "3"))
|
|
256
|
+
except ValueError:
|
|
257
|
+
self._config.worker_count = 3
|
|
258
|
+
try:
|
|
259
|
+
self._config.mode = SessionMode(self._settings_store.mode)
|
|
260
|
+
except ValueError:
|
|
261
|
+
self._config.mode = SessionMode.PARALLEL
|
|
262
|
+
self._session_namespace: str = self._config.session_id
|
|
263
|
+
self._last_started_main_session_id: Optional[str] = None
|
|
264
|
+
self._pre_cycle_selected_session: Optional[str] = None
|
|
265
|
+
self._pre_cycle_selected_session_set: bool = False
|
|
266
|
+
self._command_specs: Dict[str, CommandSpecEntry] = build_command_specs(
|
|
267
|
+
self,
|
|
268
|
+
flow_mode_cls=FlowMode,
|
|
269
|
+
boss_mode_cls=BossMode,
|
|
270
|
+
merge_mode_cls=MergeMode,
|
|
271
|
+
)
|
|
272
|
+
self._worker_flow = WorkerFlowHelper(self, FlowMode)
|
|
273
|
+
self._pause_helper = PauseHelper(self)
|
|
274
|
+
self._log_hook = self._handle_orchestrator_log
|
|
275
|
+
self._workflow = WorkflowRunner(self)
|
|
276
|
+
|
|
277
|
+
async def handle_input(self, user_input: str) -> None:
|
|
278
|
+
raw_text = user_input.rstrip("\n")
|
|
279
|
+
|
|
280
|
+
if self._awaiting_continuation_input:
|
|
281
|
+
stripped = raw_text.strip()
|
|
282
|
+
if not stripped:
|
|
283
|
+
self._emit(ControllerEventType.LOG, {"text": "追加指示が空です。何をするか具体的に入力してください。"})
|
|
284
|
+
return
|
|
285
|
+
if stripped.startswith("/"):
|
|
286
|
+
self._emit(ControllerEventType.LOG, {"text": "追加指示入力中です。コマンドではなくテキストで指示を入力してください。"})
|
|
287
|
+
return
|
|
288
|
+
self._submit_continuation_instruction(raw_text)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
text = raw_text.strip()
|
|
292
|
+
if not text:
|
|
293
|
+
return
|
|
294
|
+
if text.startswith("/"):
|
|
295
|
+
self._record_history(text)
|
|
296
|
+
await self._execute_text_command(text)
|
|
297
|
+
return
|
|
298
|
+
if self._paused:
|
|
299
|
+
self._record_history(text)
|
|
300
|
+
await self._dispatch_paused_instruction(text)
|
|
301
|
+
return
|
|
302
|
+
if self._running:
|
|
303
|
+
if self._current_cycle_id and self._current_cycle_id in self._cancelled_cycles:
|
|
304
|
+
self._queued_instruction = text
|
|
305
|
+
self._emit(ControllerEventType.LOG, {"text": "キャンセル処理中です。完了後にこの指示を実行します。"})
|
|
306
|
+
return
|
|
307
|
+
if self._running:
|
|
308
|
+
self._emit(ControllerEventType.LOG, {"text": "別の指示を処理中です。完了を待ってから再度実行してください。"})
|
|
309
|
+
return
|
|
310
|
+
self._record_history(text)
|
|
311
|
+
await self._run_instruction(text)
|
|
312
|
+
|
|
313
|
+
async def _execute_text_command(self, command_text: str) -> None:
|
|
314
|
+
parts = command_text.split(maxsplit=1)
|
|
315
|
+
if not parts:
|
|
316
|
+
return
|
|
317
|
+
name = parts[0].lower()
|
|
318
|
+
if name == "/quit":
|
|
319
|
+
name = "/exit"
|
|
320
|
+
if name == "/marge":
|
|
321
|
+
name = "/merge"
|
|
322
|
+
option = parts[1].strip() if len(parts) > 1 else None
|
|
323
|
+
if option == "":
|
|
324
|
+
option = None
|
|
325
|
+
await self.execute_command(name, option)
|
|
326
|
+
|
|
327
|
+
def get_command_suggestions(self, prefix: str) -> List[CommandSuggestion]:
|
|
328
|
+
prefix = (prefix or "/").lower()
|
|
329
|
+
if not prefix.startswith("/"):
|
|
330
|
+
prefix = "/" + prefix
|
|
331
|
+
suggestions: List[CommandSuggestion] = []
|
|
332
|
+
for name in sorted(self._command_specs.keys()):
|
|
333
|
+
if name.startswith(prefix):
|
|
334
|
+
spec = self._command_specs[name]
|
|
335
|
+
suggestions.append(CommandSuggestion(name=name, description=spec.description))
|
|
336
|
+
if not suggestions and prefix == "/":
|
|
337
|
+
for name in sorted(self._command_specs.keys()):
|
|
338
|
+
spec = self._command_specs[name]
|
|
339
|
+
suggestions.append(CommandSuggestion(name=name, description=spec.description))
|
|
340
|
+
return suggestions
|
|
341
|
+
|
|
342
|
+
def get_command_options(self, name: str) -> List[CommandOption]:
|
|
343
|
+
spec = self._command_specs.get(name)
|
|
344
|
+
if not spec:
|
|
345
|
+
return []
|
|
346
|
+
options: List[CommandOption] = []
|
|
347
|
+
if spec.options_provider:
|
|
348
|
+
options = spec.options_provider()
|
|
349
|
+
elif spec.options:
|
|
350
|
+
options = list(spec.options)
|
|
351
|
+
return options
|
|
352
|
+
|
|
353
|
+
async def execute_command(self, name: str, option: Optional[object] = None) -> None:
|
|
354
|
+
spec = self._command_specs.get(name)
|
|
355
|
+
if spec is None:
|
|
356
|
+
self._emit(ControllerEventType.LOG, {"text": f"未知のコマンドです: {name}"})
|
|
357
|
+
return
|
|
358
|
+
await spec.handler(option)
|
|
359
|
+
|
|
360
|
+
async def _cmd_exit(self, option: Optional[object]) -> None:
|
|
361
|
+
self._emit(ControllerEventType.QUIT, {})
|
|
362
|
+
|
|
363
|
+
async def _cmd_help(self, option: Optional[object]) -> None:
|
|
364
|
+
lines = ["利用可能なコマンド:"]
|
|
365
|
+
for name in sorted(self._command_specs.keys()):
|
|
366
|
+
spec = self._command_specs[name]
|
|
367
|
+
lines.append(f" {name:10s} : {spec.description}")
|
|
368
|
+
self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
|
|
369
|
+
|
|
370
|
+
async def _cmd_status(self, option: Optional[object]) -> None:
|
|
371
|
+
self._emit_status("待機中")
|
|
372
|
+
|
|
373
|
+
async def _cmd_scoreboard(self, option: Optional[object]) -> None:
|
|
374
|
+
self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": self._last_scoreboard})
|
|
375
|
+
|
|
376
|
+
async def _cmd_done(self, option: Optional[object]) -> None:
|
|
377
|
+
if self._continue_future and not self._continue_future.done():
|
|
378
|
+
self._continue_future.set_result("done")
|
|
379
|
+
self._emit(ControllerEventType.LOG, {"text": "/done を受け付けました。採点フェーズへ移行します。"})
|
|
380
|
+
return
|
|
381
|
+
if self._active_orchestrator:
|
|
382
|
+
count = self._active_orchestrator.force_complete_workers()
|
|
383
|
+
if count:
|
|
384
|
+
self._emit(ControllerEventType.LOG, {"text": f"/done を検知として扱い、{count} ワーカーを完了済みに設定しました。"})
|
|
385
|
+
else:
|
|
386
|
+
self._emit(ControllerEventType.LOG, {"text": "完了扱いにするワーカーセッションが見つかりませんでした。"})
|
|
387
|
+
else:
|
|
388
|
+
self._emit(ControllerEventType.LOG, {"text": "現在進行中のワーカークセッションがないため /done を適用できません。"})
|
|
389
|
+
|
|
390
|
+
async def _cmd_continue(self, option: Optional[object]) -> None:
|
|
391
|
+
if self._continue_future and not self._continue_future.done():
|
|
392
|
+
self._continue_future.set_result("continue")
|
|
393
|
+
self._emit(ControllerEventType.LOG, {"text": "/continue を受け付けました。追加指示を入力してください。"})
|
|
394
|
+
else:
|
|
395
|
+
self._emit(ControllerEventType.LOG, {"text": "/continue は現在利用できません。"})
|
|
396
|
+
|
|
397
|
+
def _await_worker_command(self) -> str:
|
|
398
|
+
future = Future()
|
|
399
|
+
self._continue_future = future
|
|
400
|
+
self._emit(
|
|
401
|
+
ControllerEventType.LOG,
|
|
402
|
+
{
|
|
403
|
+
"text": (
|
|
404
|
+
"ワーカーの処理が完了しました。追加で作業させるには /continue を、"
|
|
405
|
+
"評価へ進むには /done を入力してください。"
|
|
406
|
+
)
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
try:
|
|
410
|
+
decision = future.result()
|
|
411
|
+
finally:
|
|
412
|
+
self._continue_future = None
|
|
413
|
+
return str(decision)
|
|
414
|
+
|
|
415
|
+
def _await_continuation_instruction(self) -> str:
|
|
416
|
+
future = Future()
|
|
417
|
+
self._continuation_input_future = future
|
|
418
|
+
self._awaiting_continuation_input = True
|
|
419
|
+
self._emit(
|
|
420
|
+
ControllerEventType.LOG,
|
|
421
|
+
{"text": "追加指示を入力してください。完了したらワーカーのフラグ更新を待ちます。"},
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
instruction = future.result()
|
|
425
|
+
finally:
|
|
426
|
+
self._continuation_input_future = None
|
|
427
|
+
self._awaiting_continuation_input = False
|
|
428
|
+
return str(instruction).strip()
|
|
429
|
+
|
|
430
|
+
def _submit_continuation_instruction(self, instruction: str) -> None:
|
|
431
|
+
future = self._continuation_input_future
|
|
432
|
+
self._continuation_input_future = None
|
|
433
|
+
self._awaiting_continuation_input = False
|
|
434
|
+
if future and not future.done():
|
|
435
|
+
future.set_result(instruction.strip())
|
|
436
|
+
self._emit(ControllerEventType.LOG, {"text": "追加指示を受け付けました。ワーカーの完了を待ちます。"})
|
|
437
|
+
|
|
438
|
+
async def _cmd_boss(self, option: Optional[object]) -> None:
|
|
439
|
+
if option is None:
|
|
440
|
+
mode = self._config.boss_mode.value
|
|
441
|
+
self._emit(
|
|
442
|
+
ControllerEventType.LOG,
|
|
443
|
+
{
|
|
444
|
+
"text": (
|
|
445
|
+
"現在の Boss モードは {mode} です。"
|
|
446
|
+
" (skip=採点スキップ, score=採点のみ, rewrite=再実装)"
|
|
447
|
+
).format(mode=mode)
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
return
|
|
451
|
+
value = str(option).strip()
|
|
452
|
+
mapping = {
|
|
453
|
+
"skip": BossMode.SKIP,
|
|
454
|
+
"score": BossMode.SCORE,
|
|
455
|
+
"rewrite": BossMode.REWRITE,
|
|
456
|
+
}
|
|
457
|
+
new_mode = mapping.get(value.lower())
|
|
458
|
+
if new_mode is None:
|
|
459
|
+
# Treat unknown arguments as regular instructions for compatibility
|
|
460
|
+
await self.handle_input(f"{value}")
|
|
461
|
+
return
|
|
462
|
+
if new_mode == self._config.boss_mode:
|
|
463
|
+
self._emit(ControllerEventType.LOG, {"text": f"Boss モードは既に {new_mode.value} です。"})
|
|
464
|
+
return
|
|
465
|
+
self._config.boss_mode = new_mode
|
|
466
|
+
self._settings_store.boss = new_mode.value
|
|
467
|
+
self._emit(ControllerEventType.LOG, {"text": f"Boss モードを {new_mode.value} に設定しました。"})
|
|
468
|
+
|
|
469
|
+
async def _cmd_flow(self, option: Optional[object]) -> None:
|
|
470
|
+
if option is None:
|
|
471
|
+
lines = [
|
|
472
|
+
f"現在のフローモード: {self._flow_mode_display()}",
|
|
473
|
+
"利用可能なモード:",
|
|
474
|
+
]
|
|
475
|
+
for mode in FlowMode:
|
|
476
|
+
lines.append(f" {mode.value:12s} : {self._flow_mode_display(mode)}")
|
|
477
|
+
lines.append("使い方: /flow [manual|auto_review|auto_select|full_auto]")
|
|
478
|
+
self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
token = str(option).strip().lower().replace("-", "_")
|
|
482
|
+
mapping = {mode.value: mode for mode in FlowMode}
|
|
483
|
+
new_mode = mapping.get(token)
|
|
484
|
+
if new_mode is None:
|
|
485
|
+
self._emit(
|
|
486
|
+
ControllerEventType.LOG,
|
|
487
|
+
{"text": "使い方: /flow [manual|auto_review|auto_select|full_auto]"},
|
|
488
|
+
)
|
|
489
|
+
return
|
|
490
|
+
if new_mode == getattr(self, "_flow_mode", FlowMode.MANUAL):
|
|
491
|
+
self._emit(ControllerEventType.LOG, {"text": f"フローモードは既に {self._flow_mode_display(new_mode)} です。"})
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
self._flow_mode = new_mode
|
|
495
|
+
self._config.flow_mode = new_mode
|
|
496
|
+
self._settings_store.flow = new_mode.value
|
|
497
|
+
self._emit(ControllerEventType.LOG, {"text": f"フローモードを {self._flow_mode_display(new_mode)} に設定しました。"})
|
|
498
|
+
self._emit_status("待機中")
|
|
499
|
+
|
|
500
|
+
async def _cmd_merge(self, option: Optional[object]) -> None:
|
|
501
|
+
if option is None:
|
|
502
|
+
lines = [
|
|
503
|
+
f"現在のマージ戦略: {self._merge_strategy_display()}",
|
|
504
|
+
"利用可能な戦略:",
|
|
505
|
+
]
|
|
506
|
+
for strategy in MergeMode:
|
|
507
|
+
lines.append(f" {strategy.value:16s} : {self._merge_strategy_display(strategy)}")
|
|
508
|
+
lines.append("使い方: /merge [manual|auto]")
|
|
509
|
+
self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
token = str(option).strip().lower().replace("-", "_")
|
|
513
|
+
mapping = {strategy.value: strategy for strategy in MergeMode}
|
|
514
|
+
new_strategy = mapping.get(token)
|
|
515
|
+
if new_strategy is None:
|
|
516
|
+
self._emit(ControllerEventType.LOG, {"text": "使い方: /merge [manual|auto]"})
|
|
517
|
+
return
|
|
518
|
+
if new_strategy == getattr(self, "_merge_mode", MergeMode.MANUAL):
|
|
519
|
+
self._emit(ControllerEventType.LOG, {"text": f"マージ戦略は既に {self._merge_strategy_display(new_strategy)} です。"})
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
self._merge_mode = new_strategy
|
|
523
|
+
self._config.merge_mode = new_strategy
|
|
524
|
+
self._settings_store.merge = new_strategy.value
|
|
525
|
+
self._emit(ControllerEventType.LOG, {"text": f"マージ戦略を {self._merge_strategy_display(new_strategy)} に設定しました。"})
|
|
526
|
+
self._emit_status("待機中")
|
|
527
|
+
|
|
528
|
+
async def _cmd_attach(self, option: Optional[object]) -> None:
|
|
529
|
+
mode = str(option).lower() if option is not None else None
|
|
530
|
+
if mode in {"auto", "manual"}:
|
|
531
|
+
self._attach_mode = mode
|
|
532
|
+
self._emit(ControllerEventType.LOG, {"text": f"/attach モードを {mode} に設定しました。"})
|
|
533
|
+
self._settings_store.attach = mode
|
|
534
|
+
return
|
|
535
|
+
if mode == "now" or option is None:
|
|
536
|
+
await self._handle_attach_command(force=True)
|
|
537
|
+
return
|
|
538
|
+
self._emit(ControllerEventType.LOG, {"text": "使い方: /attach [auto|manual|now]"})
|
|
539
|
+
|
|
540
|
+
async def _cmd_parallel(self, option: Optional[object]) -> None:
|
|
541
|
+
if option is None:
|
|
542
|
+
self._emit(ControllerEventType.LOG, {"text": "使い方: /parallel <ワーカー数>"})
|
|
543
|
+
return
|
|
544
|
+
try:
|
|
545
|
+
value = int(str(option))
|
|
546
|
+
except ValueError:
|
|
547
|
+
self._emit(ControllerEventType.LOG, {"text": "ワーカー数は数字で指定してください。"})
|
|
548
|
+
return
|
|
549
|
+
if value < 1:
|
|
550
|
+
self._emit(ControllerEventType.LOG, {"text": "ワーカー数は1以上で指定してください。"})
|
|
551
|
+
return
|
|
552
|
+
self._config.worker_count = value
|
|
553
|
+
self._settings_store.parallel = str(value)
|
|
554
|
+
self._emit_status("設定を更新しました。")
|
|
555
|
+
|
|
556
|
+
async def _cmd_mode(self, option: Optional[object]) -> None:
|
|
557
|
+
mode = str(option).lower() if option is not None else None
|
|
558
|
+
if mode not in {"main", "parallel"}:
|
|
559
|
+
self._emit(ControllerEventType.LOG, {"text": "使い方: /mode main | /mode parallel"})
|
|
560
|
+
return
|
|
561
|
+
self._config.mode = SessionMode(mode)
|
|
562
|
+
self._settings_store.mode = mode
|
|
563
|
+
self._emit_status("設定を更新しました。")
|
|
564
|
+
|
|
565
|
+
async def _cmd_resume(self, option: Optional[object]) -> None:
|
|
566
|
+
if option is None:
|
|
567
|
+
self._list_sessions()
|
|
568
|
+
return
|
|
569
|
+
index: Optional[int] = None
|
|
570
|
+
if isinstance(option, int):
|
|
571
|
+
index = option
|
|
572
|
+
else:
|
|
573
|
+
try:
|
|
574
|
+
index = int(str(option))
|
|
575
|
+
except ValueError:
|
|
576
|
+
index = self._find_resume_index_by_session(str(option))
|
|
577
|
+
if index is None:
|
|
578
|
+
self._emit(ControllerEventType.LOG, {"text": "指定されたセッションが見つかりません。"})
|
|
579
|
+
return
|
|
580
|
+
self._load_session(index)
|
|
581
|
+
|
|
582
|
+
async def _cmd_log(self, option: Optional[object]) -> None:
|
|
583
|
+
if option is None:
|
|
584
|
+
self._emit(
|
|
585
|
+
ControllerEventType.LOG,
|
|
586
|
+
{
|
|
587
|
+
"text": "使い方: /log copy | /log save <path>\n"
|
|
588
|
+
" copy : 現在のログをクリップボードへコピー\n"
|
|
589
|
+
" save : 指定パスへログを書き出す"
|
|
590
|
+
},
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
action: str
|
|
594
|
+
argument: Optional[str] = None
|
|
595
|
+
if isinstance(option, str):
|
|
596
|
+
sub_parts = option.split(maxsplit=1)
|
|
597
|
+
action = sub_parts[0].lower()
|
|
598
|
+
if len(sub_parts) > 1:
|
|
599
|
+
argument = sub_parts[1].strip()
|
|
600
|
+
else:
|
|
601
|
+
action = str(option).lower()
|
|
602
|
+
if action == "copy":
|
|
603
|
+
self._emit(ControllerEventType.LOG_COPY, {})
|
|
604
|
+
return
|
|
605
|
+
if action == "save":
|
|
606
|
+
if not argument:
|
|
607
|
+
self._emit(ControllerEventType.LOG, {"text": "保存先パスを指定してください。例: /log save logs/output.log"})
|
|
608
|
+
return
|
|
609
|
+
self._emit(ControllerEventType.LOG_SAVE, {"path": argument})
|
|
610
|
+
return
|
|
611
|
+
self._emit(ControllerEventType.LOG, {"text": "使い方: /log copy | /log save <path>"})
|
|
612
|
+
|
|
613
|
+
async def _cmd_commit(self, option: Optional[object]) -> None:
|
|
614
|
+
mode = "manual"
|
|
615
|
+
if option is not None:
|
|
616
|
+
mode = str(option).lower()
|
|
617
|
+
if mode == "manual":
|
|
618
|
+
self._auto_commit_enabled = False
|
|
619
|
+
self._settings_store.commit = "manual"
|
|
620
|
+
self._perform_commit(auto=False, quiet_when_no_change=False)
|
|
621
|
+
return
|
|
622
|
+
if mode == "auto":
|
|
623
|
+
if self._auto_commit_enabled:
|
|
624
|
+
self._auto_commit_enabled = False
|
|
625
|
+
self._settings_store.commit = "manual"
|
|
626
|
+
self._emit(ControllerEventType.LOG, {"text": "自動コミットを無効にしました。"})
|
|
627
|
+
else:
|
|
628
|
+
self._auto_commit_enabled = True
|
|
629
|
+
self._settings_store.commit = "auto"
|
|
630
|
+
self._emit(ControllerEventType.LOG, {"text": "自動コミットを有効にしました。"})
|
|
631
|
+
self._perform_commit(auto=True, quiet_when_no_change=True)
|
|
632
|
+
return
|
|
633
|
+
self._emit(ControllerEventType.LOG, {"text": "/commit は manual または auto を指定してください。"})
|
|
634
|
+
|
|
635
|
+
def _find_resume_index_by_session(self, token: str) -> Optional[int]:
|
|
636
|
+
if not self._resume_options:
|
|
637
|
+
self._resume_options = self._manifest_store.list_sessions()
|
|
638
|
+
for idx, ref in enumerate(self._resume_options, start=1):
|
|
639
|
+
if ref.session_id == token or ref.session_id.startswith(token):
|
|
640
|
+
return idx
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
def broadcast_escape(self) -> None:
|
|
644
|
+
session_name = self._config.tmux_session
|
|
645
|
+
pane_ids = self._tmux_list_panes()
|
|
646
|
+
if pane_ids is None:
|
|
647
|
+
return
|
|
648
|
+
if not pane_ids:
|
|
649
|
+
self._emit(ControllerEventType.LOG, {"text": f"tmuxセッション {session_name} にペインが見つかりませんでした。"})
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
for pane_id in pane_ids:
|
|
653
|
+
subprocess.run(
|
|
654
|
+
["tmux", "send-keys", "-t", pane_id, "Escape"],
|
|
655
|
+
check=False,
|
|
656
|
+
)
|
|
657
|
+
self._emit(ControllerEventType.LOG, {"text": f"tmuxセッション {session_name} の {len(pane_ids)} 個のペインへEscapeを送信しました。"})
|
|
658
|
+
|
|
659
|
+
def handle_escape(self) -> None:
|
|
660
|
+
self._pause_helper.handle_escape()
|
|
661
|
+
def _tmux_list_panes(self) -> Optional[List[str]]:
|
|
662
|
+
session_name = self._config.tmux_session
|
|
663
|
+
try:
|
|
664
|
+
result = subprocess.run(
|
|
665
|
+
["tmux", "list-panes", "-t", session_name, "-F", "#{pane_id}"],
|
|
666
|
+
check=False,
|
|
667
|
+
stdout=PIPE,
|
|
668
|
+
stderr=PIPE,
|
|
669
|
+
text=True,
|
|
670
|
+
)
|
|
671
|
+
except FileNotFoundError:
|
|
672
|
+
self._emit(ControllerEventType.LOG, {"text": "tmux コマンドが見つかりません。tmuxがインストールされているか確認してください。"})
|
|
673
|
+
return None
|
|
674
|
+
if result.returncode != 0:
|
|
675
|
+
message = (result.stderr or result.stdout or "").strip()
|
|
676
|
+
if message:
|
|
677
|
+
self._emit(ControllerEventType.LOG, {"text": f"tmux list-panes に失敗しました: {message}"})
|
|
678
|
+
return None
|
|
679
|
+
return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
|
680
|
+
|
|
681
|
+
async def _dispatch_paused_instruction(self, instruction: str) -> None:
|
|
682
|
+
await self._pause_helper.dispatch_paused_instruction(instruction)
|
|
683
|
+
|
|
684
|
+
def _record_cycle_snapshot(self, result: OrchestrationResult, cycle_id: int) -> None:
|
|
685
|
+
snapshot = {
|
|
686
|
+
"cycle_id": cycle_id,
|
|
687
|
+
"selected_session": result.selected_session,
|
|
688
|
+
"scoreboard": dict(result.sessions_summary),
|
|
689
|
+
"instruction": self._last_instruction,
|
|
690
|
+
}
|
|
691
|
+
self._history.record_cycle_snapshot(result, cycle_id, self._last_instruction)
|
|
692
|
+
|
|
693
|
+
def _handle_worker_decision(
|
|
694
|
+
self,
|
|
695
|
+
fork_map: Mapping[str, str],
|
|
696
|
+
completion_info: Mapping[str, Any],
|
|
697
|
+
layout: CycleLayout,
|
|
698
|
+
) -> WorkerDecision:
|
|
699
|
+
return self._worker_flow.handle_worker_decision(fork_map, completion_info, layout)
|
|
700
|
+
|
|
701
|
+
def _record_history(self, text: str) -> None:
|
|
702
|
+
self._history.record_input(text)
|
|
703
|
+
|
|
704
|
+
def _perform_commit(self, *, auto: bool, quiet_when_no_change: bool) -> bool:
|
|
705
|
+
try:
|
|
706
|
+
repo = git.Repo(self._worktree_root)
|
|
707
|
+
except git.exc.InvalidGitRepositoryError:
|
|
708
|
+
self._emit(ControllerEventType.LOG, {"text": "Gitリポジトリが存在しません。`git init` を実行してください。"})
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
if not repo.is_dirty(untracked_files=True):
|
|
712
|
+
if not quiet_when_no_change:
|
|
713
|
+
self._emit(ControllerEventType.LOG, {"text": "コミット対象の変更がありません。"})
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
repo.git.add(A=True)
|
|
718
|
+
except Exception as exc: # noqa: BLE001
|
|
719
|
+
self._emit(ControllerEventType.LOG, {"text": f"git add に失敗しました: {exc}"})
|
|
720
|
+
return False
|
|
721
|
+
|
|
722
|
+
prefix = "sibyl-auto-save" if auto else "sibyl-manual-save"
|
|
723
|
+
message = f"{prefix} {datetime.utcnow().isoformat(timespec='seconds')}Z"
|
|
724
|
+
try:
|
|
725
|
+
repo.index.commit(message)
|
|
726
|
+
except Exception as exc: # noqa: BLE001
|
|
727
|
+
self._emit(ControllerEventType.LOG, {"text": f"コミットに失敗しました: {exc}"})
|
|
728
|
+
return False
|
|
729
|
+
|
|
730
|
+
self._emit(ControllerEventType.LOG, {"text": f"変更をコミットしました: {message}"})
|
|
731
|
+
return True
|
|
732
|
+
|
|
733
|
+
def _maybe_auto_commit(self) -> None:
|
|
734
|
+
if not getattr(self, "_auto_commit_enabled", False):
|
|
735
|
+
return
|
|
736
|
+
self._perform_commit(auto=True, quiet_when_no_change=True)
|
|
737
|
+
|
|
738
|
+
def _request_selection(self, candidates: List[CandidateInfo], scoreboard: Optional[Dict[str, Dict[str, object]]] = None) -> Future:
|
|
739
|
+
future: Future = Future()
|
|
740
|
+
context = SelectionContext(
|
|
741
|
+
future=future,
|
|
742
|
+
candidates=candidates,
|
|
743
|
+
scoreboard=scoreboard or {},
|
|
744
|
+
)
|
|
745
|
+
self._selection_context = context
|
|
746
|
+
formatted = [f"{idx + 1}. {candidate.label}" for idx, candidate in enumerate(candidates)]
|
|
747
|
+
self._emit(
|
|
748
|
+
"selection_request",
|
|
749
|
+
{
|
|
750
|
+
"candidates": formatted,
|
|
751
|
+
"scoreboard": scoreboard or {},
|
|
752
|
+
},
|
|
753
|
+
)
|
|
754
|
+
return future
|
|
755
|
+
|
|
756
|
+
def _select_candidates(
|
|
757
|
+
self,
|
|
758
|
+
candidates: List[CandidateInfo],
|
|
759
|
+
scoreboard: Optional[Dict[str, Dict[str, object]]] = None,
|
|
760
|
+
) -> SelectionDecision:
|
|
761
|
+
candidates, scoreboard = self._prune_boss_candidates(candidates, scoreboard)
|
|
762
|
+
|
|
763
|
+
mode = getattr(self, "_flow_mode", FlowMode.MANUAL)
|
|
764
|
+
if mode in {FlowMode.MANUAL, FlowMode.AUTO_REVIEW}:
|
|
765
|
+
future = self._request_selection(candidates, scoreboard)
|
|
766
|
+
return future.result()
|
|
767
|
+
|
|
768
|
+
decision = self._auto_select_decision(candidates, scoreboard or {})
|
|
769
|
+
if decision is None:
|
|
770
|
+
future = self._request_selection(candidates, scoreboard)
|
|
771
|
+
return future.result()
|
|
772
|
+
|
|
773
|
+
candidate_map = {candidate.key: candidate for candidate in candidates}
|
|
774
|
+
selected_candidate = candidate_map.get(decision.selected_key)
|
|
775
|
+
label = selected_candidate.label if selected_candidate else decision.selected_key
|
|
776
|
+
self._emit(
|
|
777
|
+
ControllerEventType.LOG,
|
|
778
|
+
{
|
|
779
|
+
"text": f"[flow {mode.value}] {label} を自動選択しました。",
|
|
780
|
+
},
|
|
781
|
+
)
|
|
782
|
+
return decision
|
|
783
|
+
|
|
784
|
+
def _auto_select_decision(
|
|
785
|
+
self,
|
|
786
|
+
candidates: List[CandidateInfo],
|
|
787
|
+
scoreboard: Dict[str, Dict[str, object]],
|
|
788
|
+
) -> Optional[SelectionDecision]:
|
|
789
|
+
boss_mode = self._config.boss_mode
|
|
790
|
+
if boss_mode == BossMode.SKIP:
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
candidate_map = {candidate.key: candidate for candidate in candidates}
|
|
794
|
+
|
|
795
|
+
def score_from_entry(entry: Dict[str, object]) -> Optional[float]:
|
|
796
|
+
score = entry.get("score")
|
|
797
|
+
if score is None:
|
|
798
|
+
return None
|
|
799
|
+
try:
|
|
800
|
+
return float(score)
|
|
801
|
+
except (TypeError, ValueError):
|
|
802
|
+
return None
|
|
803
|
+
|
|
804
|
+
selected_key: Optional[str]
|
|
805
|
+
if boss_mode == BossMode.REWRITE and "boss" in candidate_map:
|
|
806
|
+
selected_key = "boss"
|
|
807
|
+
else:
|
|
808
|
+
selected_key = None
|
|
809
|
+
best_score: Optional[float] = None
|
|
810
|
+
for candidate in candidates:
|
|
811
|
+
entry = scoreboard.get(candidate.key, {})
|
|
812
|
+
score_value = score_from_entry(entry)
|
|
813
|
+
if score_value is None:
|
|
814
|
+
continue
|
|
815
|
+
if best_score is None or score_value > best_score:
|
|
816
|
+
best_score = score_value
|
|
817
|
+
selected_key = candidate.key
|
|
818
|
+
if selected_key is None:
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
scores: Dict[str, float] = {}
|
|
822
|
+
comments: Dict[str, str] = {}
|
|
823
|
+
for candidate in candidates:
|
|
824
|
+
entry = scoreboard.get(candidate.key, {})
|
|
825
|
+
score_value = score_from_entry(entry)
|
|
826
|
+
scores[candidate.key] = score_value if score_value is not None else 0.0
|
|
827
|
+
comment_value = entry.get("comment")
|
|
828
|
+
if isinstance(comment_value, str) and comment_value:
|
|
829
|
+
comments[candidate.key] = comment_value
|
|
830
|
+
|
|
831
|
+
return SelectionDecision(selected_key=selected_key, scores=scores, comments=comments)
|
|
832
|
+
|
|
833
|
+
def _prune_boss_candidates(
|
|
834
|
+
self,
|
|
835
|
+
candidates: List[CandidateInfo],
|
|
836
|
+
scoreboard: Optional[Dict[str, Dict[str, object]]],
|
|
837
|
+
) -> tuple[List[CandidateInfo], Optional[Dict[str, Dict[str, object]]]]:
|
|
838
|
+
if self._config.boss_mode == BossMode.REWRITE:
|
|
839
|
+
return candidates, scoreboard
|
|
840
|
+
filtered = [candidate for candidate in candidates if candidate.key != "boss"]
|
|
841
|
+
if not scoreboard or "boss" not in scoreboard:
|
|
842
|
+
return filtered, scoreboard
|
|
843
|
+
trimmed = {key: value for key, value in scoreboard.items() if key != "boss"}
|
|
844
|
+
return filtered, trimmed
|
|
845
|
+
|
|
846
|
+
def history_previous(self) -> Optional[str]:
|
|
847
|
+
return self._history.history_previous()
|
|
848
|
+
|
|
849
|
+
def history_next(self) -> Optional[str]:
|
|
850
|
+
return self._history.history_next()
|
|
851
|
+
|
|
852
|
+
def history_reset(self) -> None:
|
|
853
|
+
self._history.reset_cursor()
|
|
854
|
+
|
|
855
|
+
@property
|
|
856
|
+
def _cycle_history(self) -> List[Dict[str, object]]:
|
|
857
|
+
return self._history._cycle_history
|
|
858
|
+
|
|
859
|
+
@_cycle_history.setter
|
|
860
|
+
def _cycle_history(self, value: List[Dict[str, object]]) -> None:
|
|
861
|
+
self._history.set_cycle_history(value)
|
|
862
|
+
|
|
863
|
+
def _perform_revert(self, silent: bool = False) -> None:
|
|
864
|
+
tmux_manager = self._last_tmux_manager
|
|
865
|
+
pane_ids = self._tmux_list_panes() or []
|
|
866
|
+
main_pane = pane_ids[0] if pane_ids else None
|
|
867
|
+
|
|
868
|
+
snapshot = self._history.last_snapshot()
|
|
869
|
+
if snapshot is None:
|
|
870
|
+
session_id: Optional[str]
|
|
871
|
+
if self._pre_cycle_selected_session_set and self._pre_cycle_selected_session:
|
|
872
|
+
session_id = self._pre_cycle_selected_session
|
|
873
|
+
elif self._active_main_session_id:
|
|
874
|
+
session_id = self._active_main_session_id
|
|
875
|
+
else:
|
|
876
|
+
session_id = self._last_started_main_session_id
|
|
877
|
+
self._last_selected_session = session_id
|
|
878
|
+
self._active_main_session_id = session_id
|
|
879
|
+
self._last_scoreboard = {}
|
|
880
|
+
self._last_instruction = None
|
|
881
|
+
self._paused = False
|
|
882
|
+
if tmux_manager and main_pane:
|
|
883
|
+
if session_id:
|
|
884
|
+
tmux_manager.promote_to_main(session_id=session_id, pane_id=main_pane)
|
|
885
|
+
else:
|
|
886
|
+
tmux_manager.launch_main_session(pane_id=main_pane)
|
|
887
|
+
summary = session_id or "(未選択)"
|
|
888
|
+
if not silent:
|
|
889
|
+
self._emit(ControllerEventType.LOG, {"text": f"前回のセッションを再開しました。次の指示はセッション {summary} から再開します。"})
|
|
890
|
+
self._emit_status("待機中")
|
|
891
|
+
self._emit_pause_state()
|
|
892
|
+
self._pre_cycle_selected_session = None
|
|
893
|
+
self._pre_cycle_selected_session_set = False
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
snapshot = self._history.last_snapshot() or {}
|
|
897
|
+
session_id = snapshot.get("selected_session") or self._active_main_session_id or self._last_started_main_session_id
|
|
898
|
+
|
|
899
|
+
self._last_selected_session = session_id
|
|
900
|
+
self._active_main_session_id = session_id
|
|
901
|
+
self._last_scoreboard = snapshot.get("scoreboard", {})
|
|
902
|
+
self._last_instruction = snapshot.get("instruction")
|
|
903
|
+
if self._last_scoreboard:
|
|
904
|
+
self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": self._last_scoreboard})
|
|
905
|
+
|
|
906
|
+
self._paused = False
|
|
907
|
+
if tmux_manager and main_pane:
|
|
908
|
+
if session_id:
|
|
909
|
+
tmux_manager.promote_to_main(session_id=session_id, pane_id=main_pane)
|
|
910
|
+
else:
|
|
911
|
+
tmux_manager.launch_main_session(pane_id=main_pane)
|
|
912
|
+
|
|
913
|
+
summary = session_id or "(未選択)"
|
|
914
|
+
if not silent:
|
|
915
|
+
self._emit(ControllerEventType.LOG, {"text": f"サイクルを巻き戻しました。次の指示はセッション {summary} から再開します。"})
|
|
916
|
+
self._emit_status("待機中")
|
|
917
|
+
self._emit_pause_state()
|
|
918
|
+
self._pre_cycle_selected_session = None
|
|
919
|
+
self._pre_cycle_selected_session_set = False
|
|
920
|
+
|
|
921
|
+
def _emit_pause_state(self) -> None:
|
|
922
|
+
self._emit(ControllerEventType.PAUSE_STATE, {"paused": self._paused})
|
|
923
|
+
|
|
924
|
+
def _on_main_session_started(self, session_id: str) -> None:
|
|
925
|
+
self._active_main_session_id = session_id
|
|
926
|
+
self._last_started_main_session_id = session_id
|
|
927
|
+
if self._last_selected_session is None:
|
|
928
|
+
self._last_selected_session = session_id
|
|
929
|
+
self._config.reuse_existing_session = True
|
|
930
|
+
|
|
931
|
+
async def _run_instruction(self, instruction: str) -> None:
|
|
932
|
+
await self._workflow.run(instruction)
|
|
933
|
+
|
|
934
|
+
def _resolve_selection(self, index: int) -> None:
|
|
935
|
+
if not self._selection_context:
|
|
936
|
+
self._emit(ControllerEventType.LOG, {"text": "現在選択待ちではありません。"})
|
|
937
|
+
return
|
|
938
|
+
context = self._selection_context
|
|
939
|
+
if index < 1 or index > len(context.candidates):
|
|
940
|
+
self._emit(ControllerEventType.LOG, {"text": "無効な番号です。"})
|
|
941
|
+
return
|
|
942
|
+
candidate = context.candidates[index - 1]
|
|
943
|
+
scores = {
|
|
944
|
+
cand.key: (1.0 if cand.key == candidate.key else 0.0) for cand in context.candidates
|
|
945
|
+
}
|
|
946
|
+
decision = SelectionDecision(selected_key=candidate.key, scores=scores)
|
|
947
|
+
context.future.set_result(decision)
|
|
948
|
+
self._emit(ControllerEventType.LOG, {"text": f"{candidate.label} を選択しました。"})
|
|
949
|
+
self._selection_context = None
|
|
950
|
+
self._emit(ControllerEventType.SELECTION_FINISHED, {})
|
|
951
|
+
|
|
952
|
+
async def _handle_attach_command(self, *, force: bool = False) -> None:
|
|
953
|
+
session_name = self._config.tmux_session
|
|
954
|
+
wait_for_session = not force and self._attach_mode == "auto"
|
|
955
|
+
if wait_for_session:
|
|
956
|
+
self._emit(ControllerEventType.LOG, {"text": f"[auto] tmuxセッション {session_name} の起動を待機中..."})
|
|
957
|
+
session_ready = await self._wait_for_session(session_name)
|
|
958
|
+
if not session_ready:
|
|
959
|
+
self._emit(
|
|
960
|
+
ControllerEventType.LOG,
|
|
961
|
+
{"text": f"[auto] tmuxセッション {session_name} が見つかりませんでした。少し待ってから再度試してください。"},
|
|
962
|
+
)
|
|
963
|
+
return
|
|
964
|
+
else:
|
|
965
|
+
if not self._attach_manager.session_exists(session_name):
|
|
966
|
+
self._emit(
|
|
967
|
+
ControllerEventType.LOG,
|
|
968
|
+
{
|
|
969
|
+
"text": (
|
|
970
|
+
f"tmuxセッション {session_name} がまだ存在しません。"
|
|
971
|
+
" 指示を送信してセッションを初期化した後に再度実行してください。"
|
|
972
|
+
)
|
|
973
|
+
},
|
|
974
|
+
)
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
perform_detection = not force and self._attach_mode == "auto"
|
|
978
|
+
if perform_detection:
|
|
979
|
+
if self._attach_manager.is_attached(session_name):
|
|
980
|
+
self._emit(
|
|
981
|
+
ControllerEventType.LOG,
|
|
982
|
+
{"text": f"[auto] tmuxセッション {session_name} は既に接続済みのため、自動アタッチをスキップしました。"},
|
|
983
|
+
)
|
|
984
|
+
return
|
|
985
|
+
result = self._attach_manager.attach(session_name, workdir=self._worktree_root)
|
|
986
|
+
if result.returncode == 0:
|
|
987
|
+
prefix = "[auto] " if perform_detection else ""
|
|
988
|
+
self._emit(ControllerEventType.LOG, {"text": f"{prefix}tmuxセッション {session_name} に接続しました。"})
|
|
989
|
+
else:
|
|
990
|
+
self._emit(ControllerEventType.LOG, {"text": "tmuxへの接続に失敗しました。tmuxが利用可能か確認してください。"})
|
|
991
|
+
|
|
992
|
+
def _build_resume_options(self) -> List[CommandOption]:
|
|
993
|
+
references = self._manifest_store.list_sessions()
|
|
994
|
+
self._resume_options = references
|
|
995
|
+
options: List[CommandOption] = []
|
|
996
|
+
for idx, ref in enumerate(references, start=1):
|
|
997
|
+
summary = ref.latest_instruction or ""
|
|
998
|
+
label = f"{idx}. {ref.created_at} | tmux={ref.tmux_session}"
|
|
999
|
+
if summary:
|
|
1000
|
+
label += f" | last: {summary[:40]}"
|
|
1001
|
+
options.append(CommandOption(label, idx))
|
|
1002
|
+
return options
|
|
1003
|
+
|
|
1004
|
+
def _list_sessions(self) -> None:
|
|
1005
|
+
references = self._manifest_store.list_sessions()
|
|
1006
|
+
self._resume_options = references
|
|
1007
|
+
if not references:
|
|
1008
|
+
self._emit(ControllerEventType.LOG, {"text": "保存済みセッションが見つかりません。"})
|
|
1009
|
+
return
|
|
1010
|
+
lines = [
|
|
1011
|
+
"=== 保存されたセッション ===",
|
|
1012
|
+
]
|
|
1013
|
+
for idx, ref in enumerate(references, start=1):
|
|
1014
|
+
summary = ref.latest_instruction or ""
|
|
1015
|
+
lines.append(
|
|
1016
|
+
f"{idx}. {ref.created_at} | tmux={ref.tmux_session} | workers={ref.worker_count} | mode={ref.mode}"
|
|
1017
|
+
)
|
|
1018
|
+
if summary:
|
|
1019
|
+
lines.append(f" last instruction: {summary[:80]}")
|
|
1020
|
+
self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
|
|
1021
|
+
self._emit(ControllerEventType.LOG, {"text": "再開するには /resume からセッションを選択してください。"})
|
|
1022
|
+
|
|
1023
|
+
def _load_session(self, index: int) -> None:
|
|
1024
|
+
if not self._resume_options:
|
|
1025
|
+
self._emit(ControllerEventType.LOG, {"text": "先に /resume で一覧を表示してください。"})
|
|
1026
|
+
return
|
|
1027
|
+
if index < 1 or index > len(self._resume_options):
|
|
1028
|
+
self._emit(ControllerEventType.LOG, {"text": "無効な番号です。"})
|
|
1029
|
+
return
|
|
1030
|
+
reference = self._resume_options[index - 1]
|
|
1031
|
+
try:
|
|
1032
|
+
manifest = self._manifest_store.load_manifest(reference.session_id)
|
|
1033
|
+
except FileNotFoundError:
|
|
1034
|
+
self._emit(ControllerEventType.LOG, {"text": "セッションファイルが見つかりませんでした。"})
|
|
1035
|
+
return
|
|
1036
|
+
self._apply_manifest(manifest)
|
|
1037
|
+
self._emit(ControllerEventType.LOG, {"text": f"セッション {manifest.session_id} を読み込みました。"})
|
|
1038
|
+
if manifest.scoreboard:
|
|
1039
|
+
self._emit(ControllerEventType.SCOREBOARD, {"scoreboard": manifest.scoreboard})
|
|
1040
|
+
self._show_conversation_log(manifest.conversation_log)
|
|
1041
|
+
|
|
1042
|
+
def _apply_manifest(self, manifest: SessionManifest) -> None:
|
|
1043
|
+
self._config.session_id = manifest.session_id
|
|
1044
|
+
self._config.tmux_session = manifest.tmux_session
|
|
1045
|
+
self._config.worker_count = manifest.worker_count
|
|
1046
|
+
self._config.mode = SessionMode(manifest.mode)
|
|
1047
|
+
if manifest.logs_dir:
|
|
1048
|
+
self._config.logs_root = Path(manifest.logs_dir).parent
|
|
1049
|
+
else:
|
|
1050
|
+
self._config.logs_root = _ensure_logs_directory(manifest.session_id)
|
|
1051
|
+
self._config.reuse_existing_session = True
|
|
1052
|
+
self._last_scoreboard = manifest.scoreboard or {}
|
|
1053
|
+
self._last_instruction = manifest.latest_instruction
|
|
1054
|
+
self._last_selected_session = manifest.selected_session_id
|
|
1055
|
+
self._session_namespace = manifest.session_id
|
|
1056
|
+
self._emit_status("再開準備完了")
|
|
1057
|
+
self._ensure_tmux_session(manifest)
|
|
1058
|
+
|
|
1059
|
+
def _ensure_tmux_session(self, manifest: SessionManifest) -> None:
|
|
1060
|
+
try:
|
|
1061
|
+
import libtmux
|
|
1062
|
+
except ImportError:
|
|
1063
|
+
self._emit(ControllerEventType.LOG, {"text": "libtmux が見つかりません。tmux セッションは手動で復元してください。"})
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
server = libtmux.Server() # type: ignore[attr-defined]
|
|
1067
|
+
existing = server.find_where({"session_name": manifest.tmux_session})
|
|
1068
|
+
if existing is not None:
|
|
1069
|
+
return
|
|
1070
|
+
|
|
1071
|
+
worker_count = len(manifest.workers)
|
|
1072
|
+
orchestrator = self._builder(
|
|
1073
|
+
worker_count=worker_count,
|
|
1074
|
+
log_dir=Path(manifest.logs_dir) if manifest.logs_dir else None,
|
|
1075
|
+
session_name=manifest.tmux_session,
|
|
1076
|
+
reuse_existing_session=False,
|
|
1077
|
+
session_namespace=manifest.session_id,
|
|
1078
|
+
boss_mode=self._config.boss_mode,
|
|
1079
|
+
project_root=self._worktree_root,
|
|
1080
|
+
worktree_storage_root=self._worktree_storage_root,
|
|
1081
|
+
)
|
|
1082
|
+
tmux_manager = orchestrator._tmux # type: ignore[attr-defined]
|
|
1083
|
+
orchestrator._worktree.prepare() # type: ignore[attr-defined]
|
|
1084
|
+
boss_path = Path(manifest.boss.worktree) if manifest.boss and manifest.boss.worktree else self._worktree_root
|
|
1085
|
+
tmux_manager.set_boss_path(boss_path)
|
|
1086
|
+
tmux_manager.set_reuse_existing_session(True)
|
|
1087
|
+
layout = tmux_manager.ensure_layout(session_name=manifest.tmux_session, worker_count=worker_count)
|
|
1088
|
+
tmux_manager.resume_session(
|
|
1089
|
+
pane_id=layout.main_pane,
|
|
1090
|
+
workdir=self._worktree_root,
|
|
1091
|
+
session_id=manifest.main.session_id,
|
|
1092
|
+
)
|
|
1093
|
+
for idx, worker_name in enumerate(layout.worker_names):
|
|
1094
|
+
record = manifest.workers.get(worker_name)
|
|
1095
|
+
if not record or not record.worktree:
|
|
1096
|
+
continue
|
|
1097
|
+
pane_id = layout.worker_panes[idx]
|
|
1098
|
+
tmux_manager.resume_session(
|
|
1099
|
+
pane_id=pane_id,
|
|
1100
|
+
workdir=Path(record.worktree),
|
|
1101
|
+
session_id=record.session_id,
|
|
1102
|
+
)
|
|
1103
|
+
if manifest.boss and manifest.boss.worktree:
|
|
1104
|
+
tmux_manager.resume_session(
|
|
1105
|
+
pane_id=layout.boss_pane,
|
|
1106
|
+
workdir=Path(manifest.boss.worktree),
|
|
1107
|
+
session_id=manifest.boss.session_id,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
def _show_conversation_log(self, log_path: Optional[str]) -> None:
|
|
1111
|
+
if not log_path:
|
|
1112
|
+
self._emit(ControllerEventType.LOG, {"text": "会話ログはありません。"})
|
|
1113
|
+
return
|
|
1114
|
+
path = Path(log_path)
|
|
1115
|
+
if not path.exists():
|
|
1116
|
+
self._emit(ControllerEventType.LOG, {"text": "会話ログが見つかりませんでした。"})
|
|
1117
|
+
return
|
|
1118
|
+
lines: List[str] = ["--- Conversation Log ---"]
|
|
1119
|
+
try:
|
|
1120
|
+
if path.suffix == ".jsonl":
|
|
1121
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
1122
|
+
if not raw_line.strip():
|
|
1123
|
+
continue
|
|
1124
|
+
data = json.loads(raw_line)
|
|
1125
|
+
event_type = data.get("type")
|
|
1126
|
+
if event_type == "instruction":
|
|
1127
|
+
lines.append(f"[instruction] {data.get('instruction', '')}")
|
|
1128
|
+
elif event_type == "fork":
|
|
1129
|
+
workers = ", ".join(data.get("fork_map", {}).keys())
|
|
1130
|
+
lines.append(f"[fork] workers={workers}")
|
|
1131
|
+
elif event_type == "completion":
|
|
1132
|
+
done = [k for k, v in (data.get("completion") or {}).items() if v.get("done")]
|
|
1133
|
+
lines.append(f"[completion] done={done}")
|
|
1134
|
+
elif event_type == "scoreboard":
|
|
1135
|
+
lines.append("[scoreboard]")
|
|
1136
|
+
for key, info in (data.get("scoreboard") or {}).items():
|
|
1137
|
+
score = info.get("score")
|
|
1138
|
+
selected = " [selected]" if info.get("selected") else ""
|
|
1139
|
+
comment = info.get("comment", "")
|
|
1140
|
+
lines.append(f" {key}: {score} {selected} {comment}")
|
|
1141
|
+
elif event_type == "selection":
|
|
1142
|
+
lines.append(
|
|
1143
|
+
f"[selection] session={data.get('selected_session')} key={data.get('selected_key')}"
|
|
1144
|
+
)
|
|
1145
|
+
elif event_type == "artifact":
|
|
1146
|
+
workers = list((data.get("worker_sessions") or {}).keys())
|
|
1147
|
+
lines.append(f"[artifact] main={data.get('main_session_id')} workers={workers}")
|
|
1148
|
+
else:
|
|
1149
|
+
lines.append(raw_line)
|
|
1150
|
+
else:
|
|
1151
|
+
lines.extend(path.read_text(encoding="utf-8").splitlines())
|
|
1152
|
+
except json.JSONDecodeError:
|
|
1153
|
+
lines.extend(path.read_text(encoding="utf-8").splitlines())
|
|
1154
|
+
lines.append("--- End Conversation Log ---")
|
|
1155
|
+
self._emit(ControllerEventType.LOG, {"text": "\n".join(lines)})
|
|
1156
|
+
|
|
1157
|
+
def _emit_status(self, message: str) -> None:
|
|
1158
|
+
self._emit(ControllerEventType.STATUS, {"message": message})
|
|
1159
|
+
|
|
1160
|
+
def _emit(self, event_type: Union[str, ControllerEventType], payload: Dict[str, object]) -> None:
|
|
1161
|
+
if isinstance(event_type, ControllerEventType):
|
|
1162
|
+
key = event_type.value
|
|
1163
|
+
else:
|
|
1164
|
+
key = event_type
|
|
1165
|
+
self._event_handler(key, payload)
|
|
1166
|
+
|
|
1167
|
+
def _flow_mode_display(self, mode: Optional[FlowMode] = None) -> str:
|
|
1168
|
+
current = mode or getattr(self, "_flow_mode", FlowMode.MANUAL)
|
|
1169
|
+
if isinstance(current, FlowMode):
|
|
1170
|
+
return FLOW_MODE_LABELS.get(current, current.value)
|
|
1171
|
+
return str(current)
|
|
1172
|
+
|
|
1173
|
+
def _merge_strategy_display(self, strategy: Optional[MergeMode] = None) -> str:
|
|
1174
|
+
current = strategy or getattr(self, "_merge_mode", MergeMode.MANUAL)
|
|
1175
|
+
if isinstance(current, MergeMode):
|
|
1176
|
+
return MERGE_STRATEGY_LABELS.get(current, current.value)
|
|
1177
|
+
return str(current)
|
|
1178
|
+
|
|
1179
|
+
def _create_initial_config(self) -> SessionConfig:
|
|
1180
|
+
session_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + "-" + datetime.utcnow().strftime("%f")[:6]
|
|
1181
|
+
tmux_session = f"parallel-dev-{session_id}"
|
|
1182
|
+
logs_root = _ensure_logs_directory(session_id)
|
|
1183
|
+
return SessionConfig(
|
|
1184
|
+
session_id=session_id,
|
|
1185
|
+
tmux_session=tmux_session,
|
|
1186
|
+
worker_count=3,
|
|
1187
|
+
mode=SessionMode.PARALLEL,
|
|
1188
|
+
logs_root=logs_root,
|
|
1189
|
+
boss_mode=BossMode.SCORE,
|
|
1190
|
+
flow_mode=FlowMode.FULL_AUTO,
|
|
1191
|
+
merge_mode=MergeMode.AUTO,
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
def _create_cycle_logs_dir(self) -> Path:
|
|
1195
|
+
timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
|
|
1196
|
+
logs_dir = self._config.logs_root / timestamp
|
|
1197
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1198
|
+
return logs_dir
|
|
1199
|
+
|
|
1200
|
+
async def _wait_for_session(self, session_name: str, attempts: int = 20, delay: float = 0.25) -> bool:
|
|
1201
|
+
for _ in range(attempts):
|
|
1202
|
+
if self._attach_manager.session_exists(session_name):
|
|
1203
|
+
return True
|
|
1204
|
+
await asyncio.sleep(delay)
|
|
1205
|
+
return False
|
|
1206
|
+
|
|
1207
|
+
def _build_manifest(self, result: OrchestrationResult, logs_dir: Path) -> SessionManifest:
|
|
1208
|
+
assert result.artifact is not None
|
|
1209
|
+
artifact = result.artifact
|
|
1210
|
+
main_record = PaneRecord(
|
|
1211
|
+
role="main",
|
|
1212
|
+
name=None,
|
|
1213
|
+
session_id=artifact.main_session_id,
|
|
1214
|
+
worktree=str(self._worktree_root),
|
|
1215
|
+
)
|
|
1216
|
+
workers = {
|
|
1217
|
+
name: PaneRecord(
|
|
1218
|
+
role="worker",
|
|
1219
|
+
name=name,
|
|
1220
|
+
session_id=session_id,
|
|
1221
|
+
worktree=str(artifact.worker_paths.get(name)) if artifact.worker_paths.get(name) else None,
|
|
1222
|
+
)
|
|
1223
|
+
for name, session_id in artifact.worker_sessions.items()
|
|
1224
|
+
}
|
|
1225
|
+
boss_record = (
|
|
1226
|
+
PaneRecord(
|
|
1227
|
+
role="boss",
|
|
1228
|
+
name="boss",
|
|
1229
|
+
session_id=artifact.boss_session_id,
|
|
1230
|
+
worktree=str(artifact.boss_path) if artifact.boss_path else None,
|
|
1231
|
+
)
|
|
1232
|
+
if artifact.boss_session_id
|
|
1233
|
+
else None
|
|
1234
|
+
)
|
|
1235
|
+
conversation_path = None
|
|
1236
|
+
if artifact.log_paths:
|
|
1237
|
+
conversation_path = str(artifact.log_paths.get("jsonl") or artifact.log_paths.get("yaml"))
|
|
1238
|
+
else:
|
|
1239
|
+
conversation_path = str(logs_dir / "instruction.log")
|
|
1240
|
+
return SessionManifest(
|
|
1241
|
+
session_id=self._config.session_id,
|
|
1242
|
+
created_at=datetime.utcnow().isoformat(timespec="seconds"),
|
|
1243
|
+
tmux_session=self._config.tmux_session,
|
|
1244
|
+
worker_count=len(workers),
|
|
1245
|
+
mode=self._config.mode.value,
|
|
1246
|
+
logs_dir=str(logs_dir),
|
|
1247
|
+
latest_instruction=self._last_instruction,
|
|
1248
|
+
scoreboard=self._last_scoreboard,
|
|
1249
|
+
conversation_log=conversation_path,
|
|
1250
|
+
selected_session_id=artifact.selected_session_id,
|
|
1251
|
+
main=main_record,
|
|
1252
|
+
boss=boss_record,
|
|
1253
|
+
workers=workers,
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
def _handle_merge_outcome(self, outcome: Optional[MergeOutcome]) -> None:
|
|
1257
|
+
if outcome is None:
|
|
1258
|
+
return
|
|
1259
|
+
branch = outcome.branch or "選択されたブランチ"
|
|
1260
|
+
status = outcome.status
|
|
1261
|
+
error = outcome.error
|
|
1262
|
+
reason_key = outcome.reason
|
|
1263
|
+
reason_labels = {
|
|
1264
|
+
"agent_auto": "エージェントが統合を担当",
|
|
1265
|
+
}
|
|
1266
|
+
if status == "delegate" and reason_key == "manual_user":
|
|
1267
|
+
self._emit(
|
|
1268
|
+
ControllerEventType.LOG,
|
|
1269
|
+
{
|
|
1270
|
+
"text": (
|
|
1271
|
+
f"[merge] manualモード: {branch} の統合作業はユーザに委譲されています。"
|
|
1272
|
+
"必要な作業を終えたら /done で次に進んでください。"
|
|
1273
|
+
)
|
|
1274
|
+
},
|
|
1275
|
+
)
|
|
1276
|
+
self._emit_status("統合作業待ち")
|
|
1277
|
+
elif status == "delegate" and reason_key == "agent_auto":
|
|
1278
|
+
self._emit(
|
|
1279
|
+
ControllerEventType.LOG,
|
|
1280
|
+
{
|
|
1281
|
+
"text": (
|
|
1282
|
+
f"[merge] Autoモード: {branch} の統合作業はエージェントが完了し、"
|
|
1283
|
+
"ホストは結果を同期済みです。問題があればログを確認してください。"
|
|
1284
|
+
)
|
|
1285
|
+
},
|
|
1286
|
+
)
|
|
1287
|
+
self._emit_status("統合作業完了")
|
|
1288
|
+
elif status == "delegate":
|
|
1289
|
+
label = reason_labels.get(reason_key, reason_key or "手動統合に切り替え")
|
|
1290
|
+
detail = f" 詳細: {error}" if error else ""
|
|
1291
|
+
self._emit(
|
|
1292
|
+
ControllerEventType.LOG,
|
|
1293
|
+
{"text": f"[merge] 自動マージをスキップし、エージェントに委譲します ({label}).{detail}"},
|
|
1294
|
+
)
|
|
1295
|
+
self._emit_status("統合作業待ち")
|
|
1296
|
+
elif status == "failed":
|
|
1297
|
+
self._emit(
|
|
1298
|
+
ControllerEventType.LOG,
|
|
1299
|
+
{"text": f"[merge] {branch} の自動マージに失敗しました: {error or '理由不明'}. /merge コマンドで戦略を切り替えてください。"},
|
|
1300
|
+
)
|
|
1301
|
+
self._emit_status("マージ失敗")
|
|
1302
|
+
|
|
1303
|
+
def _handle_orchestrator_log(self, message: str) -> None:
|
|
1304
|
+
status = None
|
|
1305
|
+
token = "::status::"
|
|
1306
|
+
if token in message:
|
|
1307
|
+
message, status = message.split(token, 1)
|
|
1308
|
+
message = message.rstrip()
|
|
1309
|
+
status = status.strip()
|
|
1310
|
+
self._emit(ControllerEventType.LOG, {"text": message})
|
|
1311
|
+
if status:
|
|
1312
|
+
self._emit_status(status)
|
|
1313
|
+
|
|
1314
|
+
@staticmethod
|
|
1315
|
+
def _default_builder(
|
|
1316
|
+
*,
|
|
1317
|
+
worker_count: int,
|
|
1318
|
+
log_dir: Optional[Path],
|
|
1319
|
+
session_name: Optional[str] = None,
|
|
1320
|
+
reuse_existing_session: bool = False,
|
|
1321
|
+
session_namespace: Optional[str] = None,
|
|
1322
|
+
boss_mode: BossMode = BossMode.SCORE,
|
|
1323
|
+
project_root: Optional[Path] = None,
|
|
1324
|
+
worktree_storage_root: Optional[Path] = None,
|
|
1325
|
+
) -> Orchestrator:
|
|
1326
|
+
raise RuntimeError("Orchestrator builder is not configured.")
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def build_orchestrator(
|
|
1330
|
+
*,
|
|
1331
|
+
worker_count: int,
|
|
1332
|
+
log_dir: Optional[Path],
|
|
1333
|
+
session_name: Optional[str] = None,
|
|
1334
|
+
reuse_existing_session: bool = False,
|
|
1335
|
+
session_namespace: Optional[str] = None,
|
|
1336
|
+
boss_mode: BossMode = BossMode.SCORE,
|
|
1337
|
+
project_root: Optional[Path] = None,
|
|
1338
|
+
worktree_storage_root: Optional[Path] = None,
|
|
1339
|
+
log_hook: Optional[Callable[[str], None]] = None,
|
|
1340
|
+
merge_mode: MergeMode = MergeMode.MANUAL,
|
|
1341
|
+
) -> Orchestrator:
|
|
1342
|
+
session_name = session_name or "parallel-dev"
|
|
1343
|
+
timestamp = datetime.utcnow().strftime("%y-%m-%d-%H%M%S")
|
|
1344
|
+
if log_dir:
|
|
1345
|
+
base_logs_dir = Path(log_dir)
|
|
1346
|
+
base_logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1347
|
+
else:
|
|
1348
|
+
base_logs_dir = _ensure_logs_directory(timestamp)
|
|
1349
|
+
map_session_id = session_namespace or session_name or "parallel-dev"
|
|
1350
|
+
session_map_dir = default_config_dir() / "session_maps"
|
|
1351
|
+
session_map_dir.mkdir(parents=True, exist_ok=True)
|
|
1352
|
+
session_map_path = session_map_dir / f"{map_session_id}.yaml"
|
|
1353
|
+
|
|
1354
|
+
project_root_path = Path(project_root).expanduser() if project_root else Path.cwd()
|
|
1355
|
+
storage_root_path = (
|
|
1356
|
+
Path(worktree_storage_root).expanduser()
|
|
1357
|
+
if worktree_storage_root
|
|
1358
|
+
else project_root_path
|
|
1359
|
+
)
|
|
1360
|
+
session_root = storage_root_path / ".parallel-dev"
|
|
1361
|
+
if session_namespace:
|
|
1362
|
+
session_root = session_root / "sessions" / session_namespace
|
|
1363
|
+
session_root.mkdir(parents=True, exist_ok=True)
|
|
1364
|
+
|
|
1365
|
+
monitor = CodexMonitor(
|
|
1366
|
+
logs_dir=base_logs_dir,
|
|
1367
|
+
session_map_path=session_map_path,
|
|
1368
|
+
session_namespace=session_namespace,
|
|
1369
|
+
)
|
|
1370
|
+
tmux_manager = TmuxLayoutManager(
|
|
1371
|
+
session_name=session_name,
|
|
1372
|
+
worker_count=worker_count,
|
|
1373
|
+
monitor=monitor,
|
|
1374
|
+
root_path=project_root_path,
|
|
1375
|
+
startup_delay=0.0,
|
|
1376
|
+
backtrack_delay=0.0,
|
|
1377
|
+
reuse_existing_session=reuse_existing_session,
|
|
1378
|
+
session_namespace=session_namespace,
|
|
1379
|
+
)
|
|
1380
|
+
worktree_manager = WorktreeManager(
|
|
1381
|
+
root=project_root_path,
|
|
1382
|
+
worker_count=worker_count,
|
|
1383
|
+
session_namespace=session_namespace,
|
|
1384
|
+
storage_root=storage_root_path,
|
|
1385
|
+
)
|
|
1386
|
+
log_manager = LogManager(logs_dir=base_logs_dir)
|
|
1387
|
+
|
|
1388
|
+
return Orchestrator(
|
|
1389
|
+
tmux_manager=tmux_manager,
|
|
1390
|
+
worktree_manager=worktree_manager,
|
|
1391
|
+
monitor=monitor,
|
|
1392
|
+
log_manager=log_manager,
|
|
1393
|
+
worker_count=worker_count,
|
|
1394
|
+
session_name=session_name,
|
|
1395
|
+
boss_mode=boss_mode,
|
|
1396
|
+
log_hook=log_hook,
|
|
1397
|
+
merge_mode=merge_mode,
|
|
1398
|
+
)
|