okstra 0.206.1 → 0.207.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/README.md +1 -1
- package/dist/cli-registry.mjs +7 -1
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -0
- package/docs/architecture.md +28 -4
- package/docs/cli.md +13 -11
- package/docs/project-structure-overview.md +4 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/code-review.json +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/bin/okstra-compact-reminder.sh +1 -1
- package/runtime/prompts/duties/direction-selection-worker.json +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +4 -3
- package/runtime/prompts/lead/convergence.md +41 -9
- package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
- package/runtime/prompts/lead/report-writer.md +8 -6
- package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
- package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
- package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
- package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
- package/runtime/python/okstra_ctl/code_review_target.py +92 -6
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
- package/runtime/python/okstra_ctl/domain/provider.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
- package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
- package/runtime/python/okstra_ctl/lead_progress.py +33 -1
- package/runtime/python/okstra_ctl/manager_view.py +26 -19
- package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
- package/runtime/python/okstra_ctl/models.py +4 -1
- package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/process_group.py +118 -0
- package/runtime/python/okstra_ctl/render.py +6 -2
- package/runtime/python/okstra_ctl/report_assembly.py +17 -2
- package/runtime/python/okstra_ctl/report_finalize.py +106 -2
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
- package/runtime/python/okstra_ctl/team.py +108 -9
- package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
- package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
- package/runtime/python/okstra_ctl/worker_runner.py +21 -3
- package/runtime/python/okstra_ctl/write_policy.py +57 -7
- package/runtime/python/okstra_project/dirs.py +14 -0
- package/runtime/python/okstra_project/resolver.py +2 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
- package/runtime/skills/okstra-code-review/SKILL.md +70 -32
- package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/manager/view.template.html +18 -1
|
@@ -62,7 +62,10 @@ from .final_report_paths import (
|
|
|
62
62
|
final_report_markdown_path,
|
|
63
63
|
sidecar_source_rel,
|
|
64
64
|
)
|
|
65
|
-
from .
|
|
65
|
+
from .run_artifact_prune import prune_run_artifacts
|
|
66
|
+
from .handoff import record_verified
|
|
67
|
+
from .handoff_error import HandoffError
|
|
68
|
+
from .paths import RunRef, task_dir, task_manifest_file
|
|
66
69
|
from .report_view_artifacts import html_view_path
|
|
67
70
|
from .release_gate import release_handoff_allowed
|
|
68
71
|
from .report_translation_dispatch import TranslateOutcome, translate_report
|
|
@@ -73,6 +76,7 @@ from .stage_targets import (
|
|
|
73
76
|
integrate_and_teardown_whole_task,
|
|
74
77
|
)
|
|
75
78
|
from .session import observe_lead_session
|
|
79
|
+
from .lead_progress import record_checkpoint
|
|
76
80
|
from .error_log_write import record_runtime_failure
|
|
77
81
|
|
|
78
82
|
# 포인터 값 타입만 쓴다. `okstra_project.phase_pointer` 는 okstra 안의
|
|
@@ -91,10 +95,12 @@ STEP_TRANSLATE = "translate"
|
|
|
91
95
|
STEP_TOKEN_USAGE = "token-usage"
|
|
92
96
|
STEP_RENDER_VIEWS = "render-views"
|
|
93
97
|
STEP_SPAWN_FOLLOWUPS = "spawn-followups"
|
|
98
|
+
STEP_RECORD_VERIFIED = "record-verified"
|
|
94
99
|
STEP_VALIDATE_RUN = "validate-run"
|
|
95
100
|
STEP_RECORD_GROUP_MEMORY = "record-group-memory"
|
|
96
101
|
STEP_TEARDOWN_STAGES = "teardown-stages"
|
|
97
102
|
STEP_PREFLIGHT = "preflight"
|
|
103
|
+
STEP_PRUNE_RUN_ARTIFACTS = "prune-run-artifacts"
|
|
98
104
|
|
|
99
105
|
STEP_ORDER = (
|
|
100
106
|
STEP_PROJECT_ACTIVITY,
|
|
@@ -102,13 +108,15 @@ STEP_ORDER = (
|
|
|
102
108
|
STEP_TOKEN_USAGE,
|
|
103
109
|
STEP_RENDER_VIEWS,
|
|
104
110
|
STEP_SPAWN_FOLLOWUPS,
|
|
111
|
+
STEP_RECORD_VERIFIED,
|
|
105
112
|
STEP_VALIDATE_RUN,
|
|
106
113
|
# After validation: the group's sibling tasks read this run's conclusion
|
|
107
114
|
# from `group-context.md`, and only a validated record is worth handing on.
|
|
108
115
|
STEP_RECORD_GROUP_MEMORY,
|
|
109
|
-
#
|
|
116
|
+
# Only after the run validated: it removes the stage worktrees a
|
|
110
117
|
# blocked verdict would send the user straight back to.
|
|
111
118
|
STEP_TEARDOWN_STAGES,
|
|
119
|
+
STEP_PRUNE_RUN_ARTIFACTS,
|
|
112
120
|
)
|
|
113
121
|
|
|
114
122
|
V3_STEP_ORDER = (
|
|
@@ -119,9 +127,12 @@ V3_STEP_ORDER = (
|
|
|
119
127
|
STEP_TRANSLATE,
|
|
120
128
|
STEP_RENDER_VIEWS,
|
|
121
129
|
STEP_SPAWN_FOLLOWUPS,
|
|
130
|
+
# Needs the assembled record; `validate-run` requires the row it writes.
|
|
131
|
+
STEP_RECORD_VERIFIED,
|
|
122
132
|
STEP_VALIDATE_RUN,
|
|
123
133
|
STEP_RECORD_GROUP_MEMORY,
|
|
124
134
|
STEP_TEARDOWN_STAGES,
|
|
135
|
+
STEP_PRUNE_RUN_ARTIFACTS,
|
|
125
136
|
)
|
|
126
137
|
|
|
127
138
|
# 앞 단계가 실패하면 건너뛰는 단계. 하나는 worktree 를 거두고, 하나는 검증 안 된
|
|
@@ -391,6 +402,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
|
|
|
391
402
|
ctx.task_key,
|
|
392
403
|
],
|
|
393
404
|
),
|
|
405
|
+
(
|
|
406
|
+
STEP_RECORD_VERIFIED,
|
|
407
|
+
["<in-process>", "record-verified", str(ctx.data_path)],
|
|
408
|
+
),
|
|
394
409
|
(STEP_VALIDATE_RUN, _validate_run_command(ctx, ctx.data_path)),
|
|
395
410
|
(
|
|
396
411
|
STEP_RECORD_GROUP_MEMORY,
|
|
@@ -400,6 +415,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
|
|
|
400
415
|
STEP_TEARDOWN_STAGES,
|
|
401
416
|
["<in-process>", "teardown-stages", str(ctx.data_path)],
|
|
402
417
|
),
|
|
418
|
+
(
|
|
419
|
+
STEP_PRUNE_RUN_ARTIFACTS,
|
|
420
|
+
["<in-process>", "prune-run-artifacts", str(ctx.data_path)],
|
|
421
|
+
),
|
|
403
422
|
]
|
|
404
423
|
if ctx.report_contract_version != "3.0":
|
|
405
424
|
return commands
|
|
@@ -502,6 +521,51 @@ def _next_in_group(steps: Sequence[Mapping[str, Any]]) -> dict[str, str] | None:
|
|
|
502
521
|
return None
|
|
503
522
|
|
|
504
523
|
|
|
524
|
+
def _record_verified_stages(
|
|
525
|
+
ctx: FinalizeContext,
|
|
526
|
+
command: list[str],
|
|
527
|
+
) -> subprocess.CompletedProcess:
|
|
528
|
+
"""Write a `verified` row for every stage a release-ready verdict clears.
|
|
529
|
+
|
|
530
|
+
`handoff record-verified` needs the assembled `data.json` and `validate-run`
|
|
531
|
+
requires its rows, and both happen inside this one call, so the lead had no
|
|
532
|
+
point at which to run it (observed 2026-09-26, dev-11054). Re-recording the
|
|
533
|
+
same report is a no-op, so a resumed finalize may run this again.
|
|
534
|
+
"""
|
|
535
|
+
def _done(payload: Mapping[str, Any]) -> subprocess.CompletedProcess:
|
|
536
|
+
return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")
|
|
537
|
+
|
|
538
|
+
if ctx.task_type != "final-verification":
|
|
539
|
+
return _done({"skipped": "not a final-verification run"})
|
|
540
|
+
try:
|
|
541
|
+
data = load_owned_object(Path(ctx.data_path), artifact="final report record")
|
|
542
|
+
except JsonBoundaryError as exc:
|
|
543
|
+
return subprocess.CompletedProcess(
|
|
544
|
+
command, 1, "", f"cannot read final-report data.json: {exc}")
|
|
545
|
+
if data.get("verificationScope") not in ("single-stage", "whole-task"):
|
|
546
|
+
return _done({"skipped": "report names no verification scope"})
|
|
547
|
+
if not release_handoff_allowed(data):
|
|
548
|
+
return _done({"skipped": "verdict does not clear the work for release"})
|
|
549
|
+
stages = sorted({
|
|
550
|
+
row["stage"]
|
|
551
|
+
for row in (data.get("finalVerification") or {}).get("stageReports") or ()
|
|
552
|
+
if isinstance(row, Mapping) and isinstance(row.get("stage"), int)
|
|
553
|
+
})
|
|
554
|
+
try:
|
|
555
|
+
plan_run_root = (
|
|
556
|
+
RunRef.from_report_path(Path(ctx.data_path).resolve())
|
|
557
|
+
.sibling("implementation-planning").run_dir
|
|
558
|
+
)
|
|
559
|
+
for stage in stages:
|
|
560
|
+
record_verified(
|
|
561
|
+
plan_run_root=plan_run_root, stage=stage,
|
|
562
|
+
report_path=str(ctx.data_path), data_json=str(ctx.data_path),
|
|
563
|
+
)
|
|
564
|
+
except (HandoffError, ValueError, OSError) as exc:
|
|
565
|
+
return subprocess.CompletedProcess(command, 1, "", str(exc))
|
|
566
|
+
return _done({"recorded": stages})
|
|
567
|
+
|
|
568
|
+
|
|
505
569
|
def _teardown_stage_worktrees(
|
|
506
570
|
ctx: FinalizeContext,
|
|
507
571
|
command: list[str],
|
|
@@ -538,6 +602,27 @@ def _teardown_stage_worktrees(
|
|
|
538
602
|
return _done(result)
|
|
539
603
|
|
|
540
604
|
|
|
605
|
+
def _prune_run_artifacts(
|
|
606
|
+
ctx: FinalizeContext,
|
|
607
|
+
command: list[str],
|
|
608
|
+
) -> subprocess.CompletedProcess:
|
|
609
|
+
"""이 run 디렉터리에서 run 종료 뒤 읽는 곳이 없는 산출물을 지운다.
|
|
610
|
+
|
|
611
|
+
실패한 run 에서도 돈다. 빌드 디렉터리는 남은 lockfile 로 다시 만들고, dispatch
|
|
612
|
+
스냅샷은 검증을 통과한 run 의 것만 지운다(`run_artifact_prune`).
|
|
613
|
+
"""
|
|
614
|
+
reports_dir = Path(ctx.data_path).resolve().parent
|
|
615
|
+
if reports_dir.name != "reports":
|
|
616
|
+
return subprocess.CompletedProcess(
|
|
617
|
+
command, 1, "", f"report record is not under a run's reports/: {ctx.data_path}")
|
|
618
|
+
run_dir = reports_dir.parent
|
|
619
|
+
try:
|
|
620
|
+
result = prune_run_artifacts(ctx.project_root, run_dir, apply=True)
|
|
621
|
+
except OSError as exc:
|
|
622
|
+
return subprocess.CompletedProcess(command, 1, "", str(exc))
|
|
623
|
+
return subprocess.CompletedProcess(command, 0, json.dumps(result), "")
|
|
624
|
+
|
|
625
|
+
|
|
541
626
|
def _validate_run_command(ctx: FinalizeContext, report_record_path: Path) -> list[str]:
|
|
542
627
|
command = [
|
|
543
628
|
sys.executable,
|
|
@@ -875,8 +960,12 @@ def _run_finalize_step(
|
|
|
875
960
|
return _run_translate(ctx, command)
|
|
876
961
|
if name == STEP_TEARDOWN_STAGES:
|
|
877
962
|
return _teardown_stage_worktrees(ctx, command)
|
|
963
|
+
if name == STEP_PRUNE_RUN_ARTIFACTS:
|
|
964
|
+
return _prune_run_artifacts(ctx, command)
|
|
878
965
|
if name == STEP_RECORD_GROUP_MEMORY:
|
|
879
966
|
return _record_group_memory(ctx, command)
|
|
967
|
+
if name == STEP_RECORD_VERIFIED:
|
|
968
|
+
return _record_verified_stages(ctx, command)
|
|
880
969
|
if name == STEP_VALIDATE_RUN:
|
|
881
970
|
try:
|
|
882
971
|
_link_lead_result_for_validation(ctx)
|
|
@@ -1066,6 +1155,9 @@ Runs the Phase 7 steps in their contractual order against one final-report:
|
|
|
1066
1155
|
5. spawn-followups turn section 4 rows into task stubs
|
|
1067
1156
|
6. validate-run validate the finished run artifacts
|
|
1068
1157
|
7. record-group-memory / 8. teardown-stages after a clean validation
|
|
1158
|
+
9. prune-run-artifacts remove node_modules and .next under this run
|
|
1159
|
+
directory, and the dispatch snapshots and publication
|
|
1160
|
+
locks of a validated run; runs after a failure too
|
|
1069
1161
|
|
|
1070
1162
|
Every step is idempotent, so re-running after a fixed failure is safe. The
|
|
1071
1163
|
sequence stops at the first non-zero exit and reports which step failed, except
|
|
@@ -1075,6 +1167,10 @@ exits non-zero the command prints the --only flags that resume the sequence
|
|
|
1075
1167
|
from the earliest failing step — follow those rather than rerunning the failing
|
|
1076
1168
|
step alone, which would leave the html rendered before the tokens landed.
|
|
1077
1169
|
|
|
1170
|
+
A full run (no --only) first records the lead's `phase-7-persist` checkpoint
|
|
1171
|
+
and returns it as `progressLines`; the lead emits that line instead of calling
|
|
1172
|
+
`okstra lead-progress append` for it.
|
|
1173
|
+
|
|
1078
1174
|
This is the same code path the Codex lead adapter runs automatically after its
|
|
1079
1175
|
report-writer completes, so a Claude-led and a Codex-led run finalize
|
|
1080
1176
|
identically.
|
|
@@ -1264,7 +1360,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1264
1360
|
# the generations resume and compaction split it into. It runs ahead of the
|
|
1265
1361
|
# sequence because the token-usage step below reads `leadSessionIds`.
|
|
1266
1362
|
observe_lead_session(ctx.project_root, ctx.team_state_path)
|
|
1363
|
+
# 재개(`--only`)는 Phase 7 을 새로 시작하지 않고 다시 들어가는 것이다. 이 행은
|
|
1364
|
+
# 그것을 요구하는 `validate-run` 보다 먼저 있어야 한다.
|
|
1365
|
+
progress_line = None if args.only else record_checkpoint(
|
|
1366
|
+
ctx.project_root, ctx.manifest_path, "phase-7-persist",
|
|
1367
|
+
source="okstra report-finalize",
|
|
1368
|
+
)
|
|
1267
1369
|
result = run_finalize(ctx, only=args.only or None)
|
|
1370
|
+
if progress_line:
|
|
1371
|
+
result["progressLines"] = [progress_line]
|
|
1268
1372
|
order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
|
|
1269
1373
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
1270
1374
|
print("finalize steps:", file=sys.stderr)
|
|
@@ -2498,7 +2498,7 @@ def recommended_role_models(
|
|
|
2498
2498
|
codex 이고 다른 provider 요청은 거부된다(`resolve_lead_provider`). 그래서
|
|
2499
2499
|
`lead_provider` 를 받아 그 provider 의 기본값으로 해소한다. 받지 않으면
|
|
2500
2500
|
role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
|
|
2501
|
-
화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-6-sol` 을 배정한다.
|
|
2501
|
+
화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-6.1-sol` 을 배정한다.
|
|
2502
2502
|
"""
|
|
2503
2503
|
lead_provider_id = lead_provider or "claude"
|
|
2504
2504
|
report_writer_provider_id = report_writer_provider or "claude"
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""run 이 끝난 뒤 읽는 곳이 없는 산출물을 찾고 지운다.
|
|
2
|
+
|
|
3
|
+
- 의존성·빌드 디렉터리(`node_modules`, `.next`): technical-verification 워커는
|
|
4
|
+
`experiments/<seq>/<worker-id>/` 에 사이트 복사본을 만들고 그 안에서 설치·빌드한다.
|
|
5
|
+
증거는 계획·로그·lockfile·diff 이고, 설치·빌드 결과는 lockfile 로 다시 만든다. 태스크
|
|
6
|
+
하나에 `node_modules` 46개와 `.next` 16개(약 22GB)가 남은 사례가 있다(2026-09-27).
|
|
7
|
+
- dispatch 스냅샷(`*.mutation-audit.json`)과 프롬프트 발행 잠금(`*.publish.lock`): 워커
|
|
8
|
+
시도의 종결 판정과 같은 run 의 증거 복구만 읽는다. 결과는 시도 기록의 변경 요약에
|
|
9
|
+
남고, `validate-run` 은 경로 문자열만 본다. 검증을 통과한 run 과, 같은 run 디렉터리에서
|
|
10
|
+
더 뒤의 run 이 검증을 통과한 run 의 dispatch 기록이 가리키는 것만 지운다 — 가장 최근의
|
|
11
|
+
실패한 run 은 같은 run 안에서 워커를 다시 보낼 수 있다.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from okstra_project import resolve_project_root
|
|
24
|
+
|
|
25
|
+
from .fixed_text import line
|
|
26
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
27
|
+
from .paths import task_dir
|
|
28
|
+
|
|
29
|
+
PRUNED_DIR_NAMES = frozenset({"node_modules", ".next"})
|
|
30
|
+
_DISPATCH_ARRAYS = ("agentDispatches", "workerDispatches")
|
|
31
|
+
_MANIFEST_SEQ = re.compile(r"^run-manifest-.+-(\d{3,})\.json$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tree_bytes(root: Path) -> int:
|
|
35
|
+
total = 0
|
|
36
|
+
for current, _dirs, files in os.walk(root):
|
|
37
|
+
for name in files:
|
|
38
|
+
try:
|
|
39
|
+
total += os.lstat(os.path.join(current, name)).st_size
|
|
40
|
+
except OSError:
|
|
41
|
+
continue
|
|
42
|
+
return total
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_build_dirs(root: Path) -> list[dict[str, Any]]:
|
|
46
|
+
"""`root` 아래 의존성·빌드 디렉터리. 심볼릭 링크는 따라가지도 지우지도 않는다."""
|
|
47
|
+
found: list[dict[str, Any]] = []
|
|
48
|
+
# `.okstra` 아래는 `Path.glob("**")` 이 숨김 디렉터리를 건너뛰므로 os.walk 로 훑는다.
|
|
49
|
+
for current, dirs, _files in os.walk(root):
|
|
50
|
+
kept = []
|
|
51
|
+
for name in dirs:
|
|
52
|
+
path = Path(current) / name
|
|
53
|
+
if name in PRUNED_DIR_NAMES and not path.is_symlink():
|
|
54
|
+
found.append({"path": str(path), "bytes": _tree_bytes(path)})
|
|
55
|
+
else:
|
|
56
|
+
kept.append(name)
|
|
57
|
+
dirs[:] = kept
|
|
58
|
+
return sorted(found, key=lambda row: row["path"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve(project_root: Path, value: str) -> Path:
|
|
62
|
+
path = Path(value)
|
|
63
|
+
return path if path.is_absolute() else project_root / path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _settled_manifests(manifests_dir: Path, names: list[str]) -> list[dict[str, Any]]:
|
|
67
|
+
"""검증을 통과했거나, 같은 디렉터리의 더 뒤 run 이 통과한 run 매니페스트.
|
|
68
|
+
|
|
69
|
+
한 `manifests/` 의 seq 는 한 카운터에서 나오므로 숫자 순서가 실행 순서다.
|
|
70
|
+
"""
|
|
71
|
+
loaded: list[tuple[int, dict[str, Any]]] = []
|
|
72
|
+
for name in names:
|
|
73
|
+
match = _MANIFEST_SEQ.match(name)
|
|
74
|
+
if match is None:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
manifest = load_owned_object(manifests_dir / name, artifact="run manifest")
|
|
78
|
+
except (OSError, JsonBoundaryError):
|
|
79
|
+
continue
|
|
80
|
+
loaded.append((int(match[1]), manifest))
|
|
81
|
+
passed = [
|
|
82
|
+
seq for seq, manifest in loaded
|
|
83
|
+
if (manifest.get("validation") or {}).get("status") == "passed"
|
|
84
|
+
]
|
|
85
|
+
if not passed:
|
|
86
|
+
return []
|
|
87
|
+
return [manifest for seq, manifest in loaded if seq <= max(passed)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_dispatch_leftovers(project_root: Path, root: Path) -> list[dict[str, Any]]:
|
|
91
|
+
"""`root` 아래 끝난 run(`_settled_manifests`)의 dispatch 스냅샷과 발행 잠금."""
|
|
92
|
+
found: dict[str, int] = {}
|
|
93
|
+
for current, dirs, files in os.walk(root):
|
|
94
|
+
dirs[:] = [name for name in dirs if name not in PRUNED_DIR_NAMES]
|
|
95
|
+
if Path(current).name != "manifests":
|
|
96
|
+
continue
|
|
97
|
+
for manifest in _settled_manifests(Path(current), files):
|
|
98
|
+
try:
|
|
99
|
+
team_state = load_owned_object(
|
|
100
|
+
_resolve(project_root, str(manifest.get("teamStatePath") or "")),
|
|
101
|
+
artifact="team state",
|
|
102
|
+
)
|
|
103
|
+
except (OSError, JsonBoundaryError):
|
|
104
|
+
continue
|
|
105
|
+
for key in _DISPATCH_ARRAYS:
|
|
106
|
+
for row in team_state.get(key) or ():
|
|
107
|
+
if not isinstance(row, dict):
|
|
108
|
+
continue
|
|
109
|
+
candidates = [row.get("mutationAuditSnapshotPath")]
|
|
110
|
+
if row.get("promptPath"):
|
|
111
|
+
candidates.append(f"{row['promptPath']}.publish.lock")
|
|
112
|
+
for value in candidates:
|
|
113
|
+
if not isinstance(value, str) or not value:
|
|
114
|
+
continue
|
|
115
|
+
path = _resolve(project_root, value)
|
|
116
|
+
if path.is_file() and not path.is_symlink():
|
|
117
|
+
found[str(path)] = path.stat().st_size
|
|
118
|
+
return [{"path": path, "bytes": size} for path, size in sorted(found.items())]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def prune_run_artifacts(project_root: Path, root: Path, *, apply: bool) -> dict[str, Any]:
|
|
122
|
+
dirs = find_build_dirs(root) if root.is_dir() else []
|
|
123
|
+
files = find_dispatch_leftovers(project_root, root) if root.is_dir() else []
|
|
124
|
+
if apply:
|
|
125
|
+
for row in dirs:
|
|
126
|
+
shutil.rmtree(row["path"])
|
|
127
|
+
for row in files:
|
|
128
|
+
Path(row["path"]).unlink(missing_ok=True)
|
|
129
|
+
return {
|
|
130
|
+
"root": str(root),
|
|
131
|
+
"applied": apply,
|
|
132
|
+
"dirs": dirs,
|
|
133
|
+
"files": files,
|
|
134
|
+
"totalBytes": sum(row["bytes"] for row in (*dirs, *files)),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_result_text(result: dict[str, Any]) -> str:
|
|
139
|
+
parts = [
|
|
140
|
+
"# Okstra Run Artifact Prune\n\n",
|
|
141
|
+
line("Root", result.get("root")),
|
|
142
|
+
line("Applied", result.get("applied")),
|
|
143
|
+
line("Directory count", len(result.get("dirs") or [])),
|
|
144
|
+
line("File count", len(result.get("files") or [])),
|
|
145
|
+
line("Total bytes", result.get("totalBytes")),
|
|
146
|
+
]
|
|
147
|
+
for row in result.get("dirs") or []:
|
|
148
|
+
parts.extend(("\n## Directory\n\n", line("Path", row["path"]), line("Bytes", row["bytes"])))
|
|
149
|
+
return "".join(parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_CLI_EPILOG = r"""Usage:
|
|
153
|
+
okstra prune-run-artifacts [--project-root <dir>] [--cwd <dir>]
|
|
154
|
+
[--task-group <group> --task-id <id>] [--apply] [--text]
|
|
155
|
+
|
|
156
|
+
Lists what no reader needs once a run has ended, under
|
|
157
|
+
<project-root>/.okstra/tasks (or one task root), with sizes and a total:
|
|
158
|
+
- every node_modules and .next directory (symbolic links are neither
|
|
159
|
+
followed nor removed);
|
|
160
|
+
- the dispatch snapshots (*.mutation-audit.json) and prompt publication
|
|
161
|
+
locks (*.publish.lock) recorded by a run whose validation passed, or by
|
|
162
|
+
an earlier run in the same run directory as a run that passed.
|
|
163
|
+
Nothing is removed without --apply. Source copies, logs, lockfiles, diffs,
|
|
164
|
+
prompts and results stay. --text lists directories only; files are counted.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv: list[str] | None = None) -> int:
|
|
169
|
+
parser = argparse.ArgumentParser(
|
|
170
|
+
epilog=_CLI_EPILOG,
|
|
171
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
172
|
+
prog="okstra prune-run-artifacts",
|
|
173
|
+
description="List, and with --apply remove, run artifacts no reader needs after the run.")
|
|
174
|
+
parser.add_argument("--project-root", default="")
|
|
175
|
+
parser.add_argument("--cwd", default=".")
|
|
176
|
+
parser.add_argument("--task-group", default="")
|
|
177
|
+
parser.add_argument("--task-id", default="")
|
|
178
|
+
parser.add_argument("--apply", action="store_true", help="remove the listed paths")
|
|
179
|
+
parser.add_argument("--text", action="store_true", help="emit fixed text fields")
|
|
180
|
+
args = parser.parse_args(argv)
|
|
181
|
+
if bool(args.task_group) != bool(args.task_id):
|
|
182
|
+
parser.error("--task-group and --task-id go together")
|
|
183
|
+
|
|
184
|
+
project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
185
|
+
root = (
|
|
186
|
+
task_dir(project_root, args.task_group, args.task_id)
|
|
187
|
+
if args.task_group else project_root / ".okstra" / "tasks"
|
|
188
|
+
)
|
|
189
|
+
result = {
|
|
190
|
+
"ok": True,
|
|
191
|
+
"projectRoot": str(project_root),
|
|
192
|
+
**prune_run_artifacts(project_root, root, apply=args.apply),
|
|
193
|
+
}
|
|
194
|
+
output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
|
|
195
|
+
print(output, end="" if args.text else "\n")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
raise SystemExit(main())
|
|
@@ -36,6 +36,12 @@ from .dispatch_core import (
|
|
|
36
36
|
await_dispatches,
|
|
37
37
|
dispatch_plan,
|
|
38
38
|
)
|
|
39
|
+
from .dispatch_checkpoints import (
|
|
40
|
+
record_collect_checkpoints,
|
|
41
|
+
record_dispatch_checkpoints,
|
|
42
|
+
settled_initial_dispatches,
|
|
43
|
+
)
|
|
44
|
+
from .lead_progress import record_checkpoint, render_progress_line
|
|
39
45
|
from .ports.worker_dispatch import WorkerDispatchPort, WorkerDispatchRequest
|
|
40
46
|
from .registry.host_registry import default_host_registry
|
|
41
47
|
from .registry.provider_registry import default_provider_registry
|
|
@@ -44,6 +50,7 @@ from .json_boundary import load_owned_object
|
|
|
44
50
|
|
|
45
51
|
|
|
46
52
|
_SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
|
|
53
|
+
_SOURCE = "okstra team"
|
|
47
54
|
|
|
48
55
|
|
|
49
56
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
@@ -73,7 +80,7 @@ _CLI_EPILOG = r"""Usage:
|
|
|
73
80
|
[--poll-interval-seconds <n>] [--timeout-seconds <n>] \
|
|
74
81
|
[--heartbeat-seconds <n>] [--json]
|
|
75
82
|
okstra team reclaim --project-root <dir> --run-manifest <path> \
|
|
76
|
-
[--dry-run] [--json]
|
|
83
|
+
[--dry-run] [--gate] [--json]
|
|
77
84
|
okstra team teardown --project-root <dir> --run-manifest <path> \
|
|
78
85
|
[--dry-run] [--json]
|
|
79
86
|
|
|
@@ -84,11 +91,23 @@ descriptor owns the team lifecycle (launch_mode=team) — a worker there is a CL
|
|
|
84
91
|
wrapper subprocess holding no pane, so there is nothing to close.
|
|
85
92
|
|
|
86
93
|
reclaim round boundary: closes the panes of dispatches that have finished and
|
|
87
|
-
leaves the in-progress ones open
|
|
88
|
-
|
|
94
|
+
leaves the in-progress ones open, then on a cmux-pane run records
|
|
95
|
+
`PROGRESS: phase-batch-cleanup panes=<n>`. With --gate it is the
|
|
96
|
+
cleanup before a user gate and prints
|
|
97
|
+
`PROGRESS: phase-gate-cleanup panes=<n>` without recording it.
|
|
98
|
+
|
|
89
99
|
teardown end of run: closes every recorded pane and writes off any dispatch
|
|
90
100
|
that never reached a terminal status.
|
|
91
101
|
|
|
102
|
+
On a cmux-pane run, dispatch records `phase-3-team-create` when it writes the
|
|
103
|
+
implicit-team marker, `phase-4-dispatch` for each `initial` job and
|
|
104
|
+
`phase-6-synthesis` on the first report-writer dispatch; await records
|
|
105
|
+
`phase-5-poll` on entry and again when the counts changed, and
|
|
106
|
+
`phase-5-collect` for each `initial` dispatch it settled. The lead does not
|
|
107
|
+
call `okstra lead-progress append` for those checkpoints; each command prints
|
|
108
|
+
the `PROGRESS:` lines to emit (`progressLines` in JSON output). On any other
|
|
109
|
+
backend the team commands record nothing.
|
|
110
|
+
|
|
92
111
|
--workspace-root and --okstra-bin are owned by this command.
|
|
93
112
|
"""
|
|
94
113
|
_CLI_DESCRIPTION = "Dispatch, await, and close okstra-owned worker panes."
|
|
@@ -148,6 +167,10 @@ def _add_reclaim_parser(sub) -> None:
|
|
|
148
167
|
)
|
|
149
168
|
_add_run_args(parser)
|
|
150
169
|
parser.add_argument("--dry-run", action="store_true")
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--gate", action="store_true",
|
|
172
|
+
help="cleanup before a user gate: print phase-gate-cleanup, record nothing",
|
|
173
|
+
)
|
|
151
174
|
parser.add_argument("--json", action="store_true")
|
|
152
175
|
|
|
153
176
|
|
|
@@ -179,11 +202,31 @@ def _dispatch(args) -> int:
|
|
|
179
202
|
if args.dry_run:
|
|
180
203
|
_print_json(backend_plan.to_payload(dry_run=True))
|
|
181
204
|
return 0
|
|
182
|
-
|
|
183
|
-
|
|
205
|
+
lines: list[str] = []
|
|
206
|
+
result = dispatch_plan(
|
|
207
|
+
backend_plan, wait=False,
|
|
208
|
+
before_start=lambda prepared: lines.extend(
|
|
209
|
+
_record_dispatch(args, manifest, prepared)
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
payload = backend_plan.to_payload(dry_run=False)
|
|
213
|
+
payload["progressLines"] = lines
|
|
214
|
+
_print_json(payload)
|
|
184
215
|
return result
|
|
185
216
|
|
|
186
217
|
|
|
218
|
+
def _record_dispatch(args, manifest: Mapping[str, Any], prepared) -> list[str]:
|
|
219
|
+
# cmux-pane run 만 체크포인트를 team 명령에 맡긴다. external 리드는 CLI 워커도
|
|
220
|
+
# `team await` 로 기다리지만 체크포인트는 직접 append 하므로, 여기서 쓰면 행이 둘이 된다.
|
|
221
|
+
if not _is_cmux_run(manifest):
|
|
222
|
+
return []
|
|
223
|
+
return record_dispatch_checkpoints(
|
|
224
|
+
prepared.project_root, args.run_manifest,
|
|
225
|
+
_load_json(prepared.team_state_path, "team-state"), prepared.jobs,
|
|
226
|
+
source=_SOURCE,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
187
230
|
def team_dispatch_port(manifest: Mapping[str, Any]) -> WorkerDispatchPort:
|
|
188
231
|
"""이 run 의 워커를 띄울 포트. cmux run 이면 pane, 아니면 리드 호스트의 포트.
|
|
189
232
|
|
|
@@ -209,19 +252,59 @@ def _await(args) -> int:
|
|
|
209
252
|
_validate_team_manifest(manifest)
|
|
210
253
|
_observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
|
|
211
254
|
plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
|
|
255
|
+
should_record = _is_cmux_run(manifest)
|
|
256
|
+
entry_state = _load_json(plan.team_state_path, "team-state")
|
|
257
|
+
entry = _dispatch_counts(entry_state)
|
|
258
|
+
lines = [_record_poll(args, entry)] if should_record else []
|
|
212
259
|
code = await_dispatches(
|
|
213
260
|
plan,
|
|
214
261
|
poll_interval_seconds=args.poll_interval_seconds,
|
|
215
262
|
timeout_seconds=args.timeout_seconds,
|
|
216
263
|
heartbeat_seconds=0 if args.json else args.heartbeat_seconds,
|
|
217
264
|
)
|
|
265
|
+
if should_record:
|
|
266
|
+
exit_state = _load_json(plan.team_state_path, "team-state")
|
|
267
|
+
lines.extend(record_collect_checkpoints(
|
|
268
|
+
plan.project_root, args.run_manifest,
|
|
269
|
+
settled_initial_dispatches(entry_state),
|
|
270
|
+
settled_initial_dispatches(exit_state),
|
|
271
|
+
source=_SOURCE,
|
|
272
|
+
))
|
|
273
|
+
exit_counts = _dispatch_counts(exit_state)
|
|
274
|
+
if exit_counts != entry:
|
|
275
|
+
lines.append(_record_poll(args, exit_counts))
|
|
276
|
+
lines = [line for line in lines if line]
|
|
218
277
|
if args.json:
|
|
219
|
-
|
|
278
|
+
payload = _await_payload(plan, code == 0)
|
|
279
|
+
payload["progressLines"] = lines
|
|
280
|
+
_print_json(payload)
|
|
220
281
|
else:
|
|
282
|
+
for line in lines:
|
|
283
|
+
print(line)
|
|
221
284
|
print("ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
|
|
222
285
|
return code
|
|
223
286
|
|
|
224
287
|
|
|
288
|
+
def _dispatch_counts(team_state: Mapping[str, Any]) -> tuple[int, int]:
|
|
289
|
+
"""이 run 의 dispatch 행 기준 (pending, done) — `phase-5-poll` 의 두 값."""
|
|
290
|
+
statuses = [
|
|
291
|
+
record.get("status")
|
|
292
|
+
for record in team_state.get("workerDispatches", [])
|
|
293
|
+
if isinstance(record, dict)
|
|
294
|
+
]
|
|
295
|
+
done = sum(1 for status in statuses if status in TERMINAL_WORKER_STATUSES)
|
|
296
|
+
return len(statuses) - done, done
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _record_poll(args, counts: tuple[int, int]) -> str | None:
|
|
300
|
+
pending, done = counts
|
|
301
|
+
return record_checkpoint(
|
|
302
|
+
Path(args.project_root), args.run_manifest, "phase-5-poll",
|
|
303
|
+
source=_SOURCE,
|
|
304
|
+
fields=(("pending", str(pending)), ("done", str(done))),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
225
308
|
def _teardown(args) -> int:
|
|
226
309
|
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
227
310
|
_validate_team_manifest(manifest)
|
|
@@ -259,7 +342,16 @@ def _reclaim(args) -> int:
|
|
|
259
342
|
_emit_panes(args.json, panes)
|
|
260
343
|
return 0
|
|
261
344
|
_close_panes(manifest, panes)
|
|
262
|
-
|
|
345
|
+
count = (("panes", str(len(panes))),)
|
|
346
|
+
line: str | None = None
|
|
347
|
+
if args.gate:
|
|
348
|
+
line = render_progress_line("phase-gate-cleanup", count)
|
|
349
|
+
elif _is_cmux_run(manifest):
|
|
350
|
+
line = record_checkpoint(
|
|
351
|
+
Path(args.project_root), args.run_manifest, "phase-batch-cleanup",
|
|
352
|
+
source=_SOURCE, fields=count,
|
|
353
|
+
)
|
|
354
|
+
_emit_panes(args.json, panes, line)
|
|
263
355
|
return 0
|
|
264
356
|
|
|
265
357
|
|
|
@@ -434,12 +526,19 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
|
434
526
|
mutate_team_state(team_state_path, mark)
|
|
435
527
|
|
|
436
528
|
|
|
437
|
-
def _emit_panes(
|
|
529
|
+
def _emit_panes(
|
|
530
|
+
as_json: bool, panes: list[dict[str, str]], progress_line: str | None = None
|
|
531
|
+
) -> None:
|
|
438
532
|
if as_json:
|
|
439
|
-
|
|
533
|
+
payload: dict[str, Any] = {"panes": panes}
|
|
534
|
+
if progress_line:
|
|
535
|
+
payload["progressLines"] = [progress_line]
|
|
536
|
+
_print_json(payload)
|
|
440
537
|
return
|
|
441
538
|
for pane in panes:
|
|
442
539
|
print(f"{pane['paneId']}\t{pane['kind']}")
|
|
540
|
+
if progress_line:
|
|
541
|
+
print(progress_line)
|
|
443
542
|
|
|
444
543
|
|
|
445
544
|
def _manifest_backend(manifest: Mapping[str, Any]) -> str:
|
|
@@ -206,12 +206,20 @@ def _suggest_sibling_task_ids(state: WizardState) -> str:
|
|
|
206
206
|
return ",".join(siblings)
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
def _sibling_count_note(state: WizardState, suggestion: str) -> str:
|
|
210
|
+
"""라벨은 CSV 앞부분만 보여 주므로, 선택하면 몇 개가 들어가는지 따로 적는다."""
|
|
211
|
+
count = len([tid for tid in suggestion.split(",") if tid])
|
|
212
|
+
note = _p(state.workspace_root, "related_tasks_pick")["labels"]["siblings_count"]
|
|
213
|
+
return note.format(count=count)
|
|
214
|
+
|
|
215
|
+
|
|
209
216
|
_RELATED_TASKS_PICK_SPEC = _OptionalCachedPickSpec(
|
|
210
217
|
step=S_RELATED_TASKS_PICK, prompt_key="related_tasks_pick",
|
|
211
218
|
recommend_token=_SIBLINGS_TOKEN, label_key="siblings", echo_suffix_key="siblings",
|
|
212
219
|
suggest=_suggest_sibling_task_ids, snippet_style="prefix",
|
|
213
220
|
cache_attr="last_siblings_cached", target_attr="related_tasks_raw",
|
|
214
221
|
pending_attr="related_tasks_pending_text",
|
|
222
|
+
recommend_note=_sibling_count_note,
|
|
215
223
|
)
|
|
216
224
|
|
|
217
225
|
|