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
|
@@ -44,12 +44,34 @@ ANTIGRAVITY = {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _catalog_model_id(observed: str) -> str:
|
|
48
|
+
"""Undo the dispatch-time tier suffix so the id is catalog-comparable.
|
|
49
|
+
|
|
50
|
+
Dispatch sends `gemini-3.1-pro-low`; agy serves that identity back
|
|
51
|
+
verbatim. The catalog holds the bare `gemini-3.1-pro`, so reading the
|
|
52
|
+
observation as-is made the served-model gate reject a model okstra itself
|
|
53
|
+
had asked for. Only suffixes dispatch can append are stripped, and only
|
|
54
|
+
when what remains is a catalog entry — anything else passes through for
|
|
55
|
+
the gate to judge.
|
|
56
|
+
"""
|
|
57
|
+
if observed in ANTIGRAVITY:
|
|
58
|
+
return observed # identity already carries its tier (agy's Claude models)
|
|
59
|
+
for suffix in model_discovery.dispatch_tier_suffixes():
|
|
60
|
+
bare = observed.removesuffix(f"-{suffix}")
|
|
61
|
+
if bare != observed and bare in ANTIGRAVITY:
|
|
62
|
+
return bare
|
|
63
|
+
return observed
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
def normalise_served_model(raw_model: str | None) -> ServedModelAttestation:
|
|
48
67
|
if not raw_model or not raw_model.strip():
|
|
49
68
|
return ServedModelAttestation.unknown()
|
|
50
69
|
model_id = raw_model.strip().lower().removeprefix("antigravity/")
|
|
51
70
|
return ServedModelAttestation(
|
|
52
|
-
raw_model,
|
|
71
|
+
raw_model,
|
|
72
|
+
f"antigravity/{_catalog_model_id(model_id)}",
|
|
73
|
+
"exact",
|
|
74
|
+
"provider-output",
|
|
53
75
|
)
|
|
54
76
|
|
|
55
77
|
|
|
@@ -1209,6 +1209,23 @@ def _render_prompt(
|
|
|
1209
1209
|
f"**Runner:** {assignment.runner}",
|
|
1210
1210
|
f"**Host runtime:** {assignment.host_runtime}",
|
|
1211
1211
|
]
|
|
1212
|
+
# The dispatch prompt contract requires exactly one of these on every prompt
|
|
1213
|
+
# it checks, and a critic pass is checked under the same contract as the
|
|
1214
|
+
# initial analysis it audits — but only the initial-prompt path emitted one,
|
|
1215
|
+
# so a dynamically materialized critic was refused. A dynamic prompt carries
|
|
1216
|
+
# its instruction inline, which is what `eager-include` states; the initial
|
|
1217
|
+
# path states the host's `initialPromptDeliveryMode` instead and puts it in
|
|
1218
|
+
# `anchor_lines`, so defer to it when it is already there.
|
|
1219
|
+
delivery_mode = "**Prompt Delivery Mode:**"
|
|
1220
|
+
already_declared = any(
|
|
1221
|
+
line.strip().startswith(delivery_mode)
|
|
1222
|
+
for line in (
|
|
1223
|
+
*request.instruction.anchor_lines,
|
|
1224
|
+
*request.instruction.body.splitlines(),
|
|
1225
|
+
)
|
|
1226
|
+
)
|
|
1227
|
+
if not already_declared:
|
|
1228
|
+
header.append(f"{delivery_mode} eager-include")
|
|
1212
1229
|
if assignment.host_model_value is not None:
|
|
1213
1230
|
header.append(f"**Host model value:** {assignment.host_model_value}")
|
|
1214
1231
|
duty_body = f"{common.body.rstrip()}\n\n{duty.body.rstrip()}"
|
|
@@ -304,6 +304,19 @@ def _materialize_run(
|
|
|
304
304
|
"result",
|
|
305
305
|
must_exist=False,
|
|
306
306
|
)
|
|
307
|
+
if args.audience == "report-writer" and not args.audit_source:
|
|
308
|
+
# The report writer is the one audience whose result path is not its own
|
|
309
|
+
# worker result: it writes the report `data.json`, while the audit
|
|
310
|
+
# sidecar is derived from its `.md`. With both collapsed into one value
|
|
311
|
+
# the prompt loses its `**Worker Result Path:**` anchor and the writer
|
|
312
|
+
# puts the report where the audit file belongs — silently, because every
|
|
313
|
+
# header is still present and well-formed. The roster path derives both
|
|
314
|
+
# from the manifest; a dynamic call has to name them.
|
|
315
|
+
raise AgentPromptCliError(
|
|
316
|
+
"report-writer materialization requires --audit-source: --result is "
|
|
317
|
+
"the report data.json, --audit-source the worker-result .md the "
|
|
318
|
+
"audit sidecar is derived from"
|
|
319
|
+
)
|
|
307
320
|
audit_source_path = (
|
|
308
321
|
_authorized_path(
|
|
309
322
|
project_root,
|
|
@@ -65,6 +65,7 @@ from .domain.worker_runtime import (
|
|
|
65
65
|
from .ports.worker_runtime import WorkerRuntimePort
|
|
66
66
|
from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
|
|
67
67
|
from .execution_manifest import (
|
|
68
|
+
ExecutionManifestError,
|
|
68
69
|
finish_attempt_mutation,
|
|
69
70
|
read_execution_manifest,
|
|
70
71
|
record_invocation_attempt,
|
|
@@ -208,11 +209,23 @@ def verify_served_model(
|
|
|
208
209
|
return attestation
|
|
209
210
|
if role_execution.model_ref is None or not attestation.normalized_model_ref:
|
|
210
211
|
raise DispatchError("served model differs from selected model")
|
|
212
|
+
# An unregistered ref and a genuine substitution are different failures.
|
|
213
|
+
# Folding both into "differs from selected" hid which one happened, and a
|
|
214
|
+
# catalog gap reads as a provider swapping the model out from under us.
|
|
211
215
|
try:
|
|
212
216
|
selected = pool.resolve(role_execution.model_ref)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise DispatchError(
|
|
219
|
+
f"selected model is not in the catalog: {role_execution.model_ref}"
|
|
220
|
+
) from exc
|
|
221
|
+
try:
|
|
213
222
|
observed = pool.resolve(attestation.normalized_model_ref)
|
|
214
223
|
except ValueError as exc:
|
|
215
|
-
raise DispatchError(
|
|
224
|
+
raise DispatchError(
|
|
225
|
+
"served model is not in the catalog: "
|
|
226
|
+
f"{attestation.normalized_model_ref} "
|
|
227
|
+
f"(provider reported {attestation.observed_model!r})"
|
|
228
|
+
) from exc
|
|
216
229
|
expected_level = "channel" if observed.version_kind == "channel" else "exact"
|
|
217
230
|
if attestation.level != expected_level:
|
|
218
231
|
raise DispatchError("served model attestation level is inconsistent")
|
|
@@ -1302,22 +1315,32 @@ def _spawn_job(
|
|
|
1302
1315
|
batch_artifact_paths: Sequence[Path] = (),
|
|
1303
1316
|
) -> WorkerHandle:
|
|
1304
1317
|
job = _prepare_job_attempt(plan, job, attempt)
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
(
|
|
1319
|
-
|
|
1320
|
-
|
|
1318
|
+
# Everything between recording the attempt and starting the worker runs
|
|
1319
|
+
# before any worker process exists. A failure here used to leave the attempt
|
|
1320
|
+
# `started` forever: the manifest then refused attempt 1 again ("next
|
|
1321
|
+
# attempt must be 2") and refused attempt 2 as well, because the prompt
|
|
1322
|
+
# metadata still said attempt 1. The invocation had no way forward and the
|
|
1323
|
+
# lead had to mint a new invocation id and prompt path to escape.
|
|
1324
|
+
try:
|
|
1325
|
+
contract = _persisted_write_contract(plan, job)
|
|
1326
|
+
policies = (contract[0],) if contract else ()
|
|
1327
|
+
snapshot = None
|
|
1328
|
+
if contract is not None and contract[1].mutation_audit == "batch":
|
|
1329
|
+
snapshot_path = _mutation_snapshot_path(job)
|
|
1330
|
+
if snapshot_path.is_file():
|
|
1331
|
+
snapshot = MutationSnapshot.from_payload(
|
|
1332
|
+
_load_json_object(snapshot_path, "mutation audit snapshot")
|
|
1333
|
+
)
|
|
1334
|
+
else:
|
|
1335
|
+
snapshot = _mutation_snapshot(
|
|
1336
|
+
plan,
|
|
1337
|
+
policies,
|
|
1338
|
+
(job,),
|
|
1339
|
+
round_artifact_paths=batch_artifact_paths,
|
|
1340
|
+
)
|
|
1341
|
+
except Exception:
|
|
1342
|
+
_abandon_unstarted_attempt(plan, job, attempt)
|
|
1343
|
+
raise
|
|
1321
1344
|
handle = replace(
|
|
1322
1345
|
_start_job(plan, job),
|
|
1323
1346
|
mutation_snapshot=snapshot,
|
|
@@ -1422,6 +1445,7 @@ def _mutation_snapshot(
|
|
|
1422
1445
|
policies,
|
|
1423
1446
|
orchestrator_paths=(
|
|
1424
1447
|
*round_artifact_paths,
|
|
1448
|
+
*_run_errors_log_path(plan),
|
|
1425
1449
|
plan.manifest_path,
|
|
1426
1450
|
plan.team_state_path,
|
|
1427
1451
|
Path(f"{plan.team_state_path}.lock"),
|
|
@@ -1523,6 +1547,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
|
|
|
1523
1547
|
Path(audit_sidecar_rel(str(job.worker_result_path))),
|
|
1524
1548
|
status_path_for_prompt(job.prompt_path),
|
|
1525
1549
|
log_path_for_prompt(job.prompt_path),
|
|
1550
|
+
# The prompt's three derived files are written together and belong in
|
|
1551
|
+
# one list. The audit snapshot used to be listed only for the job whose
|
|
1552
|
+
# snapshot it was, so a sibling's snapshot — written by okstra as that
|
|
1553
|
+
# sibling started — landed inside this worker's window as an
|
|
1554
|
+
# unauthorized artifact-root change.
|
|
1555
|
+
_mutation_snapshot_path(job),
|
|
1526
1556
|
}
|
|
1527
1557
|
error_logs = active_context.get("errorLogs")
|
|
1528
1558
|
if isinstance(error_logs, Mapping):
|
|
@@ -1534,6 +1564,26 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
|
|
|
1534
1564
|
return tuple(sorted(paths, key=str))
|
|
1535
1565
|
|
|
1536
1566
|
|
|
1567
|
+
def _run_errors_log_path(plan: DispatchPlan) -> tuple[Path, ...]:
|
|
1568
|
+
"""The run-level errors log, which the lead appends to while workers run.
|
|
1569
|
+
|
|
1570
|
+
The lead contract requires it to record an observed worker failure as soon
|
|
1571
|
+
as it happens, so a worker still running at that moment sees the write. It
|
|
1572
|
+
is a lead-owned run artifact like the manifest and the lead events log, and
|
|
1573
|
+
is listed for the same reason.
|
|
1574
|
+
"""
|
|
1575
|
+
active_context = _load_optional_json(
|
|
1576
|
+
plan.project_root, plan.manifest.get("activeRunContextPath")
|
|
1577
|
+
)
|
|
1578
|
+
error_logs = active_context.get("errorLogs")
|
|
1579
|
+
if not isinstance(error_logs, Mapping):
|
|
1580
|
+
return ()
|
|
1581
|
+
value = _string_value(error_logs.get("runErrorsLogPath"))
|
|
1582
|
+
if not value:
|
|
1583
|
+
return ()
|
|
1584
|
+
return (_resolve_project_path(plan.project_root, value),)
|
|
1585
|
+
|
|
1586
|
+
|
|
1537
1587
|
|
|
1538
1588
|
|
|
1539
1589
|
def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
|
|
@@ -1884,6 +1934,35 @@ def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
|
|
|
1884
1934
|
)
|
|
1885
1935
|
|
|
1886
1936
|
|
|
1937
|
+
def _abandon_unstarted_attempt(
|
|
1938
|
+
plan: DispatchPlan, job: WorkerJob, attempt: int
|
|
1939
|
+
) -> None:
|
|
1940
|
+
"""Close an attempt whose worker never started, so a retry can follow it.
|
|
1941
|
+
|
|
1942
|
+
`failed-no-mutation` is the truthful status: the dispatch died before the
|
|
1943
|
+
worker process existed, so nothing wrote anything. It is also the only
|
|
1944
|
+
terminal status the manifest lets another attempt follow.
|
|
1945
|
+
"""
|
|
1946
|
+
if not job.has_execution_identity:
|
|
1947
|
+
return
|
|
1948
|
+
try:
|
|
1949
|
+
finish_attempt_mutation(
|
|
1950
|
+
plan.manifest_path,
|
|
1951
|
+
invocation_ref=job.invocation_ref,
|
|
1952
|
+
attempt=attempt,
|
|
1953
|
+
finished_at=_utc_now(),
|
|
1954
|
+
status="failed-no-mutation",
|
|
1955
|
+
result_path=None,
|
|
1956
|
+
error_path=None,
|
|
1957
|
+
change_summary={},
|
|
1958
|
+
task_key=_require_string(plan.manifest, "taskKey"),
|
|
1959
|
+
)
|
|
1960
|
+
except (ExecutionManifestError, DispatchError, OSError):
|
|
1961
|
+
# The original dispatch failure is what the caller needs to see; a
|
|
1962
|
+
# manifest that cannot be closed here is reported by the next read.
|
|
1963
|
+
return
|
|
1964
|
+
|
|
1965
|
+
|
|
1887
1966
|
def _finish_manifest_attempt(
|
|
1888
1967
|
plan: DispatchPlan,
|
|
1889
1968
|
job: WorkerJob,
|
|
@@ -31,6 +31,8 @@ from typing import Any, Callable, Mapping, Sequence
|
|
|
31
31
|
from . import cmux
|
|
32
32
|
from .agent_invocation import (
|
|
33
33
|
AgentInvocationError,
|
|
34
|
+
AgentModelAssignment,
|
|
35
|
+
InvocationMetadataIdentity,
|
|
34
36
|
agent_model_assignment_from_payload,
|
|
35
37
|
invocation_metadata_identity,
|
|
36
38
|
v2_role_assignment_authority_errors,
|
|
@@ -104,6 +106,14 @@ WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
|
|
|
104
106
|
WORKER_STATUSES = frozenset(
|
|
105
107
|
{"in-progress", "completed", "timeout", "error", "not-run"}
|
|
106
108
|
)
|
|
109
|
+
# Which of those mean the dispatch is still expected to produce something. Two
|
|
110
|
+
# readers key off this split — `team reclaim` closes a finished dispatch's pane
|
|
111
|
+
# and must never touch a live one, and the compact-reminder hook calls a run
|
|
112
|
+
# in-flight when any dispatch is still here. Both restated the terminal four
|
|
113
|
+
# locally before, so a sixth status would have read as finished in one place and
|
|
114
|
+
# as live in the other.
|
|
115
|
+
NON_TERMINAL_WORKER_STATUSES = frozenset({"in-progress"})
|
|
116
|
+
TERMINAL_WORKER_STATUSES = WORKER_STATUSES - NON_TERMINAL_WORKER_STATUSES
|
|
107
117
|
REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
|
|
108
118
|
|
|
109
119
|
|
|
@@ -564,6 +574,9 @@ def record_verified_agent_dispatch(
|
|
|
564
574
|
if (
|
|
565
575
|
enforcement_mode == "host-native-spec-link-gate"
|
|
566
576
|
and assignment.runner != "native-session"
|
|
577
|
+
and not _is_current_session_lead(
|
|
578
|
+
run_manifest_path, execution_identity, assignment
|
|
579
|
+
)
|
|
567
580
|
):
|
|
568
581
|
raise DispatchError(
|
|
569
582
|
"host-native enforcement requires a native-session assignment"
|
|
@@ -718,6 +731,50 @@ def record_verified_agent_dispatch(
|
|
|
718
731
|
return record
|
|
719
732
|
|
|
720
733
|
|
|
734
|
+
def _is_current_session_lead(
|
|
735
|
+
manifest_path: Path,
|
|
736
|
+
execution_identity: InvocationMetadataIdentity | None,
|
|
737
|
+
assignment: AgentModelAssignment,
|
|
738
|
+
) -> bool:
|
|
739
|
+
"""Report whether this dispatch is the lead attesting its own session.
|
|
740
|
+
|
|
741
|
+
A current-session lead has no model binding, so the run manifest projects
|
|
742
|
+
its assignment as ``cli-wrapper`` while the execution manifest records the
|
|
743
|
+
participant as ``current-session``. It runs through no dispatch boundary at
|
|
744
|
+
all: the spec link is the lead associating the already-running session with
|
|
745
|
+
its verified invocation specification, which is what the host-native gate
|
|
746
|
+
records. Keyed on the execution manifest's own participant row rather than
|
|
747
|
+
on the projected runner string, so a genuine cli-wrapper worker never
|
|
748
|
+
reaches the native gate.
|
|
749
|
+
"""
|
|
750
|
+
if execution_identity is None or assignment.runner == "native-session":
|
|
751
|
+
return False
|
|
752
|
+
manifest = read_execution_manifest(manifest_path)
|
|
753
|
+
if manifest.legacy:
|
|
754
|
+
return False
|
|
755
|
+
participant = next(
|
|
756
|
+
(
|
|
757
|
+
row for row in manifest.participant_assignments
|
|
758
|
+
if row.participant_ref == execution_identity.participant_ref
|
|
759
|
+
),
|
|
760
|
+
None,
|
|
761
|
+
)
|
|
762
|
+
execution = next(
|
|
763
|
+
(
|
|
764
|
+
row for row in manifest.role_executions
|
|
765
|
+
if row.role_execution_ref == execution_identity.role_execution_ref
|
|
766
|
+
),
|
|
767
|
+
None,
|
|
768
|
+
)
|
|
769
|
+
return (
|
|
770
|
+
participant is not None
|
|
771
|
+
and execution is not None
|
|
772
|
+
and execution.role == "leader"
|
|
773
|
+
and participant.runner == "current-session"
|
|
774
|
+
and participant.entry_mode == "current-session"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
721
778
|
def _agent_write_contract(
|
|
722
779
|
project_root: Path,
|
|
723
780
|
manifest_path: Path,
|
|
@@ -158,8 +158,17 @@ def _catalog_row(model, adapter, role: str | None) -> dict[str, Any]:
|
|
|
158
158
|
)
|
|
159
159
|
)
|
|
160
160
|
except HostModelBindingError as exc:
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
# Not "unusable as a lead": the host cannot bind this model to a native
|
|
162
|
+
# session, so the lead runs through the provider CLI instead. Reporting
|
|
163
|
+
# it as unselectable made the listing disagree with what assignment
|
|
164
|
+
# resolution actually does — it accepts the model and falls back to
|
|
165
|
+
# `cli-wrapper` — leaving no way to find out why a lead was not native.
|
|
166
|
+
row["leaderRunner"] = "cli-wrapper"
|
|
167
|
+
row["reason"] = (
|
|
168
|
+
f"native-session unavailable ({exc}); the lead runs via cli-wrapper"
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
row["leaderRunner"] = "native-session"
|
|
163
172
|
return row
|
|
164
173
|
|
|
165
174
|
|
|
@@ -51,6 +51,18 @@ def _dispatch_effort(execution: str, role: str) -> str:
|
|
|
51
51
|
return effort
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def dispatch_tier_suffixes() -> frozenset[str]:
|
|
55
|
+
"""Every tier suffix dispatch can append to an agy execution value.
|
|
56
|
+
|
|
57
|
+
The provider serves back the suffixed identity it was given, so reading
|
|
58
|
+
that identity means undoing exactly this set — not guessing at whatever
|
|
59
|
+
trails the last hyphen. `low` is here because `_dispatch_effort` demotes
|
|
60
|
+
the untrusted high tier to it.
|
|
61
|
+
"""
|
|
62
|
+
efforts = {value.lower() for value in (*ROLE_EFFORT.values(), _DEFAULT_EFFORT)}
|
|
63
|
+
return frozenset(efforts | {"low"})
|
|
64
|
+
|
|
65
|
+
|
|
54
66
|
@lru_cache(maxsize=1)
|
|
55
67
|
def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
|
|
56
68
|
"""Live `agy models` ids, or () when agy is unavailable (non-blocking).
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""진행 중 run 조회 — pane 회수 의무를 어느 run 에 걸지 고르는 데 쓴다.
|
|
2
2
|
|
|
3
3
|
호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
|
|
4
|
-
직후 리드에게 "이 run 의
|
|
4
|
+
직후 리드에게 "이 run 의 끝난 워커 pane 을 라운드 경계마다 회수하라"는 의무를
|
|
5
5
|
재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`
|
|
7
|
+
정본은 run 디렉터리다(ADR-0011). 신호는 그 run 의 가장 최근 team-state 에
|
|
8
|
+
비종결 배치가 남아 있는지다. 이전 구현은 `~/.okstra/active.jsonl` 을 읽었는데,
|
|
9
|
+
그 원장은 `initial_status="running"` 으로 기록된 run 만 담고 in-session 경로는
|
|
10
|
+
`--render-only` 강제로 항상 `prepared` 가 되어 종결로 라우팅된다 — 실측에서 그
|
|
11
|
+
파일은 0행이었고 이 훅은 한 번도 발화하지 않았다. `state/lead-pane.id` 도 신호가
|
|
12
|
+
못 된다: 한 번 기록된 뒤 지워지지 않아 끝난 run 을 영구히 진행 중으로 읽는다.
|
|
10
13
|
"""
|
|
11
14
|
from __future__ import annotations
|
|
12
15
|
|
|
@@ -14,54 +17,57 @@ import json
|
|
|
14
17
|
import sys
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
|
|
17
|
-
from .
|
|
18
|
-
from .reconcile import NON_TERMINAL_RECENT_STATUSES
|
|
20
|
+
from .dispatch_state import NON_TERMINAL_WORKER_STATUSES
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
def
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
continue
|
|
31
|
-
try:
|
|
32
|
-
row = json.loads(line)
|
|
33
|
-
except json.JSONDecodeError:
|
|
34
|
-
continue
|
|
35
|
-
# 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
|
|
36
|
-
# SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
|
|
37
|
-
# 않으므로 자연히 제외된다(allowlist).
|
|
38
|
-
if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
|
|
39
|
-
continue
|
|
40
|
-
run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
|
|
41
|
-
if run_dir is not None:
|
|
42
|
-
yield row.get("projectRoot"), run_dir
|
|
23
|
+
def _newest_team_state(state_dir: Path) -> Path | None:
|
|
24
|
+
"""한 run 디렉터리의 최신 team-state.
|
|
25
|
+
|
|
26
|
+
seq 는 3자리 zero-pad 라 이름 정렬이 곧 순서다. run 하나에 seq 가 누적되고
|
|
27
|
+
이전 라운드의 `in-progress` 행이 그 파일에 남으므로, 최신이 아닌 파일을 읽으면
|
|
28
|
+
몇 시간 전에 끝난 run 을 진행 중으로 보고한다.
|
|
29
|
+
"""
|
|
30
|
+
files = sorted(state_dir.glob("team-state-*.json"))
|
|
31
|
+
return files[-1] if files else None
|
|
43
32
|
|
|
44
33
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
34
|
+
def _has_live_dispatch(team_state_path: Path) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
payload = json.loads(team_state_path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, json.JSONDecodeError):
|
|
38
|
+
return False
|
|
39
|
+
if not isinstance(payload, dict):
|
|
40
|
+
return False
|
|
41
|
+
return any(
|
|
42
|
+
isinstance(record, dict)
|
|
43
|
+
and record.get("status") in NON_TERMINAL_WORKER_STATUSES
|
|
44
|
+
for record in payload.get("workerDispatches", [])
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def in_flight_run_dirs(project_root: Path) -> list[Path]:
|
|
49
|
+
"""비종결 배치를 아직 들고 있는 이 프로젝트의 run 디렉터리 목록.
|
|
50
|
+
|
|
51
|
+
`runs/<task-type>/` 와 stage 격리 run 의 `runs/<task-type>/stage-<N>/` 을 모두
|
|
52
|
+
훑는다. 후자를 빼면 `implementation` 과 단일 stage `final-verification` 이
|
|
53
|
+
통째로 누락된다.
|
|
54
|
+
"""
|
|
55
|
+
tasks_root = Path(project_root) / ".okstra" / "tasks"
|
|
56
|
+
if not tasks_root.is_dir():
|
|
57
|
+
return []
|
|
49
58
|
out: list[Path] = []
|
|
50
|
-
for
|
|
51
|
-
if not
|
|
52
|
-
continue
|
|
53
|
-
try:
|
|
54
|
-
project_root_r = Path(project_root).resolve()
|
|
55
|
-
except OSError:
|
|
59
|
+
for state_dir in sorted(tasks_root.glob("*/*/runs/**/state")):
|
|
60
|
+
if not state_dir.is_dir():
|
|
56
61
|
continue
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
newest = _newest_team_state(state_dir)
|
|
63
|
+
if newest is not None and _has_live_dispatch(newest):
|
|
64
|
+
out.append(state_dir.parent)
|
|
59
65
|
return out
|
|
60
66
|
|
|
61
67
|
|
|
62
68
|
def main(argv: list[str]) -> int:
|
|
63
|
-
if len(argv) ==
|
|
64
|
-
for run_dir in
|
|
69
|
+
if len(argv) == 2 and argv[0] == "--in-flight-run-dirs-for":
|
|
70
|
+
for run_dir in in_flight_run_dirs(Path(argv[1])):
|
|
65
71
|
print(run_dir)
|
|
66
72
|
return 0
|
|
67
73
|
return 1
|
|
@@ -735,6 +735,15 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
|
|
|
735
735
|
catalog = _worker_catalog(ctx)
|
|
736
736
|
worker_dispatch_plan = _worker_dispatch_plan(ctx)
|
|
737
737
|
workers = []
|
|
738
|
+
for row in _optional_worker_roles(ctx):
|
|
739
|
+
workers.append({
|
|
740
|
+
**{key: row[key] for key in (
|
|
741
|
+
"workerId", "role", "agent", "provider", "runner",
|
|
742
|
+
"model", "modelExecutionValue", "resultPath", "promptPath",
|
|
743
|
+
)},
|
|
744
|
+
"status": "not-run",
|
|
745
|
+
"reason": "",
|
|
746
|
+
})
|
|
738
747
|
for w in selected:
|
|
739
748
|
m = catalog[w]
|
|
740
749
|
workers.append(
|
|
@@ -1133,6 +1142,44 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
|
|
|
1133
1142
|
]
|
|
1134
1143
|
|
|
1135
1144
|
|
|
1145
|
+
def _optional_worker_roles(ctx: dict) -> list[dict]:
|
|
1146
|
+
"""Roles this run may dispatch but does not require — today, the critics.
|
|
1147
|
+
|
|
1148
|
+
A critic is opt-in, so it never belonged in `requiredWorkerRoles`. But it
|
|
1149
|
+
was absent from the roster entirely, and both `okstra team dispatch` and
|
|
1150
|
+
the liveness reader locate a worker by its team-state row: dispatching a
|
|
1151
|
+
critic failed with `team-state has no workerId=acceptance`, while adding
|
|
1152
|
+
the row by hand failed validation as an `unexpected worker role`. Declaring
|
|
1153
|
+
it here makes the roster say what the run may run, so both sides agree.
|
|
1154
|
+
|
|
1155
|
+
Keyed off `invocationAssignments`, which is where the run records the
|
|
1156
|
+
critic it actually resolved — an absent `critic/*` entry means no critic.
|
|
1157
|
+
"""
|
|
1158
|
+
assignments = _invocation_assignments(ctx)
|
|
1159
|
+
roles: list[dict] = []
|
|
1160
|
+
for assignment_ref, assignment in sorted(assignments.items()):
|
|
1161
|
+
if not assignment_ref.startswith("critic/"):
|
|
1162
|
+
continue
|
|
1163
|
+
provider = str(assignment.get("provider", ""))
|
|
1164
|
+
roles.append({
|
|
1165
|
+
# `okstra team dispatch` projects a v2 worker's state key off the
|
|
1166
|
+
# assignment ref's last segment; the row it looks up must use it.
|
|
1167
|
+
"workerId": assignment_ref.rsplit("/", 1)[-1],
|
|
1168
|
+
"role": f"{provider_spec(provider).display_label} critic",
|
|
1169
|
+
"agent": provider,
|
|
1170
|
+
"provider": provider,
|
|
1171
|
+
"runner": str(assignment.get("runner", "")),
|
|
1172
|
+
"model": str(assignment.get("model", "")),
|
|
1173
|
+
"modelExecutionValue": str(assignment.get("modelExecutionValue", "")),
|
|
1174
|
+
# The lead names these when it materializes the invocation; a
|
|
1175
|
+
# critic has no prompt or result until it is dispatched.
|
|
1176
|
+
"resultPath": "",
|
|
1177
|
+
"promptPath": "",
|
|
1178
|
+
"attemptRequired": False,
|
|
1179
|
+
})
|
|
1180
|
+
return roles
|
|
1181
|
+
|
|
1182
|
+
|
|
1136
1183
|
def _reporter_confirmation_status(brief_bytes: bytes) -> str:
|
|
1137
1184
|
try:
|
|
1138
1185
|
lines = brief_bytes.decode("utf-8").splitlines()
|
|
@@ -1448,6 +1495,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
|
|
|
1448
1495
|
"minimumPreferredWorkerResults": len(reviewers),
|
|
1449
1496
|
"requiredWorkerAttempts": reviewers,
|
|
1450
1497
|
"requiredWorkerRoles": required_worker_roles,
|
|
1498
|
+
"optionalWorkerRoles": _optional_worker_roles(ctx),
|
|
1451
1499
|
"requiredAgentStatusEntries": required_agent_status_entries,
|
|
1452
1500
|
"requireDistinctLeadFromWorkerSession": True,
|
|
1453
1501
|
"requireAllRequiredWorkerAttempts": True,
|
|
@@ -1644,6 +1692,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1644
1692
|
"taskId": ctx.get("TASK_ID", ""),
|
|
1645
1693
|
"taskKey": ctx.get("TASK_KEY", ""),
|
|
1646
1694
|
"taskType": ctx.get("TASK_TYPE", ""),
|
|
1695
|
+
# Every other path in this manifest is project-relative; this is the
|
|
1696
|
+
# anchor they resolve against. Convergence reads it to place run
|
|
1697
|
+
# artifacts, so a manifest without it cannot seed a round.
|
|
1698
|
+
"projectRoot": ctx.get("PROJECT_ROOT", ""),
|
|
1647
1699
|
"hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
|
|
1648
1700
|
"leadRuntime": _lead_runtime(ctx),
|
|
1649
1701
|
"leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
|
|
@@ -1765,6 +1817,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1765
1817
|
"finalSynthesisOwner": lead_role,
|
|
1766
1818
|
"requiredWorkerAttempts": reviewers,
|
|
1767
1819
|
"requiredWorkerRoles": required_worker_roles,
|
|
1820
|
+
"optionalWorkerRoles": _optional_worker_roles(ctx),
|
|
1768
1821
|
"requiredAgentStatusEntries": [lead_role]
|
|
1769
1822
|
+ [catalog[item]["role"] for item in reviewers],
|
|
1770
1823
|
"requireDistinctLeadFromWorkerSession": True,
|
|
@@ -109,17 +109,21 @@ def _html_path(data_path: Path) -> Path:
|
|
|
109
109
|
|
|
110
110
|
def render_v2_html_view(
|
|
111
111
|
data_path: Path,
|
|
112
|
-
markdown_path: Path,
|
|
113
112
|
*,
|
|
114
113
|
run_meta: HtmlRunMeta,
|
|
115
114
|
templates_root: Path | None = None,
|
|
116
115
|
) -> Path:
|
|
116
|
+
"""Render the human HTML from the data.json alone.
|
|
117
|
+
|
|
118
|
+
The AI-handoff markdown sibling is a second rendering of this same record,
|
|
119
|
+
never an input here: it used to be read for a `source-md-sha256` stamp that
|
|
120
|
+
no reader ever compared, which made a derived artifact a precondition for
|
|
121
|
+
another derived artifact.
|
|
122
|
+
"""
|
|
117
123
|
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
118
124
|
errors = validate(data, load_schema_for_data(data))
|
|
119
125
|
if errors:
|
|
120
126
|
raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
|
|
121
|
-
if not markdown_path.is_file():
|
|
122
|
-
raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
|
|
123
127
|
# Validate the SSOT, then localize — the sidecar carries presentation and
|
|
124
128
|
# has no say in whether the report is well-formed.
|
|
125
129
|
data, lang = _localize(data, data_path)
|
|
@@ -150,7 +154,6 @@ def render_v2_html_view(
|
|
|
150
154
|
"taskType": view.task_type,
|
|
151
155
|
"sourceData": source_data,
|
|
152
156
|
"dataSha256": _sha256(data_path),
|
|
153
|
-
"markdownSha256": _sha256(markdown_path),
|
|
154
157
|
"clarificationItems": data.get("clarificationItems", []),
|
|
155
158
|
# Every task type ends with the same run-cost section, so it is bound
|
|
156
159
|
# here rather than in ten view models that would each rebuild it.
|