okstra 0.177.0 → 0.177.1
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/dist/commands/execute/team.mjs +14 -4
- package/dist/commands/execute/team.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +0 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +3 -3
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-compact-reminder.sh +2 -2
- package/runtime/bin/okstra-render-report-views.py +13 -10
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +13 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +96 -17
- package/runtime/python/okstra_ctl/dispatch_state.py +57 -0
- package/runtime/python/okstra_ctl/model_cli.py +11 -2
- package/runtime/python/okstra_ctl/model_discovery.py +12 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
- package/runtime/python/okstra_ctl/render.py +53 -0
- package/runtime/python/okstra_ctl/report_html/render.py +7 -4
- package/runtime/python/okstra_ctl/run.py +88 -2
- package/runtime/python/okstra_ctl/team.py +84 -14
- package/runtime/python/okstra_ctl/tmux.py +2 -3
- package/runtime/python/okstra_ctl/worker_runner.py +2 -2
- package/runtime/python/okstra_ctl/write_policy.py +9 -1
- package/runtime/schemas/final-report-v2.0.schema.json +3 -1
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/templates/reports/html/base.template.html +1 -2
- package/runtime/validators/validate-report-views.py +30 -17
- package/runtime/validators/validate-run.py +123 -78
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/runtime/bin/okstra-trace-cleanup.sh +0 -185
|
@@ -1929,12 +1929,19 @@ def _build_static_execution_manifest(
|
|
|
1929
1929
|
plan: AssignmentPlan,
|
|
1930
1930
|
context: AssignmentContext,
|
|
1931
1931
|
lead: ResolvedAssignment,
|
|
1932
|
+
translator: ResolvedAssignment,
|
|
1932
1933
|
) -> ExecutionManifest:
|
|
1933
1934
|
participants: list[ParticipantAssignment] = []
|
|
1934
1935
|
roles: list[RoleExecution] = []
|
|
1936
|
+
# The translator is resolved outside the role plan, like the lead, and both
|
|
1937
|
+
# are named in `invocationAssignments`. Without a role execution here,
|
|
1938
|
+
# `agent-prompt materialize --audience translator` has no canonical
|
|
1939
|
+
# identity to bind to and refuses — so a `reportLanguage: ko` run could
|
|
1940
|
+
# never write its translation sidecar and rendered the English source.
|
|
1935
1941
|
assignments = (
|
|
1936
1942
|
lead,
|
|
1937
1943
|
*(row for row in plan.assignments if row.role != "leader"),
|
|
1944
|
+
translator,
|
|
1938
1945
|
)
|
|
1939
1946
|
for index, assignment in enumerate(assignments, start=1):
|
|
1940
1947
|
participant_ref = f"participant-{index:03d}"
|
|
@@ -2145,8 +2152,14 @@ def _canonical_selection_provider_ids(
|
|
|
2145
2152
|
and "implementer" not in scopes.global_
|
|
2146
2153
|
)
|
|
2147
2154
|
if implementer_uses_bundled_default:
|
|
2155
|
+
# `--executor` names the provider that implements, so it belongs in the
|
|
2156
|
+
# roster the same way the bundled default does. Reading only the default
|
|
2157
|
+
# left `--executor <provider>` with no assignment of its own: the run
|
|
2158
|
+
# rendered without that provider, and asking for it with `--workers`
|
|
2159
|
+
# was refused as not being in the roster.
|
|
2148
2160
|
selected.append(
|
|
2149
|
-
|
|
2161
|
+
(inp.executor or "").strip().lower()
|
|
2162
|
+
or _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
|
|
2150
2163
|
)
|
|
2151
2164
|
if _needs_profile_worker_candidates(profile, selection, scopes):
|
|
2152
2165
|
selected.extend(
|
|
@@ -2302,6 +2315,7 @@ class RoleAssignment:
|
|
|
2302
2315
|
role: str
|
|
2303
2316
|
provider: str
|
|
2304
2317
|
model_display: str
|
|
2318
|
+
model_id: str
|
|
2305
2319
|
model_execution_value: str
|
|
2306
2320
|
runner: str
|
|
2307
2321
|
host_runtime: str
|
|
@@ -2309,9 +2323,14 @@ class RoleAssignment:
|
|
|
2309
2323
|
worker_id: str = ""
|
|
2310
2324
|
|
|
2311
2325
|
def to_model_payload(self) -> dict[str, object]:
|
|
2326
|
+
# `model` carries the catalog model id, not the display name: the run
|
|
2327
|
+
# manifest's role executions record `modelId`, and the two are compared
|
|
2328
|
+
# to bind an assignment to its execution. A display name that differs
|
|
2329
|
+
# from its id (every antigravity and kimi model) made that comparison
|
|
2330
|
+
# fail for the whole provider.
|
|
2312
2331
|
return {
|
|
2313
2332
|
"provider": self.provider,
|
|
2314
|
-
"model": self.
|
|
2333
|
+
"model": self.model_id,
|
|
2315
2334
|
"modelExecutionValue": self.model_execution_value,
|
|
2316
2335
|
"runner": self.runner,
|
|
2317
2336
|
"hostRuntime": self.host_runtime,
|
|
@@ -2351,6 +2370,7 @@ class _ModelBindings:
|
|
|
2351
2370
|
lead_assignment: RoleAssignment
|
|
2352
2371
|
worker_assignments: tuple[RoleAssignment, ...]
|
|
2353
2372
|
invocation_assignments: dict[str, dict[str, object]]
|
|
2373
|
+
translator: ResolvedAssignment
|
|
2354
2374
|
|
|
2355
2375
|
|
|
2356
2376
|
@dataclass(frozen=True)
|
|
@@ -2628,6 +2648,7 @@ def _resolve_model_bindings(
|
|
|
2628
2648
|
resolved=translator_meta,
|
|
2629
2649
|
role="translator",
|
|
2630
2650
|
)
|
|
2651
|
+
_reject_split_worker_models(executor_assignment, worker_assignments)
|
|
2631
2652
|
invocation_assignments = _build_invocation_assignments(
|
|
2632
2653
|
lead_assignment,
|
|
2633
2654
|
worker_assignments,
|
|
@@ -2656,6 +2677,7 @@ def _resolve_model_bindings(
|
|
|
2656
2677
|
lead_assignment=lead_assignment,
|
|
2657
2678
|
worker_assignments=worker_assignments,
|
|
2658
2679
|
invocation_assignments=invocation_assignments,
|
|
2680
|
+
translator=translator_meta,
|
|
2659
2681
|
)
|
|
2660
2682
|
|
|
2661
2683
|
|
|
@@ -2774,6 +2796,8 @@ def _project_model_bindings(
|
|
|
2774
2796
|
provider: _legacy_projection_from_plan(provider, plan, context)
|
|
2775
2797
|
for provider in ("claude", "codex", "antigravity")
|
|
2776
2798
|
}
|
|
2799
|
+
_reject_executor_outside_roster(executor_assignment, workers)
|
|
2800
|
+
_reject_split_worker_models(executor_assignment, workers)
|
|
2777
2801
|
invocation_assignments = _build_invocation_assignments(
|
|
2778
2802
|
lead_assignment,
|
|
2779
2803
|
workers,
|
|
@@ -2808,6 +2832,7 @@ def _project_model_bindings(
|
|
|
2808
2832
|
lead_assignment=lead_assignment,
|
|
2809
2833
|
worker_assignments=workers,
|
|
2810
2834
|
invocation_assignments=invocation_assignments,
|
|
2835
|
+
translator=translator,
|
|
2811
2836
|
)
|
|
2812
2837
|
|
|
2813
2838
|
|
|
@@ -2858,6 +2883,64 @@ def _selected_execution_provider_ids(
|
|
|
2858
2883
|
return tuple(dict.fromkeys(selected))
|
|
2859
2884
|
|
|
2860
2885
|
|
|
2886
|
+
def _reject_executor_outside_roster(
|
|
2887
|
+
executor: RoleAssignment | None,
|
|
2888
|
+
workers: tuple[RoleAssignment, ...],
|
|
2889
|
+
) -> None:
|
|
2890
|
+
"""Refuse an executor whose provider the roster never dispatches.
|
|
2891
|
+
|
|
2892
|
+
An implementation run opens its executor with `--workers <provider>`, so a
|
|
2893
|
+
provider absent from the roster has no invocation to open. The legacy
|
|
2894
|
+
selection path says so outright; the canonical path derived its roster from
|
|
2895
|
+
role models alone, so `--executor <provider>` rendered fine and the run only
|
|
2896
|
+
failed later, at `requested worker(s) are not in this run roster`.
|
|
2897
|
+
"""
|
|
2898
|
+
if executor is None:
|
|
2899
|
+
return
|
|
2900
|
+
roster = {row.worker_id for row in workers if row.worker_id}
|
|
2901
|
+
if executor.worker_id in roster:
|
|
2902
|
+
return
|
|
2903
|
+
raise PrepareError(
|
|
2904
|
+
f"--executor {executor.worker_id} is not in this run's roster "
|
|
2905
|
+
f"({', '.join(sorted(roster)) or 'empty'}); the executor is dispatched "
|
|
2906
|
+
f"as a worker, so give it a roster slot — "
|
|
2907
|
+
f"--role-model verifier={executor.worker_id}/<model> — or pick an "
|
|
2908
|
+
f"executor already in the roster."
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
def _reject_split_worker_models(
|
|
2913
|
+
executor: RoleAssignment | None,
|
|
2914
|
+
workers: tuple[RoleAssignment, ...],
|
|
2915
|
+
) -> None:
|
|
2916
|
+
"""Refuse one worker id standing for two roles on two different models.
|
|
2917
|
+
|
|
2918
|
+
The roster names a worker by provider, so an implementation run whose
|
|
2919
|
+
executor and verifier are the same provider shares that id — but
|
|
2920
|
+
`invocationAssignments["initial/<id>"]` can hold only one model, and it
|
|
2921
|
+
holds the verifier's. Dispatch then looks the executor's role execution up
|
|
2922
|
+
by that assignment, finds no row with a matching execution value, and stops
|
|
2923
|
+
at `v2 execution identity does not match a canonical role execution`. The
|
|
2924
|
+
render used to succeed and only the dispatch failed, by which point the run
|
|
2925
|
+
had already claimed its stage.
|
|
2926
|
+
"""
|
|
2927
|
+
if executor is None:
|
|
2928
|
+
return
|
|
2929
|
+
peer = next(
|
|
2930
|
+
(row for row in workers if row.worker_id == executor.worker_id),
|
|
2931
|
+
None,
|
|
2932
|
+
)
|
|
2933
|
+
if peer is None or peer.model_execution_value == executor.model_execution_value:
|
|
2934
|
+
return
|
|
2935
|
+
raise PrepareError(
|
|
2936
|
+
f"worker {executor.worker_id!r} is the executor on "
|
|
2937
|
+
f"{executor.model_execution_value!r} and a verifier on "
|
|
2938
|
+
f"{peer.model_execution_value!r}; one worker id carries one model. "
|
|
2939
|
+
f"Give both roles the same model, or pick a different --executor "
|
|
2940
|
+
f"provider."
|
|
2941
|
+
)
|
|
2942
|
+
|
|
2943
|
+
|
|
2861
2944
|
def _resolve_executor_assignment(
|
|
2862
2945
|
inp: PrepareInputs,
|
|
2863
2946
|
workers: list[str],
|
|
@@ -2930,6 +3013,7 @@ def _role_assignment(
|
|
|
2930
3013
|
role=role,
|
|
2931
3014
|
provider=resolved.provider_id,
|
|
2932
3015
|
model_display=resolved.display_name,
|
|
3016
|
+
model_id=resolved.model_id,
|
|
2933
3017
|
model_execution_value="unknown",
|
|
2934
3018
|
runner="cli-wrapper",
|
|
2935
3019
|
host_runtime=resolved.host_runtime,
|
|
@@ -2940,6 +3024,7 @@ def _role_assignment(
|
|
|
2940
3024
|
role=role,
|
|
2941
3025
|
provider=resolved.provider_id,
|
|
2942
3026
|
model_display=resolved.display_name,
|
|
3027
|
+
model_id=resolved.model_id,
|
|
2943
3028
|
model_execution_value=binding.resolved_execution_value,
|
|
2944
3029
|
runner=(
|
|
2945
3030
|
"native-session"
|
|
@@ -4086,6 +4171,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4086
4171
|
assignment_plan,
|
|
4087
4172
|
assignment_context,
|
|
4088
4173
|
models.lead,
|
|
4174
|
+
models.translator,
|
|
4089
4175
|
)
|
|
4090
4176
|
dynamic_roles = tuple(
|
|
4091
4177
|
requirement.role
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Under cmux this is every lead's door onto cmux surfaces, because okstra owns the
|
|
4
4
|
panes there rather than the host. Outside cmux a worker owns no pane at all — it
|
|
5
|
-
runs as a cli-wrapper subprocess — so there is
|
|
6
|
-
Which backend a run uses is read from its run manifest.
|
|
5
|
+
runs as a cli-wrapper subprocess — so there is no pane for either closing
|
|
6
|
+
command to act on. Which backend a run uses is read from its run manifest.
|
|
7
|
+
|
|
8
|
+
`reclaim` is the round boundary and `teardown` is the end of the run: the first
|
|
9
|
+
closes nothing but the finished dispatches' panes, the second takes every
|
|
10
|
+
recorded pane and writes off whatever never finished.
|
|
7
11
|
"""
|
|
8
12
|
from __future__ import annotations
|
|
9
13
|
|
|
@@ -18,7 +22,11 @@ from . import cmux
|
|
|
18
22
|
from .adapters.dispatch import provider_worker_wrappers
|
|
19
23
|
from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
|
|
20
24
|
from .application.dispatch_assignments import dispatch_assignments
|
|
21
|
-
from .dispatch_state import
|
|
25
|
+
from .dispatch_state import (
|
|
26
|
+
TEARDOWN_BEFORE_TERMINAL_REASON,
|
|
27
|
+
TERMINAL_WORKER_STATUSES,
|
|
28
|
+
mutate_team_state,
|
|
29
|
+
)
|
|
22
30
|
from .dispatch_core import (
|
|
23
31
|
BACKEND_CLI_WRAPPER,
|
|
24
32
|
BACKEND_CMUX_PANE,
|
|
@@ -34,7 +42,6 @@ from .session import observe_lead_session
|
|
|
34
42
|
|
|
35
43
|
|
|
36
44
|
_SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
|
|
37
|
-
_TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
@@ -47,6 +54,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
47
54
|
return _await(args)
|
|
48
55
|
if args.command == "teardown":
|
|
49
56
|
return _teardown(args)
|
|
57
|
+
if args.command == "reclaim":
|
|
58
|
+
return _reclaim(args)
|
|
50
59
|
except DispatchError as exc:
|
|
51
60
|
print(f"okstra team: {exc}", file=sys.stderr)
|
|
52
61
|
return 2
|
|
@@ -62,6 +71,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
62
71
|
_add_dispatch_parser(sub)
|
|
63
72
|
_add_await_parser(sub)
|
|
64
73
|
_add_teardown_parser(sub)
|
|
74
|
+
_add_reclaim_parser(sub)
|
|
65
75
|
return parser
|
|
66
76
|
|
|
67
77
|
|
|
@@ -85,7 +95,19 @@ def _add_await_parser(sub) -> None:
|
|
|
85
95
|
|
|
86
96
|
|
|
87
97
|
def _add_teardown_parser(sub) -> None:
|
|
88
|
-
parser = sub.add_parser(
|
|
98
|
+
parser = sub.add_parser(
|
|
99
|
+
"teardown", help="close every recorded pane at the end of the run"
|
|
100
|
+
)
|
|
101
|
+
_add_run_args(parser)
|
|
102
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
103
|
+
parser.add_argument("--json", action="store_true")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _add_reclaim_parser(sub) -> None:
|
|
107
|
+
parser = sub.add_parser(
|
|
108
|
+
"reclaim",
|
|
109
|
+
help="close the finished dispatches' panes at a round boundary",
|
|
110
|
+
)
|
|
89
111
|
_add_run_args(parser)
|
|
90
112
|
parser.add_argument("--dry-run", action="store_true")
|
|
91
113
|
parser.add_argument("--json", action="store_true")
|
|
@@ -161,8 +183,47 @@ def _teardown(args) -> int:
|
|
|
161
183
|
team_state = _load_json(team_state_path, "team-state")
|
|
162
184
|
panes = _reclaimable_panes(manifest, team_state)
|
|
163
185
|
if args.dry_run:
|
|
164
|
-
|
|
186
|
+
_emit_panes(args.json, panes)
|
|
187
|
+
return 0
|
|
188
|
+
_close_panes(manifest, panes)
|
|
189
|
+
_mark_teardown_errors(team_state_path)
|
|
190
|
+
_emit_panes(args.json, panes)
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _reclaim(args) -> int:
|
|
195
|
+
"""Close the finished dispatches' surfaces at a round boundary.
|
|
196
|
+
|
|
197
|
+
Two things teardown does are wrong here. It closes every recorded surface,
|
|
198
|
+
which mid-round would kill the workers still running; and it writes off every
|
|
199
|
+
non-terminal dispatch as an error, which would drop a worker out of the retry
|
|
200
|
+
path it has not reached yet. So this shares the closing and the reporting and
|
|
201
|
+
nothing else.
|
|
202
|
+
"""
|
|
203
|
+
manifest = _load_manifest(args.project_root, args.run_manifest)
|
|
204
|
+
_validate_team_manifest(manifest)
|
|
205
|
+
project_root = Path(args.project_root).resolve()
|
|
206
|
+
team_state_path = _resolve_project_path(
|
|
207
|
+
project_root, _require_string(manifest, "teamStatePath")
|
|
208
|
+
)
|
|
209
|
+
team_state = _load_json(team_state_path, "team-state")
|
|
210
|
+
panes = _reclaimable_panes(manifest, team_state, finished_only=True)
|
|
211
|
+
if args.dry_run:
|
|
212
|
+
_emit_panes(args.json, panes)
|
|
165
213
|
return 0
|
|
214
|
+
_close_panes(manifest, panes)
|
|
215
|
+
_emit_panes(args.json, panes)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> None:
|
|
220
|
+
"""Close the given surfaces, then give the lead its width back.
|
|
221
|
+
|
|
222
|
+
The width recovery belongs here rather than to teardown alone. cmux hands the
|
|
223
|
+
freed width to a neighbour it picks, and that neighbour is not always the
|
|
224
|
+
lead — so a round boundary that closed panes and stopped there leaves the
|
|
225
|
+
lead squeezed for exactly the stretch the user spends reading it.
|
|
226
|
+
"""
|
|
166
227
|
from .adapters.runtime.assembly import port_for, runtime_chain
|
|
167
228
|
from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
|
|
168
229
|
|
|
@@ -178,9 +239,6 @@ def _teardown(args) -> int:
|
|
|
178
239
|
chain[0].restore_lead()
|
|
179
240
|
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
|
|
180
241
|
print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
|
|
181
|
-
_mark_teardown_errors(team_state_path)
|
|
182
|
-
_emit_teardown(args.json, panes)
|
|
183
|
-
return 0
|
|
184
242
|
|
|
185
243
|
|
|
186
244
|
def _observe_lead_session_from_manifest(
|
|
@@ -240,10 +298,19 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
|
240
298
|
|
|
241
299
|
|
|
242
300
|
def _reclaimable_panes(
|
|
243
|
-
manifest: Mapping[str, Any],
|
|
301
|
+
manifest: Mapping[str, Any],
|
|
302
|
+
team_state: Mapping[str, Any],
|
|
303
|
+
*,
|
|
304
|
+
finished_only: bool = False,
|
|
244
305
|
) -> list[dict[str, str]]:
|
|
245
306
|
"""Everything this run owns and may close.
|
|
246
307
|
|
|
308
|
+
`finished_only` is what separates a round boundary from the end of the run.
|
|
309
|
+
Teardown closes every recorded surface because no dispatch is expected to
|
|
310
|
+
continue past it. Mid-round the live workers' surfaces must survive, so
|
|
311
|
+
reclaim asks for the finished ones only — closing an `in-progress` surface
|
|
312
|
+
kills that worker and the round has no result to show for it.
|
|
313
|
+
|
|
247
314
|
The recorded ids are the only candidates. There is no per-pane tag API to
|
|
248
315
|
sweep with, and scanning by title would be worse than nothing: cmux labels
|
|
249
316
|
its own agent surfaces with the same glyph the harness uses for a teammate
|
|
@@ -254,8 +321,11 @@ def _reclaimable_panes(
|
|
|
254
321
|
seen: set[str] = set()
|
|
255
322
|
panes: list[dict[str, str]] = []
|
|
256
323
|
for record in team_state.get("workerDispatches", []):
|
|
257
|
-
if isinstance(record, dict):
|
|
258
|
-
|
|
324
|
+
if not isinstance(record, dict):
|
|
325
|
+
continue
|
|
326
|
+
if finished_only and record.get("status") not in TERMINAL_WORKER_STATUSES:
|
|
327
|
+
continue
|
|
328
|
+
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
259
329
|
if _is_cmux_run(manifest):
|
|
260
330
|
return _still_open_surfaces(panes)
|
|
261
331
|
return panes
|
|
@@ -295,7 +365,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
|
295
365
|
def mark(payload: dict[str, Any]) -> bool:
|
|
296
366
|
changed = False
|
|
297
367
|
for record in payload.get("workerDispatches", []):
|
|
298
|
-
if isinstance(record, dict) and record.get("status") not in
|
|
368
|
+
if isinstance(record, dict) and record.get("status") not in TERMINAL_WORKER_STATUSES:
|
|
299
369
|
record["status"] = "error"
|
|
300
370
|
record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
|
|
301
371
|
changed = True
|
|
@@ -304,7 +374,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
|
|
|
304
374
|
mutate_team_state(team_state_path, mark)
|
|
305
375
|
|
|
306
376
|
|
|
307
|
-
def
|
|
377
|
+
def _emit_panes(as_json: bool, panes: list[dict[str, str]]) -> None:
|
|
308
378
|
if as_json:
|
|
309
379
|
_print_json({"panes": panes})
|
|
310
380
|
return
|
|
@@ -16,9 +16,8 @@ from typing import Optional, Sequence
|
|
|
16
16
|
|
|
17
17
|
# container watcher/tail pane 전용 태그. 이 태그가 붙은 pane 은 세션 종료 후에도
|
|
18
18
|
# 생존한다 — watcher/tail 의 "세션 후 생존" 불변식이다. 예전에는 SessionEnd 의
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
|
|
19
|
+
# 태그 스캔이 다른 태그만 본다는 사실이 그 생존을 지탱했지만, 지금은 그 스캔과
|
|
20
|
+
# 훅과 스크립트 자체가 없어 pane 을 세션 경계에서 회수하는 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
|
|
22
21
|
CONTAINER_TAG_OPTION = "@okstra_container_run"
|
|
23
22
|
|
|
24
23
|
|
|
@@ -225,8 +225,8 @@ class _AbnormalExit:
|
|
|
225
225
|
their default disposition ends the process outright, and nothing in this
|
|
226
226
|
file runs (measured — a bash ``trap … EXIT`` does fire on SIGTERM, which is
|
|
227
227
|
why the shell wrappers needed no equivalent of this class). Those two are
|
|
228
|
-
the common abnormal exits: a pane
|
|
229
|
-
teardown. Without this the sidecar stays at ``started`` and
|
|
228
|
+
the common abnormal exits: a pane close, ``okstra team reclaim`` /
|
|
229
|
+
``okstra team teardown``, session teardown. Without this the sidecar stays at ``started`` and
|
|
230
230
|
``worker_liveness`` reads a dead worker as a working one.
|
|
231
231
|
|
|
232
232
|
SIGKILL and a host crash remain uncovered because nothing can cover them. A
|
|
@@ -175,7 +175,15 @@ def planned_paths_from_run_manifest(
|
|
|
175
175
|
active_value = manifest.get("activeRunContextPath")
|
|
176
176
|
if not isinstance(active_value, str) or not active_value:
|
|
177
177
|
raise WritePolicyError("implementer write policy has no active run context")
|
|
178
|
-
|
|
178
|
+
# `compact_active_run_context` drops `sourceArtifacts` and stores path
|
|
179
|
+
# hints instead; the hydrator is what puts the block back. Reading the file
|
|
180
|
+
# raw always saw `None` here and reported the run as having no approved
|
|
181
|
+
# stage authority, which blocked every implementer dispatch.
|
|
182
|
+
from .path_hints import hydrate_active_run_context
|
|
183
|
+
|
|
184
|
+
active = hydrate_active_run_context(
|
|
185
|
+
_read_json(_rooted(project_root, active_value), "active run context")
|
|
186
|
+
)
|
|
179
187
|
source = active.get("sourceArtifacts")
|
|
180
188
|
run_inputs_value = source.get("runInputsPath") if isinstance(source, Mapping) else None
|
|
181
189
|
run = active.get("run")
|
|
@@ -239,9 +239,9 @@ okstra config set pr-template-path "<value>" --scope global
|
|
|
239
239
|
|
|
240
240
|
If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
|
|
241
241
|
|
|
242
|
-
Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round —
|
|
242
|
+
Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — close the panes of the dispatches that finished in the prior round so they do not accumulate, in two passes. First count: `okstra team reclaim --project-root <projectRoot> --run-manifest <RUN_MANIFEST_PATH> --dry-run` closes nothing and prints one `<paneId>\t<kind>` line per pane it would close — count those lines as `<n>`. Then run the same command **without** `--dry-run` to close them, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. The command reads each dispatch's recorded status, so an in-progress worker keeps its pane whichever moment you call it. It closes only the panes okstra opened and recorded — a pane the harness opened for its own teammate carries no recorded id and is not okstra's to close. `shutdown_request` alone only idles the agent and frees no pane, so it stays part of the run-end sequence for roster/token hygiene. A `cli-wrapper` run holds no pane at all, so `<n>` is `0` — still emit the checkpoint.
|
|
243
243
|
|
|
244
|
-
Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same
|
|
244
|
+
Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same two passes first: `okstra team reclaim … --dry-run` to count the panes, then the same command without `--dry-run` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `team reclaim` call is what closes it. This keeps a user gate from being shown while finished worker panes remain; in-progress dispatches keep their panes.
|
|
245
245
|
|
|
246
246
|
Build the `okstra render-bundle` invocation from `outcome.renderArgv`, passing every token verbatim and in order (including empty strings — they are intentional `use phase default` markers).
|
|
247
247
|
|
|
@@ -359,7 +359,7 @@ Queue = the topologically-sorted stage list from splitting `orchestration.chainS
|
|
|
359
359
|
|
|
360
360
|
1. Call Step 5's `render-bundle` with the same arguments but `--stage N` (the base commit is auto-computed by prepare from the predecessor's done `head_commit`, so do not pass it by hand). Step 5's blocking local conformance waiver offer·concurrent-run detection·git-reconcile gates apply identically to each stage's `render-bundle`.
|
|
361
361
|
2. As in Step 6, become the host-native Okstra lead and run that stage's Phase 1–7 inline. Phase 6's lead post-stage persistence appends that stage's `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` (per the implementation profile directive).
|
|
362
|
-
3. After confirming that `done` row was written,
|
|
362
|
+
3. After confirming that `done` row was written, close the panes of the stage you just finished: run `okstra team teardown --project-root <projectRoot> --run-manifest <that stage's RUN_MANIFEST_PATH>`. That stage's run is over, so this is the run-end command rather than the round-boundary one. Then move to the next stage. A `status:"failed"` row instead of `done` means the stage ended `FAIL` — close the panes the same way, then stop the queue per "Stage ended FAIL" above.
|
|
363
363
|
4. One-line report at each stage start/finish: `stage N/<total> start` / `stage N done → next K`.
|
|
364
364
|
|
|
365
365
|
Once the whole queue is consumed, end the chain and report completion to the user.
|
|
@@ -121,8 +121,7 @@
|
|
|
121
121
|
"seq": runMeta.seq,
|
|
122
122
|
"source-report": runMeta.source_report,
|
|
123
123
|
"source-data": sourceData,
|
|
124
|
-
"source-data-sha256": dataSha256
|
|
125
|
-
"source-md-sha256": markdownSha256
|
|
124
|
+
"source-data-sha256": dataSha256
|
|
126
125
|
} | tojson }}</script>
|
|
127
126
|
<script>{{ js | safe }}</script>
|
|
128
127
|
</body>
|
|
@@ -2,20 +2,26 @@
|
|
|
2
2
|
"""Validate the self-contained HTML view produced by Phase 7 step 1.5
|
|
3
3
|
(``scripts/okstra-render-report-views.py``).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
5.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
Takes the report's Markdown path as its handle and branches on schema.
|
|
6
|
+
|
|
7
|
+
A schema-v2 report is checked against its ``.data.json`` — the record the
|
|
8
|
+
HTML was rendered from — and never against the Markdown sibling, which is a
|
|
9
|
+
second rendering of that same record:
|
|
10
|
+
1. the ``*.html`` sibling exists;
|
|
11
|
+
2. the document carries the task type's own template;
|
|
12
|
+
3. every human field the task type requires is present, and no audit field
|
|
13
|
+
leaked into the human main;
|
|
14
|
+
4. each visualization's svg node ids match its fallback ids;
|
|
15
|
+
5. the Response IDs in the HTML match ``clarificationItems[]`` 1:1;
|
|
16
|
+
6. no external URL appears in ``<script src=>`` / ``<link href=>`` /
|
|
17
|
+
``<img src=>`` — the self-contained guarantee.
|
|
18
|
+
|
|
19
|
+
A schema-v1 report keeps the Markdown as its source, so its checks read the
|
|
20
|
+
§1 Clarification Items table (fail-closed when the heading is present but the
|
|
21
|
+
table will not parse), require the HTML sibling only when the report carries
|
|
22
|
+
clarification rows or an analysis-review / plan-approval contract, forbid form
|
|
23
|
+
controls in the §5.6 / §5.7 / §5.8 deliverable regions, and enforce the same
|
|
24
|
+
Response-ID parity and self-contained rules.
|
|
19
25
|
|
|
20
26
|
Exit codes: 0 on success, 1 on any failure. Failures are printed one
|
|
21
27
|
per line to stderr.
|
|
@@ -179,14 +185,21 @@ def _validate_analysis_review_html(
|
|
|
179
185
|
|
|
180
186
|
|
|
181
187
|
def validate(report_path: Path) -> list[str]:
|
|
188
|
+
"""Validate the html view of the report named by *report_path*.
|
|
189
|
+
|
|
190
|
+
A schema-v2 report is checked against its data.json and the rendered html;
|
|
191
|
+
the AI-handoff markdown is a sibling rendering of the same record and is
|
|
192
|
+
never read here, so it does not have to exist. Schema-v1 reports keep the
|
|
193
|
+
markdown as their source and still require it.
|
|
194
|
+
"""
|
|
182
195
|
failures: list[str] = []
|
|
196
|
+
v2 = _load_v2_data(report_path)
|
|
197
|
+
if v2 is not None:
|
|
198
|
+
return _validate_v2(report_path, v2[0], v2[1])
|
|
183
199
|
if not report_path.is_file():
|
|
184
200
|
return [f"final-report not found: {report_path}"]
|
|
185
201
|
|
|
186
202
|
md = report_path.read_text(encoding="utf-8")
|
|
187
|
-
v2 = _load_v2_data(report_path)
|
|
188
|
-
if v2 is not None:
|
|
189
|
-
return _validate_v2(report_path, v2[0], v2[1])
|
|
190
203
|
html_path = html_view_path(report_path)
|
|
191
204
|
# §1 헤딩이 있는데 파싱이 실패하면 md_ids 가 빈 []이 되어 "clarification 없음
|
|
192
205
|
# → skip" 으로 흘러 HTML form parity 게이트가 조용히 열린다. fail-closed.
|