okstra 0.163.2 → 0.164.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 +7 -5
- package/docs/architecture.md +12 -7
- package/docs/cli.md +8 -5
- package/docs/project-structure-overview.md +7 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -0
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/usage.sh +5 -5
- package/runtime/bin/okstra.sh +87 -91
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
- package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/application/__init__.py +1 -0
- package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
- package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
- package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
- package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
- package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
- package/runtime/python/okstra_ctl/application/start_run.py +21 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
- package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
- package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
- package/runtime/python/okstra_ctl/domain/host.py +100 -0
- package/runtime/python/okstra_ctl/domain/provider.py +66 -0
- package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
- package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
- package/runtime/python/okstra_ctl/models.py +54 -269
- package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
- package/runtime/python/okstra_ctl/ports/host.py +32 -0
- package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
- package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
- package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
- package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
- package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
- package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
- package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
- package/runtime/python/okstra_ctl/render.py +145 -47
- package/runtime/python/okstra_ctl/run.py +80 -58
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team.py +44 -32
- package/runtime/python/okstra_ctl/wizard.py +162 -64
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
- package/runtime/python/okstra_token_usage/collect.py +34 -6
- package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
- package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
- package/runtime/skills/okstra-run/SKILL.md +73 -28
- package/runtime/templates/implementation-worker-preamble.md +1 -1
- package/runtime/templates/reports/final-report.template.md +3 -3
- package/runtime/templates/reports/html/i18n/en.json +3 -1
- package/runtime/templates/reports/html/i18n/ko.json +3 -1
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +17 -22
- package/runtime/validators/validate-report-views.py +0 -39
- package/runtime/validators/validate-run.py +8 -7
- package/runtime/validators/validate_session_conformance.py +7 -2
- package/src/commands/execute/render-bundle.mjs +4 -4
- package/src/commands/execute/run.mjs +8 -25
- package/src/commands/execute/wizard.mjs +33 -13
- package/src/commands/lifecycle/doctor.mjs +10 -10
- package/src/commands/lifecycle/install.mjs +53 -30
- package/src/commands/lifecycle/preflight.mjs +14 -4
- package/src/lib/host-registry-client.mjs +176 -0
- package/src/lib/runtime-manifest.mjs +6 -8
- package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
- package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
- package/src/lib/runtime-readiness.mjs +0 -90
- package/src/lib/runtime-resolver.mjs +0 -123
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
import subprocess
|
|
6
6
|
import time
|
|
7
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Mapping, Sequence
|
|
@@ -19,6 +19,7 @@ from .dispatch_state import (
|
|
|
19
19
|
DispatchError,
|
|
20
20
|
WorkerJob,
|
|
21
21
|
dispatch_mode as _dispatch_mode,
|
|
22
|
+
dispatch_record_matches,
|
|
22
23
|
LIVENESS_AUDIT_HEARTBEAT,
|
|
23
24
|
LIVENESS_WRAPPER_STATUS,
|
|
24
25
|
load_json_object as _load_json_object,
|
|
@@ -50,6 +51,12 @@ from .initial_prompt_materialization import (
|
|
|
50
51
|
materialize_initial_prompts,
|
|
51
52
|
)
|
|
52
53
|
from .path_hints import hydrate_active_run_context
|
|
54
|
+
from .report_finalize import (
|
|
55
|
+
STEP_VALIDATE_RUN,
|
|
56
|
+
FinalizeContext,
|
|
57
|
+
FinalizeError,
|
|
58
|
+
run_finalize,
|
|
59
|
+
)
|
|
53
60
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
54
61
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
55
62
|
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
@@ -117,6 +124,8 @@ class _BuildOptions:
|
|
|
117
124
|
supported_worker_wrappers: Mapping[str, str]
|
|
118
125
|
unsupported_worker_label: str
|
|
119
126
|
dispatch_kind: str
|
|
127
|
+
cli_wrapper_assignments_only: bool = False
|
|
128
|
+
default_provider_by_worker_id: Mapping[str, str] = field(default_factory=dict)
|
|
120
129
|
|
|
121
130
|
|
|
122
131
|
@dataclass(frozen=True)
|
|
@@ -142,6 +151,8 @@ def build_dispatch_plan(
|
|
|
142
151
|
unsupported_worker_label: str,
|
|
143
152
|
dispatch_kind: str = "initial",
|
|
144
153
|
jobs_file: Path | None = None,
|
|
154
|
+
cli_wrapper_assignments_only: bool = False,
|
|
155
|
+
default_provider_by_worker_id: Mapping[str, str] | None = None,
|
|
145
156
|
) -> DispatchPlan:
|
|
146
157
|
project_root = project_root.resolve()
|
|
147
158
|
manifest_path = _resolve_project_path(project_root, str(run_manifest_path))
|
|
@@ -157,6 +168,8 @@ def build_dispatch_plan(
|
|
|
157
168
|
supported_worker_wrappers=supported_worker_wrappers,
|
|
158
169
|
unsupported_worker_label=unsupported_worker_label,
|
|
159
170
|
dispatch_kind=dispatch_kind,
|
|
171
|
+
cli_wrapper_assignments_only=cli_wrapper_assignments_only,
|
|
172
|
+
default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
|
|
160
173
|
)
|
|
161
174
|
if jobs_file:
|
|
162
175
|
jobs = _jobs_from_file(project_root, workspace_root, jobs_file, options)
|
|
@@ -247,7 +260,12 @@ def _jobs_from_roster(
|
|
|
247
260
|
requested_workers: Sequence[str],
|
|
248
261
|
options: _BuildOptions,
|
|
249
262
|
) -> list[WorkerJob]:
|
|
250
|
-
selected = _select_workers(
|
|
263
|
+
selected = _select_workers(
|
|
264
|
+
manifest,
|
|
265
|
+
requested_workers,
|
|
266
|
+
options,
|
|
267
|
+
team_state=team_state,
|
|
268
|
+
)
|
|
251
269
|
_mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
|
|
252
270
|
worker_facts, prompt_paths = _materialize_roster_prompts(
|
|
253
271
|
project_root,
|
|
@@ -278,10 +296,19 @@ def _select_workers(
|
|
|
278
296
|
manifest: Mapping[str, Any],
|
|
279
297
|
requested_workers: Sequence[str],
|
|
280
298
|
options: _BuildOptions,
|
|
299
|
+
*,
|
|
300
|
+
team_state: Mapping[str, Any] | None = None,
|
|
281
301
|
) -> list[str]:
|
|
282
302
|
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
283
303
|
if not recommended:
|
|
284
304
|
raise DispatchError("run manifest has no recommendedWorkers entries")
|
|
305
|
+
if options.cli_wrapper_assignments_only:
|
|
306
|
+
return _select_cli_wrapper_assignments(
|
|
307
|
+
recommended,
|
|
308
|
+
requested_workers,
|
|
309
|
+
team_state or {},
|
|
310
|
+
options,
|
|
311
|
+
)
|
|
285
312
|
supported = set(options.supported_worker_wrappers)
|
|
286
313
|
selected = list(requested_workers) if requested_workers else [w for w in recommended if w in supported]
|
|
287
314
|
unknown = [worker for worker in selected if worker not in recommended]
|
|
@@ -298,6 +325,69 @@ def _select_workers(
|
|
|
298
325
|
return selected
|
|
299
326
|
|
|
300
327
|
|
|
328
|
+
def _select_cli_wrapper_assignments(
|
|
329
|
+
recommended: Sequence[str],
|
|
330
|
+
requested_workers: Sequence[str],
|
|
331
|
+
team_state: Mapping[str, Any],
|
|
332
|
+
options: _BuildOptions,
|
|
333
|
+
) -> list[str]:
|
|
334
|
+
dispatchable = {
|
|
335
|
+
worker_id
|
|
336
|
+
for worker_id in recommended
|
|
337
|
+
if _is_cli_wrapper_assignment(team_state, worker_id, options)
|
|
338
|
+
}
|
|
339
|
+
if not requested_workers:
|
|
340
|
+
selected = [worker for worker in recommended if worker in dispatchable]
|
|
341
|
+
if selected:
|
|
342
|
+
return selected
|
|
343
|
+
raise DispatchError("run roster has no cli-wrapper assignments for codex-dispatch")
|
|
344
|
+
selected = list(requested_workers)
|
|
345
|
+
unknown = [worker for worker in selected if worker not in recommended]
|
|
346
|
+
if unknown:
|
|
347
|
+
raise DispatchError(
|
|
348
|
+
"requested worker(s) are not in this run roster: " + ", ".join(unknown)
|
|
349
|
+
)
|
|
350
|
+
native = [
|
|
351
|
+
worker
|
|
352
|
+
for worker in selected
|
|
353
|
+
if _runner_for_worker(team_state, worker) == "native-session"
|
|
354
|
+
]
|
|
355
|
+
if native:
|
|
356
|
+
raise DispatchError(
|
|
357
|
+
"requested worker(s) use runner=native-session and must be dispatched "
|
|
358
|
+
"by the current Codex host: " + ", ".join(native)
|
|
359
|
+
)
|
|
360
|
+
unsupported = [worker for worker in selected if worker not in dispatchable]
|
|
361
|
+
if unsupported:
|
|
362
|
+
supported = ", ".join(sorted(dispatchable))
|
|
363
|
+
raise DispatchError(
|
|
364
|
+
"unsupported Codex lead worker(s): "
|
|
365
|
+
+ ", ".join(unsupported)
|
|
366
|
+
+ f" (CLI dispatcher supports: {supported})"
|
|
367
|
+
)
|
|
368
|
+
return selected
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _is_cli_wrapper_assignment(
|
|
372
|
+
team_state: Mapping[str, Any],
|
|
373
|
+
worker_id: str,
|
|
374
|
+
options: _BuildOptions,
|
|
375
|
+
) -> bool:
|
|
376
|
+
state = _worker_state(team_state, worker_id)
|
|
377
|
+
provider = _string_value(state.get("provider")) or _provider_for_worker(
|
|
378
|
+
worker_id,
|
|
379
|
+
options,
|
|
380
|
+
)
|
|
381
|
+
return (
|
|
382
|
+
_string_value(state.get("runner")) or BACKEND_CLI_WRAPPER
|
|
383
|
+
) == BACKEND_CLI_WRAPPER and provider in options.supported_worker_wrappers
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _runner_for_worker(team_state: Mapping[str, Any], worker_id: str) -> str:
|
|
387
|
+
state = _worker_state(team_state, worker_id)
|
|
388
|
+
return _string_value(state.get("runner")) or BACKEND_CLI_WRAPPER
|
|
389
|
+
|
|
390
|
+
|
|
301
391
|
def _job_from_roster_worker(
|
|
302
392
|
project_root: Path,
|
|
303
393
|
workspace_root: Path,
|
|
@@ -340,7 +430,7 @@ def _materialize_roster_prompts(
|
|
|
340
430
|
options: _BuildOptions,
|
|
341
431
|
) -> tuple[tuple[_RosterWorkerFacts, ...], dict[str, Path]]:
|
|
342
432
|
worker_facts = tuple(
|
|
343
|
-
_roster_worker_facts(team_state, worker_id)
|
|
433
|
+
_roster_worker_facts(team_state, worker_id, options)
|
|
344
434
|
for worker_id in selected
|
|
345
435
|
)
|
|
346
436
|
try:
|
|
@@ -364,12 +454,14 @@ def _materialize_roster_prompts(
|
|
|
364
454
|
def _roster_worker_facts(
|
|
365
455
|
team_state: Mapping[str, Any],
|
|
366
456
|
worker_id: str,
|
|
457
|
+
options: _BuildOptions | None = None,
|
|
367
458
|
) -> _RosterWorkerFacts:
|
|
368
459
|
state = _worker_state(team_state, worker_id)
|
|
369
460
|
return _RosterWorkerFacts(
|
|
370
461
|
worker_id=worker_id,
|
|
371
462
|
model=_require_string(state, "modelExecutionValue"),
|
|
372
|
-
provider=_string_value(state.get("provider"))
|
|
463
|
+
provider=_string_value(state.get("provider"))
|
|
464
|
+
or _provider_for_worker(worker_id, options),
|
|
373
465
|
result_path=_require_string(state, "resultPath"),
|
|
374
466
|
role=_string_value(state.get("role")) or "worker",
|
|
375
467
|
)
|
|
@@ -398,7 +490,7 @@ def _jobs_from_file(
|
|
|
398
490
|
resolve_wrapper=lambda provider: _resolve_wrapper(
|
|
399
491
|
provider, workspace_root, options
|
|
400
492
|
),
|
|
401
|
-
default_provider=_provider_for_worker,
|
|
493
|
+
default_provider=lambda worker_id: _provider_for_worker(worker_id, options),
|
|
402
494
|
)
|
|
403
495
|
|
|
404
496
|
|
|
@@ -422,7 +514,7 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
|
422
514
|
if handle.completed_process is None:
|
|
423
515
|
return await_dispatches(plan, timeout_seconds=None)
|
|
424
516
|
outcome = _outcome_from_completed(handle)
|
|
425
|
-
if
|
|
517
|
+
if _worker_terminal_status(plan.team_state_path, job.worker_id) == "completed":
|
|
426
518
|
return 0
|
|
427
519
|
if _should_retry(outcome, attempt):
|
|
428
520
|
_append_event(plan, "worker-retry-scheduled", _retry_details(job, attempt, outcome))
|
|
@@ -565,24 +657,47 @@ def _retry_from_record(
|
|
|
565
657
|
) -> None:
|
|
566
658
|
worker_id = _require_string(record, "workerId")
|
|
567
659
|
attempt = int(record.get("attempt", 1))
|
|
568
|
-
_update_dispatch_status(plan.team_state_path, worker_id, _require_string(record, "kind"), attempt, "error", "required worker artifact was not produced")
|
|
569
|
-
_append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
|
|
570
660
|
job = _job_from_record(plan.project_root, record)
|
|
661
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, "error", "required worker artifact was not produced")
|
|
662
|
+
_append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
|
|
571
663
|
_spawn_job(plan, job, attempt + 1)
|
|
572
664
|
|
|
573
665
|
|
|
574
666
|
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
575
667
|
if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
|
|
668
|
+
post_process = _post_process_report_writer_result(plan, job)
|
|
669
|
+
if not post_process["ok"]:
|
|
670
|
+
reason = _require_string(post_process, "reason")
|
|
671
|
+
_transition_worker_status(plan.team_state_path, job.worker_id, "error", reason)
|
|
672
|
+
_update_dispatch_status(
|
|
673
|
+
plan.team_state_path,
|
|
674
|
+
job,
|
|
675
|
+
attempt,
|
|
676
|
+
"error",
|
|
677
|
+
reason,
|
|
678
|
+
)
|
|
679
|
+
details = _failure_details(
|
|
680
|
+
job,
|
|
681
|
+
attempt,
|
|
682
|
+
outcome,
|
|
683
|
+
reason,
|
|
684
|
+
exit_code=1,
|
|
685
|
+
)
|
|
686
|
+
details["postProcessing"] = post_process["steps"]
|
|
687
|
+
_append_event(plan, "worker-failed", details)
|
|
688
|
+
return
|
|
576
689
|
_transition_worker_status(
|
|
577
690
|
plan.team_state_path, job.worker_id, "completed", ""
|
|
578
691
|
)
|
|
579
|
-
_update_dispatch_status(plan.team_state_path, job
|
|
580
|
-
|
|
692
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, "completed", "")
|
|
693
|
+
details = _result_details(job, attempt, outcome)
|
|
694
|
+
details["postProcessing"] = post_process["steps"]
|
|
695
|
+
_append_event(plan, "worker-result-collected", details)
|
|
581
696
|
return
|
|
582
697
|
reason = _failure_reason(outcome)
|
|
583
698
|
status = "timeout" if outcome.timeout else "error"
|
|
584
699
|
_transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
|
|
585
|
-
_update_dispatch_status(plan.team_state_path, job
|
|
700
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
|
|
586
701
|
_append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
|
|
587
702
|
|
|
588
703
|
|
|
@@ -591,6 +706,41 @@ def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: Worke
|
|
|
591
706
|
_finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
|
|
592
707
|
|
|
593
708
|
|
|
709
|
+
def _post_process_report_writer_result(
|
|
710
|
+
plan: DispatchPlan,
|
|
711
|
+
job: WorkerJob,
|
|
712
|
+
) -> dict[str, Any]:
|
|
713
|
+
if job.worker_id != REPORT_WRITER_WORKER_ID:
|
|
714
|
+
return {"ok": True, "reason": "", "steps": []}
|
|
715
|
+
try:
|
|
716
|
+
context = FinalizeContext.from_manifest(
|
|
717
|
+
project_root=plan.project_root,
|
|
718
|
+
workspace_root=plan.workspace_root,
|
|
719
|
+
manifest_path=plan.manifest_path,
|
|
720
|
+
team_state_path=plan.team_state_path,
|
|
721
|
+
data_path=job.result_path,
|
|
722
|
+
manifest=plan.manifest,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
def settle_before(step_name: str) -> None:
|
|
726
|
+
if step_name == STEP_VALIDATE_RUN:
|
|
727
|
+
_transition_worker_status(
|
|
728
|
+
plan.team_state_path,
|
|
729
|
+
job.worker_id,
|
|
730
|
+
"completed",
|
|
731
|
+
"",
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return run_finalize(context, before_step=settle_before)
|
|
735
|
+
except FinalizeError as exc:
|
|
736
|
+
return {"ok": False, "reason": str(exc), "steps": []}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
|
|
740
|
+
team_state = _load_json_object(team_state_path, "team-state")
|
|
741
|
+
return _string_value(_worker_state(team_state, worker_id).get("status"))
|
|
742
|
+
|
|
743
|
+
|
|
594
744
|
def _record_dispatch(
|
|
595
745
|
team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
|
|
596
746
|
) -> None:
|
|
@@ -638,19 +788,23 @@ def _liveness_mode(backend: str) -> str:
|
|
|
638
788
|
|
|
639
789
|
|
|
640
790
|
def _update_dispatch_status(
|
|
641
|
-
team_state_path: Path,
|
|
791
|
+
team_state_path: Path, job: WorkerJob, attempt: int, status: str, reason: str
|
|
642
792
|
) -> None:
|
|
643
793
|
payload = _load_json_object(team_state_path, "team-state")
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
794
|
+
matches = [
|
|
795
|
+
record
|
|
796
|
+
for record in payload.get("workerDispatches", [])
|
|
797
|
+
if dispatch_record_matches(record, job.prompt_path, attempt)
|
|
798
|
+
]
|
|
799
|
+
if not matches:
|
|
800
|
+
return
|
|
801
|
+
# A lead may re-send a round's prompt without advancing `attempt`, which
|
|
802
|
+
# appends a second row the prompt cannot tell from the first. The newest is
|
|
803
|
+
# the one still running, so an outcome written to the older row would leave
|
|
804
|
+
# the live one running for good — the shape that wedged an await loop.
|
|
805
|
+
matches[-1]["status"] = status
|
|
806
|
+
matches[-1]["reason"] = reason
|
|
807
|
+
_write_json(team_state_path, payload)
|
|
654
808
|
|
|
655
809
|
|
|
656
810
|
def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
|
|
@@ -669,21 +823,58 @@ def _mark_roster_skips(
|
|
|
669
823
|
requested: Sequence[str],
|
|
670
824
|
options: _BuildOptions,
|
|
671
825
|
) -> None:
|
|
672
|
-
reasons = _skip_reasons(
|
|
826
|
+
reasons = _skip_reasons(
|
|
827
|
+
manifest,
|
|
828
|
+
selected,
|
|
829
|
+
requested,
|
|
830
|
+
options,
|
|
831
|
+
team_state=team_state,
|
|
832
|
+
)
|
|
673
833
|
if not reasons:
|
|
674
834
|
return
|
|
675
835
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
836
|
+
current_team_state = _load_json_object(team_state_path, "team-state")
|
|
676
837
|
for worker_id, reason in reasons.items():
|
|
838
|
+
worker = _worker_state(current_team_state, worker_id)
|
|
839
|
+
if (
|
|
840
|
+
_string_value(worker.get("status")) != "not-run"
|
|
841
|
+
or _string_value(worker.get("reason"))
|
|
842
|
+
):
|
|
843
|
+
continue
|
|
677
844
|
_transition_worker_status(team_state_path, worker_id, "not-run", reason)
|
|
678
845
|
|
|
679
846
|
|
|
680
847
|
def _skip_reasons(
|
|
681
|
-
manifest: Mapping[str, Any],
|
|
848
|
+
manifest: Mapping[str, Any],
|
|
849
|
+
selected: Sequence[str],
|
|
850
|
+
requested: Sequence[str],
|
|
851
|
+
options: _BuildOptions,
|
|
852
|
+
*,
|
|
853
|
+
team_state: Mapping[str, Any] | None = None,
|
|
682
854
|
) -> dict[str, str]:
|
|
855
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
856
|
+
if options.cli_wrapper_assignments_only:
|
|
857
|
+
recommended = [
|
|
858
|
+
worker_id
|
|
859
|
+
for worker_id in recommended
|
|
860
|
+
if _runner_for_worker(team_state or {}, worker_id) != "native-session"
|
|
861
|
+
]
|
|
683
862
|
if requested:
|
|
684
|
-
return {
|
|
863
|
+
return {
|
|
864
|
+
worker_id: (
|
|
865
|
+
"skipped by worker dispatch: worker was not requested in this invocation"
|
|
866
|
+
)
|
|
867
|
+
for worker_id in recommended
|
|
868
|
+
if worker_id not in set(selected)
|
|
869
|
+
}
|
|
685
870
|
supported = set(options.supported_worker_wrappers)
|
|
686
|
-
return {
|
|
871
|
+
return {
|
|
872
|
+
worker_id: (
|
|
873
|
+
"skipped by worker dispatch default: worker is not supported by this dispatcher"
|
|
874
|
+
)
|
|
875
|
+
for worker_id in recommended
|
|
876
|
+
if worker_id not in set(selected) and worker_id not in supported
|
|
877
|
+
}
|
|
687
878
|
|
|
688
879
|
|
|
689
880
|
_SIDEBAR_LEVELS = {
|
|
@@ -765,14 +956,27 @@ def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dic
|
|
|
765
956
|
}
|
|
766
957
|
|
|
767
958
|
|
|
768
|
-
def _failure_details(
|
|
959
|
+
def _failure_details(
|
|
960
|
+
job: WorkerJob,
|
|
961
|
+
attempt: int,
|
|
962
|
+
outcome: WorkerOutcome,
|
|
963
|
+
reason: str,
|
|
964
|
+
*,
|
|
965
|
+
exit_code: int | None = None,
|
|
966
|
+
) -> dict[str, Any]:
|
|
769
967
|
return {
|
|
770
968
|
"workerId": job.worker_id,
|
|
771
969
|
"attempt": attempt,
|
|
970
|
+
"maxAttempts": MAX_WORKER_ATTEMPTS,
|
|
772
971
|
"dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
|
|
773
|
-
"exitCode": outcome.returncode,
|
|
972
|
+
"exitCode": outcome.returncode if exit_code is None else exit_code,
|
|
774
973
|
"missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
|
|
775
974
|
"reason": reason,
|
|
975
|
+
"promptPath": str(job.prompt_path),
|
|
976
|
+
"resultPath": str(job.result_path),
|
|
977
|
+
"workerResultPath": str(job.worker_result_path),
|
|
978
|
+
"wrapperPath": str(job.wrapper_path),
|
|
979
|
+
"role": job.role,
|
|
776
980
|
}
|
|
777
981
|
|
|
778
982
|
|
|
@@ -863,7 +1067,14 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
|
|
|
863
1067
|
raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
|
|
864
1068
|
|
|
865
1069
|
|
|
866
|
-
def _provider_for_worker(
|
|
1070
|
+
def _provider_for_worker(
|
|
1071
|
+
worker_id: str,
|
|
1072
|
+
options: _BuildOptions | None = None,
|
|
1073
|
+
) -> str:
|
|
1074
|
+
if options is not None:
|
|
1075
|
+
configured = options.default_provider_by_worker_id.get(worker_id)
|
|
1076
|
+
if configured:
|
|
1077
|
+
return configured
|
|
867
1078
|
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
868
1079
|
return "claude"
|
|
869
1080
|
return worker_id
|
|
@@ -430,3 +430,20 @@ def _worker_job_from_file(
|
|
|
430
430
|
|
|
431
431
|
def utc_now() -> str:
|
|
432
432
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def dispatch_record_matches(record: object, prompt_path: Path, attempt: int) -> bool:
|
|
436
|
+
"""Whether this team-state row is the dispatch handed `prompt_path`.
|
|
437
|
+
|
|
438
|
+
The prompt file identifies a dispatch: each round writes its own, and a
|
|
439
|
+
second run of the same one is a retry, which `attempt` separates. Worker id
|
|
440
|
+
and dispatch kind cannot carry that — `--dispatch-kind` is the lead's to
|
|
441
|
+
pick and it has reused one across two rounds, which put the finished
|
|
442
|
+
round's completion on the older namesake row and left the row that actually
|
|
443
|
+
ran marked running, so an await re-collected that worker forever.
|
|
444
|
+
"""
|
|
445
|
+
return (
|
|
446
|
+
isinstance(record, dict)
|
|
447
|
+
and record.get("promptPath") == str(prompt_path)
|
|
448
|
+
and record.get("attempt") == attempt
|
|
449
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Domain value objects for provider selection and model resolution."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_provider_module_name = f"{__name__}.provider"
|
|
8
|
+
if __name__.startswith("scripts."):
|
|
9
|
+
_alternate_provider_module_name = f"{__name__.removeprefix('scripts.')}.provider"
|
|
10
|
+
else:
|
|
11
|
+
_alternate_provider_module_name = f"scripts.{__name__}.provider"
|
|
12
|
+
|
|
13
|
+
# Repository tests support both import paths. Sharing one module preserves the
|
|
14
|
+
# exact class identity that the adapter factory contract validates.
|
|
15
|
+
if _alternate_provider_module_name in sys.modules:
|
|
16
|
+
sys.modules[_provider_module_name] = sys.modules[_alternate_provider_module_name]
|
|
17
|
+
|
|
18
|
+
_provider = importlib.import_module(_provider_module_name)
|
|
19
|
+
|
|
20
|
+
LeadLaunchSpec = _provider.LeadLaunchSpec
|
|
21
|
+
ModelAssignment = _provider.ModelAssignment
|
|
22
|
+
ModelSpec = _provider.ModelSpec
|
|
23
|
+
ProviderSpec = _provider.ProviderSpec
|
|
24
|
+
UnknownModelError = _provider.UnknownModelError
|
|
25
|
+
UnknownProviderError = _provider.UnknownProviderError
|
|
26
|
+
|
|
27
|
+
__all__ = (
|
|
28
|
+
"LeadLaunchSpec",
|
|
29
|
+
"ModelAssignment",
|
|
30
|
+
"ModelSpec",
|
|
31
|
+
"ProviderSpec",
|
|
32
|
+
"UnknownModelError",
|
|
33
|
+
"UnknownProviderError",
|
|
34
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Host selection and session value objects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class HostDescriptor:
|
|
11
|
+
id: str
|
|
12
|
+
aliases: tuple[str, ...]
|
|
13
|
+
native_provider_id: str
|
|
14
|
+
required_executables: tuple[str, ...]
|
|
15
|
+
launch_mode: Literal["lead", "team"]
|
|
16
|
+
install_targets: frozenset[str]
|
|
17
|
+
agent_id: str
|
|
18
|
+
agent_label: str
|
|
19
|
+
role: str
|
|
20
|
+
dispatch_mode: str
|
|
21
|
+
session_accounting: str
|
|
22
|
+
has_claude_session: bool
|
|
23
|
+
relay_contract: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HostSessionContext:
|
|
28
|
+
host_id: str
|
|
29
|
+
entry_mode: Literal["spawn-process", "current-session"]
|
|
30
|
+
available_functions: frozenset[str]
|
|
31
|
+
interaction_surface: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HostResolutionContext:
|
|
36
|
+
explicit_env_host: str
|
|
37
|
+
signals: frozenset[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class HostClaim:
|
|
42
|
+
host_id: str
|
|
43
|
+
priority: int
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class HostReadiness:
|
|
49
|
+
ok: bool
|
|
50
|
+
effective_functions: frozenset[str]
|
|
51
|
+
missing_executables: tuple[str, ...]
|
|
52
|
+
checks: tuple[Mapping[str, object], ...]
|
|
53
|
+
reason: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class LeadStartRequest:
|
|
58
|
+
project_root: Path
|
|
59
|
+
task_type: str
|
|
60
|
+
argv: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class LeadResumeRequest:
|
|
65
|
+
project_root: Path
|
|
66
|
+
task_type: str
|
|
67
|
+
session_id: str
|
|
68
|
+
argv: tuple[str, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class LeadSessionPlan:
|
|
73
|
+
mode: Literal["spawn-process", "current-session"]
|
|
74
|
+
argv: tuple[str, ...]
|
|
75
|
+
env: Mapping[str, str]
|
|
76
|
+
sandbox_note: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HostNotRegistered(ValueError):
|
|
80
|
+
"""Raised when no registered host matches an explicit request."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class HostUnavailable(RuntimeError):
|
|
84
|
+
"""Raised when no single host can serve an automatic request."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HostCapabilityMismatch(ValueError):
|
|
88
|
+
"""Raised when a selected host lacks a required semantic capability."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HostAdapterContractError(ValueError):
|
|
92
|
+
"""Raised when a host manifest or factory violates the adapter contract."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ProviderNotRegistered(ValueError):
|
|
96
|
+
"""Raised when a host references an unknown native provider."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ProviderUnavailable(RuntimeError):
|
|
100
|
+
"""Raised when a registered provider cannot serve a request."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Provider and model value objects shared by the registry and adapters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Mapping, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ModelSpec:
|
|
10
|
+
display: str
|
|
11
|
+
execution: str
|
|
12
|
+
pricing: Optional[tuple[float, float, float]] = None
|
|
13
|
+
in_picker: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LeadLaunchSpec:
|
|
18
|
+
"""How to start a provider CLI as an interactive lead session."""
|
|
19
|
+
|
|
20
|
+
executable: str
|
|
21
|
+
model_flag: str
|
|
22
|
+
prompt_flag: str = ""
|
|
23
|
+
start_session_id_flag: str = ""
|
|
24
|
+
resume_session_id_flag: str = ""
|
|
25
|
+
sandbox_waiver: tuple[str, ...] = ()
|
|
26
|
+
sandbox_waiver_note: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ProviderSpec:
|
|
31
|
+
provider: str
|
|
32
|
+
display_label: str
|
|
33
|
+
models: Mapping[str, ModelSpec]
|
|
34
|
+
default_models: Mapping[str, str]
|
|
35
|
+
wrapper: str
|
|
36
|
+
supported_roles: frozenset[str]
|
|
37
|
+
lead_launch: Optional[LeadLaunchSpec] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RunnerResolution:
|
|
42
|
+
runner: str
|
|
43
|
+
wrapper: str
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class LeadProviderAssignment:
|
|
49
|
+
provider: str
|
|
50
|
+
runner: str
|
|
51
|
+
wrapper: str
|
|
52
|
+
reason: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ModelAssignment:
|
|
57
|
+
display: str
|
|
58
|
+
execution: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UnknownModelError(ValueError):
|
|
62
|
+
"""Raised when a provider has no model alias matching a raw value."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UnknownProviderError(ValueError):
|
|
66
|
+
"""Raised when a requested provider is absent from the registry."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Wizard interaction domain values and answer normalization."""
|
|
2
|
+
|
|
3
|
+
from .interaction import (
|
|
4
|
+
AnswerProtocol,
|
|
5
|
+
InteractionPlan,
|
|
6
|
+
WizardAnswerError,
|
|
7
|
+
WizardOption,
|
|
8
|
+
WizardPrompt,
|
|
9
|
+
normalize_planned_answer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AnswerProtocol",
|
|
14
|
+
"InteractionPlan",
|
|
15
|
+
"WizardAnswerError",
|
|
16
|
+
"WizardOption",
|
|
17
|
+
"WizardPrompt",
|
|
18
|
+
"normalize_planned_answer",
|
|
19
|
+
]
|