okstra 0.135.0 → 0.137.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 (44) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/contributor-change-matrix.md +1 -1
  3. package/docs/project-structure-overview.md +8 -3
  4. package/package.json +2 -3
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/interactive.sh +24 -67
  7. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +10 -8
  9. package/runtime/bin/okstra-claude-exec.sh +10 -8
  10. package/runtime/bin/okstra-codex-exec.sh +10 -8
  11. package/runtime/bin/okstra-spawn-followups.py +4 -9
  12. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  13. package/runtime/python/okstra_ctl/backfill.py +2 -1
  14. package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
  15. package/runtime/python/okstra_ctl/consumers.py +7 -5
  16. package/runtime/python/okstra_ctl/context_cost.py +6 -5
  17. package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
  18. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  19. package/runtime/python/okstra_ctl/error_log_core.py +3 -1
  20. package/runtime/python/okstra_ctl/error_report.py +2 -7
  21. package/runtime/python/okstra_ctl/handoff.py +2 -1
  22. package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
  23. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  24. package/runtime/python/okstra_ctl/path_hints.py +5 -3
  25. package/runtime/python/okstra_ctl/paths.py +278 -4
  26. package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
  27. package/runtime/python/okstra_ctl/recap.py +5 -12
  28. package/runtime/python/okstra_ctl/render.py +9 -15
  29. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  30. package/runtime/python/okstra_ctl/report_language.py +83 -0
  31. package/runtime/python/okstra_ctl/run.py +222 -239
  32. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  33. package/runtime/python/okstra_ctl/stage_targets.py +98 -55
  34. package/runtime/python/okstra_ctl/time_report.py +4 -9
  35. package/runtime/python/okstra_ctl/wizard.py +50 -50
  36. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  40. package/runtime/python/okstra_project/__init__.py +4 -0
  41. package/runtime/python/okstra_project/dirs.py +7 -0
  42. package/runtime/python/okstra_project/state.py +78 -8
  43. package/runtime/validators/validate-run.py +23 -52
  44. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -13,6 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
  from typing import Iterable
15
15
 
16
+ from okstra_ctl.paths import RunRef, runs_dir_of, task_manifest_file
16
17
  from okstra_ctl.task_target import resolve_task_root, project_rel
17
18
 
18
19
 
@@ -102,7 +103,7 @@ def _find_current_run_dir(
102
103
  task_type = manifest.get("workflow", {}).get("currentPhase") or manifest.get("taskType")
103
104
  if not isinstance(task_type, str) or not task_type:
104
105
  return None
105
- candidate = task_root / "runs" / task_type
106
+ candidate = RunRef.from_task_root(task_root, task_type).run_dir
106
107
  return candidate if candidate.is_dir() else None
107
108
 
108
109
 
@@ -244,7 +245,7 @@ def _lead_phase1_metric(
244
245
  instruction_set = task_root / "instruction-set"
245
246
  packet = instruction_set / "analysis-packet.md"
246
247
  files = [
247
- task_root / "task-manifest.json",
248
+ task_manifest_file(task_root),
248
249
  active_path,
249
250
  instruction_set / "analysis-profile.md",
250
251
  packet if packet.is_file() else instruction_set / "task-brief.md",
@@ -264,7 +265,7 @@ def _lead_phase1_metric(
264
265
  state_dir, f"team-state-{current_phase}-*.json"
265
266
  )
266
267
  files = [
267
- task_root / "task-manifest.json",
268
+ task_manifest_file(task_root),
268
269
  task_root / "instruction-set" / "task-brief.md",
269
270
  task_root / "instruction-set" / "analysis-profile.md",
270
271
  ]
@@ -401,13 +402,13 @@ def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: P
401
402
 
402
403
 
403
404
  def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
404
- manifest = _load_json(task_root / "task-manifest.json")
405
+ manifest = _load_json(task_manifest_file(task_root))
405
406
  run_dir = _find_current_run_dir(task_root, manifest, project_root)
406
407
  all_task_files = _all_files(task_root)
407
408
  task_file_count, task_bytes = _count_files(all_task_files)
408
409
  current_run_file_count, current_run_bytes = _count_files(_all_files(run_dir)) if run_dir else (0, 0)
409
410
  legacy_timestamp_files = [
410
- path for path in (task_root / "runs").rglob("*")
411
+ path for path in runs_dir_of(task_root).rglob("*")
411
412
  if path.is_file() and _is_timestamped_legacy_artifact(path)
412
413
  ]
413
414
 
@@ -11,6 +11,29 @@ from pathlib import Path
11
11
  from typing import Any, Mapping, Sequence
12
12
 
13
13
  from . import tmux
14
+ from .dispatch_state import (
15
+ BACKEND_CLI_WRAPPER,
16
+ BACKEND_MIXED,
17
+ BACKEND_TMUX_PANE,
18
+ DispatchError,
19
+ WorkerJob,
20
+ dispatch_mode as _dispatch_mode,
21
+ load_json_object as _load_json_object,
22
+ missing_completion_paths as _missing_completion_paths,
23
+ require_string as _require_string,
24
+ resolve_project_path as _resolve_project_path,
25
+ resolve_required_path as _resolve_required_path,
26
+ set_dispatch_mode as _set_dispatch_mode,
27
+ set_worker_status as _set_worker_status,
28
+ string_list as _string_list,
29
+ string_value as _string_value,
30
+ utc_now as _utc_now,
31
+ validate_initial_prompts as _validate_initial_prompts,
32
+ worker_prompt_path as _worker_prompt_path,
33
+ worker_state as _worker_state,
34
+ worktree_path as _worktree_path,
35
+ write_json as _write_json,
36
+ )
14
37
  from .final_report_paths import (
15
38
  final_report_data_path as _final_report_data_path,
16
39
  final_report_markdown_path as _final_report_markdown_path,
@@ -18,74 +41,21 @@ from .final_report_paths import (
18
41
  from .lead_events import LeadEvent, append_lead_event
19
42
  from .path_hints import hydrate_active_run_context
20
43
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
21
- from .worker_prompt_body import analysis_prompt_body
44
+ from .report_language import resolve_report_language
45
+ from .worker_prompt_body import analysis_prompt_body, report_writer_prompt_body
46
+ from .worker_prompt_body import REPORT_WRITER_WORKER_ID
22
47
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
23
48
  from .worker_prompt_policy import resolve_prompt_plan_for_manifest
24
49
  from .worker_artifact_paths import audit_sidecar_rel
25
50
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
26
51
 
27
52
 
28
- BACKEND_CLI_WRAPPER = "cli-wrapper"
29
- BACKEND_TMUX_PANE = "tmux-pane"
30
- BACKEND_MIXED = "mixed"
31
53
  LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
32
54
  LIVENESS_WRAPPER_STATUS = "wrapper-status"
33
55
  MAX_WORKER_ATTEMPTS = 2
34
- REPORT_WRITER_WORKER_ID = "report-writer"
35
56
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
36
57
 
37
58
 
38
- class DispatchError(Exception):
39
- """Raised when a worker dispatch request cannot be executed."""
40
-
41
-
42
- @dataclass(frozen=True)
43
- class WorkerJob:
44
- worker_id: str
45
- provider: str
46
- backend: str
47
- project_root: Path
48
- model_execution_value: str
49
- wrapper_path: Path
50
- prompt_path: Path
51
- result_path: Path
52
- worker_result_path: Path
53
- completion_paths: tuple[Path, ...]
54
- worktree_path: str
55
- role: str
56
- idle_timeout_seconds: int
57
- dispatch_kind: str
58
-
59
- @property
60
- def command(self) -> list[str]:
61
- return [
62
- str(self.wrapper_path),
63
- str(self.project_root),
64
- self.model_execution_value,
65
- str(self.prompt_path),
66
- self.worktree_path,
67
- self.role,
68
- str(self.idle_timeout_seconds),
69
- ]
70
-
71
- def to_payload(self) -> dict[str, Any]:
72
- return {
73
- "workerId": self.worker_id,
74
- "provider": self.provider,
75
- "backend": self.backend,
76
- "modelExecutionValue": self.model_execution_value,
77
- "wrapperPath": str(self.wrapper_path),
78
- "promptPath": str(self.prompt_path),
79
- "resultPath": str(self.result_path),
80
- "workerResultPath": str(self.worker_result_path),
81
- "completionPaths": [str(path) for path in self.completion_paths],
82
- "worktreePath": self.worktree_path,
83
- "role": self.role,
84
- "dispatchKind": self.dispatch_kind,
85
- "command": self.command,
86
- }
87
-
88
-
89
59
  @dataclass(frozen=True)
90
60
  class WorkerHandle:
91
61
  job: WorkerJob
@@ -202,20 +172,6 @@ def build_dispatch_plan(
202
172
  )
203
173
 
204
174
 
205
- def _validate_initial_prompts(
206
- manifest: Mapping[str, Any],
207
- jobs: Sequence[WorkerJob],
208
- ) -> None:
209
- records = [
210
- PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
211
- for job in jobs
212
- ]
213
- errors = validate_initial_prompt_records(manifest=manifest, records=records)
214
- if errors:
215
- task_type = _require_string(manifest, "taskType")
216
- raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
217
-
218
-
219
175
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
220
176
  if wait:
221
177
  if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
@@ -334,6 +290,7 @@ def _job_from_roster_worker(
334
290
  result_rel=result_rel,
335
291
  project_root=project_root,
336
292
  manifest=manifest,
293
+ team_state=team_state,
337
294
  active_context=active_context,
338
295
  dispatch_kind=options.dispatch_kind,
339
296
  model=model,
@@ -350,7 +307,7 @@ def _job_from_roster_worker(
350
307
  result_path=_result_path_for_worker(worker_id, result_path, manifest, project_root),
351
308
  worker_result_path=result_path,
352
309
  completion_paths=_completion_paths(worker_id, result_path, manifest, project_root),
353
- worktree_path=_worktree_path(active_context),
310
+ worktree_path=_worktree_path(manifest, active_context),
354
311
  role=_string_value(state.get("role")) or "worker",
355
312
  idle_timeout_seconds=options.idle_timeout_seconds,
356
313
  dispatch_kind=options.dispatch_kind,
@@ -412,7 +369,10 @@ def _job_from_file_worker(
412
369
 
413
370
 
414
371
  def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
415
- _set_worker_status(plan.team_state_path, job.worker_id, "running", "", job.model_execution_value)
372
+ _set_worker_status(
373
+ plan.team_state_path, job.worker_id, "running", "",
374
+ model_execution_value=job.model_execution_value,
375
+ )
416
376
  handle = _start_job(plan, job)
417
377
  _record_dispatch(plan.team_state_path, handle, attempt, "running", "")
418
378
  _append_event(plan, "worker-dispatched", _attempt_details(job, attempt, handle))
@@ -595,30 +555,6 @@ def _record_matches(record: object, worker_id: str, kind: str, attempt: int) ->
595
555
  return isinstance(record, dict) and record.get("workerId") == worker_id and record.get("kind") == kind and record.get("attempt") == attempt
596
556
 
597
557
 
598
- def _set_worker_status(
599
- team_state_path: Path, worker_id: str, status: str, reason: str, model: str = ""
600
- ) -> None:
601
- payload = _load_json_object(team_state_path, "team-state")
602
- workers = payload.get("workers")
603
- if not isinstance(workers, list):
604
- raise DispatchError(f"team-state workers must be an array: {team_state_path}")
605
- for worker in workers:
606
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
607
- worker["status"] = status
608
- worker["reason"] = reason
609
- if model:
610
- worker["model"] = model
611
- worker["modelExecutionValue"] = model
612
- _write_json(team_state_path, payload)
613
- return
614
-
615
-
616
- def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
617
- payload = _load_json_object(team_state_path, "team-state")
618
- payload["dispatchMode"] = dispatch_mode
619
- _write_json(team_state_path, payload)
620
-
621
-
622
558
  def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
623
559
  payload = _load_json_object(team_state_path, "team-state")
624
560
  dispatches = payload.get("workerDispatches")
@@ -764,10 +700,6 @@ def _failure_reason(outcome: WorkerOutcome) -> str:
764
700
  return "worker dispatch failed"
765
701
 
766
702
 
767
- def _missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
768
- return tuple(path for path in job.completion_paths if not path.is_file())
769
-
770
-
771
703
  def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
772
704
  modes = {BACKEND_CLI_WRAPPER if h.degraded_from else h.job.backend for h in handles}
773
705
  if len(modes) == 1:
@@ -775,13 +707,6 @@ def _mode_from_handles(handles: Sequence[WorkerHandle]) -> str:
775
707
  return BACKEND_MIXED
776
708
 
777
709
 
778
- def _dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
779
- backends = {job.backend for job in jobs}
780
- if len(backends) == 1:
781
- return next(iter(backends))
782
- return BACKEND_MIXED
783
-
784
-
785
710
  def _validate_manifest(manifest: Mapping[str, Any], path: Path, required: str | None) -> None:
786
711
  if required is not None and manifest.get("leadRuntime") != required:
787
712
  raise DispatchError(f"run manifest is not a {required} lead run: {path}")
@@ -811,6 +736,7 @@ def _materialize_prompt_if_missing(
811
736
  result_rel: str,
812
737
  project_root: Path,
813
738
  manifest: Mapping[str, Any],
739
+ team_state: Mapping[str, Any],
814
740
  active_context: Mapping[str, Any],
815
741
  dispatch_kind: str,
816
742
  model: str,
@@ -819,11 +745,20 @@ def _materialize_prompt_if_missing(
819
745
  if prompt_path.is_file():
820
746
  return
821
747
  prompt_path.parent.mkdir(parents=True, exist_ok=True)
748
+ audit_source_rel = ""
749
+ if worker_id == REPORT_WRITER_WORKER_ID:
750
+ # The report-writer's Result Path is the report data.json; its own worker
751
+ # result stays the markdown the audit sidecar is derived from.
752
+ audit_source_rel = result_rel
753
+ result_rel = str(_final_report_data_path(
754
+ Path(_require_string(manifest, "expectedReportPath"))
755
+ ))
822
756
  try:
823
757
  headers = worker_prompt_headers(
824
758
  project_root=project_root,
825
759
  prompt_rel=prompt_rel,
826
760
  result_rel=result_rel,
761
+ audit_source_rel=audit_source_rel or None,
827
762
  worker_id=worker_id,
828
763
  dispatch_kind=dispatch_kind,
829
764
  manifest=manifest,
@@ -840,13 +775,23 @@ def _materialize_prompt_if_missing(
840
775
  except ValueError as exc:
841
776
  raise DispatchError(str(exc)) from exc
842
777
  lines = list(headers)
843
- if plan.audience in {
778
+ if plan.audience == "report-writer":
779
+ lines.extend(
780
+ report_writer_prompt_body(
781
+ manifest,
782
+ active_context,
783
+ team_state,
784
+ model,
785
+ resolve_report_language(project_root, manifest, active_context),
786
+ )
787
+ )
788
+ elif plan.audience in {
844
789
  "analysis",
845
790
  "implementation-executor",
846
791
  "implementation-verifier",
847
792
  }:
848
793
  if _string_value(manifest.get("taskType")) == "implementation":
849
- worktree = _worktree_path(active_context)
794
+ worktree = _worktree_path(manifest, active_context)
850
795
  if not worktree:
851
796
  raise DispatchError(
852
797
  "implementation prompt generation requires a worktree path"
@@ -905,38 +850,6 @@ def _run_dir(plan: DispatchPlan) -> Path:
905
850
  return _resolve_project_path(plan.project_root, value) if value else plan.team_state_path.parent.parent
906
851
 
907
852
 
908
- def _worktree_path(active_context: Mapping[str, Any]) -> str:
909
- worktree = active_context.get("executorWorktree")
910
- if isinstance(worktree, dict):
911
- return _string_value(worktree.get("path"))
912
- return ""
913
-
914
-
915
- def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
916
- workers = team_state.get("workers")
917
- if isinstance(workers, list):
918
- for worker in workers:
919
- if isinstance(worker, dict) and worker.get("workerId") == worker_id:
920
- return worker
921
- raise DispatchError(f"team-state has no workerId={worker_id}")
922
-
923
-
924
- def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
925
- paths = manifest.get("workerPromptPathByWorkerId")
926
- if not isinstance(paths, Mapping):
927
- raise DispatchError("run manifest has no workerPromptPathByWorkerId")
928
- return _require_string(paths, worker_id)
929
-
930
-
931
- def _load_json_object(path: Path, label: str) -> dict[str, Any]:
932
- if not path.is_file():
933
- raise DispatchError(f"{label} not found: {path}")
934
- payload = json.loads(path.read_text(encoding="utf-8"))
935
- if not isinstance(payload, dict):
936
- raise DispatchError(f"{label} must be a JSON object: {path}")
937
- return payload
938
-
939
-
940
853
  def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
941
854
  if not isinstance(value, str) or not value.strip():
942
855
  return {}
@@ -946,38 +859,12 @@ def _load_optional_json(project_root: Path, value: Any) -> dict[str, Any]:
946
859
  return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
947
860
 
948
861
 
949
- def _resolve_required_path(project_root: Path, manifest: Mapping[str, Any], key: str) -> Path:
950
- return _resolve_project_path(project_root, _require_string(manifest, key))
951
-
952
-
953
- def _resolve_project_path(project_root: Path, value: str) -> Path:
954
- path = Path(value)
955
- return path if path.is_absolute() else project_root / path
956
-
957
-
958
862
  def _optional_path(value: object) -> Path | None:
959
863
  if not isinstance(value, str) or not value.strip():
960
864
  return None
961
865
  return Path(value)
962
866
 
963
867
 
964
- def _require_string(payload: Mapping[str, Any], key: str) -> str:
965
- value = payload.get(key)
966
- if not isinstance(value, str) or not value.strip():
967
- raise DispatchError(f"required string missing: {key}")
968
- return value.strip()
969
-
970
-
971
- def _string_value(value: Any) -> str:
972
- return value.strip() if isinstance(value, str) else ""
973
-
974
-
975
- def _string_list(value: Any) -> list[str]:
976
- if not isinstance(value, list):
977
- return []
978
- return [str(item).strip() for item in value if str(item).strip()]
979
-
980
-
981
868
  def _run_seq(manifest: Mapping[str, Any]) -> str:
982
869
  seqs = manifest.get("runSequencesByCategory")
983
870
  if isinstance(seqs, Mapping):
@@ -985,11 +872,3 @@ def _run_seq(manifest: Mapping[str, Any]) -> str:
985
872
  return "001"
986
873
 
987
874
 
988
- def _utc_now() -> str:
989
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
990
-
991
-
992
- def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
993
- tmp = path.with_suffix(path.suffix + ".tmp")
994
- tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
995
- os.replace(tmp, path)
@@ -0,0 +1,231 @@
1
+ """Worker dispatch plumbing shared by both lead dispatchers.
2
+
3
+ `dispatch_core` (claude / external lead) and `codex_dispatch` (codex lead) differ
4
+ in capability — only the former has tmux panes, blocking waits, and retry from a
5
+ persisted record. What they do *not* differ in is the job value object and the
6
+ run's state files: the same run-manifest keys, the same team-state document, the
7
+ same wrapper argv.
8
+
9
+ Those used to be two copies that had already drifted apart: one dispatcher
10
+ wrapped a truncated team-state in `DispatchError` while the other let
11
+ `json.JSONDecodeError` escape, one raised on an unknown `workerId` while the
12
+ other silently did nothing, and one gated the worktree argument on task type
13
+ while the other handed it to every phase. This module is the single reference
14
+ point so a fix cannot land on one lead runtime only.
15
+
16
+ Enforced by `tests/contract/test_dispatcher_shared_helpers.py`.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any, Mapping, Sequence
26
+
27
+ from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
28
+
29
+ BACKEND_CLI_WRAPPER = "cli-wrapper"
30
+ BACKEND_TMUX_PANE = "tmux-pane"
31
+ BACKEND_MIXED = "mixed"
32
+
33
+ # The worktree argument grants the wrapper `--add-dir` write access outside
34
+ # project-root. Only the phases whose workers mutate a stage worktree get it;
35
+ # analysis phases write their artifacts under project-root (see the contract in
36
+ # scripts/okstra-codex-exec.sh).
37
+ WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
38
+
39
+
40
+ class DispatchError(Exception):
41
+ """Raised when a worker dispatch request cannot be executed."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class WorkerJob:
46
+ worker_id: str
47
+ provider: str
48
+ backend: str
49
+ project_root: Path
50
+ model_execution_value: str
51
+ wrapper_path: Path
52
+ prompt_path: Path
53
+ result_path: Path
54
+ worker_result_path: Path
55
+ completion_paths: tuple[Path, ...]
56
+ worktree_path: str
57
+ role: str
58
+ idle_timeout_seconds: int
59
+ dispatch_kind: str
60
+
61
+ @property
62
+ def command(self) -> list[str]:
63
+ return [
64
+ str(self.wrapper_path),
65
+ str(self.project_root),
66
+ self.model_execution_value,
67
+ str(self.prompt_path),
68
+ self.worktree_path,
69
+ self.role,
70
+ str(self.idle_timeout_seconds),
71
+ ]
72
+
73
+ def to_payload(self) -> dict[str, Any]:
74
+ return {
75
+ "workerId": self.worker_id,
76
+ "provider": self.provider,
77
+ "backend": self.backend,
78
+ "modelExecutionValue": self.model_execution_value,
79
+ "wrapperPath": str(self.wrapper_path),
80
+ "promptPath": str(self.prompt_path),
81
+ "resultPath": str(self.result_path),
82
+ "workerResultPath": str(self.worker_result_path),
83
+ "completionPaths": [str(path) for path in self.completion_paths],
84
+ "worktreePath": self.worktree_path,
85
+ "role": self.role,
86
+ "dispatchKind": self.dispatch_kind,
87
+ "command": self.command,
88
+ }
89
+
90
+
91
+ # --- run-manifest reads -------------------------------------------------------
92
+
93
+ def string_value(value: Any) -> str:
94
+ return value.strip() if isinstance(value, str) else ""
95
+
96
+
97
+ def require_string(payload: Mapping[str, Any], key: str) -> str:
98
+ value = payload.get(key)
99
+ if not isinstance(value, str) or not value.strip():
100
+ raise DispatchError(f"missing required string field: {key}")
101
+ return value.strip()
102
+
103
+
104
+ def string_list(value: Any) -> list[str]:
105
+ if not isinstance(value, list):
106
+ return []
107
+ return [str(item).strip() for item in value if str(item).strip()]
108
+
109
+
110
+ def resolve_project_path(project_root: Path, value: str) -> Path:
111
+ path = Path(value)
112
+ return path if path.is_absolute() else project_root / path
113
+
114
+
115
+ def resolve_required_path(
116
+ project_root: Path, manifest: Mapping[str, Any], key: str
117
+ ) -> Path:
118
+ return resolve_project_path(project_root, require_string(manifest, key))
119
+
120
+
121
+ def worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
122
+ paths = manifest.get("workerPromptPathByWorkerId")
123
+ if not isinstance(paths, Mapping):
124
+ raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
125
+ return require_string(paths, worker_id)
126
+
127
+
128
+ def worktree_path(
129
+ manifest: Mapping[str, Any], active_context: Mapping[str, Any]
130
+ ) -> str:
131
+ if manifest.get("taskType") not in WORKTREE_TASK_TYPES:
132
+ return ""
133
+ worktree = active_context.get("executorWorktree")
134
+ if not isinstance(worktree, Mapping):
135
+ return ""
136
+ return str(worktree.get("path") or "")
137
+
138
+
139
+ # --- team-state document ------------------------------------------------------
140
+
141
+ def load_json_object(path: Path, label: str) -> dict[str, Any]:
142
+ if not path.is_file():
143
+ raise DispatchError(f"{label} not found: {path}")
144
+ try:
145
+ payload = json.loads(path.read_text(encoding="utf-8"))
146
+ except json.JSONDecodeError as exc:
147
+ raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
148
+ if not isinstance(payload, dict):
149
+ raise DispatchError(f"{label} must be a JSON object: {path}")
150
+ return payload
151
+
152
+
153
+ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
154
+ # Write via temp file + os.replace so a crash mid-write cannot leave a
155
+ # truncated team-state.json that every later load_json_object rejects;
156
+ # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
157
+ tmp = path.with_suffix(path.suffix + ".tmp")
158
+ tmp.write_text(
159
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
160
+ )
161
+ os.replace(tmp, path)
162
+
163
+
164
+ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
165
+ workers = team_state.get("workers")
166
+ if not isinstance(workers, list):
167
+ raise DispatchError("team-state workers must be an array")
168
+ for worker in workers:
169
+ if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
170
+ return worker
171
+ raise DispatchError(f"team-state has no workerId={worker_id}")
172
+
173
+
174
+ def set_worker_status(
175
+ team_state_path: Path,
176
+ worker_id: str,
177
+ status: str,
178
+ reason: str,
179
+ *,
180
+ model_execution_value: str = "",
181
+ ) -> None:
182
+ payload = load_json_object(team_state_path, "team-state")
183
+ workers = payload.get("workers")
184
+ if not isinstance(workers, list):
185
+ raise DispatchError(f"team-state workers must be an array: {team_state_path}")
186
+ for worker in workers:
187
+ if isinstance(worker, dict) and worker.get("workerId") == worker_id:
188
+ worker["status"] = status
189
+ worker["reason"] = reason
190
+ if model_execution_value:
191
+ worker["model"] = model_execution_value
192
+ worker["modelExecutionValue"] = model_execution_value
193
+ write_json(team_state_path, payload)
194
+ return
195
+ raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
196
+
197
+
198
+ def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
199
+ payload = load_json_object(team_state_path, "team-state")
200
+ payload["dispatchMode"] = dispatch_mode
201
+ write_json(team_state_path, payload)
202
+
203
+
204
+ # --- job facts ----------------------------------------------------------------
205
+
206
+ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
207
+ backends = {job.backend for job in jobs}
208
+ if len(backends) == 1:
209
+ return next(iter(backends))
210
+ return BACKEND_MIXED
211
+
212
+
213
+ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
214
+ return tuple(path for path in job.completion_paths if not path.is_file())
215
+
216
+
217
+ def validate_initial_prompts(
218
+ manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
219
+ ) -> None:
220
+ records = [
221
+ PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
222
+ for job in jobs
223
+ ]
224
+ errors = validate_initial_prompt_records(manifest=manifest, records=records)
225
+ if errors:
226
+ task_type = require_string(manifest, "taskType")
227
+ raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
228
+
229
+
230
+ def utc_now() -> str:
231
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -8,9 +8,11 @@ from __future__ import annotations
8
8
  import json
9
9
  from pathlib import Path
10
10
 
11
+ from .paths import runs_dir_of
12
+
11
13
 
12
14
  def glob_error_logs(task_root: Path) -> list[Path]:
13
- runs = task_root / "runs"
15
+ runs = runs_dir_of(task_root)
14
16
  if not runs.exists():
15
17
  return []
16
18
  flat = runs.glob("*/logs/errors-*.jsonl")
@@ -13,6 +13,7 @@ import sys
13
13
  from pathlib import Path
14
14
 
15
15
  from okstra_ctl.task_target import resolve_task_root, project_rel
16
+ from okstra_project import read_task_key
16
17
  from okstra_ctl.error_log_core import (
17
18
  glob_error_logs,
18
19
  parse_records,
@@ -80,13 +81,7 @@ def _timestamp_segment(now: dt.datetime) -> str:
80
81
 
81
82
 
82
83
  def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> dict:
83
- manifest_path = task_root / "task-manifest.json"
84
- task_key = ""
85
- if manifest_path.is_file():
86
- try:
87
- task_key = json.loads(manifest_path.read_text(encoding="utf-8")).get("taskKey", "")
88
- except Exception:
89
- task_key = ""
84
+ task_key = read_task_key(task_root)
90
85
  paths = glob_error_logs(task_root)
91
86
  records, skipped = parse_records(paths)
92
87
  agg = aggregate(records)
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
14
14
 
15
15
  from . import consumers, stage_targets, worktree_registry
16
16
  from .final_report_paths import final_report_markdown_path
17
+ from .paths import RunRef
17
18
  from .worktree import (compute_branch_name, compute_worktree_path,
18
19
  main_worktree_path, is_dirty_excluding_okstra,
19
20
  nested_worktree_excludes, is_ancestor, merge_branch,
@@ -62,7 +63,7 @@ def latest_whole_task_fv_accepted(project_root, project_id: str,
62
63
  f"{project_id}:{task_group}:{task_id}")
63
64
  if root is None:
64
65
  return ""
65
- reports_dir = root / "runs" / "final-verification" / "reports"
66
+ reports_dir = RunRef.from_task_root(root, "final-verification").reports_dir
66
67
  for dj in sorted(reports_dir.glob("final-report-*.data.json"),
67
68
  reverse=True):
68
69
  try: