okstra 0.91.1 → 0.92.0
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
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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:
|
|
@@ -1511,23 +1382,13 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
|
|
|
1511
1382
|
)
|
|
1512
1383
|
|
|
1513
1384
|
|
|
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)
|
|
1385
|
+
StageSelection = _implementation_stage.StageSelection
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _implementation_stage_prepare_error(
|
|
1389
|
+
exc: _implementation_stage.ImplementationStageError,
|
|
1390
|
+
) -> PrepareError:
|
|
1391
|
+
return PrepareError(str(exc))
|
|
1531
1392
|
|
|
1532
1393
|
|
|
1533
1394
|
def _select_and_provision_implementation_stage(
|
|
@@ -1538,102 +1399,18 @@ def _select_and_provision_implementation_stage(
|
|
|
1538
1399
|
task_key: str,
|
|
1539
1400
|
executor_worktree_status: str,
|
|
1540
1401
|
) -> 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
|
-
)
|
|
1402
|
+
"""Compatibility wrapper for implementation stage selection."""
|
|
1609
1403
|
try:
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
task_group_segment
|
|
1614
|
-
task_id_segment
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
base_commit=stage_base,
|
|
1404
|
+
return _implementation_stage.select_and_provision_implementation_stage(
|
|
1405
|
+
inp,
|
|
1406
|
+
ctx_stage_map,
|
|
1407
|
+
task_group_segment,
|
|
1408
|
+
task_id_segment,
|
|
1409
|
+
task_key,
|
|
1410
|
+
executor_worktree_status,
|
|
1618
1411
|
)
|
|
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
|
-
)
|
|
1412
|
+
except _implementation_stage.ImplementationStageError as exc:
|
|
1413
|
+
raise _implementation_stage_prepare_error(exc) from exc
|
|
1637
1414
|
|
|
1638
1415
|
|
|
1639
1416
|
def _apply_implementation_stage(
|
|
@@ -1642,44 +1419,8 @@ def _apply_implementation_stage(
|
|
|
1642
1419
|
ctx_stage_map: list,
|
|
1643
1420
|
sel: StageSelection,
|
|
1644
1421
|
) -> 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
|
-
)
|
|
1422
|
+
"""Compatibility wrapper for publishing implementation stage selection."""
|
|
1423
|
+
_implementation_stage.apply_implementation_stage(inp, ctx, ctx_stage_map, sel)
|
|
1683
1424
|
|
|
1684
1425
|
|
|
1685
1426
|
def _git_out(cwd, *args) -> str:
|
|
@@ -1689,17 +1430,7 @@ def _git_out(cwd, *args) -> str:
|
|
|
1689
1430
|
|
|
1690
1431
|
|
|
1691
1432
|
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)
|
|
1433
|
+
_stage_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1703
1434
|
|
|
1704
1435
|
|
|
1705
1436
|
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
|
+
)
|