okstra 0.136.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.
- package/docs/architecture/storage-model.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +8 -3
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +56 -331
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -175
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +9 -15
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +222 -239
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +98 -55
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +45 -47
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +0 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -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 .
|
|
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(
|
|
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
|
|
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")
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -6,8 +6,12 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from .consumers import
|
|
10
|
-
|
|
9
|
+
from .consumers import (
|
|
10
|
+
FAILED_CARRY_STATUSES,
|
|
11
|
+
carry_head_commit,
|
|
12
|
+
read_stage_consumer_state,
|
|
13
|
+
)
|
|
14
|
+
from .paths import RunRef, task_manifest_file
|
|
11
15
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
12
16
|
|
|
13
17
|
|
|
@@ -23,7 +27,7 @@ class ImplementationOutcome:
|
|
|
23
27
|
|
|
24
28
|
def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
25
29
|
task_root = Path(task_root)
|
|
26
|
-
manifest = _load_json(task_root
|
|
30
|
+
manifest = _load_json(task_manifest_file(task_root))
|
|
27
31
|
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
28
32
|
if workflow.get("currentPhase") != "implementation":
|
|
29
33
|
return ImplementationOutcome(completed=False, reason="current phase is not implementation")
|
|
@@ -32,7 +36,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
32
36
|
if not plan_run_root.is_dir():
|
|
33
37
|
return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
|
|
34
38
|
|
|
35
|
-
stage_map =
|
|
39
|
+
stage_map = load_stage_map(task_root, manifest)
|
|
36
40
|
if not stage_map:
|
|
37
41
|
return ImplementationOutcome(completed=False, reason="stage map unavailable")
|
|
38
42
|
|
|
@@ -53,7 +57,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
53
57
|
return ImplementationOutcome(
|
|
54
58
|
completed=True,
|
|
55
59
|
stages=sorted(required_stages),
|
|
56
|
-
head_commit=str(latest_done.get("head_commit") or "") or
|
|
60
|
+
head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
|
|
57
61
|
report_path=_latest_implementation_report(task_root),
|
|
58
62
|
next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
|
|
59
63
|
reason="all implementation stages have pass-grade carry and done rows",
|
|
@@ -67,7 +71,7 @@ def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> boo
|
|
|
67
71
|
if not outcome.completed:
|
|
68
72
|
return False
|
|
69
73
|
|
|
70
|
-
manifest_path = task_root
|
|
74
|
+
manifest_path = task_manifest_file(task_root)
|
|
71
75
|
manifest = _load_json(manifest_path)
|
|
72
76
|
workflow = manifest.get("workflow")
|
|
73
77
|
if not isinstance(workflow, dict):
|
|
@@ -110,7 +114,7 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|
|
110
114
|
return data if isinstance(data, dict) else {}
|
|
111
115
|
|
|
112
116
|
|
|
113
|
-
def
|
|
117
|
+
def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
114
118
|
plan_path = _source_plan_path(task_root, manifest)
|
|
115
119
|
if not plan_path.is_file():
|
|
116
120
|
return []
|
|
@@ -177,7 +181,7 @@ def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
|
|
|
177
181
|
|
|
178
182
|
def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
179
183
|
status = str(carry.get("status") or "").lower()
|
|
180
|
-
if status in
|
|
184
|
+
if status in FAILED_CARRY_STATUSES:
|
|
181
185
|
return False
|
|
182
186
|
conformance = carry.get("conformance")
|
|
183
187
|
if isinstance(conformance, dict):
|
|
@@ -188,21 +192,6 @@ def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
|
188
192
|
return not (isinstance(unverified, list) and unverified)
|
|
189
193
|
|
|
190
194
|
|
|
191
|
-
def _carry_head_commit(carry: dict[str, Any]) -> str:
|
|
192
|
-
stage_range = carry.get("stageCommitRange")
|
|
193
|
-
if isinstance(stage_range, dict) and stage_range.get("head"):
|
|
194
|
-
return str(stage_range["head"])
|
|
195
|
-
for key in ("head_sha", "head_commit", "head"):
|
|
196
|
-
value = carry.get(key)
|
|
197
|
-
if value:
|
|
198
|
-
return str(value)
|
|
199
|
-
commits = carry.get("commits")
|
|
200
|
-
if isinstance(commits, list) and commits:
|
|
201
|
-
last = commits[-1]
|
|
202
|
-
if isinstance(last, dict) and last.get("sha"):
|
|
203
|
-
return str(last["sha"])
|
|
204
|
-
return ""
|
|
205
|
-
|
|
206
195
|
|
|
207
196
|
def _latest_implementation_report(task_root: Path) -> str:
|
|
208
197
|
reports = sorted(
|