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.
@@ -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)