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.
- package/docs/architecture.md +17 -1
- package/docs/cli.md +11 -1
- package/docs/for-ai/skills/okstra-setup.md +8 -0
- package/docs/project-structure-overview.md +3 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-error-log.py +38 -282
- package/runtime/prompts/duties/acceptance-critic.md +25 -5
- package/runtime/prompts/duties/acceptance-verifier.md +25 -5
- package/runtime/prompts/duties/analysis-worker.md +25 -5
- package/runtime/prompts/duties/code-reviewer.md +25 -5
- package/runtime/prompts/duties/common.md +15 -11
- package/runtime/prompts/duties/diagnosis-worker.md +44 -0
- package/runtime/prompts/duties/discovery-worker.md +44 -0
- package/runtime/prompts/duties/implementation-executor.md +25 -5
- package/runtime/prompts/duties/implementation-verifier.md +25 -5
- package/runtime/prompts/duties/lead.md +25 -5
- package/runtime/prompts/duties/planning-worker.md +44 -0
- package/runtime/prompts/duties/report-writer.md +25 -5
- package/runtime/prompts/duties/reverification-worker.md +25 -5
- package/runtime/prompts/duties/schedule-verifier.md +25 -5
- package/runtime/prompts/duties/scope-critic.md +25 -5
- package/runtime/prompts/duties/translator.md +25 -5
- package/runtime/prompts/lead/convergence.md +53 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +5 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/python/okstra_ctl/agent_invocation.py +146 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +38 -0
- package/runtime/python/okstra_ctl/cmux.py +36 -19
- package/runtime/python/okstra_ctl/dispatch_core.py +317 -27
- package/runtime/python/okstra_ctl/dispatch_state.py +143 -9
- package/runtime/python/okstra_ctl/doctor.py +31 -0
- package/runtime/python/okstra_ctl/error_log_write.py +308 -0
- package/runtime/python/okstra_ctl/plan_derivations.py +94 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +114 -3
- package/runtime/python/okstra_ctl/run.py +7 -1
- package/runtime/python/okstra_ctl/schema_excerpt.py +34 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +17 -0
- package/runtime/python/okstra_ctl/worker_audit_check.py +26 -4
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +59 -9
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +24 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +12 -1
- package/runtime/python/okstra_project/resolver.py +34 -0
- package/runtime/skills/okstra-setup/references/project-config.md +38 -0
- package/runtime/validators/lib/fixtures.sh +9 -1
- 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
|
|
203
|
-
"""How far to
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
534
|
-
"""
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
543
|
-
if
|
|
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":
|
|
553
|
-
|
|
554
|
-
|
|
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 .
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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:
|