okstra 0.167.0 → 0.169.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 (102) hide show
  1. package/README.md +6 -5
  2. package/docs/architecture/storage-model.md +57 -1
  3. package/docs/architecture.md +70 -2
  4. package/docs/cli.md +8 -4
  5. package/docs/for-ai/skills/okstra-code-review.md +3 -2
  6. package/docs/for-ai/skills/okstra-schedule-gen.md +3 -1
  7. package/docs/pr-template-usage.md +10 -6
  8. package/docs/project-structure-overview.md +14 -11
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +6 -5
  12. package/runtime/agents/workers/report-writer-worker.md +9 -4
  13. package/runtime/agents/workers/translator-worker.md +6 -4
  14. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +27 -1
  15. package/runtime/prompts/coding-preflight/clean-code.md +13 -0
  16. package/runtime/prompts/duties/acceptance-critic.md +24 -0
  17. package/runtime/prompts/duties/acceptance-verifier.md +24 -0
  18. package/runtime/prompts/duties/analysis-worker.md +24 -0
  19. package/runtime/prompts/duties/code-reviewer.md +24 -0
  20. package/runtime/prompts/duties/common.md +35 -0
  21. package/runtime/prompts/duties/implementation-executor.md +24 -0
  22. package/runtime/prompts/duties/implementation-verifier.md +24 -0
  23. package/runtime/prompts/duties/lead.md +24 -0
  24. package/runtime/prompts/duties/report-writer.md +24 -0
  25. package/runtime/prompts/duties/reverification-worker.md +24 -0
  26. package/runtime/prompts/duties/schedule-verifier.md +24 -0
  27. package/runtime/prompts/duties/scope-critic.md +24 -0
  28. package/runtime/prompts/duties/translator.md +24 -0
  29. package/runtime/prompts/lead/convergence.md +51 -7
  30. package/runtime/prompts/lead/okstra-lead-contract.md +10 -20
  31. package/runtime/prompts/lead/plan-body-verification.md +16 -1
  32. package/runtime/prompts/lead/report-writer.md +20 -5
  33. package/runtime/prompts/lead/team-contract.md +13 -13
  34. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  36. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  37. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  38. package/runtime/prompts/profiles/implementation.md +4 -2
  39. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +6 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +3 -2
  41. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +8 -0
  42. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +33 -0
  43. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +13 -12
  44. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +6 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +3 -2
  46. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +2 -0
  47. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  48. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +6 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -1
  50. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +6 -0
  51. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -1
  52. package/runtime/python/okstra_ctl/agent_invocation.py +1502 -0
  53. package/runtime/python/okstra_ctl/agent_prompt_cli.py +788 -0
  54. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -107
  55. package/runtime/python/okstra_ctl/context_cost.py +46 -5
  56. package/runtime/python/okstra_ctl/dispatch_core.py +312 -37
  57. package/runtime/python/okstra_ctl/dispatch_state.py +461 -36
  58. package/runtime/python/okstra_ctl/doctor.py +150 -16
  59. package/runtime/python/okstra_ctl/entrypoints/hosts.py +87 -9
  60. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +214 -23
  61. package/runtime/python/okstra_ctl/path_hints.py +26 -0
  62. package/runtime/python/okstra_ctl/paths.py +20 -0
  63. package/runtime/python/okstra_ctl/ports/__init__.py +8 -0
  64. package/runtime/python/okstra_ctl/ports/host.py +3 -0
  65. package/runtime/python/okstra_ctl/ports/host_model.py +60 -0
  66. package/runtime/python/okstra_ctl/pr_template.py +3 -6
  67. package/runtime/python/okstra_ctl/registry/host_registry.py +5 -0
  68. package/runtime/python/okstra_ctl/render.py +217 -12
  69. package/runtime/python/okstra_ctl/report_finalize.py +44 -0
  70. package/runtime/python/okstra_ctl/run.py +368 -51
  71. package/runtime/python/okstra_ctl/session.py +16 -12
  72. package/runtime/python/okstra_ctl/team.py +11 -11
  73. package/runtime/python/okstra_ctl/worker_dispatch.py +104 -0
  74. package/runtime/python/okstra_ctl/worker_prompt_body.py +5 -38
  75. package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -2
  76. package/runtime/python/okstra_ctl/worker_prompt_policy.py +38 -1
  77. package/runtime/skills/okstra-code-review/SKILL.md +22 -3
  78. package/runtime/skills/okstra-run/SKILL.md +16 -1
  79. package/runtime/skills/okstra-schedule-gen/SKILL.md +15 -1
  80. package/runtime/templates/implementation-worker-preamble.md +0 -10
  81. package/runtime/templates/report-writer-prompt-preamble.md +0 -9
  82. package/runtime/templates/reports/settings.template.json +0 -11
  83. package/runtime/templates/worker-prompt-preamble.md +0 -10
  84. package/runtime/validators/lib/fixtures.sh +93 -0
  85. package/runtime/validators/lib/validate-assets.sh +0 -8
  86. package/runtime/validators/validate-run.py +182 -0
  87. package/src/cli-registry.mjs +14 -0
  88. package/src/commands/execute/agent-prompt.mjs +25 -0
  89. package/src/commands/execute/codex-dispatch.mjs +6 -63
  90. package/src/commands/execute/worker-dispatch.mjs +76 -0
  91. package/src/commands/lifecycle/doctor.mjs +18 -3
  92. package/src/commands/lifecycle/install.mjs +33 -15
  93. package/src/commands/lifecycle/uninstall.mjs +4 -3
  94. package/src/lib/install-assets.mjs +9 -0
  95. package/runtime/agents/workers/antigravity-worker.md +0 -259
  96. package/runtime/agents/workers/codex-worker.md +0 -259
  97. package/runtime/agents/workers/grok-worker.md +0 -259
  98. package/runtime/agents/workers/kimi-worker.md +0 -259
  99. package/runtime/prompts/coding-preflight/scripts/preedit-check.sh +0 -79
  100. package/runtime/templates/operating-standard.md +0 -22
  101. package/src/lib/worker-agent-render.mjs +0 -50
  102. /package/runtime/templates/{prd → pr}/pr-body.template.md +0 -0
@@ -16,19 +16,22 @@ from .dispatch_state import (
16
16
  BACKEND_CMUX_PANE,
17
17
  BACKEND_MIXED,
18
18
  BACKEND_TMUX_PANE,
19
+ append_worker_dispatch as _append_worker_dispatch,
19
20
  DispatchError,
20
21
  WorkerJob,
21
22
  dispatch_mode as _dispatch_mode,
22
- dispatch_record_matches,
23
23
  LIVENESS_AUDIT_HEARTBEAT,
24
24
  LIVENESS_WRAPPER_STATUS,
25
25
  load_json_object as _load_json_object,
26
+ link_agent_dispatch_result as _link_agent_dispatch_result,
26
27
  missing_completion_paths as _missing_completion_paths,
28
+ mutate_team_state as _mutate_team_state,
27
29
  require_string as _require_string,
28
30
  resolve_project_path as _resolve_project_path,
29
31
  resolve_required_path as _resolve_required_path,
30
32
  record_dispatch_facts as _record_dispatch_facts,
31
33
  transition_worker_status as _transition_worker_status,
34
+ update_worker_dispatch_status as _update_worker_dispatch_status,
32
35
  string_list as _string_list,
33
36
  string_value as _string_value,
34
37
  TEARDOWN_BEFORE_TERMINAL_REASON,
@@ -37,7 +40,6 @@ from .dispatch_state import (
37
40
  worker_jobs_from_file as _worker_jobs_from_file,
38
41
  worker_state as _worker_state,
39
42
  worktree_path as _worktree_path,
40
- write_json as _write_json,
41
43
  )
42
44
  from .final_report_paths import (
43
45
  final_report_data_path as _final_report_data_path,
@@ -74,6 +76,7 @@ class WorkerHandle:
74
76
  completed_process: subprocess.CompletedProcess[str] | None
75
77
  status_sidecar_path: Path | None
76
78
  degraded_from: str
79
+ running_process: subprocess.Popen[str] | None = None
77
80
 
78
81
 
79
82
  @dataclass(frozen=True)
@@ -174,7 +177,6 @@ def build_dispatch_plan(
174
177
  )
175
178
  if jobs_file:
176
179
  jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
177
- _validate_dispatch_prompts(manifest, active_context, jobs)
178
180
  else:
179
181
  jobs = _jobs_from_roster(
180
182
  project_root,
@@ -186,6 +188,7 @@ def build_dispatch_plan(
186
188
  requested_workers,
187
189
  options,
188
190
  )
191
+ _validate_dispatch_prompts(manifest, active_context, jobs)
189
192
  return DispatchPlan(
190
193
  project_root=project_root,
191
194
  workspace_root=workspace_root.resolve(),
@@ -199,6 +202,7 @@ def build_dispatch_plan(
199
202
 
200
203
 
201
204
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
205
+ _validate_report_writer_isolation(plan.jobs)
202
206
  if wait:
203
207
  pane_backends = sorted(
204
208
  {
@@ -225,6 +229,161 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
225
229
  return 0
226
230
 
227
231
 
232
+ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
233
+ """Start one dependency-free CLI batch before collecting any worker."""
234
+ if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
235
+ raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
236
+ _validate_report_writer_isolation(plan.jobs)
237
+ _record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
238
+ final_codes = _dispatch_cli_wrapper_batch(plan, plan.jobs)
239
+ return next((final_codes[job.worker_id] for job in plan.jobs
240
+ if final_codes.get(job.worker_id, 0) != 0), 0)
241
+
242
+
243
+ def _validate_report_writer_isolation(jobs: Sequence[WorkerJob]) -> None:
244
+ if (
245
+ any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
246
+ and len(jobs) > 1
247
+ ):
248
+ raise DispatchError(
249
+ "report-writer must run in a separate Phase 6 dispatch after "
250
+ "analysis and convergence"
251
+ )
252
+
253
+
254
+ def _dispatch_cli_wrapper_batch(
255
+ plan: DispatchPlan,
256
+ jobs: Sequence[WorkerJob],
257
+ ) -> dict[str, int]:
258
+ """Run one concurrent batch through all retries before the next dependency."""
259
+ pending = [(job, 1) for job in jobs]
260
+ final_codes: dict[str, int] = {}
261
+ while pending:
262
+ active: list[tuple[WorkerHandle, int]] = []
263
+ starting_job: WorkerJob | None = None
264
+ starting_attempt = 1
265
+ try:
266
+ for job, attempt in pending:
267
+ starting_job = job
268
+ starting_attempt = attempt
269
+ active.append((_spawn_cli_job_nonblocking(plan, job, attempt), attempt))
270
+ except (OSError, DispatchError) as exc:
271
+ if starting_job is not None:
272
+ _abort_cli_batch_start(
273
+ plan,
274
+ active,
275
+ starting_job,
276
+ starting_attempt,
277
+ pending,
278
+ exc,
279
+ )
280
+ raise
281
+ outcomes: list[tuple[WorkerHandle, int, WorkerOutcome]] = []
282
+ for handle, attempt in active:
283
+ process = handle.running_process
284
+ if process is None:
285
+ raise DispatchError("concurrent CLI worker process is missing")
286
+ returncode = process.wait()
287
+ completed = subprocess.CompletedProcess(handle.job.command, returncode)
288
+ settled = replace(handle, completed_process=completed, running_process=None)
289
+ outcomes.append((settled, attempt, _outcome_from_completed(settled)))
290
+
291
+ next_round: list[tuple[WorkerJob, int]] = []
292
+ for handle, attempt, outcome in outcomes:
293
+ job = handle.job
294
+ _finish_attempt(plan, job, attempt, outcome)
295
+ if _worker_terminal_status(plan.team_state_path, job.worker_id) == "completed":
296
+ final_codes[job.worker_id] = 0
297
+ continue
298
+ if _should_retry(outcome, attempt):
299
+ _append_event(
300
+ plan,
301
+ "worker-retry-scheduled",
302
+ _retry_details(job, attempt, outcome),
303
+ )
304
+ next_round.append((job, attempt + 1))
305
+ continue
306
+ final_codes[job.worker_id] = outcome.returncode or 1
307
+ pending = next_round
308
+ return final_codes
309
+
310
+
311
+ def _abort_cli_batch_start(
312
+ plan: DispatchPlan,
313
+ active: Sequence[tuple[WorkerHandle, int]],
314
+ failed_job: WorkerJob,
315
+ failed_attempt: int,
316
+ pending: Sequence[tuple[WorkerJob, int]],
317
+ error: Exception,
318
+ ) -> None:
319
+ reason = f"concurrent CLI batch launch failed: {error}"
320
+ for handle, _attempt in active:
321
+ process = handle.running_process
322
+ if process is not None and process.poll() is None:
323
+ process.terminate()
324
+ for handle, attempt in active:
325
+ process = handle.running_process
326
+ if process is not None:
327
+ process.wait()
328
+ _transition_worker_status(
329
+ plan.team_state_path, handle.job.worker_id, "error", reason
330
+ )
331
+ _update_dispatch_status(
332
+ plan.team_state_path, handle.job, attempt, "error", reason
333
+ )
334
+ _transition_worker_status(
335
+ plan.team_state_path, failed_job.worker_id, "error", reason
336
+ )
337
+ if not _update_dispatch_status(
338
+ plan.team_state_path,
339
+ failed_job,
340
+ failed_attempt,
341
+ "error",
342
+ reason,
343
+ ):
344
+ failed_handle = WorkerHandle(
345
+ failed_job,
346
+ "",
347
+ None,
348
+ status_path_for_prompt(failed_job.prompt_path),
349
+ "",
350
+ )
351
+ _record_dispatch(
352
+ plan.team_state_path,
353
+ failed_handle,
354
+ failed_attempt,
355
+ "error",
356
+ reason,
357
+ )
358
+ closed = {
359
+ (handle.job.worker_id, attempt) for handle, attempt in active
360
+ }
361
+ closed.add((failed_job.worker_id, failed_attempt))
362
+ for job, attempt in pending:
363
+ if (job.worker_id, attempt) in closed:
364
+ continue
365
+ _transition_worker_status(
366
+ plan.team_state_path, job.worker_id, "error", reason
367
+ )
368
+ if not _update_dispatch_status(
369
+ plan.team_state_path, job, attempt, "error", reason
370
+ ):
371
+ handle = WorkerHandle(
372
+ job,
373
+ "",
374
+ None,
375
+ status_path_for_prompt(job.prompt_path),
376
+ "",
377
+ )
378
+ _record_dispatch(
379
+ plan.team_state_path,
380
+ handle,
381
+ attempt,
382
+ "error",
383
+ reason,
384
+ )
385
+
386
+
228
387
  def await_dispatches(
229
388
  plan: DispatchPlan,
230
389
  *,
@@ -312,7 +471,15 @@ def _select_workers(
312
471
  options,
313
472
  )
314
473
  supported = set(options.supported_worker_wrappers)
315
- selected = list(requested_workers) if requested_workers else [w for w in recommended if w in supported]
474
+ selected = (
475
+ list(requested_workers)
476
+ if requested_workers
477
+ else [
478
+ worker
479
+ for worker in recommended
480
+ if worker in supported and worker != REPORT_WRITER_WORKER_ID
481
+ ]
482
+ )
316
483
  unknown = [worker for worker in selected if worker not in recommended]
317
484
  if unknown:
318
485
  raise DispatchError("requested worker(s) are not in this run roster: " + ", ".join(unknown))
@@ -339,7 +506,11 @@ def _select_cli_wrapper_assignments(
339
506
  if _is_cli_wrapper_assignment(team_state, worker_id, options)
340
507
  }
341
508
  if not requested_workers:
342
- selected = [worker for worker in recommended if worker in dispatchable]
509
+ selected = [
510
+ worker
511
+ for worker in recommended
512
+ if worker in dispatchable and worker != REPORT_WRITER_WORKER_ID
513
+ ]
343
514
  if selected:
344
515
  return selected
345
516
  raise DispatchError("run roster has no cli-wrapper assignments for codex-dispatch")
@@ -400,6 +571,8 @@ def _job_from_roster_worker(
400
571
  options: _BuildOptions,
401
572
  ) -> WorkerJob:
402
573
  result_path = _resolve_project_path(project_root, fact.result_path)
574
+ metadata = _agent_metadata(prompt_path)
575
+ invocation = _invocation_job_fields(metadata, prompt_path)
403
576
  return WorkerJob(
404
577
  worker_id=fact.worker_id,
405
578
  provider=fact.provider,
@@ -419,9 +592,47 @@ def _job_from_roster_worker(
419
592
  role=fact.role,
420
593
  idle_timeout_seconds=options.idle_timeout_seconds,
421
594
  dispatch_kind=options.dispatch_kind,
595
+ **invocation,
422
596
  )
423
597
 
424
598
 
599
+ def _agent_metadata(prompt_path: Path) -> Mapping[str, Any] | None:
600
+ metadata_path = prompt_path.with_name(prompt_path.name + ".meta.json")
601
+ if not metadata_path.is_file():
602
+ return None
603
+ return _load_json_object(metadata_path, "agent invocation metadata")
604
+
605
+
606
+ def _invocation_job_fields(
607
+ metadata: Mapping[str, Any] | None,
608
+ prompt_path: Path,
609
+ ) -> dict[str, Any]:
610
+ if metadata is None:
611
+ return {}
612
+ digests = metadata.get("digests")
613
+ assignment = metadata.get("modelAssignment")
614
+ if not isinstance(digests, Mapping) or not isinstance(assignment, Mapping):
615
+ raise DispatchError("agent invocation metadata is incomplete")
616
+ host_model_value = assignment.get("hostModelValue")
617
+ if host_model_value is not None and not isinstance(host_model_value, str):
618
+ raise DispatchError("agent invocation hostModelValue is invalid")
619
+ return {
620
+ "invocation_id": _require_string(metadata, "invocationId"),
621
+ "audience": _require_string(metadata, "audience"),
622
+ "assignment_ref": _require_string(metadata, "assignmentRef"),
623
+ "prompt_metadata_path": prompt_path.with_name(
624
+ prompt_path.name + ".meta.json"
625
+ ),
626
+ "catalog_digest": _require_string(digests, "catalogDigest"),
627
+ "assignment_digest": _require_string(digests, "assignmentDigest"),
628
+ "duty_digest": _require_string(digests, "dutyDigest"),
629
+ "instruction_digest": _require_string(digests, "instructionDigest"),
630
+ "prompt_digest": _require_string(digests, "promptDigest"),
631
+ "host_model_value": host_model_value,
632
+ "enforcement_mode": "core-pre-dispatch",
633
+ }
634
+
635
+
425
636
  def _materialize_roster_prompts(
426
637
  project_root: Path,
427
638
  workspace_root: Path,
@@ -510,6 +721,29 @@ def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle
510
721
  return handle
511
722
 
512
723
 
724
+ def _spawn_cli_job_nonblocking(
725
+ plan: DispatchPlan, job: WorkerJob, attempt: int,
726
+ ) -> WorkerHandle:
727
+ _transition_worker_status(
728
+ plan.team_state_path, job.worker_id, "in-progress", "",
729
+ model_execution_value=job.model_execution_value,
730
+ )
731
+ process: subprocess.Popen[str] | None = None
732
+ try:
733
+ process = subprocess.Popen(job.command, cwd=plan.project_root, text=True)
734
+ handle = WorkerHandle(
735
+ job, "", None, status_path_for_prompt(job.prompt_path), "", process
736
+ )
737
+ _record_dispatch(plan.team_state_path, handle, attempt, "running", "")
738
+ _append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
739
+ return handle
740
+ except (OSError, DispatchError):
741
+ if process is not None and process.poll() is None:
742
+ process.terminate()
743
+ process.wait()
744
+ raise
745
+
746
+
513
747
  def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
514
748
  for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
515
749
  handle = _spawn_job(plan, job, attempt)
@@ -700,6 +934,13 @@ def _retry_from_record(
700
934
 
701
935
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
702
936
  if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
937
+ if job.invocation_id:
938
+ _link_agent_dispatch_result(
939
+ project_root=plan.project_root,
940
+ run_manifest_path=plan.manifest_path,
941
+ dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
942
+ result_path=job.worker_result_path,
943
+ )
703
944
  post_process = _post_process_report_writer_result(plan, job)
704
945
  if not post_process["ok"]:
705
946
  reason = _require_string(post_process, "reason")
@@ -779,18 +1020,23 @@ def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
779
1020
  def _record_dispatch(
780
1021
  team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
781
1022
  ) -> None:
782
- payload = _load_json_object(team_state_path, "team-state")
783
- dispatches = payload.setdefault("workerDispatches", [])
784
- if not isinstance(dispatches, list):
785
- raise DispatchError(f"team-state workerDispatches must be an array: {team_state_path}")
786
- dispatches.append(_dispatch_record(handle.job, attempt, status, handle.pane_id, handle.degraded_from, reason))
787
- _write_json(team_state_path, payload)
1023
+ _append_worker_dispatch(
1024
+ team_state_path,
1025
+ _dispatch_record(
1026
+ handle.job,
1027
+ attempt,
1028
+ status,
1029
+ handle.pane_id,
1030
+ handle.degraded_from,
1031
+ reason,
1032
+ ),
1033
+ )
788
1034
 
789
1035
 
790
1036
  def _dispatch_record(
791
1037
  job: WorkerJob, attempt: int, status: str, pane_id: str, degraded_from: str, reason: str = ""
792
1038
  ) -> dict[str, Any]:
793
- return {
1039
+ record = {
794
1040
  "workerId": job.worker_id,
795
1041
  "role": job.role,
796
1042
  "kind": job.dispatch_kind,
@@ -814,6 +1060,18 @@ def _dispatch_record(
814
1060
  "completionPaths": [str(path) for path in job.completion_paths],
815
1061
  "worktreePath": job.worktree_path,
816
1062
  }
1063
+ if job.invocation_id:
1064
+ record.update({
1065
+ "dispatchId": f"{job.invocation_id}:attempt-{attempt}",
1066
+ "invocationId": job.invocation_id,
1067
+ "audience": job.audience,
1068
+ "assignmentRef": job.assignment_ref,
1069
+ "promptMetadataPath": str(job.prompt_metadata_path),
1070
+ **job.digests,
1071
+ "hostModelValue": job.host_model_value,
1072
+ "enforcementMode": job.enforcement_mode,
1073
+ })
1074
+ return record
817
1075
 
818
1076
 
819
1077
  def _liveness_mode(backend: str) -> str:
@@ -824,22 +1082,14 @@ def _liveness_mode(backend: str) -> str:
824
1082
 
825
1083
  def _update_dispatch_status(
826
1084
  team_state_path: Path, job: WorkerJob, attempt: int, status: str, reason: str
827
- ) -> None:
828
- payload = _load_json_object(team_state_path, "team-state")
829
- matches = [
830
- record
831
- for record in payload.get("workerDispatches", [])
832
- if dispatch_record_matches(record, job.prompt_path, attempt)
833
- ]
834
- if not matches:
835
- return
836
- # A lead may re-send a round's prompt without advancing `attempt`, which
837
- # appends a second row the prompt cannot tell from the first. The newest is
838
- # the one still running, so an outcome written to the older row would leave
839
- # the live one running for good — the shape that wedged an await loop.
840
- matches[-1]["status"] = status
841
- matches[-1]["reason"] = reason
842
- _write_json(team_state_path, payload)
1085
+ ) -> bool:
1086
+ return _update_worker_dispatch_status(
1087
+ team_state_path,
1088
+ prompt_path=job.prompt_path,
1089
+ attempt=attempt,
1090
+ status=status,
1091
+ reason=reason,
1092
+ )
843
1093
 
844
1094
 
845
1095
  def _teardown_marked_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
@@ -884,15 +1134,23 @@ def _mark_roster_skips(
884
1134
  if not reasons:
885
1135
  return
886
1136
  team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
887
- current_team_state = _load_json_object(team_state_path, "team-state")
888
- for worker_id, reason in reasons.items():
889
- worker = _worker_state(current_team_state, worker_id)
890
- if (
891
- _string_value(worker.get("status")) != "not-run"
892
- or _string_value(worker.get("reason"))
893
- ):
894
- continue
895
- _transition_worker_status(team_state_path, worker_id, "not-run", reason)
1137
+
1138
+ def mark_pristine_skips(current_team_state: dict[str, Any]) -> bool:
1139
+ changed = False
1140
+ for worker_id, reason in reasons.items():
1141
+ worker = _worker_state(current_team_state, worker_id)
1142
+ if (
1143
+ _string_value(worker.get("status")) != "not-run"
1144
+ or _string_value(worker.get("reason"))
1145
+ ):
1146
+ continue
1147
+ worker["reason"] = reason
1148
+ worker.pop("startedAt", None)
1149
+ worker.pop("endedAt", None)
1150
+ changed = True
1151
+ return changed
1152
+
1153
+ _mutate_team_state(team_state_path, mark_pristine_skips)
896
1154
 
897
1155
 
898
1156
  def _skip_reasons(
@@ -1068,6 +1326,23 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
1068
1326
  role=_require_string(record, "role"),
1069
1327
  idle_timeout_seconds=600,
1070
1328
  dispatch_kind=_require_string(record, "kind"),
1329
+ invocation_id=_string_value(record.get("invocationId")),
1330
+ audience=_string_value(record.get("audience")),
1331
+ assignment_ref=_string_value(record.get("assignmentRef")),
1332
+ prompt_metadata_path=Path(
1333
+ _string_value(record.get("promptMetadataPath"))
1334
+ ),
1335
+ catalog_digest=_string_value(record.get("catalogDigest")),
1336
+ assignment_digest=_string_value(record.get("assignmentDigest")),
1337
+ duty_digest=_string_value(record.get("dutyDigest")),
1338
+ instruction_digest=_string_value(record.get("instructionDigest")),
1339
+ prompt_digest=_string_value(record.get("promptDigest")),
1340
+ host_model_value=(
1341
+ record.get("hostModelValue")
1342
+ if isinstance(record.get("hostModelValue"), str)
1343
+ else None
1344
+ ),
1345
+ enforcement_mode=_string_value(record.get("enforcementMode")),
1071
1346
  )
1072
1347
 
1073
1348