okstra 0.160.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -0
- package/docs/cli.md +17 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra.sh +46 -13
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +67 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +27 -0
- package/runtime/python/okstra_ctl/cmux.py +595 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1 -13
- package/runtime/python/okstra_ctl/dispatch_core.py +101 -16
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/incremental_scope.py +37 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/models.py +87 -0
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/run.py +99 -7
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/python/okstra_ctl/wizard.py +171 -18
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +37 -3
- package/src/commands/execute/run.mjs +117 -30
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
import os
|
|
6
5
|
import subprocess
|
|
7
6
|
import time
|
|
8
7
|
from dataclasses import dataclass
|
|
@@ -10,9 +9,11 @@ from datetime import datetime, timezone
|
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
from typing import Any, Mapping, Sequence
|
|
12
11
|
|
|
12
|
+
from . import cmux
|
|
13
13
|
from . import tmux
|
|
14
14
|
from .dispatch_state import (
|
|
15
15
|
BACKEND_CLI_WRAPPER,
|
|
16
|
+
BACKEND_CMUX_PANE,
|
|
16
17
|
BACKEND_MIXED,
|
|
17
18
|
BACKEND_TMUX_PANE,
|
|
18
19
|
DispatchError,
|
|
@@ -185,11 +186,19 @@ def build_dispatch_plan(
|
|
|
185
186
|
|
|
186
187
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
187
188
|
if wait:
|
|
188
|
-
|
|
189
|
+
pane_backends = sorted(
|
|
190
|
+
{
|
|
191
|
+
job.backend
|
|
192
|
+
for job in plan.jobs
|
|
193
|
+
if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
if pane_backends:
|
|
189
197
|
raise DispatchError(
|
|
190
|
-
"wait=True dispatch does not support
|
|
191
|
-
"the per-job blocking loop would serialize panes
|
|
192
|
-
"running them concurrently; dispatch
|
|
198
|
+
f"wait=True dispatch does not support {'/'.join(pane_backends)} "
|
|
199
|
+
"workers: the per-job blocking loop would serialize panes "
|
|
200
|
+
"instead of running them concurrently; dispatch panes with "
|
|
201
|
+
"wait=False"
|
|
193
202
|
)
|
|
194
203
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
|
|
195
204
|
for job in plan.jobs:
|
|
@@ -427,9 +436,30 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
427
436
|
return _run_cli_wrapper(plan, job, "")
|
|
428
437
|
if job.backend == BACKEND_TMUX_PANE:
|
|
429
438
|
return _start_tmux_or_degrade(plan, job)
|
|
439
|
+
if job.backend == BACKEND_CMUX_PANE:
|
|
440
|
+
return _start_cmux_or_degrade(plan, job)
|
|
430
441
|
raise DispatchError(f"unsupported worker backend: {job.backend}")
|
|
431
442
|
|
|
432
443
|
|
|
444
|
+
def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
445
|
+
workspace = cmux.resolve_lead_workspace()
|
|
446
|
+
if not workspace:
|
|
447
|
+
_refuse_when_cmux_is_walled_off()
|
|
448
|
+
return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
|
|
449
|
+
try:
|
|
450
|
+
surface_id = cmux.spawn_worker_surface(
|
|
451
|
+
workspace=workspace,
|
|
452
|
+
cwd=plan.project_root,
|
|
453
|
+
command=job.command,
|
|
454
|
+
title=f"{job.worker_id}-worker",
|
|
455
|
+
)
|
|
456
|
+
except (RuntimeError, OSError, subprocess.SubprocessError):
|
|
457
|
+
return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
|
|
458
|
+
return WorkerHandle(
|
|
459
|
+
job, surface_id, None, status_path_for_prompt(job.prompt_path), ""
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
433
463
|
def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
434
464
|
lead_pane = tmux.resolve_caller_pane()
|
|
435
465
|
if not lead_pane:
|
|
@@ -447,17 +477,35 @@ def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
447
477
|
return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
|
|
448
478
|
|
|
449
479
|
|
|
480
|
+
def _refuse_when_cmux_is_walled_off() -> None:
|
|
481
|
+
"""Stop rather than degrade when a sandbox stands between okstra and cmux.
|
|
482
|
+
|
|
483
|
+
prepare records `cmux-pane` only after reaching cmux, so losing it by
|
|
484
|
+
dispatch time means something came between. When the socket is present but
|
|
485
|
+
connecting is denied, that something is a sandbox around the lead — and the
|
|
486
|
+
same sandbox hides the worker CLIs' own config, so the fallback this would
|
|
487
|
+
otherwise take is already dead. Degrading here spends every worker's retry
|
|
488
|
+
budget on the same wall and leaves the user with three failures and no
|
|
489
|
+
explanation, which is exactly what it looks like when nothing happens.
|
|
490
|
+
"""
|
|
491
|
+
reason = cmux.unreachable_reason()
|
|
492
|
+
if reason not in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
|
|
493
|
+
return
|
|
494
|
+
observed = (
|
|
495
|
+
"this process has no cmux environment left — a sandbox sanitized it"
|
|
496
|
+
if reason == cmux.LOST_ENVIRONMENT
|
|
497
|
+
else "cmux is running but this process may not connect to its socket"
|
|
498
|
+
)
|
|
499
|
+
raise DispatchError(
|
|
500
|
+
f"this run was prepared for cmux panes, but {observed}. The same "
|
|
501
|
+
"sandbox blocks worker CLIs from their own config, so falling back to "
|
|
502
|
+
"blocking workers would fail too. Relaunch the lead outside a sandbox "
|
|
503
|
+
"(codex: `codex -s danger-full-access`)."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
450
507
|
def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
|
|
451
|
-
|
|
452
|
-
**os.environ,
|
|
453
|
-
"OKSTRA_WORKER_ID": job.worker_id,
|
|
454
|
-
"OKSTRA_WORKER_RESULT_PATH": str(job.result_path),
|
|
455
|
-
"OKSTRA_WORKER_AUDIT_PATH": str(job.worker_result_path),
|
|
456
|
-
"OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
|
|
457
|
-
}
|
|
458
|
-
if job.worker_id == REPORT_WRITER_WORKER_ID:
|
|
459
|
-
env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(_final_report_markdown_path(job.result_path))
|
|
460
|
-
completed = subprocess.run(job.command, cwd=plan.project_root, env=env, text=True)
|
|
508
|
+
completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
|
|
461
509
|
return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
|
|
462
510
|
|
|
463
511
|
|
|
@@ -561,7 +609,7 @@ def _dispatch_record(
|
|
|
561
609
|
|
|
562
610
|
|
|
563
611
|
def _liveness_mode(backend: str) -> str:
|
|
564
|
-
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
|
|
612
|
+
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
|
|
565
613
|
return LIVENESS_WRAPPER_STATUS
|
|
566
614
|
return LIVENESS_AUDIT_HEARTBEAT
|
|
567
615
|
|
|
@@ -615,7 +663,44 @@ def _skip_reasons(
|
|
|
615
663
|
return {w: "skipped by worker dispatch default: worker is not supported by this dispatcher" for w in _string_list(manifest.get("recommendedWorkers")) if w not in set(selected) and w not in supported}
|
|
616
664
|
|
|
617
665
|
|
|
666
|
+
_SIDEBAR_LEVELS = {
|
|
667
|
+
"worker-dispatched": "progress",
|
|
668
|
+
"worker-result-collected": "success",
|
|
669
|
+
"worker-retry-scheduled": "warning",
|
|
670
|
+
"worker-failed": "error",
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
675
|
+
"""Mirror a dispatch event onto the cmux sidebar.
|
|
676
|
+
|
|
677
|
+
A long run is mostly silence, and the sidebar is the one surface still
|
|
678
|
+
visible after the user scrolls away or switches workspaces. A failed worker
|
|
679
|
+
additionally raises a notification, because that is the event whose cost
|
|
680
|
+
grows the longer it goes unnoticed.
|
|
681
|
+
"""
|
|
682
|
+
# The plan's backend, not the manifest field it was resolved from: a job
|
|
683
|
+
# that degraded to the blocking wrapper is still part of a cmux run, and
|
|
684
|
+
# that degradation is exactly what the sidebar should keep showing.
|
|
685
|
+
if plan.default_backend != BACKEND_CMUX_PANE:
|
|
686
|
+
return
|
|
687
|
+
workspace = cmux.resolve_lead_workspace()
|
|
688
|
+
worker_id = str(details.get("workerId", "") or "worker")
|
|
689
|
+
cmux.sidebar_log(
|
|
690
|
+
workspace,
|
|
691
|
+
f"{worker_id}: {event_type.removeprefix('worker-')}",
|
|
692
|
+
level=_SIDEBAR_LEVELS.get(event_type, "info"),
|
|
693
|
+
)
|
|
694
|
+
if event_type == "worker-failed":
|
|
695
|
+
cmux.sidebar_notify(
|
|
696
|
+
workspace,
|
|
697
|
+
title=f"okstra — {_require_string(plan.manifest, 'taskType')}",
|
|
698
|
+
body=f"{worker_id} failed: {details.get('reason', 'no reason recorded')}",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
618
702
|
def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
703
|
+
_relay_to_sidebar(plan, event_type, details)
|
|
619
704
|
append_lead_event(
|
|
620
705
|
plan.lead_events_path,
|
|
621
706
|
LeadEvent(
|
|
@@ -24,6 +24,7 @@ from datetime import datetime, timezone
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import Any, Callable, Mapping, Sequence
|
|
26
26
|
|
|
27
|
+
from . import cmux
|
|
27
28
|
from .worker_prompt_contract import (
|
|
28
29
|
PromptRecord,
|
|
29
30
|
validate_initial_prompt_records,
|
|
@@ -32,8 +33,23 @@ from .worker_prompt_contract import (
|
|
|
32
33
|
|
|
33
34
|
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
34
35
|
BACKEND_TMUX_PANE = "tmux-pane"
|
|
36
|
+
BACKEND_CMUX_PANE = "cmux-pane"
|
|
35
37
|
BACKEND_MIXED = "mixed"
|
|
36
38
|
|
|
39
|
+
|
|
40
|
+
def detect_terminal_backend() -> str:
|
|
41
|
+
"""Which pane backend this run gets. Called once, by prepare.
|
|
42
|
+
|
|
43
|
+
cmux wins wherever it is usable: the only thing a tmux session buys a lead
|
|
44
|
+
is Claude Code's AgentTeam, and the cmux path deliberately does not use it.
|
|
45
|
+
Everywhere else this answers `tmux-pane`, which is what every run got before
|
|
46
|
+
cmux existed. The answer is written to the run manifest and read back from
|
|
47
|
+
there — consumers must not re-detect, or two phases of one run can disagree.
|
|
48
|
+
"""
|
|
49
|
+
if cmux.cmux_available():
|
|
50
|
+
return BACKEND_CMUX_PANE
|
|
51
|
+
return BACKEND_TMUX_PANE
|
|
52
|
+
|
|
37
53
|
# `livenessMode` picks which artifact answers "is this worker still alive": the
|
|
38
54
|
# in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
|
|
39
55
|
# sidecar. Both dispatchers write it and `worker_liveness` reads it, so the
|
|
@@ -42,6 +42,43 @@ class IncrementalDecision:
|
|
|
42
42
|
reason: str
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class UserReverifyScope:
|
|
47
|
+
"""What the user answered at the wizard's re-verification-scope step.
|
|
48
|
+
|
|
49
|
+
The lead still runs `okstra incremental-scope`; this only says which of the
|
|
50
|
+
CLI's inputs the user pinned. `auto` pins nothing.
|
|
51
|
+
"""
|
|
52
|
+
mode: str # "auto" | "full" | "stages"
|
|
53
|
+
stages: list[int]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReverifyScopeError(ValueError):
|
|
57
|
+
"""The `--reverify-scope` value is not one of the three accepted forms."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_user_reverify_scope(raw: str) -> UserReverifyScope:
|
|
61
|
+
"""`--reverify-scope` → the user's pinned scope.
|
|
62
|
+
|
|
63
|
+
Accepts exactly `""` / `auto`, `full`, or a comma-separated stage list.
|
|
64
|
+
Anything else raises rather than degrading to `auto`: a typo silently read
|
|
65
|
+
as "let the lead decide" would drop a full-re-verification request the user
|
|
66
|
+
made on purpose, and the run would look like it honoured it.
|
|
67
|
+
"""
|
|
68
|
+
value = (raw or "").strip()
|
|
69
|
+
if not value or value == "auto":
|
|
70
|
+
return UserReverifyScope("auto", [])
|
|
71
|
+
if value == "full":
|
|
72
|
+
return UserReverifyScope("full", [])
|
|
73
|
+
tokens = [token.strip() for token in value.split(",") if token.strip()]
|
|
74
|
+
if not tokens or not all(token.isdigit() for token in tokens):
|
|
75
|
+
raise ReverifyScopeError(
|
|
76
|
+
f"--reverify-scope must be 'auto', 'full', or a stage-number list "
|
|
77
|
+
f"(e.g. '2,3'); got {raw!r}"
|
|
78
|
+
)
|
|
79
|
+
return UserReverifyScope("stages", sorted({int(token) for token in tokens}))
|
|
80
|
+
|
|
81
|
+
|
|
45
82
|
def _parse_depends_on(cell: str) -> list[int]:
|
|
46
83
|
text = (cell or "").strip()
|
|
47
84
|
if not text or text == "(none)":
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Lead runtime metadata shared by render and prepare paths."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
@dataclass(frozen=True)
|
|
@@ -24,9 +24,14 @@ class LeadRuntimeInfo:
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex", "antigravity", "external")
|
|
28
27
|
ARTIFACT_ONLY_LEAD_RUNTIMES = frozenset({"codex", "antigravity", "external"})
|
|
29
28
|
|
|
29
|
+
# Not a lead runtime — an adapter selected by the environment, so it is absent
|
|
30
|
+
# from ALLOWED_LEAD_RUNTIMES and from _LEAD_RUNTIMES on purpose. `--lead-runtime
|
|
31
|
+
# cmux` is not a thing a user can ask for; see `with_cmux_dispatch`.
|
|
32
|
+
CMUX_ADAPTER_NAME = "cmux"
|
|
33
|
+
CMUX_ADAPTER_RELATIVE_PATH = "lead/adapters/cmux.md"
|
|
34
|
+
|
|
30
35
|
_LEAD_RUNTIMES = {
|
|
31
36
|
"claude-code": LeadRuntimeInfo(
|
|
32
37
|
runtime="claude-code",
|
|
@@ -75,6 +80,11 @@ _LEAD_RUNTIMES = {
|
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
|
|
83
|
+
# Derived rather than restated: a hand-written copy of the registry's own keys
|
|
84
|
+
# is a list that can disagree with the thing it describes.
|
|
85
|
+
ALLOWED_LEAD_RUNTIMES = tuple(_LEAD_RUNTIMES)
|
|
86
|
+
|
|
87
|
+
|
|
78
88
|
def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
|
|
79
89
|
try:
|
|
80
90
|
return _LEAD_RUNTIMES[runtime]
|
|
@@ -85,3 +95,21 @@ def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
|
|
|
85
95
|
|
|
86
96
|
def is_artifact_only_runtime(runtime: str) -> bool:
|
|
87
97
|
return runtime in ARTIFACT_ONLY_LEAD_RUNTIMES
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def with_cmux_dispatch(info: LeadRuntimeInfo) -> LeadRuntimeInfo:
|
|
101
|
+
"""The same lead, minus the two fields cmux takes over.
|
|
102
|
+
|
|
103
|
+
On the cmux path okstra owns the worker panes rather than the host, so
|
|
104
|
+
"which adapter" and "does this lead dispatch or only render" stop depending
|
|
105
|
+
on who the lead is — they are the same for every runtime. Overriding the two
|
|
106
|
+
fields keeps that answer in one adapter file rather than repeating it in
|
|
107
|
+
four. Everything else (agent, label, role, session accounting) still
|
|
108
|
+
describes the lead itself and is left untouched.
|
|
109
|
+
"""
|
|
110
|
+
return replace(
|
|
111
|
+
info,
|
|
112
|
+
adapter_name=CMUX_ADAPTER_NAME,
|
|
113
|
+
adapter_dispatch_mode="team",
|
|
114
|
+
adapter_contract_relative_path=CMUX_ADAPTER_RELATIVE_PATH,
|
|
115
|
+
)
|
|
@@ -94,6 +94,26 @@ ANALYSIS_ROLES = frozenset({"analyser", "critic"})
|
|
|
94
94
|
IMPLEMENTATION_ROLES = frozenset({"executor", "verifier"})
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class LeadLaunchSpec:
|
|
99
|
+
"""How to start this provider's CLI as an interactive lead session.
|
|
100
|
+
|
|
101
|
+
The worker wrappers cannot serve here: they run one non-interactive turn and
|
|
102
|
+
exit, while a lead owns the session the user talks to. Empty `prompt_flag`
|
|
103
|
+
means the CLI takes the prompt positionally; empty `session_id_flag` means it
|
|
104
|
+
has no resumable session id and resumes from artifacts instead.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
executable: str
|
|
108
|
+
model_flag: str
|
|
109
|
+
prompt_flag: str = ""
|
|
110
|
+
session_id_flag: str = ""
|
|
111
|
+
# Flags that lift the CLI's own sandbox, and what the user is agreeing to by
|
|
112
|
+
# accepting them. Empty when the CLI does not sandbox itself by default.
|
|
113
|
+
sandbox_waiver: tuple[str, ...] = ()
|
|
114
|
+
sandbox_waiver_note: str = ""
|
|
115
|
+
|
|
116
|
+
|
|
97
117
|
@dataclass(frozen=True)
|
|
98
118
|
class ProviderSpec:
|
|
99
119
|
provider: str
|
|
@@ -102,6 +122,7 @@ class ProviderSpec:
|
|
|
102
122
|
default_models: Mapping[str, str]
|
|
103
123
|
wrapper: str
|
|
104
124
|
supported_roles: frozenset[str]
|
|
125
|
+
lead_launch: Optional[LeadLaunchSpec] = None
|
|
105
126
|
|
|
106
127
|
|
|
107
128
|
PROVIDERS = {
|
|
@@ -121,6 +142,11 @@ PROVIDERS = {
|
|
|
121
142
|
supported_roles=frozenset(
|
|
122
143
|
{"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
|
|
123
144
|
),
|
|
145
|
+
lead_launch=LeadLaunchSpec(
|
|
146
|
+
executable="claude",
|
|
147
|
+
model_flag="--model",
|
|
148
|
+
session_id_flag="--session-id",
|
|
149
|
+
),
|
|
124
150
|
),
|
|
125
151
|
"antigravity": ProviderSpec(
|
|
126
152
|
provider="antigravity",
|
|
@@ -133,6 +159,13 @@ PROVIDERS = {
|
|
|
133
159
|
supported_roles=frozenset(
|
|
134
160
|
{"lead", "analyser", "critic", "executor", "verifier"}
|
|
135
161
|
),
|
|
162
|
+
lead_launch=LeadLaunchSpec(
|
|
163
|
+
executable="agy",
|
|
164
|
+
model_flag="--model",
|
|
165
|
+
# Without this the prompt runs once and the session ends; okstra
|
|
166
|
+
# needs the lead to stay and drive the remaining phases.
|
|
167
|
+
prompt_flag="--prompt-interactive",
|
|
168
|
+
),
|
|
136
169
|
),
|
|
137
170
|
"codex": ProviderSpec(
|
|
138
171
|
provider="codex",
|
|
@@ -145,6 +178,20 @@ PROVIDERS = {
|
|
|
145
178
|
supported_roles=frozenset(
|
|
146
179
|
{"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
|
|
147
180
|
),
|
|
181
|
+
lead_launch=LeadLaunchSpec(
|
|
182
|
+
executable="codex",
|
|
183
|
+
model_flag="-m",
|
|
184
|
+
# A codex session sandboxes itself, and everything it spawns inherits
|
|
185
|
+
# that — okstra's dispatch and every worker CLI with it. Measured:
|
|
186
|
+
# the cmux socket returns EPERM and each worker dies unable to write
|
|
187
|
+
# its own config. A sandboxed lead cannot run okstra at all.
|
|
188
|
+
sandbox_waiver=("-s", "danger-full-access"),
|
|
189
|
+
sandbox_waiver_note=(
|
|
190
|
+
"codex will start without its filesystem and network sandbox, "
|
|
191
|
+
"which okstra needs so the lead can reach cmux and start worker "
|
|
192
|
+
"CLIs."
|
|
193
|
+
),
|
|
194
|
+
),
|
|
148
195
|
),
|
|
149
196
|
"grok": ProviderSpec(
|
|
150
197
|
provider="grok",
|
|
@@ -190,6 +237,46 @@ class UnknownProviderError(ValueError):
|
|
|
190
237
|
"""Raised when a requested provider is absent from the registry."""
|
|
191
238
|
|
|
192
239
|
|
|
240
|
+
def lead_launch_spec(provider: str) -> LeadLaunchSpec:
|
|
241
|
+
"""The launch spec for a provider that can lead, or an error naming why not."""
|
|
242
|
+
spec = provider_spec(provider)
|
|
243
|
+
if spec.lead_launch is None:
|
|
244
|
+
raise UnknownProviderError(
|
|
245
|
+
f"provider {provider!r} cannot act as a lead: no launch spec"
|
|
246
|
+
)
|
|
247
|
+
return spec.lead_launch
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def lead_launch_argv(
|
|
251
|
+
provider: str,
|
|
252
|
+
*,
|
|
253
|
+
model: str,
|
|
254
|
+
prompt: str,
|
|
255
|
+
session_id: str = "",
|
|
256
|
+
waive_sandbox: bool = True,
|
|
257
|
+
) -> list[str]:
|
|
258
|
+
"""The argv that starts this provider's CLI as the lead for one run.
|
|
259
|
+
|
|
260
|
+
The sandbox waiver is applied by default because a sandboxed lead cannot run
|
|
261
|
+
okstra at all — it reaches neither cmux nor the worker CLIs' own config. The
|
|
262
|
+
caller still owns telling the user and collecting their answer; passing
|
|
263
|
+
False produces the un-waived argv so a declined confirmation can show what
|
|
264
|
+
would otherwise have run.
|
|
265
|
+
"""
|
|
266
|
+
launch = lead_launch_spec(provider)
|
|
267
|
+
argv = [launch.executable]
|
|
268
|
+
if waive_sandbox:
|
|
269
|
+
argv.extend(launch.sandbox_waiver)
|
|
270
|
+
if model:
|
|
271
|
+
argv.extend([launch.model_flag, model])
|
|
272
|
+
if session_id and launch.session_id_flag:
|
|
273
|
+
argv.extend([launch.session_id_flag, session_id])
|
|
274
|
+
if launch.prompt_flag:
|
|
275
|
+
argv.append(launch.prompt_flag)
|
|
276
|
+
argv.append(prompt)
|
|
277
|
+
return argv
|
|
278
|
+
|
|
279
|
+
|
|
193
280
|
def provider_spec(provider: str) -> ProviderSpec:
|
|
194
281
|
"""Return one registered provider or fail before assignment/dispatch."""
|
|
195
282
|
normalized = (provider or "").strip().lower()
|
|
@@ -31,7 +31,8 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
|
|
|
31
31
|
from . import fix_cycles
|
|
32
32
|
from .analysis_inputs import ANALYSIS_TASK_TYPES
|
|
33
33
|
from .paths import okstra_home
|
|
34
|
-
from .
|
|
34
|
+
from .dispatch_state import BACKEND_CMUX_PANE
|
|
35
|
+
from .lead_runtime import lead_runtime_info, with_cmux_dispatch
|
|
35
36
|
from .models import UnknownProviderError, provider_ids, provider_spec
|
|
36
37
|
from .runner_resolution import native_provider_for_host
|
|
37
38
|
from .path_hints import compact_active_run_context, hydrate_run_context
|
|
@@ -80,7 +81,10 @@ def _lead_runtime(ctx: dict) -> str:
|
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def _lead_info(ctx: dict):
|
|
83
|
-
|
|
84
|
+
info = lead_runtime_info(_lead_runtime(ctx))
|
|
85
|
+
if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
|
|
86
|
+
return with_cmux_dispatch(info)
|
|
87
|
+
return info
|
|
84
88
|
|
|
85
89
|
|
|
86
90
|
def _lead_agent(ctx: dict) -> str:
|
|
@@ -1444,6 +1448,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1444
1448
|
"hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
|
|
1445
1449
|
"leadRuntime": _lead_runtime(ctx),
|
|
1446
1450
|
"leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
|
|
1451
|
+
"terminalBackend": ctx.get("TERMINAL_BACKEND", ""),
|
|
1447
1452
|
"runtimeResolution": _runtime_resolution(ctx),
|
|
1448
1453
|
"leadAssignment": _lead_assignment(ctx),
|
|
1449
1454
|
"workerAssignments": _worker_assignments(ctx),
|
|
@@ -46,6 +46,7 @@ from .clarification_items import (
|
|
|
46
46
|
scan_approval_gate,
|
|
47
47
|
)
|
|
48
48
|
from .error_report import prior_run_error_digest
|
|
49
|
+
from .incremental_scope import ReverifyScopeError, parse_user_reverify_scope
|
|
49
50
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
50
51
|
from .material import (
|
|
51
52
|
build_analysis_material,
|
|
@@ -65,6 +66,8 @@ from .model_discovery import normalize_execution_for_dispatch
|
|
|
65
66
|
from .models import (
|
|
66
67
|
ModelAssignment,
|
|
67
68
|
default_model,
|
|
69
|
+
lead_launch_argv,
|
|
70
|
+
lead_launch_spec,
|
|
68
71
|
provider_default_model,
|
|
69
72
|
provider_ids,
|
|
70
73
|
provider_spec,
|
|
@@ -96,6 +99,7 @@ from .render import (
|
|
|
96
99
|
)
|
|
97
100
|
from okstra_project.dirs import okstra_home
|
|
98
101
|
|
|
102
|
+
from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
|
|
99
103
|
from .run_context import (
|
|
100
104
|
compute_and_write_run_context,
|
|
101
105
|
refresh_run_context_snapshot,
|
|
@@ -372,6 +376,10 @@ class PrepareInputs:
|
|
|
372
376
|
# 별개 채널이다.
|
|
373
377
|
stages: str = ""
|
|
374
378
|
clarification_response_path: str = "" # absolute or empty
|
|
379
|
+
# implementation-planning 전용: 사용자가 고른 이번 재실행의 재검증 범위.
|
|
380
|
+
# "" / "auto" = 리드의 `okstra incremental-scope` 판정에 맡김, "full" =
|
|
381
|
+
# 전체 재검증 강제, "<stage csv>" = 그 stage 들을 impacted 로 지정.
|
|
382
|
+
reverify_scope: str = ""
|
|
375
383
|
# release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
|
|
376
384
|
# project.json → global config → 스킬 디폴트 순으로 해석된다.
|
|
377
385
|
pr_template_path: str = ""
|
|
@@ -1015,9 +1023,35 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
1015
1023
|
raise PrepareError(
|
|
1016
1024
|
f"clarification response file not found: {inp.clarification_response_path}"
|
|
1017
1025
|
)
|
|
1026
|
+
_validate_reverify_scope(inp)
|
|
1018
1027
|
return ctx_stage_map
|
|
1019
1028
|
|
|
1020
1029
|
|
|
1030
|
+
def _validate_reverify_scope(inp: PrepareInputs) -> None:
|
|
1031
|
+
"""A pinned re-verification scope is only actionable on a planning re-run.
|
|
1032
|
+
|
|
1033
|
+
Every other phase renders the tokens too (the template always reads them),
|
|
1034
|
+
but nothing consumes them there — so a value outside the one phase that
|
|
1035
|
+
acts on it is a caller mistake, not a preference to honour silently.
|
|
1036
|
+
"""
|
|
1037
|
+
if not (inp.reverify_scope or "").strip():
|
|
1038
|
+
return
|
|
1039
|
+
if inp.task_type != "implementation-planning":
|
|
1040
|
+
raise PrepareError(
|
|
1041
|
+
"--reverify-scope is only meaningful with --task-type "
|
|
1042
|
+
f"implementation-planning; got {inp.task_type}"
|
|
1043
|
+
)
|
|
1044
|
+
if not inp.clarification_response_path:
|
|
1045
|
+
raise PrepareError(
|
|
1046
|
+
"--reverify-scope needs --clarification-response: there is no prior "
|
|
1047
|
+
"report to narrow re-verification against"
|
|
1048
|
+
)
|
|
1049
|
+
try:
|
|
1050
|
+
parse_user_reverify_scope(inp.reverify_scope)
|
|
1051
|
+
except ReverifyScopeError as exc:
|
|
1052
|
+
raise PrepareError(str(exc)) from exc
|
|
1053
|
+
|
|
1054
|
+
|
|
1021
1055
|
def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
|
|
1022
1056
|
"""Apply approved-plan inputs only after canonical brief preflight succeeds."""
|
|
1023
1057
|
if inp.approve_plan_ack or inp.implementation_option:
|
|
@@ -2275,6 +2309,21 @@ def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
|
|
|
2275
2309
|
}
|
|
2276
2310
|
|
|
2277
2311
|
|
|
2312
|
+
def _reverify_scope_ctx(raw: str) -> dict[str, str]:
|
|
2313
|
+
"""Render tokens for the re-verification scope the user pinned.
|
|
2314
|
+
|
|
2315
|
+
Always emitted: the lead prompt reads both tokens unconditionally, and an
|
|
2316
|
+
absent one is a render failure rather than a silent `auto`.
|
|
2317
|
+
"""
|
|
2318
|
+
scope = parse_user_reverify_scope(raw)
|
|
2319
|
+
return {
|
|
2320
|
+
"REVERIFY_SCOPE_MODE": scope.mode,
|
|
2321
|
+
"REVERIFY_SCOPE_STAGES": (
|
|
2322
|
+
",".join(str(num) for num in scope.stages) or "(none)"
|
|
2323
|
+
),
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
|
|
2278
2327
|
def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
|
|
2279
2328
|
"""Render tokens for every model binding this run resolved."""
|
|
2280
2329
|
return {
|
|
@@ -2394,7 +2443,17 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2394
2443
|
json.loads(runtime_resolution_json or "{}")
|
|
2395
2444
|
except json.JSONDecodeError as exc:
|
|
2396
2445
|
raise PrepareError(f"invalid --runtime-resolution-json: {exc}") from exc
|
|
2397
|
-
|
|
2446
|
+
# Probed once here and reused below, so the gate and the manifest cannot
|
|
2447
|
+
# disagree about which backend this run is on.
|
|
2448
|
+
terminal_backend = detect_terminal_backend()
|
|
2449
|
+
# Outside cmux only claude-code has a dispatch backend of its own. Under
|
|
2450
|
+
# cmux okstra owns the panes for every lead, so the render-only restriction
|
|
2451
|
+
# no longer applies to any runtime.
|
|
2452
|
+
if (
|
|
2453
|
+
lead_runtime != "claude-code"
|
|
2454
|
+
and terminal_backend != BACKEND_CMUX_PANE
|
|
2455
|
+
and not inp.render_only
|
|
2456
|
+
):
|
|
2398
2457
|
raise PrepareError(
|
|
2399
2458
|
f"lead runtime `{lead_runtime}` is currently render-only; "
|
|
2400
2459
|
"use --render-only until a dispatch backend is enabled for this lead runtime."
|
|
@@ -2495,6 +2554,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2495
2554
|
)
|
|
2496
2555
|
|
|
2497
2556
|
ctx.update({
|
|
2557
|
+
"TERMINAL_BACKEND": terminal_backend,
|
|
2498
2558
|
"EXECUTOR_WORKTREE_PATH": worktree.path,
|
|
2499
2559
|
"EXECUTOR_WORKTREE_BRANCH": worktree.branch,
|
|
2500
2560
|
"EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
|
|
@@ -2565,6 +2625,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2565
2625
|
"CLAUDE_SESSION_ID": claude_session_id,
|
|
2566
2626
|
"CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
|
|
2567
2627
|
"CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
|
|
2628
|
+
**_reverify_scope_ctx(inp.reverify_scope),
|
|
2568
2629
|
"BRIEF_FILE_PATH": str(inp.brief_path),
|
|
2569
2630
|
"BRIEF_RELATIVE_PATH": brief_relative,
|
|
2570
2631
|
**_model_ctx(models),
|
|
@@ -2749,6 +2810,17 @@ def main(argv: list[str]) -> int:
|
|
|
2749
2810
|
),
|
|
2750
2811
|
)
|
|
2751
2812
|
p.add_argument("--clarification-response", default="", dest="clarification_response_path")
|
|
2813
|
+
p.add_argument(
|
|
2814
|
+
"--reverify-scope",
|
|
2815
|
+
default="",
|
|
2816
|
+
dest="reverify_scope",
|
|
2817
|
+
help=(
|
|
2818
|
+
"implementation-planning 재실행 전용. 사용자가 고른 재검증 범위. "
|
|
2819
|
+
"'' / 'auto' = 리드의 incremental-scope 판정에 맡김(기본), "
|
|
2820
|
+
"'full' = 전체 재검증 강제, '<stage csv>' (예: '2,3') = 그 stage 를 "
|
|
2821
|
+
"impacted 로 지정."
|
|
2822
|
+
),
|
|
2823
|
+
)
|
|
2752
2824
|
p.add_argument(
|
|
2753
2825
|
"--pr-template-path",
|
|
2754
2826
|
default="",
|
|
@@ -2858,6 +2930,7 @@ def main(argv: list[str]) -> int:
|
|
|
2858
2930
|
stage=args.stage,
|
|
2859
2931
|
stages=args.stages,
|
|
2860
2932
|
clarification_response_path=clarification_abs,
|
|
2933
|
+
reverify_scope=args.reverify_scope,
|
|
2861
2934
|
pr_template_path=args.pr_template_path,
|
|
2862
2935
|
render_only=args.render_only,
|
|
2863
2936
|
approve_plan_ack=args.approve_plan_ack,
|
|
@@ -2891,19 +2964,38 @@ def main(argv: list[str]) -> int:
|
|
|
2891
2964
|
else:
|
|
2892
2965
|
print(f"okstra current run dir: {ctx['RUN_DIR']}")
|
|
2893
2966
|
print(f"final report path: {ctx['FINAL_REPORT_PATH']}")
|
|
2967
|
+
lead_runtime_name = ctx.get("LEAD_RUNTIME", "claude-code")
|
|
2968
|
+
lead_provider = lead_runtime_info(lead_runtime_name).agent
|
|
2969
|
+
launch = lead_launch_spec(lead_provider)
|
|
2894
2970
|
print(f"lead model: {ctx['LEAD_MODEL']}")
|
|
2895
2971
|
print(f"claude session id: {ctx['CLAUDE_SESSION_ID']}")
|
|
2896
2972
|
print(f"resume command file: {ctx['CLAUDE_RESUME_COMMAND_PATH']}")
|
|
2897
|
-
print("launch mode: interactive
|
|
2898
|
-
print(f"
|
|
2973
|
+
print(f"launch mode: interactive {launch.executable} handoff")
|
|
2974
|
+
print(f"lead working directory: {ctx['PROJECT_ROOT']}")
|
|
2899
2975
|
print()
|
|
2900
|
-
# In non-render-only mode emit a
|
|
2901
|
-
#
|
|
2976
|
+
# In non-render-only mode emit the JSON a front end needs to exec the
|
|
2977
|
+
# lead. The argv is assembled here rather than in the caller so provider
|
|
2978
|
+
# launch knowledge stays in the catalog and every front end — the bash
|
|
2979
|
+
# wrapper and the Node CLI — starts the lead identically.
|
|
2980
|
+
prompt_file = Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"
|
|
2902
2981
|
machine = {
|
|
2903
|
-
"
|
|
2982
|
+
"leadRuntime": lead_runtime_name,
|
|
2983
|
+
"leadProvider": lead_provider,
|
|
2984
|
+
"leadExecutable": launch.executable,
|
|
2985
|
+
"leadSessionId": ctx["CLAUDE_SESSION_ID"],
|
|
2904
2986
|
"leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
|
|
2905
2987
|
"projectRoot": ctx["PROJECT_ROOT"],
|
|
2906
|
-
"promptFile": str(
|
|
2988
|
+
"promptFile": str(prompt_file),
|
|
2989
|
+
# Non-empty only when starting this lead lowers a protection the
|
|
2990
|
+
# user should agree to first. The front end asks; okstra does not
|
|
2991
|
+
# waive it silently.
|
|
2992
|
+
"sandboxWaiverNote": launch.sandbox_waiver_note,
|
|
2993
|
+
"launchArgv": lead_launch_argv(
|
|
2994
|
+
lead_provider,
|
|
2995
|
+
model=ctx["LEAD_MODEL_EXECUTION_VALUE"],
|
|
2996
|
+
session_id=ctx["CLAUDE_SESSION_ID"],
|
|
2997
|
+
prompt=prompt_file.read_text(encoding="utf-8"),
|
|
2998
|
+
),
|
|
2907
2999
|
}
|
|
2908
3000
|
print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
|
|
2909
3001
|
return 0
|