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.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. 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 / ".okstra" / "tasks" / group_seg / id_seg / "task-manifest.json"
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
- return resolve_effective_stages(
96
- self.stage_map,
97
- self.done_stages,
98
- requested,
99
- budget=budget,
100
- started_stages=self.started_stages,
101
- reserved_stages=self.reserved_stages,
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=list(stage.get("depends_on") or []),
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
- """Return ordered stage numbers this run should execute.
303
+ """Adapter over ``StageLifecycleSnapshot.resolve_effective_stages``.
202
304
 
203
- ``requested`` is ``"auto"`` or a decimal string. Auto selection returns all
204
- ready stages within the step budget, excluding done/started/reserved stages.
205
- Numeric selection returns one forced stage.
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
- started_stages = started_stages or set()
208
- reserved_stages = reserved_stages or set()
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
- if not ready:
239
- raise StageTargetError(
240
- "no stage is ready: every remaining stage has unsatisfied depends-on"
241
- )
242
- batch: list[int] = []
243
- total = 0
244
- for stage in ready:
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 resolve_whole_task_target(
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 resolve_and_integrate_whole_task(
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 stage 통합(머지[+선택 teardown]) 게이트 강제 → target 해소.
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
- with worktree_provision_mutex(
518
- okstra_home(), project_id, slugify(task_group), slugify(task_id),
519
- ):
520
- try:
521
- integ = integrate_stages(
522
- project_id=project_id, task_group=task_group, task_id=task_id,
523
- task_worktree_path=task_worktree_path, stage_map=stage_map,
524
- done_rows=done_rows, teardown=teardown,
525
- )
526
- except IntegrateError as exc:
527
- raise PrepareError(str(exc)) from exc
528
-
529
- head = _git(task_worktree_path, "rev-parse", "HEAD").stdout.strip()
530
- merged = _merged_stage_map(task_worktree_path, done_rows, head)
531
- try:
532
- target = resolve_whole_task_target(
533
- stage_map=stage_map, done_rows=done_rows, anchor_base=anchor_base,
534
- task_worktree_path=task_worktree_path, task_head=head,
535
- task_dirty=is_dirty_excluding_okstra(task_worktree_path),
536
- merged=merged,
537
- )
538
- except StageTargetError as exc:
539
- raise PrepareError(str(exc)) from exc
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": integ,
602
+ "integrate_result": integration,
546
603
  }
547
604
 
548
605
 
549
- def resolve_single_stage_target(
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: str,
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 = int(requested_stage)
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 / "history" / "timeline.json"
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": _task_key(task_root)}
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