okstra 0.159.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/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +2 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +67 -0
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/cmux.py +531 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +76 -7
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +7 -22
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +54 -3
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +51 -1
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -1
- package/runtime/templates/reports/html/i18n/ko.json +5 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +267 -208
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +135 -31
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -32,6 +32,7 @@ from .convergence_store import (
|
|
|
32
32
|
write_final_state_atomic,
|
|
33
33
|
write_json_atomic,
|
|
34
34
|
)
|
|
35
|
+
from .verdict_blocks import FindingVote, VerdictBlockError, parse_finding_votes
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
_EVIDENCE_ARTIFACT = {
|
|
@@ -110,6 +111,41 @@ _EXAMPLES: dict[str, dict[str, Any]] = {
|
|
|
110
111
|
}
|
|
111
112
|
],
|
|
112
113
|
},
|
|
114
|
+
"coverage-batch": {
|
|
115
|
+
"schemaVersion": "1.0",
|
|
116
|
+
"taskKey": "example/group/task",
|
|
117
|
+
"mode": "coverage",
|
|
118
|
+
# The critic's own provider never votes on its gaps, so `provider` here
|
|
119
|
+
# is deliberately absent from `dispatches[]`.
|
|
120
|
+
"provider": "antigravity",
|
|
121
|
+
"modelExecutionValue": "gemini-3.1-pro",
|
|
122
|
+
"dispatches": [
|
|
123
|
+
{"worker": "claude-worker", "status": "completed", "durationMs": 1250},
|
|
124
|
+
{"worker": "codex-worker", "status": "completed", "durationMs": 980},
|
|
125
|
+
],
|
|
126
|
+
"gaps": [
|
|
127
|
+
{
|
|
128
|
+
"gapId": "G-001",
|
|
129
|
+
"summary": "The rollback path has no live-data verification",
|
|
130
|
+
"category": "missing",
|
|
131
|
+
"ticketIds": ["TASK-001"],
|
|
132
|
+
"originEvidence": "No rollback query result is present",
|
|
133
|
+
"evidenceArtifacts": [_EVIDENCE_ARTIFACT],
|
|
134
|
+
"votes": {
|
|
135
|
+
"claude-worker": {
|
|
136
|
+
"verdict": "agree",
|
|
137
|
+
"disagreeBasis": None,
|
|
138
|
+
"explanation": "The plan cites no rollback verification step",
|
|
139
|
+
},
|
|
140
|
+
"codex-worker": {
|
|
141
|
+
"verdict": "agree",
|
|
142
|
+
"disagreeBasis": None,
|
|
143
|
+
"explanation": "No rollback assertion appears in the stage map",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
},
|
|
113
149
|
}
|
|
114
150
|
|
|
115
151
|
|
|
@@ -131,21 +167,22 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
131
167
|
help="print a deterministic input artifact example",
|
|
132
168
|
description=(
|
|
133
169
|
"Print one deterministic valid example as JSON.\n\n"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
"
|
|
170
|
+
"Each kind feeds one command:\n"
|
|
171
|
+
" groups -> seed --groups\n"
|
|
172
|
+
" round-results -> apply-round --results\n"
|
|
173
|
+
" coverage-batch -> apply-critic-gaps --results\n\n"
|
|
174
|
+
"`critic-results` feeds nothing: it is the critic worker's own "
|
|
175
|
+
"result document, NOT the `apply-critic-gaps --results` input. "
|
|
176
|
+
"Feeding it straight in is rejected, by design — that reducer takes "
|
|
177
|
+
"the coverage batch the lead assembles from those candidates plus "
|
|
178
|
+
"each analyser's vote, which is what `--kind coverage-batch` prints. "
|
|
179
|
+
'See prompts/lead/convergence.md §"Coverage critic".'
|
|
143
180
|
),
|
|
144
181
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
145
182
|
)
|
|
146
183
|
example.add_argument(
|
|
147
184
|
"--kind",
|
|
148
|
-
choices=("groups", "round-results", "critic-results"),
|
|
185
|
+
choices=("groups", "round-results", "critic-results", "coverage-batch"),
|
|
149
186
|
required=True,
|
|
150
187
|
)
|
|
151
188
|
|
|
@@ -160,6 +197,23 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
160
197
|
plan.add_argument("--work-state", type=Path, required=True)
|
|
161
198
|
plan.add_argument("--plan", type=Path, required=True)
|
|
162
199
|
|
|
200
|
+
collect = subparsers.add_parser(
|
|
201
|
+
"collect-results",
|
|
202
|
+
help="read one round's worker responses into the apply-round input",
|
|
203
|
+
)
|
|
204
|
+
collect.add_argument("--plan", type=Path, required=True)
|
|
205
|
+
collect.add_argument("--mode", choices=("adversarial", "collaborative"),
|
|
206
|
+
required=True,
|
|
207
|
+
help="which reverify prompt this round used; the two "
|
|
208
|
+
"carry different verdict vocabularies")
|
|
209
|
+
collect.add_argument("--result", action="append", default=[],
|
|
210
|
+
metavar="<worker>=<path>",
|
|
211
|
+
help="one worker's reverify result file (repeatable)")
|
|
212
|
+
collect.add_argument("--dispatch", action="append", default=[], required=True,
|
|
213
|
+
metavar="<worker>=<status>:<durationMs>",
|
|
214
|
+
help="terminal status per dispatched worker (repeatable)")
|
|
215
|
+
collect.add_argument("--output", type=Path, required=True)
|
|
216
|
+
|
|
163
217
|
apply = subparsers.add_parser("apply-round", help="apply structured round results")
|
|
164
218
|
apply.add_argument("--work-state", type=Path, required=True)
|
|
165
219
|
apply.add_argument("--plan", type=Path, required=True)
|
|
@@ -348,10 +402,112 @@ def _validate(args: argparse.Namespace) -> tuple[str, Path]:
|
|
|
348
402
|
return "valid", args.state
|
|
349
403
|
|
|
350
404
|
|
|
405
|
+
def _split_pair(raw: str, label: str) -> tuple[str, str]:
|
|
406
|
+
key, separator, value = raw.partition("=")
|
|
407
|
+
if not separator or not key.strip() or not value.strip():
|
|
408
|
+
raise ConvergenceContractError(f"{label} must be <worker>=<value>, got: {raw}")
|
|
409
|
+
return key.strip(), value.strip()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _dispatch_rows(raw_dispatches: list[str]) -> list[dict[str, Any]]:
|
|
413
|
+
"""`dispatches[]`, from what only the lead knows.
|
|
414
|
+
|
|
415
|
+
Terminal status and duration are not in the worker's response; they belong
|
|
416
|
+
to the dispatch. Deriving them from the presence of a result file would call
|
|
417
|
+
a timed-out worker `completed` whenever it managed to write something.
|
|
418
|
+
"""
|
|
419
|
+
rows: list[dict[str, Any]] = []
|
|
420
|
+
for raw in raw_dispatches:
|
|
421
|
+
worker, value = _split_pair(raw, "--dispatch")
|
|
422
|
+
status, _, duration = value.partition(":")
|
|
423
|
+
if not duration.isdigit():
|
|
424
|
+
raise ConvergenceContractError(
|
|
425
|
+
f"--dispatch for `{worker}` must end in :<durationMs>, got: {value}"
|
|
426
|
+
)
|
|
427
|
+
rows.append({
|
|
428
|
+
"worker": worker, "status": status, "durationMs": int(duration)
|
|
429
|
+
})
|
|
430
|
+
return rows
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _planned_finding_ids(plan: dict[str, Any]) -> dict[str, list[str]]:
|
|
434
|
+
dispatches = plan.get("dispatches")
|
|
435
|
+
if not isinstance(dispatches, list):
|
|
436
|
+
raise ConvergenceContractError("round plan has no `dispatches` array")
|
|
437
|
+
planned: dict[str, list[str]] = {}
|
|
438
|
+
for row in dispatches:
|
|
439
|
+
worker = row.get("worker") if isinstance(row, dict) else None
|
|
440
|
+
if not isinstance(worker, str) or not worker:
|
|
441
|
+
raise ConvergenceContractError("every round plan dispatch needs a `worker`")
|
|
442
|
+
planned[worker] = list(row.get("findingIds") or [])
|
|
443
|
+
return planned
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _worker_votes(
|
|
447
|
+
raw_results: list[str], planned: dict[str, list[str]], adversarial: bool
|
|
448
|
+
) -> dict[str, dict[str, FindingVote]]:
|
|
449
|
+
votes: dict[str, dict[str, FindingVote]] = {}
|
|
450
|
+
for raw in raw_results:
|
|
451
|
+
worker, path = _split_pair(raw, "--result")
|
|
452
|
+
if worker not in planned:
|
|
453
|
+
raise ConvergenceContractError(
|
|
454
|
+
f"`{worker}` returned a result but the round plan did not dispatch it"
|
|
455
|
+
)
|
|
456
|
+
parsed = parse_finding_votes(
|
|
457
|
+
Path(path).read_text(encoding="utf-8"), adversarial=adversarial
|
|
458
|
+
)
|
|
459
|
+
assigned = set(planned[worker])
|
|
460
|
+
missing = sorted(assigned - set(parsed))
|
|
461
|
+
if missing:
|
|
462
|
+
raise ConvergenceContractError(
|
|
463
|
+
f"`{worker}` was dispatched {len(assigned)} findings but returned "
|
|
464
|
+
f"no verdict for {missing} — an unanswered finding cannot be "
|
|
465
|
+
f"classified, and dropping it silently makes the round look complete"
|
|
466
|
+
)
|
|
467
|
+
unknown = sorted(set(parsed) - assigned)
|
|
468
|
+
if unknown:
|
|
469
|
+
raise ConvergenceContractError(
|
|
470
|
+
f"`{worker}` returned verdicts for {unknown}, which this round's "
|
|
471
|
+
f"plan did not dispatch to it"
|
|
472
|
+
)
|
|
473
|
+
votes[worker] = parsed
|
|
474
|
+
return votes
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
|
|
478
|
+
plan = load_json_object(args.plan)
|
|
479
|
+
planned = _planned_finding_ids(plan)
|
|
480
|
+
dispatches = _dispatch_rows(args.dispatch)
|
|
481
|
+
undispatched = sorted(set(planned) - {row["worker"] for row in dispatches})
|
|
482
|
+
if undispatched:
|
|
483
|
+
raise ConvergenceContractError(
|
|
484
|
+
f"the round plan dispatched {undispatched} but no --dispatch status "
|
|
485
|
+
f"was given for them; a planned worker with no recorded outcome is "
|
|
486
|
+
f"indistinguishable from one that was never asked"
|
|
487
|
+
)
|
|
488
|
+
votes = _worker_votes(args.result, planned, args.mode == "adversarial")
|
|
489
|
+
by_finding: dict[str, dict[str, Any]] = {}
|
|
490
|
+
for worker, parsed in votes.items():
|
|
491
|
+
for finding_id, vote in parsed.items():
|
|
492
|
+
by_finding.setdefault(finding_id, {})[worker] = {
|
|
493
|
+
"verdict": vote.verdict,
|
|
494
|
+
"disagreeBasis": vote.disagree_basis,
|
|
495
|
+
"explanation": vote.explanation,
|
|
496
|
+
}
|
|
497
|
+
write_json_atomic(args.output, {
|
|
498
|
+
"schemaVersion": "1.0",
|
|
499
|
+
"round": plan.get("round"),
|
|
500
|
+
"dispatches": dispatches,
|
|
501
|
+
"votesByFinding": by_finding,
|
|
502
|
+
})
|
|
503
|
+
return "collected", args.output
|
|
504
|
+
|
|
505
|
+
|
|
351
506
|
def _execute(args: argparse.Namespace) -> tuple[str, Path]:
|
|
352
507
|
operations: dict[str, Any] = {
|
|
353
508
|
"seed": _seed,
|
|
354
509
|
"plan-round": _plan_round,
|
|
510
|
+
"collect-results": _collect_results,
|
|
355
511
|
"apply-round": _apply_round,
|
|
356
512
|
"apply-critic-gaps": _apply_critic_gaps,
|
|
357
513
|
"finalize": _finalize,
|
|
@@ -374,7 +530,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
374
530
|
return 0
|
|
375
531
|
try:
|
|
376
532
|
action, path = _execute(args)
|
|
377
|
-
except (ConvergenceContractError,
|
|
533
|
+
except (ConvergenceContractError, VerdictBlockError,
|
|
534
|
+
json.JSONDecodeError, ValueError) as exc:
|
|
378
535
|
print(f"error: {exc}", file=sys.stderr)
|
|
379
536
|
return 2
|
|
380
537
|
except OSError as exc:
|
|
@@ -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:
|
|
@@ -259,7 +269,9 @@ def _jobs_from_roster(
|
|
|
259
269
|
prompt_path,
|
|
260
270
|
options,
|
|
261
271
|
)
|
|
262
|
-
for fact, prompt_path in
|
|
272
|
+
for fact, prompt_path in (
|
|
273
|
+
(fact, prompt_paths[fact.worker_id]) for fact in worker_facts
|
|
274
|
+
)
|
|
263
275
|
]
|
|
264
276
|
|
|
265
277
|
|
|
@@ -327,7 +339,7 @@ def _materialize_roster_prompts(
|
|
|
327
339
|
team_state: Mapping[str, Any],
|
|
328
340
|
selected: Sequence[str],
|
|
329
341
|
options: _BuildOptions,
|
|
330
|
-
) -> tuple[tuple[_RosterWorkerFacts, ...],
|
|
342
|
+
) -> tuple[tuple[_RosterWorkerFacts, ...], dict[str, Path]]:
|
|
331
343
|
worker_facts = tuple(
|
|
332
344
|
_roster_worker_facts(team_state, worker_id)
|
|
333
345
|
for worker_id in selected
|
|
@@ -425,9 +437,29 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
425
437
|
return _run_cli_wrapper(plan, job, "")
|
|
426
438
|
if job.backend == BACKEND_TMUX_PANE:
|
|
427
439
|
return _start_tmux_or_degrade(plan, job)
|
|
440
|
+
if job.backend == BACKEND_CMUX_PANE:
|
|
441
|
+
return _start_cmux_or_degrade(plan, job)
|
|
428
442
|
raise DispatchError(f"unsupported worker backend: {job.backend}")
|
|
429
443
|
|
|
430
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
|
+
|
|
431
463
|
def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
432
464
|
lead_pane = tmux.resolve_caller_pane()
|
|
433
465
|
if not lead_pane:
|
|
@@ -559,7 +591,7 @@ def _dispatch_record(
|
|
|
559
591
|
|
|
560
592
|
|
|
561
593
|
def _liveness_mode(backend: str) -> str:
|
|
562
|
-
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
|
|
594
|
+
if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
|
|
563
595
|
return LIVENESS_WRAPPER_STATUS
|
|
564
596
|
return LIVENESS_AUDIT_HEARTBEAT
|
|
565
597
|
|
|
@@ -613,7 +645,44 @@ def _skip_reasons(
|
|
|
613
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}
|
|
614
646
|
|
|
615
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
|
+
|
|
616
684
|
def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
|
|
685
|
+
_relay_to_sidebar(plan, event_type, details)
|
|
617
686
|
append_lead_event(
|
|
618
687
|
plan.lead_events_path,
|
|
619
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
|