okstra 0.175.1 → 0.176.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.
Files changed (81) hide show
  1. package/docs/architecture/storage-model.md +2 -2
  2. package/docs/architecture.md +22 -19
  3. package/docs/cli.md +18 -14
  4. package/docs/for-ai/skills/okstra-inspect.md +2 -3
  5. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  6. package/docs/project-structure-overview.md +57 -56
  7. package/docs/task-process/README.md +11 -9
  8. package/docs/task-process/common-flow.md +13 -16
  9. package/docs/task-process/error-analysis.md +9 -10
  10. package/docs/task-process/final-verification.md +7 -7
  11. package/docs/task-process/implementation-planning.md +9 -9
  12. package/docs/task-process/implementation.md +6 -6
  13. package/docs/task-process/release-handoff.md +8 -7
  14. package/docs/task-process/requirements-discovery.md +8 -8
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/bin/lib/okstra/interactive.sh +12 -6
  18. package/runtime/bin/lib/okstra/usage.sh +3 -2
  19. package/runtime/bin/okstra-spawn-followups.py +4 -2
  20. package/runtime/prompts/launch.template.md +2 -2
  21. package/runtime/prompts/lead/context-loader.md +1 -2
  22. package/runtime/prompts/lead/okstra-lead-contract.md +3 -4
  23. package/runtime/prompts/lead/report-writer.md +15 -1
  24. package/runtime/prompts/lead/team-contract.md +16 -12
  25. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  26. package/runtime/prompts/profiles/_implementation-executor.md +0 -3
  27. package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
  28. package/runtime/prompts/profiles/change-impact-analysis.md +9 -5
  29. package/runtime/prompts/profiles/error-analysis.md +14 -8
  30. package/runtime/prompts/profiles/feature-analysis.md +9 -5
  31. package/runtime/prompts/profiles/final-verification.md +10 -7
  32. package/runtime/prompts/profiles/implementation-option-selection.md +9 -5
  33. package/runtime/prompts/profiles/implementation-planning.md +13 -7
  34. package/runtime/prompts/profiles/implementation.md +9 -5
  35. package/runtime/prompts/profiles/improvement-discovery.md +10 -6
  36. package/runtime/prompts/profiles/project-analysis.md +9 -5
  37. package/runtime/prompts/profiles/requirements-discovery.md +15 -9
  38. package/runtime/prompts/wizard/prompts.ko.json +18 -1
  39. package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
  40. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
  41. package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
  42. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
  43. package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
  44. package/runtime/python/okstra_ctl/assignment_resolver.py +27 -27
  45. package/runtime/python/okstra_ctl/dispatch_core.py +120 -100
  46. package/runtime/python/okstra_ctl/domain/host.py +3 -1
  47. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +5 -0
  48. package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
  49. package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
  50. package/runtime/python/okstra_ctl/legacy_model_selection.py +115 -27
  51. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  52. package/runtime/python/okstra_ctl/next_phase.py +236 -0
  53. package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
  54. package/runtime/python/okstra_ctl/recap.py +4 -1
  55. package/runtime/python/okstra_ctl/render.py +46 -47
  56. package/runtime/python/okstra_ctl/role_requirements.py +28 -35
  57. package/runtime/python/okstra_ctl/rollup.py +4 -1
  58. package/runtime/python/okstra_ctl/run.py +8 -5
  59. package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
  60. package/runtime/python/okstra_ctl/team.py +14 -18
  61. package/runtime/python/okstra_ctl/wizard.py +248 -56
  62. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
  63. package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
  64. package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
  65. package/runtime/python/okstra_ctl/workflow.py +18 -32
  66. package/runtime/python/okstra_ctl/worktree.py +3 -3
  67. package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
  68. package/runtime/python/okstra_project/state.py +54 -6
  69. package/runtime/schemas/final-report-v2.0.schema.json +43 -5
  70. package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
  71. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  72. package/runtime/skills/okstra-inspect/facets/status.md +15 -13
  73. package/runtime/skills/okstra-rollup/SKILL.md +1 -0
  74. package/runtime/skills/okstra-run/SKILL.md +5 -5
  75. package/runtime/templates/implementation-worker-preamble.md +3 -18
  76. package/runtime/templates/project-docs/task-index.template.md +0 -1
  77. package/runtime/templates/reports/html/macros/forms.html +5 -4
  78. package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
  79. package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
  80. package/runtime/templates/worker-prompt-preamble.md +3 -36
  81. package/runtime/validators/validate-run.py +57 -99
@@ -3,16 +3,19 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: verifier
6
- count: 2
7
- optionalCount: 3
6
+ min: 2
7
+ recommended: 2
8
+ max: 5
8
9
  duty: acceptance-verifier
9
- minDistinctProviders: 2
10
10
  - role: critic
11
- count: 0
12
- optionalCount: 1
11
+ min: 0
12
+ recommended: 0
13
+ max: 1
13
14
  duty: acceptance-critic
14
15
  - role: report-writer
15
- count: 1
16
+ min: 1
17
+ recommended: 1
18
+ max: 1
16
19
  duty: report-writer
17
20
  ```
18
21
 
@@ -79,7 +82,7 @@ roles:
79
82
  - **Validation Evidence**: for every requirement in the originating plan or task brief, cite the artifact (commit SHA, test output, log line, MCP SELECT result) that demonstrates coverage. Paraphrased "verified" claims without an artifact are rejected.
80
83
  - **Read-only command log**: any pre-existing test/validation command touched during this run MUST be listed with its exact command line and one honest status — `executed` (ran; carries its exit code) / `advisory` (external Tier 3 did not PASS; carries observed/expected results and remains user-owned) / `env-unavailable` (should run but cannot in this environment — missing replica DB, container, or service; carries the reason, never a faked pass) / `not-configured` (no such qa-command tier) / `rejected` (a mutating/denied token — skipped, carries the denied token). A check that could not run locally is recorded as `env-unavailable` or `advisory` according to the external QA policy — never silently dropped and never reported as `executed` with an invented exit code. Mutating-command prohibition is the shared read-only boundary (see Non-goals); it is not restated per row.
81
84
  - **Could-not-verify roll-up (§5.8.9)**: the template mechanically aggregates every not-confirmed check into one scannable list — `gap` requirement-coverage rows, `advisory` / `not-configured` / `env-unavailable` / `rejected` command rows, and `blocked` manual tests. You do not hand-author it, but you MUST give those rows their honest status so nothing unverified hides across sections: a check silently recorded as `executed`/`covered` will not surface in the roll-up. This is okstra's answer to "say what could not be verified this run."
82
- - **Routing recommendation**: the next safe phase — one of `release-handoff`, `done`, `error-analysis`, `implementation-option-selection`, `implementation-planning` — tied to the verdict and blocker list. `release-handoff` is allowed ONLY when the Verdict Token is `accepted`. `release-handoff` is additionally allowed ONLY when the verification scope (the `Verification scope:` line of the injected `VERIFICATION_TARGET` block, recorded as the report's `verificationScope` field) is `whole-task`; a `single-stage` accepted run routes to `release-handoff(stage-group)` (or `implementation` / `done`); plain `release-handoff` remains whole-task-only. Enforcement: `validators/validate-run.py` rejects a `single-stage` report whose routing cites plain `release-handoff`.
85
+ - **Routing recommendation**: `finalVerification.routingRecommendation` is an **object** with exactly two fields — `target`, one value of the enum below, and `rationale`, the sentence tying that choice to the verdict and the blocker list. Free routing prose is not the field; a target named only in the prose does not route the task, because Phase 7 projects `workflow.nextRecommendedPhase` from `target` alone. The seven allowed targets are `release-handoff`, `release-handoff(stage-group)`, `error-analysis`, `implementation-option-selection`, `implementation-planning`, `implementation`, and `done`. Both `release-handoff` forms are allowed ONLY when the Verdict Token is `accepted`. Plain `release-handoff` is additionally allowed ONLY when the verification scope (the `Verification scope:` line of the injected `VERIFICATION_TARGET` block, recorded as the report's `verificationScope` field) is `whole-task`; a `single-stage` accepted run routes to `release-handoff(stage-group)` (or `implementation` / `done`) instead. `done` ends the lifecycle here. Enforcement: `schemas/final-report-v2.0.schema.json` rejects a `target` outside the enum, a missing `rationale`, and a string in place of the object; `validators/validate-run.py` rejects a missing `target`, a non-`accepted` verdict routed to either `release-handoff` form, and a `single-stage` report whose routing cites plain `release-handoff`.
83
86
  - **Verified-row recording** (single-stage scope only): when the Verdict Token is `accepted`, the lead MUST run `okstra handoff record-verified --plan-run-root <plan-run-root> --stage <N> --report-path <final-report.md path> --data-json <final-report data.json path>` and quote the command + exit code in the report. The helper re-validates taskType/scope/verdict from data.json, so a non-accepted or whole-task report is rejected at the tool layer. **Enforced:** `validators/validate-run.py` `_validate_verified_row_recorded` requires a `verified` row in `runs/implementation-planning/consumers.jsonl` for every accepted stage — the helper validated its own inputs but nothing checked it had ever run, leaving reports that said `accepted` while the registry said unverified, so the stage was never offered for a stage-group PR.
84
87
  - Clarification request policy (phase-specific addendum — shared policy is in `_common-contract.md`):
85
88
  - populate `## 1. Clarification Items` only when a blocker hinges on information only the user can supply (deployment intent, intended target environment, business-rule interpretation); use `Blocks=next-phase` for items that gate continuing to release-handoff
@@ -3,15 +3,19 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: designer
6
- count: 3
7
- optionalCount: 2
6
+ min: 3
7
+ recommended: 3
8
+ max: 5
8
9
  duty: direction-selection-worker
9
- minDistinctProviders: 3
10
10
  - role: report-writer
11
- count: 1
11
+ min: 1
12
+ recommended: 1
13
+ max: 1
12
14
  duty: report-writer
13
15
  - role: verifier
14
- count: 0
16
+ min: 0
17
+ recommended: 0
18
+ max: 0
15
19
  duty: reverification-worker
16
20
  dynamic: true
17
21
  ```
@@ -3,19 +3,24 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: planner
6
- count: 2
7
- optionalCount: 3
6
+ min: 2
7
+ recommended: 2
8
+ max: 5
8
9
  duty: planning-worker
9
- minDistinctProviders: 2
10
10
  - role: critic
11
- count: 0
12
- optionalCount: 1
11
+ min: 0
12
+ recommended: 0
13
+ max: 1
13
14
  duty: acceptance-critic
14
15
  - role: report-writer
15
- count: 1
16
+ min: 1
17
+ recommended: 1
18
+ max: 1
16
19
  duty: report-writer
17
20
  - role: verifier
18
- count: 0
21
+ min: 0
22
+ recommended: 0
23
+ max: 0
19
24
  duty: reverification-worker
20
25
  dynamic: true
21
26
  ```
@@ -36,6 +41,7 @@ roles:
36
41
  - never plan around an unconfirmed `intent-inference` augmentation as if it were a settled requirement. Treat the inference as settled ONLY when a `[CONFIRMED …]` marker sits on the matching `intent-check:` row after the precondition runs; absent the marker it stays a `Blocks=approval` clarification item per the precondition's `skipped` branch.
37
42
  - `conversion-block:` rows are handled by the precondition; planning around an untranslated reporter phrase is forbidden until it is resolved.
38
43
  - Worker planning procedure:
44
+ - **Ticket Tagging.** Tag every section 1–5 item with its related ticket. Use `Issue / Ticket`, fall back to Task ID, then `unknown`; comma-separate multiple tickets.
39
45
  - route by the planning input contract before analysis. A run carrying `selected-direction.json` uses the selected-direction procedure. Only a legacy rerun without that snapshot uses candidate comparison.
40
46
  - **Selected-direction planning procedure** — perform these steps in order and no others:
41
47
  1. Read `selected-direction.json` and the original requirements ledger end-to-end.
@@ -3,15 +3,19 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: implementer
6
- count: 1
6
+ min: 1
7
+ recommended: 1
8
+ max: 1
7
9
  duty: implementation-executor
8
10
  - role: verifier
9
- count: 2
10
- optionalCount: 1
11
+ min: 2
12
+ recommended: 2
13
+ max: 3
11
14
  duty: implementation-verifier
12
- minDistinctProviders: 2
13
15
  - role: report-writer
14
- count: 1
16
+ min: 1
17
+ recommended: 1
18
+ max: 1
15
19
  duty: report-writer
16
20
  ```
17
21
 
@@ -3,15 +3,19 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: analyser
6
- count: 3
7
- optionalCount: 2
6
+ min: 3
7
+ recommended: 3
8
+ max: 5
8
9
  duty: discovery-worker
9
- minDistinctProviders: 3
10
10
  - role: report-writer
11
- count: 1
11
+ min: 1
12
+ recommended: 1
13
+ max: 1
12
14
  duty: report-writer
13
15
  - role: verifier
14
- count: 0
16
+ min: 0
17
+ recommended: 0
18
+ max: 0
15
19
  duty: reverification-worker
16
20
  dynamic: true
17
21
  ```
@@ -57,7 +61,7 @@ roles:
57
61
  - Stop conditions (OR): all questions resolved / budget exhausted / user signals proceed.
58
62
  - Lead persists the round at `<RUN_DIR>/state/phase-1.5-grilling.md` with one section per question (question / recommended / user answer) and a closing `Resolved scope` / `Resolved lenses` block. Worker prompts use this resolved block as the authoritative scope and lens definition.
59
63
  - The same log includes `## Primary Pass Assignments` with a `Worker ID | Primary lens` table. It contains every selected analyser exactly once in `requiredWorkerRoles` order; the lead derives it from the resolved roster and lenses rather than provider catalog order.
60
- - After writing the log and before Phase 4 dispatch, the lead injects its **absolute path** into every analyser prompt as the `**Phase 1.5 Grilling Log:** <absolute-path>` anchor header (see `templates/worker-prompt-preamble.md` §"Anchor headers"). This is the improvement-discovery counterpart to the `**Worktree:**` / `**Verification …:**` anchors that implementation / final-verification inject: workers read the log from this explicit path rather than re-deriving `<RUN_DIR>`. The path is byte-identical across all analysers, so it does not break the dispatch-prompt invariant.
64
+ - After writing the log and before Phase 4 dispatch, the lead injects its **absolute path** into every analyser prompt as the `**Phase 1.5 Grilling Log:** <absolute-path>` anchor header (see `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`). This is the improvement-discovery counterpart to the `**Worktree:**` / `**Verification …:**` anchors that implementation / final-verification inject: workers read the log from this explicit path rather than re-deriving `<RUN_DIR>`. The path is byte-identical across all analysers, so it does not break the dispatch-prompt invariant.
61
65
  - Decision-tree walk (bounded):
62
66
  - When candidates branch on a structural question (e.g. "is module X meant to own this responsibility?"), resolve via `Read` / `Grep` first. Only escalate to the user inside the Phase 1.5 budget.
63
67
  - Expected output emphasis:
@@ -3,15 +3,19 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: analyser
6
- count: 2
7
- optionalCount: 3
6
+ min: 2
7
+ recommended: 2
8
+ max: 5
8
9
  duty: analysis-worker
9
- minDistinctProviders: 2
10
10
  - role: report-writer
11
- count: 1
11
+ min: 1
12
+ recommended: 1
13
+ max: 1
12
14
  duty: report-writer
13
15
  - role: verifier
14
- count: 0
16
+ min: 0
17
+ recommended: 0
18
+ max: 0
15
19
  duty: reverification-worker
16
20
  dynamic: true
17
21
  ```
@@ -3,19 +3,24 @@
3
3
  ```yaml
4
4
  roles:
5
5
  - role: analyser
6
- count: 2
7
- optionalCount: 3
8
- duty: analysis-worker
9
- minDistinctProviders: 2
6
+ min: 2
7
+ recommended: 2
8
+ max: 5
9
+ duty: discovery-worker
10
10
  - role: critic
11
- count: 0
12
- optionalCount: 1
11
+ min: 0
12
+ recommended: 0
13
+ max: 1
13
14
  duty: scope-critic
14
15
  - role: report-writer
15
- count: 1
16
+ min: 1
17
+ recommended: 1
18
+ max: 1
16
19
  duty: report-writer
17
20
  - role: verifier
18
- count: 0
21
+ min: 0
22
+ recommended: 0
23
+ max: 0
19
24
  duty: reverification-worker
20
25
  dynamic: true
21
26
  ```
@@ -37,7 +42,8 @@ roles:
37
42
  - `intent-inference` augmentations whose paired `intent-check:` row carries `[CONFIRMED …]` are treated as **confirmed**; trust the confirmation text in `## Reporter Confirmations` over the original inference if they differ. Unconfirmed `intent-inference` rows under `reporter-confirmations: skipped` follow the precondition's `skipped` branch above.
38
43
  - `conversion-block:` rows are explicit "translation failed" signals — never attempt to resolve them by inference here; the precondition above already handled them.
39
44
  - Worker discovery procedure:
40
- - classify the request and cite the evidence that determines both its work category and safest next phase
45
+ - **Ticket Tagging.** Tag every section 1–5 item with its related ticket. Use `Issue / Ticket`, fall back to Task ID, then `unknown`; comma-separate multiple tickets.
46
+ - classify the request and cite the evidence that supports a recommended work category and safest next phase. The lead's Phase Routing settles the next phase.
41
47
  - identify independently startable decomposition candidates without publishing or rendering fan-out artifacts; preserve every directed dependency from `Related Task Graph`
42
48
  - resolve codebase-answerable ambiguity by inspection and record file:line evidence; return only human-owned decisions as clarification candidates with the evidence already checked
43
49
  - state the reporter's rejection criteria, missing routing inputs, and the evidence boundary behind each recommendation
@@ -480,11 +480,28 @@
480
480
  "no": "아니오 — 단계별로 다시 입력"
481
481
  }
482
482
  },
483
+ "leader_session": {
484
+ "label": "리더는 현재 세션 모델을 씁니다 (읽기 전용): {model_ref}{effort_suffix}",
485
+ "echo_template": "leader-session: {value}",
486
+ "options": {
487
+ "continue": "계속"
488
+ },
489
+ "effort_suffix": " · effort {effort}"
490
+ },
483
491
  "role_count": {
484
- "label": "{role} 역할 인스턴스 수를 선택하세요 ({minimum}..{maximum}, 기본 {default})",
492
+ "label": "{role} 역할 인스턴스 수를 선택하세요 ({minimum}..{maximum}, 적정 {default})",
485
493
  "echo_template": "role-count: {value}",
486
494
  "options": {
487
495
  "count": "{count}개{default_suffix}",
496
+ "default_suffix": " (적정)"
497
+ }
498
+ },
499
+ "role_add": {
500
+ "label": "선택 역할 {role} 을(를) 이번 run 에 추가할까요? (최대 {maximum}개, 기본: 추가 안 함)",
501
+ "echo_template": "role-add: {value}",
502
+ "options": {
503
+ "skip": "추가 안 함{default_suffix}",
504
+ "add": "{count}개 추가",
488
505
  "default_suffix": " (기본)"
489
506
  }
490
507
  },
@@ -0,0 +1 @@
1
+ """Execution-surface adapters."""
@@ -0,0 +1,27 @@
1
+ """Build the injected execution-surface chain for one planned surface."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Sequence
5
+
6
+ from ...domain.worker_runtime import SURFACE_CLI_WRAPPER, SURFACE_CMUX_PANE
7
+ from ...ports.worker_runtime import WorkerRuntimePort
8
+ from .cli_wrapper import CliWrapperRuntime
9
+ from .cmux import CmuxRuntime
10
+
11
+
12
+ def runtime_chain(planned_surface: str) -> tuple[WorkerRuntimePort, ...]:
13
+ cli = CliWrapperRuntime()
14
+ if planned_surface == SURFACE_CLI_WRAPPER:
15
+ return (cli,)
16
+ if planned_surface == SURFACE_CMUX_PANE:
17
+ return (CmuxRuntime(), cli)
18
+ raise ValueError(f"unknown execution surface: {planned_surface}")
19
+
20
+
21
+ def port_for(
22
+ chain: Sequence[WorkerRuntimePort], surface: str
23
+ ) -> WorkerRuntimePort:
24
+ for port in chain:
25
+ if port.surface == surface:
26
+ return port
27
+ raise KeyError(surface)
@@ -0,0 +1,49 @@
1
+ """Run a worker as a lead-owned subprocess."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import uuid
6
+
7
+ from ...domain.worker_runtime import (
8
+ SURFACE_CLI_WRAPPER,
9
+ ProgressEvent,
10
+ RuntimeHandle,
11
+ WorkerSpawnRequest,
12
+ )
13
+
14
+
15
+ class CliWrapperRuntime:
16
+ surface = SURFACE_CLI_WRAPPER
17
+
18
+ def __init__(self) -> None:
19
+ self._children: dict[str, subprocess.Popen[str]] = {}
20
+
21
+ def spawn(self, request: WorkerSpawnRequest) -> RuntimeHandle:
22
+ process = subprocess.Popen(
23
+ list(request.command), cwd=request.cwd, text=True
24
+ )
25
+ identifier = uuid.uuid4().hex
26
+ self._children[identifier] = process
27
+ return RuntimeHandle(
28
+ surface=self.surface,
29
+ identifier=identifier,
30
+ waitable=True,
31
+ )
32
+
33
+ def close(self, handle: RuntimeHandle) -> None:
34
+ return None
35
+
36
+ def wait(self, handle: RuntimeHandle) -> int:
37
+ process = self._children[handle.identifier]
38
+ return process.wait()
39
+
40
+ def terminate(self, handle: RuntimeHandle) -> None:
41
+ process = self._children[handle.identifier]
42
+ if process.poll() is None:
43
+ process.terminate()
44
+
45
+ def notify(self, event: ProgressEvent) -> None:
46
+ return None
47
+
48
+ def restore_lead(self) -> None:
49
+ return None
@@ -0,0 +1,74 @@
1
+ """Open and signal workers on a cmux pane surface."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+
6
+ from ... import cmux
7
+ from ...domain.worker_runtime import (
8
+ SURFACE_CMUX_PANE,
9
+ EnvironmentBlocked,
10
+ ProgressEvent,
11
+ RuntimeHandle,
12
+ SurfaceUnavailable,
13
+ WorkerSpawnRequest,
14
+ )
15
+
16
+
17
+ class CmuxRuntime:
18
+ surface = SURFACE_CMUX_PANE
19
+
20
+ def spawn(self, request: WorkerSpawnRequest) -> RuntimeHandle:
21
+ workspace = cmux.resolve_lead_workspace()
22
+ if not workspace:
23
+ reason = cmux.unreachable_reason()
24
+ if reason in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
25
+ observed = (
26
+ "this process has no cmux environment left — a sandbox sanitized it"
27
+ if reason == cmux.LOST_ENVIRONMENT
28
+ else "cmux is running but this process may not connect to its socket"
29
+ )
30
+ raise EnvironmentBlocked(
31
+ "this run was prepared for cmux panes, but "
32
+ f"{observed}. The same sandbox blocks worker CLIs from "
33
+ "their own config, so falling back to blocking workers "
34
+ "would fail too. Relaunch the lead outside a sandbox "
35
+ "(use a full-access lead sandbox)."
36
+ )
37
+ raise SurfaceUnavailable("cmux lead workspace is gone")
38
+ try:
39
+ surface_id = cmux.spawn_worker_surface(
40
+ workspace=workspace,
41
+ cwd=request.cwd,
42
+ command=request.command,
43
+ title=request.title,
44
+ owned_surface_ids=request.owned_surface_ids,
45
+ )
46
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
47
+ raise SurfaceUnavailable(str(exc)) from exc
48
+ return RuntimeHandle(
49
+ surface=self.surface,
50
+ identifier=surface_id,
51
+ waitable=False,
52
+ )
53
+
54
+ def close(self, handle: RuntimeHandle) -> None:
55
+ cmux.close_surface(handle.identifier)
56
+
57
+ def wait(self, handle: RuntimeHandle) -> int:
58
+ raise RuntimeError("cmux-pane handles are not waitable")
59
+
60
+ def terminate(self, handle: RuntimeHandle) -> None:
61
+ self.close(handle)
62
+
63
+ def notify(self, event: ProgressEvent) -> None:
64
+ workspace = cmux.resolve_lead_workspace()
65
+ cmux.sidebar_log(workspace, event.message, level=event.level)
66
+ if event.notify_title is not None:
67
+ cmux.sidebar_notify(
68
+ workspace,
69
+ title=event.notify_title,
70
+ body=event.notify_body or "",
71
+ )
72
+
73
+ def restore_lead(self) -> None:
74
+ cmux.restore_lead_width()
@@ -0,0 +1,29 @@
1
+ """Choose an execution surface from an injected port chain."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Sequence
5
+
6
+ from ..domain.worker_runtime import (
7
+ EnvironmentBlocked,
8
+ RuntimeHandle,
9
+ SurfaceUnavailable,
10
+ WorkerSpawnRequest,
11
+ )
12
+ from ..ports.worker_runtime import WorkerRuntimePort
13
+
14
+
15
+ def open_worker(
16
+ chain: Sequence[WorkerRuntimePort],
17
+ request_for: Callable[[str], WorkerSpawnRequest],
18
+ ) -> RuntimeHandle:
19
+ last_unavailable: SurfaceUnavailable | None = None
20
+ for port in chain:
21
+ try:
22
+ return port.spawn(request_for(port.surface))
23
+ except EnvironmentBlocked:
24
+ raise
25
+ except SurfaceUnavailable as exc:
26
+ last_unavailable = exc
27
+ if last_unavailable is None:
28
+ raise SurfaceUnavailable("runtime chain is empty")
29
+ raise last_unavailable
@@ -339,7 +339,7 @@ def resolve_model_selection(
339
339
 
340
340
 
341
341
  def _static_requirements(profile: RoleProfile) -> tuple[RoleRequirement, ...]:
342
- leader = RoleRequirement("leader", 1, "lead")
342
+ leader = RoleRequirement("leader", 1, 1, 1, "lead")
343
343
  return (leader, *(row for row in profile.roles if not row.dynamic))
344
344
 
345
345
 
@@ -355,17 +355,17 @@ def _role_instances(
355
355
  )
356
356
  instances: list[RoleInstance] = []
357
357
  for requirement in requirements:
358
- if requirement.role in role_counts and requirement.optional_count == 0:
358
+ fixed = requirement.min_count == requirement.max_count
359
+ if requirement.role in role_counts and fixed:
359
360
  raise AssignmentResolutionError(
360
361
  f"role {requirement.role!r} has a fixed quantity and cannot "
361
362
  "receive an explicit count"
362
363
  )
363
- count = role_counts.get(requirement.role, requirement.count)
364
- maximum = requirement.count + requirement.optional_count
365
- if count < requirement.count or count > maximum:
364
+ count = role_counts.get(requirement.role, requirement.recommended_count)
365
+ if count < requirement.min_count or count > requirement.max_count:
366
366
  raise AssignmentResolutionError(
367
367
  f"role {requirement.role!r} count must be in "
368
- f"{requirement.count}..{maximum}: {count}"
368
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
369
369
  )
370
370
  instances.extend(
371
371
  RoleInstance(requirement.role, requirement.duty, ordinal)
@@ -747,20 +747,16 @@ def _constraint_failures(
747
747
  ) -> tuple[UnsatisfiedConstraint, ...]:
748
748
  failures: list[UnsatisfiedConstraint] = []
749
749
  for requirement in requirements:
750
- required = requirement.min_distinct_providers
751
- if required is None:
750
+ role_rows = [row for row in assignments if row.role == requirement.role]
751
+ if not role_rows:
752
752
  continue
753
- providers = {
754
- row.provider_id
755
- for row in assignments
756
- if row.role == requirement.role and row.ordinal <= requirement.count
757
- }
758
- if len(providers) < required:
753
+ refs = {row.model_ref for row in role_rows}
754
+ if len(refs) < len(role_rows):
759
755
  failures.append(UnsatisfiedConstraint(
760
756
  requirement.role,
761
- "minDistinctProviders",
762
- required,
763
- len(providers),
757
+ "uniqueModelRefs",
758
+ len(role_rows),
759
+ len(refs),
764
760
  ))
765
761
  return tuple(failures)
766
762
 
@@ -771,20 +767,24 @@ def _unmet_constraints(
771
767
  ) -> tuple[UnsatisfiedConstraint, ...]:
772
768
  failures: list[UnsatisfiedConstraint] = []
773
769
  for requirement in requirements:
774
- required = requirement.min_distinct_providers
775
- if required is None:
776
- continue
777
- providers = {
778
- row.provider_id
770
+ role_groups = [
771
+ candidates
779
772
  for candidates in options
773
+ if candidates and candidates[0].role == requirement.role
774
+ ]
775
+ if not role_groups:
776
+ continue
777
+ worker_count = len(role_groups)
778
+ unique_refs = {
779
+ row.model_ref
780
+ for candidates in role_groups
780
781
  for row in candidates
781
- if row.role == requirement.role and row.ordinal <= requirement.count
782
782
  }
783
- if len(providers) < required:
783
+ if len(unique_refs) < worker_count:
784
784
  failures.append(UnsatisfiedConstraint(
785
785
  requirement.role,
786
- "minDistinctProviders",
787
- required,
788
- len(providers),
786
+ "uniqueModelRefs",
787
+ worker_count,
788
+ len(unique_refs),
789
789
  ))
790
790
  return tuple(failures)