okstra 0.163.2 → 0.165.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 +8 -6
- package/docs/architecture.md +24 -15
- package/docs/cli.md +15 -8
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +22 -14
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +9 -7
- package/runtime/agents/workers/claude-worker.md +1 -0
- package/runtime/agents/workers/codex-worker.md +9 -7
- package/runtime/agents/workers/grok-worker.md +6 -4
- package/runtime/agents/workers/kimi-worker.md +6 -4
- 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-antigravity-exec.sh +1 -340
- package/runtime/bin/okstra-claude-exec.sh +1 -178
- package/runtime/bin/okstra-codex-exec.sh +1 -467
- package/runtime/bin/okstra-provider-exec.py +165 -190
- package/runtime/bin/okstra-trace-cleanup.sh +14 -7
- package/runtime/bin/okstra-wrapper-status.py +26 -19
- package/runtime/bin/okstra.sh +87 -91
- package/runtime/prompts/lead/adapters/cmux.md +2 -2
- package/runtime/prompts/lead/convergence.md +36 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
- package/runtime/prompts/lead/plan-body-verification.md +9 -1
- package/runtime/prompts/lead/report-writer.md +1 -0
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/prompts/profiles/_common-contract.md +9 -1
- package/runtime/prompts/profiles/_coverage-critic.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +6 -4
- 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} +112 -1
- 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} +53 -1
- 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 +183 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -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 +244 -29
- package/runtime/python/okstra_ctl/dispatch_state.py +27 -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 +70 -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/domain/worker_exec.py +102 -0
- package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
- package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -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/incremental_scope.py +16 -4
- 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/report_html/common.py +71 -25
- package/runtime/python/okstra_ctl/report_html/models.py +5 -0
- package/runtime/python/okstra_ctl/report_html/render.py +1 -1
- package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
- package/runtime/python/okstra_ctl/report_views.py +44 -16
- package/runtime/python/okstra_ctl/run.py +80 -58
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/stage_citations.py +52 -15
- package/runtime/python/okstra_ctl/team.py +44 -32
- package/runtime/python/okstra_ctl/user_response.py +45 -29
- package/runtime/python/okstra_ctl/wizard.py +175 -73
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
- package/runtime/python/okstra_ctl/worker_request.py +140 -0
- package/runtime/python/okstra_ctl/worker_runner.py +622 -0
- package/runtime/python/okstra_token_usage/collect.py +42 -7
- package/runtime/python/okstra_token_usage/report.py +42 -0
- package/runtime/python/okstra_token_usage/task_totals.py +88 -0
- package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
- package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
- package/runtime/skills/okstra-inspect/SKILL.md +1 -2
- package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
- package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
- package/runtime/skills/okstra-run/SKILL.md +74 -29
- package/runtime/skills/okstra-user-response/SKILL.md +15 -5
- package/runtime/templates/implementation-worker-preamble.md +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +1 -0
- package/runtime/templates/reports/final-report.template.md +3 -3
- package/runtime/templates/reports/html/assets/base.css +8 -4
- package/runtime/templates/reports/html/base.template.html +12 -6
- package/runtime/templates/reports/html/i18n/en.json +32 -7
- package/runtime/templates/reports/html/i18n/ko.json +32 -7
- package/runtime/templates/reports/html/macros/forms.html +9 -3
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
- package/runtime/templates/reports/report.js +59 -26
- package/runtime/templates/reports/user-response.template.md +12 -8
- 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 +96 -14
- package/runtime/validators/validate_session_conformance.py +69 -3
- package/src/cli-registry.mjs +0 -7
- 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/bin/okstra-wrapper-agy-stream.py +0 -61
- package/runtime/python/okstra_ctl/error_issue.py +0 -640
- package/runtime/python/okstra_ctl/issue_signals.py +0 -186
- package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
- package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
- package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
- package/src/commands/inspect/error-issue.mjs +0 -27
- 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, replace
|
|
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))
|
|
@@ -528,6 +620,10 @@ def _refuse_when_cmux_is_walled_off() -> None:
|
|
|
528
620
|
|
|
529
621
|
|
|
530
622
|
def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
|
|
623
|
+
# Whatever the job was planned as, this is a cli-wrapper dispatch: no pane
|
|
624
|
+
# took it, so the worker's stdout is read by whoever called the lead. The
|
|
625
|
+
# record already says so (`_dispatch_record`), and the command must agree.
|
|
626
|
+
job = replace(job, backend=BACKEND_CLI_WRAPPER)
|
|
531
627
|
completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
|
|
532
628
|
return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
|
|
533
629
|
|
|
@@ -565,24 +661,47 @@ def _retry_from_record(
|
|
|
565
661
|
) -> None:
|
|
566
662
|
worker_id = _require_string(record, "workerId")
|
|
567
663
|
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
664
|
job = _job_from_record(plan.project_root, record)
|
|
665
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, "error", "required worker artifact was not produced")
|
|
666
|
+
_append_event(plan, "worker-retry-scheduled", {"workerId": worker_id, "attempt": attempt})
|
|
571
667
|
_spawn_job(plan, job, attempt + 1)
|
|
572
668
|
|
|
573
669
|
|
|
574
670
|
def _finish_attempt(plan: DispatchPlan, job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> None:
|
|
575
671
|
if outcome.returncode == 0 and not outcome.missing_completion_paths and not outcome.timeout:
|
|
672
|
+
post_process = _post_process_report_writer_result(plan, job)
|
|
673
|
+
if not post_process["ok"]:
|
|
674
|
+
reason = _require_string(post_process, "reason")
|
|
675
|
+
_transition_worker_status(plan.team_state_path, job.worker_id, "error", reason)
|
|
676
|
+
_update_dispatch_status(
|
|
677
|
+
plan.team_state_path,
|
|
678
|
+
job,
|
|
679
|
+
attempt,
|
|
680
|
+
"error",
|
|
681
|
+
reason,
|
|
682
|
+
)
|
|
683
|
+
details = _failure_details(
|
|
684
|
+
job,
|
|
685
|
+
attempt,
|
|
686
|
+
outcome,
|
|
687
|
+
reason,
|
|
688
|
+
exit_code=1,
|
|
689
|
+
)
|
|
690
|
+
details["postProcessing"] = post_process["steps"]
|
|
691
|
+
_append_event(plan, "worker-failed", details)
|
|
692
|
+
return
|
|
576
693
|
_transition_worker_status(
|
|
577
694
|
plan.team_state_path, job.worker_id, "completed", ""
|
|
578
695
|
)
|
|
579
|
-
_update_dispatch_status(plan.team_state_path, job
|
|
580
|
-
|
|
696
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, "completed", "")
|
|
697
|
+
details = _result_details(job, attempt, outcome)
|
|
698
|
+
details["postProcessing"] = post_process["steps"]
|
|
699
|
+
_append_event(plan, "worker-result-collected", details)
|
|
581
700
|
return
|
|
582
701
|
reason = _failure_reason(outcome)
|
|
583
702
|
status = "timeout" if outcome.timeout else "error"
|
|
584
703
|
_transition_worker_status(plan.team_state_path, job.worker_id, status, reason)
|
|
585
|
-
_update_dispatch_status(plan.team_state_path, job
|
|
704
|
+
_update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
|
|
586
705
|
_append_event(plan, "worker-failed", _failure_details(job, attempt, outcome, reason))
|
|
587
706
|
|
|
588
707
|
|
|
@@ -591,6 +710,41 @@ def _finish_record(plan: DispatchPlan, record: Mapping[str, Any], outcome: Worke
|
|
|
591
710
|
_finish_attempt(plan, job, int(record.get("attempt", 1)), outcome)
|
|
592
711
|
|
|
593
712
|
|
|
713
|
+
def _post_process_report_writer_result(
|
|
714
|
+
plan: DispatchPlan,
|
|
715
|
+
job: WorkerJob,
|
|
716
|
+
) -> dict[str, Any]:
|
|
717
|
+
if job.worker_id != REPORT_WRITER_WORKER_ID:
|
|
718
|
+
return {"ok": True, "reason": "", "steps": []}
|
|
719
|
+
try:
|
|
720
|
+
context = FinalizeContext.from_manifest(
|
|
721
|
+
project_root=plan.project_root,
|
|
722
|
+
workspace_root=plan.workspace_root,
|
|
723
|
+
manifest_path=plan.manifest_path,
|
|
724
|
+
team_state_path=plan.team_state_path,
|
|
725
|
+
data_path=job.result_path,
|
|
726
|
+
manifest=plan.manifest,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
def settle_before(step_name: str) -> None:
|
|
730
|
+
if step_name == STEP_VALIDATE_RUN:
|
|
731
|
+
_transition_worker_status(
|
|
732
|
+
plan.team_state_path,
|
|
733
|
+
job.worker_id,
|
|
734
|
+
"completed",
|
|
735
|
+
"",
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
return run_finalize(context, before_step=settle_before)
|
|
739
|
+
except FinalizeError as exc:
|
|
740
|
+
return {"ok": False, "reason": str(exc), "steps": []}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _worker_terminal_status(team_state_path: Path, worker_id: str) -> str:
|
|
744
|
+
team_state = _load_json_object(team_state_path, "team-state")
|
|
745
|
+
return _string_value(_worker_state(team_state, worker_id).get("status"))
|
|
746
|
+
|
|
747
|
+
|
|
594
748
|
def _record_dispatch(
|
|
595
749
|
team_state_path: Path, handle: WorkerHandle, attempt: int, status: str, reason: str
|
|
596
750
|
) -> None:
|
|
@@ -638,19 +792,23 @@ def _liveness_mode(backend: str) -> str:
|
|
|
638
792
|
|
|
639
793
|
|
|
640
794
|
def _update_dispatch_status(
|
|
641
|
-
team_state_path: Path,
|
|
795
|
+
team_state_path: Path, job: WorkerJob, attempt: int, status: str, reason: str
|
|
642
796
|
) -> None:
|
|
643
797
|
payload = _load_json_object(team_state_path, "team-state")
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
798
|
+
matches = [
|
|
799
|
+
record
|
|
800
|
+
for record in payload.get("workerDispatches", [])
|
|
801
|
+
if dispatch_record_matches(record, job.prompt_path, attempt)
|
|
802
|
+
]
|
|
803
|
+
if not matches:
|
|
804
|
+
return
|
|
805
|
+
# A lead may re-send a round's prompt without advancing `attempt`, which
|
|
806
|
+
# appends a second row the prompt cannot tell from the first. The newest is
|
|
807
|
+
# the one still running, so an outcome written to the older row would leave
|
|
808
|
+
# the live one running for good — the shape that wedged an await loop.
|
|
809
|
+
matches[-1]["status"] = status
|
|
810
|
+
matches[-1]["reason"] = reason
|
|
811
|
+
_write_json(team_state_path, payload)
|
|
654
812
|
|
|
655
813
|
|
|
656
814
|
def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
|
|
@@ -669,21 +827,58 @@ def _mark_roster_skips(
|
|
|
669
827
|
requested: Sequence[str],
|
|
670
828
|
options: _BuildOptions,
|
|
671
829
|
) -> None:
|
|
672
|
-
reasons = _skip_reasons(
|
|
830
|
+
reasons = _skip_reasons(
|
|
831
|
+
manifest,
|
|
832
|
+
selected,
|
|
833
|
+
requested,
|
|
834
|
+
options,
|
|
835
|
+
team_state=team_state,
|
|
836
|
+
)
|
|
673
837
|
if not reasons:
|
|
674
838
|
return
|
|
675
839
|
team_state_path = _resolve_required_path(project_root, manifest, "teamStatePath")
|
|
840
|
+
current_team_state = _load_json_object(team_state_path, "team-state")
|
|
676
841
|
for worker_id, reason in reasons.items():
|
|
842
|
+
worker = _worker_state(current_team_state, worker_id)
|
|
843
|
+
if (
|
|
844
|
+
_string_value(worker.get("status")) != "not-run"
|
|
845
|
+
or _string_value(worker.get("reason"))
|
|
846
|
+
):
|
|
847
|
+
continue
|
|
677
848
|
_transition_worker_status(team_state_path, worker_id, "not-run", reason)
|
|
678
849
|
|
|
679
850
|
|
|
680
851
|
def _skip_reasons(
|
|
681
|
-
manifest: Mapping[str, Any],
|
|
852
|
+
manifest: Mapping[str, Any],
|
|
853
|
+
selected: Sequence[str],
|
|
854
|
+
requested: Sequence[str],
|
|
855
|
+
options: _BuildOptions,
|
|
856
|
+
*,
|
|
857
|
+
team_state: Mapping[str, Any] | None = None,
|
|
682
858
|
) -> dict[str, str]:
|
|
859
|
+
recommended = _string_list(manifest.get("recommendedWorkers"))
|
|
860
|
+
if options.cli_wrapper_assignments_only:
|
|
861
|
+
recommended = [
|
|
862
|
+
worker_id
|
|
863
|
+
for worker_id in recommended
|
|
864
|
+
if _runner_for_worker(team_state or {}, worker_id) != "native-session"
|
|
865
|
+
]
|
|
683
866
|
if requested:
|
|
684
|
-
return {
|
|
867
|
+
return {
|
|
868
|
+
worker_id: (
|
|
869
|
+
"skipped by worker dispatch: worker was not requested in this invocation"
|
|
870
|
+
)
|
|
871
|
+
for worker_id in recommended
|
|
872
|
+
if worker_id not in set(selected)
|
|
873
|
+
}
|
|
685
874
|
supported = set(options.supported_worker_wrappers)
|
|
686
|
-
return {
|
|
875
|
+
return {
|
|
876
|
+
worker_id: (
|
|
877
|
+
"skipped by worker dispatch default: worker is not supported by this dispatcher"
|
|
878
|
+
)
|
|
879
|
+
for worker_id in recommended
|
|
880
|
+
if worker_id not in set(selected) and worker_id not in supported
|
|
881
|
+
}
|
|
687
882
|
|
|
688
883
|
|
|
689
884
|
_SIDEBAR_LEVELS = {
|
|
@@ -765,14 +960,27 @@ def _result_details(job: WorkerJob, attempt: int, outcome: WorkerOutcome) -> dic
|
|
|
765
960
|
}
|
|
766
961
|
|
|
767
962
|
|
|
768
|
-
def _failure_details(
|
|
963
|
+
def _failure_details(
|
|
964
|
+
job: WorkerJob,
|
|
965
|
+
attempt: int,
|
|
966
|
+
outcome: WorkerOutcome,
|
|
967
|
+
reason: str,
|
|
968
|
+
*,
|
|
969
|
+
exit_code: int | None = None,
|
|
970
|
+
) -> dict[str, Any]:
|
|
769
971
|
return {
|
|
770
972
|
"workerId": job.worker_id,
|
|
771
973
|
"attempt": attempt,
|
|
974
|
+
"maxAttempts": MAX_WORKER_ATTEMPTS,
|
|
772
975
|
"dispatchMode": BACKEND_CLI_WRAPPER if outcome.degraded_from else job.backend,
|
|
773
|
-
"exitCode": outcome.returncode,
|
|
976
|
+
"exitCode": outcome.returncode if exit_code is None else exit_code,
|
|
774
977
|
"missingCompletionPaths": [str(path) for path in outcome.missing_completion_paths],
|
|
775
978
|
"reason": reason,
|
|
979
|
+
"promptPath": str(job.prompt_path),
|
|
980
|
+
"resultPath": str(job.result_path),
|
|
981
|
+
"workerResultPath": str(job.worker_result_path),
|
|
982
|
+
"wrapperPath": str(job.wrapper_path),
|
|
983
|
+
"role": job.role,
|
|
776
984
|
}
|
|
777
985
|
|
|
778
986
|
|
|
@@ -863,7 +1071,14 @@ def _resolve_wrapper(provider: str, workspace_root: Path, options: _BuildOptions
|
|
|
863
1071
|
raise DispatchError(f"{script} not found (searched: {', '.join(str(c) for c in candidates)})")
|
|
864
1072
|
|
|
865
1073
|
|
|
866
|
-
def _provider_for_worker(
|
|
1074
|
+
def _provider_for_worker(
|
|
1075
|
+
worker_id: str,
|
|
1076
|
+
options: _BuildOptions | None = None,
|
|
1077
|
+
) -> str:
|
|
1078
|
+
if options is not None:
|
|
1079
|
+
configured = options.default_provider_by_worker_id.get(worker_id)
|
|
1080
|
+
if configured:
|
|
1081
|
+
return configured
|
|
867
1082
|
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
868
1083
|
return "claude"
|
|
869
1084
|
return worker_id
|
|
@@ -30,6 +30,7 @@ from .worker_prompt_contract import (
|
|
|
30
30
|
validate_initial_prompt_records,
|
|
31
31
|
validate_reverify_prompt,
|
|
32
32
|
)
|
|
33
|
+
from .worker_runner import LIVE, QUIET
|
|
33
34
|
|
|
34
35
|
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
35
36
|
BACKEND_TMUX_PANE = "tmux-pane"
|
|
@@ -91,6 +92,7 @@ class WorkerJob:
|
|
|
91
92
|
|
|
92
93
|
@property
|
|
93
94
|
def command(self) -> list[str]:
|
|
95
|
+
# The flag trails the positional contract the entrypoint reads by index.
|
|
94
96
|
return [
|
|
95
97
|
str(self.wrapper_path),
|
|
96
98
|
str(self.project_root),
|
|
@@ -99,8 +101,16 @@ class WorkerJob:
|
|
|
99
101
|
self.worktree_path,
|
|
100
102
|
self.role,
|
|
101
103
|
str(self.idle_timeout_seconds),
|
|
104
|
+
"--presentation",
|
|
105
|
+
self._presentation(),
|
|
102
106
|
]
|
|
103
107
|
|
|
108
|
+
def _presentation(self) -> str:
|
|
109
|
+
# A pane is a screen a person watches; a cli-wrapper dispatch's stdout is
|
|
110
|
+
# a subagent's context window. Progress belongs in the first and not the
|
|
111
|
+
# second.
|
|
112
|
+
return QUIET if self.backend == BACKEND_CLI_WRAPPER else LIVE
|
|
113
|
+
|
|
104
114
|
def to_payload(self) -> dict[str, Any]:
|
|
105
115
|
return {
|
|
106
116
|
"workerId": self.worker_id,
|
|
@@ -430,3 +440,20 @@ def _worker_job_from_file(
|
|
|
430
440
|
|
|
431
441
|
def utc_now() -> str:
|
|
432
442
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def dispatch_record_matches(record: object, prompt_path: Path, attempt: int) -> bool:
|
|
446
|
+
"""Whether this team-state row is the dispatch handed `prompt_path`.
|
|
447
|
+
|
|
448
|
+
The prompt file identifies a dispatch: each round writes its own, and a
|
|
449
|
+
second run of the same one is a retry, which `attempt` separates. Worker id
|
|
450
|
+
and dispatch kind cannot carry that — `--dispatch-kind` is the lead's to
|
|
451
|
+
pick and it has reused one across two rounds, which put the finished
|
|
452
|
+
round's completion on the older namesake row and left the row that actually
|
|
453
|
+
ran marked running, so an await re-collected that worker forever.
|
|
454
|
+
"""
|
|
455
|
+
return (
|
|
456
|
+
isinstance(record, dict)
|
|
457
|
+
and record.get("promptPath") == str(prompt_path)
|
|
458
|
+
and record.get("attempt") == attempt
|
|
459
|
+
)
|
|
@@ -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."""
|