okstra 0.172.0 → 0.174.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 +8 -6
- package/docs/architecture/storage-model.md +24 -3
- package/docs/architecture.md +21 -35
- package/docs/cli.md +39 -7
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +33 -25
- package/docs/task-process/README.md +6 -4
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +24 -16
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +2 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +23 -6
- package/runtime/prompts/lead/report-writer.md +33 -11
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +4 -4
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +61 -46
- package/runtime/prompts/profiles/implementation.md +4 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/conformance.py +68 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
- package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
- package/runtime/python/okstra_ctl/doctor.py +2 -2
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +1 -0
- package/runtime/python/okstra_ctl/render_final_report.py +16 -19
- package/runtime/python/okstra_ctl/report_contract.py +45 -14
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +393 -4
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
- package/runtime/python/okstra_ctl/session.py +69 -12
- package/runtime/python/okstra_ctl/team.py +51 -25
- package/runtime/python/okstra_ctl/tmux.py +19 -149
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/worker_request.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/python/okstra_ctl/worktree.py +69 -3
- package/runtime/python/okstra_token_usage/cli.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +66 -6
- package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
- package/runtime/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/final-report-v2.template.md +4 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +21 -1
- package/runtime/templates/reports/html/i18n/ko.json +21 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
- package/runtime/templates/reports/i18n/en.json +13 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +111 -4
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +25 -4
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +169 -4
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +149 -498
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/execute/team.mjs +3 -3
- package/src/commands/execute/worktree-status.mjs +109 -0
- package/src/commands/lifecycle/install.mjs +0 -2
- package/src/commands/report/finalize.mjs +13 -6
- package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
- package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
- package/runtime/templates/reports/final-report.template.md +0 -1258
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
"""Claude session helpers.
|
|
2
2
|
|
|
3
|
-
bash session.sh 의 python 구현. claude
|
|
4
|
-
파일 작성.
|
|
3
|
+
bash session.sh 의 python 구현. lead claude 세션 관측, resume command
|
|
4
|
+
파일 작성. 세션 id 발급기는 워커 dispatch 도 쓰므로
|
|
5
|
+
`dispatch_state.generate_claude_session_id` 에 있다.
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
|
-
import uuid
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
from typing import Collection
|
|
12
13
|
|
|
13
|
-
from .dispatch_state import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
from .dispatch_state import (
|
|
15
|
+
DispatchError,
|
|
16
|
+
load_json_object,
|
|
17
|
+
mutate_team_state,
|
|
18
|
+
worker_session_ids,
|
|
19
|
+
)
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _claude_projects_dir_for(cwd: Path) -> Path:
|
|
@@ -76,7 +77,11 @@ def _session_mentions(jsonl_path: Path, needle: str) -> bool:
|
|
|
76
77
|
return False
|
|
77
78
|
|
|
78
79
|
|
|
79
|
-
def resolve_lead_session_id_for_run(
|
|
80
|
+
def resolve_lead_session_id_for_run(
|
|
81
|
+
project_root: Path,
|
|
82
|
+
run_dir: Path,
|
|
83
|
+
exclude_session_ids: Collection[str] = (),
|
|
84
|
+
) -> str:
|
|
80
85
|
"""`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
|
|
81
86
|
|
|
82
87
|
프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
|
|
@@ -84,6 +89,12 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
|
|
|
84
89
|
(dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
|
|
85
90
|
부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
|
|
86
91
|
자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
|
|
92
|
+
|
|
93
|
+
`exclude_session_ids` 는 같은 run **안**의 오염을 막는다. cmux 워커는
|
|
94
|
+
`agentName` 을 안 남겨 아래 필터를 통과하고, 워커 세션도 자기 run 의 산출물
|
|
95
|
+
경로를 언급하므로 needle 에도 걸린다. 워커가 리드보다 늦게 끝나면 mtime
|
|
96
|
+
정렬에서 먼저 잡혀 워커 세션이 리드로 기록된다 — dispatch 가 발급한 id 를
|
|
97
|
+
호출자가 넘겨 그 세션들을 후보에서 뺀다.
|
|
87
98
|
"""
|
|
88
99
|
proj_dir = _claude_projects_dir_for(project_root)
|
|
89
100
|
try:
|
|
@@ -91,7 +102,10 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
|
|
|
91
102
|
except OSError:
|
|
92
103
|
return ""
|
|
93
104
|
needle = str(run_dir)
|
|
105
|
+
excluded = set(exclude_session_ids)
|
|
94
106
|
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
|
107
|
+
if path.stem in excluded:
|
|
108
|
+
continue
|
|
95
109
|
if _session_has_agent_name(path):
|
|
96
110
|
continue
|
|
97
111
|
if _session_mentions(path, needle):
|
|
@@ -103,17 +117,36 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
|
|
|
103
117
|
"""이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
|
|
104
118
|
다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
|
|
105
119
|
여기에 모은다.
|
|
120
|
+
|
|
121
|
+
후보에서 뺄 워커 세션 id 는 append 대상인 team-state 자체가 들고 있다 —
|
|
122
|
+
dispatch 가 `workerDispatches[].sessionId` 에 적어 둔 값이라, 관측 시점에
|
|
123
|
+
이 run 이 연 워커 세션의 목록은 그 파일 하나로 완결된다.
|
|
106
124
|
"""
|
|
107
|
-
|
|
125
|
+
try:
|
|
126
|
+
team_state = load_json_object(team_state_path, "team-state")
|
|
127
|
+
except (DispatchError, OSError):
|
|
128
|
+
team_state = {}
|
|
129
|
+
sid = resolve_lead_session_id_for_run(
|
|
130
|
+
project_root,
|
|
131
|
+
team_state_path.parent.parent,
|
|
132
|
+
exclude_session_ids=worker_session_ids(team_state),
|
|
133
|
+
)
|
|
108
134
|
if not sid:
|
|
109
135
|
return ""
|
|
110
136
|
def add_observed_session(state: dict) -> bool:
|
|
111
137
|
lead_ids = state.setdefault("leadSessionIds", [])
|
|
138
|
+
observed = state.setdefault("observedTeamNames", [])
|
|
139
|
+
# 두 키가 list 가 아니면 `sid in None` 이 TypeError 를, `str.append` 가
|
|
140
|
+
# AttributeError 를 낸다. 그 예외는 아래 `(DispatchError, OSError)` 도
|
|
141
|
+
# `observe_lead_session` 의 `OSError` 도 통과해 dispatch 를 죽인다 —
|
|
142
|
+
# 관측이 호출자를 깨뜨리지 않는다는 계약을 docstring 이 아니라 여기서
|
|
143
|
+
# 지킨다. 관측을 건너뛰면 이 run 은 관측 이전과 같은 상태로 남는다.
|
|
144
|
+
if not isinstance(lead_ids, list) or not isinstance(observed, list):
|
|
145
|
+
return False
|
|
112
146
|
if sid in lead_ids:
|
|
113
147
|
return False
|
|
114
148
|
lead_ids.append(sid)
|
|
115
149
|
team = f"session-{sid[:8]}"
|
|
116
|
-
observed = state.setdefault("observedTeamNames", [])
|
|
117
150
|
if team not in observed:
|
|
118
151
|
observed.append(team)
|
|
119
152
|
return True
|
|
@@ -125,6 +158,30 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
|
|
|
125
158
|
return sid if changed else ""
|
|
126
159
|
|
|
127
160
|
|
|
161
|
+
def observe_lead_session(project_root: Path, team_state_path: Path) -> None:
|
|
162
|
+
"""단계 경계에서 부르는 부수효과 — 관측 실패를 호출자에게 내지 않는다.
|
|
163
|
+
|
|
164
|
+
리드 세션은 resume·compaction 으로 세대가 갈리므로 prepare 때 적힌 단 하나의
|
|
165
|
+
id 는 런 구간에 레코드가 없는 죽은 세션을 가리킬 수 있다. 리드가 반드시
|
|
166
|
+
지나가는 지점마다 관측해 `leadSessionIds` 에 세대를 모은다 — 리드의 협조를
|
|
167
|
+
요구하지 않는 것이 요점이다.
|
|
168
|
+
|
|
169
|
+
잡는 범위가 OSError 뿐인 이유: 이 아래 호출들이 각자 자기 실패를 흡수하고,
|
|
170
|
+
그러고도 밖으로 나오는 것이 후보 정렬의 `p.stat()` 이다 — 스캔 도중 세션
|
|
171
|
+
jsonl 이 사라지면 여기서 OSError 가 난다. team-state 읽기·쓰기
|
|
172
|
+
(`load_json_object` / `mutate_team_state`)와 세션 본문 스캔이 그 흡수
|
|
173
|
+
지점이고, 비정상 team-state 가 낼 TypeError/AttributeError 는 잡는 대신 아예
|
|
174
|
+
내지 않는 쪽으로 막는다 — append 대상 키(`leadSessionIds` /
|
|
175
|
+
`observedTeamNames`)는 `add_observed_session` 이, 제외 집합의 출처
|
|
176
|
+
(`workerDispatches`)는 `worker_session_ids` 가 각각 비-list 를 걸러낸다.
|
|
177
|
+
더 넓게 잡으면 team-state 를 깨뜨리는 진짜 버그까지 조용히 삼킨다.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
record_observed_lead_session(project_root, team_state_path)
|
|
181
|
+
except OSError:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
|
|
128
185
|
def write_claude_resume_command_file(
|
|
129
186
|
*,
|
|
130
187
|
resume_command_path: Path,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Neutral okstra team CLI for pane-backed worker dispatch.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
the
|
|
5
|
-
|
|
6
|
-
uses is read from its run manifest.
|
|
3
|
+
Under cmux this is every lead's door onto cmux surfaces, because okstra owns the
|
|
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.
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
@@ -15,14 +15,13 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Any, Mapping, Sequence
|
|
16
16
|
|
|
17
17
|
from . import cmux
|
|
18
|
-
from . import tmux
|
|
19
18
|
from .adapters.dispatch import provider_worker_wrappers
|
|
20
19
|
from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
|
|
21
20
|
from .application.dispatch_assignments import dispatch_assignments
|
|
22
21
|
from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON, mutate_team_state
|
|
23
22
|
from .dispatch_core import (
|
|
23
|
+
BACKEND_CLI_WRAPPER,
|
|
24
24
|
BACKEND_CMUX_PANE,
|
|
25
|
-
BACKEND_TMUX_PANE,
|
|
26
25
|
DispatchError,
|
|
27
26
|
DispatchPlan,
|
|
28
27
|
await_dispatches,
|
|
@@ -31,6 +30,7 @@ from .dispatch_core import (
|
|
|
31
30
|
from .ports.worker_dispatch import WorkerDispatchRequest
|
|
32
31
|
from .registry.host_registry import default_host_registry
|
|
33
32
|
from .registry.provider_registry import default_provider_registry
|
|
33
|
+
from .session import observe_lead_session
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
_SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
|
|
@@ -100,6 +100,7 @@ def _dispatch(args) -> int:
|
|
|
100
100
|
if args.jobs_file and args.workers:
|
|
101
101
|
raise DispatchError("--jobs-file and --workers cannot be combined")
|
|
102
102
|
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
103
|
+
_observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
|
|
103
104
|
terminal_backend = _manifest_backend(manifest)
|
|
104
105
|
lead_runtime = _require_string(manifest, "leadRuntime")
|
|
105
106
|
fallback_port = default_host_registry().resolve(
|
|
@@ -137,6 +138,7 @@ def _dispatch(args) -> int:
|
|
|
137
138
|
def _await(args) -> int:
|
|
138
139
|
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
139
140
|
_validate_team_manifest(manifest)
|
|
141
|
+
_observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
|
|
140
142
|
plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
|
|
141
143
|
code = await_dispatches(
|
|
142
144
|
plan,
|
|
@@ -157,21 +159,46 @@ def _teardown(args) -> int:
|
|
|
157
159
|
project_root = Path(args.project_root).resolve()
|
|
158
160
|
team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
|
|
159
161
|
team_state = _load_json(team_state_path, "team-state")
|
|
160
|
-
|
|
161
|
-
panes = _reclaimable_panes(manifest, team_state, run_dir)
|
|
162
|
+
panes = _reclaimable_panes(manifest, team_state)
|
|
162
163
|
if args.dry_run:
|
|
163
164
|
_emit_teardown(args.json, panes)
|
|
164
165
|
return 0
|
|
165
|
-
reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
|
|
166
|
-
for pane in panes:
|
|
167
|
-
reclaim(pane["paneId"])
|
|
168
166
|
if _is_cmux_run(manifest):
|
|
167
|
+
for pane in panes:
|
|
168
|
+
cmux.close_surface(pane["paneId"])
|
|
169
169
|
_restore_lead_width()
|
|
170
170
|
_mark_teardown_errors(team_state_path)
|
|
171
171
|
_emit_teardown(args.json, panes)
|
|
172
172
|
return 0
|
|
173
173
|
|
|
174
174
|
|
|
175
|
+
def _observe_lead_session_from_manifest(
|
|
176
|
+
project_root: Path, manifest: Mapping[str, Any]
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Collect this run's lead session generations at a phase boundary.
|
|
179
|
+
|
|
180
|
+
Resume and compaction split a lead across several session files, so the one
|
|
181
|
+
id written at prepare time can name a session holding no record of the run.
|
|
182
|
+
Dispatch and await are the boundaries a lead cannot route around, which is
|
|
183
|
+
why the observation hangs off them instead of a call the lead has to make.
|
|
184
|
+
|
|
185
|
+
`--dry-run` observes too, because the hook sits ahead of the dry-run return
|
|
186
|
+
on purpose: moving it behind would lose the generation a lead was on when a
|
|
187
|
+
dispatch failed. What gets appended is not a rehearsal either — that lead
|
|
188
|
+
really did cross this boundary, whatever the dispatch went on to do.
|
|
189
|
+
|
|
190
|
+
A manifest missing `teamStatePath` leaves nothing to observe into; that is
|
|
191
|
+
the command's own failure to report, not this side effect's.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
team_state_path = _resolve_project_path(
|
|
195
|
+
project_root, _require_string(manifest, "teamStatePath")
|
|
196
|
+
)
|
|
197
|
+
except DispatchError:
|
|
198
|
+
return
|
|
199
|
+
observe_lead_session(project_root, team_state_path)
|
|
200
|
+
|
|
201
|
+
|
|
175
202
|
def _plan_for_existing(
|
|
176
203
|
project_root: Path, workspace_root: Path, run_manifest_path: Path, manifest: Mapping[str, Any]
|
|
177
204
|
) -> DispatchPlan:
|
|
@@ -202,15 +229,16 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
|
202
229
|
|
|
203
230
|
|
|
204
231
|
def _reclaimable_panes(
|
|
205
|
-
manifest: Mapping[str, Any], team_state: Mapping[str, Any]
|
|
232
|
+
manifest: Mapping[str, Any], team_state: Mapping[str, Any]
|
|
206
233
|
) -> list[dict[str, str]]:
|
|
207
234
|
"""Everything this run owns and may close.
|
|
208
235
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
236
|
+
The recorded ids are the only candidates. There is no per-pane tag API to
|
|
237
|
+
sweep with, and scanning by title would be worse than nothing: cmux labels
|
|
238
|
+
its own agent surfaces with the same glyph the harness uses for a teammate
|
|
239
|
+
pane, so a sweep could close the lead. Only surfaces okstra created are
|
|
240
|
+
recorded, so only those can be closed. A cli-wrapper run records no surface
|
|
241
|
+
at all, which is why it reclaims nothing.
|
|
214
242
|
"""
|
|
215
243
|
seen: set[str] = set()
|
|
216
244
|
panes: list[dict[str, str]] = []
|
|
@@ -219,17 +247,15 @@ def _reclaimable_panes(
|
|
|
219
247
|
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
220
248
|
if _is_cmux_run(manifest):
|
|
221
249
|
return _still_open_surfaces(panes)
|
|
222
|
-
lead_pane = tmux.resolve_caller_pane()
|
|
223
|
-
for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
|
|
224
|
-
_append_pane(panes, seen, pane.pane_id, pane.kind)
|
|
225
250
|
return panes
|
|
226
251
|
|
|
227
252
|
|
|
228
253
|
def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
229
254
|
"""The recorded surfaces cmux still shows.
|
|
230
255
|
|
|
231
|
-
`workerDispatches`
|
|
232
|
-
|
|
256
|
+
Nothing prunes `workerDispatches` — a repeat of one `dispatchId` replaces
|
|
257
|
+
that row in place, and no row is ever dropped — so a surface closed at an
|
|
258
|
+
earlier round boundary stays recorded for the rest of the run. Taking
|
|
233
259
|
the ledger as the residual set makes the run-end cleanup gate offer to close
|
|
234
260
|
panes that left the screen rounds ago, on a workspace holding none.
|
|
235
261
|
|
|
@@ -294,10 +320,10 @@ def _manifest_backend(manifest: Mapping[str, Any]) -> str:
|
|
|
294
320
|
"""The backend prepare recorded for this run.
|
|
295
321
|
|
|
296
322
|
Deliberately not a flag on this command: the manifest already answers it,
|
|
297
|
-
and a flag would be a second answer free to disagree. A manifest
|
|
298
|
-
|
|
323
|
+
and a flag would be a second answer free to disagree. A manifest with no
|
|
324
|
+
recorded backend reads as cli-wrapper, the pane-less path.
|
|
299
325
|
"""
|
|
300
|
-
return str(manifest.get("terminalBackend") or "") or
|
|
326
|
+
return str(manifest.get("terminalBackend") or "") or BACKEND_CLI_WRAPPER
|
|
301
327
|
|
|
302
328
|
|
|
303
329
|
def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
|
|
@@ -1,39 +1,32 @@
|
|
|
1
|
-
"""tmux command helpers for okstra-owned
|
|
1
|
+
"""tmux command helpers for okstra-owned container and rerun sessions.
|
|
2
|
+
|
|
3
|
+
Worker dispatch does not come through here. Workers land in a cmux surface or,
|
|
4
|
+
outside cmux, in a cli-wrapper subprocess — neither owns a tmux pane. What is
|
|
5
|
+
left is the two places that use tmux purely as a way to hold a long-lived
|
|
6
|
+
background process: the container watcher/tail panes and `cmd-rerun.sh`'s
|
|
7
|
+
detached spawn.
|
|
8
|
+
"""
|
|
2
9
|
from __future__ import annotations
|
|
3
10
|
|
|
4
|
-
import os
|
|
5
11
|
import shlex
|
|
6
12
|
import shutil
|
|
7
13
|
import subprocess
|
|
8
|
-
from dataclasses import dataclass
|
|
9
|
-
from pathlib import Path
|
|
10
14
|
from typing import Optional, Sequence
|
|
11
15
|
|
|
12
16
|
|
|
13
|
-
# container watcher/tail pane 전용 태그.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
+
# container watcher/tail pane 전용 태그. 이 태그가 붙은 pane 은 세션 종료 후에도
|
|
18
|
+
# 생존한다 — watcher/tail 의 "세션 후 생존" 불변식이다. 예전에는 SessionEnd 의
|
|
19
|
+
# `okstra-trace-cleanup.sh --reap` 이 다른 태그만 스캔한다는 사실이 그 생존을
|
|
20
|
+
# 지탱했지만, 지금은 그 모드와 훅 자체가 없어 pane 을 세션 경계에서 회수하는
|
|
21
|
+
# 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
|
|
17
22
|
CONTAINER_TAG_OPTION = "@okstra_container_run"
|
|
18
23
|
|
|
19
24
|
|
|
20
|
-
@dataclass(frozen=True)
|
|
21
|
-
class TmuxPane:
|
|
22
|
-
pane_id: str
|
|
23
|
-
title: str
|
|
24
|
-
run_dir: str
|
|
25
|
-
kind: str
|
|
26
|
-
|
|
27
|
-
|
|
28
25
|
def _shell_quote(s: str) -> str:
|
|
29
26
|
"""POSIX shell 안전 인용. shlex.quote 가 모든 메타문자를 처리한다."""
|
|
30
27
|
return shlex.quote(s)
|
|
31
28
|
|
|
32
29
|
|
|
33
|
-
def _canonical_run_dir(run_dir: Path) -> str:
|
|
34
|
-
return str(run_dir.resolve())
|
|
35
|
-
|
|
36
|
-
|
|
37
30
|
def build_tmux_command(*, session_name: str, cwd: str, run_seq: int,
|
|
38
31
|
argv: list, okstra_script: str,
|
|
39
32
|
extra_env: Optional[dict] = None) -> list:
|
|
@@ -69,79 +62,6 @@ def run_tmux(
|
|
|
69
62
|
)
|
|
70
63
|
|
|
71
64
|
|
|
72
|
-
def resolve_caller_pane(start_pid: int | None = None) -> str:
|
|
73
|
-
try:
|
|
74
|
-
panes = run_tmux(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"])
|
|
75
|
-
except (OSError, subprocess.SubprocessError):
|
|
76
|
-
return ""
|
|
77
|
-
if panes.returncode != 0 or not panes.stdout.strip():
|
|
78
|
-
return ""
|
|
79
|
-
pane_by_pid = _pane_ids_by_pid(panes.stdout)
|
|
80
|
-
pid = str(start_pid or os.getpid())
|
|
81
|
-
for _ in range(16):
|
|
82
|
-
if not pid or pid == "0":
|
|
83
|
-
return ""
|
|
84
|
-
if pid in pane_by_pid:
|
|
85
|
-
return pane_by_pid[pid]
|
|
86
|
-
pid = _parent_pid(pid)
|
|
87
|
-
return ""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _pane_ids_by_pid(output: str) -> dict[str, str]:
|
|
91
|
-
result: dict[str, str] = {}
|
|
92
|
-
for line in output.splitlines():
|
|
93
|
-
parts = line.split(maxsplit=1)
|
|
94
|
-
if len(parts) == 2:
|
|
95
|
-
result[parts[0]] = parts[1]
|
|
96
|
-
return result
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _parent_pid(pid: str) -> str:
|
|
100
|
-
try:
|
|
101
|
-
result = subprocess.run(
|
|
102
|
-
["ps", "-o", "ppid=", "-p", pid],
|
|
103
|
-
capture_output=True,
|
|
104
|
-
text=True,
|
|
105
|
-
timeout=3,
|
|
106
|
-
check=False,
|
|
107
|
-
)
|
|
108
|
-
except (OSError, subprocess.SubprocessError):
|
|
109
|
-
return ""
|
|
110
|
-
if result.returncode != 0:
|
|
111
|
-
return ""
|
|
112
|
-
return result.stdout.strip()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def split_worker_pane(
|
|
116
|
-
*,
|
|
117
|
-
target_pane: str,
|
|
118
|
-
cwd: Path,
|
|
119
|
-
command: Sequence[str],
|
|
120
|
-
title: str,
|
|
121
|
-
run_dir: Path,
|
|
122
|
-
) -> str:
|
|
123
|
-
result = run_tmux(
|
|
124
|
-
[
|
|
125
|
-
"split-window",
|
|
126
|
-
"-h",
|
|
127
|
-
"-P",
|
|
128
|
-
"-F",
|
|
129
|
-
"#{pane_id}",
|
|
130
|
-
"-c",
|
|
131
|
-
str(cwd),
|
|
132
|
-
"-t",
|
|
133
|
-
target_pane,
|
|
134
|
-
shlex.join(command),
|
|
135
|
-
]
|
|
136
|
-
)
|
|
137
|
-
if result.returncode != 0:
|
|
138
|
-
raise RuntimeError(result.stderr.strip() or "tmux split-window failed")
|
|
139
|
-
pane_id = result.stdout.strip()
|
|
140
|
-
set_pane_title(pane_id, title)
|
|
141
|
-
tag_pane(pane_id, run_dir)
|
|
142
|
-
return pane_id
|
|
143
|
-
|
|
144
|
-
|
|
145
65
|
def new_detached_session(
|
|
146
66
|
session_name: str, cwd: str, first_cmd: str | None = None
|
|
147
67
|
) -> str:
|
|
@@ -175,9 +95,9 @@ def split_container_pane(
|
|
|
175
95
|
) -> Optional[str]:
|
|
176
96
|
"""container watcher/tail pane 을 split 하고 container 전용 태그만 부착한다.
|
|
177
97
|
|
|
178
|
-
reap 가 스캔하는
|
|
179
|
-
|
|
180
|
-
|
|
98
|
+
reap 가 스캔하는 trace 태그는 절대 부착하지 않는다 — CONTAINER_TAG_OPTION 으로
|
|
99
|
+
직접 set-option 한다. session_pane 가 빈값/None(tmux 미사용 경로)이면 raise
|
|
100
|
+
없이 None 반환(degrade).
|
|
181
101
|
"""
|
|
182
102
|
if not session_pane:
|
|
183
103
|
return None
|
|
@@ -196,9 +116,9 @@ def split_container_pane(
|
|
|
196
116
|
def tag_container_pane(pane_id: str, scope_value: str) -> None:
|
|
197
117
|
"""container 전용 태그(CONTAINER_TAG_OPTION)만 부착한다.
|
|
198
118
|
|
|
199
|
-
reap 가 스캔하는
|
|
200
|
-
|
|
201
|
-
|
|
119
|
+
reap 가 스캔하는 trace 태그는 절대 거치지 않는다 — 이 태그가 붙은 pane 은
|
|
120
|
+
세션 종료 후에도 생존하고 `down`/`stop-watcher` 의 스코프 reap 로만
|
|
121
|
+
회수된다. holder pane 도 이 태그로 묶어 회수 대상에 포함한다."""
|
|
202
122
|
run_tmux(["set-option", "-p", "-t", pane_id, CONTAINER_TAG_OPTION, scope_value])
|
|
203
123
|
|
|
204
124
|
|
|
@@ -208,56 +128,6 @@ def set_pane_title(pane_id: str, title: str) -> None:
|
|
|
208
128
|
raise RuntimeError(result.stderr.strip() or "tmux select-pane failed")
|
|
209
129
|
|
|
210
130
|
|
|
211
|
-
def tag_pane(
|
|
212
|
-
pane_id: str, run_dir: Path, *, option: str = "@okstra_worker_run"
|
|
213
|
-
) -> None:
|
|
214
|
-
result = run_tmux(
|
|
215
|
-
["set-option", "-p", "-t", pane_id, option, _canonical_run_dir(run_dir)]
|
|
216
|
-
)
|
|
217
|
-
if result.returncode != 0:
|
|
218
|
-
raise RuntimeError(result.stderr.strip() or "tmux set-option failed")
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def capture_pane(pane_id: str, *, last_lines: int = 200) -> str:
|
|
222
|
-
result = run_tmux(
|
|
223
|
-
["capture-pane", "-p", "-t", pane_id, "-S", f"-{last_lines}"]
|
|
224
|
-
)
|
|
225
|
-
if result.returncode != 0:
|
|
226
|
-
return ""
|
|
227
|
-
return result.stdout
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def list_run_panes(run_dir: Path, *, lead_pane: str = "") -> list[TmuxPane]:
|
|
231
|
-
canonical = _canonical_run_dir(run_dir)
|
|
232
|
-
try:
|
|
233
|
-
result = run_tmux(
|
|
234
|
-
[
|
|
235
|
-
"list-panes",
|
|
236
|
-
"-a",
|
|
237
|
-
"-F",
|
|
238
|
-
"#{pane_id}\t#{pane_title}\t#{@okstra_worker_run}\t#{@okstra_trace_run}",
|
|
239
|
-
]
|
|
240
|
-
)
|
|
241
|
-
except (OSError, subprocess.SubprocessError):
|
|
242
|
-
return []
|
|
243
|
-
if result.returncode != 0:
|
|
244
|
-
return []
|
|
245
|
-
return _parse_run_panes(result.stdout, canonical, lead_pane)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def _parse_run_panes(output: str, canonical: str, lead_pane: str) -> list[TmuxPane]:
|
|
249
|
-
panes: list[TmuxPane] = []
|
|
250
|
-
for line in output.splitlines():
|
|
251
|
-
pane_id, title, worker_run, trace_run = (line.split("\t") + [""] * 4)[:4]
|
|
252
|
-
if not pane_id or pane_id == lead_pane:
|
|
253
|
-
continue
|
|
254
|
-
if worker_run == canonical:
|
|
255
|
-
panes.append(TmuxPane(pane_id, title, worker_run, "worker"))
|
|
256
|
-
elif trace_run == canonical:
|
|
257
|
-
panes.append(TmuxPane(pane_id, title, trace_run, "trace"))
|
|
258
|
-
return panes
|
|
259
|
-
|
|
260
|
-
|
|
261
131
|
def kill_pane(pane_id: str) -> None:
|
|
262
132
|
try:
|
|
263
133
|
run_tmux(["kill-pane", "-t", pane_id])
|
|
@@ -20,6 +20,7 @@ from typing import Optional
|
|
|
20
20
|
|
|
21
21
|
from okstra_ctl.report_views import (
|
|
22
22
|
PLAN_DECISION_APPROVED,
|
|
23
|
+
normalize_direction_selection_identity,
|
|
23
24
|
serialize_user_response, UserResponseEntry, UserPlanDecision, infer_run_meta,
|
|
24
25
|
parse_expected_form_options,
|
|
25
26
|
)
|
|
@@ -36,6 +37,9 @@ from okstra_ctl.clarification_items import (
|
|
|
36
37
|
_PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
|
|
37
38
|
_NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
|
|
38
39
|
_ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
|
|
40
|
+
_DIRECTION_SELECTION_HEADING_RE = re.compile(
|
|
41
|
+
r"^## DIRECTION SELECTION\s*$", re.MULTILINE
|
|
42
|
+
)
|
|
39
43
|
_ANALYSIS_SIDECAR_HEADING_RE = re.compile(
|
|
40
44
|
r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
|
|
41
45
|
)
|
|
@@ -79,6 +83,20 @@ class AnalysisReviewRecord:
|
|
|
79
83
|
seq: str
|
|
80
84
|
|
|
81
85
|
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class DirectionSelectionRecord:
|
|
88
|
+
status: str
|
|
89
|
+
option_id: str
|
|
90
|
+
option_name: str
|
|
91
|
+
confirmed: bool
|
|
92
|
+
selection_note: str
|
|
93
|
+
constraints: str
|
|
94
|
+
source_report: str
|
|
95
|
+
source_data: str
|
|
96
|
+
source_data_sha256: str
|
|
97
|
+
seq: str
|
|
98
|
+
|
|
99
|
+
|
|
82
100
|
_ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
83
101
|
"accepted",
|
|
84
102
|
"revision-requested",
|
|
@@ -405,6 +423,19 @@ def _field(block: str, key: str) -> Optional[str]:
|
|
|
405
423
|
return m.group(1) if m else None
|
|
406
424
|
|
|
407
425
|
|
|
426
|
+
def _direction_identity_fields(block: str) -> tuple[str, str]:
|
|
427
|
+
match = re.search(
|
|
428
|
+
r"^- Option-ID:[ \t]*(?P<option_id>[^\r\n]*)\r?\n"
|
|
429
|
+
r"- Option-Name:[ \t]*(?P<option_name>[^\r\n]*)\r?\n"
|
|
430
|
+
r"- Confirmed:",
|
|
431
|
+
block,
|
|
432
|
+
re.MULTILINE,
|
|
433
|
+
)
|
|
434
|
+
if match is None:
|
|
435
|
+
return "", ""
|
|
436
|
+
return match.group("option_id"), match.group("option_name")
|
|
437
|
+
|
|
438
|
+
|
|
408
439
|
def _value(block: str) -> str:
|
|
409
440
|
# Value 는 "- Value:" 다음 줄들의 " > " 인용 블록.
|
|
410
441
|
m = re.search(r"^- Value:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE)
|
|
@@ -414,6 +445,50 @@ def _value(block: str) -> str:
|
|
|
414
445
|
return "\n".join(lines).strip()
|
|
415
446
|
|
|
416
447
|
|
|
448
|
+
def parse_direction_selection(
|
|
449
|
+
sidecar_text: str,
|
|
450
|
+
) -> DirectionSelectionRecord | None:
|
|
451
|
+
matches = list(_DIRECTION_SELECTION_HEADING_RE.finditer(sidecar_text))
|
|
452
|
+
if not matches:
|
|
453
|
+
return None
|
|
454
|
+
if len(matches) != 1:
|
|
455
|
+
raise UserResponseError(
|
|
456
|
+
"DIRECTION SELECTION requires exactly one block"
|
|
457
|
+
)
|
|
458
|
+
block = sidecar_text[matches[0].end():]
|
|
459
|
+
next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
|
|
460
|
+
if next_heading:
|
|
461
|
+
block = block[:next_heading.start()]
|
|
462
|
+
status = _field(block, "Status")
|
|
463
|
+
if status != "selected":
|
|
464
|
+
raise UserResponseError("DIRECTION SELECTION Status must be selected")
|
|
465
|
+
confirmed = _field(block, "Confirmed")
|
|
466
|
+
if confirmed != "true":
|
|
467
|
+
raise UserResponseError("DIRECTION SELECTION Confirmed must be true")
|
|
468
|
+
raw_option_id, raw_option_name = _direction_identity_fields(block)
|
|
469
|
+
try:
|
|
470
|
+
option_id, option_name = normalize_direction_selection_identity(
|
|
471
|
+
raw_option_id,
|
|
472
|
+
raw_option_name,
|
|
473
|
+
)
|
|
474
|
+
except ValueError as error:
|
|
475
|
+
raise UserResponseError(str(error)) from error
|
|
476
|
+
return DirectionSelectionRecord(
|
|
477
|
+
status=status,
|
|
478
|
+
option_id=option_id,
|
|
479
|
+
option_name=option_name,
|
|
480
|
+
confirmed=True,
|
|
481
|
+
selection_note=_quoted_review_value(block, "Selection-Note"),
|
|
482
|
+
constraints=_quoted_review_value(block, "Constraints"),
|
|
483
|
+
source_report=_sidecar_metadata_value(sidecar_text, "source-report"),
|
|
484
|
+
source_data=_sidecar_metadata_value(sidecar_text, "source-data"),
|
|
485
|
+
source_data_sha256=_sidecar_metadata_value(
|
|
486
|
+
sidecar_text, "source-data-sha256"
|
|
487
|
+
),
|
|
488
|
+
seq=_sidecar_metadata_value(sidecar_text, "seq"),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
417
492
|
def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
|
|
418
493
|
"""Reverse of ``serialize_user_response`` for the per-response ``## C-*``
|
|
419
494
|
blocks. The ``## PLAN DECISION`` block is skipped (read separately by
|