okstra 0.75.0 → 0.77.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.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +10 -2
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +20 -4
- package/docs/pr-template-usage.md +3 -3
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/gemini-worker.md +1 -1
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -6
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +12 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +160 -29
- package/runtime/python/okstra_ctl/run.py +78 -15
- package/runtime/python/okstra_ctl/wizard.py +23 -9
- package/runtime/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/validators/validate-run.py +56 -22
- package/runtime/validators/validate_session_conformance.py +121 -4
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +40 -4
- package/src/install.mjs +122 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +26 -0
- package/src/uninstall.mjs +26 -4
- /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
"""Codex lead CLI-worker dispatcher.
|
|
2
|
+
|
|
3
|
+
This module is intentionally narrower than the Claude Code team dispatcher. It
|
|
4
|
+
reads an already-prepared Codex run manifest and dispatches only workers that
|
|
5
|
+
have a CLI wrapper path. `report-writer` is supported only through explicit
|
|
6
|
+
Codex opt-in because the prepared manifest still records a Claude-side model by
|
|
7
|
+
default.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Mapping, Sequence
|
|
20
|
+
|
|
21
|
+
from .lead_events import LeadEvent, append_lead_event
|
|
22
|
+
from .paths import task_dir
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SUPPORTED_CLI_WORKERS = {
|
|
26
|
+
"codex": "okstra-codex-exec.sh",
|
|
27
|
+
"gemini": "okstra-gemini-exec.sh",
|
|
28
|
+
}
|
|
29
|
+
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
30
|
+
BACKEND_MIXED = "mixed"
|
|
31
|
+
MAX_WORKER_ATTEMPTS = 2
|
|
32
|
+
REPORT_WRITER_WORKER_ID = "report-writer"
|
|
33
|
+
REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
|
|
34
|
+
WORKER_PREAMBLE_PATH = Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md"
|
|
35
|
+
ANALYSIS_WORKER_LABELS = {
|
|
36
|
+
"codex": "Codex worker",
|
|
37
|
+
"gemini": "Gemini worker",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DispatchError(Exception):
|
|
42
|
+
"""Raised when a Codex dispatch request cannot be executed."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class WorkerDispatch:
|
|
47
|
+
worker_id: str
|
|
48
|
+
backend: str
|
|
49
|
+
project_root: Path
|
|
50
|
+
model_execution_value: str
|
|
51
|
+
wrapper_path: Path
|
|
52
|
+
prompt_path: Path
|
|
53
|
+
result_path: Path
|
|
54
|
+
worker_result_path: Path
|
|
55
|
+
completion_paths: tuple[Path, ...]
|
|
56
|
+
worktree_path: str
|
|
57
|
+
role: str
|
|
58
|
+
idle_timeout_seconds: int
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def command(self) -> list[str]:
|
|
62
|
+
return [
|
|
63
|
+
str(self.wrapper_path),
|
|
64
|
+
str(self.project_root),
|
|
65
|
+
self.model_execution_value,
|
|
66
|
+
str(self.prompt_path),
|
|
67
|
+
self.worktree_path,
|
|
68
|
+
self.role,
|
|
69
|
+
str(self.idle_timeout_seconds),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def to_payload(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"workerId": self.worker_id,
|
|
75
|
+
"backend": self.backend,
|
|
76
|
+
"modelExecutionValue": self.model_execution_value,
|
|
77
|
+
"wrapperPath": str(self.wrapper_path),
|
|
78
|
+
"promptPath": str(self.prompt_path),
|
|
79
|
+
"resultPath": str(self.result_path),
|
|
80
|
+
"workerResultPath": str(self.worker_result_path),
|
|
81
|
+
"completionPaths": [str(path) for path in self.completion_paths],
|
|
82
|
+
"worktreePath": self.worktree_path,
|
|
83
|
+
"role": self.role,
|
|
84
|
+
"command": self.command,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class DispatchPlan:
|
|
90
|
+
project_root: Path
|
|
91
|
+
workspace_root: Path
|
|
92
|
+
manifest_path: Path
|
|
93
|
+
team_state_path: Path
|
|
94
|
+
lead_events_path: Path
|
|
95
|
+
manifest: Mapping[str, Any]
|
|
96
|
+
workers: tuple[WorkerDispatch, ...]
|
|
97
|
+
|
|
98
|
+
def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"dryRun": dry_run,
|
|
102
|
+
"dispatchMode": _dispatch_mode(self.workers),
|
|
103
|
+
"workerBackends": {
|
|
104
|
+
worker.worker_id: worker.backend
|
|
105
|
+
for worker in self.workers
|
|
106
|
+
},
|
|
107
|
+
"runManifestPath": str(self.manifest_path),
|
|
108
|
+
"teamStatePath": str(self.team_state_path),
|
|
109
|
+
"leadEventsPath": str(self.lead_events_path),
|
|
110
|
+
"workspaceRoot": str(self.workspace_root),
|
|
111
|
+
"workers": [worker.to_payload() for worker in self.workers],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_dispatch_plan(
|
|
116
|
+
*,
|
|
117
|
+
project_root: Path,
|
|
118
|
+
run_manifest_path: Path,
|
|
119
|
+
workspace_root: Path,
|
|
120
|
+
okstra_bin: Path | None = None,
|
|
121
|
+
requested_workers: Sequence[str] = (),
|
|
122
|
+
idle_timeout_seconds: int = 600,
|
|
123
|
+
enable_codex_report_writer: bool = False,
|
|
124
|
+
report_writer_codex_model: str = "",
|
|
125
|
+
) -> DispatchPlan:
|
|
126
|
+
project_root = project_root.resolve()
|
|
127
|
+
manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
|
|
128
|
+
manifest = _load_json_object(manifest_path, "run manifest")
|
|
129
|
+
_validate_codex_manifest(manifest, manifest_path)
|
|
130
|
+
|
|
131
|
+
selected_workers = _select_workers(
|
|
132
|
+
manifest,
|
|
133
|
+
requested_workers,
|
|
134
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
135
|
+
)
|
|
136
|
+
team_state_path = _resolve_required_path(
|
|
137
|
+
project_root, manifest, "teamStatePath", "team-state"
|
|
138
|
+
)
|
|
139
|
+
team_state = _load_json_object(team_state_path, "team-state")
|
|
140
|
+
_mark_skipped_workers(
|
|
141
|
+
team_state_path,
|
|
142
|
+
team_state,
|
|
143
|
+
_skipped_worker_reasons(
|
|
144
|
+
manifest,
|
|
145
|
+
selected_workers,
|
|
146
|
+
requested_workers,
|
|
147
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
active_context = _load_optional_json_object(
|
|
151
|
+
_resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
|
|
152
|
+
)
|
|
153
|
+
lead_events_path = _resolve_required_path(
|
|
154
|
+
project_root, manifest, "leadEventsPath", "lead events"
|
|
155
|
+
)
|
|
156
|
+
_materialize_missing_worker_prompts(
|
|
157
|
+
project_root=project_root,
|
|
158
|
+
manifest=manifest,
|
|
159
|
+
team_state=team_state,
|
|
160
|
+
active_context=active_context,
|
|
161
|
+
selected_workers=selected_workers,
|
|
162
|
+
workspace_root=workspace_root,
|
|
163
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
164
|
+
)
|
|
165
|
+
workers = tuple(
|
|
166
|
+
_build_worker_dispatch(
|
|
167
|
+
worker_id=worker_id,
|
|
168
|
+
project_root=project_root,
|
|
169
|
+
manifest=manifest,
|
|
170
|
+
team_state=team_state,
|
|
171
|
+
active_context=active_context,
|
|
172
|
+
workspace_root=workspace_root,
|
|
173
|
+
okstra_bin=okstra_bin,
|
|
174
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
175
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
176
|
+
)
|
|
177
|
+
for worker_id in selected_workers
|
|
178
|
+
)
|
|
179
|
+
return DispatchPlan(
|
|
180
|
+
project_root=project_root,
|
|
181
|
+
workspace_root=workspace_root,
|
|
182
|
+
manifest_path=manifest_path,
|
|
183
|
+
team_state_path=team_state_path,
|
|
184
|
+
lead_events_path=lead_events_path,
|
|
185
|
+
manifest=manifest,
|
|
186
|
+
workers=workers,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def dispatch_plan(plan: DispatchPlan) -> int:
|
|
191
|
+
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
|
|
192
|
+
for worker in plan.workers:
|
|
193
|
+
_set_worker_status(
|
|
194
|
+
plan.team_state_path,
|
|
195
|
+
worker.worker_id,
|
|
196
|
+
"running",
|
|
197
|
+
"",
|
|
198
|
+
model_execution_value=worker.model_execution_value,
|
|
199
|
+
)
|
|
200
|
+
completed = _dispatch_worker_with_retry(plan, worker)
|
|
201
|
+
if completed == 0:
|
|
202
|
+
continue
|
|
203
|
+
return completed
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> int:
|
|
208
|
+
for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
|
|
209
|
+
attempt_details = _attempt_event_details(worker, attempt)
|
|
210
|
+
_append_event(plan, "worker-dispatched", attempt_details)
|
|
211
|
+
result = _run_worker(plan, worker)
|
|
212
|
+
missing_paths = _missing_completion_paths(worker)
|
|
213
|
+
if result.returncode == 0 and not missing_paths:
|
|
214
|
+
post_process = _post_process_report_writer_result(plan, worker)
|
|
215
|
+
if not post_process["ok"]:
|
|
216
|
+
reason = _require_string(post_process, "reason")
|
|
217
|
+
_set_worker_status(plan.team_state_path, worker.worker_id, "failed", reason)
|
|
218
|
+
details = {
|
|
219
|
+
**attempt_details,
|
|
220
|
+
"exitCode": 1,
|
|
221
|
+
"missingCompletionPaths": [],
|
|
222
|
+
"reason": reason,
|
|
223
|
+
"postProcessing": post_process["steps"],
|
|
224
|
+
}
|
|
225
|
+
_append_event(plan, "worker-failed", details)
|
|
226
|
+
return 1
|
|
227
|
+
_set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
|
|
228
|
+
details = {
|
|
229
|
+
**attempt_details,
|
|
230
|
+
"missingCompletionPaths": [],
|
|
231
|
+
"postProcessing": post_process["steps"],
|
|
232
|
+
}
|
|
233
|
+
_append_event(plan, "worker-result-collected", details)
|
|
234
|
+
return 0
|
|
235
|
+
if _should_retry_missing_result(result, missing_paths, attempt):
|
|
236
|
+
retry_details = {
|
|
237
|
+
**attempt_details,
|
|
238
|
+
"missingCompletionPaths": [str(path) for path in missing_paths],
|
|
239
|
+
"reason": "required worker artifact was not produced",
|
|
240
|
+
}
|
|
241
|
+
_append_event(plan, "worker-retry-scheduled", retry_details)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
reason = _failure_reason(result.returncode, missing_paths)
|
|
245
|
+
_set_worker_status(plan.team_state_path, worker.worker_id, "failed", reason)
|
|
246
|
+
details = {
|
|
247
|
+
**attempt_details,
|
|
248
|
+
"exitCode": result.returncode,
|
|
249
|
+
"missingCompletionPaths": [str(path) for path in missing_paths],
|
|
250
|
+
"reason": reason,
|
|
251
|
+
}
|
|
252
|
+
_append_event(plan, "worker-failed", details)
|
|
253
|
+
return result.returncode if result.returncode else 1
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
258
|
+
parser = _parser()
|
|
259
|
+
args = parser.parse_args(argv)
|
|
260
|
+
try:
|
|
261
|
+
idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
|
|
262
|
+
plan = build_dispatch_plan(
|
|
263
|
+
project_root=Path(args.project_root),
|
|
264
|
+
run_manifest_path=Path(args.run_manifest),
|
|
265
|
+
workspace_root=Path(args.workspace_root),
|
|
266
|
+
okstra_bin=Path(args.okstra_bin) if args.okstra_bin else None,
|
|
267
|
+
requested_workers=_parse_workers(args.workers),
|
|
268
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
269
|
+
enable_codex_report_writer=args.enable_codex_report_writer,
|
|
270
|
+
report_writer_codex_model=args.report_writer_codex_model,
|
|
271
|
+
)
|
|
272
|
+
if args.dry_run:
|
|
273
|
+
_print_json(plan.to_payload(dry_run=True))
|
|
274
|
+
return 0
|
|
275
|
+
code = dispatch_plan(plan)
|
|
276
|
+
_print_json({**plan.to_payload(dry_run=False), "exitCode": code})
|
|
277
|
+
return code
|
|
278
|
+
except DispatchError as exc:
|
|
279
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
280
|
+
return 2
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parser() -> argparse.ArgumentParser:
|
|
284
|
+
parser = argparse.ArgumentParser(
|
|
285
|
+
prog="okstra codex-dispatch",
|
|
286
|
+
description="Dispatch CLI-backed workers for a prepared Codex lead run.",
|
|
287
|
+
)
|
|
288
|
+
parser.add_argument("--project-root", required=True)
|
|
289
|
+
parser.add_argument("--run-manifest", required=True)
|
|
290
|
+
parser.add_argument("--workspace-root", required=True)
|
|
291
|
+
parser.add_argument("--okstra-bin", default="")
|
|
292
|
+
parser.add_argument("--workers", default="")
|
|
293
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
294
|
+
parser.add_argument("--idle-timeout-seconds", default="600")
|
|
295
|
+
parser.add_argument("--enable-codex-report-writer", action="store_true")
|
|
296
|
+
parser.add_argument("--report-writer-codex-model", default="")
|
|
297
|
+
return parser
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _materialize_missing_worker_prompts(
|
|
301
|
+
*,
|
|
302
|
+
project_root: Path,
|
|
303
|
+
manifest: Mapping[str, Any],
|
|
304
|
+
team_state: Mapping[str, Any],
|
|
305
|
+
active_context: Mapping[str, Any],
|
|
306
|
+
selected_workers: Sequence[str],
|
|
307
|
+
workspace_root: Path,
|
|
308
|
+
report_writer_codex_model: str,
|
|
309
|
+
) -> None:
|
|
310
|
+
for worker_id in selected_workers:
|
|
311
|
+
prompt_path = _resolve_project_path(
|
|
312
|
+
project_root, _worker_prompt_path(manifest, worker_id)
|
|
313
|
+
)
|
|
314
|
+
if prompt_path.is_file():
|
|
315
|
+
continue
|
|
316
|
+
prompt = _render_missing_worker_prompt(
|
|
317
|
+
project_root=project_root,
|
|
318
|
+
manifest=manifest,
|
|
319
|
+
team_state=team_state,
|
|
320
|
+
active_context=active_context,
|
|
321
|
+
worker_id=worker_id,
|
|
322
|
+
workspace_root=workspace_root,
|
|
323
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
324
|
+
)
|
|
325
|
+
prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
prompt_path.write_text(prompt, encoding="utf-8")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _render_missing_worker_prompt(
|
|
330
|
+
*,
|
|
331
|
+
project_root: Path,
|
|
332
|
+
manifest: Mapping[str, Any],
|
|
333
|
+
team_state: Mapping[str, Any],
|
|
334
|
+
active_context: Mapping[str, Any],
|
|
335
|
+
worker_id: str,
|
|
336
|
+
workspace_root: Path,
|
|
337
|
+
report_writer_codex_model: str,
|
|
338
|
+
) -> str:
|
|
339
|
+
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
340
|
+
return _render_missing_report_writer_prompt(
|
|
341
|
+
project_root=project_root,
|
|
342
|
+
manifest=manifest,
|
|
343
|
+
team_state=team_state,
|
|
344
|
+
active_context=active_context,
|
|
345
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
346
|
+
)
|
|
347
|
+
return _render_missing_analysis_worker_prompt(
|
|
348
|
+
project_root=project_root,
|
|
349
|
+
manifest=manifest,
|
|
350
|
+
team_state=team_state,
|
|
351
|
+
active_context=active_context,
|
|
352
|
+
worker_id=worker_id,
|
|
353
|
+
workspace_root=workspace_root,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _render_missing_analysis_worker_prompt(
|
|
358
|
+
*,
|
|
359
|
+
project_root: Path,
|
|
360
|
+
manifest: Mapping[str, Any],
|
|
361
|
+
team_state: Mapping[str, Any],
|
|
362
|
+
active_context: Mapping[str, Any],
|
|
363
|
+
worker_id: str,
|
|
364
|
+
workspace_root: Path,
|
|
365
|
+
) -> str:
|
|
366
|
+
worker_state = _worker_state(team_state, worker_id)
|
|
367
|
+
result_rel = _require_string(worker_state, "resultPath")
|
|
368
|
+
prompt_rel = _worker_prompt_path(manifest, worker_id)
|
|
369
|
+
model = _require_string(worker_state, "modelExecutionValue")
|
|
370
|
+
role = _analysis_pane_role(manifest, active_context, worker_id)
|
|
371
|
+
lines = _base_prompt_headers(
|
|
372
|
+
project_root=project_root,
|
|
373
|
+
manifest=manifest,
|
|
374
|
+
active_context=active_context,
|
|
375
|
+
worker_id=worker_id,
|
|
376
|
+
prompt_rel=prompt_rel,
|
|
377
|
+
result_rel=result_rel,
|
|
378
|
+
)
|
|
379
|
+
lines.extend(_worktree_headers(manifest, active_context, role))
|
|
380
|
+
lines.extend(_analysis_prompt_body(manifest, active_context, worker_id, model, role))
|
|
381
|
+
executor_tail = _implementation_executor_tail(
|
|
382
|
+
manifest, workspace_root, role
|
|
383
|
+
)
|
|
384
|
+
if executor_tail:
|
|
385
|
+
lines.extend(["", executor_tail.rstrip()])
|
|
386
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _render_missing_report_writer_prompt(
|
|
390
|
+
*,
|
|
391
|
+
project_root: Path,
|
|
392
|
+
manifest: Mapping[str, Any],
|
|
393
|
+
team_state: Mapping[str, Any],
|
|
394
|
+
active_context: Mapping[str, Any],
|
|
395
|
+
report_writer_codex_model: str,
|
|
396
|
+
) -> str:
|
|
397
|
+
worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
|
|
398
|
+
markdown_path = _resolve_required_path(
|
|
399
|
+
project_root, manifest, "expectedReportPath", "report"
|
|
400
|
+
)
|
|
401
|
+
data_json_path = _final_report_data_path(markdown_path)
|
|
402
|
+
result_rel = _project_relative_path(project_root, data_json_path)
|
|
403
|
+
worker_result_rel = _require_string(worker_state, "resultPath")
|
|
404
|
+
model = (report_writer_codex_model or "").strip()
|
|
405
|
+
if not model:
|
|
406
|
+
raise DispatchError(
|
|
407
|
+
"Codex report-writer dispatch requires --report-writer-codex-model"
|
|
408
|
+
)
|
|
409
|
+
lines = _base_prompt_headers(
|
|
410
|
+
project_root=project_root,
|
|
411
|
+
manifest=manifest,
|
|
412
|
+
active_context=active_context,
|
|
413
|
+
worker_id=REPORT_WRITER_WORKER_ID,
|
|
414
|
+
prompt_rel=_worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID),
|
|
415
|
+
result_rel=result_rel,
|
|
416
|
+
)
|
|
417
|
+
lines.extend([
|
|
418
|
+
f"**Worker Result Path:** {worker_result_rel}",
|
|
419
|
+
f"**Model:** Report writer worker, {model}",
|
|
420
|
+
f"**Report Language:** {_resolve_report_language(project_root, manifest, active_context)}",
|
|
421
|
+
])
|
|
422
|
+
lines.extend(_report_writer_prompt_body(manifest, active_context, team_state))
|
|
423
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _build_worker_dispatch(
|
|
427
|
+
*,
|
|
428
|
+
worker_id: str,
|
|
429
|
+
project_root: Path,
|
|
430
|
+
manifest: Mapping[str, Any],
|
|
431
|
+
team_state: Mapping[str, Any],
|
|
432
|
+
active_context: Mapping[str, Any],
|
|
433
|
+
workspace_root: Path,
|
|
434
|
+
okstra_bin: Path | None,
|
|
435
|
+
idle_timeout_seconds: int,
|
|
436
|
+
report_writer_codex_model: str,
|
|
437
|
+
) -> WorkerDispatch:
|
|
438
|
+
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
439
|
+
return _build_report_writer_dispatch(
|
|
440
|
+
project_root=project_root,
|
|
441
|
+
manifest=manifest,
|
|
442
|
+
team_state=team_state,
|
|
443
|
+
workspace_root=workspace_root,
|
|
444
|
+
okstra_bin=okstra_bin,
|
|
445
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
446
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
447
|
+
)
|
|
448
|
+
worker_state = _worker_state(team_state, worker_id)
|
|
449
|
+
prompt_rel = _worker_prompt_path(manifest, worker_id)
|
|
450
|
+
prompt_path = _resolve_project_path(project_root, prompt_rel)
|
|
451
|
+
result_path = _resolve_project_path(
|
|
452
|
+
project_root, _require_string(worker_state, "resultPath")
|
|
453
|
+
)
|
|
454
|
+
model_execution_value = _require_string(worker_state, "modelExecutionValue")
|
|
455
|
+
wrapper = _resolve_wrapper(worker_id, workspace_root, okstra_bin)
|
|
456
|
+
worktree_path = _worktree_path(manifest, active_context)
|
|
457
|
+
role = _pane_role(prompt_path)
|
|
458
|
+
return WorkerDispatch(
|
|
459
|
+
worker_id=worker_id,
|
|
460
|
+
backend=BACKEND_CLI_WRAPPER,
|
|
461
|
+
project_root=project_root,
|
|
462
|
+
model_execution_value=model_execution_value,
|
|
463
|
+
wrapper_path=wrapper,
|
|
464
|
+
prompt_path=prompt_path,
|
|
465
|
+
result_path=result_path,
|
|
466
|
+
worker_result_path=result_path,
|
|
467
|
+
completion_paths=(result_path,),
|
|
468
|
+
worktree_path=worktree_path,
|
|
469
|
+
role=role,
|
|
470
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _build_report_writer_dispatch(
|
|
475
|
+
*,
|
|
476
|
+
project_root: Path,
|
|
477
|
+
manifest: Mapping[str, Any],
|
|
478
|
+
team_state: Mapping[str, Any],
|
|
479
|
+
workspace_root: Path,
|
|
480
|
+
okstra_bin: Path | None,
|
|
481
|
+
idle_timeout_seconds: int,
|
|
482
|
+
report_writer_codex_model: str,
|
|
483
|
+
) -> WorkerDispatch:
|
|
484
|
+
model_execution_value = (report_writer_codex_model or "").strip()
|
|
485
|
+
if not model_execution_value:
|
|
486
|
+
raise DispatchError(
|
|
487
|
+
"Codex report-writer dispatch requires --report-writer-codex-model"
|
|
488
|
+
)
|
|
489
|
+
worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
|
|
490
|
+
prompt_path = _resolve_project_path(
|
|
491
|
+
project_root, _worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID)
|
|
492
|
+
)
|
|
493
|
+
data_json_path = _final_report_data_path(
|
|
494
|
+
_resolve_required_path(project_root, manifest, "expectedReportPath", "report")
|
|
495
|
+
)
|
|
496
|
+
markdown_path = _final_report_markdown_path(data_json_path)
|
|
497
|
+
worker_result_path = _resolve_project_path(
|
|
498
|
+
project_root, _require_string(worker_state, "resultPath")
|
|
499
|
+
)
|
|
500
|
+
_replace_report_writer_model_line(prompt_path, model_execution_value)
|
|
501
|
+
return WorkerDispatch(
|
|
502
|
+
worker_id=REPORT_WRITER_WORKER_ID,
|
|
503
|
+
backend=BACKEND_CLI_WRAPPER,
|
|
504
|
+
project_root=project_root,
|
|
505
|
+
model_execution_value=model_execution_value,
|
|
506
|
+
wrapper_path=_resolve_wrapper("codex", workspace_root, okstra_bin),
|
|
507
|
+
prompt_path=prompt_path,
|
|
508
|
+
result_path=data_json_path,
|
|
509
|
+
worker_result_path=worker_result_path,
|
|
510
|
+
completion_paths=(
|
|
511
|
+
data_json_path,
|
|
512
|
+
markdown_path,
|
|
513
|
+
worker_result_path,
|
|
514
|
+
),
|
|
515
|
+
worktree_path="",
|
|
516
|
+
role=REPORT_WRITER_WORKER_ID,
|
|
517
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _analysis_prompt_body(
|
|
522
|
+
manifest: Mapping[str, Any],
|
|
523
|
+
active_context: Mapping[str, Any],
|
|
524
|
+
worker_id: str,
|
|
525
|
+
model: str,
|
|
526
|
+
role: str,
|
|
527
|
+
) -> list[str]:
|
|
528
|
+
label = ANALYSIS_WORKER_LABELS[worker_id]
|
|
529
|
+
return [
|
|
530
|
+
f"**Model:** {label}, {model}",
|
|
531
|
+
f"**Pane role:** {role}",
|
|
532
|
+
"",
|
|
533
|
+
f"# {label} Dispatch",
|
|
534
|
+
"",
|
|
535
|
+
"## Role",
|
|
536
|
+
(
|
|
537
|
+
f"You are the {label} for okstra cross-verification. "
|
|
538
|
+
"Produce an independent worker result."
|
|
539
|
+
),
|
|
540
|
+
"",
|
|
541
|
+
"## Task",
|
|
542
|
+
f"- Task key: `{_require_string(manifest, 'taskKey')}`",
|
|
543
|
+
f"- Task type: `{_require_string(manifest, 'taskType')}`",
|
|
544
|
+
"",
|
|
545
|
+
"## Inputs",
|
|
546
|
+
*_analysis_input_lines(manifest, active_context),
|
|
547
|
+
"",
|
|
548
|
+
_mcp_pointer_line(),
|
|
549
|
+
"",
|
|
550
|
+
"## Output Contract",
|
|
551
|
+
"- Read the Worker Preamble Path end-to-end before analysis.",
|
|
552
|
+
"- Write the worker result to Result Path and no other canonical result path.",
|
|
553
|
+
"- Write the audit sidecar and any tool-failure entries as the preamble requires.",
|
|
554
|
+
"- Cite evidence with file paths and line numbers whenever you make a claim.",
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _report_writer_prompt_body(
|
|
559
|
+
manifest: Mapping[str, Any],
|
|
560
|
+
active_context: Mapping[str, Any],
|
|
561
|
+
team_state: Mapping[str, Any],
|
|
562
|
+
) -> list[str]:
|
|
563
|
+
return [
|
|
564
|
+
"",
|
|
565
|
+
"# Report Writer Worker Dispatch",
|
|
566
|
+
"",
|
|
567
|
+
"## Role",
|
|
568
|
+
(
|
|
569
|
+
"You are the Report writer worker. Author the final-report data.json "
|
|
570
|
+
"and the worker-results audit file."
|
|
571
|
+
),
|
|
572
|
+
"",
|
|
573
|
+
"## Task",
|
|
574
|
+
f"- Task key: `{_require_string(manifest, 'taskKey')}`",
|
|
575
|
+
f"- Task type: `{_require_string(manifest, 'taskType')}`",
|
|
576
|
+
"",
|
|
577
|
+
"## Inputs",
|
|
578
|
+
*_report_writer_input_lines(manifest, active_context, team_state),
|
|
579
|
+
"",
|
|
580
|
+
_mcp_pointer_line(),
|
|
581
|
+
"",
|
|
582
|
+
"## Output Contract",
|
|
583
|
+
"You are the author of TWO files:",
|
|
584
|
+
"- The final-report data.json at Result Path.",
|
|
585
|
+
"- The worker-results audit file at Worker Result Path.",
|
|
586
|
+
(
|
|
587
|
+
'After writing the data.json, invoke "okstra render-final-report '
|
|
588
|
+
'<Result Path>" so the markdown sibling is rendered before you return.'
|
|
589
|
+
),
|
|
590
|
+
"Do not return the report inline.",
|
|
591
|
+
"Copy Report Language verbatim into data.json.meta.reportLanguage.",
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _mcp_pointer_line() -> str:
|
|
596
|
+
return (
|
|
597
|
+
'**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
|
|
598
|
+
"section (already in your Required reading)."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _base_prompt_headers(
|
|
603
|
+
*,
|
|
604
|
+
project_root: Path,
|
|
605
|
+
manifest: Mapping[str, Any],
|
|
606
|
+
active_context: Mapping[str, Any],
|
|
607
|
+
worker_id: str,
|
|
608
|
+
prompt_rel: str,
|
|
609
|
+
result_rel: str,
|
|
610
|
+
) -> list[str]:
|
|
611
|
+
prompt_path = _resolve_project_path(project_root, prompt_rel)
|
|
612
|
+
return [
|
|
613
|
+
f"**Project Root:** {project_root}",
|
|
614
|
+
f"**Prompt History Path:** {prompt_rel}",
|
|
615
|
+
f"**Result Path:** {result_rel}",
|
|
616
|
+
f"Assigned worker prompt history path: {prompt_path}",
|
|
617
|
+
f"**Worker Preamble Path:** {WORKER_PREAMBLE_PATH}",
|
|
618
|
+
f"**Errors log path:** {_errors_log_path(project_root, manifest, active_context)}",
|
|
619
|
+
(
|
|
620
|
+
f"**Errors sidecar path:** "
|
|
621
|
+
f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
|
|
622
|
+
),
|
|
623
|
+
]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _analysis_input_lines(
|
|
627
|
+
manifest: Mapping[str, Any],
|
|
628
|
+
active_context: Mapping[str, Any],
|
|
629
|
+
) -> list[str]:
|
|
630
|
+
inputs = [
|
|
631
|
+
("Primary analysis packet", _instruction_path(manifest, active_context, "analysisPacketPath")),
|
|
632
|
+
("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
|
|
633
|
+
("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
|
|
634
|
+
("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
|
|
635
|
+
("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
|
|
636
|
+
("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
637
|
+
]
|
|
638
|
+
return _existing_input_lines(inputs)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _report_writer_input_lines(
|
|
642
|
+
manifest: Mapping[str, Any],
|
|
643
|
+
active_context: Mapping[str, Any],
|
|
644
|
+
team_state: Mapping[str, Any],
|
|
645
|
+
) -> list[str]:
|
|
646
|
+
inputs = [
|
|
647
|
+
("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
|
|
648
|
+
("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
|
|
649
|
+
("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
|
|
650
|
+
("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
|
|
651
|
+
("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
652
|
+
("Final report template", _instruction_path(manifest, active_context, "finalReportTemplatePath")),
|
|
653
|
+
("Final report schema", _instruction_path(manifest, active_context, "finalReportSchemaPath")),
|
|
654
|
+
("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
|
|
655
|
+
]
|
|
656
|
+
lines = _existing_input_lines(inputs)
|
|
657
|
+
lines.extend(_analysis_worker_result_lines(team_state))
|
|
658
|
+
return lines or ["- No report-writer inputs were recorded in active-run-context."]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
|
|
662
|
+
lines = [f"- {label}: `{path}`" for label, path in inputs if path]
|
|
663
|
+
return lines or ["- No input paths were recorded in active-run-context."]
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
|
|
667
|
+
lines = []
|
|
668
|
+
workers = team_state.get("workers")
|
|
669
|
+
if not isinstance(workers, list):
|
|
670
|
+
return lines
|
|
671
|
+
for worker in workers:
|
|
672
|
+
if not isinstance(worker, Mapping):
|
|
673
|
+
continue
|
|
674
|
+
worker_id = worker.get("workerId")
|
|
675
|
+
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
676
|
+
continue
|
|
677
|
+
result_path = _string_value(worker.get("resultPath"))
|
|
678
|
+
if result_path:
|
|
679
|
+
lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
|
|
680
|
+
return lines
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _instruction_path(
|
|
684
|
+
manifest: Mapping[str, Any],
|
|
685
|
+
active_context: Mapping[str, Any],
|
|
686
|
+
key: str,
|
|
687
|
+
) -> str:
|
|
688
|
+
instruction_set = active_context.get("instructionSet")
|
|
689
|
+
if isinstance(instruction_set, Mapping):
|
|
690
|
+
value = _string_value(instruction_set.get(key))
|
|
691
|
+
if value:
|
|
692
|
+
return value
|
|
693
|
+
return _string_value(manifest.get(key))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _analysis_pane_role(
|
|
697
|
+
manifest: Mapping[str, Any],
|
|
698
|
+
active_context: Mapping[str, Any],
|
|
699
|
+
worker_id: str,
|
|
700
|
+
) -> str:
|
|
701
|
+
if _require_string(manifest, "taskType") != "implementation":
|
|
702
|
+
return "worker"
|
|
703
|
+
if _executor_provider(manifest) != worker_id:
|
|
704
|
+
return "worker"
|
|
705
|
+
worktree = _worktree_path(manifest, active_context)
|
|
706
|
+
if not worktree:
|
|
707
|
+
raise DispatchError("implementation executor prompt generation requires a worktree path")
|
|
708
|
+
return "executor"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _executor_provider(manifest: Mapping[str, Any]) -> str:
|
|
712
|
+
team_contract = manifest.get("teamContract")
|
|
713
|
+
if not isinstance(team_contract, Mapping):
|
|
714
|
+
return ""
|
|
715
|
+
executor = team_contract.get("executor")
|
|
716
|
+
if not isinstance(executor, Mapping):
|
|
717
|
+
return ""
|
|
718
|
+
return _string_value(executor.get("provider"))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _worktree_headers(
|
|
722
|
+
manifest: Mapping[str, Any],
|
|
723
|
+
active_context: Mapping[str, Any],
|
|
724
|
+
role: str,
|
|
725
|
+
) -> list[str]:
|
|
726
|
+
task_type = _require_string(manifest, "taskType")
|
|
727
|
+
worktree = _worktree_path(manifest, active_context)
|
|
728
|
+
if task_type not in {"implementation", "final-verification"} or not worktree:
|
|
729
|
+
return []
|
|
730
|
+
headers = [f"**Worktree:** {worktree}"]
|
|
731
|
+
if role == "executor":
|
|
732
|
+
headers.append(f"cwd for every mutating command: {worktree}")
|
|
733
|
+
return headers
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _implementation_executor_tail(
|
|
737
|
+
manifest: Mapping[str, Any],
|
|
738
|
+
workspace_root: Path,
|
|
739
|
+
role: str,
|
|
740
|
+
) -> str:
|
|
741
|
+
if role != "executor" or _require_string(manifest, "taskType") != "implementation":
|
|
742
|
+
return ""
|
|
743
|
+
preflight_path = workspace_root / "prompts" / "profiles" / "_coding-conventions-preflight.md"
|
|
744
|
+
if not preflight_path.is_file():
|
|
745
|
+
return "# Coding-conventions preflight\n\nApply project-local conventions before editing."
|
|
746
|
+
return preflight_path.read_text(encoding="utf-8")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _errors_log_path(
|
|
750
|
+
project_root: Path,
|
|
751
|
+
manifest: Mapping[str, Any],
|
|
752
|
+
active_context: Mapping[str, Any],
|
|
753
|
+
) -> Path:
|
|
754
|
+
error_logs = active_context.get("errorLogs")
|
|
755
|
+
if isinstance(error_logs, Mapping):
|
|
756
|
+
value = _string_value(error_logs.get("runErrorsLogPath"))
|
|
757
|
+
if value:
|
|
758
|
+
return _resolve_project_path(project_root, value)
|
|
759
|
+
run_dir = _run_directory_path(project_root, manifest)
|
|
760
|
+
return run_dir / "logs" / f"errors-{_require_string(manifest, 'taskType')}-{_sequence(manifest, 'state')}.jsonl"
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _worker_errors_sidecar_path(
|
|
764
|
+
project_root: Path,
|
|
765
|
+
manifest: Mapping[str, Any],
|
|
766
|
+
active_context: Mapping[str, Any],
|
|
767
|
+
worker_id: str,
|
|
768
|
+
) -> Path:
|
|
769
|
+
error_logs = active_context.get("errorLogs")
|
|
770
|
+
if isinstance(error_logs, Mapping):
|
|
771
|
+
sidecars = error_logs.get("sidecarsByWorkerId")
|
|
772
|
+
if isinstance(sidecars, Mapping):
|
|
773
|
+
value = _string_value(sidecars.get(worker_id))
|
|
774
|
+
if value:
|
|
775
|
+
return _resolve_project_path(project_root, value)
|
|
776
|
+
run_dir = _run_directory_path(project_root, manifest)
|
|
777
|
+
task_type = _require_string(manifest, "taskType")
|
|
778
|
+
seq = _sequence(manifest, "workerResults")
|
|
779
|
+
return run_dir / "worker-results" / f"{worker_id}-worker-errors-{task_type}-{seq}.json"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _run_directory_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
|
|
783
|
+
value = _string_value(manifest.get("runDirectoryPath"))
|
|
784
|
+
if value:
|
|
785
|
+
return _resolve_project_path(project_root, value)
|
|
786
|
+
return _resolve_required_path(project_root, manifest, "teamStatePath", "team-state").parent.parent
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _sequence(manifest: Mapping[str, Any], key: str) -> str:
|
|
790
|
+
seqs = manifest.get("runSequencesByCategory")
|
|
791
|
+
if not isinstance(seqs, Mapping):
|
|
792
|
+
return _run_seq(manifest)
|
|
793
|
+
return _string_value(seqs.get(key)) or _run_seq(manifest)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _project_relative_path(project_root: Path, path: Path) -> str:
|
|
797
|
+
try:
|
|
798
|
+
return path.relative_to(project_root).as_posix()
|
|
799
|
+
except ValueError:
|
|
800
|
+
return str(path)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _resolve_report_language(
|
|
804
|
+
project_root: Path,
|
|
805
|
+
manifest: Mapping[str, Any],
|
|
806
|
+
active_context: Mapping[str, Any],
|
|
807
|
+
) -> str:
|
|
808
|
+
for value in _report_language_candidates(project_root):
|
|
809
|
+
if value == "auto":
|
|
810
|
+
return _infer_report_language(project_root, manifest, active_context)
|
|
811
|
+
if value:
|
|
812
|
+
return value
|
|
813
|
+
return _infer_report_language(project_root, manifest, active_context)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _report_language_candidates(project_root: Path) -> list[str]:
|
|
817
|
+
okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
|
|
818
|
+
return [
|
|
819
|
+
_read_report_language(project_root / ".okstra" / "project.json", "project"),
|
|
820
|
+
_read_report_language(okstra_home / "config.json", "global"),
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _read_report_language(path: Path, label: str) -> str:
|
|
825
|
+
if not path.is_file():
|
|
826
|
+
return ""
|
|
827
|
+
value = _load_json_object(path, f"{label} config").get("reportLanguage")
|
|
828
|
+
if value in (None, ""):
|
|
829
|
+
return ""
|
|
830
|
+
if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
|
|
831
|
+
return value
|
|
832
|
+
raise DispatchError(f"{label} config reportLanguage must be en, ko, or auto: {path}")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _infer_report_language(
|
|
836
|
+
project_root: Path,
|
|
837
|
+
manifest: Mapping[str, Any],
|
|
838
|
+
active_context: Mapping[str, Any],
|
|
839
|
+
) -> str:
|
|
840
|
+
task_brief = _instruction_path(manifest, active_context, "taskBriefPath")
|
|
841
|
+
if not task_brief:
|
|
842
|
+
return "en"
|
|
843
|
+
path = _resolve_project_path(project_root, task_brief)
|
|
844
|
+
if not path.is_file():
|
|
845
|
+
return "en"
|
|
846
|
+
text = path.read_text(encoding="utf-8", errors="replace")[:16000]
|
|
847
|
+
hangul_count = sum(1 for char in text if "\uac00" <= char <= "\ud7a3")
|
|
848
|
+
return "ko" if hangul_count >= 10 else "en"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _string_value(value: Any) -> str:
|
|
852
|
+
if not isinstance(value, str):
|
|
853
|
+
return ""
|
|
854
|
+
return value.strip()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
|
|
858
|
+
if manifest.get("leadRuntime") != "codex":
|
|
859
|
+
raise DispatchError(f"run manifest is not a Codex lead run: {path}")
|
|
860
|
+
if not isinstance(manifest.get("leadEventsPath"), str):
|
|
861
|
+
raise DispatchError(f"run manifest has no leadEventsPath: {path}")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _select_workers(
|
|
865
|
+
manifest: Mapping[str, Any],
|
|
866
|
+
requested_workers: Sequence[str],
|
|
867
|
+
*,
|
|
868
|
+
enable_codex_report_writer: bool,
|
|
869
|
+
) -> list[str]:
|
|
870
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
871
|
+
if not recommended:
|
|
872
|
+
raise DispatchError("run manifest has no recommendedWorkers entries")
|
|
873
|
+
supported_workers = _supported_codex_dispatch_workers(
|
|
874
|
+
enable_codex_report_writer=enable_codex_report_writer
|
|
875
|
+
)
|
|
876
|
+
if not requested_workers:
|
|
877
|
+
selected = [worker for worker in recommended if worker in supported_workers]
|
|
878
|
+
if selected:
|
|
879
|
+
return selected
|
|
880
|
+
if REPORT_WRITER_WORKER_ID in recommended and not enable_codex_report_writer:
|
|
881
|
+
raise DispatchError(
|
|
882
|
+
"report-writer requires --enable-codex-report-writer because it "
|
|
883
|
+
"runs through the Codex wrapper instead of Claude Agent"
|
|
884
|
+
)
|
|
885
|
+
supported = ", ".join(sorted(supported_workers))
|
|
886
|
+
raise DispatchError(
|
|
887
|
+
"run roster has no Codex-dispatch supported worker(s) "
|
|
888
|
+
f"(CLI dispatcher supports: {supported})"
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
selected = list(requested_workers)
|
|
892
|
+
unknown = [worker for worker in selected if worker not in recommended]
|
|
893
|
+
if unknown:
|
|
894
|
+
raise DispatchError(
|
|
895
|
+
"requested worker(s) are not in this run roster: " + ", ".join(unknown)
|
|
896
|
+
)
|
|
897
|
+
if REPORT_WRITER_WORKER_ID in selected and not enable_codex_report_writer:
|
|
898
|
+
raise DispatchError(
|
|
899
|
+
"report-writer requires --enable-codex-report-writer because it "
|
|
900
|
+
"runs through the Codex wrapper instead of Claude Agent"
|
|
901
|
+
)
|
|
902
|
+
unsupported = [
|
|
903
|
+
worker for worker in selected
|
|
904
|
+
if worker not in supported_workers
|
|
905
|
+
]
|
|
906
|
+
if unsupported:
|
|
907
|
+
supported = ", ".join(sorted(supported_workers))
|
|
908
|
+
raise DispatchError(
|
|
909
|
+
"unsupported Codex lead worker(s): "
|
|
910
|
+
+ ", ".join(unsupported)
|
|
911
|
+
+ f" (CLI dispatcher supports: {supported})"
|
|
912
|
+
)
|
|
913
|
+
return selected
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _supported_codex_dispatch_workers(*, enable_codex_report_writer: bool) -> set[str]:
|
|
917
|
+
supported = set(SUPPORTED_CLI_WORKERS)
|
|
918
|
+
if enable_codex_report_writer:
|
|
919
|
+
supported.add(REPORT_WRITER_WORKER_ID)
|
|
920
|
+
return supported
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _skipped_worker_reasons(
|
|
924
|
+
manifest: Mapping[str, Any],
|
|
925
|
+
selected_workers: Sequence[str],
|
|
926
|
+
requested_workers: Sequence[str],
|
|
927
|
+
*,
|
|
928
|
+
enable_codex_report_writer: bool,
|
|
929
|
+
) -> dict[str, str]:
|
|
930
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
931
|
+
selected = set(selected_workers)
|
|
932
|
+
if requested_workers:
|
|
933
|
+
return {
|
|
934
|
+
worker_id: "skipped by Codex dispatch: worker was not requested in this invocation"
|
|
935
|
+
for worker_id in recommended
|
|
936
|
+
if worker_id not in selected
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
supported = _supported_codex_dispatch_workers(
|
|
940
|
+
enable_codex_report_writer=enable_codex_report_writer
|
|
941
|
+
)
|
|
942
|
+
reasons = {}
|
|
943
|
+
for worker_id in recommended:
|
|
944
|
+
if worker_id in selected:
|
|
945
|
+
continue
|
|
946
|
+
if worker_id == REPORT_WRITER_WORKER_ID and not enable_codex_report_writer:
|
|
947
|
+
reasons[worker_id] = (
|
|
948
|
+
"skipped by Codex dispatch default: report-writer requires "
|
|
949
|
+
"--enable-codex-report-writer and --report-writer-codex-model"
|
|
950
|
+
)
|
|
951
|
+
elif worker_id not in supported:
|
|
952
|
+
reasons[worker_id] = (
|
|
953
|
+
"skipped by Codex dispatch default: worker is not supported by "
|
|
954
|
+
"the Codex CLI dispatcher"
|
|
955
|
+
)
|
|
956
|
+
else:
|
|
957
|
+
reasons[worker_id] = "skipped by Codex dispatch default"
|
|
958
|
+
return reasons
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _mark_skipped_workers(
|
|
962
|
+
team_state_path: Path,
|
|
963
|
+
team_state: Mapping[str, Any],
|
|
964
|
+
reasons_by_worker_id: Mapping[str, str],
|
|
965
|
+
) -> None:
|
|
966
|
+
if not reasons_by_worker_id:
|
|
967
|
+
return
|
|
968
|
+
workers = team_state.get("workers")
|
|
969
|
+
if not isinstance(workers, list):
|
|
970
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
971
|
+
changed = False
|
|
972
|
+
for worker in workers:
|
|
973
|
+
if not isinstance(worker, dict):
|
|
974
|
+
continue
|
|
975
|
+
worker_id = _string_value(worker.get("workerId"))
|
|
976
|
+
reason = reasons_by_worker_id.get(worker_id)
|
|
977
|
+
if not reason:
|
|
978
|
+
continue
|
|
979
|
+
status = _string_value(worker.get("status")) or "not-run"
|
|
980
|
+
current_reason = _string_value(worker.get("reason"))
|
|
981
|
+
if status == "not-run" and not current_reason:
|
|
982
|
+
worker["status"] = "not-run"
|
|
983
|
+
worker["reason"] = reason
|
|
984
|
+
changed = True
|
|
985
|
+
if changed:
|
|
986
|
+
_write_json(team_state_path, dict(team_state))
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _resolve_wrapper(
|
|
990
|
+
worker_id: str,
|
|
991
|
+
workspace_root: Path,
|
|
992
|
+
okstra_bin: Path | None,
|
|
993
|
+
) -> Path:
|
|
994
|
+
script = SUPPORTED_CLI_WORKERS[worker_id]
|
|
995
|
+
candidates = []
|
|
996
|
+
if okstra_bin is not None:
|
|
997
|
+
candidates.append(okstra_bin / script)
|
|
998
|
+
candidates.extend([
|
|
999
|
+
workspace_root / "bin" / script,
|
|
1000
|
+
workspace_root / "scripts" / script,
|
|
1001
|
+
])
|
|
1002
|
+
for candidate in candidates:
|
|
1003
|
+
if candidate.is_file():
|
|
1004
|
+
return candidate.resolve()
|
|
1005
|
+
searched = ", ".join(str(candidate) for candidate in candidates)
|
|
1006
|
+
raise DispatchError(f"{script} not found (searched: {searched})")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _run_worker(
|
|
1010
|
+
plan: DispatchPlan,
|
|
1011
|
+
worker: WorkerDispatch,
|
|
1012
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1013
|
+
if worker.backend == BACKEND_CLI_WRAPPER:
|
|
1014
|
+
return _run_cli_wrapper_worker(plan, worker)
|
|
1015
|
+
raise DispatchError(f"unsupported Codex worker backend: {worker.backend}")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _run_cli_wrapper_worker(
|
|
1019
|
+
plan: DispatchPlan,
|
|
1020
|
+
worker: WorkerDispatch,
|
|
1021
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1022
|
+
env = {
|
|
1023
|
+
**os.environ,
|
|
1024
|
+
"OKSTRA_WORKER_ID": worker.worker_id,
|
|
1025
|
+
"OKSTRA_WORKER_RESULT_PATH": str(worker.result_path),
|
|
1026
|
+
"OKSTRA_WORKER_AUDIT_PATH": str(worker.worker_result_path),
|
|
1027
|
+
"OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
|
|
1028
|
+
}
|
|
1029
|
+
if worker.worker_id == REPORT_WRITER_WORKER_ID:
|
|
1030
|
+
env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(
|
|
1031
|
+
_final_report_markdown_path(worker.result_path)
|
|
1032
|
+
)
|
|
1033
|
+
return subprocess.run(worker.command, cwd=plan.project_root, env=env, text=True)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _post_process_report_writer_result(
|
|
1037
|
+
plan: DispatchPlan,
|
|
1038
|
+
worker: WorkerDispatch,
|
|
1039
|
+
) -> dict[str, Any]:
|
|
1040
|
+
if worker.worker_id != REPORT_WRITER_WORKER_ID:
|
|
1041
|
+
return {"ok": True, "reason": "", "steps": []}
|
|
1042
|
+
steps = []
|
|
1043
|
+
try:
|
|
1044
|
+
commands = _report_post_process_commands(plan, worker)
|
|
1045
|
+
except DispatchError as exc:
|
|
1046
|
+
return {"ok": False, "reason": str(exc), "steps": steps}
|
|
1047
|
+
for name, command in commands:
|
|
1048
|
+
if name == "validate-run":
|
|
1049
|
+
_set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
|
|
1050
|
+
result = subprocess.run(
|
|
1051
|
+
command,
|
|
1052
|
+
cwd=plan.project_root,
|
|
1053
|
+
text=True,
|
|
1054
|
+
capture_output=True,
|
|
1055
|
+
)
|
|
1056
|
+
step = _post_process_step_payload(name, command, result)
|
|
1057
|
+
steps.append(step)
|
|
1058
|
+
if result.returncode != 0:
|
|
1059
|
+
return {
|
|
1060
|
+
"ok": False,
|
|
1061
|
+
"reason": f"{name} failed with exit code {result.returncode}",
|
|
1062
|
+
"steps": steps,
|
|
1063
|
+
}
|
|
1064
|
+
return {"ok": True, "reason": "", "steps": steps}
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _report_post_process_commands(
|
|
1068
|
+
plan: DispatchPlan,
|
|
1069
|
+
worker: WorkerDispatch,
|
|
1070
|
+
) -> list[tuple[str, list[str]]]:
|
|
1071
|
+
markdown_path = _final_report_markdown_path(worker.result_path)
|
|
1072
|
+
commands = [
|
|
1073
|
+
(
|
|
1074
|
+
"token-usage",
|
|
1075
|
+
[
|
|
1076
|
+
sys.executable,
|
|
1077
|
+
str(_resolve_workspace_script(plan.workspace_root, "okstra-token-usage.py")),
|
|
1078
|
+
str(plan.team_state_path),
|
|
1079
|
+
"--project-root",
|
|
1080
|
+
str(plan.project_root),
|
|
1081
|
+
"--write",
|
|
1082
|
+
"--substitute-data",
|
|
1083
|
+
str(worker.result_path),
|
|
1084
|
+
],
|
|
1085
|
+
),
|
|
1086
|
+
(
|
|
1087
|
+
"render-views",
|
|
1088
|
+
[
|
|
1089
|
+
sys.executable,
|
|
1090
|
+
str(_resolve_workspace_script(plan.workspace_root, "okstra-render-report-views.py")),
|
|
1091
|
+
str(markdown_path),
|
|
1092
|
+
"--task-key",
|
|
1093
|
+
_require_string(plan.manifest, "taskKey"),
|
|
1094
|
+
"--task-type",
|
|
1095
|
+
_require_string(plan.manifest, "taskType"),
|
|
1096
|
+
"--seq",
|
|
1097
|
+
_run_seq(plan.manifest),
|
|
1098
|
+
"--source-report",
|
|
1099
|
+
_project_relative_path(plan.project_root, markdown_path),
|
|
1100
|
+
],
|
|
1101
|
+
),
|
|
1102
|
+
(
|
|
1103
|
+
"spawn-followups",
|
|
1104
|
+
[
|
|
1105
|
+
sys.executable,
|
|
1106
|
+
str(_resolve_workspace_script(plan.workspace_root, "okstra-spawn-followups.py")),
|
|
1107
|
+
str(worker.result_path),
|
|
1108
|
+
"--project-root",
|
|
1109
|
+
str(plan.project_root),
|
|
1110
|
+
"--task-group",
|
|
1111
|
+
_task_group(plan.manifest),
|
|
1112
|
+
"--parent-task-key",
|
|
1113
|
+
_require_string(plan.manifest, "taskKey"),
|
|
1114
|
+
],
|
|
1115
|
+
),
|
|
1116
|
+
]
|
|
1117
|
+
commands.append(("validate-run", _validate_run_command(plan, markdown_path)))
|
|
1118
|
+
return commands
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _validate_run_command(plan: DispatchPlan, markdown_path: Path) -> list[str]:
|
|
1122
|
+
task_manifest_path = _task_manifest_path(plan.project_root, plan.manifest)
|
|
1123
|
+
command = [
|
|
1124
|
+
sys.executable,
|
|
1125
|
+
str(_resolve_workspace_validator(plan.workspace_root, "validate-run.py")),
|
|
1126
|
+
"--team-state",
|
|
1127
|
+
str(plan.team_state_path),
|
|
1128
|
+
"--report",
|
|
1129
|
+
str(markdown_path),
|
|
1130
|
+
"--run-manifest",
|
|
1131
|
+
str(plan.manifest_path),
|
|
1132
|
+
"--task-manifest",
|
|
1133
|
+
str(task_manifest_path),
|
|
1134
|
+
]
|
|
1135
|
+
final_status_path = _resolve_optional_path(
|
|
1136
|
+
plan.project_root, plan.manifest.get("finalStatusPath")
|
|
1137
|
+
)
|
|
1138
|
+
if final_status_path is not None:
|
|
1139
|
+
command.extend(["--final-status", str(final_status_path)])
|
|
1140
|
+
return command
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
|
|
1144
|
+
value = _string_value(manifest.get("taskManifestPath"))
|
|
1145
|
+
if value:
|
|
1146
|
+
return _resolve_project_path(project_root, value)
|
|
1147
|
+
task_root = _string_value(manifest.get("taskRootPath"))
|
|
1148
|
+
if task_root:
|
|
1149
|
+
return _resolve_project_path(project_root, task_root) / "task-manifest.json"
|
|
1150
|
+
return (
|
|
1151
|
+
task_dir(project_root, _task_group(manifest), _task_id(manifest))
|
|
1152
|
+
/ "task-manifest.json"
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _post_process_step_payload(
|
|
1157
|
+
name: str,
|
|
1158
|
+
command: Sequence[str],
|
|
1159
|
+
result: subprocess.CompletedProcess[str],
|
|
1160
|
+
) -> dict[str, Any]:
|
|
1161
|
+
return {
|
|
1162
|
+
"name": name,
|
|
1163
|
+
"command": list(command),
|
|
1164
|
+
"exitCode": result.returncode,
|
|
1165
|
+
"stdoutTail": _tail(result.stdout),
|
|
1166
|
+
"stderrTail": _tail(result.stderr),
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
|
|
1171
|
+
candidates = [
|
|
1172
|
+
workspace_root / "scripts" / script_name,
|
|
1173
|
+
workspace_root / script_name,
|
|
1174
|
+
]
|
|
1175
|
+
for candidate in candidates:
|
|
1176
|
+
if candidate.is_file():
|
|
1177
|
+
return candidate.resolve()
|
|
1178
|
+
searched = ", ".join(str(candidate) for candidate in candidates)
|
|
1179
|
+
raise DispatchError(f"{script_name} not found (searched: {searched})")
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
|
|
1183
|
+
candidates = [
|
|
1184
|
+
workspace_root / "validators" / validator_name,
|
|
1185
|
+
workspace_root / validator_name,
|
|
1186
|
+
]
|
|
1187
|
+
for candidate in candidates:
|
|
1188
|
+
if candidate.is_file():
|
|
1189
|
+
return candidate.resolve()
|
|
1190
|
+
searched = ", ".join(str(candidate) for candidate in candidates)
|
|
1191
|
+
raise DispatchError(f"{validator_name} not found (searched: {searched})")
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _task_group(manifest: Mapping[str, Any]) -> str:
|
|
1195
|
+
value = _string_value(manifest.get("taskGroup"))
|
|
1196
|
+
if value:
|
|
1197
|
+
return value
|
|
1198
|
+
task_key = _require_string(manifest, "taskKey")
|
|
1199
|
+
colon_parts = task_key.split(":")
|
|
1200
|
+
if len(colon_parts) >= 3 and colon_parts[-2].strip():
|
|
1201
|
+
return colon_parts[-2].strip()
|
|
1202
|
+
slash_parts = task_key.split("/")
|
|
1203
|
+
if len(slash_parts) >= 2 and slash_parts[0].strip():
|
|
1204
|
+
return slash_parts[0].strip()
|
|
1205
|
+
raise DispatchError(f"cannot infer task group from taskKey: {task_key}")
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _task_id(manifest: Mapping[str, Any]) -> str:
|
|
1209
|
+
value = _string_value(manifest.get("taskId"))
|
|
1210
|
+
if value:
|
|
1211
|
+
return value
|
|
1212
|
+
task_key = _require_string(manifest, "taskKey")
|
|
1213
|
+
colon_parts = task_key.split(":")
|
|
1214
|
+
if len(colon_parts) >= 3 and colon_parts[-1].strip():
|
|
1215
|
+
return colon_parts[-1].strip()
|
|
1216
|
+
slash_parts = task_key.split("/")
|
|
1217
|
+
if len(slash_parts) >= 2 and slash_parts[-1].strip():
|
|
1218
|
+
return slash_parts[-1].strip()
|
|
1219
|
+
raise DispatchError(f"cannot infer task id from taskKey: {task_key}")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _tail(text: str, *, limit: int = 4000) -> str:
|
|
1223
|
+
if len(text) <= limit:
|
|
1224
|
+
return text
|
|
1225
|
+
return text[-limit:]
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _append_event(
|
|
1229
|
+
plan: DispatchPlan,
|
|
1230
|
+
event_type: str,
|
|
1231
|
+
details: Mapping[str, Any],
|
|
1232
|
+
) -> None:
|
|
1233
|
+
append_lead_event(
|
|
1234
|
+
plan.lead_events_path,
|
|
1235
|
+
LeadEvent(
|
|
1236
|
+
event_type=event_type,
|
|
1237
|
+
lead_runtime="codex",
|
|
1238
|
+
task_key=_require_string(plan.manifest, "taskKey"),
|
|
1239
|
+
task_type=_require_string(plan.manifest, "taskType"),
|
|
1240
|
+
run_seq=_run_seq(plan.manifest),
|
|
1241
|
+
timestamp=_utc_now(),
|
|
1242
|
+
details=details,
|
|
1243
|
+
),
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _dispatch_mode(workers: Sequence[WorkerDispatch]) -> str:
|
|
1248
|
+
backends = {worker.backend for worker in workers}
|
|
1249
|
+
if len(backends) == 1:
|
|
1250
|
+
return next(iter(backends))
|
|
1251
|
+
return BACKEND_MIXED
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
|
|
1255
|
+
return {
|
|
1256
|
+
"dispatchMode": worker.backend,
|
|
1257
|
+
"workerId": worker.worker_id,
|
|
1258
|
+
"promptPath": str(worker.prompt_path),
|
|
1259
|
+
"resultPath": str(worker.result_path),
|
|
1260
|
+
"workerResultPath": str(worker.worker_result_path),
|
|
1261
|
+
"wrapperPath": str(worker.wrapper_path),
|
|
1262
|
+
"role": worker.role,
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _attempt_event_details(worker: WorkerDispatch, attempt: int) -> dict[str, Any]:
|
|
1267
|
+
return {
|
|
1268
|
+
**_event_details(worker),
|
|
1269
|
+
"attempt": attempt,
|
|
1270
|
+
"maxAttempts": MAX_WORKER_ATTEMPTS,
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _should_retry_missing_result(
|
|
1275
|
+
result: subprocess.CompletedProcess[str],
|
|
1276
|
+
missing_paths: Sequence[Path],
|
|
1277
|
+
attempt: int,
|
|
1278
|
+
) -> bool:
|
|
1279
|
+
return (
|
|
1280
|
+
result.returncode == 0
|
|
1281
|
+
and bool(missing_paths)
|
|
1282
|
+
and attempt < MAX_WORKER_ATTEMPTS
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _missing_completion_paths(worker: WorkerDispatch) -> list[Path]:
|
|
1287
|
+
return [path for path in worker.completion_paths if not path.is_file()]
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _set_worker_status(
|
|
1291
|
+
team_state_path: Path,
|
|
1292
|
+
worker_id: str,
|
|
1293
|
+
status: str,
|
|
1294
|
+
reason: str,
|
|
1295
|
+
*,
|
|
1296
|
+
model_execution_value: str = "",
|
|
1297
|
+
) -> None:
|
|
1298
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
1299
|
+
workers = payload.get("workers")
|
|
1300
|
+
if not isinstance(workers, list):
|
|
1301
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
1302
|
+
for worker in workers:
|
|
1303
|
+
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
1304
|
+
worker["status"] = status
|
|
1305
|
+
worker["reason"] = reason
|
|
1306
|
+
if model_execution_value:
|
|
1307
|
+
worker["model"] = model_execution_value
|
|
1308
|
+
worker["modelExecutionValue"] = model_execution_value
|
|
1309
|
+
_write_json(team_state_path, payload)
|
|
1310
|
+
return
|
|
1311
|
+
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
1315
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
1316
|
+
payload["dispatchMode"] = dispatch_mode
|
|
1317
|
+
_write_json(team_state_path, payload)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
1321
|
+
workers = team_state.get("workers")
|
|
1322
|
+
if not isinstance(workers, list):
|
|
1323
|
+
raise DispatchError("team-state workers must be an array")
|
|
1324
|
+
for worker in workers:
|
|
1325
|
+
if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
|
|
1326
|
+
return worker
|
|
1327
|
+
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
|
|
1331
|
+
paths = manifest.get("workerPromptPathByWorkerId")
|
|
1332
|
+
if not isinstance(paths, Mapping):
|
|
1333
|
+
raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
|
|
1334
|
+
return _require_string(paths, worker_id)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _worktree_path(
|
|
1338
|
+
manifest: Mapping[str, Any],
|
|
1339
|
+
active_context: Mapping[str, Any],
|
|
1340
|
+
) -> str:
|
|
1341
|
+
if manifest.get("taskType") not in {"implementation", "final-verification"}:
|
|
1342
|
+
return ""
|
|
1343
|
+
worktree = active_context.get("executorWorktree")
|
|
1344
|
+
if not isinstance(worktree, Mapping):
|
|
1345
|
+
return ""
|
|
1346
|
+
return str(worktree.get("path") or "")
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _pane_role(prompt_path: Path) -> str:
|
|
1350
|
+
if not prompt_path.is_file():
|
|
1351
|
+
raise DispatchError(f"worker prompt not found: {prompt_path}")
|
|
1352
|
+
marker = "**Pane role:**"
|
|
1353
|
+
for line in prompt_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
1354
|
+
if line.startswith(marker):
|
|
1355
|
+
role = line[len(marker):].strip()
|
|
1356
|
+
return role or "worker"
|
|
1357
|
+
return "worker"
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
1361
|
+
if not path.is_file():
|
|
1362
|
+
raise DispatchError(f"{label} not found: {path}")
|
|
1363
|
+
try:
|
|
1364
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1365
|
+
except json.JSONDecodeError as exc:
|
|
1366
|
+
raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
|
|
1367
|
+
if not isinstance(payload, dict):
|
|
1368
|
+
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
1369
|
+
return payload
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
|
|
1373
|
+
if path is None or not path.is_file():
|
|
1374
|
+
return {}
|
|
1375
|
+
return _load_json_object(path, "active-run-context")
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _resolve_required_path(
|
|
1379
|
+
project_root: Path,
|
|
1380
|
+
manifest: Mapping[str, Any],
|
|
1381
|
+
key: str,
|
|
1382
|
+
label: str,
|
|
1383
|
+
) -> Path:
|
|
1384
|
+
return _resolve_project_path(project_root, _require_string(manifest, key))
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _resolve_optional_path(project_root: Path, value: Any) -> Path | None:
|
|
1388
|
+
if not isinstance(value, str) or not value.strip():
|
|
1389
|
+
return None
|
|
1390
|
+
return _resolve_project_path(project_root, value)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _resolve_project_path(project_root: Path, value: str) -> Path:
|
|
1394
|
+
path = Path(value)
|
|
1395
|
+
return path if path.is_absolute() else project_root / path
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
1399
|
+
value = payload.get(key)
|
|
1400
|
+
if not isinstance(value, str) or not value.strip():
|
|
1401
|
+
raise DispatchError(f"missing required string field: {key}")
|
|
1402
|
+
return value
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _string_list(value: Any) -> list[str]:
|
|
1406
|
+
if not isinstance(value, list):
|
|
1407
|
+
return []
|
|
1408
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _run_seq(manifest: Mapping[str, Any]) -> str:
|
|
1412
|
+
seqs = manifest.get("runSequencesByCategory")
|
|
1413
|
+
if not isinstance(seqs, Mapping):
|
|
1414
|
+
raise DispatchError("run manifest has no runSequencesByCategory object")
|
|
1415
|
+
return _require_string(seqs, "manifests")
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
|
|
1419
|
+
if exit_code != 0:
|
|
1420
|
+
return f"wrapper exited with code {exit_code}"
|
|
1421
|
+
if missing_paths:
|
|
1422
|
+
missing = ", ".join(str(path) for path in missing_paths)
|
|
1423
|
+
return f"required worker artifact was not produced: {missing}"
|
|
1424
|
+
return "worker dispatch failed"
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _final_report_data_path(markdown_path: Path) -> Path:
|
|
1428
|
+
if markdown_path.name.endswith(".md"):
|
|
1429
|
+
return markdown_path.with_name(markdown_path.name[:-3] + ".data.json")
|
|
1430
|
+
return markdown_path.with_suffix(".data.json")
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _final_report_markdown_path(data_json_path: Path) -> Path:
|
|
1434
|
+
if data_json_path.name.endswith(".data.json"):
|
|
1435
|
+
return data_json_path.with_name(data_json_path.name[:-10] + ".md")
|
|
1436
|
+
return data_json_path.with_suffix(".md")
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _replace_report_writer_model_line(
|
|
1440
|
+
prompt_path: Path,
|
|
1441
|
+
model_execution_value: str,
|
|
1442
|
+
) -> None:
|
|
1443
|
+
if not prompt_path.is_file():
|
|
1444
|
+
raise DispatchError(f"report-writer prompt not found: {prompt_path}")
|
|
1445
|
+
lines = prompt_path.read_text(encoding="utf-8").splitlines()
|
|
1446
|
+
marker = "**Model:** Report writer worker,"
|
|
1447
|
+
replacement = f"{marker} {model_execution_value}"
|
|
1448
|
+
updated = [
|
|
1449
|
+
replacement if line.startswith(marker) else line
|
|
1450
|
+
for line in lines
|
|
1451
|
+
]
|
|
1452
|
+
if updated != lines:
|
|
1453
|
+
prompt_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _parse_workers(raw: str) -> list[str]:
|
|
1457
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _parse_idle_timeout(raw: str) -> int:
|
|
1461
|
+
try:
|
|
1462
|
+
value = int(raw)
|
|
1463
|
+
except ValueError as exc:
|
|
1464
|
+
raise DispatchError("--idle-timeout-seconds must be an integer") from exc
|
|
1465
|
+
if value < 0:
|
|
1466
|
+
raise DispatchError("--idle-timeout-seconds must be non-negative")
|
|
1467
|
+
return value
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _utc_now() -> str:
|
|
1471
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
1475
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
1476
|
+
encoding="utf-8")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _print_json(payload: Mapping[str, Any]) -> None:
|
|
1480
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
if __name__ == "__main__":
|
|
1484
|
+
raise SystemExit(main(sys.argv[1:]))
|