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
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  import subprocess
6
6
  import time
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from datetime import datetime, timezone
9
9
  from pathlib import Path
10
10
  from typing import Any, Mapping, Sequence
@@ -19,6 +19,7 @@ from .dispatch_state import (
19
19
  DispatchError,
20
20
  WorkerJob,
21
21
  dispatch_mode as _dispatch_mode,
22
+ dispatch_record_matches,
22
23
  LIVENESS_AUDIT_HEARTBEAT,
23
24
  LIVENESS_WRAPPER_STATUS,
24
25
  load_json_object as _load_json_object,
@@ -50,6 +51,12 @@ from .initial_prompt_materialization import (
50
51
  materialize_initial_prompts,
51
52
  )
52
53
  from .path_hints import hydrate_active_run_context
54
+ from .report_finalize import (
55
+ STEP_VALIDATE_RUN,
56
+ FinalizeContext,
57
+ FinalizeError,
58
+ run_finalize,
59
+ )
53
60
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
54
61
  from .worker_artifact_paths import audit_sidecar_rel
55
62
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
@@ -117,6 +124,8 @@ class _BuildOptions:
117
124
  supported_worker_wrappers: Mapping[str, str]
118
125
  unsupported_worker_label: str
119
126
  dispatch_kind: str
127
+ cli_wrapper_assignments_only: bool = False
128
+ default_provider_by_worker_id: Mapping[str, str] = field(default_factory=dict)
120
129
 
121
130
 
122
131
  @dataclass(frozen=True)
@@ -142,6 +151,8 @@ def build_dispatch_plan(
142
151
  unsupported_worker_label: str,
143
152
  dispatch_kind: str = "initial",
144
153
  jobs_file: Path | None = None,
154
+ cli_wrapper_assignments_only: bool = False,
155
+ default_provider_by_worker_id: Mapping[str, str] | None = None,
145
156
  ) -> DispatchPlan:
146
157
  project_root = project_root.resolve()
147
158
  manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
@@ -157,6 +168,8 @@ def build_dispatch_plan(
157
168
  supported_worker_wrappers=supported_worker_wrappers,
158
169
  unsupported_worker_label=unsupported_worker_label,
159
170
  dispatch_kind=dispatch_kind,
171
+ cli_wrapper_assignments_only=cli_wrapper_assignments_only,
172
+ default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
160
173
  )
161
174
  if jobs_file:
162
175
  jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
@@ -247,7 +260,12 @@ def _jobs_from_roster(
247
260
  requested_workers: Sequence[str],
248
261
  options: _BuildOptions,
249
262
  ) -> list[WorkerJob]:
250
- selected = _select_workers(manifest, requested_workers, options)
263
+ selected = _select_workers(
264
+ manifest,
265
+ requested_workers,
266
+ options,
267
+ team_state=team_state,
268
+ )
251
269
  _mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
252
270
  worker_facts, prompt_paths = _materialize_roster_prompts(
253
271
  project_root,
@@ -278,10 +296,19 @@ def _select_workers(
278
296
  manifest: Mapping[str, Any],
279
297
  requested_workers: Sequence[str],
280
298
  options: _BuildOptions,
299
+ *,
300
+ team_state: Mapping[str, Any] | None = None,
281
301
  ) -> list[str]:
282
302
  recommended = _string_list(manifest.get("recommendedWorkers"))
283
303
  if not recommended:
284
304
  raise DispatchError("run manifest has no recommendedWorkers entries")
305
+ if options.cli_wrapper_assignments_only:
306
+ return _select_cli_wrapper_assignments(
307
+ recommended,
308
+ requested_workers,
309
+ team_state or {},
310
+ options,
311
+ )
285
312
  supported = set(options.supported_worker_wrappers)
286
313
  selected = list(requested_workers) if requested_workers else [w for w in recommended if w in supported]
287
314
  unknown = [worker for worker in selected if worker not in recommended]
@@ -298,6 +325,69 @@ def _select_workers(
298
325
  return selected
299
326
 
300
327
 
328
+ def _select_cli_wrapper_assignments(
329
+ recommended: Sequence[str],
330
+ requested_workers: Sequence[str],
331
+ team_state: Mapping[str, Any],
332
+ options: _BuildOptions,
333
+ ) -> list[str]:
334
+ dispatchable = {
335
+ worker_id
336
+ for worker_id in recommended
337
+ if _is_cli_wrapper_assignment(team_state, worker_id, options)
338
+ }
339
+ if not requested_workers:
340
+ selected = [worker for worker in recommended if worker in dispatchable]
341
+ if selected:
342
+ return selected
343
+ raise DispatchError("run roster has no cli-wrapper assignments for codex-dispatch")
344
+ selected = list(requested_workers)
345
+ unknown = [worker for worker in selected if worker not in recommended]
346
+ if unknown:
347
+ raise DispatchError(
348
+ "requested worker(s) are not in this run roster: " + ", ".join(unknown)
349
+ )
350
+ native = [
351
+ worker
352
+ for worker in selected
353
+ if _runner_for_worker(team_state, worker) == "native-session"
354
+ ]
355
+ if native:
356
+ raise DispatchError(
357
+ "requested worker(s) use runner=native-session and must be dispatched "
358
+ "by the current Codex host: " + ", ".join(native)
359
+ )
360
+ unsupported = [worker for worker in selected if worker not in dispatchable]
361
+ if unsupported:
362
+ supported = ", ".join(sorted(dispatchable))
363
+ raise DispatchError(
364
+ "unsupported Codex lead worker(s): "
365
+ + ", ".join(unsupported)
366
+ + f" (CLI dispatcher supports: {supported})"
367
+ )
368
+ return selected
369
+
370
+
371
+ def _is_cli_wrapper_assignment(
372
+ team_state: Mapping[str, Any],
373
+ worker_id: str,
374
+ options: _BuildOptions,
375
+ ) -> bool:
376
+ state = _worker_state(team_state, worker_id)
377
+ provider = _string_value(state.get("provider")) or _provider_for_worker(
378
+ worker_id,
379
+ options,
380
+ )
381
+ return (
382
+ _string_value(state.get("runner")) or BACKEND_CLI_WRAPPER
383
+ ) == BACKEND_CLI_WRAPPER and provider in options.supported_worker_wrappers
384
+
385
+
386
+ def _runner_for_worker(team_state: Mapping[str, Any], worker_id: str) -> str:
387
+ state = _worker_state(team_state, worker_id)
388
+ return _string_value(state.get("runner")) or BACKEND_CLI_WRAPPER
389
+
390
+
301
391
  def _job_from_roster_worker(
302
392
  project_root: Path,
303
393
  workspace_root: Path,
@@ -340,7 +430,7 @@ def _materialize_roster_prompts(
340
430
  options: _BuildOptions,
341
431
  ) -> tuple[tuple[_RosterWorkerFacts, ...], dict[str, Path]]:
342
432
  worker_facts = tuple(
343
- _roster_worker_facts(team_state, worker_id)
433
+ _roster_worker_facts(team_state, worker_id, options)
344
434
  for worker_id in selected
345
435
  )
346
436
  try:
@@ -364,12 +454,14 @@ def _materialize_roster_prompts(
364
454
  def _roster_worker_facts(
365
455
  team_state: Mapping[str, Any],
366
456
  worker_id: str,
457
+ options: _BuildOptions | None = None,
367
458
  ) -> _RosterWorkerFacts:
368
459
  state = _worker_state(team_state, worker_id)
369
460
  return _RosterWorkerFacts(
370
461
  worker_id=worker_id,
371
462
  model=_require_string(state, "modelExecutionValue"),
372
- provider=_string_value(state.get("provider")) or _provider_for_worker(worker_id),
463
+ provider=_string_value(state.get("provider"))
464
+ or _provider_for_worker(worker_id, options),
373
465
  result_path=_require_string(state, "resultPath"),
374
466
  role=_string_value(state.get("role")) or "worker",
375
467
  )
@@ -398,7 +490,7 @@ def _jobs_from_file(
398
490
  resolve_wrapper=lambda provider: _resolve_wrapper(
399
491
  provider, workspace_root, options
400
492
  ),
401
- default_provider=_provider_for_worker,
493
+ default_provider=lambda worker_id: _provider_for_worker(worker_id, options),
402
494
  )
403
495
 
404
496
 
@@ -422,7 +514,7 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
422
514
  if handle.completed_process is None:
423
515
  return await_dispatches(plan, timeout_seconds=None)
424
516
  outcome = _outcome_from_completed(handle)
425
- if outcome.returncode == 0 and not outcome.missing_completion_paths:
517
+ if _worker_terminal_status(plan.team_state_path, job.worker_id) == "completed":
426
518
  return 0
427
519
  if _should_retry(outcome, attempt):
428
520
  _append_event(plan, "worker-retry-scheduled", _retry_details(job, attempt, outcome))
@@ -565,24 +657,47 @@ def _retry_from_record(
565
657
  ) -> None:
566
658
  worker_id = _require_string(record, "workerId")
567
659
  attempt = int(record.get("attempt", 1))
568
- _update_dispatch_status(plan.team_state_path, worker_id, _require_string(record, "kind"), attempt, "error", "required worker artifact was not produced")
569
- _append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
570
660
  job = _job_from_record(plan.project_root, record)
661
+ _update_dispatch_status(plan.team_state_path, job, attempt, "error", "required worker artifact was not produced")
662
+ _append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
571
663
  _spawn_job(plan, job, attempt + 1)
572
664
 
573
665
 
574
666
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
575
667
  if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
668
+ post_process = _post_process_report_writer_result(plan, job)
669
+ if not post_process["ok"]:
670
+ reason = _require_string(post_process, "reason")
671
+ _transition_worker_status(plan.team_state_path, job.worker_id, "error", reason)
672
+ _update_dispatch_status(
673
+ plan.team_state_path,
674
+ job,
675
+ attempt,
676
+ "error",
677
+ reason,
678
+ )
679
+ details = _failure_details(
680
+ job,
681
+ attempt,
682
+ outcome,
683
+ reason,
684
+ exit_code=1,
685
+ )
686
+ details["postProcessing"] = post_process["steps"]
687
+ _append_event(plan, "worker-failed", details)
688
+ return
576
689
  _transition_worker_status(
577
690
  plan.team_state_path, job.worker_id, "completed", ""
578
691
  )
579
- _update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, "completed", "")
580
- _append_event(plan, "worker-result-collected", _result_details(job, attempt, outcome))
692
+ _update_dispatch_status(plan.team_state_path, job, attempt, "completed", "")
693
+ details = _result_details(job, attempt, outcome)
694
+ details["postProcessing"] = post_process["steps"]
695
+ _append_event(plan, "worker-result-collected", details)
581
696
  return
582
697
  reason = _failure_reason(outcome)
583
698
  status = "timeout" if outcome.timeout else "error"
584
699
  _transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
585
- _update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, status, reason)
700
+ _update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
586
701
  _append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
587
702
 
588
703
 
@@ -591,6 +706,41 @@ def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: Worke
591
706
  _finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
592
707
 
593
708
 
709
+ def _post_process_report_writer_result(
710
+ plan: DispatchPlan,
711
+ job: WorkerJob,
712
+ ) -> dict[str, Any]:
713
+ if job.worker_id != REPORT_WRITER_WORKER_ID:
714
+ return {"ok": True, "reason": "", "steps": []}
715
+ try:
716
+ context = FinalizeContext.from_manifest(
717
+ project_root=plan.project_root,
718
+ workspace_root=plan.workspace_root,
719
+ manifest_path=plan.manifest_path,
720
+ team_state_path=plan.team_state_path,
721
+ data_path=job.result_path,
722
+ manifest=plan.manifest,
723
+ )
724
+
725
+ def settle_before(step_name: str) -> None:
726
+ if step_name == STEP_VALIDATE_RUN:
727
+ _transition_worker_status(
728
+ plan.team_state_path,
729
+ job.worker_id,
730
+ "completed",
731
+ "",
732
+ )
733
+
734
+ return run_finalize(context, before_step=settle_before)
735
+ except FinalizeError as exc:
736
+ return {"ok": False, "reason": str(exc), "steps": []}
737
+
738
+
739
+ def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
740
+ team_state = _load_json_object(team_state_path, "team-state")
741
+ return _string_value(_worker_state(team_state, worker_id).get("status"))
742
+
743
+
594
744
  def _record_dispatch(
595
745
  team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
596
746
  ) -> None:
@@ -638,19 +788,23 @@ def _liveness_mode(backend: str) -> str:
638
788
 
639
789
 
640
790
  def _update_dispatch_status(
641
- team_state_path: Path, worker_id: str, kind: str, attempt: int, status: str, reason: str
791
+ team_state_path: Path, job: WorkerJob, attempt: int, status: str, reason: str
642
792
  ) -> None:
643
793
  payload = _load_json_object(team_state_path, "team-state")
644
- for record in payload.get("workerDispatches", []):
645
- if _record_matches(record, worker_id, kind, attempt):
646
- record["status"] = status
647
- record["reason"] = reason
648
- _write_json(team_state_path, payload)
649
- return
650
-
651
-
652
- def _record_matches(record: object, worker_id: str, kind: str, attempt: int) -> bool:
653
- return isinstance(record, dict) and record.get("workerId") == worker_id and record.get("kind") == kind and record.get("attempt") == attempt
794
+ matches = [
795
+ record
796
+ for record in payload.get("workerDispatches", [])
797
+ if dispatch_record_matches(record, job.prompt_path, attempt)
798
+ ]
799
+ if not matches:
800
+ return
801
+ # A lead may re-send a round's prompt without advancing `attempt`, which
802
+ # appends a second row the prompt cannot tell from the first. The newest is
803
+ # the one still running, so an outcome written to the older row would leave
804
+ # the live one running for good — the shape that wedged an await loop.
805
+ matches[-1]["status"] = status
806
+ matches[-1]["reason"] = reason
807
+ _write_json(team_state_path, payload)
654
808
 
655
809
 
656
810
  def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
@@ -669,21 +823,58 @@ def _mark_roster_skips(
669
823
  requested: Sequence[str],
670
824
  options: _BuildOptions,
671
825
  ) -> None:
672
- reasons = _skip_reasons(manifest, selected, requested, options)
826
+ reasons = _skip_reasons(
827
+ manifest,
828
+ selected,
829
+ requested,
830
+ options,
831
+ team_state=team_state,
832
+ )
673
833
  if not reasons:
674
834
  return
675
835
  team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
836
+ current_team_state = _load_json_object(team_state_path, "team-state")
676
837
  for worker_id, reason in reasons.items():
838
+ worker = _worker_state(current_team_state, worker_id)
839
+ if (
840
+ _string_value(worker.get("status")) != "not-run"
841
+ or _string_value(worker.get("reason"))
842
+ ):
843
+ continue
677
844
  _transition_worker_status(team_state_path, worker_id, "not-run", reason)
678
845
 
679
846
 
680
847
  def _skip_reasons(
681
- manifest: Mapping[str, Any], selected: Sequence[str], requested: Sequence[str], options: _BuildOptions
848
+ manifest: Mapping[str, Any],
849
+ selected: Sequence[str],
850
+ requested: Sequence[str],
851
+ options: _BuildOptions,
852
+ *,
853
+ team_state: Mapping[str, Any] | None = None,
682
854
  ) -> dict[str, str]:
855
+ recommended = _string_list(manifest.get("recommendedWorkers"))
856
+ if options.cli_wrapper_assignments_only:
857
+ recommended = [
858
+ worker_id
859
+ for worker_id in recommended
860
+ if _runner_for_worker(team_state or {}, worker_id) != "native-session"
861
+ ]
683
862
  if requested:
684
- 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)}
863
+ return {
864
+ worker_id: (
865
+ "skipped by worker dispatch: worker was not requested in this invocation"
866
+ )
867
+ for worker_id in recommended
868
+ if worker_id not in set(selected)
869
+ }
685
870
  supported = set(options.supported_worker_wrappers)
686
- 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}
871
+ return {
872
+ worker_id: (
873
+ "skipped by worker dispatch default: worker is not supported by this dispatcher"
874
+ )
875
+ for worker_id in recommended
876
+ if worker_id not in set(selected) and worker_id not in supported
877
+ }
687
878
 
688
879
 
689
880
  _SIDEBAR_LEVELS = {
@@ -765,14 +956,27 @@ def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dic
765
956
  }
766
957
 
767
958
 
768
- def _failure_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome, reason: str) -> dict[str, Any]:
959
+ def _failure_details(
960
+ job: WorkerJob,
961
+ attempt: int,
962
+ outcome: WorkerOutcome,
963
+ reason: str,
964
+ *,
965
+ exit_code: int | None = None,
966
+ ) -> dict[str, Any]:
769
967
  return {
770
968
  "workerId": job.worker_id,
771
969
  "attempt": attempt,
970
+ "maxAttempts": MAX_WORKER_ATTEMPTS,
772
971
  "dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
773
- "exitCode": outcome.returncode,
972
+ "exitCode": outcome.returncode if exit_code is None else exit_code,
774
973
  "missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
775
974
  "reason": reason,
975
+ "promptPath": str(job.prompt_path),
976
+ "resultPath": str(job.result_path),
977
+ "workerResultPath": str(job.worker_result_path),
978
+ "wrapperPath": str(job.wrapper_path),
979
+ "role": job.role,
776
980
  }
777
981
 
778
982
 
@@ -863,7 +1067,14 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
863
1067
  raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
864
1068
 
865
1069
 
866
- def _provider_for_worker(worker_id: str) -> str:
1070
+ def _provider_for_worker(
1071
+ worker_id: str,
1072
+ options: _BuildOptions | None = None,
1073
+ ) -> str:
1074
+ if options is not None:
1075
+ configured = options.default_provider_by_worker_id.get(worker_id)
1076
+ if configured:
1077
+ return configured
867
1078
  if worker_id == REPORT_WRITER_WORKER_ID:
868
1079
  return "claude"
869
1080
  return worker_id
@@ -430,3 +430,20 @@ def _worker_job_from_file(
430
430
 
431
431
  def utc_now() -> str:
432
432
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
433
+
434
+
435
+ def dispatch_record_matches(record: object, prompt_path: Path, attempt: int) -> bool:
436
+ """Whether this team-state row is the dispatch handed `prompt_path`.
437
+
438
+ The prompt file identifies a dispatch: each round writes its own, and a
439
+ second run of the same one is a retry, which `attempt` separates. Worker id
440
+ and dispatch kind cannot carry that — `--dispatch-kind` is the lead's to
441
+ pick and it has reused one across two rounds, which put the finished
442
+ round's completion on the older namesake row and left the row that actually
443
+ ran marked running, so an await re-collected that worker forever.
444
+ """
445
+ return (
446
+ isinstance(record, dict)
447
+ and record.get("promptPath") == str(prompt_path)
448
+ and record.get("attempt") == attempt
449
+ )
@@ -0,0 +1,34 @@
1
+ """Domain value objects for provider selection and model resolution."""
2
+
3
+ import importlib
4
+ import sys
5
+
6
+
7
+ _provider_module_name = f"{__name__}.provider"
8
+ if __name__.startswith("scripts."):
9
+ _alternate_provider_module_name = f"{__name__.removeprefix('scripts.')}.provider"
10
+ else:
11
+ _alternate_provider_module_name = f"scripts.{__name__}.provider"
12
+
13
+ # Repository tests support both import paths. Sharing one module preserves the
14
+ # exact class identity that the adapter factory contract validates.
15
+ if _alternate_provider_module_name in sys.modules:
16
+ sys.modules[_provider_module_name] = sys.modules[_alternate_provider_module_name]
17
+
18
+ _provider = importlib.import_module(_provider_module_name)
19
+
20
+ LeadLaunchSpec = _provider.LeadLaunchSpec
21
+ ModelAssignment = _provider.ModelAssignment
22
+ ModelSpec = _provider.ModelSpec
23
+ ProviderSpec = _provider.ProviderSpec
24
+ UnknownModelError = _provider.UnknownModelError
25
+ UnknownProviderError = _provider.UnknownProviderError
26
+
27
+ __all__ = (
28
+ "LeadLaunchSpec",
29
+ "ModelAssignment",
30
+ "ModelSpec",
31
+ "ProviderSpec",
32
+ "UnknownModelError",
33
+ "UnknownProviderError",
34
+ )
@@ -0,0 +1,100 @@
1
+ """Host selection and session value objects."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal, Mapping
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HostDescriptor:
11
+ id: str
12
+ aliases: tuple[str, ...]
13
+ native_provider_id: str
14
+ required_executables: tuple[str, ...]
15
+ launch_mode: Literal["lead", "team"]
16
+ install_targets: frozenset[str]
17
+ agent_id: str
18
+ agent_label: str
19
+ role: str
20
+ dispatch_mode: str
21
+ session_accounting: str
22
+ has_claude_session: bool
23
+ relay_contract: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class HostSessionContext:
28
+ host_id: str
29
+ entry_mode: Literal["spawn-process", "current-session"]
30
+ available_functions: frozenset[str]
31
+ interaction_surface: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class HostResolutionContext:
36
+ explicit_env_host: str
37
+ signals: frozenset[str]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class HostClaim:
42
+ host_id: str
43
+ priority: int
44
+ reason: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class HostReadiness:
49
+ ok: bool
50
+ effective_functions: frozenset[str]
51
+ missing_executables: tuple[str, ...]
52
+ checks: tuple[Mapping[str, object], ...]
53
+ reason: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class LeadStartRequest:
58
+ project_root: Path
59
+ task_type: str
60
+ argv: tuple[str, ...]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class LeadResumeRequest:
65
+ project_root: Path
66
+ task_type: str
67
+ session_id: str
68
+ argv: tuple[str, ...]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class LeadSessionPlan:
73
+ mode: Literal["spawn-process", "current-session"]
74
+ argv: tuple[str, ...]
75
+ env: Mapping[str, str]
76
+ sandbox_note: str
77
+
78
+
79
+ class HostNotRegistered(ValueError):
80
+ """Raised when no registered host matches an explicit request."""
81
+
82
+
83
+ class HostUnavailable(RuntimeError):
84
+ """Raised when no single host can serve an automatic request."""
85
+
86
+
87
+ class HostCapabilityMismatch(ValueError):
88
+ """Raised when a selected host lacks a required semantic capability."""
89
+
90
+
91
+ class HostAdapterContractError(ValueError):
92
+ """Raised when a host manifest or factory violates the adapter contract."""
93
+
94
+
95
+ class ProviderNotRegistered(ValueError):
96
+ """Raised when a host references an unknown native provider."""
97
+
98
+
99
+ class ProviderUnavailable(RuntimeError):
100
+ """Raised when a registered provider cannot serve a request."""
@@ -0,0 +1,66 @@
1
+ """Provider and model value objects shared by the registry and adapters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Mapping, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ModelSpec:
10
+ display: str
11
+ execution: str
12
+ pricing: Optional[tuple[float, float, float]] = None
13
+ in_picker: bool = False
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class LeadLaunchSpec:
18
+ """How to start a provider CLI as an interactive lead session."""
19
+
20
+ executable: str
21
+ model_flag: str
22
+ prompt_flag: str = ""
23
+ start_session_id_flag: str = ""
24
+ resume_session_id_flag: str = ""
25
+ sandbox_waiver: tuple[str, ...] = ()
26
+ sandbox_waiver_note: str = ""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ProviderSpec:
31
+ provider: str
32
+ display_label: str
33
+ models: Mapping[str, ModelSpec]
34
+ default_models: Mapping[str, str]
35
+ wrapper: str
36
+ supported_roles: frozenset[str]
37
+ lead_launch: Optional[LeadLaunchSpec] = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RunnerResolution:
42
+ runner: str
43
+ wrapper: str
44
+ reason: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class LeadProviderAssignment:
49
+ provider: str
50
+ runner: str
51
+ wrapper: str
52
+ reason: str
53
+
54
+
55
+ @dataclass
56
+ class ModelAssignment:
57
+ display: str
58
+ execution: str
59
+
60
+
61
+ class UnknownModelError(ValueError):
62
+ """Raised when a provider has no model alias matching a raw value."""
63
+
64
+
65
+ class UnknownProviderError(ValueError):
66
+ """Raised when a requested provider is absent from the registry."""
@@ -0,0 +1,19 @@
1
+ """Wizard interaction domain values and answer normalization."""
2
+
3
+ from .interaction import (
4
+ AnswerProtocol,
5
+ InteractionPlan,
6
+ WizardAnswerError,
7
+ WizardOption,
8
+ WizardPrompt,
9
+ normalize_planned_answer,
10
+ )
11
+
12
+ __all__ = [
13
+ "AnswerProtocol",
14
+ "InteractionPlan",
15
+ "WizardAnswerError",
16
+ "WizardOption",
17
+ "WizardPrompt",
18
+ "normalize_planned_answer",
19
+ ]