okstra 0.76.0 → 0.78.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 +1 -117
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +22 -5
- package/docs/pr-template-usage.md +3 -3
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
- package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
- package/package.json +5 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/DO_NOT_EDIT.md +21 -0
- package/runtime/agents/SKILL.md +3 -0
- 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-trace-cleanup.sh +16 -12
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -17
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
- package/runtime/python/okstra_ctl/context_cost.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
- package/runtime/python/okstra_ctl/doctor.py +292 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +72 -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 +211 -29
- package/runtime/python/okstra_ctl/run.py +89 -18
- package/runtime/python/okstra_ctl/team.py +267 -0
- package/runtime/python/okstra_ctl/tmux.py +181 -10
- package/runtime/python/okstra_ctl/workflow.py +30 -71
- package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
- 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/schemas/final-report-v1.0.schema.json +3 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +112 -22
- package/runtime/validators/validate_session_conformance.py +127 -5
- package/src/cli-registry.mjs +277 -0
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +130 -5
- package/src/install.mjs +123 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +29 -0
- package/src/team.mjs +63 -0
- package/src/uninstall.mjs +27 -4
- /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Backend-neutral worker dispatch core."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from . import tmux
|
|
14
|
+
from .lead_events import LeadEvent, append_lead_event
|
|
15
|
+
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
19
|
+
BACKEND_TMUX_PANE = "tmux-pane"
|
|
20
|
+
BACKEND_MIXED = "mixed"
|
|
21
|
+
MAX_WORKER_ATTEMPTS = 2
|
|
22
|
+
REPORT_WRITER_WORKER_ID = "report-writer"
|
|
23
|
+
TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DispatchError(Exception):
|
|
27
|
+
"""Raised when a worker dispatch request cannot be executed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class WorkerJob:
|
|
32
|
+
worker_id: str
|
|
33
|
+
provider: str
|
|
34
|
+
backend: str
|
|
35
|
+
project_root: Path
|
|
36
|
+
model_execution_value: str
|
|
37
|
+
wrapper_path: Path
|
|
38
|
+
prompt_path: Path
|
|
39
|
+
result_path: Path
|
|
40
|
+
worker_result_path: Path
|
|
41
|
+
completion_paths: tuple[Path, ...]
|
|
42
|
+
worktree_path: str
|
|
43
|
+
role: str
|
|
44
|
+
idle_timeout_seconds: int
|
|
45
|
+
dispatch_kind: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def command(self) -> list[str]:
|
|
49
|
+
return [
|
|
50
|
+
str(self.wrapper_path),
|
|
51
|
+
str(self.project_root),
|
|
52
|
+
self.model_execution_value,
|
|
53
|
+
str(self.prompt_path),
|
|
54
|
+
self.worktree_path,
|
|
55
|
+
self.role,
|
|
56
|
+
str(self.idle_timeout_seconds),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
def to_payload(self) -> dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"workerId": self.worker_id,
|
|
62
|
+
"provider": self.provider,
|
|
63
|
+
"backend": self.backend,
|
|
64
|
+
"modelExecutionValue": self.model_execution_value,
|
|
65
|
+
"wrapperPath": str(self.wrapper_path),
|
|
66
|
+
"promptPath": str(self.prompt_path),
|
|
67
|
+
"resultPath": str(self.result_path),
|
|
68
|
+
"workerResultPath": str(self.worker_result_path),
|
|
69
|
+
"completionPaths": [str(path) for path in self.completion_paths],
|
|
70
|
+
"worktreePath": self.worktree_path,
|
|
71
|
+
"role": self.role,
|
|
72
|
+
"dispatchKind": self.dispatch_kind,
|
|
73
|
+
"command": self.command,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class WorkerHandle:
|
|
79
|
+
job: WorkerJob
|
|
80
|
+
pane_id: str
|
|
81
|
+
completed_process: subprocess.CompletedProcess[str] | None
|
|
82
|
+
status_sidecar_path: Path | None
|
|
83
|
+
degraded_from: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class WorkerOutcome:
|
|
88
|
+
returncode: int
|
|
89
|
+
missing_completion_paths: tuple[Path, ...]
|
|
90
|
+
pane_id: str
|
|
91
|
+
status_sidecar_path: Path | None
|
|
92
|
+
timeout: bool
|
|
93
|
+
terminal_stage: str
|
|
94
|
+
degraded_from: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class DispatchPlan:
|
|
99
|
+
project_root: Path
|
|
100
|
+
workspace_root: Path
|
|
101
|
+
manifest_path: Path
|
|
102
|
+
team_state_path: Path
|
|
103
|
+
lead_events_path: Path
|
|
104
|
+
manifest: Mapping[str, Any]
|
|
105
|
+
jobs: tuple[WorkerJob, ...]
|
|
106
|
+
default_backend: str
|
|
107
|
+
|
|
108
|
+
def to_payload(self, dry_run: bool) -> dict[str, Any]:
|
|
109
|
+
payload = {
|
|
110
|
+
"ok": True,
|
|
111
|
+
"dryRun": dry_run,
|
|
112
|
+
"dispatchMode": _dispatch_mode(self.jobs),
|
|
113
|
+
"workerBackends": {job.worker_id: job.backend for job in self.jobs},
|
|
114
|
+
"runManifestPath": str(self.manifest_path),
|
|
115
|
+
"teamStatePath": str(self.team_state_path),
|
|
116
|
+
"leadEventsPath": str(self.lead_events_path),
|
|
117
|
+
"workspaceRoot": str(self.workspace_root),
|
|
118
|
+
"workers": [job.to_payload() for job in self.jobs],
|
|
119
|
+
}
|
|
120
|
+
if self.jobs:
|
|
121
|
+
payload["workerDispatches"] = [
|
|
122
|
+
_dispatch_record(job, 1, "not-run", "", "") for job in self.jobs
|
|
123
|
+
]
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class _BuildOptions:
|
|
129
|
+
okstra_bin: Path | None
|
|
130
|
+
idle_timeout_seconds: int
|
|
131
|
+
default_backend: str
|
|
132
|
+
supported_worker_wrappers: Mapping[str, str]
|
|
133
|
+
unsupported_worker_label: str
|
|
134
|
+
dispatch_kind: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_dispatch_plan(
|
|
138
|
+
*,
|
|
139
|
+
project_root: Path,
|
|
140
|
+
run_manifest_path: Path,
|
|
141
|
+
workspace_root: Path,
|
|
142
|
+
okstra_bin: Path | None = None,
|
|
143
|
+
requested_workers: Sequence[str] = (),
|
|
144
|
+
idle_timeout_seconds: int = 600,
|
|
145
|
+
enable_report_writer: bool = False,
|
|
146
|
+
report_writer_model: str = "",
|
|
147
|
+
required_lead_runtime: str | None = None,
|
|
148
|
+
default_backend: str = BACKEND_CLI_WRAPPER,
|
|
149
|
+
supported_worker_wrappers: Mapping[str, str],
|
|
150
|
+
unsupported_worker_label: str,
|
|
151
|
+
dispatch_kind: str = "initial",
|
|
152
|
+
jobs_file: Path | None = None,
|
|
153
|
+
) -> DispatchPlan:
|
|
154
|
+
project_root = project_root.resolve()
|
|
155
|
+
manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
|
|
156
|
+
manifest = _load_json_object(manifest_path, "run manifest")
|
|
157
|
+
_validate_manifest(manifest, manifest_path, required_lead_runtime)
|
|
158
|
+
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
159
|
+
team_state = _load_json_object(team_state_path, "team-state")
|
|
160
|
+
active_context = _load_optional_json(project_root, manifest.get("activeRunContextPath"))
|
|
161
|
+
options = _BuildOptions(
|
|
162
|
+
okstra_bin=okstra_bin,
|
|
163
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
164
|
+
default_backend=default_backend,
|
|
165
|
+
supported_worker_wrappers=supported_worker_wrappers,
|
|
166
|
+
unsupported_worker_label=unsupported_worker_label,
|
|
167
|
+
dispatch_kind=dispatch_kind,
|
|
168
|
+
)
|
|
169
|
+
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options) if jobs_file else _jobs_from_roster(
|
|
170
|
+
project_root,
|
|
171
|
+
workspace_root,
|
|
172
|
+
manifest,
|
|
173
|
+
team_state,
|
|
174
|
+
active_context,
|
|
175
|
+
requested_workers,
|
|
176
|
+
enable_report_writer,
|
|
177
|
+
report_writer_model,
|
|
178
|
+
options,
|
|
179
|
+
)
|
|
180
|
+
return DispatchPlan(
|
|
181
|
+
project_root=project_root,
|
|
182
|
+
workspace_root=workspace_root.resolve(),
|
|
183
|
+
manifest_path=manifest_path,
|
|
184
|
+
team_state_path=team_state_path,
|
|
185
|
+
lead_events_path=_resolve_required_path(project_root, manifest, "leadEventsPath"),
|
|
186
|
+
manifest=manifest,
|
|
187
|
+
jobs=tuple(jobs),
|
|
188
|
+
default_backend=default_backend,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
193
|
+
if wait:
|
|
194
|
+
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
|
|
195
|
+
for job in plan.jobs:
|
|
196
|
+
result = _dispatch_job_with_retry(plan, job)
|
|
197
|
+
if result != 0:
|
|
198
|
+
return result
|
|
199
|
+
return 0
|
|
200
|
+
handles = [_spawn_job(plan, job, 1) for job in plan.jobs]
|
|
201
|
+
_set_dispatch_mode(plan.team_state_path, _mode_from_handles(handles))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def await_dispatches(
|
|
206
|
+
plan: DispatchPlan,
|
|
207
|
+
*,
|
|
208
|
+
poll_interval_seconds: int = 5,
|
|
209
|
+
timeout_seconds: int | None = None,
|
|
210
|
+
heartbeat_seconds: int = 30,
|
|
211
|
+
) -> int:
|
|
212
|
+
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
|
|
213
|
+
last_heartbeat = 0.0
|
|
214
|
+
while True:
|
|
215
|
+
running = _running_dispatches(plan.team_state_path)
|
|
216
|
+
if not running:
|
|
217
|
+
return 0
|
|
218
|
+
_advance_running_dispatches(plan, running)
|
|
219
|
+
running = _running_dispatches(plan.team_state_path)
|
|
220
|
+
if not running:
|
|
221
|
+
return 0
|
|
222
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
223
|
+
return 1
|
|
224
|
+
now = time.monotonic()
|
|
225
|
+
if heartbeat_seconds > 0 and now - last_heartbeat >= heartbeat_seconds:
|
|
226
|
+
print(f"WAITING {len(running)} worker dispatch(es)")
|
|
227
|
+
last_heartbeat = now
|
|
228
|
+
time.sleep(max(poll_interval_seconds, 0))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _jobs_from_roster(
|
|
232
|
+
project_root: Path,
|
|
233
|
+
workspace_root: Path,
|
|
234
|
+
manifest: Mapping[str, Any],
|
|
235
|
+
team_state: Mapping[str, Any],
|
|
236
|
+
active_context: Mapping[str, Any],
|
|
237
|
+
requested_workers: Sequence[str],
|
|
238
|
+
enable_report_writer: bool,
|
|
239
|
+
report_writer_model: str,
|
|
240
|
+
options: _BuildOptions,
|
|
241
|
+
) -> list[WorkerJob]:
|
|
242
|
+
selected = _select_workers(manifest, requested_workers, enable_report_writer, options)
|
|
243
|
+
_mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
|
|
244
|
+
return [
|
|
245
|
+
_job_from_roster_worker(
|
|
246
|
+
project_root, workspace_root, manifest, team_state, active_context,
|
|
247
|
+
worker_id, report_writer_model, options,
|
|
248
|
+
)
|
|
249
|
+
for worker_id in selected
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _select_workers(
|
|
254
|
+
manifest: Mapping[str, Any],
|
|
255
|
+
requested_workers: Sequence[str],
|
|
256
|
+
enable_report_writer: bool,
|
|
257
|
+
options: _BuildOptions,
|
|
258
|
+
) -> list[str]:
|
|
259
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
260
|
+
if not recommended:
|
|
261
|
+
raise DispatchError("run manifest has no recommendedWorkers entries")
|
|
262
|
+
supported = set(options.supported_worker_wrappers)
|
|
263
|
+
if not enable_report_writer and not requested_workers:
|
|
264
|
+
supported.discard(REPORT_WRITER_WORKER_ID)
|
|
265
|
+
selected = list(requested_workers) if requested_workers else [w for w in recommended if w in supported]
|
|
266
|
+
unknown = [worker for worker in selected if worker not in recommended]
|
|
267
|
+
if unknown:
|
|
268
|
+
raise DispatchError("requested worker(s) are not in this run roster: " + ", ".join(unknown))
|
|
269
|
+
unsupported = [worker for worker in selected if worker not in supported]
|
|
270
|
+
if unsupported:
|
|
271
|
+
allowed = ", ".join(sorted(supported))
|
|
272
|
+
raise DispatchError(
|
|
273
|
+
f"unsupported {options.unsupported_worker_label} worker(s): "
|
|
274
|
+
+ ", ".join(unsupported)
|
|
275
|
+
+ f" (dispatcher supports: {allowed})"
|
|
276
|
+
)
|
|
277
|
+
return selected
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _job_from_roster_worker(
|
|
281
|
+
project_root: Path,
|
|
282
|
+
workspace_root: Path,
|
|
283
|
+
manifest: Mapping[str, Any],
|
|
284
|
+
team_state: Mapping[str, Any],
|
|
285
|
+
active_context: Mapping[str, Any],
|
|
286
|
+
worker_id: str,
|
|
287
|
+
report_writer_model: str,
|
|
288
|
+
options: _BuildOptions,
|
|
289
|
+
) -> WorkerJob:
|
|
290
|
+
state = _worker_state(team_state, worker_id)
|
|
291
|
+
provider = _provider_for_worker(worker_id)
|
|
292
|
+
model = _model_for_worker(worker_id, state, report_writer_model)
|
|
293
|
+
prompt_path = _resolve_project_path(project_root, _worker_prompt_path(manifest, worker_id))
|
|
294
|
+
result_path = _resolve_project_path(project_root, _require_string(state, "resultPath"))
|
|
295
|
+
_materialize_prompt_if_missing(prompt_path, worker_id, result_path, project_root)
|
|
296
|
+
return WorkerJob(
|
|
297
|
+
worker_id=worker_id,
|
|
298
|
+
provider=provider,
|
|
299
|
+
backend=options.default_backend,
|
|
300
|
+
project_root=project_root,
|
|
301
|
+
model_execution_value=model,
|
|
302
|
+
wrapper_path=_resolve_wrapper(provider, workspace_root, options),
|
|
303
|
+
prompt_path=prompt_path,
|
|
304
|
+
result_path=_result_path_for_worker(worker_id, result_path, manifest, project_root),
|
|
305
|
+
worker_result_path=result_path,
|
|
306
|
+
completion_paths=_completion_paths(worker_id, result_path, manifest, project_root),
|
|
307
|
+
worktree_path=_worktree_path(active_context),
|
|
308
|
+
role=_string_value(state.get("role")) or "worker",
|
|
309
|
+
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
310
|
+
dispatch_kind=options.dispatch_kind,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _jobs_from_file(
|
|
315
|
+
project_root: Path,
|
|
316
|
+
workspace_root: Path,
|
|
317
|
+
jobs_file: Path | None,
|
|
318
|
+
options: _BuildOptions,
|
|
319
|
+
) -> list[WorkerJob]:
|
|
320
|
+
if jobs_file is None:
|
|
321
|
+
return []
|
|
322
|
+
payload = _load_json_object(_resolve_project_path(project_root, str(jobs_file)), "jobs file")
|
|
323
|
+
dispatch_kind = _string_value(payload.get("dispatchKind")) or options.dispatch_kind
|
|
324
|
+
workers = payload.get("workers")
|
|
325
|
+
if not isinstance(workers, list):
|
|
326
|
+
raise DispatchError("jobs file workers must be an array")
|
|
327
|
+
return [
|
|
328
|
+
_job_from_file_worker(project_root, workspace_root, item, options, dispatch_kind)
|
|
329
|
+
for item in workers
|
|
330
|
+
if isinstance(item, dict)
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _job_from_file_worker(
|
|
335
|
+
project_root: Path,
|
|
336
|
+
workspace_root: Path,
|
|
337
|
+
item: Mapping[str, Any],
|
|
338
|
+
options: _BuildOptions,
|
|
339
|
+
dispatch_kind: str,
|
|
340
|
+
) -> WorkerJob:
|
|
341
|
+
worker_id = _require_string(item, "workerId")
|
|
342
|
+
provider = _provider_for_job(item, worker_id)
|
|
343
|
+
prompt_path = _resolve_project_path(project_root, _require_string(item, "promptPath"))
|
|
344
|
+
result_path = _resolve_project_path(project_root, _require_string(item, "resultPath"))
|
|
345
|
+
worker_result_path = _resolve_project_path(project_root, _require_string(item, "workerResultPath"))
|
|
346
|
+
completion_paths = tuple(
|
|
347
|
+
_resolve_project_path(project_root, path)
|
|
348
|
+
for path in _string_list(item.get("completionPaths"))
|
|
349
|
+
) or (result_path,)
|
|
350
|
+
return WorkerJob(
|
|
351
|
+
worker_id=worker_id,
|
|
352
|
+
provider=provider,
|
|
353
|
+
backend=options.default_backend,
|
|
354
|
+
project_root=project_root,
|
|
355
|
+
model_execution_value=_require_string(item, "modelExecutionValue"),
|
|
356
|
+
wrapper_path=_resolve_wrapper(provider, workspace_root, options),
|
|
357
|
+
prompt_path=prompt_path,
|
|
358
|
+
result_path=result_path,
|
|
359
|
+
worker_result_path=worker_result_path,
|
|
360
|
+
completion_paths=completion_paths,
|
|
361
|
+
worktree_path=_string_value(item.get("worktreePath")),
|
|
362
|
+
role=_require_string(item, "role"),
|
|
363
|
+
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
364
|
+
dispatch_kind=dispatch_kind,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
|
|
369
|
+
_set_worker_status(plan.team_state_path, job.worker_id, "running", "", job.model_execution_value)
|
|
370
|
+
handle = _start_job(plan, job)
|
|
371
|
+
_record_dispatch(plan.team_state_path, handle, attempt, "running", "")
|
|
372
|
+
_append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
|
|
373
|
+
if handle.completed_process is not None:
|
|
374
|
+
outcome = _outcome_from_completed(handle)
|
|
375
|
+
_finish_attempt(plan, job, attempt, outcome)
|
|
376
|
+
return handle
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
380
|
+
for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
|
|
381
|
+
handle = _spawn_job(plan, job, attempt)
|
|
382
|
+
if handle.completed_process is None:
|
|
383
|
+
return await_dispatches(plan, timeout_seconds=None)
|
|
384
|
+
outcome = _outcome_from_completed(handle)
|
|
385
|
+
if outcome.returncode == 0 and not outcome.missing_completion_paths:
|
|
386
|
+
return 0
|
|
387
|
+
if _should_retry(outcome, attempt):
|
|
388
|
+
_append_event(plan, "worker-retry-scheduled", _retry_details(job, attempt, outcome))
|
|
389
|
+
continue
|
|
390
|
+
return outcome.returncode or 1
|
|
391
|
+
return 1
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
395
|
+
if job.backend == BACKEND_CLI_WRAPPER:
|
|
396
|
+
return _run_cli_wrapper(plan, job, "")
|
|
397
|
+
if job.backend == BACKEND_TMUX_PANE:
|
|
398
|
+
return _start_tmux_or_degrade(plan, job)
|
|
399
|
+
raise DispatchError(f"unsupported worker backend: {job.backend}")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
403
|
+
lead_pane = tmux.resolve_caller_pane()
|
|
404
|
+
if not lead_pane:
|
|
405
|
+
return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
|
|
406
|
+
try:
|
|
407
|
+
pane_id = tmux.split_worker_pane(
|
|
408
|
+
target_pane=lead_pane,
|
|
409
|
+
cwd=plan.project_root,
|
|
410
|
+
command=job.command,
|
|
411
|
+
title=f"{job.worker_id}-worker",
|
|
412
|
+
run_dir=_run_dir(plan),
|
|
413
|
+
)
|
|
414
|
+
except RuntimeError:
|
|
415
|
+
return _run_cli_wrapper(plan, job, BACKEND_TMUX_PANE)
|
|
416
|
+
return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
|
|
420
|
+
env = {
|
|
421
|
+
**os.environ,
|
|
422
|
+
"OKSTRA_WORKER_ID": job.worker_id,
|
|
423
|
+
"OKSTRA_WORKER_RESULT_PATH": str(job.result_path),
|
|
424
|
+
"OKSTRA_WORKER_AUDIT_PATH": str(job.worker_result_path),
|
|
425
|
+
"OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
|
|
426
|
+
}
|
|
427
|
+
if job.worker_id == REPORT_WRITER_WORKER_ID:
|
|
428
|
+
env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(_final_report_markdown_path(job.result_path))
|
|
429
|
+
completed = subprocess.run(job.command, cwd=plan.project_root, env=env, text=True)
|
|
430
|
+
return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
|
|
434
|
+
completed = handle.completed_process
|
|
435
|
+
if completed is None:
|
|
436
|
+
raise DispatchError("completed process missing")
|
|
437
|
+
return WorkerOutcome(
|
|
438
|
+
returncode=completed.returncode,
|
|
439
|
+
missing_completion_paths=_missing_completion_paths(handle.job),
|
|
440
|
+
pane_id=handle.pane_id,
|
|
441
|
+
status_sidecar_path=handle.status_sidecar_path,
|
|
442
|
+
timeout=False,
|
|
443
|
+
terminal_stage="exited",
|
|
444
|
+
degraded_from=handle.degraded_from,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[str, Any]]) -> None:
|
|
449
|
+
for record in records:
|
|
450
|
+
status_path = _optional_path(record.get("statusSidecarPath"))
|
|
451
|
+
status = read_wrapper_status(status_path) if status_path else None
|
|
452
|
+
if status is None or not status.is_terminal:
|
|
453
|
+
continue
|
|
454
|
+
outcome = _outcome_from_status(record, status)
|
|
455
|
+
if _should_retry(outcome, int(record.get("attempt", 1))):
|
|
456
|
+
_retry_from_record(plan, record, outcome)
|
|
457
|
+
continue
|
|
458
|
+
_finish_record(plan, record, outcome)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _retry_from_record(
|
|
462
|
+
plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome
|
|
463
|
+
) -> None:
|
|
464
|
+
worker_id = _require_string(record, "workerId")
|
|
465
|
+
attempt = int(record.get("attempt", 1))
|
|
466
|
+
_update_dispatch_status(plan.team_state_path, worker_id, _require_string(record, "kind"), attempt, "error", "required worker artifact was not produced")
|
|
467
|
+
_append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
|
|
468
|
+
job = _job_from_record(plan.project_root, record)
|
|
469
|
+
_spawn_job(plan, job, attempt + 1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
473
|
+
if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
|
|
474
|
+
_set_worker_status(plan.team_state_path, job.worker_id, "completed", "")
|
|
475
|
+
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, "completed", "")
|
|
476
|
+
_append_event(plan, "worker-result-collected", _result_details(job, attempt, outcome))
|
|
477
|
+
return
|
|
478
|
+
reason = _failure_reason(outcome)
|
|
479
|
+
status = "timeout" if outcome.timeout else "error"
|
|
480
|
+
_set_worker_status(plan.team_state_path, job.worker_id, status, reason)
|
|
481
|
+
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, status, reason)
|
|
482
|
+
_append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome) -> None:
|
|
486
|
+
job = _job_from_record(plan.project_root, record)
|
|
487
|
+
_finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _record_dispatch(
|
|
491
|
+
team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
|
|
492
|
+
) -> None:
|
|
493
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
494
|
+
dispatches = payload.setdefault("workerDispatches", [])
|
|
495
|
+
if not isinstance(dispatches, list):
|
|
496
|
+
raise DispatchError(f"team-state workerDispatches must be an array: {team_state_path}")
|
|
497
|
+
dispatches.append(_dispatch_record(handle.job, attempt, status, handle.pane_id, handle.degraded_from, reason))
|
|
498
|
+
_write_json(team_state_path, payload)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _dispatch_record(
|
|
502
|
+
job: WorkerJob, attempt: int, status: str, pane_id: str, degraded_from: str, reason: str = ""
|
|
503
|
+
) -> dict[str, Any]:
|
|
504
|
+
return {
|
|
505
|
+
"workerId": job.worker_id,
|
|
506
|
+
"role": job.role,
|
|
507
|
+
"kind": job.dispatch_kind,
|
|
508
|
+
"attempt": attempt,
|
|
509
|
+
"backendType": BACKEND_CLI_WRAPPER if degraded_from else job.backend,
|
|
510
|
+
"provider": job.provider,
|
|
511
|
+
"status": status,
|
|
512
|
+
"paneId": pane_id,
|
|
513
|
+
"promptPath": str(job.prompt_path),
|
|
514
|
+
"resultPath": str(job.result_path),
|
|
515
|
+
"workerResultPath": str(job.worker_result_path),
|
|
516
|
+
"statusSidecarPath": str(status_path_for_prompt(job.prompt_path)),
|
|
517
|
+
"degradedFrom": degraded_from,
|
|
518
|
+
"reason": reason,
|
|
519
|
+
"modelExecutionValue": job.model_execution_value,
|
|
520
|
+
"wrapperPath": str(job.wrapper_path),
|
|
521
|
+
"completionPaths": [str(path) for path in job.completion_paths],
|
|
522
|
+
"worktreePath": job.worktree_path,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _update_dispatch_status(
|
|
527
|
+
team_state_path: Path, worker_id: str, kind: str, attempt: int, status: str, reason: str
|
|
528
|
+
) -> None:
|
|
529
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
530
|
+
for record in payload.get("workerDispatches", []):
|
|
531
|
+
if _record_matches(record, worker_id, kind, attempt):
|
|
532
|
+
record["status"] = status
|
|
533
|
+
record["reason"] = reason
|
|
534
|
+
_write_json(team_state_path, payload)
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _record_matches(record: object, worker_id: str, kind: str, attempt: int) -> bool:
|
|
539
|
+
return isinstance(record, dict) and record.get("workerId") == worker_id and record.get("kind") == kind and record.get("attempt") == attempt
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _set_worker_status(
|
|
543
|
+
team_state_path: Path, worker_id: str, status: str, reason: str, model: str = ""
|
|
544
|
+
) -> None:
|
|
545
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
546
|
+
workers = payload.get("workers")
|
|
547
|
+
if not isinstance(workers, list):
|
|
548
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
549
|
+
for worker in workers:
|
|
550
|
+
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
551
|
+
worker["status"] = status
|
|
552
|
+
worker["reason"] = reason
|
|
553
|
+
if model:
|
|
554
|
+
worker["model"] = model
|
|
555
|
+
worker["modelExecutionValue"] = model
|
|
556
|
+
_write_json(team_state_path, payload)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
561
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
562
|
+
payload["dispatchMode"] = dispatch_mode
|
|
563
|
+
_write_json(team_state_path, payload)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
|
|
567
|
+
payload = _load_json_object(team_state_path, "team-state")
|
|
568
|
+
dispatches = payload.get("workerDispatches")
|
|
569
|
+
if not isinstance(dispatches, list):
|
|
570
|
+
return []
|
|
571
|
+
return [record for record in dispatches if isinstance(record, dict) and record.get("status") == "running"]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _mark_roster_skips(
|
|
575
|
+
project_root: Path,
|
|
576
|
+
manifest: Mapping[str, Any],
|
|
577
|
+
team_state: Mapping[str, Any],
|
|
578
|
+
selected: Sequence[str],
|
|
579
|
+
requested: Sequence[str],
|
|
580
|
+
options: _BuildOptions,
|
|
581
|
+
) -> None:
|
|
582
|
+
reasons = _skip_reasons(manifest, selected, requested, options)
|
|
583
|
+
if not reasons:
|
|
584
|
+
return
|
|
585
|
+
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
586
|
+
for worker_id, reason in reasons.items():
|
|
587
|
+
_set_worker_status(team_state_path, worker_id, "not-run", reason)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _skip_reasons(
|
|
591
|
+
manifest: Mapping[str, Any], selected: Sequence[str], requested: Sequence[str], options: _BuildOptions
|
|
592
|
+
) -> dict[str, str]:
|
|
593
|
+
if requested:
|
|
594
|
+
return {w: "skipped by worker dispatch: worker was not requested in this invocation" for w in _string_list(manifest.get("recommendedWorkers")) if w not in set(selected)}
|
|
595
|
+
supported = set(options.supported_worker_wrappers)
|
|
596
|
+
return {w: "skipped by worker dispatch default: worker is not supported by this dispatcher" for w in _string_list(manifest.get("recommendedWorkers")) if w not in set(selected) and w not in supported}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
600
|
+
append_lead_event(
|
|
601
|
+
plan.lead_events_path,
|
|
602
|
+
LeadEvent(
|
|
603
|
+
event_type=event_type,
|
|
604
|
+
lead_runtime=_require_string(plan.manifest, "leadRuntime"),
|
|
605
|
+
task_key=_require_string(plan.manifest, "taskKey"),
|
|
606
|
+
task_type=_require_string(plan.manifest, "taskType"),
|
|
607
|
+
run_seq=_run_seq(plan.manifest),
|
|
608
|
+
timestamp=_utc_now(),
|
|
609
|
+
details=details,
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _attempt_details(job: WorkerJob, attempt: int, handle: WorkerHandle) -> dict[str, Any]:
|
|
615
|
+
return {**_event_details(job, handle), "attempt": attempt, "maxAttempts": MAX_WORKER_ATTEMPTS}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _event_details(job: WorkerJob, handle: WorkerHandle) -> dict[str, Any]:
|
|
619
|
+
return {
|
|
620
|
+
"dispatchMode": BACKEND_CLI_WRAPPER if handle.degraded_from else job.backend,
|
|
621
|
+
"workerId": job.worker_id,
|
|
622
|
+
"promptPath": str(job.prompt_path),
|
|
623
|
+
"resultPath": str(job.result_path),
|
|
624
|
+
"workerResultPath": str(job.worker_result_path),
|
|
625
|
+
"wrapperPath": str(job.wrapper_path),
|
|
626
|
+
"role": job.role,
|
|
627
|
+
"paneId": handle.pane_id,
|
|
628
|
+
"degradedFrom": handle.degraded_from,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dict[str, Any]:
|
|
633
|
+
return {
|
|
634
|
+
"workerId": job.worker_id,
|
|
635
|
+
"attempt": attempt,
|
|
636
|
+
"dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
|
|
637
|
+
"missingCompletionPaths": [],
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _failure_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome, reason: str) -> dict[str, Any]:
|
|
642
|
+
return {
|
|
643
|
+
"workerId": job.worker_id,
|
|
644
|
+
"attempt": attempt,
|
|
645
|
+
"dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
|
|
646
|
+
"exitCode": outcome.returncode,
|
|
647
|
+
"missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
|
|
648
|
+
"reason": reason,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _retry_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dict[str, Any]:
|
|
653
|
+
return {
|
|
654
|
+
"workerId": job.worker_id,
|
|
655
|
+
"attempt": attempt,
|
|
656
|
+
"missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
|
|
657
|
+
"reason": "required worker artifact was not produced",
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _outcome_from_status(record: Mapping[str, Any], status) -> WorkerOutcome:
|
|
662
|
+
return WorkerOutcome(
|
|
663
|
+
returncode=status.exit_code or 0,
|
|
664
|
+
missing_completion_paths=tuple(path for path in _record_completion_paths(record) if not path.is_file()),
|
|
665
|
+
pane_id=_string_value(record.get("paneId")),
|
|
666
|
+
status_sidecar_path=status.path,
|
|
667
|
+
timeout=status.timeout,
|
|
668
|
+
terminal_stage=status.stage,
|
|
669
|
+
degraded_from=_string_value(record.get("degradedFrom")),
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob:
|
|
674
|
+
return WorkerJob(
|
|
675
|
+
worker_id=_require_string(record, "workerId"),
|
|
676
|
+
provider=_require_string(record, "provider"),
|
|
677
|
+
backend=BACKEND_CLI_WRAPPER,
|
|
678
|
+
project_root=project_root,
|
|
679
|
+
model_execution_value=_require_string(record, "modelExecutionValue"),
|
|
680
|
+
wrapper_path=Path(_require_string(record, "wrapperPath")),
|
|
681
|
+
prompt_path=Path(_require_string(record, "promptPath")),
|
|
682
|
+
result_path=Path(_require_string(record, "resultPath")),
|
|
683
|
+
worker_result_path=Path(_require_string(record, "workerResultPath")),
|
|
684
|
+
completion_paths=tuple(_record_completion_paths(record)),
|
|
685
|
+
worktree_path=_string_value(record.get("worktreePath")),
|
|
686
|
+
role=_require_string(record, "role"),
|
|
687
|
+
idle_timeout_seconds=600,
|
|
688
|
+
dispatch_kind=_require_string(record, "kind"),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _record_completion_paths(record: Mapping[str, Any]) -> list[Path]:
|
|
693
|
+
return [Path(path) for path in _string_list(record.get("completionPaths"))]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _should_retry(outcome: WorkerOutcome, attempt: int) -> bool:
|
|
697
|
+
return outcome.returncode == 0 and bool(outcome.missing_completion_paths) and attempt < MAX_WORKER_ATTEMPTS
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _failure_reason(outcome: WorkerOutcome) -> str:
|
|
701
|
+
if outcome.timeout:
|
|
702
|
+
return "worker wrapper timed out"
|
|
703
|
+
if outcome.returncode != 0:
|
|
704
|
+
return f"wrapper exited with code {outcome.returncode}"
|
|
705
|
+
if outcome.missing_completion_paths:
|
|
706
|
+
missing = ", ".join(str(path) for path in outcome.missing_completion_paths)
|
|
707
|
+
return f"required worker artifact was not produced: {missing}"
|
|
708
|
+
return "worker dispatch failed"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
712
|
+
return tuple(path for path in job.completion_paths if not path.is_file())
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
|
|
716
|
+
modes = {BACKEND_CLI_WRAPPER if h.degraded_from else h.job.backend for h in handles}
|
|
717
|
+
if len(modes) == 1:
|
|
718
|
+
return next(iter(modes))
|
|
719
|
+
return BACKEND_MIXED
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
|
|
723
|
+
backends = {job.backend for job in jobs}
|
|
724
|
+
if len(backends) == 1:
|
|
725
|
+
return next(iter(backends))
|
|
726
|
+
return BACKEND_MIXED
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _validate_manifest(manifest: Mapping[str, Any], path: Path, required: str | None) -> None:
|
|
730
|
+
if required is not None and manifest.get("leadRuntime") != required:
|
|
731
|
+
raise DispatchError(f"run manifest is not a {required} lead run: {path}")
|
|
732
|
+
if not isinstance(manifest.get("leadEventsPath"), str):
|
|
733
|
+
raise DispatchError(f"run manifest has no leadEventsPath: {path}")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions) -> Path:
|
|
737
|
+
script = options.supported_worker_wrappers.get(provider)
|
|
738
|
+
if not script:
|
|
739
|
+
raise DispatchError(f"unsupported worker provider: {provider}")
|
|
740
|
+
candidates = []
|
|
741
|
+
if options.okstra_bin is not None:
|
|
742
|
+
candidates.append(options.okstra_bin / script)
|
|
743
|
+
candidates.extend([workspace_root / "bin" / script, workspace_root / "scripts" / script])
|
|
744
|
+
for candidate in candidates:
|
|
745
|
+
if candidate.is_file():
|
|
746
|
+
return candidate.resolve()
|
|
747
|
+
raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _materialize_prompt_if_missing(prompt_path: Path, worker_id: str, result_path: Path, project_root: Path) -> None:
|
|
751
|
+
if prompt_path.is_file():
|
|
752
|
+
return
|
|
753
|
+
prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
754
|
+
prompt_path.write_text(
|
|
755
|
+
"\n".join([
|
|
756
|
+
f"# {worker_id} worker prompt",
|
|
757
|
+
f"**Project Root:** {project_root}",
|
|
758
|
+
f"**Result Path:** {result_path}",
|
|
759
|
+
"",
|
|
760
|
+
]),
|
|
761
|
+
encoding="utf-8",
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _provider_for_worker(worker_id: str) -> str:
|
|
766
|
+
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
767
|
+
return "claude"
|
|
768
|
+
return worker_id
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _provider_for_job(item: Mapping[str, Any], worker_id: str) -> str:
|
|
772
|
+
provider = _string_value(item.get("provider"))
|
|
773
|
+
if provider:
|
|
774
|
+
return provider
|
|
775
|
+
return _provider_for_worker(worker_id)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _model_for_worker(worker_id: str, state: Mapping[str, Any], report_writer_model: str) -> str:
|
|
779
|
+
if worker_id == REPORT_WRITER_WORKER_ID and report_writer_model.strip():
|
|
780
|
+
return report_writer_model.strip()
|
|
781
|
+
return _require_string(state, "modelExecutionValue")
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _result_path_for_worker(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> Path:
|
|
785
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
786
|
+
return result_path
|
|
787
|
+
return _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _completion_paths(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> tuple[Path, ...]:
|
|
791
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
792
|
+
return (result_path,)
|
|
793
|
+
data_json = _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
|
|
794
|
+
return (data_json, _final_report_markdown_path(data_json), result_path)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _final_report_data_path(markdown_path: Path) -> Path:
|
|
798
|
+
if markdown_path.name.endswith(".md"):
|
|
799
|
+
return markdown_path.with_name(markdown_path.name[:-3] + ".data.json")
|
|
800
|
+
return markdown_path.with_suffix(".data.json")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _final_report_markdown_path(data_json_path: Path) -> Path:
|
|
804
|
+
if data_json_path.name.endswith(".data.json"):
|
|
805
|
+
return data_json_path.with_name(data_json_path.name[:-10] + ".md")
|
|
806
|
+
return data_json_path.with_suffix(".md")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _run_dir(plan: DispatchPlan) -> Path:
|
|
810
|
+
value = _string_value(plan.manifest.get("runDirectoryPath"))
|
|
811
|
+
return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _worktree_path(active_context: Mapping[str, Any]) -> str:
|
|
815
|
+
worktree = active_context.get("executorWorktree")
|
|
816
|
+
if isinstance(worktree, dict):
|
|
817
|
+
return _string_value(worktree.get("path"))
|
|
818
|
+
return ""
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
822
|
+
workers = team_state.get("workers")
|
|
823
|
+
if isinstance(workers, list):
|
|
824
|
+
for worker in workers:
|
|
825
|
+
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
826
|
+
return worker
|
|
827
|
+
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
|
|
831
|
+
paths = manifest.get("workerPromptPathByWorkerId")
|
|
832
|
+
if not isinstance(paths, Mapping):
|
|
833
|
+
raise DispatchError("run manifest has no workerPromptPathByWorkerId")
|
|
834
|
+
return _require_string(paths, worker_id)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
838
|
+
if not path.is_file():
|
|
839
|
+
raise DispatchError(f"{label} not found: {path}")
|
|
840
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
841
|
+
if not isinstance(payload, dict):
|
|
842
|
+
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
843
|
+
return payload
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
|
|
847
|
+
if not isinstance(value, str) or not value.strip():
|
|
848
|
+
return {}
|
|
849
|
+
path = _resolve_project_path(project_root, value)
|
|
850
|
+
return _load_json_object(path, "active-run-context") if path.is_file() else {}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _resolve_required_path(project_root: Path, manifest: Mapping[str, Any], key: str) -> Path:
|
|
854
|
+
return _resolve_project_path(project_root, _require_string(manifest, key))
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _resolve_project_path(project_root: Path, value: str) -> Path:
|
|
858
|
+
path = Path(value)
|
|
859
|
+
return path if path.is_absolute() else project_root / path
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _optional_path(value: object) -> Path | None:
|
|
863
|
+
if not isinstance(value, str) or not value.strip():
|
|
864
|
+
return None
|
|
865
|
+
return Path(value)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
869
|
+
value = payload.get(key)
|
|
870
|
+
if not isinstance(value, str) or not value.strip():
|
|
871
|
+
raise DispatchError(f"required string missing: {key}")
|
|
872
|
+
return value.strip()
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _string_value(value: Any) -> str:
|
|
876
|
+
return value.strip() if isinstance(value, str) else ""
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _string_list(value: Any) -> list[str]:
|
|
880
|
+
if not isinstance(value, list):
|
|
881
|
+
return []
|
|
882
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _run_seq(manifest: Mapping[str, Any]) -> str:
|
|
886
|
+
seqs = manifest.get("runSequencesByCategory")
|
|
887
|
+
if isinstance(seqs, Mapping):
|
|
888
|
+
return _string_value(seqs.get("manifests")) or "001"
|
|
889
|
+
return "001"
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _utc_now() -> str:
|
|
893
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
897
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|