okstra 0.160.0 → 0.161.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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/adapters/cmux.md +67 -0
- package/runtime/python/okstra_ctl/cmux.py +531 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +72 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/run.py +13 -1
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +37 -3
package/docs/architecture.md
CHANGED
|
@@ -147,6 +147,7 @@ Runtime entry points are consolidated in Python packages. Bash and skills only c
|
|
|
147
147
|
|
|
148
148
|
- [`prompts/lead/okstra-lead-contract.md`](../prompts/lead/okstra-lead-contract.md) is the runtime-neutral lifecycle core: phase boundaries, artifacts, convergence, report ownership, and persistence semantics.
|
|
149
149
|
- `prompts/lead/adapters/claude-code.md`, `prompts/lead/adapters/codex.md`, `prompts/lead/adapters/antigravity.md`, and `prompts/lead/adapters/external.md` map the same semantic operations to one selected host runtime. The generated launch prompt exposes the core path plus exactly one adapter path.
|
|
150
|
+
- `prompts/lead/adapters/cmux.md` is selected by environment rather than by runtime: when the run manifest's `terminalBackend` is `cmux-pane`, every lead runtime resolves to it and dispatches through `okstra team`, because okstra owns the worker panes on that path instead of the host. It overrides only the adapter and the dispatch mode; the lead's agent, role, and session accounting still come from its own runtime.
|
|
150
151
|
- Runtime metadata and role assignments are persisted separately, but the lead provider is derived from the host: Claude Code maps to Claude, Codex maps to Codex, and Antigravity CLI maps to Antigravity. New runs persist `hostRuntime`, `leadAssignment`, and `workerAssignments[]`; each assignment records its provider, model, execution value, and resolved `native-session` or `cli-wrapper` runner. `lead-execution-prompt.md` is canonical, while `claude-execution-prompt.md` is a byte-identical compatibility alias for historical consumers.
|
|
151
152
|
- Provider registry and front-door separation are implemented: the active Claude Code, Codex, or Antigravity host owns the native lead session, while non-host providers run through their registered CLI wrappers.
|
|
152
153
|
- [`skills/okstra-setup/SKILL.md`](../skills/okstra-setup/SKILL.md) — **first-run bootstrap**. Runs `okstra install` and creates `project.json`.
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# cmux Lead Runtime Adapter
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
This adapter maps the neutral Okstra lead operations to a cmux session, where Okstra owns the worker panes regardless of which model is leading. Read it only when the rendered launch prompt selects it; the run manifest's `terminalBackend` is `cmux-pane` for exactly those runs.
|
|
6
|
+
|
|
7
|
+
It replaces the per-runtime adapter, not the lead contract. Your own runtime still decides how you read files, ask the user, and record your session — this file only decides how workers are started, awaited, and reclaimed.
|
|
8
|
+
|
|
9
|
+
## Capability declaration
|
|
10
|
+
|
|
11
|
+
| Field | Value |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `runtime` | environment-selected — any lead runtime resolves to this adapter under cmux |
|
|
14
|
+
| `leadRoleLabel` | `Okstra lead` |
|
|
15
|
+
| `userPromptMode` | `host-text` |
|
|
16
|
+
| `workerDispatchBackend` | `cmux-pane` |
|
|
17
|
+
| `initialPromptDeliveryMode` | `lazy-path-reference` |
|
|
18
|
+
| `sessionAccounting` | unchanged — keep your own runtime's accounting |
|
|
19
|
+
| `resumeMode` | `artifact-checkpoint` |
|
|
20
|
+
| `teardownMode` | `pane-teardown` |
|
|
21
|
+
| `leadEventSource` | `lead-events-jsonl` |
|
|
22
|
+
|
|
23
|
+
## Semantic operation mapping
|
|
24
|
+
|
|
25
|
+
| Operation | Mapping |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `read_artifacts` | Read the manifest-provided paths through the current host's file or shell interface. |
|
|
28
|
+
| `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
|
|
29
|
+
| `prompt_user` | Ask through the host text/question interface and require an explicit approval or clarification response. |
|
|
30
|
+
| `dispatch_worker` | Run `okstra team dispatch --project-root <root> --run-manifest <path>`; use `--dry-run` first when the core requires a dispatch preview. |
|
|
31
|
+
| `await_workers` | Run `okstra team await --project-root <root> --run-manifest <path>` through the host's asynchronous shell facility. |
|
|
32
|
+
| `redispatch_worker` | Create the core-specified fresh jobs file and dispatch it with a new `dispatchKind`; never reuse a live worker conversation. |
|
|
33
|
+
| `shutdown_workers` | Run `okstra team teardown --project-root <root> --run-manifest <path>` only after the user-approved cleanup gate. |
|
|
34
|
+
| `record_lead_event` | Append the required structured event to the manifest-provided `leadEventsPath`; emit the matching user-facing `PROGRESS:` line. |
|
|
35
|
+
| `collect_usage` | Collect artifact/CLI-log-backed usage through the existing Okstra token-usage path; never substitute another runtime's session log. |
|
|
36
|
+
|
|
37
|
+
## Pane placement is not yours to compute
|
|
38
|
+
|
|
39
|
+
Okstra creates, sizes, labels, and closes every worker pane. Do not issue terminal-multiplexer commands of any kind — not to place a worker, not to resize the lead, not to reclaim a finished round. Pane geometry depends on the display, and a lead that recomputes it per run gets it wrong differently on every host.
|
|
40
|
+
|
|
41
|
+
Concretely: you never choose a split direction, a pane width, a surface id, or a title. `okstra team dispatch` does all of it and records what it created.
|
|
42
|
+
|
|
43
|
+
## Watching a worker is not the same as judging it
|
|
44
|
+
|
|
45
|
+
Workers run beside you, so you can read their screens. That is a diagnostic channel and nothing more.
|
|
46
|
+
|
|
47
|
+
- A worker is finished when its dispatch record reaches a terminal status and its required Result Paths exist. Nothing you see on a screen changes that verdict.
|
|
48
|
+
- Never parse a pane's contents into a result. Terminal output is a rendered grid — wrapped to the pane's width, with history truncated — so a path or a number read off it may be silently incomplete.
|
|
49
|
+
- Never send input to a running worker. Workers are one-shot sessions whose prompt is already delivered; retries and re-verification always create a fresh session, and interrupting a live worker contaminates exactly the context that rule protects.
|
|
50
|
+
|
|
51
|
+
Use the screen to tell "still working" from "stuck", and to see at a glance which worker failed. Use the artifacts for everything else.
|
|
52
|
+
|
|
53
|
+
## cmux dispatch details
|
|
54
|
+
|
|
55
|
+
- For convergence reverify, consume the persisted round plan exactly. This adapter may map and transport each returned batch, but it cannot change batch membership and does not classify findings or branch on task type, provider, or model identity.
|
|
56
|
+
- Do not invoke Claude Code team tools or `okstra codex-dispatch`. Under cmux every lead dispatches through `okstra team`, including a Claude Code lead.
|
|
57
|
+
- Worker completion is valid only from `workerDispatches[]`, terminal status sidecars, and required Result Paths. Pane creation alone is not completion.
|
|
58
|
+
- Reverify uses a fresh jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json`, sets `dispatchKind: "reverify-r<N>"`, and dispatches with `okstra team dispatch --project-root <root> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>`.
|
|
59
|
+
- Report-writer uses a fresh one-job jobs file with `dispatchKind: "report-writer"` and the same schema, then dispatches through `okstra team dispatch --project-root <root> --run-manifest <path> --jobs-file <jobs-file>`.
|
|
60
|
+
- Every reverify or report-writer jobs file carries `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`. For reverify, set `role` to `worker-reverify-r<N>` for the pane title. The report-writer completion paths include both data.json and the worker-results audit file.
|
|
61
|
+
- After either dispatch, run `okstra team await --project-root <root> --run-manifest <path>` before evaluating terminal status or completion paths.
|
|
62
|
+
|
|
63
|
+
## Completion, cleanup, and resume
|
|
64
|
+
|
|
65
|
+
- Await through `okstra team await`; raw Result Path polling is forbidden for this backend.
|
|
66
|
+
- Reclaim each round's panes at the round boundary through `okstra team teardown`, before the next round's dispatch. Finished workers leave their panes behind on purpose — the screen survives the process so you can still read a failure — so an unreclaimed round keeps shrinking the space the next one gets.
|
|
67
|
+
- Resume from run artifacts and lead-events checkpoints. After usage collection, persistence, and the core user-approval gate, run `okstra team teardown --project-root <root> --run-manifest <path>` and tear down only Okstra-owned panes recorded for the run.
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""cmux command helpers for okstra-owned worker surfaces.
|
|
2
|
+
|
|
3
|
+
The tmux sibling of this module is `tmux.py`. cmux is a GUI app, so there is no
|
|
4
|
+
detached-server equivalent here — every surface okstra creates lands in the
|
|
5
|
+
workspace the user is already looking at.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Sequence
|
|
17
|
+
|
|
18
|
+
PING_OK = "PONG"
|
|
19
|
+
|
|
20
|
+
# cmux answers over a local unix socket, so a probe that has not returned in a
|
|
21
|
+
# few seconds means the app is wedged or gone — not that it is being slow.
|
|
22
|
+
PROBE_TIMEOUT_SECONDS = 5
|
|
23
|
+
|
|
24
|
+
# A login shell sources the user's whole rc chain, which can be slow on a
|
|
25
|
+
# developer machine, so this gets far more room than a socket probe.
|
|
26
|
+
LOGIN_SHELL_TIMEOUT_SECONDS = 15
|
|
27
|
+
|
|
28
|
+
# cmux plants a per-surface directory of CLI shims on PATH; every entry under it
|
|
29
|
+
# re-enters cmux's own agent wrapper instead of the real CLI.
|
|
30
|
+
SHIM_DIR_MARKER = "cmux-cli-shims"
|
|
31
|
+
|
|
32
|
+
# Workers fill two columns and then grow downward. A third column would cost
|
|
33
|
+
# width, and width is the dimension a horizontal split cannot give back.
|
|
34
|
+
GRID_COLUMNS = 2
|
|
35
|
+
|
|
36
|
+
# Measured against a Claude Code worker: 67 columns renders losslessly, 33 drops
|
|
37
|
+
# content off the right edge. Below this floor okstra stacks a tab instead of
|
|
38
|
+
# splitting, because an unreadable pane costs the lead its view of that worker.
|
|
39
|
+
WORKER_MIN_COLUMNS = 60
|
|
40
|
+
|
|
41
|
+
# What the lead keeps for itself once workers arrive.
|
|
42
|
+
LEAD_TARGET_COLUMNS = 80
|
|
43
|
+
|
|
44
|
+
# Sidebar entries are keyed by source so tools do not overwrite each other's.
|
|
45
|
+
SIDEBAR_SOURCE = "okstra"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmux_cli_path() -> str:
|
|
49
|
+
"""Absolute path to the cmux CLI, or "" when cmux is not installed.
|
|
50
|
+
|
|
51
|
+
The bundled CLI wins over PATH: cmux documents the /usr/local/bin/cmux
|
|
52
|
+
symlink as a manual step, so a working install may leave PATH untouched.
|
|
53
|
+
"""
|
|
54
|
+
bundled = os.environ.get("CMUX_BUNDLED_CLI_PATH", "")
|
|
55
|
+
if bundled and os.access(bundled, os.X_OK):
|
|
56
|
+
return bundled
|
|
57
|
+
return shutil.which("cmux") or ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_cmux(
|
|
61
|
+
args: Sequence[str], *, timeout: int = PROBE_TIMEOUT_SECONDS
|
|
62
|
+
) -> subprocess.CompletedProcess[str]:
|
|
63
|
+
cli = cmux_cli_path()
|
|
64
|
+
if not cli:
|
|
65
|
+
raise FileNotFoundError(
|
|
66
|
+
"cmux CLI not found: CMUX_BUNDLED_CLI_PATH unset and no cmux on PATH"
|
|
67
|
+
)
|
|
68
|
+
return subprocess.run(
|
|
69
|
+
[cli, *args],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmux_available() -> bool:
|
|
78
|
+
"""True only when okstra can drive cmux *and* knows where the lead sits.
|
|
79
|
+
|
|
80
|
+
Being able to drive cmux is not sufficient. Workers attach to the lead's
|
|
81
|
+
workspace, so a run whose lead location is unresolvable would open panes on
|
|
82
|
+
a screen nobody is watching; degrading to the blocking wrapper is the better
|
|
83
|
+
failure. Nested tmux is exactly that case — see `resolve_lead_workspace`.
|
|
84
|
+
"""
|
|
85
|
+
if not cmux_cli_path():
|
|
86
|
+
return False
|
|
87
|
+
# Cheapest discriminator first: no workspace in the environment means this
|
|
88
|
+
# is not a cmux-hosted session, and the probes below would only confirm that
|
|
89
|
+
# the app happens to be installed.
|
|
90
|
+
if not _lead_workspace_env():
|
|
91
|
+
return False
|
|
92
|
+
if not _ping_answers():
|
|
93
|
+
return False
|
|
94
|
+
return bool(resolve_lead_workspace())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve_lead_workspace() -> str:
|
|
98
|
+
"""The lead's workspace UUID, or "" when it cannot be resolved.
|
|
99
|
+
|
|
100
|
+
The UUID is read from the environment rather than from `identify`, whose
|
|
101
|
+
refs are positional and shift as surfaces open and close. `identify` is
|
|
102
|
+
consulted only to prove the environment is current: a tmux server freezes
|
|
103
|
+
the CMUX_* block it was launched with, so those variables can outlive the
|
|
104
|
+
surface they name.
|
|
105
|
+
"""
|
|
106
|
+
workspace = _lead_workspace_env()
|
|
107
|
+
if not workspace:
|
|
108
|
+
return ""
|
|
109
|
+
if not identify_caller():
|
|
110
|
+
return ""
|
|
111
|
+
return workspace
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _lead_workspace_env() -> str:
|
|
115
|
+
return os.environ.get("CMUX_WORKSPACE_ID", "").strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class PaneGeometry:
|
|
120
|
+
"""One pane as `pane.list` reports it.
|
|
121
|
+
|
|
122
|
+
`columns` and `rows` come straight from cmux rather than being derived from
|
|
123
|
+
the container frame, so a display with a different cell size needs no
|
|
124
|
+
conversion here.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
pane_id: str
|
|
128
|
+
surface_ids: tuple[str, ...]
|
|
129
|
+
columns: int
|
|
130
|
+
rows: int
|
|
131
|
+
x: int
|
|
132
|
+
y: int
|
|
133
|
+
cell_width_points: int
|
|
134
|
+
ref: str = ""
|
|
135
|
+
selected_surface_id: str = ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Placement:
|
|
140
|
+
"""Where the next worker goes: split `pane_id`, or stack a tab into it."""
|
|
141
|
+
|
|
142
|
+
pane_id: str
|
|
143
|
+
direction: str
|
|
144
|
+
stack_as_tab: bool
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def plan_worker_placement(
|
|
148
|
+
panes: Sequence[PaneGeometry], *, lead_pane_id: str, min_columns: int
|
|
149
|
+
) -> Placement:
|
|
150
|
+
"""Pick the next worker slot from the workspace's current geometry.
|
|
151
|
+
|
|
152
|
+
Stateless by design: okstra records surface UUIDs, never a layout, so a
|
|
153
|
+
resumed or crashed run cannot carry a layout model that no longer matches
|
|
154
|
+
the screen. Every dispatch re-reads the panes and derives the next slot.
|
|
155
|
+
"""
|
|
156
|
+
workers = [pane for pane in panes if pane.pane_id != lead_pane_id]
|
|
157
|
+
if not workers:
|
|
158
|
+
return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
|
|
159
|
+
|
|
160
|
+
columns = _panes_by_column(workers)
|
|
161
|
+
if len(columns) < GRID_COLUMNS:
|
|
162
|
+
return _widen_the_grid(workers, min_columns=min_columns)
|
|
163
|
+
return _extend_the_shortest_column(columns)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def lead_shrink_points(lead: PaneGeometry, *, target_columns: int) -> int:
|
|
167
|
+
"""How far to push the lead's right border, in the points `pane.resize` takes.
|
|
168
|
+
|
|
169
|
+
The API's `amount` is points, not cells — measured at this pane's own
|
|
170
|
+
`cell_width_points`, so passing a column count shrinks by an eighth of the
|
|
171
|
+
intent on a typical display.
|
|
172
|
+
"""
|
|
173
|
+
surplus = lead.columns - target_columns
|
|
174
|
+
if surplus <= 0:
|
|
175
|
+
return 0
|
|
176
|
+
return surplus * lead.cell_width_points
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _widen_the_grid(
|
|
180
|
+
workers: Sequence[PaneGeometry], *, min_columns: int
|
|
181
|
+
) -> Placement:
|
|
182
|
+
rightmost = max(workers, key=lambda pane: pane.x)
|
|
183
|
+
if rightmost.columns // GRID_COLUMNS >= min_columns:
|
|
184
|
+
return Placement(pane_id=rightmost.pane_id, direction="right", stack_as_tab=False)
|
|
185
|
+
roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.x))
|
|
186
|
+
return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _extend_the_shortest_column(
|
|
190
|
+
columns: dict[int, list[PaneGeometry]]
|
|
191
|
+
) -> Placement:
|
|
192
|
+
shortest = min(columns.values(), key=lambda group: (len(group), group[0].x))
|
|
193
|
+
bottom = max(shortest, key=lambda pane: pane.y)
|
|
194
|
+
return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _panes_by_column(
|
|
198
|
+
workers: Sequence[PaneGeometry],
|
|
199
|
+
) -> dict[int, list[PaneGeometry]]:
|
|
200
|
+
columns: dict[int, list[PaneGeometry]] = {}
|
|
201
|
+
for pane in workers:
|
|
202
|
+
columns.setdefault(pane.x, []).append(pane)
|
|
203
|
+
return columns
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def shim_free_login_path() -> str:
|
|
207
|
+
"""The user's login PATH with cmux's per-surface CLI shims removed.
|
|
208
|
+
|
|
209
|
+
A cmux pane execs its command through `login … bash --noprofile --norc`, so
|
|
210
|
+
none of the user's shell rc runs and PATH is cmux's own short list. What that
|
|
211
|
+
list does hold is a shim directory bound to the new surface, where `claude`
|
|
212
|
+
and `codex` are wrappers into cmux's agent lifecycle rather than the real
|
|
213
|
+
CLIs — and where a provider cmux does not know has no entry at all, which is
|
|
214
|
+
a plain exit 127 inside the worker wrapper.
|
|
215
|
+
|
|
216
|
+
Restoring the login shell's PATH and dropping the shim entries gives the
|
|
217
|
+
wrapper what it would see in an ordinary terminal, which is what its own
|
|
218
|
+
`command -v <cli>` check expects. Nothing here is provider-specific, so
|
|
219
|
+
adding a provider does not touch this path.
|
|
220
|
+
"""
|
|
221
|
+
entries = _login_shell_path().split(os.pathsep)
|
|
222
|
+
return os.pathsep.join(
|
|
223
|
+
entry for entry in entries if entry and SHIM_DIR_MARKER not in entry
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def worker_command_line(
|
|
228
|
+
*, cwd: Path, argv: Sequence[str], path_value: str
|
|
229
|
+
) -> str:
|
|
230
|
+
"""The single shell line a cmux pane execs to run one worker.
|
|
231
|
+
|
|
232
|
+
Every part is shell-quoted. PATH entries routinely contain spaces — macOS
|
|
233
|
+
ships `/Applications/VMware Fusion.app/Contents/Public` on any machine with
|
|
234
|
+
VMware — and an unquoted assignment stops at the first space, then hands the
|
|
235
|
+
remainder to the shell as a command name.
|
|
236
|
+
|
|
237
|
+
The `cd` is part of the command because neither `new-split` nor
|
|
238
|
+
`respawn-pane` accepts a working directory the way `tmux split-window -c`
|
|
239
|
+
does.
|
|
240
|
+
"""
|
|
241
|
+
return (
|
|
242
|
+
f"cd {shlex.quote(str(cwd))} && "
|
|
243
|
+
f"PATH={shlex.quote(path_value)} exec {shlex.join(argv)}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def list_panes(workspace: str) -> list[PaneGeometry]:
|
|
248
|
+
payload = rpc("pane.list", {"workspace_id": workspace})
|
|
249
|
+
return [_pane_geometry(entry) for entry in payload.get("panes", [])]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def spawn_worker_surface(
|
|
253
|
+
*, workspace: str, cwd: Path, command: Sequence[str], title: str
|
|
254
|
+
) -> str:
|
|
255
|
+
"""Start one worker beside the lead and return its surface UUID.
|
|
256
|
+
|
|
257
|
+
The UUID is what okstra records and later closes by. Positional refs cannot
|
|
258
|
+
serve that purpose: cmux renumbers them as surfaces open and close, so a
|
|
259
|
+
close by ref can land on a pane okstra never created.
|
|
260
|
+
"""
|
|
261
|
+
panes = list_panes(workspace)
|
|
262
|
+
lead = _lead_pane(panes)
|
|
263
|
+
placement = plan_worker_placement(
|
|
264
|
+
panes, lead_pane_id=lead.pane_id, min_columns=WORKER_MIN_COLUMNS
|
|
265
|
+
)
|
|
266
|
+
target = _pane_by_id(panes, placement.pane_id)
|
|
267
|
+
surface_uuid = _open_worker_surface(workspace, placement, target)
|
|
268
|
+
run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
|
|
269
|
+
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
270
|
+
_shrink_lead_pane(workspace)
|
|
271
|
+
return surface_uuid
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def close_surface(surface_uuid: str) -> None:
|
|
275
|
+
"""Close an okstra-created surface, killing whatever still runs inside it."""
|
|
276
|
+
try:
|
|
277
|
+
run_cmux(["close-surface", "--surface", surface_uuid])
|
|
278
|
+
except (OSError, subprocess.SubprocessError):
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
|
|
283
|
+
"""What the worker's screen shows — for the lead to look at, never to parse.
|
|
284
|
+
|
|
285
|
+
cmux hands back a rendered grid: wrapped to the pane's width with a finite
|
|
286
|
+
history, so a path or a count read off it can be silently truncated.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
result = run_cmux(
|
|
290
|
+
[
|
|
291
|
+
"capture-pane",
|
|
292
|
+
"--surface",
|
|
293
|
+
surface_uuid,
|
|
294
|
+
"--scrollback",
|
|
295
|
+
"--lines",
|
|
296
|
+
str(last_lines),
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
except (OSError, subprocess.SubprocessError):
|
|
300
|
+
return ""
|
|
301
|
+
return result.stdout if result.returncode == 0 else ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def sidebar_log(workspace: str, message: str, *, level: str = "info") -> None:
|
|
305
|
+
"""Append one line to the workspace sidebar's log."""
|
|
306
|
+
_sidebar_call(
|
|
307
|
+
workspace,
|
|
308
|
+
["log", "--workspace", workspace, "--level", level,
|
|
309
|
+
"--source", SIDEBAR_SOURCE, "--", message],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sidebar_notify(workspace: str, *, title: str, body: str) -> None:
|
|
314
|
+
"""Raise a notification, which badges the tab even from another workspace."""
|
|
315
|
+
_sidebar_call(
|
|
316
|
+
workspace,
|
|
317
|
+
["notify", "--workspace", workspace, "--title", title, "--body", body],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def sidebar_progress(workspace: str, done: int, total: int, *, label: str) -> None:
|
|
322
|
+
"""Show `done`/`total` as the sidebar's progress bar.
|
|
323
|
+
|
|
324
|
+
An empty roster reports nothing rather than zero percent: no workers is not
|
|
325
|
+
the same claim as no progress.
|
|
326
|
+
"""
|
|
327
|
+
if total <= 0:
|
|
328
|
+
return
|
|
329
|
+
fraction = min(1.0, done / total)
|
|
330
|
+
_sidebar_call(
|
|
331
|
+
workspace,
|
|
332
|
+
["set-progress", str(round(fraction, 2)), "--workspace", workspace,
|
|
333
|
+
"--label", label],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _sidebar_call(workspace: str, args: Sequence[str]) -> None:
|
|
338
|
+
"""Best-effort sidebar write.
|
|
339
|
+
|
|
340
|
+
The sidebar is a view of the run, not part of it, so a wedged or closed cmux
|
|
341
|
+
must never take a dispatch down. The workspace is always explicit: letting
|
|
342
|
+
cmux choose would drop one run's noise into whichever workspace happens to
|
|
343
|
+
be focused, which on a machine running two okstra tasks is the other one.
|
|
344
|
+
"""
|
|
345
|
+
if not workspace:
|
|
346
|
+
return
|
|
347
|
+
try:
|
|
348
|
+
run_cmux(args)
|
|
349
|
+
except (OSError, subprocess.SubprocessError):
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def rpc(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
354
|
+
"""Call a cmux rpc method.
|
|
355
|
+
|
|
356
|
+
The rpc surface is used rather than the same-named CLI verbs wherever both
|
|
357
|
+
exist, because the CLI swallows failures — `resize-pane` reports success on
|
|
358
|
+
a rejected resize, while `pane.resize` returns the reason.
|
|
359
|
+
"""
|
|
360
|
+
result = run_cmux(["rpc", method, json.dumps(params)])
|
|
361
|
+
if result.returncode != 0:
|
|
362
|
+
raise RuntimeError(result.stderr.strip() or f"cmux {method} failed")
|
|
363
|
+
try:
|
|
364
|
+
payload = json.loads(result.stdout)
|
|
365
|
+
except ValueError as exc:
|
|
366
|
+
raise RuntimeError(f"cmux {method} returned non-JSON output") from exc
|
|
367
|
+
return payload if isinstance(payload, dict) else {}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _open_worker_surface(
|
|
371
|
+
workspace: str, placement: Placement, target: PaneGeometry
|
|
372
|
+
) -> str:
|
|
373
|
+
before = _surface_ids(workspace)
|
|
374
|
+
_create_surface(workspace, placement, target)
|
|
375
|
+
new_ids = _surface_ids(workspace) - before
|
|
376
|
+
if len(new_ids) != 1:
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
f"cmux opened {len(new_ids)} surfaces where exactly one was expected"
|
|
379
|
+
)
|
|
380
|
+
return new_ids.pop()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _surface_ids(workspace: str) -> set[str]:
|
|
384
|
+
"""Every surface UUID in the workspace.
|
|
385
|
+
|
|
386
|
+
The new surface is identified by diffing this set rather than by translating
|
|
387
|
+
the `OK surface:N` echo, because `list-pane-surfaces` reports only the
|
|
388
|
+
focused pane unless given a `--pane`, and the new pane is not focused.
|
|
389
|
+
"""
|
|
390
|
+
return {
|
|
391
|
+
surface_id
|
|
392
|
+
for pane in list_panes(workspace)
|
|
393
|
+
for surface_id in pane.surface_ids
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _create_surface(
|
|
398
|
+
workspace: str, placement: Placement, target: PaneGeometry
|
|
399
|
+
) -> None:
|
|
400
|
+
if placement.stack_as_tab:
|
|
401
|
+
created = run_cmux(
|
|
402
|
+
["new-surface", "--workspace", workspace, "--pane", target.pane_id]
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
created = run_cmux(
|
|
406
|
+
[
|
|
407
|
+
"new-split",
|
|
408
|
+
placement.direction,
|
|
409
|
+
"--workspace",
|
|
410
|
+
workspace,
|
|
411
|
+
"--surface",
|
|
412
|
+
target.selected_surface_id or target.surface_ids[0],
|
|
413
|
+
]
|
|
414
|
+
)
|
|
415
|
+
if created.returncode != 0:
|
|
416
|
+
raise RuntimeError(created.stderr.strip() or "cmux could not open a pane")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
|
|
420
|
+
line = worker_command_line(
|
|
421
|
+
cwd=cwd, argv=command, path_value=shim_free_login_path()
|
|
422
|
+
)
|
|
423
|
+
started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
|
|
424
|
+
if started.returncode != 0:
|
|
425
|
+
raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _shrink_lead_pane(workspace: str) -> None:
|
|
429
|
+
"""Give the lead's width to the workers by pushing its right border left.
|
|
430
|
+
|
|
431
|
+
The lead cannot shrink itself: `pane.resize` moves the named pane's border,
|
|
432
|
+
so asking the leftmost pane to move `left` fails with no adjacent border and
|
|
433
|
+
`right` widens it. The neighbour on its right carries the request instead.
|
|
434
|
+
"""
|
|
435
|
+
panes = list_panes(workspace)
|
|
436
|
+
lead = _lead_pane(panes)
|
|
437
|
+
amount = lead_shrink_points(lead, target_columns=LEAD_TARGET_COLUMNS)
|
|
438
|
+
if amount <= 0:
|
|
439
|
+
return
|
|
440
|
+
neighbours = [pane for pane in panes if pane.x > lead.x]
|
|
441
|
+
if not neighbours:
|
|
442
|
+
return
|
|
443
|
+
rpc(
|
|
444
|
+
"pane.resize",
|
|
445
|
+
{
|
|
446
|
+
"workspace_id": workspace,
|
|
447
|
+
"pane_id": min(neighbours, key=lambda pane: pane.x).pane_id,
|
|
448
|
+
"direction": "left",
|
|
449
|
+
"amount": amount,
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _lead_pane(panes: Sequence[PaneGeometry]) -> PaneGeometry:
|
|
455
|
+
caller_pane_ref = identify_caller().get("pane_ref", "")
|
|
456
|
+
for pane in panes:
|
|
457
|
+
if caller_pane_ref and pane.ref == caller_pane_ref:
|
|
458
|
+
return pane
|
|
459
|
+
raise RuntimeError("cmux could not locate the lead's pane in its workspace")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _pane_by_id(panes: Sequence[PaneGeometry], pane_id: str) -> PaneGeometry:
|
|
463
|
+
for pane in panes:
|
|
464
|
+
if pane.pane_id == pane_id:
|
|
465
|
+
return pane
|
|
466
|
+
raise RuntimeError(f"cmux pane {pane_id} disappeared while placing a worker")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
470
|
+
frame = entry.get("pixel_frame") or {}
|
|
471
|
+
return PaneGeometry(
|
|
472
|
+
pane_id=str(entry.get("id", "")),
|
|
473
|
+
surface_ids=tuple(str(s) for s in entry.get("surface_ids") or ()),
|
|
474
|
+
columns=int(entry.get("columns", 0)),
|
|
475
|
+
rows=int(entry.get("rows", 0)),
|
|
476
|
+
x=int(frame.get("x", 0)),
|
|
477
|
+
y=int(frame.get("y", 0)),
|
|
478
|
+
cell_width_points=int(entry.get("cell_width_points", 0)),
|
|
479
|
+
ref=str(entry.get("ref", "")),
|
|
480
|
+
selected_surface_id=str(entry.get("selected_surface_id", "")),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _login_shell_path() -> str:
|
|
485
|
+
"""PATH as the user's own login shell resolves it.
|
|
486
|
+
|
|
487
|
+
Falls back to the inherited PATH: a worker started with a shim-filtered
|
|
488
|
+
inherited PATH is still better than one started with cmux's bare list.
|
|
489
|
+
"""
|
|
490
|
+
shell = os.environ.get("SHELL", "") or "/bin/sh"
|
|
491
|
+
try:
|
|
492
|
+
result = subprocess.run(
|
|
493
|
+
[shell, "-lc", 'printf %s "$PATH"'],
|
|
494
|
+
capture_output=True,
|
|
495
|
+
text=True,
|
|
496
|
+
timeout=LOGIN_SHELL_TIMEOUT_SECONDS,
|
|
497
|
+
check=False,
|
|
498
|
+
)
|
|
499
|
+
except (OSError, subprocess.SubprocessError):
|
|
500
|
+
return os.environ.get("PATH", "")
|
|
501
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
502
|
+
return os.environ.get("PATH", "")
|
|
503
|
+
return result.stdout.strip()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def identify_caller() -> dict[str, Any]:
|
|
507
|
+
"""The caller's ref bundle, or {} when cmux cannot resolve it.
|
|
508
|
+
|
|
509
|
+
`identify` exits 0 even for an id it cannot resolve, so the exit code says
|
|
510
|
+
nothing; a null `caller` is the only signal that the lookup failed.
|
|
511
|
+
"""
|
|
512
|
+
try:
|
|
513
|
+
result = run_cmux(["identify"])
|
|
514
|
+
except (OSError, subprocess.SubprocessError):
|
|
515
|
+
return {}
|
|
516
|
+
if result.returncode != 0:
|
|
517
|
+
return {}
|
|
518
|
+
try:
|
|
519
|
+
payload = json.loads(result.stdout)
|
|
520
|
+
except ValueError:
|
|
521
|
+
return {}
|
|
522
|
+
caller = payload.get("caller")
|
|
523
|
+
return caller if isinstance(caller, dict) else {}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _ping_answers() -> bool:
|
|
527
|
+
try:
|
|
528
|
+
result = run_cmux(["ping"])
|
|
529
|
+
except (OSError, subprocess.SubprocessError):
|
|
530
|
+
return False
|
|
531
|
+
return result.returncode == 0 and result.stdout.strip() == PING_OK
|
|
@@ -10,9 +10,11 @@ from datetime import datetime, timezone
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Mapping, Sequence
|
|
12
12
|
|
|
13
|
+
from . import cmux
|
|
13
14
|
from . import tmux
|
|
14
15
|
from .dispatch_state import (
|
|
15
16
|
BACKEND_CLI_WRAPPER,
|
|
17
|
+
BACKEND_CMUX_PANE,
|
|
16
18
|
BACKEND_MIXED,
|
|
17
19
|
BACKEND_TMUX_PANE,
|
|
18
20
|
DispatchError,
|
|
@@ -185,11 +187,19 @@ def build_dispatch_plan(
|
|
|
185
187
|
|
|
186
188
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
187
189
|
if wait:
|
|
188
|
-
|
|
190
|
+
pane_backends = sorted(
|
|
191
|
+
{
|
|
192
|
+
job.backend
|
|
193
|
+
for job in plan.jobs
|
|
194
|
+
if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
if pane_backends:
|
|
189
198
|
raise DispatchError(
|
|
190
|
-
"wait=True dispatch does not support
|
|
191
|
-
"the per-job blocking loop would serialize panes
|
|
192
|
-
"running them concurrently; dispatch
|
|
199
|
+
f"wait=True dispatch does not support {'/'.join(pane_backends)} "
|
|
200
|
+
"workers: the per-job blocking loop would serialize panes "
|
|
201
|
+
"instead of running them concurrently; dispatch panes with "
|
|
202
|
+
"wait=False"
|
|
193
203
|
)
|
|
194
204
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
|
|
195
205
|
for job in plan.jobs:
|
|
@@ -427,9 +437,29 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
427
437
|
return _run_cli_wrapper(plan, job, "")
|
|
428
438
|
if job.backend == BACKEND_TMUX_PANE:
|
|
429
439
|
return _start_tmux_or_degrade(plan, job)
|
|
440
|
+
if job.backend == BACKEND_CMUX_PANE:
|
|
441
|
+
return _start_cmux_or_degrade(plan, job)
|
|
430
442
|
raise DispatchError(f"unsupported worker backend: {job.backend}")
|
|
431
443
|
|
|
432
444
|
|
|
445
|
+
def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
446
|
+
workspace = cmux.resolve_lead_workspace()
|
|
447
|
+
if not workspace:
|
|
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:
|
|
@@ -561,7 +591,7 @@ def _dispatch_record(
|
|
|
561
591
|
|
|
562
592
|
|
|
563
593
|
def _liveness_mode(backend: str) -> str:
|
|
564
|
-
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
|
|
594
|
+
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
|
|
565
595
|
return LIVENESS_WRAPPER_STATUS
|
|
566
596
|
return LIVENESS_AUDIT_HEARTBEAT
|
|
567
597
|
|
|
@@ -615,7 +645,44 @@ def _skip_reasons(
|
|
|
615
645
|
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
646
|
|
|
617
647
|
|
|
648
|
+
_SIDEBAR_LEVELS = {
|
|
649
|
+
"worker-dispatched": "progress",
|
|
650
|
+
"worker-result-collected": "success",
|
|
651
|
+
"worker-retry-scheduled": "warning",
|
|
652
|
+
"worker-failed": "error",
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
657
|
+
"""Mirror a dispatch event onto the cmux sidebar.
|
|
658
|
+
|
|
659
|
+
A long run is mostly silence, and the sidebar is the one surface still
|
|
660
|
+
visible after the user scrolls away or switches workspaces. A failed worker
|
|
661
|
+
additionally raises a notification, because that is the event whose cost
|
|
662
|
+
grows the longer it goes unnoticed.
|
|
663
|
+
"""
|
|
664
|
+
# The plan's backend, not the manifest field it was resolved from: a job
|
|
665
|
+
# that degraded to the blocking wrapper is still part of a cmux run, and
|
|
666
|
+
# that degradation is exactly what the sidebar should keep showing.
|
|
667
|
+
if plan.default_backend != BACKEND_CMUX_PANE:
|
|
668
|
+
return
|
|
669
|
+
workspace = cmux.resolve_lead_workspace()
|
|
670
|
+
worker_id = str(details.get("workerId", "") or "worker")
|
|
671
|
+
cmux.sidebar_log(
|
|
672
|
+
workspace,
|
|
673
|
+
f"{worker_id}: {event_type.removeprefix('worker-')}",
|
|
674
|
+
level=_SIDEBAR_LEVELS.get(event_type, "info"),
|
|
675
|
+
)
|
|
676
|
+
if event_type == "worker-failed":
|
|
677
|
+
cmux.sidebar_notify(
|
|
678
|
+
workspace,
|
|
679
|
+
title=f"okstra — {_require_string(plan.manifest, 'taskType')}",
|
|
680
|
+
body=f"{worker_id} failed: {details.get('reason', 'no reason recorded')}",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
618
684
|
def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
685
|
+
_relay_to_sidebar(plan, event_type, details)
|
|
619
686
|
append_lead_event(
|
|
620
687
|
plan.lead_events_path,
|
|
621
688
|
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
|
|
@@ -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
|
+
)
|
|
@@ -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),
|
|
@@ -96,6 +96,7 @@ from .render import (
|
|
|
96
96
|
)
|
|
97
97
|
from okstra_project.dirs import okstra_home
|
|
98
98
|
|
|
99
|
+
from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
|
|
99
100
|
from .run_context import (
|
|
100
101
|
compute_and_write_run_context,
|
|
101
102
|
refresh_run_context_snapshot,
|
|
@@ -2394,7 +2395,17 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2394
2395
|
json.loads(runtime_resolution_json or "{}")
|
|
2395
2396
|
except json.JSONDecodeError as exc:
|
|
2396
2397
|
raise PrepareError(f"invalid --runtime-resolution-json: {exc}") from exc
|
|
2397
|
-
|
|
2398
|
+
# Probed once here and reused below, so the gate and the manifest cannot
|
|
2399
|
+
# disagree about which backend this run is on.
|
|
2400
|
+
terminal_backend = detect_terminal_backend()
|
|
2401
|
+
# Outside cmux only claude-code has a dispatch backend of its own. Under
|
|
2402
|
+
# cmux okstra owns the panes for every lead, so the render-only restriction
|
|
2403
|
+
# no longer applies to any runtime.
|
|
2404
|
+
if (
|
|
2405
|
+
lead_runtime != "claude-code"
|
|
2406
|
+
and terminal_backend != BACKEND_CMUX_PANE
|
|
2407
|
+
and not inp.render_only
|
|
2408
|
+
):
|
|
2398
2409
|
raise PrepareError(
|
|
2399
2410
|
f"lead runtime `{lead_runtime}` is currently render-only; "
|
|
2400
2411
|
"use --render-only until a dispatch backend is enabled for this lead runtime."
|
|
@@ -2495,6 +2506,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2495
2506
|
)
|
|
2496
2507
|
|
|
2497
2508
|
ctx.update({
|
|
2509
|
+
"TERMINAL_BACKEND": terminal_backend,
|
|
2498
2510
|
"EXECUTOR_WORKTREE_PATH": worktree.path,
|
|
2499
2511
|
"EXECUTOR_WORKTREE_BRANCH": worktree.branch,
|
|
2500
2512
|
"EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Neutral okstra team CLI for
|
|
1
|
+
"""Neutral okstra team CLI for pane-backed worker dispatch.
|
|
2
|
+
|
|
3
|
+
Outside cmux this is the external lead's door onto tmux panes. Under cmux it
|
|
4
|
+
is every lead's door onto cmux surfaces, because okstra owns the panes there
|
|
5
|
+
rather than the host. Which backend a run uses is read from its run manifest.
|
|
6
|
+
"""
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
4
9
|
import argparse
|
|
@@ -7,8 +12,10 @@ import sys
|
|
|
7
12
|
from pathlib import Path
|
|
8
13
|
from typing import Any, Mapping, Sequence
|
|
9
14
|
|
|
15
|
+
from . import cmux
|
|
10
16
|
from . import tmux
|
|
11
17
|
from .dispatch_core import (
|
|
18
|
+
BACKEND_CMUX_PANE,
|
|
12
19
|
BACKEND_TMUX_PANE,
|
|
13
20
|
DispatchError,
|
|
14
21
|
DispatchPlan,
|
|
@@ -51,7 +58,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
51
58
|
|
|
52
59
|
|
|
53
60
|
def _add_dispatch_parser(sub) -> None:
|
|
54
|
-
parser = sub.add_parser("dispatch", help="dispatch
|
|
61
|
+
parser = sub.add_parser("dispatch", help="dispatch pane-backed workers")
|
|
55
62
|
_add_run_args(parser)
|
|
56
63
|
parser.add_argument("--workers", default="")
|
|
57
64
|
parser.add_argument("--jobs-file", default="")
|
|
@@ -61,7 +68,7 @@ def _add_dispatch_parser(sub) -> None:
|
|
|
61
68
|
|
|
62
69
|
|
|
63
70
|
def _add_await_parser(sub) -> None:
|
|
64
|
-
parser = sub.add_parser("await", help="wait for
|
|
71
|
+
parser = sub.add_parser("await", help="wait for pane-backed workers")
|
|
65
72
|
_add_run_args(parser)
|
|
66
73
|
parser.add_argument("--poll-interval-seconds", type=int, default=5)
|
|
67
74
|
parser.add_argument("--timeout-seconds", type=int, default=None)
|
|
@@ -70,7 +77,7 @@ def _add_await_parser(sub) -> None:
|
|
|
70
77
|
|
|
71
78
|
|
|
72
79
|
def _add_teardown_parser(sub) -> None:
|
|
73
|
-
parser = sub.add_parser("teardown", help="
|
|
80
|
+
parser = sub.add_parser("teardown", help="reclaim this run's worker panes")
|
|
74
81
|
_add_run_args(parser)
|
|
75
82
|
parser.add_argument("--dry-run", action="store_true")
|
|
76
83
|
parser.add_argument("--json", action="store_true")
|
|
@@ -94,8 +101,8 @@ def _dispatch(args) -> int:
|
|
|
94
101
|
okstra_bin=Path(args.okstra_bin),
|
|
95
102
|
requested_workers=requested,
|
|
96
103
|
idle_timeout_seconds=args.idle_timeout_seconds,
|
|
97
|
-
required_lead_runtime="external",
|
|
98
|
-
default_backend=
|
|
104
|
+
required_lead_runtime=None if _is_cmux_run(manifest) else "external",
|
|
105
|
+
default_backend=_manifest_backend(manifest),
|
|
99
106
|
supported_worker_wrappers=_SUPPORTED_WRAPPERS,
|
|
100
107
|
unsupported_worker_label="external lead",
|
|
101
108
|
dispatch_kind=args.dispatch_kind,
|
|
@@ -133,13 +140,13 @@ def _teardown(args) -> int:
|
|
|
133
140
|
team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
|
|
134
141
|
team_state = _load_json(team_state_path, "team-state")
|
|
135
142
|
run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
|
|
136
|
-
|
|
137
|
-
panes = _teardown_panes(team_state, run_dir, lead_pane)
|
|
143
|
+
panes = _reclaimable_panes(manifest, team_state, run_dir)
|
|
138
144
|
if args.dry_run:
|
|
139
145
|
_emit_teardown(args.json, panes)
|
|
140
146
|
return 0
|
|
147
|
+
reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
|
|
141
148
|
for pane in panes:
|
|
142
|
-
|
|
149
|
+
reclaim(pane["paneId"])
|
|
143
150
|
_mark_teardown_errors(team_state_path)
|
|
144
151
|
_emit_teardown(args.json, panes)
|
|
145
152
|
return 0
|
|
@@ -158,7 +165,7 @@ def _plan_for_existing(
|
|
|
158
165
|
lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
|
|
159
166
|
manifest=manifest,
|
|
160
167
|
jobs=(),
|
|
161
|
-
default_backend=
|
|
168
|
+
default_backend=_manifest_backend(manifest),
|
|
162
169
|
)
|
|
163
170
|
|
|
164
171
|
|
|
@@ -174,12 +181,25 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
|
|
177
|
-
def
|
|
184
|
+
def _reclaimable_panes(
|
|
185
|
+
manifest: Mapping[str, Any], team_state: Mapping[str, Any], run_dir: Path
|
|
186
|
+
) -> list[dict[str, str]]:
|
|
187
|
+
"""Everything this run owns and may close.
|
|
188
|
+
|
|
189
|
+
Under cmux the recorded ids are the whole list. There is no per-pane tag API
|
|
190
|
+
to sweep with, and scanning by title would be worse than nothing: cmux labels
|
|
191
|
+
its own agent surfaces with the same glyph okstra's tmux cleanup treats as a
|
|
192
|
+
teammate marker, so a sweep could close the lead. Only surfaces okstra
|
|
193
|
+
created are recorded, so only those can be closed.
|
|
194
|
+
"""
|
|
178
195
|
seen: set[str] = set()
|
|
179
196
|
panes: list[dict[str, str]] = []
|
|
180
197
|
for record in team_state.get("workerDispatches", []):
|
|
181
198
|
if isinstance(record, dict):
|
|
182
199
|
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
200
|
+
if _is_cmux_run(manifest):
|
|
201
|
+
return panes
|
|
202
|
+
lead_pane = tmux.resolve_caller_pane()
|
|
183
203
|
for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
|
|
184
204
|
_append_pane(panes, seen, pane.pane_id, pane.kind)
|
|
185
205
|
return panes
|
|
@@ -208,10 +228,29 @@ def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
|
|
|
208
228
|
print(f"{pane['paneId']}\t{pane['kind']}")
|
|
209
229
|
|
|
210
230
|
|
|
231
|
+
def _manifest_backend(manifest: Mapping[str, Any]) -> str:
|
|
232
|
+
"""The backend prepare recorded for this run.
|
|
233
|
+
|
|
234
|
+
Deliberately not a flag on this command: the manifest already answers it,
|
|
235
|
+
and a flag would be a second answer free to disagree. A manifest written
|
|
236
|
+
before the field existed reads as tmux, which is what those runs used.
|
|
237
|
+
"""
|
|
238
|
+
return str(manifest.get("terminalBackend") or "") or BACKEND_TMUX_PANE
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
|
|
242
|
+
return _manifest_backend(manifest) == BACKEND_CMUX_PANE
|
|
243
|
+
|
|
244
|
+
|
|
211
245
|
def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
|
|
212
246
|
runtime = manifest.get("leadRuntime")
|
|
213
247
|
if runtime == "external":
|
|
214
248
|
return
|
|
249
|
+
if _is_cmux_run(manifest):
|
|
250
|
+
# Under cmux okstra owns the panes for every lead, so this command is no
|
|
251
|
+
# longer the external lead's private door and the advice below no longer
|
|
252
|
+
# applies — there is nowhere else for a codex or Claude Code lead to go.
|
|
253
|
+
return
|
|
215
254
|
if runtime == "codex":
|
|
216
255
|
raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
|
|
217
256
|
if runtime == "claude-code":
|
|
@@ -39,6 +39,12 @@ SECONDARY_BRIEF_FILENAME="validation-brief-secondary.md"
|
|
|
39
39
|
export OKSTRA_SKIP_INSTALL_CHECK="${OKSTRA_SKIP_INSTALL_CHECK:-1}"
|
|
40
40
|
export OKSTRA_CTL_SKIP_RECONCILE="${OKSTRA_CTL_SKIP_RECONCILE:-1}"
|
|
41
41
|
export OKSTRA_CTL_SKIP_BACKFILL="${OKSTRA_CTL_SKIP_BACKFILL:-1}"
|
|
42
|
+
# Same reason as the three above: the synthetic run must render the same way on
|
|
43
|
+
# every machine. cmux outranks tmux when present, so a maintainer running this
|
|
44
|
+
# from inside cmux would otherwise get the cmux adapter and a lead-session
|
|
45
|
+
# requirement this fixture never simulates. The cmux path has its own coverage
|
|
46
|
+
# in tests/run/test_cmux*.py and tests/contract/test_validate_session_conformance.py.
|
|
47
|
+
export CMUX_WORKSPACE_ID=""
|
|
42
48
|
|
|
43
49
|
# shellcheck source=lib/common.sh
|
|
44
50
|
source "$SCRIPT_DIR/lib/common.sh"
|
|
@@ -112,9 +112,19 @@ _ENTRY_GUARD_READS = (
|
|
|
112
112
|
),
|
|
113
113
|
)
|
|
114
114
|
|
|
115
|
+
# 환경으로 선택되는 어댑터라 lead 의 런타임이 이 파일을 가르쳐 주지 않는다.
|
|
116
|
+
# 읽지 않은 lead 는 자기 런타임이 아는 방식 — cmux 경로에서는 okstra 가 소유한
|
|
117
|
+
# 디스패치를 host 네이티브로 가로채는 방식 — 으로 되돌아간다.
|
|
118
|
+
CMUX_ADAPTER_BASENAME = "cmux.md"
|
|
119
|
+
CMUX_ADAPTER_NAME = "cmux"
|
|
120
|
+
_CMUX_ADAPTER_CITE = "prompts/lead/adapters/cmux.md"
|
|
121
|
+
|
|
115
122
|
# Read 증거는 basename 으로 거른다 — 절대 경로는 레이어(repo / runtime / 설치본)
|
|
116
123
|
# 마다 다르지만 basename 은 동일하다. 목록이 갈리지 않도록 기대치에서 파생한다.
|
|
117
|
-
|
|
124
|
+
_TRACKED_READ_BASENAMES = (
|
|
125
|
+
*(row.basename for row in _ENTRY_GUARD_READS),
|
|
126
|
+
CMUX_ADAPTER_BASENAME,
|
|
127
|
+
)
|
|
118
128
|
|
|
119
129
|
|
|
120
130
|
@dataclass
|
|
@@ -208,7 +218,7 @@ def _scan_one_jsonl(
|
|
|
208
218
|
progress.append((ts, m.group("phase"), line))
|
|
209
219
|
elif block.get("type") == "tool_use" and block.get("name") == "Read":
|
|
210
220
|
base = Path(str((block.get("input") or {}).get("file_path") or "")).name
|
|
211
|
-
if base in
|
|
221
|
+
if base in _TRACKED_READ_BASENAMES:
|
|
212
222
|
reads.setdefault(base, []).append(ts)
|
|
213
223
|
return progress, reads, agent_name
|
|
214
224
|
|
|
@@ -334,7 +344,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
|
|
|
334
344
|
if not isinstance(basename, str) or not basename:
|
|
335
345
|
raw_path = details.get("path") or details.get("filePath")
|
|
336
346
|
basename = Path(str(raw_path or "")).name
|
|
337
|
-
if basename not in
|
|
347
|
+
if basename not in _TRACKED_READ_BASENAMES:
|
|
338
348
|
return None
|
|
339
349
|
return (basename, event.timestamp)
|
|
340
350
|
|
|
@@ -752,6 +762,29 @@ def _instruction_set_dir(run_dir: Path, suffix: str | None, project_root: Path)
|
|
|
752
762
|
return path if path.is_dir() else None
|
|
753
763
|
|
|
754
764
|
|
|
765
|
+
def _check_cmux_adapter_read(
|
|
766
|
+
evidence: _LeadEvidence, team_state: dict, errors: list[str]
|
|
767
|
+
) -> None:
|
|
768
|
+
"""검사 4 — cmux 어댑터 읽음. 모든 task-type 에 적용된다.
|
|
769
|
+
|
|
770
|
+
다른 어댑터는 lead 의 런타임이 고르지만 이것은 환경이 고른다. 그래서 읽지
|
|
771
|
+
않은 lead 에게는 이 경로가 존재한다는 사실 자체가 닿지 않고, 자기 런타임이
|
|
772
|
+
아는 host 네이티브 디스패치로 되돌아간다 — cmux 경로에서 okstra 가 소유한
|
|
773
|
+
바로 그 일이다."""
|
|
774
|
+
adapter = team_state.get("leadAdapter")
|
|
775
|
+
name = str(adapter.get("name", "")).strip() if isinstance(adapter, dict) else ""
|
|
776
|
+
if name != CMUX_ADAPTER_NAME:
|
|
777
|
+
return
|
|
778
|
+
if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
|
|
779
|
+
return
|
|
780
|
+
errors.append(
|
|
781
|
+
f"cmux adapter: no `Read` of `{CMUX_ADAPTER_BASENAME}` found in the "
|
|
782
|
+
"selected adapter evidence source within this run's window — the cmux "
|
|
783
|
+
"adapter is selected by environment, not by lead runtime, so it MUST be "
|
|
784
|
+
f"read before dispatch ({_CMUX_ADAPTER_CITE})."
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
755
788
|
def _check_implementation_entry_guard(
|
|
756
789
|
evidence: _LeadEvidence, errors: list[str], instruction_set: Path | None
|
|
757
790
|
) -> None:
|
|
@@ -837,6 +870,7 @@ def validate_session_conformance(
|
|
|
837
870
|
result.errors.append(error)
|
|
838
871
|
return result
|
|
839
872
|
_check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
|
|
873
|
+
_check_cmux_adapter_read(evidence, team_state, result.errors)
|
|
840
874
|
if task_type == "implementation":
|
|
841
875
|
_check_implementation_entry_guard(
|
|
842
876
|
evidence,
|