okstra 0.169.0 → 0.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/docs/architecture.md +17 -1
  2. package/docs/cli.md +11 -1
  3. package/docs/for-ai/skills/okstra-setup.md +8 -0
  4. package/docs/project-structure-overview.md +3 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-error-log.py +38 -282
  8. package/runtime/prompts/duties/acceptance-critic.md +25 -5
  9. package/runtime/prompts/duties/acceptance-verifier.md +25 -5
  10. package/runtime/prompts/duties/analysis-worker.md +25 -5
  11. package/runtime/prompts/duties/code-reviewer.md +25 -5
  12. package/runtime/prompts/duties/common.md +15 -11
  13. package/runtime/prompts/duties/diagnosis-worker.md +44 -0
  14. package/runtime/prompts/duties/discovery-worker.md +44 -0
  15. package/runtime/prompts/duties/implementation-executor.md +25 -5
  16. package/runtime/prompts/duties/implementation-verifier.md +25 -5
  17. package/runtime/prompts/duties/lead.md +25 -5
  18. package/runtime/prompts/duties/planning-worker.md +44 -0
  19. package/runtime/prompts/duties/report-writer.md +25 -5
  20. package/runtime/prompts/duties/reverification-worker.md +25 -5
  21. package/runtime/prompts/duties/schedule-verifier.md +25 -5
  22. package/runtime/prompts/duties/scope-critic.md +25 -5
  23. package/runtime/prompts/duties/translator.md +25 -5
  24. package/runtime/prompts/lead/convergence.md +53 -7
  25. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  26. package/runtime/prompts/lead/plan-body-verification.md +5 -1
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  29. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  30. package/runtime/prompts/profiles/final-verification.md +1 -1
  31. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  32. package/runtime/python/okstra_ctl/agent_invocation.py +146 -6
  33. package/runtime/python/okstra_ctl/agent_prompt_cli.py +38 -0
  34. package/runtime/python/okstra_ctl/cmux.py +36 -19
  35. package/runtime/python/okstra_ctl/dispatch_core.py +317 -27
  36. package/runtime/python/okstra_ctl/dispatch_state.py +143 -9
  37. package/runtime/python/okstra_ctl/doctor.py +31 -0
  38. package/runtime/python/okstra_ctl/error_log_write.py +308 -0
  39. package/runtime/python/okstra_ctl/plan_derivations.py +94 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +114 -3
  41. package/runtime/python/okstra_ctl/run.py +7 -1
  42. package/runtime/python/okstra_ctl/schema_excerpt.py +34 -0
  43. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -0
  44. package/runtime/python/okstra_ctl/worker_audit_check.py +26 -4
  45. package/runtime/python/okstra_ctl/worker_audit_ledger.py +59 -9
  46. package/runtime/python/okstra_ctl/worker_prompt_contract.py +24 -1
  47. package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -2
  48. package/runtime/python/okstra_ctl/worker_prompt_policy.py +12 -1
  49. package/runtime/python/okstra_project/resolver.py +34 -0
  50. package/runtime/skills/okstra-setup/references/project-config.md +38 -0
  51. package/runtime/validators/lib/fixtures.sh +9 -1
  52. package/runtime/validators/validate-run.py +37 -2
@@ -199,17 +199,23 @@ def plan_worker_placement(
199
199
  return _extend_the_shortest_column(columns)
200
200
 
201
201
 
202
- def lead_shrink_points(lead: PaneGeometry, *, target_columns: int) -> int:
203
- """How far to push the lead's right border, in the points `pane.resize` takes.
202
+ def lead_resize_points(lead: PaneGeometry, *, target_columns: int) -> int:
203
+ """How far to move the lead's right border, in the points `pane.resize` takes.
204
+
205
+ Signed: positive when the lead is too wide and the border comes in, negative
206
+ when it is too narrow and the border goes out.
207
+
208
+ Both directions are needed. A split halves whatever pane it lands on, and
209
+ the first worker of every round lands on the lead — so a rule that only ever
210
+ shrinks leaves that half permanent, and the round after it takes half of
211
+ what is left. Measured on this display: 215 columns becomes 80, then 40,
212
+ then 20, until neither the lead nor its workers can be read.
204
213
 
205
214
  The API's `amount` is points, not cells — measured at this pane's own
206
- `cell_width_points`, so passing a column count shrinks by an eighth of the
207
- intent on a typical display.
215
+ `cell_width_points`, so passing a column count moves the border by an eighth
216
+ of the intent on a typical display.
208
217
  """
209
- surplus = lead.columns - target_columns
210
- if surplus <= 0:
211
- return 0
212
- return surplus * lead.cell_width_points
218
+ return (lead.columns - target_columns) * lead.cell_width_points
213
219
 
214
220
 
215
221
  def _holds_an_okstra_surface(
@@ -326,7 +332,7 @@ def spawn_worker_surface(
326
332
  surface_uuid = _open_worker_surface(workspace, placement, target)
327
333
  run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
328
334
  _exec_worker(surface_uuid, cwd=cwd, command=command)
329
- _shrink_lead_pane(workspace)
335
+ _size_lead_pane(workspace)
330
336
  return surface_uuid
331
337
 
332
338
 
@@ -530,28 +536,39 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
530
536
  raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
531
537
 
532
538
 
533
- def _shrink_lead_pane(workspace: str) -> None:
534
- """Give the lead's width to the workers by pushing its right border left.
539
+ def _size_lead_pane(workspace: str) -> None:
540
+ """Put the lead back on its target width, leaving the rest to the workers.
541
+
542
+ Which pane carries the request follows from what `pane.resize` does: it
543
+ moves the named pane's own border in the direction given. The lead can push
544
+ its right border out — that is `right` on the lead itself — but it cannot
545
+ pull that border in, because `left` on the leftmost pane finds no adjacent
546
+ border to move. Narrowing is therefore the right-hand neighbour's request,
547
+ and widening is the lead's.
535
548
 
536
- The lead cannot shrink itself: `pane.resize` moves the named pane's border,
537
- so asking the leftmost pane to move `left` fails with no adjacent border and
538
- `right` widens it. The neighbour on its right carries the request instead.
549
+ Run after every worker opens rather than once per round: the split that just
550
+ happened is what knocked the lead off its width, and no other event does.
539
551
  """
540
552
  panes = list_panes(workspace)
541
553
  lead = _lead_pane(panes)
542
- amount = lead_shrink_points(lead, target_columns=LEAD_TARGET_COLUMNS)
543
- if amount <= 0:
554
+ offset = lead_resize_points(lead, target_columns=LEAD_TARGET_COLUMNS)
555
+ if offset == 0:
544
556
  return
545
557
  neighbours = [pane for pane in panes if pane.x > lead.x]
546
558
  if not neighbours:
547
559
  return
560
+ narrowing = offset > 0
548
561
  rpc(
549
562
  "pane.resize",
550
563
  {
551
564
  "workspace_id": workspace,
552
- "pane_id": min(neighbours, key=lambda pane: pane.x).pane_id,
553
- "direction": "left",
554
- "amount": amount,
565
+ "pane_id": (
566
+ min(neighbours, key=lambda pane: pane.x).pane_id
567
+ if narrowing
568
+ else lead.pane_id
569
+ ),
570
+ "direction": "left" if narrowing else "right",
571
+ "amount": abs(offset),
555
572
  },
556
573
  )
557
574
 
@@ -19,7 +19,9 @@ from .dispatch_state import (
19
19
  append_worker_dispatch as _append_worker_dispatch,
20
20
  DispatchError,
21
21
  WorkerJob,
22
+ dispatch_completion_paths as _completion_paths,
22
23
  dispatch_mode as _dispatch_mode,
24
+ dispatch_result_path as _result_path_for_worker,
23
25
  LIVENESS_AUDIT_HEARTBEAT,
24
26
  LIVENESS_WRAPPER_STATUS,
25
27
  load_json_object as _load_json_object,
@@ -41,10 +43,7 @@ from .dispatch_state import (
41
43
  worker_state as _worker_state,
42
44
  worktree_path as _worktree_path,
43
45
  )
44
- from .final_report_paths import (
45
- final_report_data_path as _final_report_data_path,
46
- final_report_markdown_path as _final_report_markdown_path,
47
- )
46
+ from .error_log_write import append_observed
48
47
  from .lead_events import LeadEvent, append_lead_event
49
48
  from .initial_prompt_materialization import (
50
49
  InitialPromptMaterializationError,
@@ -54,19 +53,35 @@ from .initial_prompt_materialization import (
54
53
  materialize_initial_prompts,
55
54
  )
56
55
  from .path_hints import hydrate_active_run_context
56
+ from .schema_excerpt import bundle_excerpt_path, excerpt_version_skew
57
+ from .seeding import installed_version
57
58
  from .report_finalize import (
58
59
  STEP_VALIDATE_RUN,
59
60
  FinalizeContext,
60
61
  FinalizeError,
61
62
  run_finalize,
62
63
  )
64
+ from .worker_audit_ledger import (
65
+ check_worker_results_audit,
66
+ parse_worker_result_name,
67
+ )
63
68
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
69
+ from .worker_prompt_headers import (
70
+ WorkerPromptHeaderError,
71
+ resolve_errors_log_path,
72
+ )
64
73
  from .worker_artifact_paths import audit_sidecar_rel
65
74
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
66
75
 
67
76
 
68
77
  MAX_WORKER_ATTEMPTS = 2
69
78
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
79
+ # What the error log records for a wrapper the dispatcher timed out, matching
80
+ # the value `team-contract` prescribes for a polling-cap termination.
81
+ _WRAPPER_TIMEOUT_EXIT_CODE = 124
82
+ # The excerpt shares one atomic PIPE_BUF append with the rest of the record, so
83
+ # it is capped far below the writer's own 2048-byte stderr limit.
84
+ _WRAPPER_LOG_TAIL_BYTES = 800
70
85
 
71
86
 
72
87
  @dataclass(frozen=True)
@@ -176,7 +191,7 @@ def build_dispatch_plan(
176
191
  default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
177
192
  )
178
193
  if jobs_file:
179
- jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
194
+ jobs = _jobs_from_file(project_root, workspace_root, jobs_file, manifest, options)
180
195
  else:
181
196
  jobs = _jobs_from_roster(
182
197
  project_root,
@@ -189,6 +204,7 @@ def build_dispatch_plan(
189
204
  options,
190
205
  )
191
206
  _validate_dispatch_prompts(manifest, active_context, jobs)
207
+ _reject_stale_schema_excerpt(project_root, manifest, jobs)
192
208
  return DispatchPlan(
193
209
  project_root=project_root,
194
210
  workspace_root=workspace_root.resolve(),
@@ -690,6 +706,7 @@ def _jobs_from_file(
690
706
  project_root: Path,
691
707
  workspace_root: Path,
692
708
  jobs_file: Path | None,
709
+ manifest: Mapping[str, Any],
693
710
  options: _BuildOptions,
694
711
  ) -> list[WorkerJob]:
695
712
  if jobs_file is None:
@@ -697,6 +714,7 @@ def _jobs_from_file(
697
714
  return _worker_jobs_from_file(
698
715
  project_root,
699
716
  jobs_file,
717
+ manifest=manifest,
700
718
  backend=options.default_backend,
701
719
  idle_timeout_seconds=options.idle_timeout_seconds,
702
720
  default_dispatch_kind=options.dispatch_kind,
@@ -927,20 +945,23 @@ def _retry_from_record(
927
945
  worker_id = _require_string(record, "workerId")
928
946
  attempt = int(record.get("attempt", 1))
929
947
  job = _job_from_record(plan.project_root, record)
930
- _update_dispatch_status(plan.team_state_path, job, attempt, "error", "required worker artifact was not produced")
931
- _append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
948
+ reason = "required worker artifact was not produced"
949
+ _update_dispatch_status(plan.team_state_path, job, attempt, "error", reason)
950
+ # `team-contract` counts the first attempt's failure as a recorded
951
+ # `cli-failure`; a retry that succeeds settles `completed` and would
952
+ # otherwise leave no trace that anything had to be re-run.
953
+ details: dict[str, Any] = {"workerId": worker_id, "attempt": attempt}
954
+ details["errorLogAppend"] = _record_wrapper_failure(
955
+ plan, job, attempt, outcome, reason
956
+ )
957
+ _append_event(plan, "worker-retry-scheduled", details)
932
958
  _spawn_job(plan, job, attempt + 1)
933
959
 
934
960
 
935
961
  def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
936
- if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
937
- if job.invocation_id:
938
- _link_agent_dispatch_result(
939
- project_root=plan.project_root,
940
- run_manifest_path=plan.manifest_path,
941
- dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
942
- result_path=job.worker_result_path,
943
- )
962
+ settlement = _settle(plan, job, attempt, outcome)
963
+ if settlement.completed:
964
+ result_link = _link_result(plan, job, attempt)
944
965
  post_process = _post_process_report_writer_result(plan, job)
945
966
  if not post_process["ok"]:
946
967
  reason = _require_string(post_process, "reason")
@@ -965,16 +986,65 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
965
986
  _transition_worker_status(
966
987
  plan.team_state_path, job.worker_id, "completed", ""
967
988
  )
968
- _update_dispatch_status(plan.team_state_path, job, attempt, "completed", "")
989
+ _update_dispatch_status(
990
+ plan.team_state_path,
991
+ job,
992
+ attempt,
993
+ "completed",
994
+ "; ".join(part for part in (settlement.note, result_link["reason"]) if part),
995
+ )
969
996
  details = _result_details(job, attempt, outcome)
970
997
  details["postProcessing"] = post_process["steps"]
998
+ details["resultLink"] = result_link
999
+ if settlement.error_log_append is not None:
1000
+ details["errorLogAppend"] = settlement.error_log_append
971
1001
  _append_event(plan, "worker-result-collected", details)
972
1002
  return
973
- reason = _failure_reason(outcome)
1003
+ reason = settlement.reason
974
1004
  status = "timeout" if outcome.timeout else "error"
975
1005
  _transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
976
1006
  _update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
977
- _append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
1007
+ details = _failure_details(job, attempt, outcome, reason)
1008
+ if settlement.error_log_append is not None:
1009
+ details["errorLogAppend"] = settlement.error_log_append
1010
+ _append_event(plan, "worker-failed", details)
1011
+
1012
+
1013
+ def _link_result(
1014
+ plan: DispatchPlan, job: WorkerJob, attempt: int
1015
+ ) -> dict[str, Any]:
1016
+ """Bind this result to its verified dispatch, reporting rather than raising.
1017
+
1018
+ Linking is bookkeeping around a dispatch that has already settled, and it
1019
+ used to run before any status was written. A refused link — the live case is
1020
+ a corrective re-dispatch claiming a path the first attempt still owns — threw
1021
+ out of `_finish_attempt`, so nothing transitioned, the row stayed `running`,
1022
+ and the exception reached the caller as exit 2. The next `await` re-read the
1023
+ same terminal sidecar, re-settled the same way, and threw at the same line:
1024
+ a worker with complete artifacts wedged the run permanently, and no amount of
1025
+ waiting could clear it.
1026
+
1027
+ So the settle is written either way and the refusal travels back as data. It
1028
+ is not swallowed: the reason lands in the dispatch row and in the lead event,
1029
+ and the post-hoc validators still require every accepted result to carry a
1030
+ link, so an unlinked result fails where an audit failure belongs rather than
1031
+ by stopping the run mid-phase. `agent-prompt reject-result` is the remedy the
1032
+ refusal names.
1033
+ """
1034
+ link: dict[str, Any] = {"ok": True, "reason": ""}
1035
+ if not job.invocation_id:
1036
+ return link
1037
+ try:
1038
+ _link_agent_dispatch_result(
1039
+ project_root=plan.project_root,
1040
+ run_manifest_path=plan.manifest_path,
1041
+ dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
1042
+ result_path=job.worker_result_path,
1043
+ )
1044
+ except (DispatchError, OSError) as exc:
1045
+ link["ok"] = False
1046
+ link["reason"] = f"result link refused: {exc}"
1047
+ return link
978
1048
 
979
1049
 
980
1050
  def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome) -> None:
@@ -982,6 +1052,191 @@ def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: Worke
982
1052
  _finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
983
1053
 
984
1054
 
1055
+ @dataclass(frozen=True)
1056
+ class _Settlement:
1057
+ """How one attempt's terminal status was decided."""
1058
+
1059
+ completed: bool
1060
+ reason: str
1061
+ note: str
1062
+ error_log_append: dict[str, Any] | None
1063
+
1064
+
1065
+ def _settle(
1066
+ plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome
1067
+ ) -> _Settlement:
1068
+ """Judge an attempt by its artifacts, not by the wrapper's exit code alone.
1069
+
1070
+ A wrapper can die after its worker has already written everything — an
1071
+ observed case is a connection dropped at session teardown, long after the
1072
+ result file and its audit sidecar were on disk. Settling that as `error`
1073
+ discards a complete analysis, and not figuratively: `convergence_engine`
1074
+ admits only dispatches that settled `completed`, so the worker's findings
1075
+ never reach re-verification. The lead's own re-dispatch triggers agree —
1076
+ they name a missing, unparseable, or audit-failing result, never an exit
1077
+ code — but the only signal `team await` gave was the status.
1078
+
1079
+ So a non-zero exit with every completion path present is re-judged by the
1080
+ audit-sidecar contract, the same rules `okstra worker-audit-check` runs. It
1081
+ passes and the dispatch settles `completed`; it fails and the dispatch stays
1082
+ `error` exactly as before. Either way the wrapper's failure is written to the
1083
+ run error log, so a `completed` here is never a swallowed failure.
1084
+ """
1085
+ if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
1086
+ return _Settlement(True, "", "", None)
1087
+ reason = _failure_reason(outcome)
1088
+ completed = False
1089
+ note = ""
1090
+ if not outcome.timeout and not outcome.missing_completion_paths:
1091
+ audit_failures = _audit_sidecar_failures(job)
1092
+ if audit_failures:
1093
+ reason = (
1094
+ f"{reason}; worker artifacts failed the audit-sidecar "
1095
+ f"contract: {audit_failures[0]}"
1096
+ )
1097
+ else:
1098
+ completed = True
1099
+ note = (
1100
+ f"{reason}, but every completion artifact was written and "
1101
+ f"passed the audit-sidecar contract"
1102
+ )
1103
+ append = _record_wrapper_failure(plan, job, attempt, outcome, note or reason)
1104
+ return _Settlement(completed, "" if completed else reason, note, append)
1105
+
1106
+
1107
+ def _audit_sidecar_failures(job: WorkerJob) -> tuple[str, ...]:
1108
+ """This worker's audit-sidecar contract failures, if the check can run.
1109
+
1110
+ The check's arguments come from the result filename rather than the manifest
1111
+ so the scan cannot widen past the file this job produced: `worker-results/`
1112
+ accumulates every run's artifacts, and the `worker=` filter matches the
1113
+ `-worker`-suffixed role, not the bare provider id. A non-canonical name
1114
+ leaves nothing to enforce, and an unverifiable artifact must not be promoted
1115
+ to `completed`, so that reports one failure rather than an empty tuple.
1116
+ """
1117
+ parsed = parse_worker_result_name(job.worker_result_path.name)
1118
+ if parsed is None:
1119
+ return (
1120
+ f"worker result `{job.worker_result_path.name}` is not a canonical "
1121
+ f"`<role>-worker-<task-type>-<seq>.md` name, so the audit-sidecar "
1122
+ f"contract could not be checked",
1123
+ )
1124
+ return tuple(
1125
+ check_worker_results_audit(
1126
+ job.worker_result_path.parent.parent,
1127
+ parsed.task_type,
1128
+ parsed.seq,
1129
+ worker=parsed.worker_role,
1130
+ )
1131
+ )
1132
+
1133
+
1134
+ def _record_wrapper_failure(
1135
+ plan: DispatchPlan,
1136
+ job: WorkerJob,
1137
+ attempt: int,
1138
+ outcome: WorkerOutcome,
1139
+ message: str,
1140
+ ) -> dict[str, Any]:
1141
+ """Write the wrapper's own failure to the run-level error log.
1142
+
1143
+ `okstra-lead-contract` tells Lead the deterministic dispatcher records this
1144
+ and that Lead does not need to re-record it. Nothing did: no code path
1145
+ anywhere called the error-log writer, so every wrapper failure vanished, and
1146
+ with it the `instruction-set/prior-run-errors.md` digest the next run reads
1147
+ and the `/okstra-inspect errors` report. The dispatcher is the only component
1148
+ that holds the exit code, so it is the one that writes.
1149
+
1150
+ Never raises. Logging is bookkeeping around a dispatch that has already
1151
+ settled; letting a rejected or unwritable record throw here would turn a
1152
+ recorded outcome into an unrecorded crash. What went wrong travels back in
1153
+ the lead event instead.
1154
+ """
1155
+ result: dict[str, Any] = {"ok": False, "reason": "", "path": ""}
1156
+ try:
1157
+ out_path = resolve_errors_log_path(
1158
+ plan.project_root,
1159
+ plan.manifest,
1160
+ _load_optional_json(
1161
+ plan.project_root, plan.manifest.get("activeRunContextPath")
1162
+ ),
1163
+ )
1164
+ result["path"] = str(out_path)
1165
+ append_observed(
1166
+ out_path=out_path,
1167
+ task_key=_string_value(plan.manifest.get("taskKey")),
1168
+ phase=_workflow_phase(plan.manifest),
1169
+ agent=_error_log_agent(job.worker_id),
1170
+ agent_role=(
1171
+ "report-writer"
1172
+ if job.worker_id == REPORT_WRITER_WORKER_ID
1173
+ else "worker"
1174
+ ),
1175
+ model=job.model_execution_value,
1176
+ error_type="cli-failure",
1177
+ command=" ".join(job.command),
1178
+ command_kind="wrapper",
1179
+ exit_code=_WRAPPER_TIMEOUT_EXIT_CODE if outcome.timeout else outcome.returncode,
1180
+ duration_ms=_wrapper_duration_ms(outcome),
1181
+ message=f"attempt {attempt}: {message}",
1182
+ stderr_excerpt=_wrapper_log_tail(job),
1183
+ context=None,
1184
+ )
1185
+ except (OSError, ValueError, TypeError, WorkerPromptHeaderError) as exc:
1186
+ result["reason"] = f"{type(exc).__name__}: {exc}"
1187
+ return result
1188
+ result["ok"] = True
1189
+ return result
1190
+
1191
+
1192
+ def _error_log_agent(worker_id: str) -> str:
1193
+ """The error log's `--agent` enum value for a worker id.
1194
+
1195
+ The log's own allow-list is the authority on what it accepts; a worker whose
1196
+ name is outside it is reported as such by `append_observed` rather than
1197
+ silently rewritten into some other agent's records.
1198
+ """
1199
+ if worker_id == REPORT_WRITER_WORKER_ID:
1200
+ return REPORT_WRITER_WORKER_ID
1201
+ return f"{worker_id}-worker"
1202
+
1203
+
1204
+ def _workflow_phase(manifest: Mapping[str, Any]) -> str:
1205
+ workflow = manifest.get("workflow")
1206
+ if isinstance(workflow, Mapping):
1207
+ return _string_value(workflow.get("currentPhase"))
1208
+ return ""
1209
+
1210
+
1211
+ def _wrapper_duration_ms(outcome: WorkerOutcome) -> int | None:
1212
+ if outcome.status_sidecar_path is None:
1213
+ return None
1214
+ status = read_wrapper_status(outcome.status_sidecar_path)
1215
+ if status is None:
1216
+ return None
1217
+ value = status.raw.get("duration_ms")
1218
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
1219
+
1220
+
1221
+ def _wrapper_log_tail(job: WorkerJob) -> str | None:
1222
+ """The tail of the wrapper transcript, which usually names the real failure.
1223
+
1224
+ The observed case put `API Error: Connection lost mid-response.` in the last
1225
+ two lines and nothing anywhere else; without it the record says only that
1226
+ some process exited 1. Capped well under the writer's own excerpt limit
1227
+ because a whole record must stay inside one atomic `PIPE_BUF` append.
1228
+ """
1229
+ log_path = job.prompt_path.with_suffix(job.prompt_path.suffix + ".log")
1230
+ try:
1231
+ with log_path.open("rb") as handle:
1232
+ handle.seek(0, 2)
1233
+ handle.seek(max(0, handle.tell() - _WRAPPER_LOG_TAIL_BYTES))
1234
+ tail = handle.read()
1235
+ except OSError:
1236
+ return None
1237
+ return tail.decode("utf-8", errors="replace").strip() or None
1238
+
1239
+
985
1240
  def _post_process_report_writer_result(
986
1241
  plan: DispatchPlan,
987
1242
  job: WorkerJob,
@@ -1262,6 +1517,10 @@ def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dic
1262
1517
  "attempt": attempt,
1263
1518
  "dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
1264
1519
  "missingCompletionPaths": [],
1520
+ # A collected result can still come from a wrapper that exited non-zero
1521
+ # (`_settle`). The status says the artifacts are good; this says what the
1522
+ # process did, so the trace never loses one fact to the other.
1523
+ "wrapperExitCode": outcome.returncode,
1265
1524
  }
1266
1525
 
1267
1526
 
@@ -1406,17 +1665,48 @@ def _provider_for_worker(
1406
1665
  return worker_id
1407
1666
 
1408
1667
 
1409
- def _result_path_for_worker(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> Path:
1410
- if worker_id != REPORT_WRITER_WORKER_ID:
1411
- return result_path
1412
- return _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
1413
1668
 
1414
1669
 
1415
- def _completion_paths(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> tuple[Path, ...]:
1416
- if worker_id != REPORT_WRITER_WORKER_ID:
1417
- return (result_path,)
1418
- data_json = _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
1419
- return (data_json, _final_report_markdown_path(data_json), result_path)
1670
+ def _reject_stale_schema_excerpt(
1671
+ project_root: Path,
1672
+ manifest: Mapping[str, Any],
1673
+ jobs: Sequence[WorkerJob],
1674
+ ) -> None:
1675
+ """Refuse to send the report writer at a schema excerpt from another runtime.
1676
+
1677
+ The bundle's `instruction-set/final-report-schema.json` is cut at prep time
1678
+ and never moves again, while validation always runs against the installed
1679
+ schema. A run long enough to straddle a runtime upgrade therefore has the
1680
+ author writing to one contract and the validator reading another — and the
1681
+ only thing that noticed was the renderer, in Phase 6, after the worker had
1682
+ authored the whole report. The two versions are comparable the moment the
1683
+ dispatch is built, and the remedy is the same either way, so it belongs here.
1684
+
1685
+ Only the report writer is stopped: it is the only worker that authors against
1686
+ the excerpt. Re-running bundle prep re-cuts it from the installed schema.
1687
+ """
1688
+ writer = next(
1689
+ (job for job in jobs if job.worker_id == REPORT_WRITER_WORKER_ID), None
1690
+ )
1691
+ if writer is None:
1692
+ return
1693
+ expected = _string_value(manifest.get("expectedReportPath"))
1694
+ if not expected:
1695
+ return
1696
+ excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
1697
+ if excerpt_path is None:
1698
+ return
1699
+ installed = installed_version()
1700
+ cut_from = excerpt_version_skew(excerpt_path, installed)
1701
+ if not cut_from:
1702
+ return
1703
+ raise DispatchError(
1704
+ f"the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
1705
+ f"{cut_from} but this runtime is {installed}. The report writer authors "
1706
+ f"against that excerpt and validation runs against the installed schema, "
1707
+ f"so dispatching now spends a full authoring pass on the wrong contract. "
1708
+ f"Re-prepare the task bundle to re-cut the excerpt, then dispatch again."
1709
+ )
1420
1710
 
1421
1711
 
1422
1712
  def _run_dir(plan: DispatchPlan) -> Path: