okstra 0.91.1 → 0.92.1
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.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- package/runtime/python/okstra_ctl/implementation_stage.py +217 -0
- package/runtime/python/okstra_ctl/run.py +109 -369
- package/runtime/python/okstra_ctl/stage_reconcile.py +33 -0
- package/runtime/python/okstra_ctl/stage_targets.py +277 -0
- package/runtime/python/okstra_ctl/wizard.py +44 -6
- package/runtime/skills/okstra-run/SKILL.md +2 -2
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -358,7 +358,7 @@
|
|
|
358
358
|
}
|
|
359
359
|
},
|
|
360
360
|
"defaults_or_custom": {
|
|
361
|
-
"label": "역할별
|
|
361
|
+
"label": "역할별 모델 선택 단계입니다 (참여 워커 구성을 바꾸는 게 아닙니다).\n이번 run 에서 모델을 고를 역할:\n{role_models}\n· 기본값으로 진행 — 위 추천 모델을 그대로 쓰고, directive·관련 task 없이 바로 넘어갑니다.\n· 커스터마이즈 — 위 역할별 모델을 직접 고르고, 추가 directive·관련 task 도 지정합니다.\n(추천 값은 runtime 기본값으로 해소됩니다. 분석에 참여하지 않는 역할은 위 목록에 나오지 않습니다 — 예: antigravity 는 implementation 의 executor 로 선택했을 때만 표시됩니다.)",
|
|
362
362
|
"echo_template": "customize: {value}",
|
|
363
363
|
"options": {
|
|
364
364
|
"defaults": "기본값으로 진행 (역할별 추천 모델 그대로)",
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Implementation stage run orchestration.
|
|
2
|
+
|
|
3
|
+
This module owns the lifecycle for one ``implementation`` stage run: recover
|
|
4
|
+
consumer state, select an available Stage Map entry, provision the isolated
|
|
5
|
+
stage worktree, and publish the selected stage into run context.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import stage_targets
|
|
17
|
+
from .stage_reconcile import auto_reconcile_best_effort
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ImplementationStageError(Exception):
|
|
21
|
+
"""Implementation stage selection or provisioning failed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class StageSelection:
|
|
26
|
+
"""Resolved implementation stage and its isolated worktree coordinates."""
|
|
27
|
+
|
|
28
|
+
stage: int
|
|
29
|
+
worktree_path: str
|
|
30
|
+
worktree_branch: str
|
|
31
|
+
worktree_base_ref: str
|
|
32
|
+
worktree_status: str
|
|
33
|
+
worktree_note: str
|
|
34
|
+
started_head_commit: str
|
|
35
|
+
concurrent_stages: list[int] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_out(cwd: str | Path, *args: str) -> str:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "-C", str(cwd), *args],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
)
|
|
44
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
|
|
48
|
+
if isinstance(exc, ImplementationStageError):
|
|
49
|
+
return exc
|
|
50
|
+
return ImplementationStageError(str(exc))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def select_and_provision_implementation_stage(
|
|
54
|
+
inp: Any,
|
|
55
|
+
ctx_stage_map: list[dict[str, Any]],
|
|
56
|
+
task_group_segment: str,
|
|
57
|
+
task_id_segment: str,
|
|
58
|
+
task_key: str,
|
|
59
|
+
executor_worktree_status: str,
|
|
60
|
+
) -> StageSelection:
|
|
61
|
+
"""Select a ready implementation stage and provision its stage worktree.
|
|
62
|
+
|
|
63
|
+
The returned selection is path-independent with respect to run artifacts:
|
|
64
|
+
callers can compute stage-specific run paths after this function resolves
|
|
65
|
+
the selected stage.
|
|
66
|
+
"""
|
|
67
|
+
del task_key # The registry uses the split identity fields.
|
|
68
|
+
|
|
69
|
+
from .consumers import read_consumers, backfill_done_from_carry
|
|
70
|
+
from . import worktree as _worktree
|
|
71
|
+
from . import worktree_registry as _reg
|
|
72
|
+
|
|
73
|
+
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
74
|
+
backfill_done_from_carry(plan_run_root)
|
|
75
|
+
auto_reconcile_best_effort(inp, plan_run_root)
|
|
76
|
+
consumed = read_consumers(plan_run_root)
|
|
77
|
+
done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
|
|
78
|
+
started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
|
|
79
|
+
reserved_stages = _reg.list_active_stage_numbers(
|
|
80
|
+
inp.project_id,
|
|
81
|
+
inp.task_group,
|
|
82
|
+
inp.task_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
batch = stage_targets.resolve_effective_stages(
|
|
87
|
+
ctx_stage_map,
|
|
88
|
+
done_stages,
|
|
89
|
+
inp.stage,
|
|
90
|
+
started_stages=started_stages,
|
|
91
|
+
reserved_stages=reserved_stages,
|
|
92
|
+
)
|
|
93
|
+
except stage_targets.StageTargetError as exc:
|
|
94
|
+
raise _as_implementation_stage_error(exc) from exc
|
|
95
|
+
|
|
96
|
+
selected = batch[0]
|
|
97
|
+
concurrent_stages = sorted(reserved_stages - done_stages - {selected})
|
|
98
|
+
|
|
99
|
+
if executor_worktree_status.startswith("skipped"):
|
|
100
|
+
head = _git_out(inp.project_root, "rev-parse", "HEAD")
|
|
101
|
+
return StageSelection(
|
|
102
|
+
stage=selected,
|
|
103
|
+
worktree_path="",
|
|
104
|
+
worktree_branch="",
|
|
105
|
+
worktree_base_ref="",
|
|
106
|
+
worktree_status=executor_worktree_status,
|
|
107
|
+
worktree_note="",
|
|
108
|
+
started_head_commit=head,
|
|
109
|
+
concurrent_stages=concurrent_stages,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
|
|
113
|
+
if head_sha:
|
|
114
|
+
_reg.set_implementation_base(
|
|
115
|
+
inp.project_id,
|
|
116
|
+
inp.task_group,
|
|
117
|
+
inp.task_id,
|
|
118
|
+
head_sha,
|
|
119
|
+
)
|
|
120
|
+
anchor = _reg.get_implementation_base(
|
|
121
|
+
inp.project_id,
|
|
122
|
+
inp.task_group,
|
|
123
|
+
inp.task_id,
|
|
124
|
+
) or ""
|
|
125
|
+
|
|
126
|
+
selected_stage = next(
|
|
127
|
+
s for s in ctx_stage_map if s["stage_number"] == selected
|
|
128
|
+
)
|
|
129
|
+
consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
|
|
130
|
+
try:
|
|
131
|
+
stage_base = stage_targets.resolve_stage_base_commit(
|
|
132
|
+
selected_stage,
|
|
133
|
+
consumer_done_rows,
|
|
134
|
+
anchor_base_commit=anchor,
|
|
135
|
+
candidate_base=head_sha,
|
|
136
|
+
project_root=Path(inp.project_root),
|
|
137
|
+
plan_run_root=plan_run_root,
|
|
138
|
+
)
|
|
139
|
+
except stage_targets.StageTargetError as exc:
|
|
140
|
+
raise _as_implementation_stage_error(exc) from exc
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
prov = _worktree.provision_stage_worktree(
|
|
144
|
+
project_root=Path(inp.project_root),
|
|
145
|
+
project_id=inp.project_id,
|
|
146
|
+
task_group_segment=task_group_segment,
|
|
147
|
+
task_id_segment=task_id_segment,
|
|
148
|
+
work_category=inp.work_category,
|
|
149
|
+
stage_number=selected,
|
|
150
|
+
base_commit=stage_base,
|
|
151
|
+
)
|
|
152
|
+
except RuntimeError as exc:
|
|
153
|
+
from .git_reconcile import guidance
|
|
154
|
+
|
|
155
|
+
hint = guidance(
|
|
156
|
+
plan_run_root=plan_run_root,
|
|
157
|
+
project_id=inp.project_id,
|
|
158
|
+
task_group=inp.task_group,
|
|
159
|
+
task_id=inp.task_id,
|
|
160
|
+
work_category=inp.work_category,
|
|
161
|
+
)
|
|
162
|
+
raise ImplementationStageError(
|
|
163
|
+
f"stage worktree provisioning failed: {exc}\n{hint}"
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
return StageSelection(
|
|
167
|
+
stage=selected,
|
|
168
|
+
worktree_path=prov.path,
|
|
169
|
+
worktree_branch=prov.branch,
|
|
170
|
+
worktree_base_ref=prov.base_ref,
|
|
171
|
+
worktree_status=prov.status,
|
|
172
|
+
worktree_note=prov.note,
|
|
173
|
+
started_head_commit=prov.base_ref,
|
|
174
|
+
concurrent_stages=concurrent_stages,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def apply_implementation_stage(
|
|
179
|
+
inp: Any,
|
|
180
|
+
ctx: dict[str, Any],
|
|
181
|
+
ctx_stage_map: list[dict[str, Any]],
|
|
182
|
+
sel: StageSelection,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Publish a selected stage into run context and ``consumers.jsonl``."""
|
|
185
|
+
from .consumers import append_consumer
|
|
186
|
+
|
|
187
|
+
ctx["parsed_stage_map"] = ctx_stage_map
|
|
188
|
+
ctx["effective_stages"] = [sel.stage]
|
|
189
|
+
csv = str(sel.stage)
|
|
190
|
+
ctx["EFFECTIVE_STAGES"] = csv
|
|
191
|
+
ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
|
|
192
|
+
ctx["STAGE_BATCH_DIRECTIVE"] = (
|
|
193
|
+
f"- **Stage for this implementation run:** `{csv}`. "
|
|
194
|
+
"Execute exactly this Stage Map stage — this is the authoritative scope. "
|
|
195
|
+
"Do NOT recompute from `consumers.jsonl`; the runtime already selected "
|
|
196
|
+
"and reserved this stage."
|
|
197
|
+
)
|
|
198
|
+
inp.stage = csv
|
|
199
|
+
print(f"selected stages: {csv}", file=sys.stdout)
|
|
200
|
+
|
|
201
|
+
if sel.worktree_status and not sel.worktree_status.startswith("skipped"):
|
|
202
|
+
ctx["EXECUTOR_WORKTREE_PATH"] = sel.worktree_path
|
|
203
|
+
ctx["EXECUTOR_WORKTREE_BRANCH"] = sel.worktree_branch
|
|
204
|
+
ctx["EXECUTOR_WORKTREE_BASE_REF"] = sel.worktree_base_ref
|
|
205
|
+
ctx["EXECUTOR_WORKTREE_STATUS"] = sel.worktree_status
|
|
206
|
+
ctx["EXECUTOR_WORKTREE_NOTE"] = sel.worktree_note
|
|
207
|
+
|
|
208
|
+
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
209
|
+
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
210
|
+
append_consumer(
|
|
211
|
+
plan_run_root,
|
|
212
|
+
impl_task_key=ctx["TASK_KEY"],
|
|
213
|
+
stage=sel.stage,
|
|
214
|
+
status="started",
|
|
215
|
+
started_at=now,
|
|
216
|
+
head_commit=sel.started_head_commit,
|
|
217
|
+
)
|
|
@@ -89,6 +89,11 @@ from .workers import (
|
|
|
89
89
|
)
|
|
90
90
|
from .workflow import compute_workflow_state, load_phase_forbidden
|
|
91
91
|
from .locks import worktree_provision_mutex
|
|
92
|
+
from . import stage_targets as _stage_targets
|
|
93
|
+
from . import implementation_stage as _implementation_stage
|
|
94
|
+
from .stage_reconcile import (
|
|
95
|
+
auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
|
|
96
|
+
)
|
|
92
97
|
from .worktree import (
|
|
93
98
|
WorktreeProvision,
|
|
94
99
|
okstra_clean_gate_excludes,
|
|
@@ -418,7 +423,12 @@ def _validate_stage_structure(plan_path: str) -> None:
|
|
|
418
423
|
)
|
|
419
424
|
|
|
420
425
|
|
|
421
|
-
RUN_STEP_BUDGET =
|
|
426
|
+
RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
|
|
427
|
+
_FVTarget = _stage_targets.FinalVerificationTarget
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> PrepareError:
|
|
431
|
+
return PrepareError(str(exc))
|
|
422
432
|
|
|
423
433
|
|
|
424
434
|
def _resolve_effective_stages(
|
|
@@ -429,98 +439,40 @@ def _resolve_effective_stages(
|
|
|
429
439
|
started_stages: set = None,
|
|
430
440
|
reserved_stages: set = None,
|
|
431
441
|
) -> list:
|
|
432
|
-
"""
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
started_stages = started_stages or set()
|
|
442
|
-
reserved_stages = reserved_stages or set()
|
|
443
|
-
occupied = done_stages | started_stages | reserved_stages
|
|
444
|
-
if requested != "auto":
|
|
445
|
-
try:
|
|
446
|
-
n = int(requested)
|
|
447
|
-
except ValueError:
|
|
448
|
-
raise PrepareError(
|
|
449
|
-
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
450
|
-
)
|
|
451
|
-
target = next((s for s in stages if s["stage_number"] == n), None)
|
|
452
|
-
if target is None:
|
|
453
|
-
raise PrepareError(
|
|
454
|
-
f"--stage {n} not in Stage Map "
|
|
455
|
-
f"(have {[s['stage_number'] for s in stages]})"
|
|
456
|
-
)
|
|
457
|
-
if n in done_stages:
|
|
458
|
-
raise PrepareError(
|
|
459
|
-
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
460
|
-
)
|
|
461
|
-
if n in started_stages or n in reserved_stages:
|
|
462
|
-
raise PrepareError(
|
|
463
|
-
f"--stage {n} already in progress or reserved by another run"
|
|
464
|
-
)
|
|
465
|
-
return [n]
|
|
466
|
-
|
|
467
|
-
ready = [
|
|
468
|
-
s for s in stages
|
|
469
|
-
if s["stage_number"] not in occupied
|
|
470
|
-
and all(d in done_stages for d in s["depends_on"])
|
|
471
|
-
]
|
|
472
|
-
if not ready:
|
|
473
|
-
raise PrepareError(
|
|
474
|
-
"no stage is ready: every remaining stage has unsatisfied depends-on"
|
|
442
|
+
"""Compatibility wrapper for the public stage-target policy module."""
|
|
443
|
+
try:
|
|
444
|
+
return _stage_targets.resolve_effective_stages(
|
|
445
|
+
stages,
|
|
446
|
+
done_stages,
|
|
447
|
+
requested,
|
|
448
|
+
budget=budget,
|
|
449
|
+
started_stages=started_stages,
|
|
450
|
+
reserved_stages=reserved_stages,
|
|
475
451
|
)
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
for s in ready:
|
|
479
|
-
sc = s.get("step_count", 0) or 0
|
|
480
|
-
if batch and total + sc > budget:
|
|
481
|
-
break
|
|
482
|
-
batch.append(s["stage_number"])
|
|
483
|
-
total += sc
|
|
484
|
-
return batch
|
|
452
|
+
except _stage_targets.StageTargetError as exc:
|
|
453
|
+
raise _stage_target_prepare_error(exc) from exc
|
|
485
454
|
|
|
486
455
|
|
|
487
456
|
def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
|
|
488
|
-
"""
|
|
489
|
-
|
|
490
|
-
predecessor stage's done commit has been merged into the task worktree HEAD."""
|
|
491
|
-
r = _subprocess.run(
|
|
492
|
-
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
493
|
-
cwd=str(project_root), capture_output=True, text=True,
|
|
494
|
-
)
|
|
495
|
-
return r.returncode == 0
|
|
457
|
+
"""Compatibility wrapper for tests and older internal callers."""
|
|
458
|
+
return _stage_targets.commit_is_ancestor(project_root, ancestor, descendant)
|
|
496
459
|
|
|
497
460
|
|
|
498
461
|
def _check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
499
462
|
pred_commits: dict, candidate_base: str,
|
|
500
463
|
stage_n: int) -> None:
|
|
501
|
-
"""
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if plan_run_root is not None:
|
|
511
|
-
_record_reconciled(
|
|
512
|
-
plan_run_root,
|
|
513
|
-
impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
|
|
514
|
-
stage=d, new_commit=match.matched_commit,
|
|
515
|
-
replaced=head, reason="auto-patch-id")
|
|
516
|
-
continue
|
|
517
|
-
raise PrepareError(
|
|
518
|
-
f"multi-dependency stage {stage_n}: predecessor stage {d} "
|
|
519
|
-
f"({head[:8]}) is not merged into the task worktree "
|
|
520
|
-
f"({candidate_base[:8]}). Merge stage branches "
|
|
521
|
-
f"(e.g. the `-s{d}` branches) into the task worktree "
|
|
522
|
-
"(or into main, then refresh the worktree) and retry."
|
|
464
|
+
"""Compatibility wrapper for the stage-target dependency gate."""
|
|
465
|
+
try:
|
|
466
|
+
_stage_targets.check_multi_dep_merged(
|
|
467
|
+
project_root,
|
|
468
|
+
plan_run_root,
|
|
469
|
+
latest,
|
|
470
|
+
pred_commits,
|
|
471
|
+
candidate_base,
|
|
472
|
+
stage_n,
|
|
523
473
|
)
|
|
474
|
+
except _stage_targets.StageTargetError as exc:
|
|
475
|
+
raise _stage_target_prepare_error(exc) from exc
|
|
524
476
|
|
|
525
477
|
|
|
526
478
|
def _resolve_stage_base_commit(
|
|
@@ -531,70 +483,18 @@ def _resolve_stage_base_commit(
|
|
|
531
483
|
project_root=None,
|
|
532
484
|
plan_run_root=None,
|
|
533
485
|
) -> str:
|
|
534
|
-
"""
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
deps = stage.get("depends_on") or []
|
|
547
|
-
if len(deps) >= 2:
|
|
548
|
-
n = stage["stage_number"]
|
|
549
|
-
# 1) 모든 선행의 done head_commit 수집
|
|
550
|
-
pred_commits = {}
|
|
551
|
-
for d in deps:
|
|
552
|
-
head = (latest.get(d) or {}).get("head_commit")
|
|
553
|
-
if not head:
|
|
554
|
-
raise PrepareError(
|
|
555
|
-
f"predecessor stage {d} has no done row with head_commit "
|
|
556
|
-
f"in consumers.jsonl; multi-dependency stage {n} cannot start"
|
|
557
|
-
)
|
|
558
|
-
pred_commits[d] = head
|
|
559
|
-
# 2) candidate (task-key worktree HEAD) 필요
|
|
560
|
-
if not candidate_base or project_root is None:
|
|
561
|
-
raise PrepareError(
|
|
562
|
-
f"candidate base missing for multi-dependency stage {n}; "
|
|
563
|
-
"task-key worktree HEAD could not be resolved"
|
|
564
|
-
)
|
|
565
|
-
# 3) 모든 선행 done 이 candidate 에 (ancestor 또는 patch-equivalent 로)
|
|
566
|
-
# 머지됐는지 — patch-equivalent 면 보정 row 를 자동 기록한다.
|
|
567
|
-
_check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
568
|
-
pred_commits, candidate_base, n)
|
|
569
|
-
return candidate_base
|
|
570
|
-
if not deps:
|
|
571
|
-
if not anchor_base_commit:
|
|
572
|
-
raise PrepareError(
|
|
573
|
-
f"anchor base commit missing for independent stage "
|
|
574
|
-
f"{stage['stage_number']}; first-stage prepare should have "
|
|
575
|
-
"fixed it via worktree_registry.set_implementation_base"
|
|
576
|
-
)
|
|
577
|
-
return anchor_base_commit
|
|
578
|
-
# 단일 의존
|
|
579
|
-
pred = deps[0]
|
|
580
|
-
head = (latest.get(pred) or {}).get("head_commit") or ""
|
|
581
|
-
if head:
|
|
582
|
-
return head
|
|
583
|
-
raise PrepareError(
|
|
584
|
-
f"predecessor stage {pred} has no done row with head_commit in "
|
|
585
|
-
"consumers.jsonl; cannot derive base for stage "
|
|
586
|
-
f"{stage['stage_number']}"
|
|
587
|
-
)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
@dataclass
|
|
591
|
-
class _FVTarget:
|
|
592
|
-
scope: str # "whole-task" | "single-stage"
|
|
593
|
-
base: str
|
|
594
|
-
head: str
|
|
595
|
-
worktree_path: str
|
|
596
|
-
stages: list # list[int]
|
|
597
|
-
reports: list # list[str], report_path 값(없으면 "")
|
|
486
|
+
"""Compatibility wrapper for stage worktree base selection."""
|
|
487
|
+
try:
|
|
488
|
+
return _stage_targets.resolve_stage_base_commit(
|
|
489
|
+
stage,
|
|
490
|
+
consumer_done_rows,
|
|
491
|
+
anchor_base_commit,
|
|
492
|
+
candidate_base=candidate_base,
|
|
493
|
+
project_root=project_root,
|
|
494
|
+
plan_run_root=plan_run_root,
|
|
495
|
+
)
|
|
496
|
+
except _stage_targets.StageTargetError as exc:
|
|
497
|
+
raise _stage_target_prepare_error(exc) from exc
|
|
598
498
|
|
|
599
499
|
|
|
600
500
|
def _resolve_whole_task_target(
|
|
@@ -602,66 +502,37 @@ def _resolve_whole_task_target(
|
|
|
602
502
|
task_worktree_path: str, task_head: str, task_dirty: bool,
|
|
603
503
|
merged: dict,
|
|
604
504
|
) -> "_FVTarget":
|
|
605
|
-
"""
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
)
|
|
616
|
-
if not merged.get(n, False):
|
|
617
|
-
sha = done_by_stage[n].get("head_commit", "")
|
|
618
|
-
raise PrepareError(
|
|
619
|
-
f"final-verification(whole-task): stage {n} done commit "
|
|
620
|
-
f"{sha} not merged into task worktree HEAD — merge stage "
|
|
621
|
-
"branches then retry"
|
|
622
|
-
)
|
|
623
|
-
if task_dirty:
|
|
624
|
-
raise PrepareError(
|
|
625
|
-
"final-verification: worktree has uncommitted source changes "
|
|
626
|
-
"(outside .okstra/) — commit or stash before verifying"
|
|
505
|
+
"""Compatibility wrapper for whole-task verification target selection."""
|
|
506
|
+
try:
|
|
507
|
+
return _stage_targets.resolve_whole_task_target(
|
|
508
|
+
stage_map=stage_map,
|
|
509
|
+
done_rows=done_rows,
|
|
510
|
+
anchor_base=anchor_base,
|
|
511
|
+
task_worktree_path=task_worktree_path,
|
|
512
|
+
task_head=task_head,
|
|
513
|
+
task_dirty=task_dirty,
|
|
514
|
+
merged=merged,
|
|
627
515
|
)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
return _FVTarget(
|
|
631
|
-
scope="whole-task", base=anchor_base, head=task_head,
|
|
632
|
-
worktree_path=task_worktree_path, stages=stages, reports=reports,
|
|
633
|
-
)
|
|
516
|
+
except _stage_targets.StageTargetError as exc:
|
|
517
|
+
raise _stage_target_prepare_error(exc) from exc
|
|
634
518
|
|
|
635
519
|
|
|
636
520
|
def _resolve_single_stage_target(
|
|
637
521
|
*, requested_stage: str, done_rows: list, stage_base: str,
|
|
638
522
|
stage_worktree_path: str, stage_head: str, stage_dirty: bool,
|
|
639
523
|
) -> "_FVTarget":
|
|
640
|
-
"""
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
)
|
|
650
|
-
if not stage_worktree_path:
|
|
651
|
-
raise PrepareError(
|
|
652
|
-
f"final-verification(single-stage): stage worktree not found for "
|
|
653
|
-
f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
|
|
654
|
-
)
|
|
655
|
-
if stage_dirty:
|
|
656
|
-
raise PrepareError(
|
|
657
|
-
"final-verification: worktree has uncommitted source changes "
|
|
658
|
-
"(outside .okstra/) — commit or stash before verifying"
|
|
524
|
+
"""Compatibility wrapper for single-stage verification target selection."""
|
|
525
|
+
try:
|
|
526
|
+
return _stage_targets.resolve_single_stage_target(
|
|
527
|
+
requested_stage=requested_stage,
|
|
528
|
+
done_rows=done_rows,
|
|
529
|
+
stage_base=stage_base,
|
|
530
|
+
stage_worktree_path=stage_worktree_path,
|
|
531
|
+
stage_head=stage_head,
|
|
532
|
+
stage_dirty=stage_dirty,
|
|
659
533
|
)
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
worktree_path=stage_worktree_path, stages=[n],
|
|
663
|
-
reports=[done_by_stage[n].get("report_path", "")],
|
|
664
|
-
)
|
|
534
|
+
except _stage_targets.StageTargetError as exc:
|
|
535
|
+
raise _stage_target_prepare_error(exc) from exc
|
|
665
536
|
|
|
666
537
|
|
|
667
538
|
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
@@ -1444,33 +1315,42 @@ class _ModelBindings:
|
|
|
1444
1315
|
executor_model_meta: ModelAssignment
|
|
1445
1316
|
|
|
1446
1317
|
|
|
1318
|
+
def recommended_role_models() -> dict[str, str]:
|
|
1319
|
+
"""역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
|
|
1320
|
+
wizard 의 안내 표기가 공유하는 단일 기준점."""
|
|
1321
|
+
lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
|
|
1322
|
+
return {
|
|
1323
|
+
"lead": lead_default,
|
|
1324
|
+
"claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus"),
|
|
1325
|
+
"codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5"),
|
|
1326
|
+
"antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "auto"),
|
|
1327
|
+
"report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default),
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
|
|
1447
1331
|
def _resolve_worker_models(inp: PrepareInputs) -> dict:
|
|
1448
1332
|
"""lead/claude/codex/antigravity/report-writer 모델을 alias → (display, execution) 로 해소."""
|
|
1449
|
-
|
|
1450
|
-
claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus")
|
|
1451
|
-
codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
|
|
1452
|
-
antigravity_default = _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "auto")
|
|
1453
|
-
report_writer_default = _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default)
|
|
1333
|
+
rec = recommended_role_models()
|
|
1454
1334
|
return {
|
|
1455
1335
|
"lead": resolve_model_metadata(
|
|
1456
1336
|
provider="claude", raw_value=inp.lead_model,
|
|
1457
|
-
default_display=
|
|
1337
|
+
default_display=rec["lead"], default_execution=rec["lead"],
|
|
1458
1338
|
),
|
|
1459
1339
|
"cw": resolve_model_metadata(
|
|
1460
1340
|
provider="claude", raw_value=inp.claude_model,
|
|
1461
|
-
default_display=
|
|
1341
|
+
default_display=rec["claude"], default_execution=rec["claude"],
|
|
1462
1342
|
),
|
|
1463
1343
|
"co": resolve_model_metadata(
|
|
1464
1344
|
provider="codex", raw_value=inp.codex_model,
|
|
1465
|
-
default_display=
|
|
1345
|
+
default_display=rec["codex"], default_execution=rec["codex"],
|
|
1466
1346
|
),
|
|
1467
1347
|
"ge": resolve_model_metadata(
|
|
1468
1348
|
provider="antigravity", raw_value=inp.antigravity_model,
|
|
1469
|
-
default_display=
|
|
1349
|
+
default_display=rec["antigravity"], default_execution=rec["antigravity"],
|
|
1470
1350
|
),
|
|
1471
1351
|
"rw": resolve_model_metadata(
|
|
1472
1352
|
provider="claude", raw_value=inp.report_writer_model,
|
|
1473
|
-
default_display=
|
|
1353
|
+
default_display=rec["report-writer"], default_execution=rec["report-writer"],
|
|
1474
1354
|
),
|
|
1475
1355
|
}
|
|
1476
1356
|
|
|
@@ -1511,23 +1391,13 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
|
|
|
1511
1391
|
)
|
|
1512
1392
|
|
|
1513
1393
|
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
the surrounding flow degraded (non-git / nested worktree); in that case
|
|
1522
|
-
`started_head_commit` is the project HEAD instead of the worktree base."""
|
|
1523
|
-
stage: int
|
|
1524
|
-
worktree_path: str
|
|
1525
|
-
worktree_branch: str
|
|
1526
|
-
worktree_base_ref: str
|
|
1527
|
-
worktree_status: str
|
|
1528
|
-
worktree_note: str
|
|
1529
|
-
started_head_commit: str
|
|
1530
|
-
concurrent_stages: list = field(default_factory=list)
|
|
1394
|
+
StageSelection = _implementation_stage.StageSelection
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _implementation_stage_prepare_error(
|
|
1398
|
+
exc: _implementation_stage.ImplementationStageError,
|
|
1399
|
+
) -> PrepareError:
|
|
1400
|
+
return PrepareError(str(exc))
|
|
1531
1401
|
|
|
1532
1402
|
|
|
1533
1403
|
def _select_and_provision_implementation_stage(
|
|
@@ -1538,102 +1408,18 @@ def _select_and_provision_implementation_stage(
|
|
|
1538
1408
|
task_key: str,
|
|
1539
1409
|
executor_worktree_status: str,
|
|
1540
1410
|
) -> StageSelection:
|
|
1541
|
-
"""
|
|
1542
|
-
worktree, without touching any run-artifact path.
|
|
1543
|
-
|
|
1544
|
-
spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 가 backward compat
|
|
1545
|
-
로 batch 를 반환하지만 첫 번째만 실행한다 — stage 마다 격리 worktree·branch
|
|
1546
|
-
가 필요해 batch 가 의미를 잃기 때문. `executor_worktree_status` 가
|
|
1547
|
-
"skipped*" 면 worktree 없이 degrade 하고 project HEAD 만 기록한다."""
|
|
1548
|
-
from .consumers import read_consumers, backfill_done_from_carry
|
|
1549
|
-
from . import worktree as _worktree
|
|
1550
|
-
from . import worktree_registry as _reg
|
|
1551
|
-
|
|
1552
|
-
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
1553
|
-
# carry sidecars are the SSOT for stage completion; recover any `done` rows
|
|
1554
|
-
# the lead failed to append before the dependency gate reads them.
|
|
1555
|
-
backfill_done_from_carry(plan_run_root)
|
|
1556
|
-
_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1557
|
-
consumed = read_consumers(plan_run_root)
|
|
1558
|
-
done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
|
|
1559
|
-
started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
|
|
1560
|
-
reserved_stages = _reg.list_active_stage_numbers(
|
|
1561
|
-
inp.project_id, inp.task_group, inp.task_id,
|
|
1562
|
-
)
|
|
1563
|
-
|
|
1564
|
-
batch = _resolve_effective_stages(
|
|
1565
|
-
ctx_stage_map, done_stages, inp.stage,
|
|
1566
|
-
started_stages=started_stages, reserved_stages=reserved_stages,
|
|
1567
|
-
)
|
|
1568
|
-
selected = batch[0]
|
|
1569
|
-
# done stage 는 동시 run 이 아니다 — done 기록 시 점유는 해제되지만
|
|
1570
|
-
# (consumers._release_stage_reservation), crash·구버전 기록의 잔존
|
|
1571
|
-
# 예약이 남을 수 있어 읽기에서도 차감한다.
|
|
1572
|
-
concurrent_stages = sorted(reserved_stages - done_stages - {selected})
|
|
1573
|
-
|
|
1574
|
-
# spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
|
|
1575
|
-
# stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
|
|
1576
|
-
if executor_worktree_status.startswith("skipped"):
|
|
1577
|
-
head = _git_out(inp.project_root, "rev-parse", "HEAD")
|
|
1578
|
-
return StageSelection(
|
|
1579
|
-
stage=selected,
|
|
1580
|
-
worktree_path="",
|
|
1581
|
-
worktree_branch="",
|
|
1582
|
-
worktree_base_ref="",
|
|
1583
|
-
worktree_status=executor_worktree_status,
|
|
1584
|
-
worktree_note="",
|
|
1585
|
-
started_head_commit=head,
|
|
1586
|
-
concurrent_stages=concurrent_stages,
|
|
1587
|
-
)
|
|
1588
|
-
|
|
1589
|
-
# anchor base commit 1회 고정 (task-key worktree HEAD 기준)
|
|
1590
|
-
head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
|
|
1591
|
-
if head_sha:
|
|
1592
|
-
_reg.set_implementation_base(
|
|
1593
|
-
inp.project_id, inp.task_group, inp.task_id, head_sha,
|
|
1594
|
-
)
|
|
1595
|
-
anchor = _reg.get_implementation_base(
|
|
1596
|
-
inp.project_id, inp.task_group, inp.task_id,
|
|
1597
|
-
) or ""
|
|
1598
|
-
|
|
1599
|
-
# stage 격리 worktree 발급 — 의존 종류별 base 결정
|
|
1600
|
-
selected_stage = next(
|
|
1601
|
-
s for s in ctx_stage_map if s["stage_number"] == selected
|
|
1602
|
-
)
|
|
1603
|
-
consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
|
|
1604
|
-
stage_base = _resolve_stage_base_commit(
|
|
1605
|
-
selected_stage, consumer_done_rows, anchor_base_commit=anchor,
|
|
1606
|
-
candidate_base=head_sha, project_root=Path(inp.project_root),
|
|
1607
|
-
plan_run_root=plan_run_root,
|
|
1608
|
-
)
|
|
1411
|
+
"""Compatibility wrapper for implementation stage selection."""
|
|
1609
1412
|
try:
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
task_group_segment
|
|
1614
|
-
task_id_segment
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
base_commit=stage_base,
|
|
1413
|
+
return _implementation_stage.select_and_provision_implementation_stage(
|
|
1414
|
+
inp,
|
|
1415
|
+
ctx_stage_map,
|
|
1416
|
+
task_group_segment,
|
|
1417
|
+
task_id_segment,
|
|
1418
|
+
task_key,
|
|
1419
|
+
executor_worktree_status,
|
|
1618
1420
|
)
|
|
1619
|
-
except
|
|
1620
|
-
|
|
1621
|
-
hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
|
|
1622
|
-
task_group=inp.task_group, task_id=inp.task_id,
|
|
1623
|
-
work_category=inp.work_category)
|
|
1624
|
-
raise PrepareError(
|
|
1625
|
-
f"stage worktree provisioning failed: {exc}\n{hint}") from exc
|
|
1626
|
-
|
|
1627
|
-
return StageSelection(
|
|
1628
|
-
stage=selected,
|
|
1629
|
-
worktree_path=prov.path,
|
|
1630
|
-
worktree_branch=prov.branch,
|
|
1631
|
-
worktree_base_ref=prov.base_ref,
|
|
1632
|
-
worktree_status=prov.status,
|
|
1633
|
-
worktree_note=prov.note,
|
|
1634
|
-
started_head_commit=prov.base_ref,
|
|
1635
|
-
concurrent_stages=concurrent_stages,
|
|
1636
|
-
)
|
|
1421
|
+
except _implementation_stage.ImplementationStageError as exc:
|
|
1422
|
+
raise _implementation_stage_prepare_error(exc) from exc
|
|
1637
1423
|
|
|
1638
1424
|
|
|
1639
1425
|
def _apply_implementation_stage(
|
|
@@ -1642,44 +1428,8 @@ def _apply_implementation_stage(
|
|
|
1642
1428
|
ctx_stage_map: list,
|
|
1643
1429
|
sel: StageSelection,
|
|
1644
1430
|
) -> None:
|
|
1645
|
-
"""
|
|
1646
|
-
|
|
1647
|
-
from .consumers import append_consumer
|
|
1648
|
-
import datetime as _dt
|
|
1649
|
-
|
|
1650
|
-
ctx["parsed_stage_map"] = ctx_stage_map
|
|
1651
|
-
ctx["effective_stages"] = [sel.stage]
|
|
1652
|
-
csv = str(sel.stage)
|
|
1653
|
-
ctx["EFFECTIVE_STAGES"] = csv
|
|
1654
|
-
ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
|
|
1655
|
-
ctx["STAGE_BATCH_DIRECTIVE"] = (
|
|
1656
|
-
f"- **Stage for this implementation run:** `{csv}`. "
|
|
1657
|
-
"Execute exactly this Stage Map stage — this is the authoritative scope. "
|
|
1658
|
-
"Do NOT recompute from `consumers.jsonl`; the runtime already selected "
|
|
1659
|
-
"and reserved this stage."
|
|
1660
|
-
)
|
|
1661
|
-
inp.stage = csv
|
|
1662
|
-
# Observable contract: callers (okstra.sh, e2e harness) scan stdout for the
|
|
1663
|
-
# resolved stage. Keep this line — it is the run's stage-selection receipt.
|
|
1664
|
-
print(f"selected stages: {csv}", file=sys.stdout)
|
|
1665
|
-
|
|
1666
|
-
if sel.worktree_status and not sel.worktree_status.startswith("skipped"):
|
|
1667
|
-
ctx["EXECUTOR_WORKTREE_PATH"] = sel.worktree_path
|
|
1668
|
-
ctx["EXECUTOR_WORKTREE_BRANCH"] = sel.worktree_branch
|
|
1669
|
-
ctx["EXECUTOR_WORKTREE_BASE_REF"] = sel.worktree_base_ref
|
|
1670
|
-
ctx["EXECUTOR_WORKTREE_STATUS"] = sel.worktree_status
|
|
1671
|
-
ctx["EXECUTOR_WORKTREE_NOTE"] = sel.worktree_note
|
|
1672
|
-
|
|
1673
|
-
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
1674
|
-
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
1675
|
-
append_consumer(
|
|
1676
|
-
plan_run_root,
|
|
1677
|
-
impl_task_key=ctx["TASK_KEY"],
|
|
1678
|
-
stage=sel.stage,
|
|
1679
|
-
status="started",
|
|
1680
|
-
started_at=now,
|
|
1681
|
-
head_commit=sel.started_head_commit,
|
|
1682
|
-
)
|
|
1431
|
+
"""Compatibility wrapper for publishing implementation stage selection."""
|
|
1432
|
+
_implementation_stage.apply_implementation_stage(inp, ctx, ctx_stage_map, sel)
|
|
1683
1433
|
|
|
1684
1434
|
|
|
1685
1435
|
def _git_out(cwd, *args) -> str:
|
|
@@ -1689,17 +1439,7 @@ def _git_out(cwd, *args) -> str:
|
|
|
1689
1439
|
|
|
1690
1440
|
|
|
1691
1441
|
def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
|
|
1692
|
-
|
|
1693
|
-
from .git_reconcile import auto_reconcile
|
|
1694
|
-
for it in auto_reconcile(
|
|
1695
|
-
project_root=Path(inp.project_root), plan_run_root=plan_run_root,
|
|
1696
|
-
project_id=inp.project_id, task_group=inp.task_group,
|
|
1697
|
-
task_id=inp.task_id, work_category=inp.work_category):
|
|
1698
|
-
print(f"git-reconcile: stage {it.stage} done commit "
|
|
1699
|
-
f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
|
|
1700
|
-
"(patch-equivalent)", file=sys.stdout)
|
|
1701
|
-
except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
|
|
1702
|
-
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
1442
|
+
_stage_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1703
1443
|
|
|
1704
1444
|
|
|
1705
1445
|
def _is_ancestor(cwd, commit, head) -> bool:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Best-effort git reconciliation shared by stage prepare flows."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
|
|
10
|
+
"""Run patch-equivalence reconciliation without changing prepare outcome.
|
|
11
|
+
|
|
12
|
+
Reconciliation is advisory: failures are reported to stderr and the normal
|
|
13
|
+
dependency gates still make the authoritative decision.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
from .git_reconcile import auto_reconcile
|
|
17
|
+
|
|
18
|
+
for item in auto_reconcile(
|
|
19
|
+
project_root=Path(inp.project_root),
|
|
20
|
+
plan_run_root=plan_run_root,
|
|
21
|
+
project_id=inp.project_id,
|
|
22
|
+
task_group=inp.task_group,
|
|
23
|
+
task_id=inp.task_id,
|
|
24
|
+
work_category=inp.work_category,
|
|
25
|
+
):
|
|
26
|
+
print(
|
|
27
|
+
f"git-reconcile: stage {item.stage} done commit "
|
|
28
|
+
f"{item.recorded[:8]} -> {item.suggested_commit[:8]} "
|
|
29
|
+
"(patch-equivalent)",
|
|
30
|
+
file=sys.stdout,
|
|
31
|
+
)
|
|
32
|
+
except Exception as exc:
|
|
33
|
+
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Stage readiness and verification target rules.
|
|
2
|
+
|
|
3
|
+
This module owns the policy that decides which Stage Map stage can run, which
|
|
4
|
+
commit it branches from, and what final-verification should inspect. It keeps
|
|
5
|
+
the stage lifecycle rules behind one interface instead of leaking raw
|
|
6
|
+
``consumers.jsonl`` rows and git ancestry checks into prepare callers.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RUN_STEP_BUDGET = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StageTargetError(Exception):
|
|
20
|
+
"""Stage target selection or verification precondition failed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FinalVerificationTarget:
|
|
25
|
+
scope: str # "whole-task" | "single-stage"
|
|
26
|
+
base: str
|
|
27
|
+
head: str
|
|
28
|
+
worktree_path: str
|
|
29
|
+
stages: list[int]
|
|
30
|
+
reports: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_effective_stages(
|
|
34
|
+
stages: list[dict[str, Any]],
|
|
35
|
+
done_stages: set[int],
|
|
36
|
+
requested: str,
|
|
37
|
+
budget: int = RUN_STEP_BUDGET,
|
|
38
|
+
started_stages: set[int] | None = None,
|
|
39
|
+
reserved_stages: set[int] | None = None,
|
|
40
|
+
) -> list[int]:
|
|
41
|
+
"""Return ordered stage numbers this run should execute.
|
|
42
|
+
|
|
43
|
+
``requested`` is ``"auto"`` or a decimal string. Auto selection returns all
|
|
44
|
+
ready stages within the step budget, excluding done/started/reserved stages.
|
|
45
|
+
Numeric selection returns one forced stage.
|
|
46
|
+
"""
|
|
47
|
+
started_stages = started_stages or set()
|
|
48
|
+
reserved_stages = reserved_stages or set()
|
|
49
|
+
occupied = done_stages | started_stages | reserved_stages
|
|
50
|
+
if requested != "auto":
|
|
51
|
+
try:
|
|
52
|
+
n = int(requested)
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise StageTargetError(
|
|
55
|
+
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
56
|
+
) from exc
|
|
57
|
+
target = next((s for s in stages if s["stage_number"] == n), None)
|
|
58
|
+
if target is None:
|
|
59
|
+
raise StageTargetError(
|
|
60
|
+
f"--stage {n} not in Stage Map "
|
|
61
|
+
f"(have {[s['stage_number'] for s in stages]})"
|
|
62
|
+
)
|
|
63
|
+
if n in done_stages:
|
|
64
|
+
raise StageTargetError(
|
|
65
|
+
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
66
|
+
)
|
|
67
|
+
if n in started_stages or n in reserved_stages:
|
|
68
|
+
raise StageTargetError(
|
|
69
|
+
f"--stage {n} already in progress or reserved by another run"
|
|
70
|
+
)
|
|
71
|
+
return [n]
|
|
72
|
+
|
|
73
|
+
ready = [
|
|
74
|
+
s for s in stages
|
|
75
|
+
if s["stage_number"] not in occupied
|
|
76
|
+
and all(d in done_stages for d in s["depends_on"])
|
|
77
|
+
]
|
|
78
|
+
if not ready:
|
|
79
|
+
raise StageTargetError(
|
|
80
|
+
"no stage is ready: every remaining stage has unsatisfied depends-on"
|
|
81
|
+
)
|
|
82
|
+
batch: list[int] = []
|
|
83
|
+
total = 0
|
|
84
|
+
for stage in ready:
|
|
85
|
+
step_count = stage.get("step_count", 0) or 0
|
|
86
|
+
if batch and total + step_count > budget:
|
|
87
|
+
break
|
|
88
|
+
batch.append(stage["stage_number"])
|
|
89
|
+
total += step_count
|
|
90
|
+
return batch
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bool:
|
|
94
|
+
"""True iff ``ancestor`` is an ancestor of ``descendant`` in git history."""
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
97
|
+
cwd=str(project_root),
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
)
|
|
101
|
+
return result.returncode == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_multi_dep_merged(
|
|
105
|
+
project_root: Path,
|
|
106
|
+
plan_run_root: Path | None,
|
|
107
|
+
latest: dict[int, dict[str, Any]],
|
|
108
|
+
pred_commits: dict[int, str],
|
|
109
|
+
candidate_base: str,
|
|
110
|
+
stage_n: int,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Ensure all predecessor commits are merged into the candidate base."""
|
|
113
|
+
from .git_reconcile import content_merged, _record_reconciled
|
|
114
|
+
|
|
115
|
+
for dep_stage, head in pred_commits.items():
|
|
116
|
+
if commit_is_ancestor(project_root, head, candidate_base):
|
|
117
|
+
continue
|
|
118
|
+
match = content_merged(project_root, head, candidate_base)
|
|
119
|
+
if match.status in ("ancestor", "patch-equivalent"):
|
|
120
|
+
if plan_run_root is not None:
|
|
121
|
+
_record_reconciled(
|
|
122
|
+
plan_run_root,
|
|
123
|
+
impl_task_key=(latest.get(dep_stage) or {}).get("impl_task_key", ""),
|
|
124
|
+
stage=dep_stage,
|
|
125
|
+
new_commit=match.matched_commit,
|
|
126
|
+
replaced=head,
|
|
127
|
+
reason="auto-patch-id",
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
raise StageTargetError(
|
|
131
|
+
f"multi-dependency stage {stage_n}: predecessor stage {dep_stage} "
|
|
132
|
+
f"({head[:8]}) is not merged into the task worktree "
|
|
133
|
+
f"({candidate_base[:8]}). Merge stage branches "
|
|
134
|
+
f"(e.g. the `-s{dep_stage}` branches) into the task worktree "
|
|
135
|
+
"(or into main, then refresh the worktree) and retry."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_stage_base_commit(
|
|
140
|
+
stage: dict[str, Any],
|
|
141
|
+
consumer_done_rows: list[dict[str, Any]],
|
|
142
|
+
anchor_base_commit: str,
|
|
143
|
+
candidate_base: str = "",
|
|
144
|
+
project_root: Path | None = None,
|
|
145
|
+
plan_run_root: Path | None = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Pick the git base commit a stage's isolated worktree branches from."""
|
|
148
|
+
from .consumers import latest_done_by_stage
|
|
149
|
+
|
|
150
|
+
latest = latest_done_by_stage(consumer_done_rows)
|
|
151
|
+
deps = stage.get("depends_on") or []
|
|
152
|
+
if len(deps) >= 2:
|
|
153
|
+
stage_number = stage["stage_number"]
|
|
154
|
+
pred_commits: dict[int, str] = {}
|
|
155
|
+
for dep_stage in deps:
|
|
156
|
+
head = (latest.get(dep_stage) or {}).get("head_commit")
|
|
157
|
+
if not head:
|
|
158
|
+
raise StageTargetError(
|
|
159
|
+
f"predecessor stage {dep_stage} has no done row with head_commit "
|
|
160
|
+
f"in consumers.jsonl; multi-dependency stage {stage_number} cannot start"
|
|
161
|
+
)
|
|
162
|
+
pred_commits[dep_stage] = head
|
|
163
|
+
if not candidate_base or project_root is None:
|
|
164
|
+
raise StageTargetError(
|
|
165
|
+
f"candidate base missing for multi-dependency stage {stage_number}; "
|
|
166
|
+
"task-key worktree HEAD could not be resolved"
|
|
167
|
+
)
|
|
168
|
+
check_multi_dep_merged(
|
|
169
|
+
project_root,
|
|
170
|
+
plan_run_root,
|
|
171
|
+
latest,
|
|
172
|
+
pred_commits,
|
|
173
|
+
candidate_base,
|
|
174
|
+
stage_number,
|
|
175
|
+
)
|
|
176
|
+
return candidate_base
|
|
177
|
+
if not deps:
|
|
178
|
+
if not anchor_base_commit:
|
|
179
|
+
raise StageTargetError(
|
|
180
|
+
f"anchor base commit missing for independent stage "
|
|
181
|
+
f"{stage['stage_number']}; first-stage prepare should have "
|
|
182
|
+
"fixed it via worktree_registry.set_implementation_base"
|
|
183
|
+
)
|
|
184
|
+
return anchor_base_commit
|
|
185
|
+
pred = deps[0]
|
|
186
|
+
head = (latest.get(pred) or {}).get("head_commit") or ""
|
|
187
|
+
if head:
|
|
188
|
+
return head
|
|
189
|
+
raise StageTargetError(
|
|
190
|
+
f"predecessor stage {pred} has no done row with head_commit in "
|
|
191
|
+
"consumers.jsonl; cannot derive base for stage "
|
|
192
|
+
f"{stage['stage_number']}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_whole_task_target(
|
|
197
|
+
*,
|
|
198
|
+
stage_map: list[dict[str, Any]],
|
|
199
|
+
done_rows: list[dict[str, Any]],
|
|
200
|
+
anchor_base: str,
|
|
201
|
+
task_worktree_path: str,
|
|
202
|
+
task_head: str,
|
|
203
|
+
task_dirty: bool,
|
|
204
|
+
merged: dict[int, bool],
|
|
205
|
+
) -> FinalVerificationTarget:
|
|
206
|
+
"""Resolve whole-task final-verification target, enforcing all gates."""
|
|
207
|
+
from .consumers import latest_done_by_stage
|
|
208
|
+
|
|
209
|
+
done_by_stage = latest_done_by_stage(done_rows)
|
|
210
|
+
for stage in stage_map:
|
|
211
|
+
n = stage["stage_number"]
|
|
212
|
+
if n not in done_by_stage:
|
|
213
|
+
raise StageTargetError(
|
|
214
|
+
f"final-verification(whole-task): stage {n} not done — "
|
|
215
|
+
f"run implementation --stage {n} first"
|
|
216
|
+
)
|
|
217
|
+
if not merged.get(n, False):
|
|
218
|
+
sha = done_by_stage[n].get("head_commit", "")
|
|
219
|
+
raise StageTargetError(
|
|
220
|
+
f"final-verification(whole-task): stage {n} done commit "
|
|
221
|
+
f"{sha} not merged into task worktree HEAD — merge stage "
|
|
222
|
+
"branches then retry"
|
|
223
|
+
)
|
|
224
|
+
if task_dirty:
|
|
225
|
+
raise StageTargetError(
|
|
226
|
+
"final-verification: worktree has uncommitted source changes "
|
|
227
|
+
"(outside .okstra/) — commit or stash before verifying"
|
|
228
|
+
)
|
|
229
|
+
stages = [s["stage_number"] for s in stage_map]
|
|
230
|
+
reports = [done_by_stage[n].get("report_path", "") for n in stages]
|
|
231
|
+
return FinalVerificationTarget(
|
|
232
|
+
scope="whole-task",
|
|
233
|
+
base=anchor_base,
|
|
234
|
+
head=task_head,
|
|
235
|
+
worktree_path=task_worktree_path,
|
|
236
|
+
stages=stages,
|
|
237
|
+
reports=reports,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve_single_stage_target(
|
|
242
|
+
*,
|
|
243
|
+
requested_stage: str,
|
|
244
|
+
done_rows: list[dict[str, Any]],
|
|
245
|
+
stage_base: str,
|
|
246
|
+
stage_worktree_path: str,
|
|
247
|
+
stage_head: str,
|
|
248
|
+
stage_dirty: bool,
|
|
249
|
+
) -> FinalVerificationTarget:
|
|
250
|
+
"""Resolve single-stage final-verification target, enforcing all gates."""
|
|
251
|
+
from .consumers import latest_done_by_stage
|
|
252
|
+
|
|
253
|
+
n = int(requested_stage)
|
|
254
|
+
done_by_stage = latest_done_by_stage(done_rows)
|
|
255
|
+
if n not in done_by_stage:
|
|
256
|
+
raise StageTargetError(
|
|
257
|
+
f"final-verification(single-stage): stage {n} not done — "
|
|
258
|
+
f"run implementation --stage {n} first"
|
|
259
|
+
)
|
|
260
|
+
if not stage_worktree_path:
|
|
261
|
+
raise StageTargetError(
|
|
262
|
+
f"final-verification(single-stage): stage worktree not found for "
|
|
263
|
+
f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
|
|
264
|
+
)
|
|
265
|
+
if stage_dirty:
|
|
266
|
+
raise StageTargetError(
|
|
267
|
+
"final-verification: worktree has uncommitted source changes "
|
|
268
|
+
"(outside .okstra/) — commit or stash before verifying"
|
|
269
|
+
)
|
|
270
|
+
return FinalVerificationTarget(
|
|
271
|
+
scope="single-stage",
|
|
272
|
+
base=stage_base,
|
|
273
|
+
head=stage_head,
|
|
274
|
+
worktree_path=stage_worktree_path,
|
|
275
|
+
stages=[n],
|
|
276
|
+
reports=[done_by_stage[n].get("report_path", "")],
|
|
277
|
+
)
|
|
@@ -44,6 +44,7 @@ from okstra_ctl.run import (
|
|
|
44
44
|
_reject_blocking_plan_body_gate,
|
|
45
45
|
_set_data_json_approved_true_if_present,
|
|
46
46
|
_validate_data_json_approval_consistency,
|
|
47
|
+
recommended_role_models,
|
|
47
48
|
)
|
|
48
49
|
from okstra_ctl.user_response import (
|
|
49
50
|
UserResponseApprovalRecord,
|
|
@@ -93,6 +94,15 @@ TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
|
|
|
93
94
|
|
|
94
95
|
EXECUTORS = ["claude", "codex", "antigravity"]
|
|
95
96
|
|
|
97
|
+
# 역할 한 줄 설명 — defaults_or_custom 안내에서 각 역할이 무슨 일을 하는지 보여준다.
|
|
98
|
+
ROLE_BLURBS = {
|
|
99
|
+
"lead": "Claude lead · 워커 dispatch·교차검증 수렴·최종 합성",
|
|
100
|
+
"claude": "독립 분석 워커 (Claude)",
|
|
101
|
+
"codex": "독립 분석 워커 (Codex)",
|
|
102
|
+
"antigravity": "독립 분석 워커 (Antigravity)",
|
|
103
|
+
"report-writer": "최종 리포트 작성 (분석은 하지 않음)",
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
# Task types that consume an approved plan and need a stage-scope pick:
|
|
97
107
|
# implementation executes a stage, final-verification verifies a stage
|
|
98
108
|
# (or the whole task via `auto`). Both gate the approved-plan + stage steps.
|
|
@@ -2357,11 +2367,34 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
|
|
|
2357
2367
|
f"lead-model={state.lead_model or 'default'})")
|
|
2358
2368
|
|
|
2359
2369
|
|
|
2370
|
+
def _role_model_lines(state: WizardState) -> str:
|
|
2371
|
+
"""이번 run 에서 실제로 모델을 고르게 되는 역할만, 추천 모델과 함께 나열한다.
|
|
2372
|
+
뒤따르는 *_model 단계의 등장 조건과 1:1 로 맞춰 안내와 실제 화면이 어긋나지
|
|
2373
|
+
않게 한다 (그래서 분석에 참여하지 않는 antigravity 는 executor 일 때만 나온다)."""
|
|
2374
|
+
rec = recommended_role_models()
|
|
2375
|
+
roster = _resolved_roster(state)
|
|
2376
|
+
impl = state.task_type == "implementation"
|
|
2377
|
+
|
|
2378
|
+
def line(role_label: str, blurb: str, model: str) -> str:
|
|
2379
|
+
return f" · {role_label} ({blurb}) — 추천 {model}"
|
|
2380
|
+
|
|
2381
|
+
lines = [line("lead", ROLE_BLURBS["lead"], rec["lead"])]
|
|
2382
|
+
if impl:
|
|
2383
|
+
ex = state.executor or "claude"
|
|
2384
|
+
lines.append(line(f"executor={ex}", "코드 구현 실행자", rec.get(ex, "auto")))
|
|
2385
|
+
else:
|
|
2386
|
+
for w in ("claude", "codex", "antigravity"):
|
|
2387
|
+
if w in roster:
|
|
2388
|
+
lines.append(line(w, ROLE_BLURBS[w], rec[w]))
|
|
2389
|
+
if impl or "report-writer" in roster:
|
|
2390
|
+
lines.append(line("report-writer", ROLE_BLURBS["report-writer"],
|
|
2391
|
+
rec["report-writer"]))
|
|
2392
|
+
return "\n".join(lines)
|
|
2393
|
+
|
|
2394
|
+
|
|
2360
2395
|
def _build_defaults_or_custom(state: WizardState) -> Prompt:
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
or "(profile default)")
|
|
2364
|
-
t = _p(state.workspace_root, "defaults_or_custom", workers=roster)
|
|
2396
|
+
t = _p(state.workspace_root, "defaults_or_custom",
|
|
2397
|
+
role_models=_role_model_lines(state))
|
|
2365
2398
|
return Prompt(
|
|
2366
2399
|
step=S_DEFAULTS_OR_CUSTOM, kind="pick",
|
|
2367
2400
|
label=t["label"],
|
|
@@ -3306,11 +3339,16 @@ def _remaining_screens(state: WizardState) -> int:
|
|
|
3306
3339
|
return screens
|
|
3307
3340
|
|
|
3308
3341
|
|
|
3309
|
-
def _screen_progress(state: WizardState) -> dict[str,
|
|
3342
|
+
def _screen_progress(state: WizardState) -> dict[str, Any]:
|
|
3310
3343
|
passed = _passed_screens(state)
|
|
3311
3344
|
total = passed + _remaining_screens(state)
|
|
3312
3345
|
index = passed + 1
|
|
3313
|
-
|
|
3346
|
+
remaining = max(0, total - index)
|
|
3347
|
+
suffix = "마지막 단계" if remaining == 0 else f"앞으로 {remaining} 스텝 남음"
|
|
3348
|
+
return {
|
|
3349
|
+
"index": index, "total": total, "remaining": remaining,
|
|
3350
|
+
"label": f"Step {index}/{total} · {suffix}",
|
|
3351
|
+
}
|
|
3314
3352
|
|
|
3315
3353
|
|
|
3316
3354
|
def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
|
|
@@ -32,7 +32,7 @@ Every wizard call returns JSON. The two shapes you'll see:
|
|
|
32
32
|
"progress": { "index": 5, "total": 11, "remaining": 6 } } }
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). `index`
|
|
35
|
+
Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). It holds `index` (1-based number of the **current screen**; pick_group counts as one screen), `total` (the wizard's forward estimate of the full screen count — may grow by a few when the user opens a branch, e.g. choosing *Customize* adds the model screen), `remaining` (`total − index`), and **`label`** — the ready-to-render Korean marker the wizard already composed (e.g. `Step 10/11 · 앞으로 1 스텝 남음`, or `Step 11/11 · 마지막 단계` on the final screen). **Always suffix the rendered prompt with `progress.label` verbatim** — see Step 3. Do not recompute the marker or decide "마지막 단계" yourself; only the wizard knows whether more screens follow.
|
|
36
36
|
|
|
37
37
|
```json
|
|
38
38
|
{ "ok": false, "error": "approved plan has no APPROVED marker: ...",
|
|
@@ -96,7 +96,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
|
|
|
96
96
|
|
|
97
97
|
Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
|
|
98
98
|
|
|
99
|
-
1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix
|
|
99
|
+
1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix it with ` (<next.progress.label>)` — render `progress.label` exactly as the wizard sent it, never recompute it. Example: label `Step 8/11 · 앞으로 3 스텝 남음` → `모델을 선택하세요 (Step 8/11 · 앞으로 3 스텝 남음)`. Re-prompts after `ok: false` reuse `current.progress.label` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
|
|
100
100
|
- `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
|
|
101
101
|
- `pick` + `multi: true` → `AskUserQuestion` with `multiSelect: true`, `label`, and `options`. Join the selected `value`s with `,` into a single literal CSV string (e.g. `"claude,codex,antigravity"`) and submit it as a single `--answer "claude,codex,antigravity"`. Empty selection submits `--answer ""` and the wizard re-prompts.
|
|
102
102
|
- `pick_group` → one `AskUserQuestion` with one question per `questions[]` entry (tab). Map each tab's selected `value` back by `questions[].step`, assemble a JSON object, and submit it as a single literal `--answer '<json>'`.
|