okstra 0.144.0 → 0.146.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/README.md +4 -1
- package/docs/architecture.md +22 -4
- package/docs/cli.md +53 -6
- package/docs/project-structure-overview.md +19 -9
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +28 -2
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +30 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
- package/runtime/prompts/profiles/feature-analysis.md +24 -0
- package/runtime/prompts/profiles/forbidden-actions.json +18 -0
- package/runtime/prompts/profiles/project-analysis.md +24 -0
- package/runtime/prompts/wizard/prompts.ko.json +44 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +79 -4
- package/runtime/python/okstra_ctl/render_final_report.py +13 -4
- package/runtime/python/okstra_ctl/report_views.py +134 -3
- package/runtime/python/okstra_ctl/run.py +118 -0
- package/runtime/python/okstra_ctl/run_context.py +34 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
- package/runtime/python/okstra_ctl/user_response.py +309 -3
- package/runtime/python/okstra_ctl/wizard.py +579 -32
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +27 -5
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
- package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
- package/runtime/templates/reports/final-report.template.md +220 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/project-analysis-input.template.md +58 -0
- package/runtime/templates/reports/report.js +84 -5
- package/runtime/templates/reports/user-response.template.md +19 -1
- package/runtime/validators/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +94 -1
- package/runtime/validators/validate_analysis_report.py +895 -0
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/render-bundle.mjs +3 -0
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- package/src/commands/execute/phase-cleanup.mjs +0 -38
|
@@ -43,10 +43,12 @@ from .dispatch_state import (
|
|
|
43
43
|
resolve_project_path as _resolve_project_path,
|
|
44
44
|
resolve_required_path as _resolve_required_path,
|
|
45
45
|
set_dispatch_mode as _set_dispatch_mode,
|
|
46
|
-
set_worker_status as _set_worker_status,
|
|
47
46
|
string_list as _string_list,
|
|
48
47
|
string_value as _string_value,
|
|
48
|
+
transition_worker_status as _transition_worker_status,
|
|
49
49
|
utc_now as _utc_now,
|
|
50
|
+
validate_dispatch_prompts as _validate_dispatch_prompts,
|
|
51
|
+
worker_jobs_from_file as _worker_jobs_from_file,
|
|
50
52
|
worker_state as _worker_state,
|
|
51
53
|
worktree_path as _worktree_path,
|
|
52
54
|
write_json as _write_json,
|
|
@@ -120,33 +122,46 @@ def build_dispatch_plan(
|
|
|
120
122
|
idle_timeout_seconds: int = 600,
|
|
121
123
|
enable_codex_report_writer: bool = False,
|
|
122
124
|
report_writer_codex_model: str = "",
|
|
125
|
+
dispatch_kind: str = "initial",
|
|
126
|
+
jobs_file: Path | None = None,
|
|
123
127
|
) -> DispatchPlan:
|
|
124
128
|
inputs = _load_dispatch_inputs(
|
|
125
129
|
project_root,
|
|
126
130
|
run_manifest_path,
|
|
127
131
|
requested_workers,
|
|
128
132
|
enable_codex_report_writer=enable_codex_report_writer,
|
|
133
|
+
select_roster=jobs_file is None,
|
|
129
134
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
if jobs_file is not None:
|
|
136
|
+
workers = _codex_jobs_from_file(
|
|
137
|
+
inputs,
|
|
138
|
+
jobs_file,
|
|
139
|
+
workspace_root=workspace_root,
|
|
140
|
+
okstra_bin=okstra_bin,
|
|
141
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
142
|
+
dispatch_kind=dispatch_kind,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
worker_requests, prompt_paths = _materialize_selected_worker_prompts(
|
|
146
|
+
project_root=inputs.project_root,
|
|
147
|
+
manifest_path=inputs.manifest_path,
|
|
148
|
+
workspace_root=workspace_root,
|
|
149
|
+
team_state=inputs.team_state,
|
|
150
|
+
selected_workers=inputs.selected_workers,
|
|
151
|
+
report_writer_codex_model=report_writer_codex_model,
|
|
152
|
+
)
|
|
153
|
+
workers = _build_selected_worker_dispatches(
|
|
154
|
+
project_root=inputs.project_root,
|
|
155
|
+
manifest=inputs.manifest,
|
|
156
|
+
team_state=inputs.team_state,
|
|
157
|
+
active_context=inputs.active_context,
|
|
158
|
+
workspace_root=workspace_root,
|
|
159
|
+
okstra_bin=okstra_bin,
|
|
160
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
161
|
+
selected_workers=inputs.selected_workers,
|
|
162
|
+
worker_requests=worker_requests,
|
|
163
|
+
prompt_paths=prompt_paths,
|
|
164
|
+
)
|
|
150
165
|
return DispatchPlan(
|
|
151
166
|
project_root=inputs.project_root,
|
|
152
167
|
workspace_root=workspace_root,
|
|
@@ -158,36 +173,68 @@ def build_dispatch_plan(
|
|
|
158
173
|
)
|
|
159
174
|
|
|
160
175
|
|
|
176
|
+
def _codex_jobs_from_file(
|
|
177
|
+
inputs: _DispatchInputs,
|
|
178
|
+
jobs_file: Path,
|
|
179
|
+
*,
|
|
180
|
+
workspace_root: Path,
|
|
181
|
+
okstra_bin: Path | None,
|
|
182
|
+
idle_timeout_seconds: int,
|
|
183
|
+
dispatch_kind: str,
|
|
184
|
+
) -> list[WorkerJob]:
|
|
185
|
+
workers = _worker_jobs_from_file(
|
|
186
|
+
inputs.project_root,
|
|
187
|
+
jobs_file,
|
|
188
|
+
backend=BACKEND_CLI_WRAPPER,
|
|
189
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
190
|
+
default_dispatch_kind=dispatch_kind,
|
|
191
|
+
resolve_wrapper=lambda provider: _resolve_wrapper(
|
|
192
|
+
provider, workspace_root, okstra_bin
|
|
193
|
+
),
|
|
194
|
+
default_provider=lambda worker_id: (
|
|
195
|
+
"codex" if worker_id == REPORT_WRITER_WORKER_ID else worker_id
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
_validate_dispatch_prompts(inputs.manifest, inputs.active_context, workers)
|
|
199
|
+
return workers
|
|
200
|
+
|
|
201
|
+
|
|
161
202
|
def _load_dispatch_inputs(
|
|
162
203
|
project_root: Path,
|
|
163
204
|
run_manifest_path: Path,
|
|
164
205
|
requested_workers: Sequence[str],
|
|
165
206
|
*,
|
|
166
207
|
enable_codex_report_writer: bool,
|
|
208
|
+
select_roster: bool = True,
|
|
167
209
|
) -> _DispatchInputs:
|
|
168
210
|
project_root = project_root.resolve()
|
|
169
211
|
manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
|
|
170
212
|
manifest = _load_json_object(manifest_path, "run manifest")
|
|
171
213
|
_validate_codex_manifest(manifest, manifest_path)
|
|
172
|
-
selected_workers =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
214
|
+
selected_workers = (
|
|
215
|
+
tuple(
|
|
216
|
+
_select_workers(
|
|
217
|
+
manifest,
|
|
218
|
+
requested_workers,
|
|
219
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
220
|
+
)
|
|
177
221
|
)
|
|
222
|
+
if select_roster
|
|
223
|
+
else ()
|
|
178
224
|
)
|
|
179
225
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
180
226
|
team_state = _load_json_object(team_state_path, "team-state")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
227
|
+
if select_roster:
|
|
228
|
+
_mark_skipped_workers(
|
|
229
|
+
team_state_path,
|
|
230
|
+
team_state,
|
|
231
|
+
_skipped_worker_reasons(
|
|
232
|
+
manifest,
|
|
233
|
+
selected_workers,
|
|
234
|
+
requested_workers,
|
|
235
|
+
enable_codex_report_writer=enable_codex_report_writer,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
191
238
|
active_context = _load_optional_json_object(
|
|
192
239
|
_resolve_optional_path(project_root, manifest.get("activeRunContextPath"))
|
|
193
240
|
)
|
|
@@ -276,13 +323,6 @@ def _build_selected_worker_dispatches(
|
|
|
276
323
|
def dispatch_plan(plan: DispatchPlan) -> int:
|
|
277
324
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
|
|
278
325
|
for worker in plan.workers:
|
|
279
|
-
_set_worker_status(
|
|
280
|
-
plan.team_state_path,
|
|
281
|
-
worker.worker_id,
|
|
282
|
-
"running",
|
|
283
|
-
"",
|
|
284
|
-
model_execution_value=worker.model_execution_value,
|
|
285
|
-
)
|
|
286
326
|
completed = _dispatch_worker_with_retry(plan, worker)
|
|
287
327
|
if completed == 0:
|
|
288
328
|
continue
|
|
@@ -292,6 +332,13 @@ def dispatch_plan(plan: DispatchPlan) -> int:
|
|
|
292
332
|
|
|
293
333
|
def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
294
334
|
for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
|
|
335
|
+
_transition_worker_status(
|
|
336
|
+
plan.team_state_path,
|
|
337
|
+
worker.worker_id,
|
|
338
|
+
"in-progress",
|
|
339
|
+
"",
|
|
340
|
+
model_execution_value=worker.model_execution_value,
|
|
341
|
+
)
|
|
295
342
|
_record_worker_attempt(plan, worker, attempt, "running")
|
|
296
343
|
attempt_details = _attempt_event_details(worker, attempt)
|
|
297
344
|
_append_event(plan, "worker-dispatched", attempt_details)
|
|
@@ -301,7 +348,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
301
348
|
post_process = _post_process_report_writer_result(plan, worker)
|
|
302
349
|
if not post_process["ok"]:
|
|
303
350
|
reason = _require_string(post_process, "reason")
|
|
304
|
-
|
|
351
|
+
_transition_worker_status(
|
|
352
|
+
plan.team_state_path, worker.worker_id, "error", reason
|
|
353
|
+
)
|
|
305
354
|
_record_worker_attempt(plan, worker, attempt, "error", reason)
|
|
306
355
|
details = {
|
|
307
356
|
**attempt_details,
|
|
@@ -312,7 +361,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
312
361
|
}
|
|
313
362
|
_append_event(plan, "worker-failed", details)
|
|
314
363
|
return 1
|
|
315
|
-
|
|
364
|
+
_transition_worker_status(
|
|
365
|
+
plan.team_state_path, worker.worker_id, "completed", ""
|
|
366
|
+
)
|
|
316
367
|
_record_worker_attempt(plan, worker, attempt, "completed")
|
|
317
368
|
details = {
|
|
318
369
|
**attempt_details,
|
|
@@ -338,7 +389,9 @@ def _dispatch_worker_with_retry(plan: DispatchPlan, worker: WorkerJob) -> int:
|
|
|
338
389
|
continue
|
|
339
390
|
|
|
340
391
|
reason = _failure_reason(result.returncode, missing_paths)
|
|
341
|
-
|
|
392
|
+
_transition_worker_status(
|
|
393
|
+
plan.team_state_path, worker.worker_id, "error", reason
|
|
394
|
+
)
|
|
342
395
|
_record_worker_attempt(plan, worker, attempt, "error", reason)
|
|
343
396
|
details = {
|
|
344
397
|
**attempt_details,
|
|
@@ -378,7 +431,7 @@ def _matches_worker_attempt(
|
|
|
378
431
|
return (
|
|
379
432
|
isinstance(record, dict)
|
|
380
433
|
and record.get("workerId") == worker.worker_id
|
|
381
|
-
and record.get("kind") ==
|
|
434
|
+
and record.get("kind") == worker.dispatch_kind
|
|
382
435
|
and record.get("attempt") == attempt
|
|
383
436
|
)
|
|
384
437
|
|
|
@@ -386,14 +439,13 @@ def _matches_worker_attempt(
|
|
|
386
439
|
def _worker_dispatch_record(
|
|
387
440
|
worker: WorkerJob, attempt: int, status: str, reason: str
|
|
388
441
|
) -> dict[str, Any]:
|
|
389
|
-
provider = "codex" if worker.worker_id == REPORT_WRITER_WORKER_ID else worker.worker_id
|
|
390
442
|
return {
|
|
391
443
|
"workerId": worker.worker_id,
|
|
392
444
|
"role": worker.role,
|
|
393
|
-
"kind":
|
|
445
|
+
"kind": worker.dispatch_kind,
|
|
394
446
|
"attempt": attempt,
|
|
395
447
|
"backendType": worker.backend,
|
|
396
|
-
"provider": provider,
|
|
448
|
+
"provider": worker.provider,
|
|
397
449
|
"status": status,
|
|
398
450
|
"paneId": "",
|
|
399
451
|
"promptPath": str(worker.prompt_path),
|
|
@@ -410,6 +462,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
410
462
|
parser = _parser()
|
|
411
463
|
args = parser.parse_args(argv)
|
|
412
464
|
try:
|
|
465
|
+
if args.jobs_file and args.workers:
|
|
466
|
+
raise DispatchError("--jobs-file and --workers cannot be combined")
|
|
413
467
|
idle_timeout_seconds = _parse_idle_timeout(args.idle_timeout_seconds)
|
|
414
468
|
plan = build_dispatch_plan(
|
|
415
469
|
project_root=Path(args.project_root),
|
|
@@ -420,6 +474,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
420
474
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
421
475
|
enable_codex_report_writer=args.enable_codex_report_writer,
|
|
422
476
|
report_writer_codex_model=args.report_writer_codex_model,
|
|
477
|
+
dispatch_kind=args.dispatch_kind,
|
|
478
|
+
jobs_file=Path(args.jobs_file) if args.jobs_file else None,
|
|
423
479
|
)
|
|
424
480
|
if args.dry_run:
|
|
425
481
|
_print_json(plan.to_payload(dry_run=True))
|
|
@@ -446,6 +502,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
446
502
|
parser.add_argument("--idle-timeout-seconds", default="600")
|
|
447
503
|
parser.add_argument("--enable-codex-report-writer", action="store_true")
|
|
448
504
|
parser.add_argument("--report-writer-codex-model", default="")
|
|
505
|
+
parser.add_argument("--dispatch-kind", default="initial")
|
|
506
|
+
parser.add_argument("--jobs-file", default="")
|
|
449
507
|
return parser
|
|
450
508
|
|
|
451
509
|
|
|
@@ -672,7 +730,6 @@ def _mark_skipped_workers(
|
|
|
672
730
|
workers = team_state.get("workers")
|
|
673
731
|
if not isinstance(workers, list):
|
|
674
732
|
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
675
|
-
changed = False
|
|
676
733
|
for worker in workers:
|
|
677
734
|
if not isinstance(worker, dict):
|
|
678
735
|
continue
|
|
@@ -683,11 +740,9 @@ def _mark_skipped_workers(
|
|
|
683
740
|
status = _string_value(worker.get("status")) or "not-run"
|
|
684
741
|
current_reason = _string_value(worker.get("reason"))
|
|
685
742
|
if status == "not-run" and not current_reason:
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if changed:
|
|
690
|
-
_write_json(team_state_path, dict(team_state))
|
|
743
|
+
_transition_worker_status(
|
|
744
|
+
team_state_path, worker_id, "not-run", reason
|
|
745
|
+
)
|
|
691
746
|
|
|
692
747
|
|
|
693
748
|
def _resolve_wrapper(
|
|
@@ -695,7 +750,9 @@ def _resolve_wrapper(
|
|
|
695
750
|
workspace_root: Path,
|
|
696
751
|
okstra_bin: Path | None,
|
|
697
752
|
) -> Path:
|
|
698
|
-
script = SUPPORTED_CLI_WORKERS
|
|
753
|
+
script = SUPPORTED_CLI_WORKERS.get(worker_id)
|
|
754
|
+
if script is None:
|
|
755
|
+
raise DispatchError(f"unsupported worker provider: {worker_id}")
|
|
699
756
|
candidates = []
|
|
700
757
|
if okstra_bin is not None:
|
|
701
758
|
candidates.append(okstra_bin / script)
|
|
@@ -759,7 +816,9 @@ def _post_process_report_writer_result(
|
|
|
759
816
|
# validate-run reads team-state; the writer is done by the time its
|
|
760
817
|
# artifacts pass the earlier steps.
|
|
761
818
|
if step_name == STEP_VALIDATE_RUN:
|
|
762
|
-
|
|
819
|
+
_transition_worker_status(
|
|
820
|
+
plan.team_state_path, worker.worker_id, "completed", ""
|
|
821
|
+
)
|
|
763
822
|
|
|
764
823
|
return run_finalize(ctx, before_step=settle_before)
|
|
765
824
|
|
|
@@ -1946,7 +1946,9 @@ def _parse_group(
|
|
|
1946
1946
|
"findingId": _required_string(group, "findingId", label),
|
|
1947
1947
|
"summary": _required_string(group, "summary", label),
|
|
1948
1948
|
"category": _required_string(group, "category", label),
|
|
1949
|
-
"ticketIds":
|
|
1949
|
+
"ticketIds": _string_array_allow_empty(
|
|
1950
|
+
group.get("ticketIds"), f"{label}.ticketIds"
|
|
1951
|
+
),
|
|
1950
1952
|
"originWorker": origin,
|
|
1951
1953
|
"originEvidence": origin_evidence,
|
|
1952
1954
|
"discoveredBy": discovered_by,
|
|
@@ -24,11 +24,12 @@ from .dispatch_state import (
|
|
|
24
24
|
resolve_project_path as _resolve_project_path,
|
|
25
25
|
resolve_required_path as _resolve_required_path,
|
|
26
26
|
set_dispatch_mode as _set_dispatch_mode,
|
|
27
|
-
|
|
27
|
+
transition_worker_status as _transition_worker_status,
|
|
28
28
|
string_list as _string_list,
|
|
29
29
|
string_value as _string_value,
|
|
30
30
|
utc_now as _utc_now,
|
|
31
|
-
|
|
31
|
+
validate_dispatch_prompts as _validate_dispatch_prompts,
|
|
32
|
+
worker_jobs_from_file as _worker_jobs_from_file,
|
|
32
33
|
worker_state as _worker_state,
|
|
33
34
|
worktree_path as _worktree_path,
|
|
34
35
|
write_json as _write_json,
|
|
@@ -160,7 +161,7 @@ def build_dispatch_plan(
|
|
|
160
161
|
)
|
|
161
162
|
if jobs_file:
|
|
162
163
|
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
|
|
163
|
-
|
|
164
|
+
_validate_dispatch_prompts(manifest, active_context, jobs)
|
|
164
165
|
else:
|
|
165
166
|
jobs = _jobs_from_roster(
|
|
166
167
|
project_root,
|
|
@@ -389,55 +390,22 @@ def _jobs_from_file(
|
|
|
389
390
|
) -> list[WorkerJob]:
|
|
390
391
|
if jobs_file is None:
|
|
391
392
|
return []
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if not isinstance(workers, list):
|
|
396
|
-
raise DispatchError("jobs file workers must be an array")
|
|
397
|
-
return [
|
|
398
|
-
_job_from_file_worker(project_root, workspace_root, item, options, dispatch_kind)
|
|
399
|
-
for item in workers
|
|
400
|
-
if isinstance(item, dict)
|
|
401
|
-
]
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def _job_from_file_worker(
|
|
405
|
-
project_root: Path,
|
|
406
|
-
workspace_root: Path,
|
|
407
|
-
item: Mapping[str, Any],
|
|
408
|
-
options: _BuildOptions,
|
|
409
|
-
dispatch_kind: str,
|
|
410
|
-
) -> WorkerJob:
|
|
411
|
-
worker_id = _require_string(item, "workerId")
|
|
412
|
-
provider = _provider_for_job(item, worker_id)
|
|
413
|
-
prompt_path = _resolve_project_path(project_root, _require_string(item, "promptPath"))
|
|
414
|
-
result_path = _resolve_project_path(project_root, _require_string(item, "resultPath"))
|
|
415
|
-
worker_result_path = _resolve_project_path(project_root, _require_string(item, "workerResultPath"))
|
|
416
|
-
completion_paths = tuple(
|
|
417
|
-
_resolve_project_path(project_root, path)
|
|
418
|
-
for path in _string_list(item.get("completionPaths"))
|
|
419
|
-
) or (result_path,)
|
|
420
|
-
return WorkerJob(
|
|
421
|
-
worker_id=worker_id,
|
|
422
|
-
provider=provider,
|
|
393
|
+
return _worker_jobs_from_file(
|
|
394
|
+
project_root,
|
|
395
|
+
jobs_file,
|
|
423
396
|
backend=options.default_backend,
|
|
424
|
-
project_root=project_root,
|
|
425
|
-
model_execution_value=_require_string(item, "modelExecutionValue"),
|
|
426
|
-
wrapper_path=_resolve_wrapper(provider, workspace_root, options),
|
|
427
|
-
prompt_path=prompt_path,
|
|
428
|
-
result_path=result_path,
|
|
429
|
-
worker_result_path=worker_result_path,
|
|
430
|
-
completion_paths=completion_paths,
|
|
431
|
-
worktree_path=_string_value(item.get("worktreePath")),
|
|
432
|
-
role=_require_string(item, "role"),
|
|
433
397
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
434
|
-
|
|
398
|
+
default_dispatch_kind=options.dispatch_kind,
|
|
399
|
+
resolve_wrapper=lambda provider: _resolve_wrapper(
|
|
400
|
+
provider, workspace_root, options
|
|
401
|
+
),
|
|
402
|
+
default_provider=_provider_for_worker,
|
|
435
403
|
)
|
|
436
404
|
|
|
437
405
|
|
|
438
406
|
def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle:
|
|
439
|
-
|
|
440
|
-
plan.team_state_path, job.worker_id, "
|
|
407
|
+
_transition_worker_status(
|
|
408
|
+
plan.team_state_path, job.worker_id, "in-progress", "",
|
|
441
409
|
model_execution_value=job.model_execution_value,
|
|
442
410
|
)
|
|
443
411
|
handle = _start_job(plan, job)
|
|
@@ -544,13 +512,15 @@ def _retry_from_record(
|
|
|
544
512
|
|
|
545
513
|
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
546
514
|
if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
|
|
547
|
-
|
|
515
|
+
_transition_worker_status(
|
|
516
|
+
plan.team_state_path, job.worker_id, "completed", ""
|
|
517
|
+
)
|
|
548
518
|
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, "completed", "")
|
|
549
519
|
_append_event(plan, "worker-result-collected", _result_details(job, attempt, outcome))
|
|
550
520
|
return
|
|
551
521
|
reason = _failure_reason(outcome)
|
|
552
522
|
status = "timeout" if outcome.timeout else "error"
|
|
553
|
-
|
|
523
|
+
_transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
|
|
554
524
|
_update_dispatch_status(plan.team_state_path, job.worker_id, job.dispatch_kind, attempt, status, reason)
|
|
555
525
|
_append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
|
|
556
526
|
|
|
@@ -643,7 +613,7 @@ def _mark_roster_skips(
|
|
|
643
613
|
return
|
|
644
614
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
645
615
|
for worker_id, reason in reasons.items():
|
|
646
|
-
|
|
616
|
+
_transition_worker_status(team_state_path, worker_id, "not-run", reason)
|
|
647
617
|
|
|
648
618
|
|
|
649
619
|
def _skip_reasons(
|
|
@@ -801,13 +771,6 @@ def _provider_for_worker(worker_id: str) -> str:
|
|
|
801
771
|
return worker_id
|
|
802
772
|
|
|
803
773
|
|
|
804
|
-
def _provider_for_job(item: Mapping[str, Any], worker_id: str) -> str:
|
|
805
|
-
provider = _string_value(item.get("provider"))
|
|
806
|
-
if provider:
|
|
807
|
-
return provider
|
|
808
|
-
return _provider_for_worker(worker_id)
|
|
809
|
-
|
|
810
|
-
|
|
811
774
|
def _model_for_worker(worker_id: str, state: Mapping[str, Any], report_writer_model: str) -> str:
|
|
812
775
|
if worker_id == REPORT_WRITER_WORKER_ID and report_writer_model.strip():
|
|
813
776
|
return report_writer_model.strip()
|
|
@@ -22,9 +22,13 @@ import os
|
|
|
22
22
|
from dataclasses import dataclass
|
|
23
23
|
from datetime import datetime, timezone
|
|
24
24
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Mapping, Sequence
|
|
25
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
26
26
|
|
|
27
|
-
from .worker_prompt_contract import
|
|
27
|
+
from .worker_prompt_contract import (
|
|
28
|
+
PromptRecord,
|
|
29
|
+
validate_initial_prompt_records,
|
|
30
|
+
validate_reverify_prompt,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
30
34
|
BACKEND_TMUX_PANE = "tmux-pane"
|
|
@@ -35,6 +39,10 @@ BACKEND_MIXED = "mixed"
|
|
|
35
39
|
# analysis phases write their artifacts under project-root (see the contract in
|
|
36
40
|
# scripts/okstra-codex-exec.sh).
|
|
37
41
|
WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
|
|
42
|
+
WORKER_STATUSES = frozenset(
|
|
43
|
+
{"in-progress", "completed", "timeout", "error", "not-run"}
|
|
44
|
+
)
|
|
45
|
+
REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
|
|
38
46
|
|
|
39
47
|
|
|
40
48
|
class DispatchError(Exception):
|
|
@@ -171,14 +179,24 @@ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str,
|
|
|
171
179
|
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
172
180
|
|
|
173
181
|
|
|
174
|
-
def
|
|
182
|
+
def transition_worker_status(
|
|
175
183
|
team_state_path: Path,
|
|
176
184
|
worker_id: str,
|
|
177
185
|
status: str,
|
|
178
186
|
reason: str,
|
|
179
187
|
*,
|
|
180
188
|
model_execution_value: str = "",
|
|
189
|
+
at: str | datetime | None = None,
|
|
181
190
|
) -> None:
|
|
191
|
+
if status not in WORKER_STATUSES:
|
|
192
|
+
allowed = ", ".join(sorted(WORKER_STATUSES))
|
|
193
|
+
raise DispatchError(
|
|
194
|
+
f"unsupported worker status `{status}`; expected one of: {allowed}"
|
|
195
|
+
)
|
|
196
|
+
reason = reason.strip()
|
|
197
|
+
if status in REASON_REQUIRED_STATUSES and not reason:
|
|
198
|
+
raise DispatchError(f"worker status `{status}` requires a non-empty reason")
|
|
199
|
+
timestamp = _utc_timestamp(at)
|
|
182
200
|
payload = load_json_object(team_state_path, "team-state")
|
|
183
201
|
workers = payload.get("workers")
|
|
184
202
|
if not isinstance(workers, list):
|
|
@@ -187,6 +205,14 @@ def set_worker_status(
|
|
|
187
205
|
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
188
206
|
worker["status"] = status
|
|
189
207
|
worker["reason"] = reason
|
|
208
|
+
if status == "in-progress":
|
|
209
|
+
worker["startedAt"] = timestamp
|
|
210
|
+
worker.pop("endedAt", None)
|
|
211
|
+
elif status == "not-run":
|
|
212
|
+
worker.pop("startedAt", None)
|
|
213
|
+
worker.pop("endedAt", None)
|
|
214
|
+
else:
|
|
215
|
+
worker["endedAt"] = timestamp
|
|
190
216
|
if model_execution_value:
|
|
191
217
|
worker["model"] = model_execution_value
|
|
192
218
|
worker["modelExecutionValue"] = model_execution_value
|
|
@@ -195,6 +221,23 @@ def set_worker_status(
|
|
|
195
221
|
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
196
222
|
|
|
197
223
|
|
|
224
|
+
def _utc_timestamp(value: str | datetime | None) -> str:
|
|
225
|
+
if value is None:
|
|
226
|
+
instant = datetime.now(timezone.utc)
|
|
227
|
+
elif isinstance(value, datetime):
|
|
228
|
+
instant = value
|
|
229
|
+
elif isinstance(value, str):
|
|
230
|
+
try:
|
|
231
|
+
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
232
|
+
except ValueError as exc:
|
|
233
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`") from exc
|
|
234
|
+
else:
|
|
235
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`")
|
|
236
|
+
if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
|
|
237
|
+
raise DispatchError(f"invalid UTC timestamp `{value}`")
|
|
238
|
+
return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
239
|
+
|
|
240
|
+
|
|
198
241
|
def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
199
242
|
payload = load_json_object(team_state_path, "team-state")
|
|
200
243
|
payload["dispatchMode"] = dispatch_mode
|
|
@@ -227,5 +270,126 @@ def validate_initial_prompts(
|
|
|
227
270
|
raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
|
|
228
271
|
|
|
229
272
|
|
|
273
|
+
def validate_dispatch_prompts(
|
|
274
|
+
manifest: Mapping[str, Any],
|
|
275
|
+
active_context: Mapping[str, Any],
|
|
276
|
+
jobs: Sequence[WorkerJob],
|
|
277
|
+
) -> None:
|
|
278
|
+
initial_jobs = [
|
|
279
|
+
job for job in jobs if not job.dispatch_kind.startswith("reverify-r")
|
|
280
|
+
]
|
|
281
|
+
if initial_jobs:
|
|
282
|
+
validate_initial_prompts(manifest, initial_jobs)
|
|
283
|
+
|
|
284
|
+
reverify_jobs = [
|
|
285
|
+
job for job in jobs if job.dispatch_kind.startswith("reverify-r")
|
|
286
|
+
]
|
|
287
|
+
if not reverify_jobs:
|
|
288
|
+
return
|
|
289
|
+
task_type = require_string(manifest, "taskType")
|
|
290
|
+
workflow = active_context.get("workflow")
|
|
291
|
+
if not isinstance(workflow, Mapping):
|
|
292
|
+
raise DispatchError("reverify prompt contract: active run workflow is missing")
|
|
293
|
+
forbidden_actions = string_value(workflow.get("forbiddenActions"))
|
|
294
|
+
if not forbidden_actions:
|
|
295
|
+
raise DispatchError(
|
|
296
|
+
"reverify prompt contract: workflow.forbiddenActions is missing"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
errors: list[str] = []
|
|
300
|
+
for job in reverify_jobs:
|
|
301
|
+
try:
|
|
302
|
+
text = job.prompt_path.read_text(encoding="utf-8")
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
errors.append(f"{job.worker_id}: cannot read prompt {job.prompt_path}: {exc}")
|
|
305
|
+
continue
|
|
306
|
+
errors.extend(
|
|
307
|
+
f"{job.worker_id}: {error}"
|
|
308
|
+
for error in validate_reverify_prompt(
|
|
309
|
+
text,
|
|
310
|
+
task_type=task_type,
|
|
311
|
+
forbidden_actions=forbidden_actions,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if errors:
|
|
315
|
+
raise DispatchError("reverify prompt contract: " + "; ".join(errors))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def worker_jobs_from_file(
|
|
319
|
+
project_root: Path,
|
|
320
|
+
jobs_file: Path,
|
|
321
|
+
*,
|
|
322
|
+
backend: str,
|
|
323
|
+
idle_timeout_seconds: int,
|
|
324
|
+
default_dispatch_kind: str,
|
|
325
|
+
resolve_wrapper: Callable[[str], Path],
|
|
326
|
+
default_provider: Callable[[str], str],
|
|
327
|
+
) -> list[WorkerJob]:
|
|
328
|
+
payload = load_json_object(
|
|
329
|
+
resolve_project_path(project_root, str(jobs_file)),
|
|
330
|
+
"jobs file",
|
|
331
|
+
)
|
|
332
|
+
dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
|
|
333
|
+
workers = payload.get("workers")
|
|
334
|
+
if not isinstance(workers, list):
|
|
335
|
+
raise DispatchError("jobs file workers must be an array")
|
|
336
|
+
return [
|
|
337
|
+
_worker_job_from_file(
|
|
338
|
+
project_root,
|
|
339
|
+
item,
|
|
340
|
+
backend=backend,
|
|
341
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
342
|
+
dispatch_kind=dispatch_kind,
|
|
343
|
+
resolve_wrapper=resolve_wrapper,
|
|
344
|
+
default_provider=default_provider,
|
|
345
|
+
)
|
|
346
|
+
for item in workers
|
|
347
|
+
if isinstance(item, Mapping)
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _worker_job_from_file(
|
|
352
|
+
project_root: Path,
|
|
353
|
+
item: Mapping[str, Any],
|
|
354
|
+
*,
|
|
355
|
+
backend: str,
|
|
356
|
+
idle_timeout_seconds: int,
|
|
357
|
+
dispatch_kind: str,
|
|
358
|
+
resolve_wrapper: Callable[[str], Path],
|
|
359
|
+
default_provider: Callable[[str], str],
|
|
360
|
+
) -> WorkerJob:
|
|
361
|
+
worker_id = require_string(item, "workerId")
|
|
362
|
+
provider = string_value(item.get("provider")) or default_provider(worker_id)
|
|
363
|
+
prompt_path = resolve_project_path(
|
|
364
|
+
project_root, require_string(item, "promptPath")
|
|
365
|
+
)
|
|
366
|
+
result_path = resolve_project_path(
|
|
367
|
+
project_root, require_string(item, "resultPath")
|
|
368
|
+
)
|
|
369
|
+
worker_result_path = resolve_project_path(
|
|
370
|
+
project_root, require_string(item, "workerResultPath")
|
|
371
|
+
)
|
|
372
|
+
completion_paths = tuple(
|
|
373
|
+
resolve_project_path(project_root, path)
|
|
374
|
+
for path in string_list(item.get("completionPaths"))
|
|
375
|
+
) or (result_path,)
|
|
376
|
+
return WorkerJob(
|
|
377
|
+
worker_id=worker_id,
|
|
378
|
+
provider=provider,
|
|
379
|
+
backend=backend,
|
|
380
|
+
project_root=project_root,
|
|
381
|
+
model_execution_value=require_string(item, "modelExecutionValue"),
|
|
382
|
+
wrapper_path=resolve_wrapper(provider),
|
|
383
|
+
prompt_path=prompt_path,
|
|
384
|
+
result_path=result_path,
|
|
385
|
+
worker_result_path=worker_result_path,
|
|
386
|
+
completion_paths=completion_paths,
|
|
387
|
+
worktree_path=string_value(item.get("worktreePath")),
|
|
388
|
+
role=require_string(item, "role"),
|
|
389
|
+
idle_timeout_seconds=idle_timeout_seconds,
|
|
390
|
+
dispatch_kind=dispatch_kind,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
230
394
|
def utc_now() -> str:
|
|
231
395
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|