okstra 0.176.1 → 0.177.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/execute/team.mjs +14 -4
- package/dist/commands/execute/team.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +0 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +3 -3
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +2 -2
- package/docs/task-process/final-verification.md +5 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/bin/okstra-compact-reminder.sh +2 -2
- package/runtime/bin/okstra-provider-exec.py +2 -7
- package/runtime/bin/okstra-render-report-views.py +13 -10
- package/runtime/prompts/coding-preflight/overview.md +2 -1
- package/runtime/prompts/lead/convergence.md +11 -6
- package/runtime/prompts/lead/okstra-lead-contract.md +3 -3
- package/runtime/prompts/lead/plan-body-verification.md +4 -2
- package/runtime/prompts/lead/report-writer.md +8 -4
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +11 -9
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +7 -6
- package/runtime/prompts/wizard/prompts.ko.json +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +15 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +140 -17
- package/runtime/python/okstra_ctl/dispatch_state.py +60 -3
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +49 -3
- package/runtime/python/okstra_ctl/handoff.py +27 -14
- package/runtime/python/okstra_ctl/model_cli.py +11 -2
- package/runtime/python/okstra_ctl/model_discovery.py +12 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
- package/runtime/python/okstra_ctl/release_gate.py +56 -0
- package/runtime/python/okstra_ctl/render.py +53 -0
- package/runtime/python/okstra_ctl/report_contract.py +1 -0
- package/runtime/python/okstra_ctl/report_finalize.py +54 -0
- package/runtime/python/okstra_ctl/report_html/render.py +7 -4
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
- package/runtime/python/okstra_ctl/run.py +119 -18
- package/runtime/python/okstra_ctl/stage_targets.py +73 -1
- package/runtime/python/okstra_ctl/team.py +84 -14
- package/runtime/python/okstra_ctl/tmux.py +2 -3
- package/runtime/python/okstra_ctl/wizard.py +19 -10
- package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
- package/runtime/python/okstra_ctl/worker_runner.py +2 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
- package/runtime/python/okstra_ctl/write_policy.py +9 -1
- package/runtime/schemas/final-report-v2.0.schema.json +58 -19
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/templates/reports/html/base.template.html +1 -2
- package/runtime/templates/reports/html/i18n/en.json +4 -0
- package/runtime/templates/reports/html/i18n/ko.json +4 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -1
- package/runtime/validators/validate-report-views.py +30 -17
- package/runtime/validators/validate-run.py +221 -90
- package/runtime/validators/validate_analysis_report.py +2 -5
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/runtime/bin/okstra-trace-cleanup.sh +0 -185
|
@@ -1580,7 +1580,7 @@ def _materialize_release_handoff_input(
|
|
|
1580
1580
|
반환: ctx 에 올릴 {"HANDOFF_MODE": ..., "HANDOFF_STAGES": ...}."""
|
|
1581
1581
|
from .consumers import read_consumers
|
|
1582
1582
|
from .handoff import (HandoffError, _require_eligible,
|
|
1583
|
-
|
|
1583
|
+
latest_whole_task_fv_release_ready)
|
|
1584
1584
|
from .paths import task_dir
|
|
1585
1585
|
from .render import render_template_with_ctx
|
|
1586
1586
|
from .run_context import _now_task_date
|
|
@@ -1622,7 +1622,7 @@ def _materialize_release_handoff_input(
|
|
|
1622
1622
|
stages_csv = ",".join(str(n) for n in nums)
|
|
1623
1623
|
report_rows = _collect_handoff_source_report_rows(rows, nums)
|
|
1624
1624
|
else:
|
|
1625
|
-
report =
|
|
1625
|
+
report = latest_whole_task_fv_release_ready(
|
|
1626
1626
|
project_root, inp.project_id, inp.task_group, inp.task_id)
|
|
1627
1627
|
if not report:
|
|
1628
1628
|
raise PrepareError(
|
|
@@ -1659,6 +1659,33 @@ def _materialize_release_handoff_input(
|
|
|
1659
1659
|
return {"HANDOFF_MODE": mode, "HANDOFF_STAGES": stages_csv}
|
|
1660
1660
|
|
|
1661
1661
|
|
|
1662
|
+
QA_COMMAND_EXECUTING_TASK_TYPES = ("implementation", "final-verification")
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def validate_project_qa_commands(task_type: str, project_root: Path) -> None:
|
|
1666
|
+
"""`qaCommands` 를 실행하는 phase 진입에서 변경성 토큰 선언을 막는다.
|
|
1667
|
+
|
|
1668
|
+
`implementation` 은 verifier 의 QA gate baseline 으로, `final-verification` 은
|
|
1669
|
+
프로파일이 정의한 Tier 2 재실행 집합으로 같은 선언을 읽는다. 실행 직전에 리드가
|
|
1670
|
+
스스로 걸러내게 두면 그 자기검사가 유일한 방어선이 되므로 진입에서 막는다.
|
|
1671
|
+
나머지 task-type 은 이 선언을 읽지 않아 잘못된 값이 있어도 동작에 닿지 않는다.
|
|
1672
|
+
"""
|
|
1673
|
+
if task_type not in QA_COMMAND_EXECUTING_TASK_TYPES:
|
|
1674
|
+
return
|
|
1675
|
+
project_json = project_json_path(project_root)
|
|
1676
|
+
if not project_json.is_file():
|
|
1677
|
+
return
|
|
1678
|
+
try:
|
|
1679
|
+
project_meta = json.loads(project_json.read_text())
|
|
1680
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1681
|
+
raise PrepareError(
|
|
1682
|
+
f"project.json read failed at {project_json}: {exc}"
|
|
1683
|
+
) from exc
|
|
1684
|
+
qa_errors = validate_qa_commands(project_meta.get("qaCommands"))
|
|
1685
|
+
if qa_errors:
|
|
1686
|
+
raise PrepareError(_format_qa_errors(qa_errors))
|
|
1687
|
+
|
|
1688
|
+
|
|
1662
1689
|
def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
|
|
1663
1690
|
"""`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다.
|
|
1664
1691
|
|
|
@@ -1745,21 +1772,9 @@ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
|
|
|
1745
1772
|
# is preserved by the `: {exc}` suffix and the `raise ... from exc`.
|
|
1746
1773
|
raise PrepareError(f"project.json upsert failed for {project_root}: {exc}") from exc
|
|
1747
1774
|
|
|
1748
|
-
|
|
1749
|
-
#
|
|
1750
|
-
# 잘못된 선언이 있어도 동작에 영향이 없어 fail-fast 할 이유가 없다.
|
|
1775
|
+
validate_project_qa_commands(inp.task_type, project_root)
|
|
1776
|
+
# waiver 는 stage 단위 Tier 3 면제라 implementation 진입에서만 적용한다.
|
|
1751
1777
|
if inp.task_type == "implementation":
|
|
1752
|
-
project_json = project_json_path(project_root)
|
|
1753
|
-
if project_json.is_file():
|
|
1754
|
-
try:
|
|
1755
|
-
project_meta = json.loads(project_json.read_text())
|
|
1756
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
1757
|
-
raise PrepareError(
|
|
1758
|
-
f"project.json read failed at {project_json}: {exc}"
|
|
1759
|
-
) from exc
|
|
1760
|
-
qa_errors = validate_qa_commands(project_meta.get("qaCommands"))
|
|
1761
|
-
if qa_errors:
|
|
1762
|
-
raise PrepareError(_format_qa_errors(qa_errors))
|
|
1763
1778
|
_apply_qa_waiver_if_requested(inp, project_root)
|
|
1764
1779
|
|
|
1765
1780
|
|
|
@@ -1914,12 +1929,19 @@ def _build_static_execution_manifest(
|
|
|
1914
1929
|
plan: AssignmentPlan,
|
|
1915
1930
|
context: AssignmentContext,
|
|
1916
1931
|
lead: ResolvedAssignment,
|
|
1932
|
+
translator: ResolvedAssignment,
|
|
1917
1933
|
) -> ExecutionManifest:
|
|
1918
1934
|
participants: list[ParticipantAssignment] = []
|
|
1919
1935
|
roles: list[RoleExecution] = []
|
|
1936
|
+
# The translator is resolved outside the role plan, like the lead, and both
|
|
1937
|
+
# are named in `invocationAssignments`. Without a role execution here,
|
|
1938
|
+
# `agent-prompt materialize --audience translator` has no canonical
|
|
1939
|
+
# identity to bind to and refuses — so a `reportLanguage: ko` run could
|
|
1940
|
+
# never write its translation sidecar and rendered the English source.
|
|
1920
1941
|
assignments = (
|
|
1921
1942
|
lead,
|
|
1922
1943
|
*(row for row in plan.assignments if row.role != "leader"),
|
|
1944
|
+
translator,
|
|
1923
1945
|
)
|
|
1924
1946
|
for index, assignment in enumerate(assignments, start=1):
|
|
1925
1947
|
participant_ref = f"participant-{index:03d}"
|
|
@@ -2130,8 +2152,14 @@ def _canonical_selection_provider_ids(
|
|
|
2130
2152
|
and "implementer" not in scopes.global_
|
|
2131
2153
|
)
|
|
2132
2154
|
if implementer_uses_bundled_default:
|
|
2155
|
+
# `--executor` names the provider that implements, so it belongs in the
|
|
2156
|
+
# roster the same way the bundled default does. Reading only the default
|
|
2157
|
+
# left `--executor <provider>` with no assignment of its own: the run
|
|
2158
|
+
# rendered without that provider, and asking for it with `--workers`
|
|
2159
|
+
# was refused as not being in the roster.
|
|
2133
2160
|
selected.append(
|
|
2134
|
-
|
|
2161
|
+
(inp.executor or "").strip().lower()
|
|
2162
|
+
or _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
|
|
2135
2163
|
)
|
|
2136
2164
|
if _needs_profile_worker_candidates(profile, selection, scopes):
|
|
2137
2165
|
selected.extend(
|
|
@@ -2287,6 +2315,7 @@ class RoleAssignment:
|
|
|
2287
2315
|
role: str
|
|
2288
2316
|
provider: str
|
|
2289
2317
|
model_display: str
|
|
2318
|
+
model_id: str
|
|
2290
2319
|
model_execution_value: str
|
|
2291
2320
|
runner: str
|
|
2292
2321
|
host_runtime: str
|
|
@@ -2294,9 +2323,14 @@ class RoleAssignment:
|
|
|
2294
2323
|
worker_id: str = ""
|
|
2295
2324
|
|
|
2296
2325
|
def to_model_payload(self) -> dict[str, object]:
|
|
2326
|
+
# `model` carries the catalog model id, not the display name: the run
|
|
2327
|
+
# manifest's role executions record `modelId`, and the two are compared
|
|
2328
|
+
# to bind an assignment to its execution. A display name that differs
|
|
2329
|
+
# from its id (every antigravity and kimi model) made that comparison
|
|
2330
|
+
# fail for the whole provider.
|
|
2297
2331
|
return {
|
|
2298
2332
|
"provider": self.provider,
|
|
2299
|
-
"model": self.
|
|
2333
|
+
"model": self.model_id,
|
|
2300
2334
|
"modelExecutionValue": self.model_execution_value,
|
|
2301
2335
|
"runner": self.runner,
|
|
2302
2336
|
"hostRuntime": self.host_runtime,
|
|
@@ -2336,6 +2370,7 @@ class _ModelBindings:
|
|
|
2336
2370
|
lead_assignment: RoleAssignment
|
|
2337
2371
|
worker_assignments: tuple[RoleAssignment, ...]
|
|
2338
2372
|
invocation_assignments: dict[str, dict[str, object]]
|
|
2373
|
+
translator: ResolvedAssignment
|
|
2339
2374
|
|
|
2340
2375
|
|
|
2341
2376
|
@dataclass(frozen=True)
|
|
@@ -2613,6 +2648,7 @@ def _resolve_model_bindings(
|
|
|
2613
2648
|
resolved=translator_meta,
|
|
2614
2649
|
role="translator",
|
|
2615
2650
|
)
|
|
2651
|
+
_reject_split_worker_models(executor_assignment, worker_assignments)
|
|
2616
2652
|
invocation_assignments = _build_invocation_assignments(
|
|
2617
2653
|
lead_assignment,
|
|
2618
2654
|
worker_assignments,
|
|
@@ -2641,6 +2677,7 @@ def _resolve_model_bindings(
|
|
|
2641
2677
|
lead_assignment=lead_assignment,
|
|
2642
2678
|
worker_assignments=worker_assignments,
|
|
2643
2679
|
invocation_assignments=invocation_assignments,
|
|
2680
|
+
translator=translator_meta,
|
|
2644
2681
|
)
|
|
2645
2682
|
|
|
2646
2683
|
|
|
@@ -2759,6 +2796,8 @@ def _project_model_bindings(
|
|
|
2759
2796
|
provider: _legacy_projection_from_plan(provider, plan, context)
|
|
2760
2797
|
for provider in ("claude", "codex", "antigravity")
|
|
2761
2798
|
}
|
|
2799
|
+
_reject_executor_outside_roster(executor_assignment, workers)
|
|
2800
|
+
_reject_split_worker_models(executor_assignment, workers)
|
|
2762
2801
|
invocation_assignments = _build_invocation_assignments(
|
|
2763
2802
|
lead_assignment,
|
|
2764
2803
|
workers,
|
|
@@ -2793,6 +2832,7 @@ def _project_model_bindings(
|
|
|
2793
2832
|
lead_assignment=lead_assignment,
|
|
2794
2833
|
worker_assignments=workers,
|
|
2795
2834
|
invocation_assignments=invocation_assignments,
|
|
2835
|
+
translator=translator,
|
|
2796
2836
|
)
|
|
2797
2837
|
|
|
2798
2838
|
|
|
@@ -2843,6 +2883,64 @@ def _selected_execution_provider_ids(
|
|
|
2843
2883
|
return tuple(dict.fromkeys(selected))
|
|
2844
2884
|
|
|
2845
2885
|
|
|
2886
|
+
def _reject_executor_outside_roster(
|
|
2887
|
+
executor: RoleAssignment | None,
|
|
2888
|
+
workers: tuple[RoleAssignment, ...],
|
|
2889
|
+
) -> None:
|
|
2890
|
+
"""Refuse an executor whose provider the roster never dispatches.
|
|
2891
|
+
|
|
2892
|
+
An implementation run opens its executor with `--workers <provider>`, so a
|
|
2893
|
+
provider absent from the roster has no invocation to open. The legacy
|
|
2894
|
+
selection path says so outright; the canonical path derived its roster from
|
|
2895
|
+
role models alone, so `--executor <provider>` rendered fine and the run only
|
|
2896
|
+
failed later, at `requested worker(s) are not in this run roster`.
|
|
2897
|
+
"""
|
|
2898
|
+
if executor is None:
|
|
2899
|
+
return
|
|
2900
|
+
roster = {row.worker_id for row in workers if row.worker_id}
|
|
2901
|
+
if executor.worker_id in roster:
|
|
2902
|
+
return
|
|
2903
|
+
raise PrepareError(
|
|
2904
|
+
f"--executor {executor.worker_id} is not in this run's roster "
|
|
2905
|
+
f"({', '.join(sorted(roster)) or 'empty'}); the executor is dispatched "
|
|
2906
|
+
f"as a worker, so give it a roster slot — "
|
|
2907
|
+
f"--role-model verifier={executor.worker_id}/<model> — or pick an "
|
|
2908
|
+
f"executor already in the roster."
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
def _reject_split_worker_models(
|
|
2913
|
+
executor: RoleAssignment | None,
|
|
2914
|
+
workers: tuple[RoleAssignment, ...],
|
|
2915
|
+
) -> None:
|
|
2916
|
+
"""Refuse one worker id standing for two roles on two different models.
|
|
2917
|
+
|
|
2918
|
+
The roster names a worker by provider, so an implementation run whose
|
|
2919
|
+
executor and verifier are the same provider shares that id — but
|
|
2920
|
+
`invocationAssignments["initial/<id>"]` can hold only one model, and it
|
|
2921
|
+
holds the verifier's. Dispatch then looks the executor's role execution up
|
|
2922
|
+
by that assignment, finds no row with a matching execution value, and stops
|
|
2923
|
+
at `v2 execution identity does not match a canonical role execution`. The
|
|
2924
|
+
render used to succeed and only the dispatch failed, by which point the run
|
|
2925
|
+
had already claimed its stage.
|
|
2926
|
+
"""
|
|
2927
|
+
if executor is None:
|
|
2928
|
+
return
|
|
2929
|
+
peer = next(
|
|
2930
|
+
(row for row in workers if row.worker_id == executor.worker_id),
|
|
2931
|
+
None,
|
|
2932
|
+
)
|
|
2933
|
+
if peer is None or peer.model_execution_value == executor.model_execution_value:
|
|
2934
|
+
return
|
|
2935
|
+
raise PrepareError(
|
|
2936
|
+
f"worker {executor.worker_id!r} is the executor on "
|
|
2937
|
+
f"{executor.model_execution_value!r} and a verifier on "
|
|
2938
|
+
f"{peer.model_execution_value!r}; one worker id carries one model. "
|
|
2939
|
+
f"Give both roles the same model, or pick a different --executor "
|
|
2940
|
+
f"provider."
|
|
2941
|
+
)
|
|
2942
|
+
|
|
2943
|
+
|
|
2846
2944
|
def _resolve_executor_assignment(
|
|
2847
2945
|
inp: PrepareInputs,
|
|
2848
2946
|
workers: list[str],
|
|
@@ -2915,6 +3013,7 @@ def _role_assignment(
|
|
|
2915
3013
|
role=role,
|
|
2916
3014
|
provider=resolved.provider_id,
|
|
2917
3015
|
model_display=resolved.display_name,
|
|
3016
|
+
model_id=resolved.model_id,
|
|
2918
3017
|
model_execution_value="unknown",
|
|
2919
3018
|
runner="cli-wrapper",
|
|
2920
3019
|
host_runtime=resolved.host_runtime,
|
|
@@ -2925,6 +3024,7 @@ def _role_assignment(
|
|
|
2925
3024
|
role=role,
|
|
2926
3025
|
provider=resolved.provider_id,
|
|
2927
3026
|
model_display=resolved.display_name,
|
|
3027
|
+
model_id=resolved.model_id,
|
|
2928
3028
|
model_execution_value=binding.resolved_execution_value,
|
|
2929
3029
|
runner=(
|
|
2930
3030
|
"native-session"
|
|
@@ -4071,6 +4171,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4071
4171
|
assignment_plan,
|
|
4072
4172
|
assignment_context,
|
|
4073
4173
|
models.lead,
|
|
4174
|
+
models.translator,
|
|
4074
4175
|
)
|
|
4075
4176
|
dynamic_roles = tuple(
|
|
4076
4177
|
requirement.role
|
|
@@ -767,7 +767,10 @@ def _acquire_whole_task_target(
|
|
|
767
767
|
)
|
|
768
768
|
or ""
|
|
769
769
|
),
|
|
770
|
-
teardown
|
|
770
|
+
# 정리는 판정 뒤로 미룬다(Phase 7 `teardown-stages`). 되돌릴 수 없는 정리를
|
|
771
|
+
# 판정 앞에 두면, 재작업이 가장 필요한 blocked 판정에서 stage 작업물이 이미
|
|
772
|
+
# 사라져 있다. 여기서는 통합만 하고 worktree/registry 키는 남긴다.
|
|
773
|
+
teardown=False,
|
|
771
774
|
)
|
|
772
775
|
return FinalVerificationTargetAcquisition(
|
|
773
776
|
target=whole["target"],
|
|
@@ -776,6 +779,75 @@ def _acquire_whole_task_target(
|
|
|
776
779
|
)
|
|
777
780
|
|
|
778
781
|
|
|
782
|
+
def integrate_and_teardown_whole_task(
|
|
783
|
+
*, project_root: Path, task_group: str, task_id: str,
|
|
784
|
+
) -> dict[str, Any]:
|
|
785
|
+
"""판정이 끝난 whole-task 검증의 stage worktree 와 registry 키를 회수한다.
|
|
786
|
+
|
|
787
|
+
진입은 통합만 하고 정리를 남겨 두므로(`_resolve_whole_task_acquisition`), 정리는
|
|
788
|
+
판정 뒤인 Phase 7 에서 여기로 들어온다. 통합은 이미 끝나 있어 Phase A 는 전부
|
|
789
|
+
`already_merged` 로 지나가고 Phase B 만 실제 일을 한다. 두 번 불려도 결과는 같다 —
|
|
790
|
+
사라진 worktree 는 건너뛰고, 미커밋 변경이 남은 stage 트리는 보존한다.
|
|
791
|
+
|
|
792
|
+
stage_map 은 done 행에서 만든다. 정리 대상은 완료된 stage 뿐이고, 계획에만 있고
|
|
793
|
+
완료되지 않은 stage 는 Phase A 가 어차피 건너뛰기 때문이다.
|
|
794
|
+
"""
|
|
795
|
+
from json import loads as _loads
|
|
796
|
+
|
|
797
|
+
from okstra_project.dirs import okstra_home, project_json_path
|
|
798
|
+
|
|
799
|
+
from . import consumers, worktree_registry
|
|
800
|
+
from .locks import worktree_provision_mutex
|
|
801
|
+
from .paths import task_runs_dir
|
|
802
|
+
from .stage_integrate import integrate_stages
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
project_id = _loads(
|
|
806
|
+
project_json_path(project_root).read_text(encoding="utf-8")
|
|
807
|
+
).get("projectId", "")
|
|
808
|
+
except (OSError, ValueError):
|
|
809
|
+
project_id = ""
|
|
810
|
+
if not project_id:
|
|
811
|
+
return {"skipped": "project.json declares no projectId"}
|
|
812
|
+
|
|
813
|
+
plan_run_root = task_runs_dir(
|
|
814
|
+
project_root, task_group, task_id
|
|
815
|
+
) / "implementation-planning"
|
|
816
|
+
done_rows = [
|
|
817
|
+
row for row in consumers.read_consumers(plan_run_root)
|
|
818
|
+
if row.get("status") == "done"
|
|
819
|
+
]
|
|
820
|
+
if not done_rows:
|
|
821
|
+
return {"skipped": "no done stage rows to reclaim"}
|
|
822
|
+
stage_map = [
|
|
823
|
+
{"stage_number": stage}
|
|
824
|
+
for stage in sorted(consumers.latest_done_by_stage(done_rows))
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
entry = worktree_registry.lookup(project_id, task_group, task_id)
|
|
828
|
+
if entry is None:
|
|
829
|
+
return {"skipped": "task worktree is no longer registered"}
|
|
830
|
+
|
|
831
|
+
with worktree_provision_mutex(okstra_home(), project_id, task_group, task_id):
|
|
832
|
+
result = integrate_stages(
|
|
833
|
+
project_id=project_id,
|
|
834
|
+
task_group=task_group,
|
|
835
|
+
task_id=task_id,
|
|
836
|
+
task_worktree_path=entry.worktree_path,
|
|
837
|
+
stage_map=stage_map,
|
|
838
|
+
done_rows=done_rows,
|
|
839
|
+
teardown=True,
|
|
840
|
+
)
|
|
841
|
+
return {
|
|
842
|
+
"tornDown": result.torn_down,
|
|
843
|
+
"teardownSkipped": [
|
|
844
|
+
{"stage": stage, "reason": reason}
|
|
845
|
+
for stage, reason in result.teardown_skipped
|
|
846
|
+
],
|
|
847
|
+
"warnings": result.warnings,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
779
851
|
def acquire_final_verification_target(
|
|
780
852
|
request: FinalVerificationTargetRequest,
|
|
781
853
|
) -> FinalVerificationTargetAcquisition:
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Under cmux this is every lead's door onto cmux surfaces, because okstra owns the
|
|
4
4
|
panes there rather than the host. Outside cmux a worker owns no pane at all — it
|
|
5
|
-
runs as a cli-wrapper subprocess — so there is
|
|
6
|
-
Which backend a run uses is read from its run manifest.
|
|
5
|
+
runs as a cli-wrapper subprocess — so there is no pane for either closing
|
|
6
|
+
command to act on. Which backend a run uses is read from its run manifest.
|
|
7
|
+
|
|
8
|
+
`reclaim` is the round boundary and `teardown` is the end of the run: the first
|
|
9
|
+
closes nothing but the finished dispatches' panes, the second takes every
|
|
10
|
+
recorded pane and writes off whatever never finished.
|
|
7
11
|
"""
|
|
8
12
|
from __future__ import annotations
|
|
9
13
|
|
|
@@ -18,7 +22,11 @@ from . import cmux
|
|
|
18
22
|
from .adapters.dispatch import provider_worker_wrappers
|
|
19
23
|
from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
|
|
20
24
|
from .application.dispatch_assignments import dispatch_assignments
|
|
21
|
-
from .dispatch_state import
|
|
25
|
+
from .dispatch_state import (
|
|
26
|
+
TEARDOWN_BEFORE_TERMINAL_REASON,
|
|
27
|
+
TERMINAL_WORKER_STATUSES,
|
|
28
|
+
mutate_team_state,
|
|
29
|
+
)
|
|
22
30
|
from .dispatch_core import (
|
|
23
31
|
BACKEND_CLI_WRAPPER,
|
|
24
32
|
BACKEND_CMUX_PANE,
|
|
@@ -34,7 +42,6 @@ from .session import observe_lead_session
|
|
|
34
42
|
|
|
35
43
|
|
|
36
44
|
_SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
|
|
37
|
-
_TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
@@ -47,6 +54,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
47
54
|
return _await(args)
|
|
48
55
|
if args.command == "teardown":
|
|
49
56
|
return _teardown(args)
|
|
57
|
+
if args.command == "reclaim":
|
|
58
|
+
return _reclaim(args)
|
|
50
59
|
except DispatchError as exc:
|
|
51
60
|
print(f"okstra team: {exc}", file=sys.stderr)
|
|
52
61
|
return 2
|
|
@@ -62,6 +71,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
62
71
|
_add_dispatch_parser(sub)
|
|
63
72
|
_add_await_parser(sub)
|
|
64
73
|
_add_teardown_parser(sub)
|
|
74
|
+
_add_reclaim_parser(sub)
|
|
65
75
|
return parser
|
|
66
76
|
|
|
67
77
|
|
|
@@ -85,7 +95,19 @@ def _add_await_parser(sub) -> None:
|
|
|
85
95
|
|
|
86
96
|
|
|
87
97
|
def _add_teardown_parser(sub) -> None:
|
|
88
|
-
parser = sub.add_parser(
|
|
98
|
+
parser = sub.add_parser(
|
|
99
|
+
"teardown", help="close every recorded pane at the end of the run"
|
|
100
|
+
)
|
|
101
|
+
_add_run_args(parser)
|
|
102
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
103
|
+
parser.add_argument("--json", action="store_true")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _add_reclaim_parser(sub) -> None:
|
|
107
|
+
parser = sub.add_parser(
|
|
108
|
+
"reclaim",
|
|
109
|
+
help="close the finished dispatches' panes at a round boundary",
|
|
110
|
+
)
|
|
89
111
|
_add_run_args(parser)
|
|
90
112
|
parser.add_argument("--dry-run", action="store_true")
|
|
91
113
|
parser.add_argument("--json", action="store_true")
|
|
@@ -161,8 +183,47 @@ def _teardown(args) -> int:
|
|
|
161
183
|
team_state = _load_json(team_state_path, "team-state")
|
|
162
184
|
panes = _reclaimable_panes(manifest, team_state)
|
|
163
185
|
if args.dry_run:
|
|
164
|
-
|
|
186
|
+
_emit_panes(args.json, panes)
|
|
187
|
+
return 0
|
|
188
|
+
_close_panes(manifest, panes)
|
|
189
|
+
_mark_teardown_errors(team_state_path)
|
|
190
|
+
_emit_panes(args.json, panes)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _reclaim(args) -> int:
|
|
195
|
+
"""Close the finished dispatches' surfaces at a round boundary.
|
|
196
|
+
|
|
197
|
+
Two things teardown does are wrong here. It closes every recorded surface,
|
|
198
|
+
which mid-round would kill the workers still running; and it writes off every
|
|
199
|
+
non-terminal dispatch as an error, which would drop a worker out of the retry
|
|
200
|
+
path it has not reached yet. So this shares the closing and the reporting and
|
|
201
|
+
nothing else.
|
|
202
|
+
"""
|
|
203
|
+
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
204
|
+
_validate_team_manifest(manifest)
|
|
205
|
+
project_root = Path(args.project_root).resolve()
|
|
206
|
+
team_state_path = _resolve_project_path(
|
|
207
|
+
project_root, _require_string(manifest, "teamStatePath")
|
|
208
|
+
)
|
|
209
|
+
team_state = _load_json(team_state_path, "team-state")
|
|
210
|
+
panes = _reclaimable_panes(manifest, team_state, finished_only=True)
|
|
211
|
+
if args.dry_run:
|
|
212
|
+
_emit_panes(args.json, panes)
|
|
165
213
|
return 0
|
|
214
|
+
_close_panes(manifest, panes)
|
|
215
|
+
_emit_panes(args.json, panes)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> None:
|
|
220
|
+
"""Close the given surfaces, then give the lead its width back.
|
|
221
|
+
|
|
222
|
+
The width recovery belongs here rather than to teardown alone. cmux hands the
|
|
223
|
+
freed width to a neighbour it picks, and that neighbour is not always the
|
|
224
|
+
lead — so a round boundary that closed panes and stopped there leaves the
|
|
225
|
+
lead squeezed for exactly the stretch the user spends reading it.
|
|
226
|
+
"""
|
|
166
227
|
from .adapters.runtime.assembly import port_for, runtime_chain
|
|
167
228
|
from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
|
|
168
229
|
|
|
@@ -178,9 +239,6 @@ def _teardown(args) -> int:
|
|
|
178
239
|
chain[0].restore_lead()
|
|
179
240
|
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
|
|
180
241
|
print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
|
|
181
|
-
_mark_teardown_errors(team_state_path)
|
|
182
|
-
_emit_teardown(args.json, panes)
|
|
183
|
-
return 0
|
|
184
242
|
|
|
185
243
|
|
|
186
244
|
def _observe_lead_session_from_manifest(
|
|
@@ -240,10 +298,19 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
|
240
298
|
|
|
241
299
|
|
|
242
300
|
def _reclaimable_panes(
|
|
243
|
-
manifest: Mapping[str, Any],
|
|
301
|
+
manifest: Mapping[str, Any],
|
|
302
|
+
team_state: Mapping[str, Any],
|
|
303
|
+
*,
|
|
304
|
+
finished_only: bool = False,
|
|
244
305
|
) -> list[dict[str, str]]:
|
|
245
306
|
"""Everything this run owns and may close.
|
|
246
307
|
|
|
308
|
+
`finished_only` is what separates a round boundary from the end of the run.
|
|
309
|
+
Teardown closes every recorded surface because no dispatch is expected to
|
|
310
|
+
continue past it. Mid-round the live workers' surfaces must survive, so
|
|
311
|
+
reclaim asks for the finished ones only — closing an `in-progress` surface
|
|
312
|
+
kills that worker and the round has no result to show for it.
|
|
313
|
+
|
|
247
314
|
The recorded ids are the only candidates. There is no per-pane tag API to
|
|
248
315
|
sweep with, and scanning by title would be worse than nothing: cmux labels
|
|
249
316
|
its own agent surfaces with the same glyph the harness uses for a teammate
|
|
@@ -254,8 +321,11 @@ def _reclaimable_panes(
|
|
|
254
321
|
seen: set[str] = set()
|
|
255
322
|
panes: list[dict[str, str]] = []
|
|
256
323
|
for record in team_state.get("workerDispatches", []):
|
|
257
|
-
if isinstance(record, dict):
|
|
258
|
-
|
|
324
|
+
if not isinstance(record, dict):
|
|
325
|
+
continue
|
|
326
|
+
if finished_only and record.get("status") not in TERMINAL_WORKER_STATUSES:
|
|
327
|
+
continue
|
|
328
|
+
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
259
329
|
if _is_cmux_run(manifest):
|
|
260
330
|
return _still_open_surfaces(panes)
|
|
261
331
|
return panes
|
|
@@ -295,7 +365,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
|
295
365
|
def mark(payload: dict[str, Any]) -> bool:
|
|
296
366
|
changed = False
|
|
297
367
|
for record in payload.get("workerDispatches", []):
|
|
298
|
-
if isinstance(record, dict) and record.get("status") not in
|
|
368
|
+
if isinstance(record, dict) and record.get("status") not in TERMINAL_WORKER_STATUSES:
|
|
299
369
|
record["status"] = "error"
|
|
300
370
|
record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
|
|
301
371
|
changed = True
|
|
@@ -304,7 +374,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
|
304
374
|
mutate_team_state(team_state_path, mark)
|
|
305
375
|
|
|
306
376
|
|
|
307
|
-
def
|
|
377
|
+
def _emit_panes(as_json: bool, panes: list[dict[str, str]]) -> None:
|
|
308
378
|
if as_json:
|
|
309
379
|
_print_json({"panes": panes})
|
|
310
380
|
return
|
|
@@ -16,9 +16,8 @@ from typing import Optional, Sequence
|
|
|
16
16
|
|
|
17
17
|
# container watcher/tail pane 전용 태그. 이 태그가 붙은 pane 은 세션 종료 후에도
|
|
18
18
|
# 생존한다 — watcher/tail 의 "세션 후 생존" 불변식이다. 예전에는 SessionEnd 의
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
|
|
19
|
+
# 태그 스캔이 다른 태그만 본다는 사실이 그 생존을 지탱했지만, 지금은 그 스캔과
|
|
20
|
+
# 훅과 스크립트 자체가 없어 pane 을 세션 경계에서 회수하는 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
|
|
22
21
|
CONTAINER_TAG_OPTION = "@okstra_container_run"
|
|
23
22
|
|
|
24
23
|
|
|
@@ -1406,26 +1406,35 @@ def _role_add_prompt(
|
|
|
1406
1406
|
state: WizardState,
|
|
1407
1407
|
requirement: RoleRequirement,
|
|
1408
1408
|
) -> Prompt:
|
|
1409
|
-
"""min=0 선택 역할:
|
|
1409
|
+
"""min=0 선택 역할: 프로필의 적정 수량이 기본이고, 1..max 를 이 스텝에서 고른다.
|
|
1410
|
+
|
|
1411
|
+
적정이 0 이면 기본은 추가 안 함이다. 0 보다 큰 적정을 선언한 역할만 기본이 열린
|
|
1412
|
+
상태로 뜬다 — 어느 쪽이든 사용자는 이 화면에서 바꿀 수 있다.
|
|
1413
|
+
"""
|
|
1410
1414
|
prompt = _p(
|
|
1411
1415
|
state.workspace_root,
|
|
1412
1416
|
"role_add",
|
|
1413
1417
|
role=requirement.role,
|
|
1414
1418
|
maximum=str(requirement.max_count),
|
|
1415
1419
|
)
|
|
1420
|
+
suffix = prompt["options"].get("default_suffix", "")
|
|
1421
|
+
|
|
1422
|
+
def _default_suffix(count: int) -> str:
|
|
1423
|
+
return suffix if count == requirement.recommended_count else ""
|
|
1424
|
+
|
|
1416
1425
|
options = [
|
|
1417
1426
|
_opt(
|
|
1418
1427
|
"0",
|
|
1419
|
-
prompt["options"]["skip"].format(
|
|
1420
|
-
default_suffix=prompt["options"].get("default_suffix", ""),
|
|
1421
|
-
),
|
|
1428
|
+
prompt["options"]["skip"].format(default_suffix=_default_suffix(0)),
|
|
1422
1429
|
),
|
|
1423
1430
|
]
|
|
1424
1431
|
for count in range(1, requirement.max_count + 1):
|
|
1425
1432
|
options.append(
|
|
1426
1433
|
_opt(
|
|
1427
1434
|
str(count),
|
|
1428
|
-
prompt["options"]["add"].format(
|
|
1435
|
+
prompt["options"]["add"].format(
|
|
1436
|
+
count=count, default_suffix=_default_suffix(count),
|
|
1437
|
+
),
|
|
1429
1438
|
)
|
|
1430
1439
|
)
|
|
1431
1440
|
return Prompt(
|
|
@@ -3865,10 +3874,10 @@ def _handoff_eligibility(state: WizardState) -> list:
|
|
|
3865
3874
|
return compute_eligibility(stage_map, rows)
|
|
3866
3875
|
|
|
3867
3876
|
|
|
3868
|
-
def
|
|
3877
|
+
def _latest_whole_task_fv_release_ready(state: WizardState) -> str:
|
|
3869
3878
|
"""accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
|
|
3870
|
-
from okstra_ctl.handoff import
|
|
3871
|
-
return
|
|
3879
|
+
from okstra_ctl.handoff import latest_whole_task_fv_release_ready
|
|
3880
|
+
return latest_whole_task_fv_release_ready(
|
|
3872
3881
|
state.project_root, state.project_id, state.task_group, state.task_id)
|
|
3873
3882
|
|
|
3874
3883
|
|
|
@@ -3876,7 +3885,7 @@ def _build_handoff_stage_pick(state: WizardState) -> Prompt:
|
|
|
3876
3885
|
elig = _handoff_eligibility(state)
|
|
3877
3886
|
eligible = [e for e in elig if e["eligible"]]
|
|
3878
3887
|
blocked = [e for e in elig if not e["eligible"]]
|
|
3879
|
-
whole_task_report =
|
|
3888
|
+
whole_task_report = _latest_whole_task_fv_release_ready(state)
|
|
3880
3889
|
msgs = _handoff_msgs(state)
|
|
3881
3890
|
blocked_summary = ("; ".join(
|
|
3882
3891
|
f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
|
|
@@ -3908,7 +3917,7 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
3908
3917
|
if WHOLE_TASK_STAGE in picks:
|
|
3909
3918
|
if len(picks) > 1:
|
|
3910
3919
|
raise WizardError(t["errors"]["whole_task_exclusive"])
|
|
3911
|
-
if not
|
|
3920
|
+
if not _latest_whole_task_fv_release_ready(state):
|
|
3912
3921
|
raise WizardError(t["errors"]["whole_task_missing"])
|
|
3913
3922
|
state.handoff_mode = "whole-task"
|
|
3914
3923
|
state.handoff_stages = ""
|
|
@@ -37,6 +37,8 @@ from dataclasses import dataclass
|
|
|
37
37
|
from datetime import datetime, timezone
|
|
38
38
|
from pathlib import Path
|
|
39
39
|
|
|
40
|
+
from .wrapper_status import log_path_for_prompt
|
|
41
|
+
|
|
40
42
|
from okstra_ctl.dispatch_state import (
|
|
41
43
|
DispatchError,
|
|
42
44
|
LIVENESS_AUDIT_HEARTBEAT,
|
|
@@ -69,7 +71,7 @@ def _utc_now() -> datetime:
|
|
|
69
71
|
|
|
70
72
|
def _log_path(prompt: Path) -> Path:
|
|
71
73
|
"""The wrapper's live log, named as okstra-*-exec.sh names it."""
|
|
72
|
-
return
|
|
74
|
+
return log_path_for_prompt(prompt)
|
|
73
75
|
|
|
74
76
|
|
|
75
77
|
def probe_heartbeat(
|
|
@@ -225,8 +225,8 @@ class _AbnormalExit:
|
|
|
225
225
|
their default disposition ends the process outright, and nothing in this
|
|
226
226
|
file runs (measured — a bash ``trap … EXIT`` does fire on SIGTERM, which is
|
|
227
227
|
why the shell wrappers needed no equivalent of this class). Those two are
|
|
228
|
-
the common abnormal exits: a pane
|
|
229
|
-
teardown. Without this the sidecar stays at ``started`` and
|
|
228
|
+
the common abnormal exits: a pane close, ``okstra team reclaim`` /
|
|
229
|
+
``okstra team teardown``, session teardown. Without this the sidecar stays at ``started`` and
|
|
230
230
|
``worker_liveness`` reads a dead worker as a working one.
|
|
231
231
|
|
|
232
232
|
SIGKILL and a host crash remain uncovered because nothing can cover them. A
|
|
@@ -25,6 +25,21 @@ def status_path_for_prompt(prompt_path: Path) -> Path:
|
|
|
25
25
|
return prompt_path.with_suffix(prompt_path.suffix + ".status.json")
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def log_path_for_prompt(prompt_path: Path) -> Path:
|
|
29
|
+
"""Where the wrapper writes its live log for this prompt.
|
|
30
|
+
|
|
31
|
+
A `.md` prompt drops that suffix rather than stacking on it, so the log of
|
|
32
|
+
`…-009.md` is `…-009.log` and not `…-009.md.log`. Both spellings existed:
|
|
33
|
+
the entrypoint wrote the first while the dispatcher listed the second among
|
|
34
|
+
the write policy's allowed artifacts, so every CLI worker's own log read as
|
|
35
|
+
an unauthorized change to the artifact root. One function now, because the
|
|
36
|
+
disagreement stays invisible until an audit compares the two.
|
|
37
|
+
"""
|
|
38
|
+
if prompt_path.name.endswith(".md"):
|
|
39
|
+
return prompt_path.with_name(f"{prompt_path.name[:-3]}.log")
|
|
40
|
+
return Path(f"{prompt_path}.log")
|
|
41
|
+
|
|
42
|
+
|
|
28
43
|
def read_wrapper_status(path: Path) -> WrapperStatus | None:
|
|
29
44
|
try:
|
|
30
45
|
raw = json.loads(path.read_text(encoding="utf-8"))
|