okstra 0.173.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/docs/architecture/storage-model.md +13 -3
- package/docs/architecture.md +5 -21
- package/docs/cli.md +3 -2
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +13 -13
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/implementation-planning.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -1
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +6 -6
- package/runtime/prompts/profiles/_common-contract.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 +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +12 -9
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -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/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/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/render_final_report.py +15 -19
- package/runtime/python/okstra_ctl/report_contract.py +0 -1
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/run.py +43 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
- 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/worker_request.py +2 -0
- 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/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +63 -3
- package/runtime/validators/validate-run.py +14 -473
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- 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,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])
|
|
@@ -35,6 +35,7 @@ def build_request(
|
|
|
35
35
|
worktree_path: Path | None,
|
|
36
36
|
role: str,
|
|
37
37
|
idle_timeout_seconds: int,
|
|
38
|
+
session_id: str = "",
|
|
38
39
|
) -> WorkerExecRequest:
|
|
39
40
|
"""One dispatch, with every value the strategies will not re-derive.
|
|
40
41
|
|
|
@@ -55,6 +56,7 @@ def build_request(
|
|
|
55
56
|
auto_approve=True, write_scope=write_scope(root, worktree, role)
|
|
56
57
|
),
|
|
57
58
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
59
|
+
session_id=session_id,
|
|
58
60
|
)
|
|
59
61
|
|
|
60
62
|
|
|
@@ -77,6 +77,27 @@ DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# Sync dirs materialised as a REAL directory whose children are symlinked one
|
|
81
|
+
# by one, instead of a single symlink standing in for the whole directory.
|
|
82
|
+
#
|
|
83
|
+
# Why the split exists: git does not follow a symlink, so a symlinked directory
|
|
84
|
+
# is one *file* to it. A project that ignores host config by its contents
|
|
85
|
+
# (`.claude/*`) matches every child but never the bare `.claude` path, so the
|
|
86
|
+
# symlink lands in `git status` as
|
|
87
|
+
# `?? .claude` while the same directory is invisible in the main checkout. Any
|
|
88
|
+
# plan step asserting a clean worktree then fails on okstra's own provisioning.
|
|
89
|
+
# Linking the children instead reproduces the main checkout's shape, so
|
|
90
|
+
# whatever the project's ignore rules do there, they do here too.
|
|
91
|
+
#
|
|
92
|
+
# Only `.claude` qualifies. The other sync dirs are shared okstra state that
|
|
93
|
+
# okstra WRITES into, and a directory symlink is what makes a newly created
|
|
94
|
+
# top-level entry land in the main checkout rather than diverging inside the
|
|
95
|
+
# worktree. `.claude` is host configuration okstra reads; its one okstra write
|
|
96
|
+
# is the fixed `settings.local.json` child seeded by
|
|
97
|
+
# `_seed_worktree_settings_symlink`.
|
|
98
|
+
CHILD_LINKED_SYNC_DIRS: tuple[str, ...] = (".claude",)
|
|
99
|
+
|
|
100
|
+
|
|
80
101
|
# Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
|
|
81
102
|
# at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
|
|
82
103
|
# every task sees the live shared file. The split exists because the original
|
|
@@ -522,16 +543,27 @@ def is_ancestor(cwd, commit: str, head: str) -> bool:
|
|
|
522
543
|
return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
|
|
523
544
|
|
|
524
545
|
|
|
525
|
-
def
|
|
526
|
-
"""
|
|
546
|
+
def dirty_entries_excluding_okstra(cwd) -> list[str]:
|
|
547
|
+
"""`git status --short` rows for changes outside okstra-owned paths.
|
|
527
548
|
|
|
528
549
|
okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
|
|
529
550
|
plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
|
|
551
|
+
|
|
552
|
+
This is the clean-worktree question every okstra gate asks, and the one a
|
|
553
|
+
plan step must ask through `okstra worktree-status` rather than through a
|
|
554
|
+
bare `git status --porcelain`: okstra provisions `.okstra` plus the synced
|
|
555
|
+
entries into every task worktree, so a bare status is never empty there and
|
|
556
|
+
an assertion built on it fails on okstra's own scaffolding.
|
|
530
557
|
"""
|
|
531
558
|
owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
|
|
532
559
|
excludes = [f":(exclude){p}" for p in owned]
|
|
533
560
|
out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
|
|
534
|
-
return
|
|
561
|
+
return [line for line in out.splitlines() if line.strip()]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def is_dirty_excluding_okstra(cwd) -> bool:
|
|
565
|
+
"""True iff the worktree has changes outside okstra-owned paths."""
|
|
566
|
+
return bool(dirty_entries_excluding_okstra(cwd))
|
|
535
567
|
|
|
536
568
|
|
|
537
569
|
class MergeError(RuntimeError):
|
|
@@ -580,6 +612,9 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
580
612
|
version-controlled files.
|
|
581
613
|
- Parent directories are created as needed for nested entries.
|
|
582
614
|
|
|
615
|
+
Entries listed in `CHILD_LINKED_SYNC_DIRS` are materialised as a real
|
|
616
|
+
directory of per-child symlinks instead (see that constant).
|
|
617
|
+
|
|
583
618
|
Returns a list of human-readable notes (one per linked entry) so the
|
|
584
619
|
caller can include them in the provisioning note.
|
|
585
620
|
"""
|
|
@@ -592,6 +627,10 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
592
627
|
if dst.exists() or dst.is_symlink():
|
|
593
628
|
continue
|
|
594
629
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
if rel in CHILD_LINKED_SYNC_DIRS and src.is_dir():
|
|
631
|
+
_link_dir_children(src, dst)
|
|
632
|
+
notes.append(rel)
|
|
633
|
+
continue
|
|
595
634
|
try:
|
|
596
635
|
os.symlink(src, dst)
|
|
597
636
|
except FileExistsError:
|
|
@@ -600,6 +639,33 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
600
639
|
return notes
|
|
601
640
|
|
|
602
641
|
|
|
642
|
+
def _link_dir_children(src: Path, dst: Path) -> None:
|
|
643
|
+
"""Create `dst` as a real directory whose entries symlink to `src`'s
|
|
644
|
+
children, so git sees the same directory shape it sees in the main
|
|
645
|
+
checkout (`CHILD_LINKED_SYNC_DIRS`).
|
|
646
|
+
|
|
647
|
+
Each link points at the MAIN checkout's child rather than its resolved
|
|
648
|
+
target, so a child that is itself a symlink (e.g. `.claude/settings.local.json`
|
|
649
|
+
→ `~/.okstra/templates/settings.local.json`) keeps following whatever the
|
|
650
|
+
main checkout currently points at. An unreadable source or a child that
|
|
651
|
+
cannot be linked degrades to "that child is absent in this worktree" —
|
|
652
|
+
provisioning is not worth failing over host config.
|
|
653
|
+
"""
|
|
654
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
try:
|
|
656
|
+
children = sorted(src.iterdir())
|
|
657
|
+
except OSError:
|
|
658
|
+
return
|
|
659
|
+
for child in children:
|
|
660
|
+
link = dst / child.name
|
|
661
|
+
if link.exists() or link.is_symlink():
|
|
662
|
+
continue
|
|
663
|
+
try:
|
|
664
|
+
os.symlink(child, link)
|
|
665
|
+
except OSError:
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
|
|
603
669
|
def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
|
|
604
670
|
"""File-level counterpart to `_link_sync_dirs` (FU-V2).
|
|
605
671
|
|
|
@@ -57,7 +57,7 @@ def main() -> int:
|
|
|
57
57
|
"from the freshly computed usageSummary, then re-render the "
|
|
58
58
|
"sibling final-report markdown via the renderer. The data.json "
|
|
59
59
|
"is the SSOT; the markdown is regenerated from it. "
|
|
60
|
-
"See schemas/final-report-
|
|
60
|
+
"See schemas/final-report-v2.0.schema.json for the data shape."
|
|
61
61
|
),
|
|
62
62
|
)
|
|
63
63
|
parser.add_argument(
|
|
@@ -21,6 +21,7 @@ from .antigravity import (
|
|
|
21
21
|
)
|
|
22
22
|
from .paths import claude_project_dir, utc_now
|
|
23
23
|
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
24
|
+
from okstra_ctl.dispatch_state import worker_session_ids
|
|
24
25
|
from okstra_ctl.models import provider_wrappers
|
|
25
26
|
from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
26
27
|
|
|
@@ -864,22 +865,66 @@ def collect_claude_runtime_usage(
|
|
|
864
865
|
f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
|
|
865
866
|
)
|
|
866
867
|
|
|
867
|
-
# Workers —
|
|
868
|
-
#
|
|
869
|
-
# implementation `-executor`, report-writer `-impl` / `-2
|
|
868
|
+
# Workers — dispatch 가 기록한 세션 id 로 짚은 세션과 agentName prefix 로
|
|
869
|
+
# 찾은 세션의 합집합을 합산한다(재배치 `-002`, convergence `-reverify-r1`,
|
|
870
|
+
# implementation `-executor`, report-writer `-impl` / `-2` 등).
|
|
871
|
+
sessions_dir = claude_project_dir(cwd)
|
|
872
|
+
# sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
|
|
873
|
+
attributed_sids: set[str] = set()
|
|
870
874
|
for worker in state.get("workers", []):
|
|
871
875
|
worker_id = worker.get("workerId")
|
|
872
876
|
agent = worker.get("agent")
|
|
873
877
|
prefixes = match_prefixes(worker_id) if worker_id else []
|
|
874
878
|
|
|
879
|
+
# pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
|
|
880
|
+
# 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
|
|
881
|
+
# 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
|
|
882
|
+
# attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
|
|
883
|
+
# 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
|
|
884
|
+
# 그 워커가 run 의 모든 세션을 흡수한다.
|
|
885
|
+
dispatched_sids = worker_session_ids(state, worker_id) if worker_id else []
|
|
875
886
|
matched: list[tuple[str, Path, dict]] = []
|
|
887
|
+
matched_sids: set[str] = set()
|
|
888
|
+
for sid in dispatched_sids:
|
|
889
|
+
path = sessions_dir / f"{sid}.jsonl"
|
|
890
|
+
if not path.is_file():
|
|
891
|
+
continue
|
|
892
|
+
totals = claude_session_totals(path, since=run_since, until=run_until,
|
|
893
|
+
incremental=incremental)
|
|
894
|
+
# 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
|
|
895
|
+
# `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
|
|
896
|
+
# usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
|
|
897
|
+
# 남아야 할 상태를 허위 0 이 덮는다.
|
|
898
|
+
if not totals.get("startedAt"):
|
|
899
|
+
continue
|
|
900
|
+
matched.append((sid, path, totals))
|
|
901
|
+
matched_sids.add(sid)
|
|
902
|
+
attributed_sids.add(sid)
|
|
903
|
+
|
|
904
|
+
# 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
|
|
905
|
+
# 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
|
|
906
|
+
# 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
|
|
907
|
+
# 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
|
|
876
908
|
for agent_name, entries in by_agent.items():
|
|
877
|
-
if agent_matches(agent_name, prefixes):
|
|
878
|
-
|
|
909
|
+
if not agent_matches(agent_name, prefixes):
|
|
910
|
+
continue
|
|
911
|
+
for entry in entries:
|
|
912
|
+
if entry[0] in matched_sids:
|
|
913
|
+
continue
|
|
914
|
+
# team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
|
|
915
|
+
# 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
|
|
916
|
+
# startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
|
|
917
|
+
# 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
|
|
918
|
+
# 세션만 남의 것이 된다.
|
|
919
|
+
if not entry[2].get("startedAt"):
|
|
920
|
+
continue
|
|
921
|
+
matched.append(entry)
|
|
922
|
+
matched_sids.add(entry[0])
|
|
879
923
|
|
|
880
924
|
if not matched:
|
|
881
925
|
worker["usage"] = na_block(
|
|
882
|
-
|
|
926
|
+
"no Claude session jsonl in the run window for dispatched "
|
|
927
|
+
f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
|
|
883
928
|
)
|
|
884
929
|
continue
|
|
885
930
|
|
|
@@ -919,6 +964,21 @@ def collect_claude_runtime_usage(
|
|
|
919
964
|
# session the harness never tagged with `name`. Without this, the run-level
|
|
920
965
|
# Worker total reads 0 and the report validator hard-fails a legitimate run.
|
|
921
966
|
# Attribution is aggregate, not per-worker; usageSummary records it openly.
|
|
967
|
+
#
|
|
968
|
+
# 먼저 sid 로 귀속된 세션을 뺀다. 이 두 집합은 `agentName` 유무로 배타적이었지만
|
|
969
|
+
# (by_agent 는 있어야 들어가고 unattributed 는 없어야 들어간다) sid 경로는
|
|
970
|
+
# agentName 을 안 보므로 그 배타성이 더는 성립하지 않는다. team needle 에
|
|
971
|
+
# 걸리면서 agentName 이 없는 세션이 동시에 기록된 dispatch sid 이기도 하면,
|
|
972
|
+
# 빼지 않을 경우 그 토큰이 워커 usage 와 이 폴드 양쪽에 들어가 `_populate_usage_summary`
|
|
973
|
+
# 의 workerTotalTokens 를 부풀린다. 두 리스트는 인덱스가 대응하므로 함께 거른다.
|
|
974
|
+
kept = [
|
|
975
|
+
(sid, totals)
|
|
976
|
+
for sid, totals in zip(unattributed_sessions, unattributed_totals)
|
|
977
|
+
if sid not in attributed_sids
|
|
978
|
+
]
|
|
979
|
+
unattributed_sessions = [sid for sid, _ in kept]
|
|
980
|
+
unattributed_totals = [totals for _, totals in kept]
|
|
981
|
+
|
|
922
982
|
unattributed_usage = None
|
|
923
983
|
if unattributed_totals:
|
|
924
984
|
unattributed_usage = usage_block(
|
|
@@ -16,6 +16,17 @@ shared state. The built-in default is `.project-docs`, `.scratch`,
|
|
|
16
16
|
okstra-owned context and writes still stay under `<PROJECT_ROOT>/.okstra/**`
|
|
17
17
|
unless the task brief explicitly authorizes a non-okstra path.
|
|
18
18
|
|
|
19
|
+
`.claude` is the one entry materialised as a real directory whose children are
|
|
20
|
+
symlinked one by one, instead of a single symlink for the whole directory. git
|
|
21
|
+
does not follow a symlink, so a symlinked directory is one file to it, and a
|
|
22
|
+
project that ignores host config by its contents (`.claude/*`) matches every
|
|
23
|
+
child but never the bare `.claude` path — the directory would be invisible in
|
|
24
|
+
the main checkout and `?? .claude` in the task worktree. Linking the children
|
|
25
|
+
reproduces the main checkout's shape, so the project's own ignore rules decide
|
|
26
|
+
the outcome in both places. The other entries stay whole-directory symlinks
|
|
27
|
+
because okstra writes into them, and the directory symlink is what makes a
|
|
28
|
+
newly created top-level entry land in the main checkout.
|
|
29
|
+
|
|
19
30
|
To override per-project, add a `worktreeSyncDirs` array to `project.json`.
|
|
20
31
|
Empty array disables the feature; the field is preserved across the runtime's
|
|
21
32
|
auto-upserts (only `projectId`, `projectRoot`, `createdAt`, `updatedAt` are
|