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
|
@@ -23,20 +23,41 @@ from .final_report_paths import (
|
|
|
23
23
|
final_report_markdown_path as _final_report_markdown_path,
|
|
24
24
|
)
|
|
25
25
|
from .lead_events import LeadEvent, append_lead_event
|
|
26
|
+
from .dispatch_state import (
|
|
27
|
+
BACKEND_CLI_WRAPPER,
|
|
28
|
+
BACKEND_MIXED,
|
|
29
|
+
BACKEND_TMUX_PANE,
|
|
30
|
+
DispatchError,
|
|
31
|
+
WorkerJob,
|
|
32
|
+
dispatch_mode as _dispatch_mode,
|
|
33
|
+
load_json_object as _load_json_object,
|
|
34
|
+
missing_completion_paths as _missing_completion_paths,
|
|
35
|
+
require_string as _require_string,
|
|
36
|
+
resolve_project_path as _resolve_project_path,
|
|
37
|
+
resolve_required_path as _resolve_required_path,
|
|
38
|
+
set_dispatch_mode as _set_dispatch_mode,
|
|
39
|
+
set_worker_status as _set_worker_status,
|
|
40
|
+
string_list as _string_list,
|
|
41
|
+
string_value as _string_value,
|
|
42
|
+
utc_now as _utc_now,
|
|
43
|
+
validate_initial_prompts as _validate_initial_prompts,
|
|
44
|
+
worker_prompt_path as _worker_prompt_path,
|
|
45
|
+
worker_state as _worker_state,
|
|
46
|
+
worktree_path as _worktree_path,
|
|
47
|
+
write_json as _write_json,
|
|
48
|
+
)
|
|
26
49
|
from .path_hints import hydrate_active_run_context
|
|
27
50
|
from .report_finalize import (
|
|
28
51
|
STEP_VALIDATE_RUN,
|
|
29
52
|
FinalizeContext,
|
|
30
53
|
FinalizeError,
|
|
31
54
|
project_relative_path as _project_relative_path,
|
|
32
|
-
require_string as _require_string,
|
|
33
55
|
resolve_optional_path as _resolve_optional_path,
|
|
34
|
-
resolve_project_path as _resolve_project_path,
|
|
35
56
|
run_finalize,
|
|
36
57
|
run_seq as _run_seq,
|
|
37
|
-
string_value as _string_value,
|
|
38
58
|
)
|
|
39
59
|
from .wrapper_status import status_path_for_prompt
|
|
60
|
+
from .report_language import resolve_report_language
|
|
40
61
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
41
62
|
from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
|
|
42
63
|
from .worker_prompt_body import (
|
|
@@ -45,6 +66,8 @@ from .worker_prompt_body import (
|
|
|
45
66
|
existing_input_lines as _existing_input_lines,
|
|
46
67
|
instruction_path as _instruction_path,
|
|
47
68
|
mcp_pointer_line as _mcp_pointer_line,
|
|
69
|
+
report_writer_prompt_body,
|
|
70
|
+
REPORT_WRITER_WORKER_ID,
|
|
48
71
|
)
|
|
49
72
|
from .worker_prompt_policy import (
|
|
50
73
|
PromptPlan,
|
|
@@ -56,58 +79,7 @@ SUPPORTED_CLI_WORKERS = {
|
|
|
56
79
|
"codex": "okstra-codex-exec.sh",
|
|
57
80
|
"antigravity": "okstra-antigravity-exec.sh",
|
|
58
81
|
}
|
|
59
|
-
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
60
|
-
BACKEND_MIXED = "mixed"
|
|
61
82
|
MAX_WORKER_ATTEMPTS = 2
|
|
62
|
-
REPORT_WRITER_WORKER_ID = "report-writer"
|
|
63
|
-
REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class DispatchError(Exception):
|
|
67
|
-
"""Raised when a Codex dispatch request cannot be executed."""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@dataclass(frozen=True)
|
|
71
|
-
class WorkerDispatch:
|
|
72
|
-
worker_id: str
|
|
73
|
-
backend: str
|
|
74
|
-
project_root: Path
|
|
75
|
-
model_execution_value: str
|
|
76
|
-
wrapper_path: Path
|
|
77
|
-
prompt_path: Path
|
|
78
|
-
result_path: Path
|
|
79
|
-
worker_result_path: Path
|
|
80
|
-
completion_paths: tuple[Path, ...]
|
|
81
|
-
worktree_path: str
|
|
82
|
-
role: str
|
|
83
|
-
idle_timeout_seconds: int
|
|
84
|
-
|
|
85
|
-
@property
|
|
86
|
-
def command(self) -> list[str]:
|
|
87
|
-
return [
|
|
88
|
-
str(self.wrapper_path),
|
|
89
|
-
str(self.project_root),
|
|
90
|
-
self.model_execution_value,
|
|
91
|
-
str(self.prompt_path),
|
|
92
|
-
self.worktree_path,
|
|
93
|
-
self.role,
|
|
94
|
-
str(self.idle_timeout_seconds),
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
def to_payload(self) -> dict[str, Any]:
|
|
98
|
-
return {
|
|
99
|
-
"workerId": self.worker_id,
|
|
100
|
-
"backend": self.backend,
|
|
101
|
-
"modelExecutionValue": self.model_execution_value,
|
|
102
|
-
"wrapperPath": str(self.wrapper_path),
|
|
103
|
-
"promptPath": str(self.prompt_path),
|
|
104
|
-
"resultPath": str(self.result_path),
|
|
105
|
-
"workerResultPath": str(self.worker_result_path),
|
|
106
|
-
"completionPaths": [str(path) for path in self.completion_paths],
|
|
107
|
-
"worktreePath": self.worktree_path,
|
|
108
|
-
"role": self.role,
|
|
109
|
-
"command": self.command,
|
|
110
|
-
}
|
|
111
83
|
|
|
112
84
|
|
|
113
85
|
@dataclass(frozen=True)
|
|
@@ -118,7 +90,7 @@ class DispatchPlan:
|
|
|
118
90
|
team_state_path: Path
|
|
119
91
|
lead_events_path: Path
|
|
120
92
|
manifest: Mapping[str, Any]
|
|
121
|
-
workers: tuple[
|
|
93
|
+
workers: tuple[WorkerJob, ...]
|
|
122
94
|
|
|
123
95
|
def to_payload(self, *, dry_run: bool) -> dict[str, Any]:
|
|
124
96
|
return {
|
|
@@ -158,9 +130,7 @@ def build_dispatch_plan(
|
|
|
158
130
|
requested_workers,
|
|
159
131
|
enable_codex_report_writer=enable_codex_report_writer,
|
|
160
132
|
)
|
|
161
|
-
team_state_path = _resolve_required_path(
|
|
162
|
-
project_root, manifest, "teamStatePath", "team-state"
|
|
163
|
-
)
|
|
133
|
+
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
164
134
|
team_state = _load_json_object(team_state_path, "team-state")
|
|
165
135
|
_mark_skipped_workers(
|
|
166
136
|
team_state_path,
|
|
@@ -175,9 +145,7 @@ def build_dispatch_plan(
|
|
|
175
145
|
active_context = _load_optional_json_object(
|
|
176
146
|
_resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
|
|
177
147
|
)
|
|
178
|
-
lead_events_path = _resolve_required_path(
|
|
179
|
-
project_root, manifest, "leadEventsPath", "lead events"
|
|
180
|
-
)
|
|
148
|
+
lead_events_path = _resolve_required_path(project_root, manifest, "leadEventsPath")
|
|
181
149
|
_materialize_missing_worker_prompts(
|
|
182
150
|
project_root=project_root,
|
|
183
151
|
manifest=manifest,
|
|
@@ -213,20 +181,6 @@ def build_dispatch_plan(
|
|
|
213
181
|
)
|
|
214
182
|
|
|
215
183
|
|
|
216
|
-
def _validate_initial_prompts(
|
|
217
|
-
manifest: Mapping[str, Any],
|
|
218
|
-
workers: Sequence[WorkerDispatch],
|
|
219
|
-
) -> None:
|
|
220
|
-
records = [
|
|
221
|
-
PromptRecord(worker.worker_id, "initial", worker.prompt_path)
|
|
222
|
-
for worker in workers
|
|
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
184
|
def dispatch_plan(plan: DispatchPlan) -> int:
|
|
231
185
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
|
|
232
186
|
for worker in plan.workers:
|
|
@@ -244,7 +198,7 @@ def dispatch_plan(plan: DispatchPlan) -> int:
|
|
|
244
198
|
return 0
|
|
245
199
|
|
|
246
200
|
|
|
247
|
-
def _dispatch_worker_with_retry(plan: DispatchPlan, worker:
|
|
201
|
+
def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
248
202
|
for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
|
|
249
203
|
_record_worker_attempt(plan, worker, attempt, "running")
|
|
250
204
|
attempt_details = _attempt_event_details(worker, attempt)
|
|
@@ -305,7 +259,7 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerDispatch) -> i
|
|
|
305
259
|
return 1
|
|
306
260
|
def _record_worker_attempt(
|
|
307
261
|
plan: DispatchPlan,
|
|
308
|
-
worker:
|
|
262
|
+
worker: WorkerJob,
|
|
309
263
|
attempt: int,
|
|
310
264
|
status: str,
|
|
311
265
|
reason: str = "",
|
|
@@ -327,7 +281,7 @@ def _record_worker_attempt(
|
|
|
327
281
|
|
|
328
282
|
|
|
329
283
|
def _matches_worker_attempt(
|
|
330
|
-
record: object, worker:
|
|
284
|
+
record: object, worker: WorkerJob, attempt: int
|
|
331
285
|
) -> bool:
|
|
332
286
|
return (
|
|
333
287
|
isinstance(record, dict)
|
|
@@ -338,7 +292,7 @@ def _matches_worker_attempt(
|
|
|
338
292
|
|
|
339
293
|
|
|
340
294
|
def _worker_dispatch_record(
|
|
341
|
-
worker:
|
|
295
|
+
worker: WorkerJob, attempt: int, status: str, reason: str
|
|
342
296
|
) -> dict[str, Any]:
|
|
343
297
|
provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
|
|
344
298
|
return {
|
|
@@ -515,9 +469,7 @@ def _render_missing_report_writer_prompt(
|
|
|
515
469
|
report_writer_codex_model: str,
|
|
516
470
|
) -> str:
|
|
517
471
|
worker_state = _worker_state(team_state, REPORT_WRITER_WORKER_ID)
|
|
518
|
-
markdown_path = _resolve_required_path(
|
|
519
|
-
project_root, manifest, "expectedReportPath", "report"
|
|
520
|
-
)
|
|
472
|
+
markdown_path = _resolve_required_path(project_root, manifest, "expectedReportPath")
|
|
521
473
|
data_json_path = _final_report_data_path(markdown_path)
|
|
522
474
|
result_rel = _project_relative_path(project_root, data_json_path)
|
|
523
475
|
worker_result_rel = _require_string(worker_state, "resultPath")
|
|
@@ -535,12 +487,13 @@ def _render_missing_report_writer_prompt(
|
|
|
535
487
|
result_rel=result_rel,
|
|
536
488
|
audit_source_rel=worker_result_rel,
|
|
537
489
|
)
|
|
538
|
-
lines.extend(
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
490
|
+
lines.extend(report_writer_prompt_body(
|
|
491
|
+
manifest,
|
|
492
|
+
active_context,
|
|
493
|
+
team_state,
|
|
494
|
+
model,
|
|
495
|
+
resolve_report_language(project_root, manifest, active_context),
|
|
496
|
+
))
|
|
544
497
|
return "\n".join(lines).rstrip() + "\n"
|
|
545
498
|
|
|
546
499
|
|
|
@@ -555,7 +508,7 @@ def _build_worker_dispatch(
|
|
|
555
508
|
okstra_bin: Path | None,
|
|
556
509
|
idle_timeout_seconds: int,
|
|
557
510
|
report_writer_codex_model: str,
|
|
558
|
-
) ->
|
|
511
|
+
) -> WorkerJob:
|
|
559
512
|
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
560
513
|
return _build_report_writer_dispatch(
|
|
561
514
|
project_root=project_root,
|
|
@@ -576,8 +529,9 @@ def _build_worker_dispatch(
|
|
|
576
529
|
wrapper = _resolve_wrapper(worker_id, workspace_root, okstra_bin)
|
|
577
530
|
worktree_path = _worktree_path(manifest, active_context)
|
|
578
531
|
role = _pane_role(prompt_path)
|
|
579
|
-
return
|
|
532
|
+
return WorkerJob(
|
|
580
533
|
worker_id=worker_id,
|
|
534
|
+
provider=worker_id,
|
|
581
535
|
backend=BACKEND_CLI_WRAPPER,
|
|
582
536
|
project_root=project_root,
|
|
583
537
|
model_execution_value=model_execution_value,
|
|
@@ -589,6 +543,7 @@ def _build_worker_dispatch(
|
|
|
589
543
|
worktree_path=worktree_path,
|
|
590
544
|
role=role,
|
|
591
545
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
546
|
+
dispatch_kind="initial",
|
|
592
547
|
)
|
|
593
548
|
|
|
594
549
|
|
|
@@ -601,7 +556,7 @@ def _build_report_writer_dispatch(
|
|
|
601
556
|
okstra_bin: Path | None,
|
|
602
557
|
idle_timeout_seconds: int,
|
|
603
558
|
report_writer_codex_model: str,
|
|
604
|
-
) ->
|
|
559
|
+
) -> WorkerJob:
|
|
605
560
|
model_execution_value = (report_writer_codex_model or "").strip()
|
|
606
561
|
if not model_execution_value:
|
|
607
562
|
raise DispatchError(
|
|
@@ -612,15 +567,17 @@ def _build_report_writer_dispatch(
|
|
|
612
567
|
project_root, _worker_prompt_path(manifest, REPORT_WRITER_WORKER_ID)
|
|
613
568
|
)
|
|
614
569
|
data_json_path = _final_report_data_path(
|
|
615
|
-
_resolve_required_path(project_root, manifest, "expectedReportPath"
|
|
570
|
+
_resolve_required_path(project_root, manifest, "expectedReportPath")
|
|
616
571
|
)
|
|
617
572
|
markdown_path = _final_report_markdown_path(data_json_path)
|
|
618
573
|
worker_result_path = _resolve_project_path(
|
|
619
574
|
project_root, _require_string(worker_state, "resultPath")
|
|
620
575
|
)
|
|
621
576
|
_replace_report_writer_model_line(prompt_path, model_execution_value)
|
|
622
|
-
return
|
|
577
|
+
return WorkerJob(
|
|
623
578
|
worker_id=REPORT_WRITER_WORKER_ID,
|
|
579
|
+
# codex 경로의 report-writer 는 claude 가 아니라 codex wrapper 로 돈다.
|
|
580
|
+
provider="codex",
|
|
624
581
|
backend=BACKEND_CLI_WRAPPER,
|
|
625
582
|
project_root=project_root,
|
|
626
583
|
model_execution_value=model_execution_value,
|
|
@@ -636,46 +593,10 @@ def _build_report_writer_dispatch(
|
|
|
636
593
|
worktree_path="",
|
|
637
594
|
role=REPORT_WRITER_WORKER_ID,
|
|
638
595
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
596
|
+
dispatch_kind="initial",
|
|
639
597
|
)
|
|
640
598
|
|
|
641
599
|
|
|
642
|
-
def _report_writer_prompt_body(
|
|
643
|
-
manifest: Mapping[str, Any],
|
|
644
|
-
active_context: Mapping[str, Any],
|
|
645
|
-
team_state: Mapping[str, Any],
|
|
646
|
-
) -> list[str]:
|
|
647
|
-
return [
|
|
648
|
-
"",
|
|
649
|
-
"# Report Writer Worker Dispatch",
|
|
650
|
-
"",
|
|
651
|
-
"## Role",
|
|
652
|
-
(
|
|
653
|
-
"You are the Report writer worker. Author the final-report data.json "
|
|
654
|
-
"and the worker-results audit file."
|
|
655
|
-
),
|
|
656
|
-
"",
|
|
657
|
-
"## Task",
|
|
658
|
-
f"- Task key: `{_require_string(manifest, 'taskKey')}`",
|
|
659
|
-
f"- Task type: `{_require_string(manifest, 'taskType')}`",
|
|
660
|
-
"",
|
|
661
|
-
"## Inputs",
|
|
662
|
-
*_report_writer_input_lines(manifest, active_context, team_state),
|
|
663
|
-
"",
|
|
664
|
-
_mcp_pointer_line(),
|
|
665
|
-
"",
|
|
666
|
-
"## Output Contract",
|
|
667
|
-
"You are the author of TWO files:",
|
|
668
|
-
"- The final-report data.json at Result Path.",
|
|
669
|
-
"- The worker-results audit file at Audit sidecar path.",
|
|
670
|
-
(
|
|
671
|
-
'After writing the data.json, invoke "okstra render-final-report '
|
|
672
|
-
'<Result Path>" so the markdown sibling is rendered before you return.'
|
|
673
|
-
),
|
|
674
|
-
"Do not return the report inline.",
|
|
675
|
-
"Copy Report Language verbatim into data.json.meta.reportLanguage.",
|
|
676
|
-
]
|
|
677
|
-
|
|
678
|
-
|
|
679
600
|
def _base_prompt_headers(
|
|
680
601
|
*,
|
|
681
602
|
project_root: Path,
|
|
@@ -715,43 +636,6 @@ def _initial_prompt_plan(
|
|
|
715
636
|
raise DispatchError(str(exc)) from exc
|
|
716
637
|
|
|
717
638
|
|
|
718
|
-
def _report_writer_input_lines(
|
|
719
|
-
manifest: Mapping[str, Any],
|
|
720
|
-
active_context: Mapping[str, Any],
|
|
721
|
-
team_state: Mapping[str, Any],
|
|
722
|
-
) -> list[str]:
|
|
723
|
-
inputs = [
|
|
724
|
-
("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
|
|
725
|
-
("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
|
|
726
|
-
("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
|
|
727
|
-
("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
|
|
728
|
-
("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
729
|
-
("Final report template", _instruction_path(manifest, active_context, "finalReportTemplatePath")),
|
|
730
|
-
("Final report schema", _instruction_path(manifest, active_context, "finalReportSchemaPath")),
|
|
731
|
-
("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
|
|
732
|
-
]
|
|
733
|
-
lines = _existing_input_lines(inputs)
|
|
734
|
-
lines.extend(_analysis_worker_result_lines(team_state))
|
|
735
|
-
return lines or ["- No report-writer inputs were recorded in active-run-context."]
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
|
|
739
|
-
lines = []
|
|
740
|
-
workers = team_state.get("workers")
|
|
741
|
-
if not isinstance(workers, list):
|
|
742
|
-
return lines
|
|
743
|
-
for worker in workers:
|
|
744
|
-
if not isinstance(worker, Mapping):
|
|
745
|
-
continue
|
|
746
|
-
worker_id = worker.get("workerId")
|
|
747
|
-
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
748
|
-
continue
|
|
749
|
-
result_path = _string_value(worker.get("resultPath"))
|
|
750
|
-
if result_path:
|
|
751
|
-
lines.append(f"- Analysis result ({worker_id}): `{result_path}`")
|
|
752
|
-
return lines
|
|
753
|
-
|
|
754
|
-
|
|
755
639
|
def _analysis_pane_role(
|
|
756
640
|
manifest: Mapping[str, Any],
|
|
757
641
|
active_context: Mapping[str, Any],
|
|
@@ -864,54 +748,6 @@ def _clarification_response_section(
|
|
|
864
748
|
]
|
|
865
749
|
|
|
866
750
|
|
|
867
|
-
def _resolve_report_language(
|
|
868
|
-
project_root: Path,
|
|
869
|
-
manifest: Mapping[str, Any],
|
|
870
|
-
active_context: Mapping[str, Any],
|
|
871
|
-
) -> str:
|
|
872
|
-
for value in _report_language_candidates(project_root):
|
|
873
|
-
if value == "auto":
|
|
874
|
-
return _infer_report_language(project_root, manifest, active_context)
|
|
875
|
-
if value:
|
|
876
|
-
return value
|
|
877
|
-
return _infer_report_language(project_root, manifest, active_context)
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
def _report_language_candidates(project_root: Path) -> list[str]:
|
|
881
|
-
okstra_home = Path(os.environ.get("OKSTRA_HOME") or Path.home() / ".okstra")
|
|
882
|
-
return [
|
|
883
|
-
_read_report_language(project_root / ".okstra" / "project.json", "project"),
|
|
884
|
-
_read_report_language(okstra_home / "config.json", "global"),
|
|
885
|
-
]
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
def _read_report_language(path: Path, label: str) -> str:
|
|
889
|
-
if not path.is_file():
|
|
890
|
-
return ""
|
|
891
|
-
value = _load_json_object(path, f"{label} config").get("reportLanguage")
|
|
892
|
-
if value in (None, ""):
|
|
893
|
-
return ""
|
|
894
|
-
if isinstance(value, str) and value in REPORT_LANGUAGE_VALUES:
|
|
895
|
-
return value
|
|
896
|
-
raise DispatchError(f"{label} config reportLanguage must be en, ko, or auto: {path}")
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
def _infer_report_language(
|
|
900
|
-
project_root: Path,
|
|
901
|
-
manifest: Mapping[str, Any],
|
|
902
|
-
active_context: Mapping[str, Any],
|
|
903
|
-
) -> str:
|
|
904
|
-
task_brief = _instruction_path(manifest, active_context, "taskBriefPath")
|
|
905
|
-
if not task_brief:
|
|
906
|
-
return "en"
|
|
907
|
-
path = _resolve_project_path(project_root, task_brief)
|
|
908
|
-
if not path.is_file():
|
|
909
|
-
return "en"
|
|
910
|
-
text = path.read_text(encoding="utf-8", errors="replace")[:16000]
|
|
911
|
-
hangul_count = sum(1 for char in text if "\uac00" <= char <= "\ud7a3")
|
|
912
|
-
return "ko" if hangul_count >= 10 else "en"
|
|
913
|
-
|
|
914
|
-
|
|
915
751
|
def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
|
|
916
752
|
if manifest.get("leadRuntime") != "codex":
|
|
917
753
|
raise DispatchError(f"run manifest is not a Codex lead run: {path}")
|
|
@@ -1066,7 +902,7 @@ def _resolve_wrapper(
|
|
|
1066
902
|
|
|
1067
903
|
def _run_worker(
|
|
1068
904
|
plan: DispatchPlan,
|
|
1069
|
-
worker:
|
|
905
|
+
worker: WorkerJob,
|
|
1070
906
|
) -> subprocess.CompletedProcess[str]:
|
|
1071
907
|
if worker.backend == BACKEND_CLI_WRAPPER:
|
|
1072
908
|
return _run_cli_wrapper_worker(plan, worker)
|
|
@@ -1075,7 +911,7 @@ def _run_worker(
|
|
|
1075
911
|
|
|
1076
912
|
def _run_cli_wrapper_worker(
|
|
1077
913
|
plan: DispatchPlan,
|
|
1078
|
-
worker:
|
|
914
|
+
worker: WorkerJob,
|
|
1079
915
|
) -> subprocess.CompletedProcess[str]:
|
|
1080
916
|
env = {
|
|
1081
917
|
**os.environ,
|
|
@@ -1093,7 +929,7 @@ def _run_cli_wrapper_worker(
|
|
|
1093
929
|
|
|
1094
930
|
def _post_process_report_writer_result(
|
|
1095
931
|
plan: DispatchPlan,
|
|
1096
|
-
worker:
|
|
932
|
+
worker: WorkerJob,
|
|
1097
933
|
) -> dict[str, Any]:
|
|
1098
934
|
if worker.worker_id != REPORT_WRITER_WORKER_ID:
|
|
1099
935
|
return {"ok": True, "reason": "", "steps": []}
|
|
@@ -1137,14 +973,7 @@ def _append_event(
|
|
|
1137
973
|
)
|
|
1138
974
|
|
|
1139
975
|
|
|
1140
|
-
def
|
|
1141
|
-
backends = {worker.backend for worker in workers}
|
|
1142
|
-
if len(backends) == 1:
|
|
1143
|
-
return next(iter(backends))
|
|
1144
|
-
return BACKEND_MIXED
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
|
|
976
|
+
def _event_details(worker: WorkerJob) -> dict[str, Any]:
|
|
1148
977
|
return {
|
|
1149
978
|
"dispatchMode": worker.backend,
|
|
1150
979
|
"workerId": worker.worker_id,
|
|
@@ -1156,7 +985,7 @@ def _event_details(worker: WorkerDispatch) -> dict[str, Any]:
|
|
|
1156
985
|
}
|
|
1157
986
|
|
|
1158
987
|
|
|
1159
|
-
def _attempt_event_details(worker:
|
|
988
|
+
def _attempt_event_details(worker: WorkerJob, attempt: int) -> dict[str, Any]:
|
|
1160
989
|
return {
|
|
1161
990
|
**_event_details(worker),
|
|
1162
991
|
"attempt": attempt,
|
|
@@ -1176,69 +1005,6 @@ def _should_retry_missing_result(
|
|
|
1176
1005
|
)
|
|
1177
1006
|
|
|
1178
1007
|
|
|
1179
|
-
def _missing_completion_paths(worker: WorkerDispatch) -> list[Path]:
|
|
1180
|
-
return [path for path in worker.completion_paths if not path.is_file()]
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
def _set_worker_status(
|
|
1184
|
-
team_state_path: Path,
|
|
1185
|
-
worker_id: str,
|
|
1186
|
-
status: str,
|
|
1187
|
-
reason: str,
|
|
1188
|
-
*,
|
|
1189
|
-
model_execution_value: str = "",
|
|
1190
|
-
) -> None:
|
|
1191
|
-
payload = _load_json_object(team_state_path, "team-state")
|
|
1192
|
-
workers = payload.get("workers")
|
|
1193
|
-
if not isinstance(workers, list):
|
|
1194
|
-
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
1195
|
-
for worker in workers:
|
|
1196
|
-
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
1197
|
-
worker["status"] = status
|
|
1198
|
-
worker["reason"] = reason
|
|
1199
|
-
if model_execution_value:
|
|
1200
|
-
worker["model"] = model_execution_value
|
|
1201
|
-
worker["modelExecutionValue"] = model_execution_value
|
|
1202
|
-
_write_json(team_state_path, payload)
|
|
1203
|
-
return
|
|
1204
|
-
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
def _set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
1208
|
-
payload = _load_json_object(team_state_path, "team-state")
|
|
1209
|
-
payload["dispatchMode"] = dispatch_mode
|
|
1210
|
-
_write_json(team_state_path, payload)
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
def _worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
1214
|
-
workers = team_state.get("workers")
|
|
1215
|
-
if not isinstance(workers, list):
|
|
1216
|
-
raise DispatchError("team-state workers must be an array")
|
|
1217
|
-
for worker in workers:
|
|
1218
|
-
if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
|
|
1219
|
-
return worker
|
|
1220
|
-
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
def _worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
|
|
1224
|
-
paths = manifest.get("workerPromptPathByWorkerId")
|
|
1225
|
-
if not isinstance(paths, Mapping):
|
|
1226
|
-
raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
|
|
1227
|
-
return _require_string(paths, worker_id)
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
def _worktree_path(
|
|
1231
|
-
manifest: Mapping[str, Any],
|
|
1232
|
-
active_context: Mapping[str, Any],
|
|
1233
|
-
) -> str:
|
|
1234
|
-
if manifest.get("taskType") not in {"implementation", "final-verification"}:
|
|
1235
|
-
return ""
|
|
1236
|
-
worktree = active_context.get("executorWorktree")
|
|
1237
|
-
if not isinstance(worktree, Mapping):
|
|
1238
|
-
return ""
|
|
1239
|
-
return str(worktree.get("path") or "")
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
1008
|
def _pane_role(prompt_path: Path) -> str:
|
|
1243
1009
|
if not prompt_path.is_file():
|
|
1244
1010
|
raise DispatchError(f"worker prompt not found: {prompt_path}")
|
|
@@ -1250,39 +1016,12 @@ def _pane_role(prompt_path: Path) -> str:
|
|
|
1250
1016
|
return "worker"
|
|
1251
1017
|
|
|
1252
1018
|
|
|
1253
|
-
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
1254
|
-
if not path.is_file():
|
|
1255
|
-
raise DispatchError(f"{label} not found: {path}")
|
|
1256
|
-
try:
|
|
1257
|
-
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1258
|
-
except json.JSONDecodeError as exc:
|
|
1259
|
-
raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
|
|
1260
|
-
if not isinstance(payload, dict):
|
|
1261
|
-
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
1262
|
-
return payload
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
1019
|
def _load_optional_json_object(path: Path | None) -> dict[str, Any]:
|
|
1266
1020
|
if path is None or not path.is_file():
|
|
1267
1021
|
return {}
|
|
1268
1022
|
return hydrate_active_run_context(_load_json_object(path, "active-run-context"))
|
|
1269
1023
|
|
|
1270
1024
|
|
|
1271
|
-
def _resolve_required_path(
|
|
1272
|
-
project_root: Path,
|
|
1273
|
-
manifest: Mapping[str, Any],
|
|
1274
|
-
key: str,
|
|
1275
|
-
label: str,
|
|
1276
|
-
) -> Path:
|
|
1277
|
-
return _resolve_project_path(project_root, _require_string(manifest, key))
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
def _string_list(value: Any) -> list[str]:
|
|
1281
|
-
if not isinstance(value, list):
|
|
1282
|
-
return []
|
|
1283
|
-
return [str(item).strip() for item in value if str(item).strip()]
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
1025
|
def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
|
|
1287
1026
|
if exit_code != 0:
|
|
1288
1027
|
return f"wrapper exited with code {exit_code}"
|
|
@@ -1323,20 +1062,6 @@ def _parse_idle_timeout(raw: str) -> int:
|
|
|
1323
1062
|
return value
|
|
1324
1063
|
|
|
1325
1064
|
|
|
1326
|
-
def _utc_now() -> str:
|
|
1327
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
def _write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
1331
|
-
# Write via temp file + os.replace so a crash mid-write cannot leave a
|
|
1332
|
-
# truncated team-state.json that every later _load_json_object rejects;
|
|
1333
|
-
# os.replace is atomic on POSIX, matching run_context._atomic_write_json.
|
|
1334
|
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
1335
|
-
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
1336
|
-
encoding="utf-8")
|
|
1337
|
-
os.replace(tmp, path)
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
1065
|
def _print_json(payload: Mapping[str, Any]) -> None:
|
|
1341
1066
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1342
1067
|
|
|
@@ -202,7 +202,7 @@ def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
|
|
|
202
202
|
# `runs/implementation/carry/stage-<N>.json`. The `done` row in consumers.jsonl
|
|
203
203
|
# is a derived index that the lead appends by hand (per the implementation
|
|
204
204
|
# profile) — so it can be missing even when the stage actually finished. The
|
|
205
|
-
# dependency gate (`
|
|
205
|
+
# dependency gate (`stage_targets.resolve_stage_base_commit`) reads `done.head_commit`, so a
|
|
206
206
|
# missing `done` row wrongly blocks downstream stages. We treat the carry file
|
|
207
207
|
# as the source of truth and backfill the missing `done` rows from it before
|
|
208
208
|
# the gate runs. A stage with no carry, or an unfinished carry, is left blocked
|
|
@@ -218,7 +218,9 @@ def _carry_stage_number(carry: Dict[str, Any], filename: str) -> Optional[int]:
|
|
|
218
218
|
return int(m.group(1)) if m else None
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
FAILED_CARRY_STATUSES = ("fail", "failed", "blocked", "error", "aborted")
|
|
222
|
+
"""carry 가 실패를 명시하는 status 값. 이 목록은 하나의 business fact 다 —
|
|
223
|
+
implementation_outcome 의 pass-grade 판정도 같은 목록을 본다."""
|
|
222
224
|
|
|
223
225
|
|
|
224
226
|
def _carry_is_complete(carry: Dict[str, Any]) -> bool:
|
|
@@ -227,12 +229,12 @@ def _carry_is_complete(carry: Dict[str, Any]) -> bool:
|
|
|
227
229
|
# Treat it as complete unless it explicitly records a failure status. The
|
|
228
230
|
# real backfill guard is whether a head commit can be extracted.
|
|
229
231
|
status = carry.get("status")
|
|
230
|
-
if status is not None and str(status).lower() in
|
|
232
|
+
if status is not None and str(status).lower() in FAILED_CARRY_STATUSES:
|
|
231
233
|
return False
|
|
232
234
|
return True
|
|
233
235
|
|
|
234
236
|
|
|
235
|
-
def
|
|
237
|
+
def carry_head_commit(carry: Dict[str, Any]) -> str:
|
|
236
238
|
rng = carry.get("stageCommitRange")
|
|
237
239
|
if isinstance(rng, dict) and rng.get("head"):
|
|
238
240
|
return str(rng["head"])
|
|
@@ -288,7 +290,7 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
|
|
|
288
290
|
continue
|
|
289
291
|
if not _carry_is_complete(carry):
|
|
290
292
|
continue
|
|
291
|
-
head =
|
|
293
|
+
head = carry_head_commit(carry)
|
|
292
294
|
if not head:
|
|
293
295
|
continue
|
|
294
296
|
impl_key = key_by_stage.get(stage) or carry.get("impl_task_key") or fallback_key
|