okstra 0.177.0 → 0.178.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/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 +2 -2
- package/runtime/prompts/lead/report-writer.md +5 -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/report_views.py +35 -0
- 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/user_response.py +20 -4
- 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 +24 -1
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/templates/reports/final-report-v2.template.md +2 -1
- 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 +256 -78
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/runtime/bin/okstra-trace-cleanup.sh +0 -185
|
@@ -1123,6 +1123,19 @@ _ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
|
1123
1123
|
})
|
|
1124
1124
|
|
|
1125
1125
|
PLAN_DECISION_APPROVED = "approved"
|
|
1126
|
+
@dataclass(frozen=True)
|
|
1127
|
+
class UserReportAuthoring:
|
|
1128
|
+
"""사용자가 리드의 최종 리포트 직접 저작을 허가했는지에 대한 답.
|
|
1129
|
+
|
|
1130
|
+
승인이어도 ``reason`` 이 필수다 — 리포트 헤더가 이 사유를 그대로 실어
|
|
1131
|
+
나중에 읽는 사람이 이 run 이 왜 report-writer 경로를 벗어났는지 본다."""
|
|
1132
|
+
status: str
|
|
1133
|
+
reason: str = ""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
REPORT_AUTHORING_APPROVED = "approved"
|
|
1137
|
+
_REPORT_AUTHORING_STATUSES = frozenset({REPORT_AUTHORING_APPROVED, "denied"})
|
|
1138
|
+
|
|
1126
1139
|
_PLAN_DECISION_STATUSES = frozenset({
|
|
1127
1140
|
PLAN_DECISION_APPROVED,
|
|
1128
1141
|
"revision-requested",
|
|
@@ -1208,6 +1221,25 @@ def _serialize_plan_decision(decision: UserPlanDecision) -> str:
|
|
|
1208
1221
|
return chunk
|
|
1209
1222
|
|
|
1210
1223
|
|
|
1224
|
+
def _serialize_report_authoring(decision: UserReportAuthoring) -> str:
|
|
1225
|
+
"""The user's answer on letting the lead author the final report itself.
|
|
1226
|
+
|
|
1227
|
+
Same shape as PLAN DECISION because it is the same kind of fact: a decision
|
|
1228
|
+
only the user may make, written into the sidecar the user owns. A reason is
|
|
1229
|
+
required even on approval — the report carries it forward, so a later reader
|
|
1230
|
+
sees why this run left the report-writer path.
|
|
1231
|
+
"""
|
|
1232
|
+
if decision.status not in _REPORT_AUTHORING_STATUSES:
|
|
1233
|
+
raise ValueError(f"invalid REPORT AUTHORING status: {decision.status}")
|
|
1234
|
+
if not decision.reason.strip():
|
|
1235
|
+
raise ValueError("REPORT AUTHORING requires a Reason")
|
|
1236
|
+
return (
|
|
1237
|
+
"\n## REPORT AUTHORING\n"
|
|
1238
|
+
f"- Status: {decision.status}\n"
|
|
1239
|
+
f"{_quoted_sidecar_field('Reason', decision.reason)}"
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1211
1243
|
def _serialize_direction_selection(selection: UserDirectionSelection) -> str:
|
|
1212
1244
|
option_id, option_name = normalize_direction_selection_identity(
|
|
1213
1245
|
selection.option_id, selection.option_name
|
|
@@ -1233,6 +1265,7 @@ def serialize_user_response(
|
|
|
1233
1265
|
plan_decision: UserPlanDecision | None = None,
|
|
1234
1266
|
analysis_review: UserResponseAnalysisReview | None = None,
|
|
1235
1267
|
direction_selection: UserDirectionSelection | None = None,
|
|
1268
|
+
report_authoring: UserReportAuthoring | None = None,
|
|
1236
1269
|
) -> str:
|
|
1237
1270
|
"""Return the canonical markdown text the HTML 'Export user
|
|
1238
1271
|
response' button must produce. Used by validators to confirm that
|
|
@@ -1285,6 +1318,8 @@ def serialize_user_response(
|
|
|
1285
1318
|
body_chunks.append(_serialize_analysis_review(analysis_review))
|
|
1286
1319
|
if direction_selection is not None:
|
|
1287
1320
|
body_chunks.append(_serialize_direction_selection(direction_selection))
|
|
1321
|
+
if report_authoring is not None:
|
|
1322
|
+
body_chunks.append(_serialize_report_authoring(report_authoring))
|
|
1288
1323
|
return head + "".join(body_chunks)
|
|
1289
1324
|
|
|
1290
1325
|
|
|
@@ -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
|
|
|
@@ -21,7 +21,8 @@ from typing import Optional
|
|
|
21
21
|
from okstra_ctl.report_views import (
|
|
22
22
|
PLAN_DECISION_APPROVED,
|
|
23
23
|
normalize_direction_selection_identity,
|
|
24
|
-
serialize_user_response, UserResponseEntry, UserPlanDecision,
|
|
24
|
+
serialize_user_response, UserResponseEntry, UserPlanDecision,
|
|
25
|
+
UserReportAuthoring, infer_run_meta,
|
|
25
26
|
parse_expected_form_options,
|
|
26
27
|
)
|
|
27
28
|
from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
|
|
@@ -669,7 +670,8 @@ def show_open_rows(report_path: Path) -> dict:
|
|
|
669
670
|
|
|
670
671
|
def write_sidecar(report_path: Path, answers: list[dict],
|
|
671
672
|
plan_decision: Optional[dict], created_at: str,
|
|
672
|
-
task_key: str = ""
|
|
673
|
+
task_key: str = "",
|
|
674
|
+
report_authoring: Optional[dict] = None) -> Path:
|
|
673
675
|
run_meta = infer_run_meta(report_path, task_key=task_key or None)
|
|
674
676
|
out_dir = user_responses_dir_for_report(report_path)
|
|
675
677
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -690,9 +692,15 @@ def write_sidecar(report_path: Path, answers: list[dict],
|
|
|
690
692
|
status=plan_decision["status"],
|
|
691
693
|
implementation_option=plan_decision.get("implementationOption", ""),
|
|
692
694
|
reason=plan_decision.get("reason", ""))
|
|
695
|
+
authoring = None
|
|
696
|
+
if report_authoring and report_authoring.get("status"):
|
|
697
|
+
authoring = UserReportAuthoring(
|
|
698
|
+
status=report_authoring["status"],
|
|
699
|
+
reason=report_authoring.get("reason", ""))
|
|
693
700
|
sidecar.write_text(
|
|
694
701
|
serialize_user_response(run_meta=run_meta, entries=list(merged.values()),
|
|
695
|
-
created_at=created_at, plan_decision=decision
|
|
702
|
+
created_at=created_at, plan_decision=decision,
|
|
703
|
+
report_authoring=authoring),
|
|
696
704
|
encoding="utf-8")
|
|
697
705
|
return sidecar
|
|
698
706
|
|
|
@@ -715,6 +723,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
715
723
|
pw.add_argument(
|
|
716
724
|
"--plan-decision", default="",
|
|
717
725
|
help='JSON plan decision, e.g. {"status":"rejected","reason":"..."}')
|
|
726
|
+
pw.add_argument(
|
|
727
|
+
"--report-authoring", default="",
|
|
728
|
+
help='JSON report-authoring permission, e.g. '
|
|
729
|
+
'{"status":"approved","reason":"report-writer failed twice"}. '
|
|
730
|
+
'Only the user may grant this; the lead cannot write it for itself.')
|
|
718
731
|
pw.add_argument("--task-key", default="", help="task-key from list/show context")
|
|
719
732
|
|
|
720
733
|
ns = parser.parse_args(argv)
|
|
@@ -728,9 +741,12 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
728
741
|
if ns.cmd == "write":
|
|
729
742
|
answers = json.loads(ns.answers)
|
|
730
743
|
decision = json.loads(ns.plan_decision) if ns.plan_decision else None
|
|
744
|
+
authoring = (
|
|
745
|
+
json.loads(ns.report_authoring) if ns.report_authoring else None
|
|
746
|
+
)
|
|
731
747
|
created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
732
748
|
p = write_sidecar(Path(ns.report), answers, decision, created_at,
|
|
733
|
-
task_key=ns.task_key)
|
|
749
|
+
task_key=ns.task_key, report_authoring=authoring)
|
|
734
750
|
json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
|
|
735
751
|
return 0
|
|
736
752
|
return 1
|
|
@@ -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")
|
|
@@ -191,6 +191,27 @@
|
|
|
191
191
|
],
|
|
192
192
|
"description": "Lead-authored fallback is only valid for release-handoff or recorded report-writer dispatch failure."
|
|
193
193
|
},
|
|
194
|
+
"leadAuthoredFallback": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"description": "Why this run took the lead-authored fallback and who permitted it. Present exactly when reportAuthor is `Okstra lead` on a task type other than release-handoff. The approval passes the gate but does not retire it: this block is what a later reader sees, so the run does not read as a normal report-writer run once the sidecar is out of view.",
|
|
197
|
+
"required": [
|
|
198
|
+
"dispatchFailureReason",
|
|
199
|
+
"approvalSidecar"
|
|
200
|
+
],
|
|
201
|
+
"additionalProperties": false,
|
|
202
|
+
"properties": {
|
|
203
|
+
"dispatchFailureReason": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"minLength": 1,
|
|
206
|
+
"description": "The reason recorded on the failed report-writer dispatch row, verbatim."
|
|
207
|
+
},
|
|
208
|
+
"approvalSidecar": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"minLength": 1,
|
|
211
|
+
"description": "Project-relative path of the user-responses sidecar carrying the approving `## REPORT AUTHORING` block."
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
194
215
|
"leadModel": {
|
|
195
216
|
"type": "string",
|
|
196
217
|
"minLength": 1
|
|
@@ -6999,7 +7020,9 @@
|
|
|
6999
7020
|
"enum": [
|
|
7000
7021
|
"Claude Code",
|
|
7001
7022
|
"Codex",
|
|
7002
|
-
"Antigravity"
|
|
7023
|
+
"Antigravity",
|
|
7024
|
+
"Grok",
|
|
7025
|
+
"Kimi"
|
|
7003
7026
|
]
|
|
7004
7027
|
},
|
|
7005
7028
|
"role": {
|
|
@@ -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.
|
|
@@ -34,7 +34,8 @@ schema-version: {{ schemaVersion | yaml_scalar }}
|
|
|
34
34
|
- Task Type: `{{ header.taskType }}`
|
|
35
35
|
- Report Owner: `{{ header.reportOwner }}`
|
|
36
36
|
- Report Author: `{{ header.reportAuthor }}`
|
|
37
|
-
- Lead
|
|
37
|
+
{% if header.leadAuthoredFallback %}- Lead-authored fallback: report-writer dispatch failed — `{{ header.leadAuthoredFallback.dispatchFailureReason }}`; permitted by the user in `{{ header.leadAuthoredFallback.approvalSidecar }}`
|
|
38
|
+
{% endif %}- Lead Model: `{{ header.leadModel | model_detail }}`
|
|
38
39
|
- Okstra Version: `{{ header.okstraVersion }}`
|
|
39
40
|
|
|
40
41
|
## AI Handoff Summary
|
|
@@ -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>
|