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.
Files changed (65) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +1 -117
  4. package/docs/contributor-change-matrix.md +12 -0
  5. package/docs/kr/architecture.md +1 -1
  6. package/docs/kr/cli.md +22 -5
  7. package/docs/pr-template-usage.md +3 -3
  8. package/docs/project-structure-overview.md +1 -1
  9. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  10. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
  11. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  12. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
  13. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
  14. package/package.json +5 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/DO_NOT_EDIT.md +21 -0
  17. package/runtime/agents/SKILL.md +3 -0
  18. package/runtime/bin/lib/okstra/cli.sh +5 -1
  19. package/runtime/bin/lib/okstra/globals.sh +1 -0
  20. package/runtime/bin/lib/okstra/usage.sh +6 -4
  21. package/runtime/bin/okstra-trace-cleanup.sh +16 -12
  22. package/runtime/bin/okstra.sh +1 -0
  23. package/runtime/prompts/launch.template.md +4 -13
  24. package/runtime/prompts/profiles/error-analysis.md +6 -0
  25. package/runtime/prompts/profiles/forbidden-actions.json +69 -0
  26. package/runtime/prompts/profiles/implementation.md +1 -8
  27. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  28. package/runtime/prompts/profiles/release-handoff.md +3 -17
  29. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
  31. package/runtime/python/okstra_ctl/context_cost.py +1 -1
  32. package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
  33. package/runtime/python/okstra_ctl/doctor.py +292 -0
  34. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  35. package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
  36. package/runtime/python/okstra_ctl/paths.py +3 -0
  37. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  38. package/runtime/python/okstra_ctl/render.py +211 -29
  39. package/runtime/python/okstra_ctl/run.py +89 -18
  40. package/runtime/python/okstra_ctl/team.py +267 -0
  41. package/runtime/python/okstra_ctl/tmux.py +181 -10
  42. package/runtime/python/okstra_ctl/workflow.py +30 -71
  43. package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
  44. package/runtime/python/okstra_token_usage/codex.py +12 -7
  45. package/runtime/python/okstra_token_usage/collect.py +243 -54
  46. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  47. package/runtime/schemas/final-report-v1.0.schema.json +3 -1
  48. package/runtime/skills/okstra-convergence/SKILL.md +3 -0
  49. package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
  50. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
  52. package/runtime/validators/forbidden_actions.py +135 -0
  53. package/runtime/validators/validate-run.py +112 -22
  54. package/runtime/validators/validate_session_conformance.py +127 -5
  55. package/src/cli-registry.mjs +277 -0
  56. package/src/codex-dispatch.mjs +70 -0
  57. package/src/codex-run.mjs +68 -0
  58. package/src/doctor.mjs +130 -5
  59. package/src/install.mjs +123 -26
  60. package/src/paths.mjs +17 -3
  61. package/src/render-bundle.mjs +2 -0
  62. package/src/runtime-manifest.mjs +29 -0
  63. package/src/team.mjs +63 -0
  64. package/src/uninstall.mjs +27 -4
  65. /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")