okstra 0.136.0 → 0.138.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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +32 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +4 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +18 -19
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +262 -328
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +330 -101
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +47 -49
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/lib/fixtures.sh +2 -0
- package/runtime/validators/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -15,6 +15,7 @@ from datetime import datetime, timezone
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from okstra_ctl.ids import slugify_task_segment
|
|
18
|
+
from okstra_ctl.paths import task_dir, task_manifest_file
|
|
18
19
|
from okstra_project import (
|
|
19
20
|
ResolverError,
|
|
20
21
|
StateError,
|
|
@@ -40,7 +41,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
|
|
|
40
41
|
id_seg = entry.get("taskIdPathSegment") or slugify_task_segment(
|
|
41
42
|
entry.get("taskId", "")
|
|
42
43
|
)
|
|
43
|
-
return project_root
|
|
44
|
+
return task_manifest_file(task_dir(project_root, group_seg, id_seg))
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -8,10 +8,12 @@ the stage lifecycle rules behind one interface instead of leaking raw
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import heapq
|
|
11
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
|
+
from .stage_integrate import IntegrateResult
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
RUN_STEP_BUDGET = 8
|
|
17
19
|
|
|
@@ -38,6 +40,29 @@ class FinalVerificationTarget:
|
|
|
38
40
|
reports: list[str]
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class FinalVerificationTargetRequest:
|
|
45
|
+
"""Semantic inputs needed to acquire one final-verification target."""
|
|
46
|
+
|
|
47
|
+
project_root: Path
|
|
48
|
+
project_id: str
|
|
49
|
+
task_group: str
|
|
50
|
+
task_id: str
|
|
51
|
+
work_category: str
|
|
52
|
+
approved_plan_path: Path
|
|
53
|
+
stage: int | None
|
|
54
|
+
stage_map: tuple[dict[str, Any], ...]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class FinalVerificationTargetAcquisition:
|
|
59
|
+
"""Resolved target facts returned to the prepare adapter."""
|
|
60
|
+
|
|
61
|
+
target: FinalVerificationTarget
|
|
62
|
+
worktree_branch: str
|
|
63
|
+
integration_result: IntegrateResult | None
|
|
64
|
+
|
|
65
|
+
|
|
41
66
|
@dataclass(frozen=True)
|
|
42
67
|
class StageLifecycle:
|
|
43
68
|
stage: int
|
|
@@ -49,6 +74,23 @@ class StageLifecycle:
|
|
|
49
74
|
pr_covered: bool
|
|
50
75
|
head_commit: str
|
|
51
76
|
report_path: str
|
|
77
|
+
step_count: int
|
|
78
|
+
deps_satisfied: bool
|
|
79
|
+
blocked_by: list[int]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def status(self) -> str:
|
|
83
|
+
"""Lifecycle state, in precedence order: done > active > ready > blocked.
|
|
84
|
+
|
|
85
|
+
``started`` and ``reserved`` collapse into ``active`` because every
|
|
86
|
+
caller that distinguishes them already words the two the same way (see
|
|
87
|
+
the numeric-request error in ``resolve_effective_stages``).
|
|
88
|
+
"""
|
|
89
|
+
if self.done:
|
|
90
|
+
return "done"
|
|
91
|
+
if self.started or self.reserved:
|
|
92
|
+
return "active"
|
|
93
|
+
return "ready" if self.deps_satisfied else "blocked"
|
|
52
94
|
|
|
53
95
|
def handoff_eligibility_record(self) -> dict[str, Any]:
|
|
54
96
|
reasons: list[str] = []
|
|
@@ -92,14 +134,67 @@ class StageLifecycleSnapshot:
|
|
|
92
134
|
requested: str,
|
|
93
135
|
budget: int = RUN_STEP_BUDGET,
|
|
94
136
|
) -> list[int]:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
137
|
+
"""Return ordered stage numbers this run should execute.
|
|
138
|
+
|
|
139
|
+
``requested`` is ``"auto"`` or a decimal string. Auto selection returns
|
|
140
|
+
every stage reporting ``ready`` within the step budget; numeric
|
|
141
|
+
selection returns one forced stage. Readiness itself is not decided
|
|
142
|
+
here — ``StageLifecycle.status`` is the single judgment point.
|
|
143
|
+
"""
|
|
144
|
+
if requested != "auto":
|
|
145
|
+
return [self._forced_stage(requested)]
|
|
146
|
+
|
|
147
|
+
ready = [lc for lc in self.lifecycles if lc.status == "ready"]
|
|
148
|
+
if not ready:
|
|
149
|
+
raise StageTargetError(self._no_ready_stage_reason())
|
|
150
|
+
|
|
151
|
+
batch: list[int] = []
|
|
152
|
+
total = 0
|
|
153
|
+
for lifecycle in ready:
|
|
154
|
+
if batch and total + lifecycle.step_count > budget:
|
|
155
|
+
break
|
|
156
|
+
batch.append(lifecycle.stage)
|
|
157
|
+
total += lifecycle.step_count
|
|
158
|
+
return batch
|
|
159
|
+
|
|
160
|
+
def _forced_stage(self, requested: str) -> int:
|
|
161
|
+
try:
|
|
162
|
+
n = int(requested)
|
|
163
|
+
except ValueError as exc:
|
|
164
|
+
raise StageTargetError(
|
|
165
|
+
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
166
|
+
) from exc
|
|
167
|
+
by_number = {lc.stage: lc for lc in self.lifecycles}
|
|
168
|
+
lifecycle = by_number.get(n)
|
|
169
|
+
if lifecycle is None:
|
|
170
|
+
raise StageTargetError(
|
|
171
|
+
f"--stage {n} not in Stage Map (have {list(by_number)})"
|
|
172
|
+
)
|
|
173
|
+
if lifecycle.status == "done":
|
|
174
|
+
raise StageTargetError(
|
|
175
|
+
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
176
|
+
)
|
|
177
|
+
if lifecycle.status == "active":
|
|
178
|
+
# Wording is load-bearing: okstra-run's unattended chain matches it
|
|
179
|
+
# to terminate normally instead of raising an exception gate.
|
|
180
|
+
raise StageTargetError(
|
|
181
|
+
f"--stage {n} already in progress or reserved by another run"
|
|
182
|
+
)
|
|
183
|
+
return n
|
|
184
|
+
|
|
185
|
+
def _no_ready_stage_reason(self) -> str:
|
|
186
|
+
blocked = [lc for lc in self.lifecycles if lc.status == "blocked"]
|
|
187
|
+
if not blocked:
|
|
188
|
+
return (
|
|
189
|
+
"no stage is ready: every stage is done or occupied by "
|
|
190
|
+
"another run"
|
|
191
|
+
)
|
|
192
|
+
detail = "; ".join(
|
|
193
|
+
f"stage {lc.stage} blocked by "
|
|
194
|
+
+ ", ".join(str(d) for d in lc.blocked_by)
|
|
195
|
+
for lc in blocked
|
|
102
196
|
)
|
|
197
|
+
return f"no stage is ready: {detail}"
|
|
103
198
|
|
|
104
199
|
def resolve_implementation_stage(self, requested: str) -> int:
|
|
105
200
|
return self.resolve_effective_stages(requested)[0]
|
|
@@ -131,9 +226,16 @@ def stage_lifecycle_snapshot_from_state(
|
|
|
131
226
|
for stage in stage_map:
|
|
132
227
|
stage_number = stage["stage_number"]
|
|
133
228
|
done_row = consumer_state.done_by_stage.get(stage_number) or {}
|
|
229
|
+
depends_on = list(stage.get("depends_on") or [])
|
|
230
|
+
blocked_by = [
|
|
231
|
+
d for d in depends_on if d not in consumer_state.done_stages
|
|
232
|
+
]
|
|
134
233
|
lifecycles.append(StageLifecycle(
|
|
135
234
|
stage=stage_number,
|
|
136
|
-
depends_on=
|
|
235
|
+
depends_on=depends_on,
|
|
236
|
+
step_count=int(stage.get("step_count") or 0),
|
|
237
|
+
deps_satisfied=not blocked_by,
|
|
238
|
+
blocked_by=blocked_by,
|
|
137
239
|
done=stage_number in consumer_state.done_stages,
|
|
138
240
|
started=stage_number in consumer_state.started_stages,
|
|
139
241
|
reserved=stage_number in reserved,
|
|
@@ -198,56 +300,22 @@ def resolve_effective_stages(
|
|
|
198
300
|
started_stages: set[int] | None = None,
|
|
199
301
|
reserved_stages: set[int] | None = None,
|
|
200
302
|
) -> list[int]:
|
|
201
|
-
"""
|
|
303
|
+
"""Adapter over ``StageLifecycleSnapshot.resolve_effective_stages``.
|
|
202
304
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
305
|
+
Entry point for callers holding raw stage-map and consumer sets rather than
|
|
306
|
+
a snapshot. It only assembles the snapshot; the readiness judgment lives on
|
|
307
|
+
``StageLifecycle.status``.
|
|
206
308
|
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
occupied = done_stages | started_stages | reserved_stages
|
|
210
|
-
if requested != "auto":
|
|
211
|
-
try:
|
|
212
|
-
n = int(requested)
|
|
213
|
-
except ValueError as exc:
|
|
214
|
-
raise StageTargetError(
|
|
215
|
-
f"--stage must be 'auto' or an integer, got {requested!r}"
|
|
216
|
-
) from exc
|
|
217
|
-
target = next((s for s in stages if s["stage_number"] == n), None)
|
|
218
|
-
if target is None:
|
|
219
|
-
raise StageTargetError(
|
|
220
|
-
f"--stage {n} not in Stage Map "
|
|
221
|
-
f"(have {[s['stage_number'] for s in stages]})"
|
|
222
|
-
)
|
|
223
|
-
if n in done_stages:
|
|
224
|
-
raise StageTargetError(
|
|
225
|
-
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
226
|
-
)
|
|
227
|
-
if n in started_stages or n in reserved_stages:
|
|
228
|
-
raise StageTargetError(
|
|
229
|
-
f"--stage {n} already in progress or reserved by another run"
|
|
230
|
-
)
|
|
231
|
-
return [n]
|
|
232
|
-
|
|
233
|
-
ready = [
|
|
234
|
-
s for s in stages
|
|
235
|
-
if s["stage_number"] not in occupied
|
|
236
|
-
and all(d in done_stages for d in s["depends_on"])
|
|
309
|
+
rows: list[dict[str, Any]] = [
|
|
310
|
+
{"stage": n, "status": "done"} for n in sorted(done_stages)
|
|
237
311
|
]
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
step_count = stage.get("step_count", 0) or 0
|
|
246
|
-
if batch and total + step_count > budget:
|
|
247
|
-
break
|
|
248
|
-
batch.append(stage["stage_number"])
|
|
249
|
-
total += step_count
|
|
250
|
-
return batch
|
|
312
|
+
rows += [
|
|
313
|
+
{"stage": n, "status": "started"} for n in sorted(started_stages or set())
|
|
314
|
+
]
|
|
315
|
+
snapshot = stage_lifecycle_snapshot_from_rows(
|
|
316
|
+
stages, rows, reserved_stages=reserved_stages,
|
|
317
|
+
)
|
|
318
|
+
return snapshot.resolve_effective_stages(requested, budget=budget)
|
|
251
319
|
|
|
252
320
|
|
|
253
321
|
def resolve_implementation_stage(
|
|
@@ -430,7 +498,7 @@ def resolve_stage_base_commit(
|
|
|
430
498
|
)
|
|
431
499
|
|
|
432
500
|
|
|
433
|
-
def
|
|
501
|
+
def _resolve_whole_task_target(
|
|
434
502
|
*,
|
|
435
503
|
stage_map: list[dict[str, Any]],
|
|
436
504
|
done_rows: list[dict[str, Any]],
|
|
@@ -490,65 +558,80 @@ def _merged_stage_map(
|
|
|
490
558
|
}
|
|
491
559
|
|
|
492
560
|
|
|
493
|
-
def
|
|
561
|
+
def _resolve_and_integrate_whole_task_unlocked(
|
|
494
562
|
*, project_id: str, task_group: str, task_id: str,
|
|
495
563
|
task_worktree_path: str, stage_map: list[dict[str, Any]],
|
|
496
564
|
done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
|
|
497
565
|
) -> dict[str, Any]:
|
|
498
|
-
"""whole-task
|
|
499
|
-
|
|
500
|
-
final-verification(teardown=True)과 container(teardown=False)의 단일 참조점.
|
|
501
|
-
ctx/PrepareInputs 결합 없는 헬퍼. 머지 충돌·미머지·dirty(.okstra 외)면
|
|
502
|
-
`PrepareError`. 반환: head_commit / merged_stage_map / target_ref / target /
|
|
503
|
-
integrate_result.
|
|
504
|
-
|
|
505
|
-
머지(`git merge`)+teardown 은 task-key 단위 `worktree_provision_mutex` 로
|
|
506
|
-
감싼다 — 동시 `container up` 둘 또는 container up × whole-task
|
|
507
|
-
final-verification 이 같은 공유 worktree 의 index/HEAD 에 동시에 머지하면
|
|
508
|
-
index.lock 충돌·반쯤 적용된 머지로 트리가 깨진다. 두 호출 지점(container 의
|
|
509
|
-
`_integrate_source_tree`, run.py 의 `_reserve_final_verification_target`)
|
|
510
|
-
모두 이 락 밖에서 진입하므로 self-deadlock 없이 여기서 1회만 잡는다."""
|
|
566
|
+
"""Integrate and resolve a whole-task target while the caller owns the lock."""
|
|
511
567
|
from .stage_integrate import IntegrateError, integrate_stages
|
|
512
568
|
from .worktree import _git, is_dirty_excluding_okstra
|
|
513
|
-
from .locks import worktree_provision_mutex
|
|
514
|
-
from okstra_project.dirs import okstra_home
|
|
515
|
-
from okstra_project.state import slugify
|
|
516
569
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
570
|
+
try:
|
|
571
|
+
integration = integrate_stages(
|
|
572
|
+
project_id=project_id,
|
|
573
|
+
task_group=task_group,
|
|
574
|
+
task_id=task_id,
|
|
575
|
+
task_worktree_path=task_worktree_path,
|
|
576
|
+
stage_map=stage_map,
|
|
577
|
+
done_rows=done_rows,
|
|
578
|
+
teardown=teardown,
|
|
579
|
+
)
|
|
580
|
+
except IntegrateError as exc:
|
|
581
|
+
raise PrepareError(str(exc)) from exc
|
|
582
|
+
|
|
583
|
+
head = _git(task_worktree_path, "rev-parse", "HEAD").stdout.strip()
|
|
584
|
+
merged = _merged_stage_map(task_worktree_path, done_rows, head)
|
|
585
|
+
try:
|
|
586
|
+
target = _resolve_whole_task_target(
|
|
587
|
+
stage_map=stage_map,
|
|
588
|
+
done_rows=done_rows,
|
|
589
|
+
anchor_base=anchor_base,
|
|
590
|
+
task_worktree_path=task_worktree_path,
|
|
591
|
+
task_head=head,
|
|
592
|
+
task_dirty=is_dirty_excluding_okstra(task_worktree_path),
|
|
593
|
+
merged=merged,
|
|
594
|
+
)
|
|
595
|
+
except StageTargetError as exc:
|
|
596
|
+
raise PrepareError(str(exc)) from exc
|
|
540
597
|
return {
|
|
541
598
|
"head_commit": head,
|
|
542
599
|
"merged_stage_map": merged,
|
|
543
600
|
"target_ref": target.head,
|
|
544
601
|
"target": target,
|
|
545
|
-
"integrate_result":
|
|
602
|
+
"integrate_result": integration,
|
|
546
603
|
}
|
|
547
604
|
|
|
548
605
|
|
|
549
|
-
def
|
|
606
|
+
def resolve_and_integrate_whole_task(
|
|
607
|
+
*, project_id: str, task_group: str, task_id: str,
|
|
608
|
+
task_worktree_path: str, stage_map: list[dict[str, Any]],
|
|
609
|
+
done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
|
|
610
|
+
) -> dict[str, Any]:
|
|
611
|
+
"""Locked whole-task integration interface shared with the container."""
|
|
612
|
+
from okstra_project.dirs import okstra_home
|
|
613
|
+
from okstra_project.state import slugify
|
|
614
|
+
|
|
615
|
+
from .locks import worktree_provision_mutex
|
|
616
|
+
|
|
617
|
+
with worktree_provision_mutex(
|
|
618
|
+
okstra_home(), project_id, slugify(task_group), slugify(task_id),
|
|
619
|
+
):
|
|
620
|
+
return _resolve_and_integrate_whole_task_unlocked(
|
|
621
|
+
project_id=project_id,
|
|
622
|
+
task_group=task_group,
|
|
623
|
+
task_id=task_id,
|
|
624
|
+
task_worktree_path=task_worktree_path,
|
|
625
|
+
stage_map=stage_map,
|
|
626
|
+
done_rows=done_rows,
|
|
627
|
+
anchor_base=anchor_base,
|
|
628
|
+
teardown=teardown,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _resolve_single_stage_target(
|
|
550
633
|
*,
|
|
551
|
-
requested_stage:
|
|
634
|
+
requested_stage: int,
|
|
552
635
|
done_rows: list[dict[str, Any]],
|
|
553
636
|
stage_base: str,
|
|
554
637
|
stage_worktree_path: str,
|
|
@@ -558,7 +641,7 @@ def resolve_single_stage_target(
|
|
|
558
641
|
"""Resolve single-stage final-verification target, enforcing all gates."""
|
|
559
642
|
from .consumers import latest_done_by_stage
|
|
560
643
|
|
|
561
|
-
n =
|
|
644
|
+
n = requested_stage
|
|
562
645
|
done_by_stage = latest_done_by_stage(done_rows)
|
|
563
646
|
if n not in done_by_stage:
|
|
564
647
|
raise StageTargetError(
|
|
@@ -583,3 +666,149 @@ def resolve_single_stage_target(
|
|
|
583
666
|
stages=[n],
|
|
584
667
|
reports=[done_by_stage[n].get("report_path", "")],
|
|
585
668
|
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _read_final_verification_done_rows(
|
|
672
|
+
request: FinalVerificationTargetRequest,
|
|
673
|
+
registry_coordinates: tuple[str, str, str],
|
|
674
|
+
) -> list[dict[str, Any]]:
|
|
675
|
+
from .consumers import backfill_done_from_carry, read_stage_consumer_state
|
|
676
|
+
from .plan_run_root import plan_run_root_from_approved_plan
|
|
677
|
+
from .stage_reconcile import auto_reconcile_best_effort
|
|
678
|
+
|
|
679
|
+
plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
|
|
680
|
+
backfill_done_from_carry(plan_run_root)
|
|
681
|
+
project_id, task_group, task_id = registry_coordinates
|
|
682
|
+
auto_reconcile_best_effort(
|
|
683
|
+
replace(
|
|
684
|
+
request,
|
|
685
|
+
project_id=project_id,
|
|
686
|
+
task_group=task_group,
|
|
687
|
+
task_id=task_id,
|
|
688
|
+
),
|
|
689
|
+
plan_run_root,
|
|
690
|
+
)
|
|
691
|
+
return read_stage_consumer_state(plan_run_root).done_rows
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _final_verification_registry_coordinates(
|
|
695
|
+
request: FinalVerificationTargetRequest,
|
|
696
|
+
) -> tuple[str, str, str]:
|
|
697
|
+
from okstra_project.state import slugify
|
|
698
|
+
|
|
699
|
+
def segment(value: str) -> str:
|
|
700
|
+
return slugify(value) or "_"
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
segment(request.project_id),
|
|
704
|
+
segment(request.task_group),
|
|
705
|
+
segment(request.task_id),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _acquire_single_stage_target(
|
|
710
|
+
request: FinalVerificationTargetRequest,
|
|
711
|
+
done_rows: list[dict[str, Any]],
|
|
712
|
+
registry_coordinates: tuple[str, str, str],
|
|
713
|
+
) -> FinalVerificationTargetAcquisition:
|
|
714
|
+
from . import worktree_registry
|
|
715
|
+
from .worktree import _git, is_dirty_excluding_okstra
|
|
716
|
+
|
|
717
|
+
stage = request.stage
|
|
718
|
+
assert stage is not None
|
|
719
|
+
row = worktree_registry.get_stage_row(*registry_coordinates, stage)
|
|
720
|
+
worktree_path = (row or {}).get("worktree_path", "")
|
|
721
|
+
head = ""
|
|
722
|
+
if worktree_path and Path(worktree_path).is_dir():
|
|
723
|
+
head_result = _git(worktree_path, "rev-parse", "HEAD")
|
|
724
|
+
if head_result.returncode == 0:
|
|
725
|
+
head = head_result.stdout.strip()
|
|
726
|
+
if not head:
|
|
727
|
+
worktree_path = ""
|
|
728
|
+
target = _resolve_single_stage_target(
|
|
729
|
+
requested_stage=stage,
|
|
730
|
+
done_rows=done_rows,
|
|
731
|
+
stage_base=(row or {}).get("base_ref", ""),
|
|
732
|
+
stage_worktree_path=worktree_path,
|
|
733
|
+
stage_head=head,
|
|
734
|
+
stage_dirty=(
|
|
735
|
+
is_dirty_excluding_okstra(worktree_path) if worktree_path else False
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
return FinalVerificationTargetAcquisition(
|
|
739
|
+
target=target,
|
|
740
|
+
worktree_branch=(row or {}).get("branch", ""),
|
|
741
|
+
integration_result=None,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _acquire_whole_task_target(
|
|
746
|
+
request: FinalVerificationTargetRequest,
|
|
747
|
+
done_rows: list[dict[str, Any]],
|
|
748
|
+
registry_coordinates: tuple[str, str, str],
|
|
749
|
+
) -> FinalVerificationTargetAcquisition:
|
|
750
|
+
from . import worktree_registry
|
|
751
|
+
|
|
752
|
+
project_id, task_group, task_id = registry_coordinates
|
|
753
|
+
entry = worktree_registry.lookup(project_id, task_group, task_id)
|
|
754
|
+
worktree_path = (
|
|
755
|
+
entry.worktree_path if entry is not None else str(request.project_root)
|
|
756
|
+
)
|
|
757
|
+
whole = _resolve_and_integrate_whole_task_unlocked(
|
|
758
|
+
project_id=project_id,
|
|
759
|
+
task_group=task_group,
|
|
760
|
+
task_id=task_id,
|
|
761
|
+
task_worktree_path=worktree_path,
|
|
762
|
+
stage_map=list(request.stage_map),
|
|
763
|
+
done_rows=done_rows,
|
|
764
|
+
anchor_base=(
|
|
765
|
+
worktree_registry.get_implementation_base(
|
|
766
|
+
project_id, task_group, task_id
|
|
767
|
+
)
|
|
768
|
+
or ""
|
|
769
|
+
),
|
|
770
|
+
teardown=True,
|
|
771
|
+
)
|
|
772
|
+
return FinalVerificationTargetAcquisition(
|
|
773
|
+
target=whole["target"],
|
|
774
|
+
worktree_branch=entry.branch if entry is not None else "",
|
|
775
|
+
integration_result=whole["integrate_result"],
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def acquire_final_verification_target(
|
|
780
|
+
request: FinalVerificationTargetRequest,
|
|
781
|
+
) -> FinalVerificationTargetAcquisition:
|
|
782
|
+
"""Acquire a stable final-verification target behind one task-key lock."""
|
|
783
|
+
from okstra_project.dirs import okstra_home
|
|
784
|
+
from okstra_project.state import slugify
|
|
785
|
+
|
|
786
|
+
from .locks import worktree_provision_mutex
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
with worktree_provision_mutex(
|
|
790
|
+
okstra_home(),
|
|
791
|
+
request.project_id,
|
|
792
|
+
slugify(request.task_group),
|
|
793
|
+
slugify(request.task_id),
|
|
794
|
+
):
|
|
795
|
+
registry_coordinates = _final_verification_registry_coordinates(request)
|
|
796
|
+
done_rows = _read_final_verification_done_rows(
|
|
797
|
+
request,
|
|
798
|
+
registry_coordinates,
|
|
799
|
+
)
|
|
800
|
+
if request.stage is not None:
|
|
801
|
+
return _acquire_single_stage_target(
|
|
802
|
+
request,
|
|
803
|
+
done_rows,
|
|
804
|
+
registry_coordinates,
|
|
805
|
+
)
|
|
806
|
+
return _acquire_whole_task_target(
|
|
807
|
+
request,
|
|
808
|
+
done_rows,
|
|
809
|
+
registry_coordinates,
|
|
810
|
+
)
|
|
811
|
+
except PrepareError:
|
|
812
|
+
raise
|
|
813
|
+
except (OSError, RuntimeError, StageTargetError, ValueError) as exc:
|
|
814
|
+
raise PrepareError(str(exc)) from exc
|
|
@@ -14,12 +14,13 @@ import json
|
|
|
14
14
|
import sys
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from okstra_ctl.paths import resolve_under_root
|
|
17
|
+
from okstra_ctl.paths import resolve_under_root, task_timeline_file
|
|
18
|
+
from okstra_project import read_task_key
|
|
18
19
|
from okstra_ctl.task_target import resolve_task_root
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def load_runs(task_root: Path) -> tuple[list[dict], bool]:
|
|
22
|
-
path = task_root
|
|
23
|
+
path = task_timeline_file(task_root)
|
|
23
24
|
try:
|
|
24
25
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
25
26
|
except (OSError, ValueError):
|
|
@@ -30,12 +31,6 @@ def load_runs(task_root: Path) -> tuple[list[dict], bool]:
|
|
|
30
31
|
return runs, False
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
def _task_key(task_root: Path) -> str:
|
|
34
|
-
try:
|
|
35
|
-
return json.loads((task_root / "task-manifest.json").read_text(encoding="utf-8")).get("taskKey", "")
|
|
36
|
-
except (OSError, ValueError):
|
|
37
|
-
return ""
|
|
38
|
-
|
|
39
34
|
|
|
40
35
|
def _duration_ms(block) -> int:
|
|
41
36
|
"""usage / leadUsage block 의 durationMs — na_block·missing·None 은 0."""
|
|
@@ -194,7 +189,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
194
189
|
|
|
195
190
|
task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
|
|
196
191
|
runs, _ = load_runs(task_root)
|
|
197
|
-
result = {"ok": True, "taskKey":
|
|
192
|
+
result = {"ok": True, "taskKey": read_task_key(task_root)}
|
|
198
193
|
result.update(aggregate_time(runs, project_root))
|
|
199
194
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
200
195
|
return 0
|