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.
Files changed (62) hide show
  1. package/dist/commands/execute/team.mjs +14 -4
  2. package/dist/commands/execute/team.mjs.map +1 -1
  3. package/dist/commands/lifecycle/install.mjs +0 -1
  4. package/dist/commands/lifecycle/install.mjs.map +1 -1
  5. package/docs/architecture.md +3 -3
  6. package/docs/cli.md +1 -1
  7. package/docs/project-structure-overview.md +2 -2
  8. package/docs/task-process/final-verification.md +5 -3
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/report-writer-worker.md +2 -2
  12. package/runtime/bin/okstra-compact-reminder.sh +2 -2
  13. package/runtime/bin/okstra-provider-exec.py +2 -7
  14. package/runtime/bin/okstra-render-report-views.py +13 -10
  15. package/runtime/prompts/coding-preflight/overview.md +2 -1
  16. package/runtime/prompts/lead/convergence.md +11 -6
  17. package/runtime/prompts/lead/okstra-lead-contract.md +3 -3
  18. package/runtime/prompts/lead/plan-body-verification.md +4 -2
  19. package/runtime/prompts/lead/report-writer.md +8 -4
  20. package/runtime/prompts/profiles/_common-contract.md +2 -2
  21. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  22. package/runtime/prompts/profiles/final-verification.md +11 -9
  23. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  24. package/runtime/prompts/profiles/release-handoff.md +7 -6
  25. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  26. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
  27. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
  28. package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
  29. package/runtime/python/okstra_ctl/agent_prompt_cli.py +15 -2
  30. package/runtime/python/okstra_ctl/dispatch_core.py +140 -17
  31. package/runtime/python/okstra_ctl/dispatch_state.py +60 -3
  32. package/runtime/python/okstra_ctl/execution_mutation_audit.py +49 -3
  33. package/runtime/python/okstra_ctl/handoff.py +27 -14
  34. package/runtime/python/okstra_ctl/model_cli.py +11 -2
  35. package/runtime/python/okstra_ctl/model_discovery.py +12 -0
  36. package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
  37. package/runtime/python/okstra_ctl/release_gate.py +56 -0
  38. package/runtime/python/okstra_ctl/render.py +53 -0
  39. package/runtime/python/okstra_ctl/report_contract.py +1 -0
  40. package/runtime/python/okstra_ctl/report_finalize.py +54 -0
  41. package/runtime/python/okstra_ctl/report_html/render.py +7 -4
  42. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +4 -1
  43. package/runtime/python/okstra_ctl/run.py +119 -18
  44. package/runtime/python/okstra_ctl/stage_targets.py +73 -1
  45. package/runtime/python/okstra_ctl/team.py +84 -14
  46. package/runtime/python/okstra_ctl/tmux.py +2 -3
  47. package/runtime/python/okstra_ctl/wizard.py +19 -10
  48. package/runtime/python/okstra_ctl/worker_liveness.py +3 -1
  49. package/runtime/python/okstra_ctl/worker_runner.py +2 -2
  50. package/runtime/python/okstra_ctl/wrapper_status.py +15 -0
  51. package/runtime/python/okstra_ctl/write_policy.py +9 -1
  52. package/runtime/schemas/final-report-v2.0.schema.json +58 -19
  53. package/runtime/skills/okstra-run/SKILL.md +3 -3
  54. package/runtime/templates/reports/html/base.template.html +1 -2
  55. package/runtime/templates/reports/html/i18n/en.json +4 -0
  56. package/runtime/templates/reports/html/i18n/ko.json +4 -0
  57. package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -1
  58. package/runtime/validators/validate-report-views.py +30 -17
  59. package/runtime/validators/validate-run.py +221 -90
  60. package/runtime/validators/validate_analysis_report.py +2 -5
  61. package/runtime/validators/validate_session_conformance.py +1 -1
  62. 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
- latest_whole_task_fv_accepted)
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 = latest_whole_task_fv_accepted(
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
- # `qaCommands` 는 implementation phase verifier 의 QA gate baseline 으로만
1749
- # 쓰이므로 검증도 implementation 진입 시에만 수행한다. 다른 task-type 에서는
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
- _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
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.model_display,
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=True,
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 nothing for teardown to reclaim.
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 TEARDOWN_BEFORE_TERMINAL_REASON, mutate_team_state
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("teardown", help="reclaim this run's worker panes")
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
- _emit_teardown(args.json, panes)
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], team_state: 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
- _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
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 _TERMINAL_STATUSES:
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 _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
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
- # `okstra-trace-cleanup.sh --reap` 이 다른 태그만 스캔한다는 사실이 그 생존을
20
- # 지탱했지만, 지금은 그 모드와 훅 자체가 없어 pane 을 세션 경계에서 회수하는
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 선택 역할: 기본은 추가 안 함, 추가 시 1..max 수량을 이 스텝에서 고른다."""
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(count=count),
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 _latest_whole_task_fv_accepted(state: WizardState) -> str:
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 latest_whole_task_fv_accepted
3871
- return latest_whole_task_fv_accepted(
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 = _latest_whole_task_fv_accepted(state)
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 _latest_whole_task_fv_accepted(state):
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 prompt.with_suffix(".log") if prompt.suffix == ".md" else Path(f"{prompt}.log")
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 kill, ``okstra-trace-cleanup.sh``, session
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"))