okstra 0.137.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.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +24 -2
- package/package.json +1 -1
- 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/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/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 +158 -365
- package/runtime/python/okstra_ctl/dispatch_core.py +118 -138
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/render.py +9 -4
- package/runtime/python/okstra_ctl/run.py +40 -89
- package/runtime/python/okstra_ctl/stage_targets.py +232 -46
- package/runtime/python/okstra_ctl/wizard.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/validators/lib/fixtures.sh +2 -0
|
@@ -1948,10 +1948,15 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1948
1948
|
"- Worker prompt paths in `task-manifest.json` are assigned prompt-history "
|
|
1949
1949
|
"locations, not pre-rendered completion markers. An absent file means its "
|
|
1950
1950
|
"dispatch history has not been materialized yet.\n"
|
|
1951
|
-
"-
|
|
1952
|
-
"
|
|
1953
|
-
"
|
|
1954
|
-
"
|
|
1951
|
+
"- Code-backed `dispatch_worker` mappings leave missing roster prompts to "
|
|
1952
|
+
"`okstra_ctl.initial_prompt_materialization` and its canonical "
|
|
1953
|
+
"`materialize_initial_prompts()` entry point. The selected adapter supplies "
|
|
1954
|
+
"its declared delivery mode; do not construct or overwrite those prompts "
|
|
1955
|
+
"manually.\n"
|
|
1956
|
+
"- For native in-process dispatch, use the same canonical initial-prompt headers, "
|
|
1957
|
+
"persist the assigned prompt history through `write_artifact`, and apply "
|
|
1958
|
+
"the adapter-declared `lazy-path-reference` delivery mode before starting the "
|
|
1959
|
+
"worker. This native path does not apply to reverify or critic dispatches.\n"
|
|
1955
1960
|
"- Use `await_workers` through the selected adapter and confirm terminal state "
|
|
1956
1961
|
"plus every required Result Path. File presence alone is never a signal to "
|
|
1957
1962
|
"skip; worker selection and skipped status come from `team-state`.\n"
|
|
@@ -96,11 +96,7 @@ from .workflow import compute_workflow_state, load_phase_forbidden
|
|
|
96
96
|
from .locks import worktree_provision_mutex
|
|
97
97
|
from . import stage_targets as _stage_targets
|
|
98
98
|
from .plan_run_root import plan_run_root_from_approved_plan
|
|
99
|
-
from . import stage_integrate as _stage_integrate
|
|
100
99
|
from . import implementation_stage as _implementation_stage
|
|
101
|
-
from .stage_reconcile import (
|
|
102
|
-
auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
|
|
103
|
-
)
|
|
104
100
|
from .work_categories import resolve_work_category
|
|
105
101
|
from .worktree import (
|
|
106
102
|
WorktreeProvision,
|
|
@@ -437,40 +433,13 @@ def _validate_stage_structure(plan_path: str) -> None:
|
|
|
437
433
|
|
|
438
434
|
|
|
439
435
|
RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
|
|
440
|
-
_FVTarget = _stage_targets.FinalVerificationTarget
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> PrepareError:
|
|
444
|
-
return PrepareError(str(exc))
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _resolve_single_stage_target(
|
|
452
|
-
*, requested_stage: str, done_rows: list, stage_base: str,
|
|
453
|
-
stage_worktree_path: str, stage_head: str, stage_dirty: bool,
|
|
454
|
-
) -> "_FVTarget":
|
|
455
|
-
"""Compatibility wrapper for single-stage verification target selection."""
|
|
456
|
-
try:
|
|
457
|
-
return _stage_targets.resolve_single_stage_target(
|
|
458
|
-
requested_stage=requested_stage,
|
|
459
|
-
done_rows=done_rows,
|
|
460
|
-
stage_base=stage_base,
|
|
461
|
-
stage_worktree_path=stage_worktree_path,
|
|
462
|
-
stage_head=stage_head,
|
|
463
|
-
stage_dirty=stage_dirty,
|
|
464
|
-
)
|
|
465
|
-
except _stage_targets.StageTargetError as exc:
|
|
466
|
-
raise _stage_target_prepare_error(exc) from exc
|
|
467
436
|
|
|
468
437
|
|
|
469
438
|
def _stage_map_reject_detail(stages, errs):
|
|
470
439
|
"""빈/손상 Stage Map 의 거부 사유 문자열을 돌려준다. 정상이면 None.
|
|
471
440
|
|
|
472
441
|
빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
|
|
473
|
-
(
|
|
442
|
+
(`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
|
|
474
443
|
통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
|
|
475
444
|
stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
|
|
476
445
|
게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
|
|
@@ -1432,16 +1401,6 @@ def _git_out(cwd, *args) -> str:
|
|
|
1432
1401
|
return r.stdout.strip() if r.returncode == 0 else ""
|
|
1433
1402
|
|
|
1434
1403
|
|
|
1435
|
-
def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
|
|
1436
|
-
_stage_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
def _is_dirty_excluding_okstra(cwd) -> bool:
|
|
1441
|
-
from .worktree import is_dirty_excluding_okstra
|
|
1442
|
-
return is_dirty_excluding_okstra(cwd)
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
1404
|
def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
|
|
1446
1405
|
"""Placeholder until the selected stage registry row is resolved."""
|
|
1447
1406
|
return WorktreeProvision(
|
|
@@ -1471,57 +1430,46 @@ def _format_integration(integ) -> str:
|
|
|
1471
1430
|
return "\n".join(lines)
|
|
1472
1431
|
|
|
1473
1432
|
|
|
1474
|
-
def
|
|
1475
|
-
inp: "PrepareInputs",
|
|
1433
|
+
def _final_verification_target_request(
|
|
1434
|
+
inp: "PrepareInputs",
|
|
1435
|
+
ctx_stage_map: list[dict],
|
|
1436
|
+
) -> _stage_targets.FinalVerificationTargetRequest:
|
|
1437
|
+
stage = (
|
|
1438
|
+
int(inp.stage)
|
|
1439
|
+
if inp.stage and inp.stage != "auto"
|
|
1440
|
+
else None
|
|
1441
|
+
)
|
|
1442
|
+
return _stage_targets.FinalVerificationTargetRequest(
|
|
1443
|
+
project_root=Path(inp.project_root),
|
|
1444
|
+
project_id=inp.project_id,
|
|
1445
|
+
task_group=inp.task_group,
|
|
1446
|
+
task_id=inp.task_id,
|
|
1447
|
+
work_category=inp.work_category,
|
|
1448
|
+
approved_plan_path=Path(inp.approved_plan_path),
|
|
1449
|
+
stage=stage,
|
|
1450
|
+
stage_map=tuple(ctx_stage_map),
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _apply_final_verification_target(
|
|
1455
|
+
ctx: dict,
|
|
1456
|
+
acquisition: _stage_targets.FinalVerificationTargetAcquisition,
|
|
1476
1457
|
) -> None:
|
|
1477
|
-
"""
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
# carry sidecars are the SSOT for stage completion — recover missing `done`
|
|
1485
|
-
# rows before the whole-task gate checks every stage.
|
|
1486
|
-
backfill_done_from_carry(plan_run_root)
|
|
1487
|
-
_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1488
|
-
done_rows = read_stage_consumer_state(plan_run_root).done_rows
|
|
1489
|
-
|
|
1490
|
-
if inp.stage and inp.stage != "auto":
|
|
1491
|
-
n = int(inp.stage)
|
|
1492
|
-
row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
|
|
1493
|
-
wt_path = (row or {}).get("worktree_path", "")
|
|
1494
|
-
stage_base = (row or {}).get("base_ref", "")
|
|
1495
|
-
stage_branch = (row or {}).get("branch", "")
|
|
1496
|
-
head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
|
|
1497
|
-
target = _resolve_single_stage_target(
|
|
1498
|
-
requested_stage=inp.stage, done_rows=done_rows,
|
|
1499
|
-
stage_base=stage_base, stage_worktree_path=wt_path,
|
|
1500
|
-
stage_head=head,
|
|
1501
|
-
stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
|
|
1502
|
-
)
|
|
1503
|
-
ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
|
|
1504
|
-
ctx["EXECUTOR_WORKTREE_BRANCH"] = stage_branch
|
|
1505
|
-
ctx["EXECUTOR_WORKTREE_BASE_REF"] = stage_base
|
|
1458
|
+
"""Translate acquired domain facts into render-context fields."""
|
|
1459
|
+
target = acquisition.target
|
|
1460
|
+
if target.scope == "single-stage":
|
|
1461
|
+
stage = target.stages[0]
|
|
1462
|
+
ctx["EXECUTOR_WORKTREE_PATH"] = target.worktree_path
|
|
1463
|
+
ctx["EXECUTOR_WORKTREE_BRANCH"] = acquisition.worktree_branch
|
|
1464
|
+
ctx["EXECUTOR_WORKTREE_BASE_REF"] = target.base
|
|
1506
1465
|
ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
|
|
1507
1466
|
ctx["EXECUTOR_WORKTREE_NOTE"] = (
|
|
1508
|
-
f"final-verification uses implementation stage {
|
|
1467
|
+
f"final-verification uses implementation stage {stage} worktree"
|
|
1509
1468
|
)
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
inp.project_id, inp.task_group, inp.task_id) or ""
|
|
1514
|
-
# whole-task 통합(머지+teardown)+게이트 강제는 container 와 공유하는
|
|
1515
|
-
# 순수 헬퍼가 단일 책임. ctx 주입만 run.py 측에 남긴다.
|
|
1516
|
-
whole = _stage_targets.resolve_and_integrate_whole_task(
|
|
1517
|
-
project_id=inp.project_id, task_group=inp.task_group,
|
|
1518
|
-
task_id=inp.task_id, task_worktree_path=wt_path,
|
|
1519
|
-
stage_map=ctx_stage_map, done_rows=done_rows,
|
|
1520
|
-
anchor_base=anchor, teardown=True,
|
|
1469
|
+
if acquisition.integration_result is not None:
|
|
1470
|
+
ctx["STAGE_INTEGRATION"] = _format_integration(
|
|
1471
|
+
acquisition.integration_result
|
|
1521
1472
|
)
|
|
1522
|
-
ctx["STAGE_INTEGRATION"] = _format_integration(whole["integrate_result"])
|
|
1523
|
-
target = whole["target"]
|
|
1524
|
-
|
|
1525
1473
|
diff_stat = _git_out(target.worktree_path, "diff", "--stat",
|
|
1526
1474
|
f"{target.base}..{target.head}")
|
|
1527
1475
|
ctx["VERIFICATION_SCOPE"] = target.scope
|
|
@@ -2263,7 +2211,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2263
2211
|
ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
|
|
2264
2212
|
ctx["CLAUDE_RESUME_COMMAND_FILENAME"] = ""
|
|
2265
2213
|
if inp.task_type == "final-verification":
|
|
2266
|
-
|
|
2214
|
+
acquisition = _stage_targets.acquire_final_verification_target(
|
|
2215
|
+
_final_verification_target_request(inp, ctx_stage_map)
|
|
2216
|
+
)
|
|
2217
|
+
_apply_final_verification_target(ctx, acquisition)
|
|
2267
2218
|
|
|
2268
2219
|
_write_bundle_artifacts(
|
|
2269
2220
|
inp, ctx, project_root, lead_runtime, claude_session_id,
|
|
@@ -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
|
|
@@ -473,7 +498,7 @@ def resolve_stage_base_commit(
|
|
|
473
498
|
)
|
|
474
499
|
|
|
475
500
|
|
|
476
|
-
def
|
|
501
|
+
def _resolve_whole_task_target(
|
|
477
502
|
*,
|
|
478
503
|
stage_map: list[dict[str, Any]],
|
|
479
504
|
done_rows: list[dict[str, Any]],
|
|
@@ -533,65 +558,80 @@ def _merged_stage_map(
|
|
|
533
558
|
}
|
|
534
559
|
|
|
535
560
|
|
|
536
|
-
def
|
|
561
|
+
def _resolve_and_integrate_whole_task_unlocked(
|
|
537
562
|
*, project_id: str, task_group: str, task_id: str,
|
|
538
563
|
task_worktree_path: str, stage_map: list[dict[str, Any]],
|
|
539
564
|
done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
|
|
540
565
|
) -> dict[str, Any]:
|
|
541
|
-
"""whole-task
|
|
542
|
-
|
|
543
|
-
final-verification(teardown=True)과 container(teardown=False)의 단일 참조점.
|
|
544
|
-
ctx/PrepareInputs 결합 없는 헬퍼. 머지 충돌·미머지·dirty(.okstra 외)면
|
|
545
|
-
`PrepareError`. 반환: head_commit / merged_stage_map / target_ref / target /
|
|
546
|
-
integrate_result.
|
|
547
|
-
|
|
548
|
-
머지(`git merge`)+teardown 은 task-key 단위 `worktree_provision_mutex` 로
|
|
549
|
-
감싼다 — 동시 `container up` 둘 또는 container up × whole-task
|
|
550
|
-
final-verification 이 같은 공유 worktree 의 index/HEAD 에 동시에 머지하면
|
|
551
|
-
index.lock 충돌·반쯤 적용된 머지로 트리가 깨진다. 두 호출 지점(container 의
|
|
552
|
-
`_integrate_source_tree`, run.py 의 `_reserve_final_verification_target`)
|
|
553
|
-
모두 이 락 밖에서 진입하므로 self-deadlock 없이 여기서 1회만 잡는다."""
|
|
566
|
+
"""Integrate and resolve a whole-task target while the caller owns the lock."""
|
|
554
567
|
from .stage_integrate import IntegrateError, integrate_stages
|
|
555
568
|
from .worktree import _git, is_dirty_excluding_okstra
|
|
556
|
-
from .locks import worktree_provision_mutex
|
|
557
|
-
from okstra_project.dirs import okstra_home
|
|
558
|
-
from okstra_project.state import slugify
|
|
559
|
-
|
|
560
|
-
with worktree_provision_mutex(
|
|
561
|
-
okstra_home(), project_id, slugify(task_group), slugify(task_id),
|
|
562
|
-
):
|
|
563
|
-
try:
|
|
564
|
-
integ = integrate_stages(
|
|
565
|
-
project_id=project_id, task_group=task_group, task_id=task_id,
|
|
566
|
-
task_worktree_path=task_worktree_path, stage_map=stage_map,
|
|
567
|
-
done_rows=done_rows, teardown=teardown,
|
|
568
|
-
)
|
|
569
|
-
except IntegrateError as exc:
|
|
570
|
-
raise PrepareError(str(exc)) from exc
|
|
571
569
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
583
597
|
return {
|
|
584
598
|
"head_commit": head,
|
|
585
599
|
"merged_stage_map": merged,
|
|
586
600
|
"target_ref": target.head,
|
|
587
601
|
"target": target,
|
|
588
|
-
"integrate_result":
|
|
602
|
+
"integrate_result": integration,
|
|
589
603
|
}
|
|
590
604
|
|
|
591
605
|
|
|
592
|
-
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(
|
|
593
633
|
*,
|
|
594
|
-
requested_stage:
|
|
634
|
+
requested_stage: int,
|
|
595
635
|
done_rows: list[dict[str, Any]],
|
|
596
636
|
stage_base: str,
|
|
597
637
|
stage_worktree_path: str,
|
|
@@ -601,7 +641,7 @@ def resolve_single_stage_target(
|
|
|
601
641
|
"""Resolve single-stage final-verification target, enforcing all gates."""
|
|
602
642
|
from .consumers import latest_done_by_stage
|
|
603
643
|
|
|
604
|
-
n =
|
|
644
|
+
n = requested_stage
|
|
605
645
|
done_by_stage = latest_done_by_stage(done_rows)
|
|
606
646
|
if n not in done_by_stage:
|
|
607
647
|
raise StageTargetError(
|
|
@@ -626,3 +666,149 @@ def resolve_single_stage_target(
|
|
|
626
666
|
stages=[n],
|
|
627
667
|
reports=[done_by_stage[n].get("report_path", "")],
|
|
628
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
|
|
@@ -81,8 +81,8 @@ from okstra_ctl.worktree import (
|
|
|
81
81
|
compute_worktree_path,
|
|
82
82
|
is_git_work_tree,
|
|
83
83
|
main_worktree_path,
|
|
84
|
-
preview_stage_worktree_decision,
|
|
85
84
|
preview_worktree_decision,
|
|
85
|
+
resolve_stage_worktree_decision,
|
|
86
86
|
)
|
|
87
87
|
from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
|
|
88
88
|
from okstra_ctl.work_categories import resolve_work_category
|
|
@@ -3175,7 +3175,7 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
|
|
|
3175
3175
|
)
|
|
3176
3176
|
return _msg(state.workspace_root, "confirmation", "worktree_impl_auto",
|
|
3177
3177
|
path=str(parent_dir))
|
|
3178
|
-
decision =
|
|
3178
|
+
decision = resolve_stage_worktree_decision(
|
|
3179
3179
|
project_id=state.project_id, task_group_segment=state.task_group,
|
|
3180
3180
|
task_id_segment=state.task_id,
|
|
3181
3181
|
work_category=_preview_work_category(state),
|
|
@@ -16,6 +16,10 @@ from .worker_prompt_policy import (
|
|
|
16
16
|
MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
|
|
17
17
|
MAX_FINAL_VERIFICATION_BODY_LINES = 96
|
|
18
18
|
|
|
19
|
+
PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
|
|
20
|
+
PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
|
|
21
|
+
MODEL_HEADER = "**Model:**"
|
|
22
|
+
|
|
19
23
|
_DIRECTIVE_HEADING = "## Run-specific directive"
|
|
20
24
|
_WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
|
|
21
25
|
_PRIMARY_PACKET_RE = re.compile(
|
|
@@ -48,6 +52,7 @@ _NON_BODY_PREFIXES = (
|
|
|
48
52
|
"**Verification head ref:**",
|
|
49
53
|
"**Verification target path:**",
|
|
50
54
|
"**Verification target digest:**",
|
|
55
|
+
PROMPT_DELIVERY_MODE_HEADER,
|
|
51
56
|
)
|
|
52
57
|
_REQUIRED_TARGET_PREFIXES = (
|
|
53
58
|
"**Worktree:**",
|
|
@@ -81,6 +86,8 @@ class PromptRecord:
|
|
|
81
86
|
worker_id: str
|
|
82
87
|
dispatch_kind: str
|
|
83
88
|
path: Path
|
|
89
|
+
expected_model: str | None = None
|
|
90
|
+
expected_delivery_mode: str | None = None
|
|
84
91
|
|
|
85
92
|
|
|
86
93
|
def validate_final_verification_initial_prompt(text: str) -> list[str]:
|
|
@@ -194,6 +201,10 @@ def validate_initial_prompt_records(
|
|
|
194
201
|
f"{record.worker_id}: {error}"
|
|
195
202
|
for error in _validate_prompt_for_plan(text, plan, manifest)
|
|
196
203
|
)
|
|
204
|
+
errors.extend(
|
|
205
|
+
f"{record.worker_id}: {error}"
|
|
206
|
+
for error in _validate_record_metadata(text, record)
|
|
207
|
+
)
|
|
197
208
|
if plan.equality_group:
|
|
198
209
|
group = equality_groups.setdefault(plan.equality_group, {})
|
|
199
210
|
group[record.worker_id] = text
|
|
@@ -202,6 +213,68 @@ def validate_initial_prompt_records(
|
|
|
202
213
|
return errors
|
|
203
214
|
|
|
204
215
|
|
|
216
|
+
def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
|
|
217
|
+
errors = _validate_delivery_mode(
|
|
218
|
+
_header_values(text, PROMPT_DELIVERY_MODE_HEADER),
|
|
219
|
+
record.expected_delivery_mode,
|
|
220
|
+
)
|
|
221
|
+
if record.expected_model is None:
|
|
222
|
+
return errors
|
|
223
|
+
model = _model_value(_header_values(text, MODEL_HEADER))
|
|
224
|
+
if model is None:
|
|
225
|
+
errors.append(
|
|
226
|
+
"exactly one non-empty **Model:** <label>, <model> header is required"
|
|
227
|
+
)
|
|
228
|
+
elif model != record.expected_model:
|
|
229
|
+
errors.append(
|
|
230
|
+
f"prompt model does not match requested model: {record.expected_model}"
|
|
231
|
+
)
|
|
232
|
+
return errors
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_delivery_mode(
|
|
236
|
+
values: list[str],
|
|
237
|
+
expected: str | None,
|
|
238
|
+
) -> list[str]:
|
|
239
|
+
errors = []
|
|
240
|
+
if len(values) != 1 or not values[0]:
|
|
241
|
+
errors.append(
|
|
242
|
+
"exactly one non-empty **Prompt Delivery Mode:** header is required"
|
|
243
|
+
)
|
|
244
|
+
errors.extend(
|
|
245
|
+
f"unsupported Prompt Delivery Mode: {value}"
|
|
246
|
+
for value in values
|
|
247
|
+
if value and value not in PROMPT_DELIVERY_MODES
|
|
248
|
+
)
|
|
249
|
+
if (
|
|
250
|
+
expected is not None
|
|
251
|
+
and len(values) == 1
|
|
252
|
+
and values[0]
|
|
253
|
+
and values[0] != expected
|
|
254
|
+
):
|
|
255
|
+
errors.append(
|
|
256
|
+
f"Prompt Delivery Mode does not match requested mode: {expected}"
|
|
257
|
+
)
|
|
258
|
+
return errors
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _header_values(text: str, prefix: str) -> list[str]:
|
|
262
|
+
return [
|
|
263
|
+
line.strip()[len(prefix):].strip()
|
|
264
|
+
for line in text.splitlines()
|
|
265
|
+
if line.strip().startswith(prefix)
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _model_value(values: list[str]) -> str | None:
|
|
270
|
+
if len(values) != 1:
|
|
271
|
+
return None
|
|
272
|
+
parts = [part.strip() for part in values[0].rsplit(",", 1)]
|
|
273
|
+
if len(parts) != 2 or not all(parts):
|
|
274
|
+
return None
|
|
275
|
+
return parts[1]
|
|
276
|
+
|
|
277
|
+
|
|
205
278
|
def _resolve_record_plan(
|
|
206
279
|
manifest: Mapping[str, Any],
|
|
207
280
|
record: PromptRecord,
|