okstra 0.128.0 → 0.129.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +15 -2
  2. package/docs/architecture.md +21 -5
  3. package/docs/cli.md +13 -2
  4. package/docs/project-structure-overview.md +22 -7
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -3
  8. package/runtime/agents/workers/claude-worker.md +4 -4
  9. package/runtime/agents/workers/codex-worker.md +3 -3
  10. package/runtime/agents/workers/report-writer-worker.md +6 -6
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -1
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/convergence.md +42 -84
  15. package/runtime/prompts/lead/okstra-lead-contract.md +10 -8
  16. package/runtime/prompts/lead/report-writer.md +22 -28
  17. package/runtime/prompts/lead/team-contract.md +28 -11
  18. package/runtime/prompts/profiles/_common-contract.md +1 -1
  19. package/runtime/prompts/profiles/error-analysis.md +1 -1
  20. package/runtime/prompts/profiles/implementation-planning.md +5 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  22. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  23. package/runtime/python/okstra_ctl/analysis_packet.py +7 -13
  24. package/runtime/python/okstra_ctl/codex_dispatch.py +70 -319
  25. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  26. package/runtime/python/okstra_ctl/convergence.py +223 -0
  27. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  28. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  29. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +53 -14
  31. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  32. package/runtime/python/okstra_ctl/path_hints.py +23 -1
  33. package/runtime/python/okstra_ctl/paths.py +10 -1
  34. package/runtime/python/okstra_ctl/render.py +56 -11
  35. package/runtime/python/okstra_ctl/report_finalize.py +394 -0
  36. package/runtime/python/okstra_ctl/worker_liveness.py +6 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +102 -9
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +79 -9
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  41. package/runtime/templates/implementation-worker-preamble.md +51 -0
  42. package/runtime/templates/operating-standard.md +4 -4
  43. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  44. package/runtime/templates/worker-error-contract.md +46 -0
  45. package/runtime/templates/worker-prompt-preamble.md +28 -234
  46. package/runtime/validators/lib/fixtures.sh +27 -12
  47. package/runtime/validators/validate-run.py +201 -72
  48. package/src/cli-registry.mjs +18 -0
  49. package/src/commands/execute/convergence.mjs +34 -0
  50. package/src/commands/report/finalize.mjs +65 -0
@@ -14,6 +14,11 @@
14
14
  - before deciding whether to fan out, read `Related Task Graph`. Use `depends-on`, `blocks` / `blocked-by`, parent/child, follow-up, and split edges as seed ordering constraints. Do not flatten a directed graph into unrelated tasks.
15
15
  - `intent-inference` augmentations whose paired `intent-check:` row carries `[CONFIRMED …]` are treated as **confirmed**; trust the confirmation text in `## Reporter Confirmations` over the original inference if they differ. Unconfirmed `intent-inference` rows under `reporter-confirmations: skipped` follow the precondition's `skipped` branch above.
16
16
  - `conversion-block:` rows are explicit "translation failed" signals — never attempt to resolve them by inference here; the precondition above already handled them.
17
+ - Worker discovery procedure:
18
+ - classify the request and cite the evidence that determines both its work category and safest next phase
19
+ - identify independently startable decomposition candidates without publishing or rendering fan-out artifacts; preserve every directed dependency from `Related Task Graph`
20
+ - resolve codebase-answerable ambiguity by inspection and record file:line evidence; return only human-owned decisions as clarification candidates with the evidence already checked
21
+ - state the reporter's rejection criteria, missing routing inputs, and the evidence boundary behind each recommendation
17
22
  - Primary focus areas:
18
23
  - classify the work as bugfix, feature, improvement, refactor, or ops
19
24
  - determine whether `error-analysis` or `implementation-planning` is the next safe step. Direct `implementation` handoff is never a valid routing target — implementation requires an approved `implementation-planning` report
@@ -49,7 +54,9 @@
49
54
  - evidence-backed routing decision
50
55
  - uncertainty boundaries and missing inputs
51
56
  - next recommended phase and safe resume guidance
52
- - canonical-term resolution for every `terminology:*` brief item, written as a one-line `<term> = <definition>` line in a new `Domain Alignment` subsection of the final report; alongside each, propose whether `<PROJECT_ROOT>/.okstra/glossary.md` should be updated (proposal only — actual writes happen via `okstra-brief-gen` Step 4.5 on a subsequent run)
57
+ - canonical-term resolution for every `terminology:*` brief item as `<term> = <definition>`, plus whether `<PROJECT_ROOT>/.okstra/glossary.md` should be updated
58
+ - Report assembly instructions:
59
+ - write canonical-term resolutions in a new `Domain Alignment` subsection of the final report; actual glossary writes happen via `okstra-brief-gen` Step 4.5 on a subsequent run
53
60
  - Clarification request policy (phase-specific addenda — shared policy is in `_common-contract.md`):
54
61
  - if any blocking input is missing at the time of writing the final report, populate `## 1. Clarification Items` in `final-report-template.md` (a single unified table; `Blocks=next-phase` for items the next run cannot start without)
55
62
  - prefer concrete questions whose answers map directly to a routing decision (`bugfix` vs `feature`, `error-analysis` vs `implementation-planning`, etc.). State each option in plain language with one sentence describing what choosing it would mean for the next phase.
@@ -24,29 +24,23 @@ PROFILE_SECTIONS = (
24
24
  "Primary focus areas",
25
25
  "Expected output emphasis",
26
26
  "Non-goals",
27
- "Clarification request policy",
28
27
  )
29
- PROFILE_SECTIONS_BY_TASK_TYPE = {
28
+ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
30
29
  "requirements-discovery": (
31
- "Fan-out",
32
- "Decision-tree walk",
30
+ "Worker discovery procedure",
33
31
  ),
34
32
  "error-analysis": (
35
- "Diagnosis loop",
33
+ "Worker diagnosis procedure",
36
34
  ),
37
35
  "implementation-planning": (
38
- "Section heading contract",
39
- "Required deliverable shape",
40
- "No-placeholder rule",
41
- "Self-review pass before finalising the report",
36
+ "Worker planning procedure",
37
+ "Design principles applied when scoring options",
42
38
  ),
43
39
  "final-verification": (
44
40
  "Worker verification procedure",
45
41
  ),
46
42
  "improvement-discovery": (
47
- "Phase 1.5 — Lead reflect-back grilling",
48
- "Worker diversity rule",
49
- "Decision-tree walk",
43
+ "Worker candidate procedure",
50
44
  ),
51
45
  }
52
46
  CLARIFICATION_SECTIONS = (
@@ -156,7 +150,7 @@ def _brief_block(brief_text: str) -> list[str]:
156
150
  def _profile_block(task_type: str, profile_text: str) -> list[str]:
157
151
  section_names = (
158
152
  PROFILE_SECTIONS
159
- + PROFILE_SECTIONS_BY_TASK_TYPE.get(task_type, ())
153
+ + WORKER_PROFILE_SECTIONS_BY_TASK_TYPE.get(task_type, ())
160
154
  )
161
155
  profile_sections = _extract_sections(profile_text, section_names)
162
156
  profile_bullets = _extract_bullet_sections(profile_text, section_names)
@@ -24,10 +24,32 @@ from .final_report_paths import (
24
24
  )
25
25
  from .lead_events import LeadEvent, append_lead_event
26
26
  from .path_hints import hydrate_active_run_context
27
+ from .report_finalize import (
28
+ STEP_VALIDATE_RUN,
29
+ FinalizeContext,
30
+ FinalizeError,
31
+ project_relative_path as _project_relative_path,
32
+ require_string as _require_string,
33
+ resolve_optional_path as _resolve_optional_path,
34
+ resolve_project_path as _resolve_project_path,
35
+ run_finalize,
36
+ run_seq as _run_seq,
37
+ string_value as _string_value,
38
+ )
27
39
  from .wrapper_status import status_path_for_prompt
28
- from .paths import task_dir
29
40
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
30
- from .worker_prompt_contract import validate_final_verification_prompt_paths
41
+ from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
42
+ from .worker_prompt_body import (
43
+ analysis_input_lines as _analysis_input_lines,
44
+ analysis_prompt_body as _analysis_prompt_body,
45
+ existing_input_lines as _existing_input_lines,
46
+ instruction_path as _instruction_path,
47
+ mcp_pointer_line as _mcp_pointer_line,
48
+ )
49
+ from .worker_prompt_policy import (
50
+ PromptPlan,
51
+ resolve_prompt_plan_for_manifest,
52
+ )
31
53
 
32
54
 
33
55
  SUPPORTED_CLI_WORKERS = {
@@ -39,10 +61,6 @@ BACKEND_MIXED = "mixed"
39
61
  MAX_WORKER_ATTEMPTS = 2
40
62
  REPORT_WRITER_WORKER_ID = "report-writer"
41
63
  REPORT_LANGUAGE_VALUES = {"en", "ko", "auto"}
42
- ANALYSIS_WORKER_LABELS = {
43
- "codex": "Codex worker",
44
- "antigravity": "Antigravity worker",
45
- }
46
64
 
47
65
 
48
66
  class DispatchError(Exception):
@@ -183,7 +201,7 @@ def build_dispatch_plan(
183
201
  )
184
202
  for worker_id in selected_workers
185
203
  )
186
- _validate_initial_final_verification_prompts(manifest, workers)
204
+ _validate_initial_prompts(manifest, workers)
187
205
  return DispatchPlan(
188
206
  project_root=project_root,
189
207
  workspace_root=workspace_root,
@@ -195,21 +213,18 @@ def build_dispatch_plan(
195
213
  )
196
214
 
197
215
 
198
- def _validate_initial_final_verification_prompts(
216
+ def _validate_initial_prompts(
199
217
  manifest: Mapping[str, Any],
200
218
  workers: Sequence[WorkerDispatch],
201
219
  ) -> None:
202
- if manifest.get("taskType") != "final-verification":
203
- return
204
- prompt_paths = {
205
- worker.worker_id: worker.prompt_path
220
+ records = [
221
+ PromptRecord(worker.worker_id, "initial", worker.prompt_path)
206
222
  for worker in workers
207
- if worker.worker_id != REPORT_WRITER_WORKER_ID
208
- and "-reverify-r" not in worker.prompt_path.name
209
- }
210
- errors = validate_final_verification_prompt_paths(prompt_paths)
223
+ ]
224
+ errors = validate_initial_prompt_records(manifest=manifest, records=records)
211
225
  if errors:
212
- raise DispatchError("final-verification prompt contract: " + "; ".join(errors))
226
+ task_type = _require_string(manifest, "taskType")
227
+ raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
213
228
 
214
229
 
215
230
  def dispatch_plan(plan: DispatchPlan) -> int:
@@ -344,8 +359,6 @@ def _worker_dispatch_record(
344
359
  }
345
360
 
346
361
 
347
-
348
-
349
362
  def main(argv: Sequence[str] | None = None) -> int:
350
363
  parser = _parser()
351
364
  args = parser.parse_args(argv)
@@ -367,7 +380,7 @@ def main(argv: Sequence[str] | None = None) -> int:
367
380
  code = dispatch_plan(plan)
368
381
  _print_json({**plan.to_payload(dry_run=False), "exitCode": code})
369
382
  return code
370
- except DispatchError as exc:
383
+ except (DispatchError, FinalizeError) as exc:
371
384
  print(f"error: {exc}", file=sys.stderr)
372
385
  return 2
373
386
 
@@ -460,6 +473,7 @@ def _render_missing_analysis_worker_prompt(
460
473
  prompt_rel = _worker_prompt_path(manifest, worker_id)
461
474
  model = _require_string(worker_state, "modelExecutionValue")
462
475
  role = _analysis_pane_role(manifest, active_context, worker_id)
476
+ plan = _initial_prompt_plan(manifest, worker_id)
463
477
  lines = _base_prompt_headers(
464
478
  project_root=project_root,
465
479
  manifest=manifest,
@@ -469,7 +483,16 @@ def _render_missing_analysis_worker_prompt(
469
483
  result_rel=result_rel,
470
484
  )
471
485
  lines.extend(_worktree_headers(manifest, active_context, role))
472
- lines.extend(_analysis_prompt_body(manifest, active_context, worker_id, model, role))
486
+ lines.extend(
487
+ _analysis_prompt_body(
488
+ manifest,
489
+ active_context,
490
+ worker_id,
491
+ model,
492
+ role,
493
+ plan,
494
+ )
495
+ )
473
496
  executor_tail = _implementation_executor_tail(
474
497
  manifest,
475
498
  active_context,
@@ -614,43 +637,6 @@ def _build_report_writer_dispatch(
614
637
  )
615
638
 
616
639
 
617
- def _analysis_prompt_body(
618
- manifest: Mapping[str, Any],
619
- active_context: Mapping[str, Any],
620
- worker_id: str,
621
- model: str,
622
- role: str,
623
- ) -> list[str]:
624
- label = ANALYSIS_WORKER_LABELS[worker_id]
625
- return [
626
- f"**Model:** {label}, {model}",
627
- f"**Pane role:** {role}",
628
- "",
629
- f"# {label} Dispatch",
630
- "",
631
- "## Role",
632
- (
633
- f"You are the {label} for okstra cross-verification. "
634
- "Produce an independent worker result."
635
- ),
636
- "",
637
- "## Task",
638
- f"- Task key: `{_require_string(manifest, 'taskKey')}`",
639
- f"- Task type: `{_require_string(manifest, 'taskType')}`",
640
- "",
641
- "## Inputs",
642
- *_analysis_input_lines(manifest, active_context),
643
- "",
644
- _mcp_pointer_line(),
645
- "",
646
- "## Output Contract",
647
- "- Read the Worker Preamble Path end-to-end before analysis.",
648
- "- Write the worker result to Result Path and no other canonical result path.",
649
- "- Write the audit sidecar and any tool-failure entries as the preamble requires.",
650
- "- Cite evidence with file paths and line numbers whenever you make a claim.",
651
- ]
652
-
653
-
654
640
  def _report_writer_prompt_body(
655
641
  manifest: Mapping[str, Any],
656
642
  active_context: Mapping[str, Any],
@@ -688,13 +674,6 @@ def _report_writer_prompt_body(
688
674
  ]
689
675
 
690
676
 
691
- def _mcp_pointer_line() -> str:
692
- return (
693
- '**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
694
- "section (already in your Required reading)."
695
- )
696
-
697
-
698
677
  def _base_prompt_headers(
699
678
  *,
700
679
  project_root: Path,
@@ -710,6 +689,7 @@ def _base_prompt_headers(
710
689
  prompt_rel=prompt_rel,
711
690
  result_rel=result_rel,
712
691
  worker_id=worker_id,
692
+ dispatch_kind="initial",
713
693
  manifest=manifest,
714
694
  active_context=active_context,
715
695
  )
@@ -717,26 +697,18 @@ def _base_prompt_headers(
717
697
  raise DispatchError(str(exc)) from exc
718
698
 
719
699
 
720
- def _analysis_input_lines(
700
+ def _initial_prompt_plan(
721
701
  manifest: Mapping[str, Any],
722
- active_context: Mapping[str, Any],
723
- ) -> list[str]:
724
- if manifest.get("taskType") == "final-verification":
725
- packet_path = _instruction_path(
726
- manifest,
727
- active_context,
728
- "analysisPacketPath",
702
+ worker_id: str,
703
+ ) -> PromptPlan:
704
+ try:
705
+ return resolve_prompt_plan_for_manifest(
706
+ manifest=manifest,
707
+ worker_id=worker_id,
708
+ dispatch_kind="initial",
729
709
  )
730
- return _existing_input_lines((("Primary analysis packet", packet_path),))
731
- inputs = [
732
- ("Primary analysis packet", _instruction_path(manifest, active_context, "analysisPacketPath")),
733
- ("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
734
- ("Analysis profile", _instruction_path(manifest, active_context, "analysisProfilePath")),
735
- ("Analysis material", _instruction_path(manifest, active_context, "analysisMaterialPath")),
736
- ("Reference expectations", _instruction_path(manifest, active_context, "referenceExpectationsPath")),
737
- ("Clarification response", _instruction_path(manifest, active_context, "clarificationResponsePath")),
738
- ]
739
- return _existing_input_lines(inputs)
710
+ except ValueError as exc:
711
+ raise DispatchError(str(exc)) from exc
740
712
 
741
713
 
742
714
  def _report_writer_input_lines(
@@ -759,11 +731,6 @@ def _report_writer_input_lines(
759
731
  return lines or ["- No report-writer inputs were recorded in active-run-context."]
760
732
 
761
733
 
762
- def _existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
763
- lines = [f"- {label}: `{path}`" for label, path in inputs if path]
764
- return lines or ["- No input paths were recorded in active-run-context."]
765
-
766
-
767
734
  def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
768
735
  lines = []
769
736
  workers = team_state.get("workers")
@@ -781,19 +748,6 @@ def _analysis_worker_result_lines(team_state: Mapping[str, Any]) -> list[str]:
781
748
  return lines
782
749
 
783
750
 
784
- def _instruction_path(
785
- manifest: Mapping[str, Any],
786
- active_context: Mapping[str, Any],
787
- key: str,
788
- ) -> str:
789
- instruction_set = active_context.get("instructionSet")
790
- if isinstance(instruction_set, Mapping):
791
- value = _string_value(instruction_set.get(key))
792
- if value:
793
- return value
794
- return _string_value(manifest.get(key))
795
-
796
-
797
751
  def _analysis_pane_role(
798
752
  manifest: Mapping[str, Any],
799
753
  active_context: Mapping[str, Any],
@@ -875,13 +829,6 @@ def _implementation_executor_tail(
875
829
  return "\n\n".join(parts)
876
830
 
877
831
 
878
- def _project_relative_path(project_root: Path, path: Path) -> str:
879
- try:
880
- return path.relative_to(project_root).as_posix()
881
- except ValueError:
882
- return str(path)
883
-
884
-
885
832
  def _resolve_report_language(
886
833
  project_root: Path,
887
834
  manifest: Mapping[str, Any],
@@ -930,12 +877,6 @@ def _infer_report_language(
930
877
  return "ko" if hangul_count >= 10 else "en"
931
878
 
932
879
 
933
- def _string_value(value: Any) -> str:
934
- if not isinstance(value, str):
935
- return ""
936
- return value.strip()
937
-
938
-
939
880
  def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
940
881
  if manifest.get("leadRuntime") != "codex":
941
882
  raise DispatchError(f"run manifest is not a Codex lead run: {path}")
@@ -1121,190 +1062,25 @@ def _post_process_report_writer_result(
1121
1062
  ) -> dict[str, Any]:
1122
1063
  if worker.worker_id != REPORT_WRITER_WORKER_ID:
1123
1064
  return {"ok": True, "reason": "", "steps": []}
1124
- steps = []
1125
1065
  try:
1126
- commands = _report_post_process_commands(plan, worker)
1127
- except DispatchError as exc:
1128
- return {"ok": False, "reason": str(exc), "steps": steps}
1129
- for name, command in commands:
1130
- if name == "validate-run":
1131
- _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
1132
- result = subprocess.run(
1133
- command,
1134
- cwd=plan.project_root,
1135
- text=True,
1136
- capture_output=True,
1066
+ ctx = FinalizeContext.from_manifest(
1067
+ project_root=plan.project_root,
1068
+ workspace_root=plan.workspace_root,
1069
+ manifest_path=plan.manifest_path,
1070
+ team_state_path=plan.team_state_path,
1071
+ data_path=worker.result_path,
1072
+ manifest=plan.manifest,
1137
1073
  )
1138
- step = _post_process_step_payload(name, command, result)
1139
- steps.append(step)
1140
- if result.returncode != 0:
1141
- return {
1142
- "ok": False,
1143
- "reason": f"{name} failed with exit code {result.returncode}",
1144
- "steps": steps,
1145
- }
1146
- return {"ok": True, "reason": "", "steps": steps}
1147
-
1148
-
1149
- def _report_post_process_commands(
1150
- plan: DispatchPlan,
1151
- worker: WorkerDispatch,
1152
- ) -> list[tuple[str, list[str]]]:
1153
- markdown_path = _final_report_markdown_path(worker.result_path)
1154
- commands = [
1155
- (
1156
- "token-usage",
1157
- [
1158
- sys.executable,
1159
- str(_resolve_workspace_script(plan.workspace_root, "okstra-token-usage.py")),
1160
- str(plan.team_state_path),
1161
- "--project-root",
1162
- str(plan.project_root),
1163
- "--write",
1164
- "--substitute-data",
1165
- str(worker.result_path),
1166
- ],
1167
- ),
1168
- (
1169
- "render-views",
1170
- [
1171
- sys.executable,
1172
- str(_resolve_workspace_script(plan.workspace_root, "okstra-render-report-views.py")),
1173
- str(markdown_path),
1174
- "--task-key",
1175
- _require_string(plan.manifest, "taskKey"),
1176
- "--task-type",
1177
- _require_string(plan.manifest, "taskType"),
1178
- "--seq",
1179
- _run_seq(plan.manifest),
1180
- "--source-report",
1181
- _project_relative_path(plan.project_root, markdown_path),
1182
- ],
1183
- ),
1184
- (
1185
- "spawn-followups",
1186
- [
1187
- sys.executable,
1188
- str(_resolve_workspace_script(plan.workspace_root, "okstra-spawn-followups.py")),
1189
- str(worker.result_path),
1190
- "--project-root",
1191
- str(plan.project_root),
1192
- "--task-group",
1193
- _task_group(plan.manifest),
1194
- "--parent-task-key",
1195
- _require_string(plan.manifest, "taskKey"),
1196
- ],
1197
- ),
1198
- ]
1199
- commands.append(("validate-run", _validate_run_command(plan, markdown_path)))
1200
- return commands
1201
-
1202
-
1203
- def _validate_run_command(plan: DispatchPlan, markdown_path: Path) -> list[str]:
1204
- task_manifest_path = _task_manifest_path(plan.project_root, plan.manifest)
1205
- command = [
1206
- sys.executable,
1207
- str(_resolve_workspace_validator(plan.workspace_root, "validate-run.py")),
1208
- "--team-state",
1209
- str(plan.team_state_path),
1210
- "--report",
1211
- str(markdown_path),
1212
- "--run-manifest",
1213
- str(plan.manifest_path),
1214
- "--task-manifest",
1215
- str(task_manifest_path),
1216
- ]
1217
- final_status_path = _resolve_optional_path(
1218
- plan.project_root, plan.manifest.get("finalStatusPath")
1219
- )
1220
- if final_status_path is not None:
1221
- command.extend(["--final-status", str(final_status_path)])
1222
- return command
1223
-
1224
-
1225
- def _task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
1226
- value = _string_value(manifest.get("taskManifestPath"))
1227
- if value:
1228
- return _resolve_project_path(project_root, value)
1229
- task_root = _string_value(manifest.get("taskRootPath"))
1230
- if task_root:
1231
- return _resolve_project_path(project_root, task_root) / "task-manifest.json"
1232
- return (
1233
- task_dir(project_root, _task_group(manifest), _task_id(manifest))
1234
- / "task-manifest.json"
1235
- )
1236
-
1237
-
1238
- def _post_process_step_payload(
1239
- name: str,
1240
- command: Sequence[str],
1241
- result: subprocess.CompletedProcess[str],
1242
- ) -> dict[str, Any]:
1243
- return {
1244
- "name": name,
1245
- "command": list(command),
1246
- "exitCode": result.returncode,
1247
- "stdoutTail": _tail(result.stdout),
1248
- "stderrTail": _tail(result.stderr),
1249
- }
1250
-
1251
-
1252
- def _resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
1253
- candidates = [
1254
- workspace_root / "scripts" / script_name,
1255
- workspace_root / script_name,
1256
- ]
1257
- for candidate in candidates:
1258
- if candidate.is_file():
1259
- return candidate.resolve()
1260
- searched = ", ".join(str(candidate) for candidate in candidates)
1261
- raise DispatchError(f"{script_name} not found (searched: {searched})")
1262
-
1263
-
1264
- def _resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
1265
- candidates = [
1266
- workspace_root / "validators" / validator_name,
1267
- workspace_root / validator_name,
1268
- ]
1269
- for candidate in candidates:
1270
- if candidate.is_file():
1271
- return candidate.resolve()
1272
- searched = ", ".join(str(candidate) for candidate in candidates)
1273
- raise DispatchError(f"{validator_name} not found (searched: {searched})")
1274
-
1275
-
1276
- def _task_group(manifest: Mapping[str, Any]) -> str:
1277
- value = _string_value(manifest.get("taskGroup"))
1278
- if value:
1279
- return value
1280
- task_key = _require_string(manifest, "taskKey")
1281
- colon_parts = task_key.split(":")
1282
- if len(colon_parts) >= 3 and colon_parts[-2].strip():
1283
- return colon_parts[-2].strip()
1284
- slash_parts = task_key.split("/")
1285
- if len(slash_parts) >= 2 and slash_parts[0].strip():
1286
- return slash_parts[0].strip()
1287
- raise DispatchError(f"cannot infer task group from taskKey: {task_key}")
1288
-
1289
-
1290
- def _task_id(manifest: Mapping[str, Any]) -> str:
1291
- value = _string_value(manifest.get("taskId"))
1292
- if value:
1293
- return value
1294
- task_key = _require_string(manifest, "taskKey")
1295
- colon_parts = task_key.split(":")
1296
- if len(colon_parts) >= 3 and colon_parts[-1].strip():
1297
- return colon_parts[-1].strip()
1298
- slash_parts = task_key.split("/")
1299
- if len(slash_parts) >= 2 and slash_parts[-1].strip():
1300
- return slash_parts[-1].strip()
1301
- raise DispatchError(f"cannot infer task id from taskKey: {task_key}")
1074
+ except FinalizeError as exc:
1075
+ return {"ok": False, "reason": str(exc), "steps": []}
1302
1076
 
1077
+ def settle_before(step_name: str) -> None:
1078
+ # validate-run reads team-state; the writer is done by the time its
1079
+ # artifacts pass the earlier steps.
1080
+ if step_name == STEP_VALIDATE_RUN:
1081
+ _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
1303
1082
 
1304
- def _tail(text: str, *, limit: int = 4000) -> str:
1305
- if len(text) <= limit:
1306
- return text
1307
- return text[-limit:]
1083
+ return run_finalize(ctx, before_step=settle_before)
1308
1084
 
1309
1085
 
1310
1086
  def _append_event(
@@ -1466,37 +1242,12 @@ def _resolve_required_path(
1466
1242
  return _resolve_project_path(project_root, _require_string(manifest, key))
1467
1243
 
1468
1244
 
1469
- def _resolve_optional_path(project_root: Path, value: Any) -> Path | None:
1470
- if not isinstance(value, str) or not value.strip():
1471
- return None
1472
- return _resolve_project_path(project_root, value)
1473
-
1474
-
1475
- def _resolve_project_path(project_root: Path, value: str) -> Path:
1476
- path = Path(value)
1477
- return path if path.is_absolute() else project_root / path
1478
-
1479
-
1480
- def _require_string(payload: Mapping[str, Any], key: str) -> str:
1481
- value = payload.get(key)
1482
- if not isinstance(value, str) or not value.strip():
1483
- raise DispatchError(f"missing required string field: {key}")
1484
- return value
1485
-
1486
-
1487
1245
  def _string_list(value: Any) -> list[str]:
1488
1246
  if not isinstance(value, list):
1489
1247
  return []
1490
1248
  return [str(item).strip() for item in value if str(item).strip()]
1491
1249
 
1492
1250
 
1493
- def _run_seq(manifest: Mapping[str, Any]) -> str:
1494
- seqs = manifest.get("runSequencesByCategory")
1495
- if not isinstance(seqs, Mapping):
1496
- raise DispatchError("run manifest has no runSequencesByCategory object")
1497
- return _require_string(seqs, "manifests")
1498
-
1499
-
1500
1251
  def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1501
1252
  if exit_code != 0:
1502
1253
  return f"wrapper exited with code {exit_code}"