okstra 0.144.0 → 0.146.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 (71) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +22 -4
  3. package/docs/cli.md +53 -6
  4. package/docs/project-structure-overview.md +19 -9
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +5 -6
  8. package/runtime/bin/okstra-trace-cleanup.sh +28 -2
  9. package/runtime/prompts/lead/adapters/claude-code.md +3 -3
  10. package/runtime/prompts/lead/convergence.md +30 -5
  11. package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
  12. package/runtime/prompts/lead/report-writer.md +20 -14
  13. package/runtime/prompts/lead/team-contract.md +3 -3
  14. package/runtime/prompts/profiles/_common-contract.md +1 -1
  15. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  16. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  18. package/runtime/prompts/profiles/project-analysis.md +24 -0
  19. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  20. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  21. package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
  22. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  23. package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
  24. package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
  25. package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
  26. package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
  27. package/runtime/python/okstra_ctl/path_hints.py +6 -0
  28. package/runtime/python/okstra_ctl/paths.py +7 -44
  29. package/runtime/python/okstra_ctl/render.py +79 -4
  30. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  31. package/runtime/python/okstra_ctl/report_views.py +134 -3
  32. package/runtime/python/okstra_ctl/run.py +118 -0
  33. package/runtime/python/okstra_ctl/run_context.py +34 -2
  34. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  35. package/runtime/python/okstra_ctl/user_response.py +309 -3
  36. package/runtime/python/okstra_ctl/wizard.py +579 -32
  37. package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
  38. package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
  39. package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  41. package/runtime/python/okstra_ctl/worker_state.py +65 -0
  42. package/runtime/python/okstra_ctl/workflow.py +22 -0
  43. package/runtime/python/okstra_token_usage/antigravity.py +3 -0
  44. package/runtime/python/okstra_token_usage/codex.py +54 -23
  45. package/runtime/python/okstra_token_usage/collect.py +141 -33
  46. package/runtime/python/okstra_token_usage/paths.py +27 -0
  47. package/runtime/python/okstra_vendor/__init__.py +15 -2
  48. package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
  49. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  50. package/runtime/skills/okstra-run/SKILL.md +27 -5
  51. package/runtime/skills/okstra-setup/references/project-config.md +13 -4
  52. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  53. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  54. package/runtime/templates/reports/final-report.template.md +220 -0
  55. package/runtime/templates/reports/i18n/en.json +8 -0
  56. package/runtime/templates/reports/i18n/ko.json +8 -0
  57. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  58. package/runtime/templates/reports/report.js +84 -5
  59. package/runtime/templates/reports/user-response.template.md +19 -1
  60. package/runtime/validators/lib/fixtures.sh +1 -1
  61. package/runtime/validators/validate-report-views.py +61 -7
  62. package/runtime/validators/validate-run.py +94 -1
  63. package/runtime/validators/validate_analysis_report.py +895 -0
  64. package/src/cli-registry.mjs +7 -10
  65. package/src/commands/execute/render-bundle.mjs +3 -0
  66. package/src/commands/execute/worker-state.mjs +29 -0
  67. package/src/commands/inspect/worker-liveness.mjs +5 -3
  68. package/src/commands/lifecycle/preflight.mjs +13 -3
  69. package/src/lib/runtime-readiness.mjs +90 -0
  70. package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
  71. package/src/commands/execute/phase-cleanup.mjs +0 -38
@@ -43,10 +43,12 @@ from .dispatch_state import (
43
43
  resolve_project_path as _resolve_project_path,
44
44
  resolve_required_path as _resolve_required_path,
45
45
  set_dispatch_mode as _set_dispatch_mode,
46
- set_worker_status as _set_worker_status,
47
46
  string_list as _string_list,
48
47
  string_value as _string_value,
48
+ transition_worker_status as _transition_worker_status,
49
49
  utc_now as _utc_now,
50
+ validate_dispatch_prompts as _validate_dispatch_prompts,
51
+ worker_jobs_from_file as _worker_jobs_from_file,
50
52
  worker_state as _worker_state,
51
53
  worktree_path as _worktree_path,
52
54
  write_json as _write_json,
@@ -120,33 +122,46 @@ def build_dispatch_plan(
120
122
  idle_timeout_seconds: int = 600,
121
123
  enable_codex_report_writer: bool = False,
122
124
  report_writer_codex_model: str = "",
125
+ dispatch_kind: str = "initial",
126
+ jobs_file: Path | None = None,
123
127
  ) -> DispatchPlan:
124
128
  inputs = _load_dispatch_inputs(
125
129
  project_root,
126
130
  run_manifest_path,
127
131
  requested_workers,
128
132
  enable_codex_report_writer=enable_codex_report_writer,
133
+ select_roster=jobs_file is None,
129
134
  )
130
- worker_requests, prompt_paths = _materialize_selected_worker_prompts(
131
- project_root=inputs.project_root,
132
- manifest_path=inputs.manifest_path,
133
- workspace_root=workspace_root,
134
- team_state=inputs.team_state,
135
- selected_workers=inputs.selected_workers,
136
- report_writer_codex_model=report_writer_codex_model,
137
- )
138
- workers = _build_selected_worker_dispatches(
139
- project_root=inputs.project_root,
140
- manifest=inputs.manifest,
141
- team_state=inputs.team_state,
142
- active_context=inputs.active_context,
143
- workspace_root=workspace_root,
144
- okstra_bin=okstra_bin,
145
- idle_timeout_seconds=idle_timeout_seconds,
146
- selected_workers=inputs.selected_workers,
147
- worker_requests=worker_requests,
148
- prompt_paths=prompt_paths,
149
- )
135
+ if jobs_file is not None:
136
+ workers = _codex_jobs_from_file(
137
+ inputs,
138
+ jobs_file,
139
+ workspace_root=workspace_root,
140
+ okstra_bin=okstra_bin,
141
+ idle_timeout_seconds=idle_timeout_seconds,
142
+ dispatch_kind=dispatch_kind,
143
+ )
144
+ else:
145
+ worker_requests, prompt_paths = _materialize_selected_worker_prompts(
146
+ project_root=inputs.project_root,
147
+ manifest_path=inputs.manifest_path,
148
+ workspace_root=workspace_root,
149
+ team_state=inputs.team_state,
150
+ selected_workers=inputs.selected_workers,
151
+ report_writer_codex_model=report_writer_codex_model,
152
+ )
153
+ workers = _build_selected_worker_dispatches(
154
+ project_root=inputs.project_root,
155
+ manifest=inputs.manifest,
156
+ team_state=inputs.team_state,
157
+ active_context=inputs.active_context,
158
+ workspace_root=workspace_root,
159
+ okstra_bin=okstra_bin,
160
+ idle_timeout_seconds=idle_timeout_seconds,
161
+ selected_workers=inputs.selected_workers,
162
+ worker_requests=worker_requests,
163
+ prompt_paths=prompt_paths,
164
+ )
150
165
  return DispatchPlan(
151
166
  project_root=inputs.project_root,
152
167
  workspace_root=workspace_root,
@@ -158,36 +173,68 @@ def build_dispatch_plan(
158
173
  )
159
174
 
160
175
 
176
+ def _codex_jobs_from_file(
177
+ inputs: _DispatchInputs,
178
+ jobs_file: Path,
179
+ *,
180
+ workspace_root: Path,
181
+ okstra_bin: Path | None,
182
+ idle_timeout_seconds: int,
183
+ dispatch_kind: str,
184
+ ) -> list[WorkerJob]:
185
+ workers = _worker_jobs_from_file(
186
+ inputs.project_root,
187
+ jobs_file,
188
+ backend=BACKEND_CLI_WRAPPER,
189
+ idle_timeout_seconds=idle_timeout_seconds,
190
+ default_dispatch_kind=dispatch_kind,
191
+ resolve_wrapper=lambda provider: _resolve_wrapper(
192
+ provider, workspace_root, okstra_bin
193
+ ),
194
+ default_provider=lambda worker_id: (
195
+ "codex" if worker_id == REPORT_WRITER_WORKER_ID else worker_id
196
+ ),
197
+ )
198
+ _validate_dispatch_prompts(inputs.manifest, inputs.active_context, workers)
199
+ return workers
200
+
201
+
161
202
  def _load_dispatch_inputs(
162
203
  project_root: Path,
163
204
  run_manifest_path: Path,
164
205
  requested_workers: Sequence[str],
165
206
  *,
166
207
  enable_codex_report_writer: bool,
208
+ select_roster: bool = True,
167
209
  ) -> _DispatchInputs:
168
210
  project_root = project_root.resolve()
169
211
  manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
170
212
  manifest = _load_json_object(manifest_path, "run manifest")
171
213
  _validate_codex_manifest(manifest, manifest_path)
172
- selected_workers = tuple(
173
- _select_workers(
174
- manifest,
175
- requested_workers,
176
- enable_codex_report_writer=enable_codex_report_writer,
214
+ selected_workers = (
215
+ tuple(
216
+ _select_workers(
217
+ manifest,
218
+ requested_workers,
219
+ enable_codex_report_writer=enable_codex_report_writer,
220
+ )
177
221
  )
222
+ if select_roster
223
+ else ()
178
224
  )
179
225
  team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
180
226
  team_state = _load_json_object(team_state_path, "team-state")
181
- _mark_skipped_workers(
182
- team_state_path,
183
- team_state,
184
- _skipped_worker_reasons(
185
- manifest,
186
- selected_workers,
187
- requested_workers,
188
- enable_codex_report_writer=enable_codex_report_writer,
189
- ),
190
- )
227
+ if select_roster:
228
+ _mark_skipped_workers(
229
+ team_state_path,
230
+ team_state,
231
+ _skipped_worker_reasons(
232
+ manifest,
233
+ selected_workers,
234
+ requested_workers,
235
+ enable_codex_report_writer=enable_codex_report_writer,
236
+ ),
237
+ )
191
238
  active_context = _load_optional_json_object(
192
239
  _resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
193
240
  )
@@ -276,13 +323,6 @@ def _build_selected_worker_dispatches(
276
323
  def dispatch_plan(plan: DispatchPlan) -> int:
277
324
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
278
325
  for worker in plan.workers:
279
- _set_worker_status(
280
- plan.team_state_path,
281
- worker.worker_id,
282
- "running",
283
- "",
284
- model_execution_value=worker.model_execution_value,
285
- )
286
326
  completed = _dispatch_worker_with_retry(plan, worker)
287
327
  if completed == 0:
288
328
  continue
@@ -292,6 +332,13 @@ def dispatch_plan(plan: DispatchPlan) -> int:
292
332
 
293
333
  def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
294
334
  for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
335
+ _transition_worker_status(
336
+ plan.team_state_path,
337
+ worker.worker_id,
338
+ "in-progress",
339
+ "",
340
+ model_execution_value=worker.model_execution_value,
341
+ )
295
342
  _record_worker_attempt(plan, worker, attempt, "running")
296
343
  attempt_details = _attempt_event_details(worker, attempt)
297
344
  _append_event(plan, "worker-dispatched", attempt_details)
@@ -301,7 +348,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
301
348
  post_process = _post_process_report_writer_result(plan, worker)
302
349
  if not post_process["ok"]:
303
350
  reason = _require_string(post_process, "reason")
304
- _set_worker_status(plan.team_state_path, worker.worker_id, "error", reason)
351
+ _transition_worker_status(
352
+ plan.team_state_path, worker.worker_id, "error", reason
353
+ )
305
354
  _record_worker_attempt(plan, worker, attempt, "error", reason)
306
355
  details = {
307
356
  **attempt_details,
@@ -312,7 +361,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
312
361
  }
313
362
  _append_event(plan, "worker-failed", details)
314
363
  return 1
315
- _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
364
+ _transition_worker_status(
365
+ plan.team_state_path, worker.worker_id, "completed", ""
366
+ )
316
367
  _record_worker_attempt(plan, worker, attempt, "completed")
317
368
  details = {
318
369
  **attempt_details,
@@ -338,7 +389,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
338
389
  continue
339
390
 
340
391
  reason = _failure_reason(result.returncode, missing_paths)
341
- _set_worker_status(plan.team_state_path, worker.worker_id, "error", reason)
392
+ _transition_worker_status(
393
+ plan.team_state_path, worker.worker_id, "error", reason
394
+ )
342
395
  _record_worker_attempt(plan, worker, attempt, "error", reason)
343
396
  details = {
344
397
  **attempt_details,
@@ -378,7 +431,7 @@ def _matches_worker_attempt(
378
431
  return (
379
432
  isinstance(record, dict)
380
433
  and record.get("workerId") == worker.worker_id
381
- and record.get("kind") == "initial"
434
+ and record.get("kind") == worker.dispatch_kind
382
435
  and record.get("attempt") == attempt
383
436
  )
384
437
 
@@ -386,14 +439,13 @@ def _matches_worker_attempt(
386
439
  def _worker_dispatch_record(
387
440
  worker: WorkerJob, attempt: int, status: str, reason: str
388
441
  ) -> dict[str, Any]:
389
- provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
390
442
  return {
391
443
  "workerId": worker.worker_id,
392
444
  "role": worker.role,
393
- "kind": "initial",
445
+ "kind": worker.dispatch_kind,
394
446
  "attempt": attempt,
395
447
  "backendType": worker.backend,
396
- "provider": provider,
448
+ "provider": worker.provider,
397
449
  "status": status,
398
450
  "paneId": "",
399
451
  "promptPath": str(worker.prompt_path),
@@ -410,6 +462,8 @@ def main(argv: Sequence[str] | None = None) -> int:
410
462
  parser = _parser()
411
463
  args = parser.parse_args(argv)
412
464
  try:
465
+ if args.jobs_file and args.workers:
466
+ raise DispatchError("--jobs-file and --workers cannot be combined")
413
467
  idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
414
468
  plan = build_dispatch_plan(
415
469
  project_root=Path(args.project_root),
@@ -420,6 +474,8 @@ def main(argv: Sequence[str] | None = None) -> int:
420
474
  idle_timeout_seconds=idle_timeout_seconds,
421
475
  enable_codex_report_writer=args.enable_codex_report_writer,
422
476
  report_writer_codex_model=args.report_writer_codex_model,
477
+ dispatch_kind=args.dispatch_kind,
478
+ jobs_file=Path(args.jobs_file) if args.jobs_file else None,
423
479
  )
424
480
  if args.dry_run:
425
481
  _print_json(plan.to_payload(dry_run=True))
@@ -446,6 +502,8 @@ def _parser() -> argparse.ArgumentParser:
446
502
  parser.add_argument("--idle-timeout-seconds", default="600")
447
503
  parser.add_argument("--enable-codex-report-writer", action="store_true")
448
504
  parser.add_argument("--report-writer-codex-model", default="")
505
+ parser.add_argument("--dispatch-kind", default="initial")
506
+ parser.add_argument("--jobs-file", default="")
449
507
  return parser
450
508
 
451
509
 
@@ -672,7 +730,6 @@ def _mark_skipped_workers(
672
730
  workers = team_state.get("workers")
673
731
  if not isinstance(workers, list):
674
732
  raise DispatchError(f"team-state workers must be an array: {team_state_path}")
675
- changed = False
676
733
  for worker in workers:
677
734
  if not isinstance(worker, dict):
678
735
  continue
@@ -683,11 +740,9 @@ def _mark_skipped_workers(
683
740
  status = _string_value(worker.get("status")) or "not-run"
684
741
  current_reason = _string_value(worker.get("reason"))
685
742
  if status == "not-run" and not current_reason:
686
- worker["status"] = "not-run"
687
- worker["reason"] = reason
688
- changed = True
689
- if changed:
690
- _write_json(team_state_path, dict(team_state))
743
+ _transition_worker_status(
744
+ team_state_path, worker_id, "not-run", reason
745
+ )
691
746
 
692
747
 
693
748
  def _resolve_wrapper(
@@ -695,7 +750,9 @@ def _resolve_wrapper(
695
750
  workspace_root: Path,
696
751
  okstra_bin: Path | None,
697
752
  ) -> Path:
698
- script = SUPPORTED_CLI_WORKERS[worker_id]
753
+ script = SUPPORTED_CLI_WORKERS.get(worker_id)
754
+ if script is None:
755
+ raise DispatchError(f"unsupported worker provider: {worker_id}")
699
756
  candidates = []
700
757
  if okstra_bin is not None:
701
758
  candidates.append(okstra_bin / script)
@@ -759,7 +816,9 @@ def _post_process_report_writer_result(
759
816
  # validate-run reads team-state; the writer is done by the time its
760
817
  # artifacts pass the earlier steps.
761
818
  if step_name == STEP_VALIDATE_RUN:
762
- _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
819
+ _transition_worker_status(
820
+ plan.team_state_path, worker.worker_id, "completed", ""
821
+ )
763
822
 
764
823
  return run_finalize(ctx, before_step=settle_before)
765
824
 
@@ -1946,7 +1946,9 @@ def _parse_group(
1946
1946
  "findingId": _required_string(group, "findingId", label),
1947
1947
  "summary": _required_string(group, "summary", label),
1948
1948
  "category": _required_string(group, "category", label),
1949
- "ticketIds": _string_array(group.get("ticketIds"), f"{label}.ticketIds"),
1949
+ "ticketIds": _string_array_allow_empty(
1950
+ group.get("ticketIds"), f"{label}.ticketIds"
1951
+ ),
1950
1952
  "originWorker": origin,
1951
1953
  "originEvidence": origin_evidence,
1952
1954
  "discoveredBy": discovered_by,
@@ -24,11 +24,12 @@ from .dispatch_state import (
24
24
  resolve_project_path as _resolve_project_path,
25
25
  resolve_required_path as _resolve_required_path,
26
26
  set_dispatch_mode as _set_dispatch_mode,
27
- set_worker_status as _set_worker_status,
27
+ transition_worker_status as _transition_worker_status,
28
28
  string_list as _string_list,
29
29
  string_value as _string_value,
30
30
  utc_now as _utc_now,
31
- validate_initial_prompts as _validate_initial_prompts,
31
+ validate_dispatch_prompts as _validate_dispatch_prompts,
32
+ worker_jobs_from_file as _worker_jobs_from_file,
32
33
  worker_state as _worker_state,
33
34
  worktree_path as _worktree_path,
34
35
  write_json as _write_json,
@@ -160,7 +161,7 @@ def build_dispatch_plan(
160
161
  )
161
162
  if jobs_file:
162
163
  jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
163
- _validate_initial_prompts(manifest, jobs)
164
+ _validate_dispatch_prompts(manifest, active_context, jobs)
164
165
  else:
165
166
  jobs = _jobs_from_roster(
166
167
  project_root,
@@ -389,55 +390,22 @@ def _jobs_from_file(
389
390
  ) -> list[WorkerJob]:
390
391
  if jobs_file is None:
391
392
  return []
392
- payload = _load_json_object(_resolve_project_path(project_root, str(jobs_file)), "jobs file")
393
- dispatch_kind = _string_value(payload.get("dispatchKind")) or options.dispatch_kind
394
- workers = payload.get("workers")
395
- if not isinstance(workers, list):
396
- raise DispatchError("jobs file workers must be an array")
397
- return [
398
- _job_from_file_worker(project_root, workspace_root, item, options, dispatch_kind)
399
- for item in workers
400
- if isinstance(item, dict)
401
- ]
402
-
403
-
404
- def _job_from_file_worker(
405
- project_root: Path,
406
- workspace_root: Path,
407
- item: Mapping[str, Any],
408
- options: _BuildOptions,
409
- dispatch_kind: str,
410
- ) -> WorkerJob:
411
- worker_id = _require_string(item, "workerId")
412
- provider = _provider_for_job(item, worker_id)
413
- prompt_path = _resolve_project_path(project_root, _require_string(item, "promptPath"))
414
- result_path = _resolve_project_path(project_root, _require_string(item, "resultPath"))
415
- worker_result_path = _resolve_project_path(project_root, _require_string(item, "workerResultPath"))
416
- completion_paths = tuple(
417
- _resolve_project_path(project_root, path)
418
- for path in _string_list(item.get("completionPaths"))
419
- ) or (result_path,)
420
- return WorkerJob(
421
- worker_id=worker_id,
422
- provider=provider,
393
+ return _worker_jobs_from_file(
394
+ project_root,
395
+ jobs_file,
423
396
  backend=options.default_backend,
424
- project_root=project_root,
425
- model_execution_value=_require_string(item, "modelExecutionValue"),
426
- wrapper_path=_resolve_wrapper(provider, workspace_root, options),
427
- prompt_path=prompt_path,
428
- result_path=result_path,
429
- worker_result_path=worker_result_path,
430
- completion_paths=completion_paths,
431
- worktree_path=_string_value(item.get("worktreePath")),
432
- role=_require_string(item, "role"),
433
397
  idle_timeout_seconds=options.idle_timeout_seconds,
434
- dispatch_kind=dispatch_kind,
398
+ default_dispatch_kind=options.dispatch_kind,
399
+ resolve_wrapper=lambda provider: _resolve_wrapper(
400
+ provider, workspace_root, options
401
+ ),
402
+ default_provider=_provider_for_worker,
435
403
  )
436
404
 
437
405
 
438
406
  def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
439
- _set_worker_status(
440
- plan.team_state_path, job.worker_id, "running", "",
407
+ _transition_worker_status(
408
+ plan.team_state_path, job.worker_id, "in-progress", "",
441
409
  model_execution_value=job.model_execution_value,
442
410
  )
443
411
  handle = _start_job(plan, job)
@@ -544,13 +512,15 @@ def _retry_from_record(
544
512
 
545
513
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
546
514
  if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
547
- _set_worker_status(plan.team_state_path, job.worker_id, "completed", "")
515
+ _transition_worker_status(
516
+ plan.team_state_path, job.worker_id, "completed", ""
517
+ )
548
518
  _update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, "completed", "")
549
519
  _append_event(plan, "worker-result-collected", _result_details(job, attempt, outcome))
550
520
  return
551
521
  reason = _failure_reason(outcome)
552
522
  status = "timeout" if outcome.timeout else "error"
553
- _set_worker_status(plan.team_state_path, job.worker_id, status, reason)
523
+ _transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
554
524
  _update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, status, reason)
555
525
  _append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
556
526
 
@@ -643,7 +613,7 @@ def _mark_roster_skips(
643
613
  return
644
614
  team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
645
615
  for worker_id, reason in reasons.items():
646
- _set_worker_status(team_state_path, worker_id, "not-run", reason)
616
+ _transition_worker_status(team_state_path, worker_id, "not-run", reason)
647
617
 
648
618
 
649
619
  def _skip_reasons(
@@ -801,13 +771,6 @@ def _provider_for_worker(worker_id: str) -> str:
801
771
  return worker_id
802
772
 
803
773
 
804
- def _provider_for_job(item: Mapping[str, Any], worker_id: str) -> str:
805
- provider = _string_value(item.get("provider"))
806
- if provider:
807
- return provider
808
- return _provider_for_worker(worker_id)
809
-
810
-
811
774
  def _model_for_worker(worker_id: str, state: Mapping[str, Any], report_writer_model: str) -> str:
812
775
  if worker_id == REPORT_WRITER_WORKER_ID and report_writer_model.strip():
813
776
  return report_writer_model.strip()
@@ -22,9 +22,13 @@ import os
22
22
  from dataclasses import dataclass
23
23
  from datetime import datetime, timezone
24
24
  from pathlib import Path
25
- from typing import Any, Mapping, Sequence
25
+ from typing import Any, Callable, Mapping, Sequence
26
26
 
27
- from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
27
+ from .worker_prompt_contract import (
28
+ PromptRecord,
29
+ validate_initial_prompt_records,
30
+ validate_reverify_prompt,
31
+ )
28
32
 
29
33
  BACKEND_CLI_WRAPPER = "cli-wrapper"
30
34
  BACKEND_TMUX_PANE = "tmux-pane"
@@ -35,6 +39,10 @@ BACKEND_MIXED = "mixed"
35
39
  # analysis phases write their artifacts under project-root (see the contract in
36
40
  # scripts/okstra-codex-exec.sh).
37
41
  WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
42
+ WORKER_STATUSES = frozenset(
43
+ {"in-progress", "completed", "timeout", "error", "not-run"}
44
+ )
45
+ REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
38
46
 
39
47
 
40
48
  class DispatchError(Exception):
@@ -171,14 +179,24 @@ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str,
171
179
  raise DispatchError(f"team-state has no workerId={worker_id}")
172
180
 
173
181
 
174
- def set_worker_status(
182
+ def transition_worker_status(
175
183
  team_state_path: Path,
176
184
  worker_id: str,
177
185
  status: str,
178
186
  reason: str,
179
187
  *,
180
188
  model_execution_value: str = "",
189
+ at: str | datetime | None = None,
181
190
  ) -> None:
191
+ if status not in WORKER_STATUSES:
192
+ allowed = ", ".join(sorted(WORKER_STATUSES))
193
+ raise DispatchError(
194
+ f"unsupported worker status `{status}`; expected one of: {allowed}"
195
+ )
196
+ reason = reason.strip()
197
+ if status in REASON_REQUIRED_STATUSES and not reason:
198
+ raise DispatchError(f"worker status `{status}` requires a non-empty reason")
199
+ timestamp = _utc_timestamp(at)
182
200
  payload = load_json_object(team_state_path, "team-state")
183
201
  workers = payload.get("workers")
184
202
  if not isinstance(workers, list):
@@ -187,6 +205,14 @@ def set_worker_status(
187
205
  if isinstance(worker, dict) and worker.get("workerId") == worker_id:
188
206
  worker["status"] = status
189
207
  worker["reason"] = reason
208
+ if status == "in-progress":
209
+ worker["startedAt"] = timestamp
210
+ worker.pop("endedAt", None)
211
+ elif status == "not-run":
212
+ worker.pop("startedAt", None)
213
+ worker.pop("endedAt", None)
214
+ else:
215
+ worker["endedAt"] = timestamp
190
216
  if model_execution_value:
191
217
  worker["model"] = model_execution_value
192
218
  worker["modelExecutionValue"] = model_execution_value
@@ -195,6 +221,23 @@ def set_worker_status(
195
221
  raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
196
222
 
197
223
 
224
+ def _utc_timestamp(value: str | datetime | None) -> str:
225
+ if value is None:
226
+ instant = datetime.now(timezone.utc)
227
+ elif isinstance(value, datetime):
228
+ instant = value
229
+ elif isinstance(value, str):
230
+ try:
231
+ instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
232
+ except ValueError as exc:
233
+ raise DispatchError(f"invalid UTC timestamp `{value}`") from exc
234
+ else:
235
+ raise DispatchError(f"invalid UTC timestamp `{value}`")
236
+ if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
237
+ raise DispatchError(f"invalid UTC timestamp `{value}`")
238
+ return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
239
+
240
+
198
241
  def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
199
242
  payload = load_json_object(team_state_path, "team-state")
200
243
  payload["dispatchMode"] = dispatch_mode
@@ -227,5 +270,126 @@ def validate_initial_prompts(
227
270
  raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
228
271
 
229
272
 
273
+ def validate_dispatch_prompts(
274
+ manifest: Mapping[str, Any],
275
+ active_context: Mapping[str, Any],
276
+ jobs: Sequence[WorkerJob],
277
+ ) -> None:
278
+ initial_jobs = [
279
+ job for job in jobs if not job.dispatch_kind.startswith("reverify-r")
280
+ ]
281
+ if initial_jobs:
282
+ validate_initial_prompts(manifest, initial_jobs)
283
+
284
+ reverify_jobs = [
285
+ job for job in jobs if job.dispatch_kind.startswith("reverify-r")
286
+ ]
287
+ if not reverify_jobs:
288
+ return
289
+ task_type = require_string(manifest, "taskType")
290
+ workflow = active_context.get("workflow")
291
+ if not isinstance(workflow, Mapping):
292
+ raise DispatchError("reverify prompt contract: active run workflow is missing")
293
+ forbidden_actions = string_value(workflow.get("forbiddenActions"))
294
+ if not forbidden_actions:
295
+ raise DispatchError(
296
+ "reverify prompt contract: workflow.forbiddenActions is missing"
297
+ )
298
+
299
+ errors: list[str] = []
300
+ for job in reverify_jobs:
301
+ try:
302
+ text = job.prompt_path.read_text(encoding="utf-8")
303
+ except OSError as exc:
304
+ errors.append(f"{job.worker_id}: cannot read prompt {job.prompt_path}: {exc}")
305
+ continue
306
+ errors.extend(
307
+ f"{job.worker_id}: {error}"
308
+ for error in validate_reverify_prompt(
309
+ text,
310
+ task_type=task_type,
311
+ forbidden_actions=forbidden_actions,
312
+ )
313
+ )
314
+ if errors:
315
+ raise DispatchError("reverify prompt contract: " + "; ".join(errors))
316
+
317
+
318
+ def worker_jobs_from_file(
319
+ project_root: Path,
320
+ jobs_file: Path,
321
+ *,
322
+ backend: str,
323
+ idle_timeout_seconds: int,
324
+ default_dispatch_kind: str,
325
+ resolve_wrapper: Callable[[str], Path],
326
+ default_provider: Callable[[str], str],
327
+ ) -> list[WorkerJob]:
328
+ payload = load_json_object(
329
+ resolve_project_path(project_root, str(jobs_file)),
330
+ "jobs file",
331
+ )
332
+ dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
333
+ workers = payload.get("workers")
334
+ if not isinstance(workers, list):
335
+ raise DispatchError("jobs file workers must be an array")
336
+ return [
337
+ _worker_job_from_file(
338
+ project_root,
339
+ item,
340
+ backend=backend,
341
+ idle_timeout_seconds=idle_timeout_seconds,
342
+ dispatch_kind=dispatch_kind,
343
+ resolve_wrapper=resolve_wrapper,
344
+ default_provider=default_provider,
345
+ )
346
+ for item in workers
347
+ if isinstance(item, Mapping)
348
+ ]
349
+
350
+
351
+ def _worker_job_from_file(
352
+ project_root: Path,
353
+ item: Mapping[str, Any],
354
+ *,
355
+ backend: str,
356
+ idle_timeout_seconds: int,
357
+ dispatch_kind: str,
358
+ resolve_wrapper: Callable[[str], Path],
359
+ default_provider: Callable[[str], str],
360
+ ) -> WorkerJob:
361
+ worker_id = require_string(item, "workerId")
362
+ provider = string_value(item.get("provider")) or default_provider(worker_id)
363
+ prompt_path = resolve_project_path(
364
+ project_root, require_string(item, "promptPath")
365
+ )
366
+ result_path = resolve_project_path(
367
+ project_root, require_string(item, "resultPath")
368
+ )
369
+ worker_result_path = resolve_project_path(
370
+ project_root, require_string(item, "workerResultPath")
371
+ )
372
+ completion_paths = tuple(
373
+ resolve_project_path(project_root, path)
374
+ for path in string_list(item.get("completionPaths"))
375
+ ) or (result_path,)
376
+ return WorkerJob(
377
+ worker_id=worker_id,
378
+ provider=provider,
379
+ backend=backend,
380
+ project_root=project_root,
381
+ model_execution_value=require_string(item, "modelExecutionValue"),
382
+ wrapper_path=resolve_wrapper(provider),
383
+ prompt_path=prompt_path,
384
+ result_path=result_path,
385
+ worker_result_path=worker_result_path,
386
+ completion_paths=completion_paths,
387
+ worktree_path=string_value(item.get("worktreePath")),
388
+ role=require_string(item, "role"),
389
+ idle_timeout_seconds=idle_timeout_seconds,
390
+ dispatch_kind=dispatch_kind,
391
+ )
392
+
393
+
230
394
  def utc_now() -> str:
231
395
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")