okstra 0.169.1 → 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/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/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 +60 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +30 -0
- package/runtime/python/okstra_ctl/cmux.py +36 -19
- package/runtime/python/okstra_ctl/dispatch_core.py +92 -22
- package/runtime/python/okstra_ctl/dispatch_state.py +143 -9
- package/runtime/python/okstra_ctl/doctor.py +31 -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_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
|
@@ -41,6 +41,7 @@ from .dispatch_state import (
|
|
|
41
41
|
DispatchError,
|
|
42
42
|
link_agent_dispatch_result,
|
|
43
43
|
record_verified_agent_dispatch,
|
|
44
|
+
reject_agent_dispatch_result,
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
|
|
@@ -110,6 +111,18 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
110
111
|
)
|
|
111
112
|
record_dispatch.add_argument("--json", action="store_true")
|
|
112
113
|
|
|
114
|
+
reject_result = commands.add_parser(
|
|
115
|
+
"reject-result",
|
|
116
|
+
help="mark a linked result rejected so a corrective re-dispatch can "
|
|
117
|
+
"claim its path",
|
|
118
|
+
)
|
|
119
|
+
_common_paths(reject_result)
|
|
120
|
+
reject_result.add_argument("--run-manifest", required=True)
|
|
121
|
+
reject_result.add_argument("--dispatch-id", required=True)
|
|
122
|
+
reject_result.add_argument("--superseded-by", required=True)
|
|
123
|
+
reject_result.add_argument("--reason", required=True)
|
|
124
|
+
reject_result.add_argument("--json", action="store_true")
|
|
125
|
+
|
|
113
126
|
link_result = commands.add_parser("link-result")
|
|
114
127
|
_common_paths(link_result)
|
|
115
128
|
link_result.add_argument("--run-manifest", required=True)
|
|
@@ -156,6 +169,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
156
169
|
if args.command == "record-dispatch":
|
|
157
170
|
_record_dispatch(args)
|
|
158
171
|
return 0
|
|
172
|
+
if args.command == "reject-result":
|
|
173
|
+
_reject_result(args)
|
|
174
|
+
return 0
|
|
159
175
|
_link_result(args)
|
|
160
176
|
return 0
|
|
161
177
|
except (
|
|
@@ -192,6 +208,20 @@ def _record_dispatch(args: argparse.Namespace) -> None:
|
|
|
192
208
|
_emit(record, args.json)
|
|
193
209
|
|
|
194
210
|
|
|
211
|
+
def _reject_result(args: argparse.Namespace) -> None:
|
|
212
|
+
project_root = _project_root(args.project_root)
|
|
213
|
+
row = reject_agent_dispatch_result(
|
|
214
|
+
project_root=project_root,
|
|
215
|
+
run_manifest_path=_project_input(
|
|
216
|
+
project_root, args.run_manifest, "run manifest"
|
|
217
|
+
),
|
|
218
|
+
dispatch_id=args.dispatch_id,
|
|
219
|
+
superseded_by=args.superseded_by,
|
|
220
|
+
reason=args.reason,
|
|
221
|
+
)
|
|
222
|
+
_emit(row, args.json)
|
|
223
|
+
|
|
224
|
+
|
|
195
225
|
def _link_result(args: argparse.Namespace) -> None:
|
|
196
226
|
project_root = _project_root(args.project_root)
|
|
197
227
|
link = link_agent_dispatch_result(
|
|
@@ -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,6 @@ 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
|
-
)
|
|
48
46
|
from .error_log_write import append_observed
|
|
49
47
|
from .lead_events import LeadEvent, append_lead_event
|
|
50
48
|
from .initial_prompt_materialization import (
|
|
@@ -55,6 +53,8 @@ from .initial_prompt_materialization import (
|
|
|
55
53
|
materialize_initial_prompts,
|
|
56
54
|
)
|
|
57
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
|
|
58
58
|
from .report_finalize import (
|
|
59
59
|
STEP_VALIDATE_RUN,
|
|
60
60
|
FinalizeContext,
|
|
@@ -191,7 +191,7 @@ def build_dispatch_plan(
|
|
|
191
191
|
default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
|
|
192
192
|
)
|
|
193
193
|
if jobs_file:
|
|
194
|
-
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
|
|
194
|
+
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, manifest, options)
|
|
195
195
|
else:
|
|
196
196
|
jobs = _jobs_from_roster(
|
|
197
197
|
project_root,
|
|
@@ -204,6 +204,7 @@ def build_dispatch_plan(
|
|
|
204
204
|
options,
|
|
205
205
|
)
|
|
206
206
|
_validate_dispatch_prompts(manifest, active_context, jobs)
|
|
207
|
+
_reject_stale_schema_excerpt(project_root, manifest, jobs)
|
|
207
208
|
return DispatchPlan(
|
|
208
209
|
project_root=project_root,
|
|
209
210
|
workspace_root=workspace_root.resolve(),
|
|
@@ -705,6 +706,7 @@ def _jobs_from_file(
|
|
|
705
706
|
project_root: Path,
|
|
706
707
|
workspace_root: Path,
|
|
707
708
|
jobs_file: Path | None,
|
|
709
|
+
manifest: Mapping[str, Any],
|
|
708
710
|
options: _BuildOptions,
|
|
709
711
|
) -> list[WorkerJob]:
|
|
710
712
|
if jobs_file is None:
|
|
@@ -712,6 +714,7 @@ def _jobs_from_file(
|
|
|
712
714
|
return _worker_jobs_from_file(
|
|
713
715
|
project_root,
|
|
714
716
|
jobs_file,
|
|
717
|
+
manifest=manifest,
|
|
715
718
|
backend=options.default_backend,
|
|
716
719
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
717
720
|
default_dispatch_kind=options.dispatch_kind,
|
|
@@ -958,13 +961,7 @@ def _retry_from_record(
|
|
|
958
961
|
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
959
962
|
settlement = _settle(plan, job, attempt, outcome)
|
|
960
963
|
if settlement.completed:
|
|
961
|
-
|
|
962
|
-
_link_agent_dispatch_result(
|
|
963
|
-
project_root=plan.project_root,
|
|
964
|
-
run_manifest_path=plan.manifest_path,
|
|
965
|
-
dispatch_id=f"{job.invocation_id}:attempt-{attempt}",
|
|
966
|
-
result_path=job.worker_result_path,
|
|
967
|
-
)
|
|
964
|
+
result_link = _link_result(plan, job, attempt)
|
|
968
965
|
post_process = _post_process_report_writer_result(plan, job)
|
|
969
966
|
if not post_process["ok"]:
|
|
970
967
|
reason = _require_string(post_process, "reason")
|
|
@@ -990,10 +987,15 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
|
|
|
990
987
|
plan.team_state_path, job.worker_id, "completed", ""
|
|
991
988
|
)
|
|
992
989
|
_update_dispatch_status(
|
|
993
|
-
plan.team_state_path,
|
|
990
|
+
plan.team_state_path,
|
|
991
|
+
job,
|
|
992
|
+
attempt,
|
|
993
|
+
"completed",
|
|
994
|
+
"; ".join(part for part in (settlement.note, result_link["reason"]) if part),
|
|
994
995
|
)
|
|
995
996
|
details = _result_details(job, attempt, outcome)
|
|
996
997
|
details["postProcessing"] = post_process["steps"]
|
|
998
|
+
details["resultLink"] = result_link
|
|
997
999
|
if settlement.error_log_append is not None:
|
|
998
1000
|
details["errorLogAppend"] = settlement.error_log_append
|
|
999
1001
|
_append_event(plan, "worker-result-collected", details)
|
|
@@ -1008,6 +1010,43 @@ def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: W
|
|
|
1008
1010
|
_append_event(plan, "worker-failed", details)
|
|
1009
1011
|
|
|
1010
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
|
|
1048
|
+
|
|
1049
|
+
|
|
1011
1050
|
def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome) -> None:
|
|
1012
1051
|
job = _job_from_record(plan.project_root, record)
|
|
1013
1052
|
_finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
|
|
@@ -1626,17 +1665,48 @@ def _provider_for_worker(
|
|
|
1626
1665
|
return worker_id
|
|
1627
1666
|
|
|
1628
1667
|
|
|
1629
|
-
def _result_path_for_worker(worker_id: str, result_path: Path, manifest: Mapping[str, Any], project_root: Path) -> Path:
|
|
1630
|
-
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1631
|
-
return result_path
|
|
1632
|
-
return _final_report_data_path(_resolve_required_path(project_root, manifest, "expectedReportPath"))
|
|
1633
1668
|
|
|
1634
1669
|
|
|
1635
|
-
def
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
+
)
|
|
1640
1710
|
|
|
1641
1711
|
|
|
1642
1712
|
def _run_dir(plan: DispatchPlan) -> Path:
|
|
@@ -33,6 +33,11 @@ from .agent_invocation import (
|
|
|
33
33
|
agent_model_assignment_from_payload,
|
|
34
34
|
verify_agent_invocation,
|
|
35
35
|
)
|
|
36
|
+
from .final_report_paths import (
|
|
37
|
+
final_report_data_path,
|
|
38
|
+
final_report_markdown_path,
|
|
39
|
+
)
|
|
40
|
+
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
36
41
|
from .worker_prompt_contract import (
|
|
37
42
|
PromptRecord,
|
|
38
43
|
validate_initial_prompt_records,
|
|
@@ -504,9 +509,20 @@ def link_agent_dispatch_result(
|
|
|
504
509
|
item for item in links
|
|
505
510
|
if isinstance(item, Mapping) and item.get("resultPath") == result_relative
|
|
506
511
|
]
|
|
507
|
-
|
|
512
|
+
# A superseded link is the record of a result the lead rejected, kept so
|
|
513
|
+
# the chain shows the corrective round happened rather than hiding it.
|
|
514
|
+
# It no longer owns the path, so the re-dispatch may claim it.
|
|
515
|
+
live_conflicts = [
|
|
516
|
+
item for item in same_result
|
|
517
|
+
if not item.get("supersededBy") and dict(item) != link
|
|
518
|
+
]
|
|
519
|
+
if live_conflicts:
|
|
508
520
|
raise DispatchError(
|
|
509
|
-
f"agent result is already linked to another dispatch:
|
|
521
|
+
f"agent result is already linked to another dispatch: "
|
|
522
|
+
f"{result_relative}. If that result was rejected and re-dispatched, "
|
|
523
|
+
f"record the rejection with `okstra agent-prompt reject-result "
|
|
524
|
+
f"--dispatch-id {live_conflicts[0].get('dispatchId')} "
|
|
525
|
+
f"--superseded-by {dispatch_id}` first"
|
|
510
526
|
)
|
|
511
527
|
if any(
|
|
512
528
|
isinstance(item, Mapping)
|
|
@@ -523,6 +539,69 @@ def link_agent_dispatch_result(
|
|
|
523
539
|
return link
|
|
524
540
|
|
|
525
541
|
|
|
542
|
+
def reject_agent_dispatch_result(
|
|
543
|
+
*,
|
|
544
|
+
project_root: Path,
|
|
545
|
+
run_manifest_path: Path,
|
|
546
|
+
dispatch_id: str,
|
|
547
|
+
superseded_by: str,
|
|
548
|
+
reason: str,
|
|
549
|
+
) -> dict[str, str]:
|
|
550
|
+
"""Record that a linked result was rejected and re-dispatched.
|
|
551
|
+
|
|
552
|
+
The contract tells the lead to re-dispatch a worker whose answer violated the
|
|
553
|
+
response format, with a correction paragraph appended. That path did not
|
|
554
|
+
finish: the prompt is immutable and already dispatched, so
|
|
555
|
+
`--replace-undispatched` refuses (correctly), and a fresh invocation id
|
|
556
|
+
produces a worker that writes the same result path — where `link-result`
|
|
557
|
+
refused because the path already belonged to the first dispatch. The worker
|
|
558
|
+
ran, wrote a good result, and the ledger could not accept it.
|
|
559
|
+
|
|
560
|
+
Rejection is a fact worth recording rather than routing around, so it is
|
|
561
|
+
written rather than allowed implicitly: the first link stays in the ledger
|
|
562
|
+
marked `supersededBy` with the lead's reason, and only then may the
|
|
563
|
+
re-dispatch claim the path. Nothing is deleted, so the chain still shows both
|
|
564
|
+
attempts and why the second exists.
|
|
565
|
+
"""
|
|
566
|
+
if not superseded_by.strip():
|
|
567
|
+
raise DispatchError("superseding dispatch ID is required")
|
|
568
|
+
if not reason.strip():
|
|
569
|
+
raise DispatchError(
|
|
570
|
+
"a rejection reason is required — the ledger has to say why a "
|
|
571
|
+
"returned result was not accepted"
|
|
572
|
+
)
|
|
573
|
+
project_root = project_root.resolve()
|
|
574
|
+
manifest_path = resolve_project_path(
|
|
575
|
+
project_root, str(run_manifest_path)
|
|
576
|
+
).resolve(strict=True)
|
|
577
|
+
manifest = load_json_object(manifest_path, "run manifest")
|
|
578
|
+
team_state_path = resolve_required_path(project_root, manifest, "teamStatePath")
|
|
579
|
+
with _team_state_lock(team_state_path):
|
|
580
|
+
team_state = load_json_object(team_state_path, "team-state")
|
|
581
|
+
links = team_state.get("agentResultLinks")
|
|
582
|
+
if not isinstance(links, list):
|
|
583
|
+
raise DispatchError("team-state agentResultLinks must be an array")
|
|
584
|
+
matches = [
|
|
585
|
+
item for item in links
|
|
586
|
+
if isinstance(item, Mapping) and item.get("dispatchId") == dispatch_id
|
|
587
|
+
]
|
|
588
|
+
if len(matches) != 1:
|
|
589
|
+
raise DispatchError(
|
|
590
|
+
f"expected exactly one agent result link for {dispatch_id}, "
|
|
591
|
+
f"found {len(matches)}"
|
|
592
|
+
)
|
|
593
|
+
row = matches[0]
|
|
594
|
+
if row.get("supersededBy"):
|
|
595
|
+
raise DispatchError(
|
|
596
|
+
f"agent result link is already superseded by "
|
|
597
|
+
f"{row['supersededBy']}: {dispatch_id}"
|
|
598
|
+
)
|
|
599
|
+
row["supersededBy"] = superseded_by
|
|
600
|
+
row["rejectionReason"] = reason
|
|
601
|
+
write_json(team_state_path, team_state)
|
|
602
|
+
return dict(row)
|
|
603
|
+
|
|
604
|
+
|
|
526
605
|
def _relative_project_path(project_root: Path, path: Path) -> str:
|
|
527
606
|
try:
|
|
528
607
|
return path.resolve(strict=False).relative_to(project_root).as_posix()
|
|
@@ -644,6 +723,54 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
|
644
723
|
return tuple(path for path in job.completion_paths if not path.is_file())
|
|
645
724
|
|
|
646
725
|
|
|
726
|
+
def dispatch_result_path(
|
|
727
|
+
worker_id: str,
|
|
728
|
+
worker_result_path: Path,
|
|
729
|
+
manifest: Mapping[str, Any],
|
|
730
|
+
project_root: Path,
|
|
731
|
+
) -> Path:
|
|
732
|
+
"""What `resultPath` means for this worker.
|
|
733
|
+
|
|
734
|
+
For everyone but the report writer it is the worker-result file. The report
|
|
735
|
+
writer authors three artifacts and its canonical result is the final-report
|
|
736
|
+
data.json, not its own `.md` pointer — a distinction that is not cosmetic:
|
|
737
|
+
`dispatch_core` hands `job.result_path` to report finalization as the data
|
|
738
|
+
path, so a `.md` there is parsed as JSON and a complete report settles
|
|
739
|
+
`error`.
|
|
740
|
+
|
|
741
|
+
This lives beside `worker_jobs_from_file` because both job constructors must
|
|
742
|
+
apply it. It was `dispatch_core`-private while the roster path applied it and
|
|
743
|
+
the `--jobs-file` path took whatever the file said, which is how the two
|
|
744
|
+
produced different jobs for the same worker.
|
|
745
|
+
"""
|
|
746
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
747
|
+
return worker_result_path
|
|
748
|
+
return final_report_data_path(
|
|
749
|
+
resolve_required_path(project_root, manifest, "expectedReportPath")
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def dispatch_completion_paths(
|
|
754
|
+
worker_id: str,
|
|
755
|
+
worker_result_path: Path,
|
|
756
|
+
manifest: Mapping[str, Any],
|
|
757
|
+
project_root: Path,
|
|
758
|
+
) -> tuple[Path, ...]:
|
|
759
|
+
"""Every artifact that must exist before this worker counts as done.
|
|
760
|
+
|
|
761
|
+
The report writer's three are the data.json, its rendered Markdown sibling,
|
|
762
|
+
and the worker-result pointer (`prompts/lead/report-writer.md` §"Completion
|
|
763
|
+
detection"). Same reason as `dispatch_result_path`: both constructors need
|
|
764
|
+
the same answer.
|
|
765
|
+
"""
|
|
766
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
767
|
+
return (worker_result_path,)
|
|
768
|
+
data_json = final_report_data_path(
|
|
769
|
+
resolve_required_path(project_root, manifest, "expectedReportPath")
|
|
770
|
+
)
|
|
771
|
+
return (data_json, final_report_markdown_path(data_json), worker_result_path)
|
|
772
|
+
|
|
773
|
+
|
|
647
774
|
def validate_initial_prompts(
|
|
648
775
|
manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
|
|
649
776
|
) -> None:
|
|
@@ -798,6 +925,7 @@ def worker_jobs_from_file(
|
|
|
798
925
|
project_root: Path,
|
|
799
926
|
jobs_file: Path,
|
|
800
927
|
*,
|
|
928
|
+
manifest: Mapping[str, Any],
|
|
801
929
|
backend: str,
|
|
802
930
|
idle_timeout_seconds: int,
|
|
803
931
|
default_dispatch_kind: str,
|
|
@@ -816,6 +944,7 @@ def worker_jobs_from_file(
|
|
|
816
944
|
_worker_job_from_file(
|
|
817
945
|
project_root,
|
|
818
946
|
item,
|
|
947
|
+
manifest=manifest,
|
|
819
948
|
backend=backend,
|
|
820
949
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
821
950
|
dispatch_kind=dispatch_kind,
|
|
@@ -831,6 +960,7 @@ def _worker_job_from_file(
|
|
|
831
960
|
project_root: Path,
|
|
832
961
|
item: Mapping[str, Any],
|
|
833
962
|
*,
|
|
963
|
+
manifest: Mapping[str, Any],
|
|
834
964
|
backend: str,
|
|
835
965
|
idle_timeout_seconds: int,
|
|
836
966
|
dispatch_kind: str,
|
|
@@ -842,16 +972,20 @@ def _worker_job_from_file(
|
|
|
842
972
|
prompt_path = resolve_project_path(
|
|
843
973
|
project_root, require_string(item, "promptPath")
|
|
844
974
|
)
|
|
845
|
-
result_path = resolve_project_path(
|
|
846
|
-
project_root, require_string(item, "resultPath")
|
|
847
|
-
)
|
|
848
975
|
worker_result_path = resolve_project_path(
|
|
849
976
|
project_root, require_string(item, "workerResultPath")
|
|
850
977
|
)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
978
|
+
# The file's own `resultPath` / `completionPaths` are advisory: the same
|
|
979
|
+
# derivation the roster path applies decides them, so a jobs-file dispatch
|
|
980
|
+
# and a roster dispatch of one worker cannot disagree about which artifact
|
|
981
|
+
# is the result. A file that names the report writer's `.md` as its result
|
|
982
|
+
# used to reach report finalization as the data path.
|
|
983
|
+
result_path = dispatch_result_path(
|
|
984
|
+
worker_id, worker_result_path, manifest, project_root
|
|
985
|
+
)
|
|
986
|
+
completion_paths = dispatch_completion_paths(
|
|
987
|
+
worker_id, worker_result_path, manifest, project_root
|
|
988
|
+
)
|
|
855
989
|
digests = item.get("digests")
|
|
856
990
|
digest_values = digests if isinstance(digests, Mapping) else {}
|
|
857
991
|
host_model_value = item.get("hostModelValue")
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Iterable
|
|
10
10
|
|
|
11
11
|
from okstra_project import ResolverError, project_json_path, resolve_project_root
|
|
12
|
+
from okstra_project.resolver import resolve_review_rule_packs
|
|
12
13
|
|
|
13
14
|
from . import improvement_lenses, worktree_registry
|
|
14
15
|
from .models import provider_wrappers
|
|
@@ -99,6 +100,9 @@ def _project_checks(cwd: Path) -> tuple[Path | None, list[DoctorCheck]]:
|
|
|
99
100
|
architecture = _architecture_declaration_check(project_root)
|
|
100
101
|
if architecture is not None:
|
|
101
102
|
checks.append(architecture)
|
|
103
|
+
review_packs = _review_rule_pack_check(project_root)
|
|
104
|
+
if review_packs is not None:
|
|
105
|
+
checks.append(review_packs)
|
|
102
106
|
return project_root, checks
|
|
103
107
|
|
|
104
108
|
|
|
@@ -158,6 +162,33 @@ def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
|
|
|
158
162
|
)
|
|
159
163
|
|
|
160
164
|
|
|
165
|
+
def _review_rule_pack_check(project_root: Path) -> DoctorCheck | None:
|
|
166
|
+
"""Report a declared review rule pack that no worker will be able to open.
|
|
167
|
+
|
|
168
|
+
`reviewRulePacks` is what makes a project's own review standard apply
|
|
169
|
+
without every brief citing it, so a stale path costs the whole pack in
|
|
170
|
+
silence: the phase records `project-review-rules: declared <path>
|
|
171
|
+
unreadable` at best, and a run that reviewed against nothing still passes.
|
|
172
|
+
Absent declaration stays quiet — brief-cited packs remain the other, equally
|
|
173
|
+
valid, channel.
|
|
174
|
+
|
|
175
|
+
What this cannot check is whether a pack that does resolve was actually read
|
|
176
|
+
and applied; that stays the worker's own `project-review-rules:` record.
|
|
177
|
+
"""
|
|
178
|
+
declared = resolve_review_rule_packs(project_root)
|
|
179
|
+
if not declared:
|
|
180
|
+
return None
|
|
181
|
+
missing = [path for path in declared if not Path(path).is_file()]
|
|
182
|
+
if not missing:
|
|
183
|
+
return _ok("review rule packs", f"{len(declared)} declared, all readable")
|
|
184
|
+
return _fail(
|
|
185
|
+
"review rule packs",
|
|
186
|
+
f"declared but not readable: {', '.join(missing)} — phases skip a pack "
|
|
187
|
+
"they cannot open, so the run reviews against fewer rules than declared. "
|
|
188
|
+
f"Fix the path in {project_json_path(project_root)} or drop the entry.",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
161
192
|
def _profile_check(workspace: Path, phase: str) -> DoctorCheck:
|
|
162
193
|
profile = _profile_path(workspace, phase)
|
|
163
194
|
if profile.is_file():
|