okstra 0.163.2 → 0.165.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 (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. 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, replace
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))
@@ -528,6 +620,10 @@ def _refuse_when_cmux_is_walled_off() -> None:
528
620
 
529
621
 
530
622
  def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
623
+ # Whatever the job was planned as, this is a cli-wrapper dispatch: no pane
624
+ # took it, so the worker's stdout is read by whoever called the lead. The
625
+ # record already says so (`_dispatch_record`), and the command must agree.
626
+ job = replace(job, backend=BACKEND_CLI_WRAPPER)
531
627
  completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
532
628
  return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
533
629
 
@@ -565,24 +661,47 @@ def _retry_from_record(
565
661
  ) -> None:
566
662
  worker_id = _require_string(record, "workerId")
567
663
  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
664
  job = _job_from_record(plan.project_root, record)
665
+ _update_dispatch_status(plan.team_state_path, job, attempt, "error", "required worker artifact was not produced")
666
+ _append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
571
667
  _spawn_job(plan, job, attempt + 1)
572
668
 
573
669
 
574
670
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
575
671
  if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
672
+ post_process = _post_process_report_writer_result(plan, job)
673
+ if not post_process["ok"]:
674
+ reason = _require_string(post_process, "reason")
675
+ _transition_worker_status(plan.team_state_path, job.worker_id, "error", reason)
676
+ _update_dispatch_status(
677
+ plan.team_state_path,
678
+ job,
679
+ attempt,
680
+ "error",
681
+ reason,
682
+ )
683
+ details = _failure_details(
684
+ job,
685
+ attempt,
686
+ outcome,
687
+ reason,
688
+ exit_code=1,
689
+ )
690
+ details["postProcessing"] = post_process["steps"]
691
+ _append_event(plan, "worker-failed", details)
692
+ return
576
693
  _transition_worker_status(
577
694
  plan.team_state_path, job.worker_id, "completed", ""
578
695
  )
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))
696
+ _update_dispatch_status(plan.team_state_path, job, attempt, "completed", "")
697
+ details = _result_details(job, attempt, outcome)
698
+ details["postProcessing"] = post_process["steps"]
699
+ _append_event(plan, "worker-result-collected", details)
581
700
  return
582
701
  reason = _failure_reason(outcome)
583
702
  status = "timeout" if outcome.timeout else "error"
584
703
  _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)
704
+ _update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
586
705
  _append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
587
706
 
588
707
 
@@ -591,6 +710,41 @@ def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: Worke
591
710
  _finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
592
711
 
593
712
 
713
+ def _post_process_report_writer_result(
714
+ plan: DispatchPlan,
715
+ job: WorkerJob,
716
+ ) -> dict[str, Any]:
717
+ if job.worker_id != REPORT_WRITER_WORKER_ID:
718
+ return {"ok": True, "reason": "", "steps": []}
719
+ try:
720
+ context = FinalizeContext.from_manifest(
721
+ project_root=plan.project_root,
722
+ workspace_root=plan.workspace_root,
723
+ manifest_path=plan.manifest_path,
724
+ team_state_path=plan.team_state_path,
725
+ data_path=job.result_path,
726
+ manifest=plan.manifest,
727
+ )
728
+
729
+ def settle_before(step_name: str) -> None:
730
+ if step_name == STEP_VALIDATE_RUN:
731
+ _transition_worker_status(
732
+ plan.team_state_path,
733
+ job.worker_id,
734
+ "completed",
735
+ "",
736
+ )
737
+
738
+ return run_finalize(context, before_step=settle_before)
739
+ except FinalizeError as exc:
740
+ return {"ok": False, "reason": str(exc), "steps": []}
741
+
742
+
743
+ def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
744
+ team_state = _load_json_object(team_state_path, "team-state")
745
+ return _string_value(_worker_state(team_state, worker_id).get("status"))
746
+
747
+
594
748
  def _record_dispatch(
595
749
  team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
596
750
  ) -> None:
@@ -638,19 +792,23 @@ def _liveness_mode(backend: str) -> str:
638
792
 
639
793
 
640
794
  def _update_dispatch_status(
641
- team_state_path: Path, worker_id: str, kind: str, attempt: int, status: str, reason: str
795
+ team_state_path: Path, job: WorkerJob, attempt: int, status: str, reason: str
642
796
  ) -> None:
643
797
  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
798
+ matches = [
799
+ record
800
+ for record in payload.get("workerDispatches", [])
801
+ if dispatch_record_matches(record, job.prompt_path, attempt)
802
+ ]
803
+ if not matches:
804
+ return
805
+ # A lead may re-send a round's prompt without advancing `attempt`, which
806
+ # appends a second row the prompt cannot tell from the first. The newest is
807
+ # the one still running, so an outcome written to the older row would leave
808
+ # the live one running for good — the shape that wedged an await loop.
809
+ matches[-1]["status"] = status
810
+ matches[-1]["reason"] = reason
811
+ _write_json(team_state_path, payload)
654
812
 
655
813
 
656
814
  def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
@@ -669,21 +827,58 @@ def _mark_roster_skips(
669
827
  requested: Sequence[str],
670
828
  options: _BuildOptions,
671
829
  ) -> None:
672
- reasons = _skip_reasons(manifest, selected, requested, options)
830
+ reasons = _skip_reasons(
831
+ manifest,
832
+ selected,
833
+ requested,
834
+ options,
835
+ team_state=team_state,
836
+ )
673
837
  if not reasons:
674
838
  return
675
839
  team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
840
+ current_team_state = _load_json_object(team_state_path, "team-state")
676
841
  for worker_id, reason in reasons.items():
842
+ worker = _worker_state(current_team_state, worker_id)
843
+ if (
844
+ _string_value(worker.get("status")) != "not-run"
845
+ or _string_value(worker.get("reason"))
846
+ ):
847
+ continue
677
848
  _transition_worker_status(team_state_path, worker_id, "not-run", reason)
678
849
 
679
850
 
680
851
  def _skip_reasons(
681
- manifest: Mapping[str, Any], selected: Sequence[str], requested: Sequence[str], options: _BuildOptions
852
+ manifest: Mapping[str, Any],
853
+ selected: Sequence[str],
854
+ requested: Sequence[str],
855
+ options: _BuildOptions,
856
+ *,
857
+ team_state: Mapping[str, Any] | None = None,
682
858
  ) -> dict[str, str]:
859
+ recommended = _string_list(manifest.get("recommendedWorkers"))
860
+ if options.cli_wrapper_assignments_only:
861
+ recommended = [
862
+ worker_id
863
+ for worker_id in recommended
864
+ if _runner_for_worker(team_state or {}, worker_id) != "native-session"
865
+ ]
683
866
  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)}
867
+ return {
868
+ worker_id: (
869
+ "skipped by worker dispatch: worker was not requested in this invocation"
870
+ )
871
+ for worker_id in recommended
872
+ if worker_id not in set(selected)
873
+ }
685
874
  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}
875
+ return {
876
+ worker_id: (
877
+ "skipped by worker dispatch default: worker is not supported by this dispatcher"
878
+ )
879
+ for worker_id in recommended
880
+ if worker_id not in set(selected) and worker_id not in supported
881
+ }
687
882
 
688
883
 
689
884
  _SIDEBAR_LEVELS = {
@@ -765,14 +960,27 @@ def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dic
765
960
  }
766
961
 
767
962
 
768
- def _failure_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome, reason: str) -> dict[str, Any]:
963
+ def _failure_details(
964
+ job: WorkerJob,
965
+ attempt: int,
966
+ outcome: WorkerOutcome,
967
+ reason: str,
968
+ *,
969
+ exit_code: int | None = None,
970
+ ) -> dict[str, Any]:
769
971
  return {
770
972
  "workerId": job.worker_id,
771
973
  "attempt": attempt,
974
+ "maxAttempts": MAX_WORKER_ATTEMPTS,
772
975
  "dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
773
- "exitCode": outcome.returncode,
976
+ "exitCode": outcome.returncode if exit_code is None else exit_code,
774
977
  "missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
775
978
  "reason": reason,
979
+ "promptPath": str(job.prompt_path),
980
+ "resultPath": str(job.result_path),
981
+ "workerResultPath": str(job.worker_result_path),
982
+ "wrapperPath": str(job.wrapper_path),
983
+ "role": job.role,
776
984
  }
777
985
 
778
986
 
@@ -863,7 +1071,14 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
863
1071
  raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
864
1072
 
865
1073
 
866
- def _provider_for_worker(worker_id: str) -> str:
1074
+ def _provider_for_worker(
1075
+ worker_id: str,
1076
+ options: _BuildOptions | None = None,
1077
+ ) -> str:
1078
+ if options is not None:
1079
+ configured = options.default_provider_by_worker_id.get(worker_id)
1080
+ if configured:
1081
+ return configured
867
1082
  if worker_id == REPORT_WRITER_WORKER_ID:
868
1083
  return "claude"
869
1084
  return worker_id
@@ -30,6 +30,7 @@ from .worker_prompt_contract import (
30
30
  validate_initial_prompt_records,
31
31
  validate_reverify_prompt,
32
32
  )
33
+ from .worker_runner import LIVE, QUIET
33
34
 
34
35
  BACKEND_CLI_WRAPPER = "cli-wrapper"
35
36
  BACKEND_TMUX_PANE = "tmux-pane"
@@ -91,6 +92,7 @@ class WorkerJob:
91
92
 
92
93
  @property
93
94
  def command(self) -> list[str]:
95
+ # The flag trails the positional contract the entrypoint reads by index.
94
96
  return [
95
97
  str(self.wrapper_path),
96
98
  str(self.project_root),
@@ -99,8 +101,16 @@ class WorkerJob:
99
101
  self.worktree_path,
100
102
  self.role,
101
103
  str(self.idle_timeout_seconds),
104
+ "--presentation",
105
+ self._presentation(),
102
106
  ]
103
107
 
108
+ def _presentation(self) -> str:
109
+ # A pane is a screen a person watches; a cli-wrapper dispatch's stdout is
110
+ # a subagent's context window. Progress belongs in the first and not the
111
+ # second.
112
+ return QUIET if self.backend == BACKEND_CLI_WRAPPER else LIVE
113
+
104
114
  def to_payload(self) -> dict[str, Any]:
105
115
  return {
106
116
  "workerId": self.worker_id,
@@ -430,3 +440,20 @@ def _worker_job_from_file(
430
440
 
431
441
  def utc_now() -> str:
432
442
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
443
+
444
+
445
+ def dispatch_record_matches(record: object, prompt_path: Path, attempt: int) -> bool:
446
+ """Whether this team-state row is the dispatch handed `prompt_path`.
447
+
448
+ The prompt file identifies a dispatch: each round writes its own, and a
449
+ second run of the same one is a retry, which `attempt` separates. Worker id
450
+ and dispatch kind cannot carry that — `--dispatch-kind` is the lead's to
451
+ pick and it has reused one across two rounds, which put the finished
452
+ round's completion on the older namesake row and left the row that actually
453
+ ran marked running, so an await re-collected that worker forever.
454
+ """
455
+ return (
456
+ isinstance(record, dict)
457
+ and record.get("promptPath") == str(prompt_path)
458
+ and record.get("attempt") == attempt
459
+ )
@@ -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."""