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.
- 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/interactive.sh +24 -67
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-antigravity-exec.sh +10 -8
- package/runtime/bin/okstra-claude-exec.sh +10 -8
- package/runtime/bin/okstra-codex-exec.sh +10 -8
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/python/okstra_ctl/backfill.py +2 -1
- 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 +6 -5
- 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_log_core.py +3 -1
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/handoff.py +2 -1
- package/runtime/python/okstra_ctl/implementation_outcome.py +17 -27
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/path_hints.py +5 -3
- package/runtime/python/okstra_ctl/paths.py +278 -4
- package/runtime/python/okstra_ctl/plan_run_root.py +4 -4
- 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 +50 -50
- 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 +23 -52
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 .
|
|
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")
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|