okstra 0.163.2 → 0.164.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 (112) hide show
  1. package/README.md +7 -5
  2. package/docs/architecture.md +12 -7
  3. package/docs/cli.md +8 -5
  4. package/docs/project-structure-overview.md +7 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/claude-worker.md +1 -0
  8. package/runtime/bin/lib/okstra/cli.sh +5 -0
  9. package/runtime/bin/lib/okstra/globals.sh +2 -0
  10. package/runtime/bin/lib/okstra/usage.sh +5 -5
  11. package/runtime/bin/okstra.sh +87 -91
  12. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  13. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  14. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  15. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  16. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  17. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  18. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  19. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  20. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  21. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  22. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  23. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  24. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  25. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  26. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  27. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  28. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  29. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
  30. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  31. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  32. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  33. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  34. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  35. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
  36. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  37. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  39. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  41. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
  43. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  44. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
  45. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  46. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
  47. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  48. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
  49. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  50. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
  51. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  52. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  53. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  54. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  55. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  56. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  57. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  58. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  59. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  60. package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
  61. package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
  62. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  63. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  64. package/runtime/python/okstra_ctl/domain/provider.py +66 -0
  65. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  66. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  67. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  68. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  69. package/runtime/python/okstra_ctl/models.py +54 -269
  70. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  71. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  72. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  73. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  74. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  75. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  76. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  77. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  78. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  79. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  80. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  81. package/runtime/python/okstra_ctl/render.py +145 -47
  82. package/runtime/python/okstra_ctl/run.py +80 -58
  83. package/runtime/python/okstra_ctl/session.py +1 -1
  84. package/runtime/python/okstra_ctl/team.py +44 -32
  85. package/runtime/python/okstra_ctl/wizard.py +162 -64
  86. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  87. package/runtime/python/okstra_token_usage/collect.py +34 -6
  88. package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
  89. package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
  90. package/runtime/skills/okstra-run/SKILL.md +73 -28
  91. package/runtime/templates/implementation-worker-preamble.md +1 -1
  92. package/runtime/templates/reports/final-report.template.md +3 -3
  93. package/runtime/templates/reports/html/i18n/en.json +3 -1
  94. package/runtime/templates/reports/html/i18n/ko.json +3 -1
  95. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
  96. package/runtime/templates/worker-prompt-preamble.md +1 -1
  97. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  98. package/runtime/validators/validate-report-views.py +0 -39
  99. package/runtime/validators/validate-run.py +8 -7
  100. package/runtime/validators/validate_session_conformance.py +7 -2
  101. package/src/commands/execute/render-bundle.mjs +4 -4
  102. package/src/commands/execute/run.mjs +8 -25
  103. package/src/commands/execute/wizard.mjs +33 -13
  104. package/src/commands/lifecycle/doctor.mjs +10 -10
  105. package/src/commands/lifecycle/install.mjs +53 -30
  106. package/src/commands/lifecycle/preflight.mjs +14 -4
  107. package/src/lib/host-registry-client.mjs +176 -0
  108. package/src/lib/runtime-manifest.mjs +6 -8
  109. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  110. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  111. package/src/lib/runtime-readiness.mjs +0 -90
  112. package/src/lib/runtime-resolver.mjs +0 -123
@@ -1,477 +1,81 @@
1
1
  """CLI-wrapper dispatcher for a Codex-hosted Okstra lead.
2
2
 
3
3
  The prepared assignment's ``runner`` field is authoritative. Native Codex
4
- assignments stay in the host session; this module runs only ``cli-wrapper``
5
- assignments through their registered provider wrappers.
4
+ assignments stay in the host session; this entrypoint executes only the backend
5
+ plan returned by the worker-dispatch application use case.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import json
11
- import subprocess
12
11
  import sys
13
- from dataclasses import dataclass
14
- from datetime import datetime, timezone
15
12
  from pathlib import Path
16
13
  from typing import Any, Mapping, Sequence
17
14
 
18
- from .final_report_paths import (
19
- final_report_data_path as _final_report_data_path,
20
- final_report_markdown_path as _final_report_markdown_path,
21
- )
22
- from .initial_prompt_materialization import (
23
- InitialPromptMaterializationError,
24
- InitialPromptMaterializationRequest,
25
- InitialPromptWorkerRequest,
26
- PromptDeliveryMode,
27
- materialize_initial_prompts,
28
- )
29
- from .lead_events import LeadEvent, append_lead_event
30
- from .dispatch_state import (
31
- BACKEND_CLI_WRAPPER,
32
- BACKEND_MIXED,
33
- BACKEND_TMUX_PANE,
34
- DispatchError,
35
- WorkerJob,
36
- dispatch_mode as _dispatch_mode,
37
- load_json_object as _load_json_object,
38
- missing_completion_paths as _missing_completion_paths,
39
- LIVENESS_WRAPPER_STATUS,
40
- require_string as _require_string,
41
- resolve_project_path as _resolve_project_path,
42
- resolve_required_path as _resolve_required_path,
43
- set_dispatch_mode as _set_dispatch_mode,
44
- string_list as _string_list,
45
- string_value as _string_value,
46
- transition_worker_status as _transition_worker_status,
47
- utc_now as _utc_now,
48
- validate_dispatch_prompts as _validate_dispatch_prompts,
49
- worker_jobs_from_file as _worker_jobs_from_file,
50
- worker_state as _worker_state,
51
- worktree_path as _worktree_path,
52
- write_json as _write_json,
53
- )
54
- from .path_hints import hydrate_active_run_context
55
- from .report_finalize import (
56
- STEP_VALIDATE_RUN,
57
- FinalizeContext,
58
- FinalizeError,
59
- resolve_optional_path as _resolve_optional_path,
60
- run_finalize,
61
- run_seq as _run_seq,
62
- )
63
- from .wrapper_status import status_path_for_prompt
64
- from .worker_prompt_body import REPORT_WRITER_WORKER_ID
15
+ from . import dispatch_core
16
+ from .adapters.dispatch import with_worker_dispatch
17
+ from .adapters.dispatch.cli_wrapper import CliWrapperDispatchPort
18
+ from .application.dispatch_assignments import dispatch_assignments
19
+ from .dispatch_state import DispatchError
65
20
  from .models import provider_wrappers
21
+ from .ports.worker_dispatch import WorkerDispatchRequest
22
+ from .registry.host_registry import default_host_registry
66
23
 
67
24
 
68
25
  SUPPORTED_CLI_WORKERS = provider_wrappers("analyser")
69
- MAX_WORKER_ATTEMPTS = 2
70
-
71
-
72
- @dataclass(frozen=True)
73
- class DispatchPlan:
74
- project_root: Path
75
- workspace_root: Path
76
- manifest_path: Path
77
- team_state_path: Path
78
- lead_events_path: Path
79
- manifest: Mapping[str, Any]
80
- workers: tuple[WorkerJob, ...]
81
-
82
- def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
83
- return {
84
- "ok": True,
85
- "dryRun": dry_run,
86
- "dispatchMode": _dispatch_mode(self.workers),
87
- "workerBackends": {
88
- worker.worker_id: worker.backend
89
- for worker in self.workers
90
- },
91
- "runManifestPath": str(self.manifest_path),
92
- "teamStatePath": str(self.team_state_path),
93
- "leadEventsPath": str(self.lead_events_path),
94
- "workspaceRoot": str(self.workspace_root),
95
- "workers": [worker.to_payload() for worker in self.workers],
96
- }
97
-
98
-
99
- @dataclass(frozen=True)
100
- class _DispatchInputs:
101
- project_root: Path
102
- manifest_path: Path
103
- manifest: Mapping[str, Any]
104
- selected_workers: tuple[str, ...]
105
- team_state_path: Path
106
- team_state: Mapping[str, Any]
107
- active_context: Mapping[str, Any]
108
- lead_events_path: Path
109
-
110
-
111
- def build_dispatch_plan(
112
- *,
113
- project_root: Path,
114
- run_manifest_path: Path,
115
- workspace_root: Path,
116
- okstra_bin: Path | None = None,
117
- requested_workers: Sequence[str] = (),
118
- idle_timeout_seconds: int = 600,
119
- dispatch_kind: str = "initial",
120
- jobs_file: Path | None = None,
121
- ) -> DispatchPlan:
122
- inputs = _load_dispatch_inputs(
123
- project_root,
124
- run_manifest_path,
125
- requested_workers,
126
- select_roster=jobs_file is None,
127
- )
128
- if jobs_file is not None:
129
- workers = _codex_jobs_from_file(
130
- inputs,
131
- jobs_file,
132
- workspace_root=workspace_root,
133
- okstra_bin=okstra_bin,
134
- idle_timeout_seconds=idle_timeout_seconds,
135
- dispatch_kind=dispatch_kind,
136
- )
137
- else:
138
- worker_requests, prompt_paths = _materialize_selected_worker_prompts(
139
- project_root=inputs.project_root,
140
- manifest_path=inputs.manifest_path,
141
- workspace_root=workspace_root,
142
- team_state=inputs.team_state,
143
- selected_workers=inputs.selected_workers,
144
- )
145
- workers = _build_selected_worker_dispatches(
146
- project_root=inputs.project_root,
147
- manifest=inputs.manifest,
148
- team_state=inputs.team_state,
149
- active_context=inputs.active_context,
150
- workspace_root=workspace_root,
151
- okstra_bin=okstra_bin,
152
- idle_timeout_seconds=idle_timeout_seconds,
153
- selected_workers=inputs.selected_workers,
154
- worker_requests=worker_requests,
155
- prompt_paths=prompt_paths,
156
- )
157
- return DispatchPlan(
158
- project_root=inputs.project_root,
159
- workspace_root=workspace_root,
160
- manifest_path=inputs.manifest_path,
161
- team_state_path=inputs.team_state_path,
162
- lead_events_path=inputs.lead_events_path,
163
- manifest=inputs.manifest,
164
- workers=workers,
165
- )
166
-
167
-
168
- def _codex_jobs_from_file(
169
- inputs: _DispatchInputs,
170
- jobs_file: Path,
171
- *,
172
- workspace_root: Path,
173
- okstra_bin: Path | None,
174
- idle_timeout_seconds: int,
175
- dispatch_kind: str,
176
- ) -> list[WorkerJob]:
177
- workers = _worker_jobs_from_file(
178
- inputs.project_root,
179
- jobs_file,
180
- backend=BACKEND_CLI_WRAPPER,
181
- idle_timeout_seconds=idle_timeout_seconds,
182
- default_dispatch_kind=dispatch_kind,
183
- resolve_wrapper=lambda provider: _resolve_wrapper(
184
- provider, workspace_root, okstra_bin
185
- ),
186
- default_provider=lambda worker_id: (
187
- "codex" if worker_id == REPORT_WRITER_WORKER_ID else worker_id
188
- ),
189
- )
190
- _validate_dispatch_prompts(inputs.manifest, inputs.active_context, workers)
191
- return workers
192
-
193
-
194
- def _load_dispatch_inputs(
195
- project_root: Path,
196
- run_manifest_path: Path,
197
- requested_workers: Sequence[str],
198
- *,
199
- select_roster: bool = True,
200
- ) -> _DispatchInputs:
201
- project_root = project_root.resolve()
202
- manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
203
- manifest = _load_json_object(manifest_path, "run manifest")
204
- _validate_codex_manifest(manifest, manifest_path)
205
- team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
206
- team_state = _load_json_object(team_state_path, "team-state")
207
- selected_workers = (
208
- tuple(
209
- _select_workers(
210
- manifest,
211
- team_state,
212
- requested_workers,
213
- )
214
- )
215
- if select_roster
216
- else ()
217
- )
218
- if select_roster:
219
- _mark_skipped_workers(
220
- team_state_path,
221
- team_state,
222
- _skipped_worker_reasons(
223
- manifest,
224
- team_state,
225
- selected_workers,
226
- requested_workers,
227
- ),
228
- )
229
- active_context = _load_optional_json_object(
230
- _resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
231
- )
232
- return _DispatchInputs(
233
- project_root=project_root,
234
- manifest_path=manifest_path,
235
- manifest=manifest,
236
- selected_workers=selected_workers,
237
- team_state_path=team_state_path,
238
- team_state=team_state,
239
- active_context=active_context,
240
- lead_events_path=_resolve_required_path(project_root, manifest, "leadEventsPath"),
241
- )
242
-
243
-
244
- def _materialize_selected_worker_prompts(
245
- *,
246
- project_root: Path,
247
- manifest_path: Path,
248
- workspace_root: Path,
249
- team_state: Mapping[str, Any],
250
- selected_workers: Sequence[str],
251
- ) -> tuple[tuple[InitialPromptWorkerRequest, ...], tuple[Path, ...]]:
252
- worker_requests = tuple(
253
- InitialPromptWorkerRequest(
254
- worker_id=worker_id,
255
- model=_requested_worker_model(worker_id, team_state),
256
- )
257
- for worker_id in selected_workers
258
- )
259
- try:
260
- prompt_paths = materialize_initial_prompts(
261
- InitialPromptMaterializationRequest(
262
- project_root=project_root,
263
- run_manifest_path=manifest_path,
264
- runtime_root=workspace_root,
265
- delivery_mode=PromptDeliveryMode.EAGER_INCLUDE,
266
- workers=worker_requests,
267
- )
268
- )
269
- except InitialPromptMaterializationError as exc:
270
- raise DispatchError(f"{exc.reason}: {exc}") from exc
271
- return worker_requests, prompt_paths
272
-
273
-
274
- def _build_selected_worker_dispatches(
275
- *,
276
- project_root: Path,
277
- manifest: Mapping[str, Any],
278
- team_state: Mapping[str, Any],
279
- active_context: Mapping[str, Any],
280
- workspace_root: Path,
281
- okstra_bin: Path | None,
282
- idle_timeout_seconds: int,
283
- selected_workers: Sequence[str],
284
- worker_requests: Sequence[InitialPromptWorkerRequest],
285
- prompt_paths: Mapping[str, Path],
286
- ) -> tuple[WorkerJob, ...]:
287
- return tuple(
288
- _build_worker_dispatch(
289
- worker_id=worker_id,
290
- project_root=project_root,
291
- manifest=manifest,
292
- team_state=team_state,
293
- active_context=active_context,
294
- workspace_root=workspace_root,
295
- okstra_bin=okstra_bin,
296
- idle_timeout_seconds=idle_timeout_seconds,
297
- model_execution_value=worker_request.model,
298
- prompt_path=prompt_path,
299
- )
300
- for worker_id, worker_request, prompt_path in (
301
- (worker_id, worker_request, prompt_paths[worker_id])
302
- for worker_id, worker_request in zip(
303
- selected_workers, worker_requests, strict=True
304
- )
305
- )
306
- )
307
-
308
-
309
- def dispatch_plan(plan: DispatchPlan) -> int:
310
- _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
311
- for worker in plan.workers:
312
- completed = _dispatch_worker_with_retry(plan, worker)
313
- if completed == 0:
314
- continue
315
- return completed
316
- return 0
317
-
318
-
319
- def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
320
- for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
321
- _transition_worker_status(
322
- plan.team_state_path,
323
- worker.worker_id,
324
- "in-progress",
325
- "",
326
- model_execution_value=worker.model_execution_value,
327
- )
328
- _record_worker_attempt(plan, worker, attempt, "running")
329
- attempt_details = _attempt_event_details(worker, attempt)
330
- _append_event(plan, "worker-dispatched", attempt_details)
331
- result = _run_worker(plan, worker)
332
- missing_paths = _missing_completion_paths(worker)
333
- if result.returncode == 0 and not missing_paths:
334
- post_process = _post_process_report_writer_result(plan, worker)
335
- if not post_process["ok"]:
336
- reason = _require_string(post_process, "reason")
337
- _transition_worker_status(
338
- plan.team_state_path, worker.worker_id, "error", reason
339
- )
340
- _record_worker_attempt(plan, worker, attempt, "error", reason)
341
- details = {
342
- **attempt_details,
343
- "exitCode": 1,
344
- "missingCompletionPaths": [],
345
- "reason": reason,
346
- "postProcessing": post_process["steps"],
347
- }
348
- _append_event(plan, "worker-failed", details)
349
- return 1
350
- _transition_worker_status(
351
- plan.team_state_path, worker.worker_id, "completed", ""
352
- )
353
- _record_worker_attempt(plan, worker, attempt, "completed")
354
- details = {
355
- **attempt_details,
356
- "missingCompletionPaths": [],
357
- "postProcessing": post_process["steps"],
358
- }
359
- _append_event(plan, "worker-result-collected", details)
360
- return 0
361
- if _should_retry_missing_result(result, missing_paths, attempt):
362
- retry_details = {
363
- **attempt_details,
364
- "missingCompletionPaths": [str(path) for path in missing_paths],
365
- "reason": "required worker artifact was not produced",
366
- }
367
- _record_worker_attempt(
368
- plan,
369
- worker,
370
- attempt,
371
- "error",
372
- "required worker artifact was not produced",
373
- )
374
- _append_event(plan, "worker-retry-scheduled", retry_details)
375
- continue
376
-
377
- reason = _failure_reason(result.returncode, missing_paths)
378
- _transition_worker_status(
379
- plan.team_state_path, worker.worker_id, "error", reason
380
- )
381
- _record_worker_attempt(plan, worker, attempt, "error", reason)
382
- details = {
383
- **attempt_details,
384
- "exitCode": result.returncode,
385
- "missingCompletionPaths": [str(path) for path in missing_paths],
386
- "reason": reason,
387
- }
388
- _append_event(plan, "worker-failed", details)
389
- return result.returncode if result.returncode else 1
390
- return 1
391
- def _record_worker_attempt(
392
- plan: DispatchPlan,
393
- worker: WorkerJob,
394
- attempt: int,
395
- status: str,
396
- reason: str = "",
397
- ) -> None:
398
- payload = _load_json_object(plan.team_state_path, "team-state")
399
- dispatches = payload.setdefault("workerDispatches", [])
400
- if not isinstance(dispatches, list):
401
- raise DispatchError(
402
- f"team-state workerDispatches must be an array: {plan.team_state_path}"
403
- )
404
- for record in dispatches:
405
- if _matches_worker_attempt(record, worker, attempt):
406
- record["status"] = status
407
- record["reason"] = reason
408
- _write_json(plan.team_state_path, payload)
409
- return
410
- dispatches.append(_worker_dispatch_record(worker, attempt, status, reason))
411
- _write_json(plan.team_state_path, payload)
412
-
413
-
414
- def _matches_worker_attempt(
415
- record: object, worker: WorkerJob, attempt: int
416
- ) -> bool:
417
- return (
418
- isinstance(record, dict)
419
- and record.get("workerId") == worker.worker_id
420
- and record.get("kind") == worker.dispatch_kind
421
- and record.get("attempt") == attempt
422
- )
423
-
424
-
425
- def _worker_dispatch_record(
426
- worker: WorkerJob, attempt: int, status: str, reason: str
427
- ) -> dict[str, Any]:
428
- return {
429
- "workerId": worker.worker_id,
430
- "role": worker.role,
431
- "kind": worker.dispatch_kind,
432
- "attempt": attempt,
433
- "backendType": worker.backend,
434
- "provider": worker.provider,
435
- "status": status,
436
- "paneId": "",
437
- "promptPath": str(worker.prompt_path),
438
- "resultPath": str(worker.result_path),
439
- "workerResultPath": str(worker.worker_result_path),
440
- "livenessMode": LIVENESS_WRAPPER_STATUS,
441
- "statusSidecarPath": str(status_path_for_prompt(worker.prompt_path)),
442
- "degradedFrom": "",
443
- "reason": reason,
444
- }
445
26
 
446
27
 
447
28
  def main(argv: Sequence[str] | None = None) -> int:
448
- parser = _parser()
449
- args = parser.parse_args(argv)
29
+ args = _parser().parse_args(argv)
450
30
  try:
451
31
  if args.jobs_file and args.workers:
452
32
  raise DispatchError("--jobs-file and --workers cannot be combined")
453
- idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
454
- plan = build_dispatch_plan(
455
- project_root=Path(args.project_root),
456
- run_manifest_path=Path(args.run_manifest),
457
- workspace_root=Path(args.workspace_root),
458
- okstra_bin=Path(args.okstra_bin) if args.okstra_bin else None,
459
- requested_workers=_parse_workers(args.workers),
460
- idle_timeout_seconds=idle_timeout_seconds,
461
- dispatch_kind=args.dispatch_kind,
462
- jobs_file=Path(args.jobs_file) if args.jobs_file else None,
463
- )
33
+ request = _dispatch_request(args)
34
+ port = CliWrapperDispatchPort(
35
+ supported_worker_wrappers=SUPPORTED_CLI_WORKERS,
36
+ unsupported_worker_label="Codex lead",
37
+ required_lead_runtime="codex",
38
+ cli_wrapper_assignments_only=True,
39
+ default_provider_by_worker_id={"report-writer": "codex"},
40
+ )
41
+ host_adapter = with_worker_dispatch(
42
+ default_host_registry().resolve("codex"),
43
+ port,
44
+ )
45
+ plan = dispatch_assignments(request, host_adapter.worker_dispatch())
46
+ backend_plan = plan.backend_plan
47
+ if backend_plan is None:
48
+ raise DispatchError("CLI wrapper dispatch did not produce a backend plan")
464
49
  if args.dry_run:
465
- _print_json(plan.to_payload(dry_run=True))
50
+ _print_json(_backend_payload(backend_plan, dry_run=True))
466
51
  return 0
467
- code = dispatch_plan(plan)
468
- _print_json({**plan.to_payload(dry_run=False), "exitCode": code})
52
+ code = dispatch_core.dispatch_plan(backend_plan)
53
+ _print_json({**_backend_payload(backend_plan, dry_run=False), "exitCode": code})
469
54
  return code
470
- except (DispatchError, FinalizeError) as exc:
55
+ except DispatchError as exc:
471
56
  print(f"error: {exc}", file=sys.stderr)
472
57
  return 2
473
58
 
474
59
 
60
+ def _dispatch_request(args: argparse.Namespace) -> WorkerDispatchRequest:
61
+ return WorkerDispatchRequest(
62
+ project_root=Path(args.project_root),
63
+ run_manifest=Path(args.run_manifest),
64
+ workspace_root=Path(args.workspace_root),
65
+ okstra_bin=Path(args.okstra_bin) if args.okstra_bin else None,
66
+ requested_workers=tuple(_parse_workers(args.workers)),
67
+ idle_timeout_seconds=_parse_idle_timeout(args.idle_timeout_seconds),
68
+ dispatch_kind=args.dispatch_kind,
69
+ jobs_file=Path(args.jobs_file) if args.jobs_file else None,
70
+ )
71
+
72
+
73
+ def _backend_payload(backend_plan, *, dry_run: bool) -> dict[str, Any]:
74
+ payload = backend_plan.to_payload(dry_run=dry_run)
75
+ payload.pop("workerDispatches", None)
76
+ return payload
77
+
78
+
475
79
  def _parser() -> argparse.ArgumentParser:
476
80
  parser = argparse.ArgumentParser(
477
81
  prog="okstra codex-dispatch",
@@ -489,387 +93,6 @@ def _parser() -> argparse.ArgumentParser:
489
93
  return parser
490
94
 
491
95
 
492
- def _build_worker_dispatch(
493
- *,
494
- worker_id: str,
495
- project_root: Path,
496
- manifest: Mapping[str, Any],
497
- team_state: Mapping[str, Any],
498
- active_context: Mapping[str, Any],
499
- workspace_root: Path,
500
- okstra_bin: Path | None,
501
- idle_timeout_seconds: int,
502
- model_execution_value: str,
503
- prompt_path: Path,
504
- ) -> WorkerJob:
505
- worker_state = _worker_state(team_state, worker_id)
506
- provider = _assigned_provider(worker_state, worker_id)
507
- if worker_id == REPORT_WRITER_WORKER_ID:
508
- return _build_report_writer_dispatch(
509
- project_root=project_root,
510
- manifest=manifest,
511
- team_state=team_state,
512
- workspace_root=workspace_root,
513
- okstra_bin=okstra_bin,
514
- idle_timeout_seconds=idle_timeout_seconds,
515
- model_execution_value=model_execution_value,
516
- prompt_path=prompt_path,
517
- provider=provider,
518
- )
519
- result_path = _resolve_project_path(
520
- project_root, _require_string(worker_state, "resultPath")
521
- )
522
- wrapper = _resolve_wrapper(provider, workspace_root, okstra_bin)
523
- worktree_path = _worktree_path(manifest, active_context)
524
- role = _pane_role(prompt_path)
525
- return WorkerJob(
526
- worker_id=worker_id,
527
- provider=provider,
528
- backend=BACKEND_CLI_WRAPPER,
529
- project_root=project_root,
530
- model_execution_value=model_execution_value,
531
- wrapper_path=wrapper,
532
- prompt_path=prompt_path,
533
- result_path=result_path,
534
- worker_result_path=result_path,
535
- completion_paths=(result_path,),
536
- worktree_path=worktree_path,
537
- role=role,
538
- idle_timeout_seconds=idle_timeout_seconds,
539
- dispatch_kind="initial",
540
- )
541
-
542
-
543
- def _build_report_writer_dispatch(
544
- *,
545
- project_root: Path,
546
- manifest: Mapping[str, Any],
547
- team_state: Mapping[str, Any],
548
- workspace_root: Path,
549
- okstra_bin: Path | None,
550
- idle_timeout_seconds: int,
551
- model_execution_value: str,
552
- prompt_path: Path,
553
- provider: str,
554
- ) -> WorkerJob:
555
- worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
556
- data_json_path = _final_report_data_path(
557
- _resolve_required_path(project_root, manifest, "expectedReportPath")
558
- )
559
- markdown_path = _final_report_markdown_path(data_json_path)
560
- worker_result_path = _resolve_project_path(
561
- project_root, _require_string(worker_state, "resultPath")
562
- )
563
- return WorkerJob(
564
- worker_id=REPORT_WRITER_WORKER_ID,
565
- provider=provider,
566
- backend=BACKEND_CLI_WRAPPER,
567
- project_root=project_root,
568
- model_execution_value=model_execution_value,
569
- wrapper_path=_resolve_wrapper(provider, workspace_root, okstra_bin),
570
- prompt_path=prompt_path,
571
- result_path=data_json_path,
572
- worker_result_path=worker_result_path,
573
- completion_paths=(
574
- data_json_path,
575
- markdown_path,
576
- worker_result_path,
577
- ),
578
- worktree_path="",
579
- role=REPORT_WRITER_WORKER_ID,
580
- idle_timeout_seconds=idle_timeout_seconds,
581
- dispatch_kind="initial",
582
- )
583
-
584
-
585
- def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
586
- if manifest.get("leadRuntime") != "codex":
587
- raise DispatchError(f"run manifest is not a Codex lead run: {path}")
588
- if not isinstance(manifest.get("leadEventsPath"), str):
589
- raise DispatchError(f"run manifest has no leadEventsPath: {path}")
590
-
591
-
592
- def _requested_worker_model(
593
- worker_id: str,
594
- team_state: Mapping[str, Any],
595
- ) -> str:
596
- state = _worker_state(team_state, worker_id)
597
- return _require_string(state, "modelExecutionValue")
598
-
599
-
600
- def _select_workers(
601
- manifest: Mapping[str, Any],
602
- team_state: Mapping[str, Any],
603
- requested_workers: Sequence[str],
604
- ) -> list[str]:
605
- recommended = _string_list(manifest.get("recommendedWorkers"))
606
- if not recommended:
607
- raise DispatchError("run manifest has no recommendedWorkers entries")
608
- dispatchable = _dispatchable_workers(recommended, team_state)
609
- if not requested_workers:
610
- selected = [worker for worker in recommended if worker in dispatchable]
611
- if selected:
612
- return selected
613
- raise DispatchError(
614
- "run roster has no cli-wrapper assignments for codex-dispatch"
615
- )
616
-
617
- selected = list(requested_workers)
618
- unknown = [worker for worker in selected if worker not in recommended]
619
- if unknown:
620
- raise DispatchError(
621
- "requested worker(s) are not in this run roster: " + ", ".join(unknown)
622
- )
623
- native = [
624
- worker for worker in selected
625
- if _assigned_runner(_worker_state(team_state, worker)) == "native-session"
626
- ]
627
- if native:
628
- raise DispatchError(
629
- "requested worker(s) use runner=native-session and must be dispatched "
630
- "by the current Codex host: " + ", ".join(native)
631
- )
632
- unsupported = [worker for worker in selected if worker not in dispatchable]
633
- if unsupported:
634
- supported = ", ".join(sorted(dispatchable))
635
- raise DispatchError(
636
- "unsupported Codex lead worker(s): "
637
- + ", ".join(unsupported)
638
- + f" (CLI dispatcher supports: {supported})"
639
- )
640
- return selected
641
-
642
-
643
- def _assigned_provider(worker_state: Mapping[str, Any], worker_id: str) -> str:
644
- provider = _string_value(worker_state.get("provider"))
645
- if provider:
646
- return provider
647
- return "codex" if worker_id == REPORT_WRITER_WORKER_ID else worker_id
648
-
649
-
650
- def _assigned_runner(worker_state: Mapping[str, Any]) -> str:
651
- return _string_value(worker_state.get("runner")) or BACKEND_CLI_WRAPPER
652
-
653
-
654
- def _dispatchable_workers(
655
- recommended: Sequence[str], team_state: Mapping[str, Any],
656
- ) -> set[str]:
657
- dispatchable = set()
658
- for worker_id in recommended:
659
- state = _worker_state(team_state, worker_id)
660
- provider = _assigned_provider(state, worker_id)
661
- if (
662
- _assigned_runner(state) == BACKEND_CLI_WRAPPER
663
- and provider in SUPPORTED_CLI_WORKERS
664
- ):
665
- dispatchable.add(worker_id)
666
- return dispatchable
667
-
668
-
669
- def _skipped_worker_reasons(
670
- manifest: Mapping[str, Any],
671
- team_state: Mapping[str, Any],
672
- selected_workers: Sequence[str],
673
- requested_workers: Sequence[str],
674
- ) -> dict[str, str]:
675
- recommended = _string_list(manifest.get("recommendedWorkers"))
676
- selected = set(selected_workers)
677
- if requested_workers:
678
- return {
679
- worker_id: "skipped by Codex dispatch: worker was not requested in this invocation"
680
- for worker_id in recommended
681
- if worker_id not in selected
682
- and _assigned_runner(_worker_state(team_state, worker_id))
683
- == BACKEND_CLI_WRAPPER
684
- }
685
-
686
- supported = _dispatchable_workers(recommended, team_state)
687
- reasons = {}
688
- for worker_id in recommended:
689
- if worker_id in selected:
690
- continue
691
- state = _worker_state(team_state, worker_id)
692
- if _assigned_runner(state) == "native-session":
693
- continue
694
- if worker_id not in supported:
695
- reasons[worker_id] = (
696
- "skipped by Codex dispatch default: assigned provider has no "
697
- "registered CLI wrapper"
698
- )
699
- else:
700
- reasons[worker_id] = "skipped by Codex dispatch default"
701
- return reasons
702
-
703
-
704
- def _mark_skipped_workers(
705
- team_state_path: Path,
706
- team_state: Mapping[str, Any],
707
- reasons_by_worker_id: Mapping[str, str],
708
- ) -> None:
709
- if not reasons_by_worker_id:
710
- return
711
- workers = team_state.get("workers")
712
- if not isinstance(workers, list):
713
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
714
- for worker in workers:
715
- if not isinstance(worker, dict):
716
- continue
717
- worker_id = _string_value(worker.get("workerId"))
718
- reason = reasons_by_worker_id.get(worker_id)
719
- if not reason:
720
- continue
721
- status = _string_value(worker.get("status")) or "not-run"
722
- current_reason = _string_value(worker.get("reason"))
723
- if status == "not-run" and not current_reason:
724
- _transition_worker_status(
725
- team_state_path, worker_id, "not-run", reason
726
- )
727
-
728
-
729
- def _resolve_wrapper(
730
- worker_id: str,
731
- workspace_root: Path,
732
- okstra_bin: Path | None,
733
- ) -> Path:
734
- script = SUPPORTED_CLI_WORKERS.get(worker_id)
735
- if script is None:
736
- raise DispatchError(f"unsupported worker provider: {worker_id}")
737
- candidates = []
738
- if okstra_bin is not None:
739
- candidates.append(okstra_bin / script)
740
- candidates.extend([
741
- workspace_root / "bin" / script,
742
- workspace_root / "scripts" / script,
743
- ])
744
- for candidate in candidates:
745
- if candidate.is_file():
746
- return candidate.resolve()
747
- searched = ", ".join(str(candidate) for candidate in candidates)
748
- raise DispatchError(f"{script} not found (searched: {searched})")
749
-
750
-
751
- def _run_worker(
752
- plan: DispatchPlan,
753
- worker: WorkerJob,
754
- ) -> subprocess.CompletedProcess[str]:
755
- if worker.backend == BACKEND_CLI_WRAPPER:
756
- return _run_cli_wrapper_worker(plan, worker)
757
- raise DispatchError(f"unsupported Codex worker backend: {worker.backend}")
758
-
759
-
760
- def _run_cli_wrapper_worker(
761
- plan: DispatchPlan,
762
- worker: WorkerJob,
763
- ) -> subprocess.CompletedProcess[str]:
764
- return subprocess.run(worker.command, cwd=plan.project_root, text=True)
765
-
766
-
767
- def _post_process_report_writer_result(
768
- plan: DispatchPlan,
769
- worker: WorkerJob,
770
- ) -> dict[str, Any]:
771
- if worker.worker_id != REPORT_WRITER_WORKER_ID:
772
- return {"ok": True, "reason": "", "steps": []}
773
- try:
774
- ctx = FinalizeContext.from_manifest(
775
- project_root=plan.project_root,
776
- workspace_root=plan.workspace_root,
777
- manifest_path=plan.manifest_path,
778
- team_state_path=plan.team_state_path,
779
- data_path=worker.result_path,
780
- manifest=plan.manifest,
781
- )
782
- except FinalizeError as exc:
783
- return {"ok": False, "reason": str(exc), "steps": []}
784
-
785
- def settle_before(step_name: str) -> None:
786
- # validate-run reads team-state; the writer is done by the time its
787
- # artifacts pass the earlier steps.
788
- if step_name == STEP_VALIDATE_RUN:
789
- _transition_worker_status(
790
- plan.team_state_path, worker.worker_id, "completed", ""
791
- )
792
-
793
- return run_finalize(ctx, before_step=settle_before)
794
-
795
-
796
- def _append_event(
797
- plan: DispatchPlan,
798
- event_type: str,
799
- details: Mapping[str, Any],
800
- ) -> None:
801
- append_lead_event(
802
- plan.lead_events_path,
803
- LeadEvent(
804
- event_type=event_type,
805
- lead_runtime="codex",
806
- task_key=_require_string(plan.manifest, "taskKey"),
807
- task_type=_require_string(plan.manifest, "taskType"),
808
- run_seq=_run_seq(plan.manifest),
809
- timestamp=_utc_now(),
810
- details=details,
811
- ),
812
- )
813
-
814
-
815
- def _event_details(worker: WorkerJob) -> dict[str, Any]:
816
- return {
817
- "dispatchMode": worker.backend,
818
- "workerId": worker.worker_id,
819
- "promptPath": str(worker.prompt_path),
820
- "resultPath": str(worker.result_path),
821
- "workerResultPath": str(worker.worker_result_path),
822
- "wrapperPath": str(worker.wrapper_path),
823
- "role": worker.role,
824
- }
825
-
826
-
827
- def _attempt_event_details(worker: WorkerJob, attempt: int) -> dict[str, Any]:
828
- return {
829
- **_event_details(worker),
830
- "attempt": attempt,
831
- "maxAttempts": MAX_WORKER_ATTEMPTS,
832
- }
833
-
834
-
835
- def _should_retry_missing_result(
836
- result: subprocess.CompletedProcess[str],
837
- missing_paths: Sequence[Path],
838
- attempt: int,
839
- ) -> bool:
840
- return (
841
- result.returncode == 0
842
- and bool(missing_paths)
843
- and attempt < MAX_WORKER_ATTEMPTS
844
- )
845
-
846
-
847
- def _pane_role(prompt_path: Path) -> str:
848
- if not prompt_path.is_file():
849
- raise DispatchError(f"worker prompt not found: {prompt_path}")
850
- marker = "**Pane role:**"
851
- for line in prompt_path.read_text(encoding="utf-8", errors="replace").splitlines():
852
- if line.startswith(marker):
853
- role = line[len(marker):].strip()
854
- return role or "worker"
855
- return "worker"
856
-
857
-
858
- def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
859
- if path is None or not path.is_file():
860
- return {}
861
- return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
862
-
863
-
864
- def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
865
- if exit_code != 0:
866
- return f"wrapper exited with code {exit_code}"
867
- if missing_paths:
868
- missing = ", ".join(str(path) for path in missing_paths)
869
- return f"required worker artifact was not produced: {missing}"
870
- return "worker dispatch failed"
871
-
872
-
873
96
  def _parse_workers(raw: str) -> list[str]:
874
97
  return [item.strip() for item in raw.split(",") if item.strip()]
875
98