okstra 0.187.0 → 0.188.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/lifecycle/install.mjs +3 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +23 -5
- package/runtime/prompts/lead/adapters/cmux.md +3 -3
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
- package/runtime/python/okstra_ctl/agent/activity.py +4 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
- package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
- package/runtime/python/okstra_ctl/context_cost.py +16 -2
- package/runtime/python/okstra_ctl/convergence.py +6 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
- package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
- package/runtime/python/okstra_ctl/implementation_options.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/report_finalize.py +47 -2
- package/runtime/python/okstra_ctl/report_narrative.py +132 -33
- package/runtime/python/okstra_ctl/report_projections.py +3 -1
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
- package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
- package/runtime/python/okstra_ctl/worker_runner.py +42 -8
- package/runtime/python/okstra_token_usage/antigravity.py +50 -12
- package/runtime/python/okstra_token_usage/collect.py +139 -33
- package/runtime/python/okstra_token_usage/pricing.py +6 -3
- package/runtime/python/okstra_token_usage/report.py +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +2 -0
- package/runtime/validators/validate-run.py +6 -1
|
@@ -13,7 +13,7 @@ import os
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
import shutil
|
|
15
15
|
import tempfile
|
|
16
|
-
from typing import get_args
|
|
16
|
+
from typing import Any, Mapping, get_args
|
|
17
17
|
|
|
18
18
|
from ..invocation import (
|
|
19
19
|
AgentInstruction,
|
|
@@ -31,7 +31,13 @@ from ...assignment_environment import load_assignment_context
|
|
|
31
31
|
from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignment
|
|
32
32
|
from ...path_hints import hydrate_active_run_context
|
|
33
33
|
from ...worker_prompt_headers import worker_prompt_headers
|
|
34
|
+
from ...final_report_paths import final_report_data_path
|
|
34
35
|
from ...report_inputs import report_narrative_path, uses_report_contract_v3
|
|
36
|
+
from ...report_synthesis_packet import (
|
|
37
|
+
ReportSynthesisPacketError,
|
|
38
|
+
materialize_report_synthesis_packet,
|
|
39
|
+
)
|
|
40
|
+
from ...worker_prompt_body import report_writer_input_lines
|
|
35
41
|
from ...dispatch_state import detect_terminal_backend
|
|
36
42
|
from .dynamic_verifier import (
|
|
37
43
|
_dynamic_verifier_source,
|
|
@@ -54,6 +60,10 @@ from .run_identity import _validate_run_identity
|
|
|
54
60
|
|
|
55
61
|
|
|
56
62
|
_AUDIENCES = frozenset(get_args(AgentAudience))
|
|
63
|
+
# 결과 파일과 별개의 워커 결과 포인터를 가진 audience. 이들만 `--audit-source` 로
|
|
64
|
+
# 두 번째 경로를 받고, 프롬프트에 `**Worker Result Path:**` 앵커가 생긴다.
|
|
65
|
+
# 나머지 audience 의 결과는 하나뿐이고 감사 사이드카는 그 이름에서 파생된다.
|
|
66
|
+
_POINTER_AUDIENCES = frozenset({"report-writer", "translator"})
|
|
57
67
|
|
|
58
68
|
|
|
59
69
|
def _materialize(args: argparse.Namespace) -> PreparedAgentInvocation:
|
|
@@ -116,35 +126,6 @@ def _materialize_run(
|
|
|
116
126
|
"result",
|
|
117
127
|
must_exist=False,
|
|
118
128
|
)
|
|
119
|
-
if args.audience == "report-writer" and not args.audit_source:
|
|
120
|
-
# The report writer is the one audience whose result path is not its own
|
|
121
|
-
# worker result: it writes the report body, while the audit sidecar is
|
|
122
|
-
# derived from its `.md`. With both collapsed into one value the prompt
|
|
123
|
-
# loses its `**Worker Result Path:**` anchor and the writer puts the
|
|
124
|
-
# report where the audit file belongs — silently, because every header
|
|
125
|
-
# is still present and well-formed. The roster path derives both from
|
|
126
|
-
# the manifest; a dynamic call has to name them.
|
|
127
|
-
#
|
|
128
|
-
# Which artifact `--result` names depends on the report contract, and
|
|
129
|
-
# `dispatch_state.dispatch_result_path` is what decides it. Naming only
|
|
130
|
-
# the v2 answer here sent v3 runs to the data.json, so the narrative
|
|
131
|
-
# `report-finalize` assembles from was never written and the phase
|
|
132
|
-
# failed later, at a place that does not point back here.
|
|
133
|
-
if uses_report_contract_v3(manifest):
|
|
134
|
-
expected = report_narrative_path(project_root, manifest)
|
|
135
|
-
raise AgentPromptCliError(
|
|
136
|
-
"report-writer materialization requires --audit-source: under "
|
|
137
|
-
f"report contract 3.0 --result is the narrative ({expected}), "
|
|
138
|
-
"which `report-finalize` assembles the report data.json from, "
|
|
139
|
-
"and --audit-source the worker-result .md the audit sidecar is "
|
|
140
|
-
"derived from"
|
|
141
|
-
)
|
|
142
|
-
raise AgentPromptCliError(
|
|
143
|
-
"report-writer materialization requires --audit-source: under "
|
|
144
|
-
"report contract 2.0 --result is the report data.json, and "
|
|
145
|
-
"--audit-source the worker-result .md the audit sidecar is "
|
|
146
|
-
"derived from"
|
|
147
|
-
)
|
|
148
129
|
audit_source_path = (
|
|
149
130
|
_authorized_path(
|
|
150
131
|
project_root,
|
|
@@ -154,8 +135,68 @@ def _materialize_run(
|
|
|
154
135
|
must_exist=False,
|
|
155
136
|
)
|
|
156
137
|
if args.audit_source
|
|
157
|
-
else
|
|
138
|
+
else None
|
|
158
139
|
)
|
|
140
|
+
if args.audience not in _POINTER_AUDIENCES:
|
|
141
|
+
# 결과가 하나뿐인 audience 다. 두 번째 경로는 워커(`**Result Path:**` 에
|
|
142
|
+
# 쓴다)와 수집기(jobs-file 의 workerResultPath 를 기다린다)에게 서로 다른
|
|
143
|
+
# 파일을 말하는 것밖에 못 한다. 실측(2026-09-02, fontsninja-v3-site
|
|
144
|
+
# dev-10626-1 error-analysis r04): 리드가 reverify 에 `-worker-` 이름을
|
|
145
|
+
# `--audit-source` 로 따로 넘겨 검증 디스패치 9건 중 8건이 결과를 다
|
|
146
|
+
# 쓰고도 `required worker artifact was not produced` 로 끝났다.
|
|
147
|
+
if (
|
|
148
|
+
audit_source_path is not None
|
|
149
|
+
and _normalized(audit_source_path) != _normalized(result_path)
|
|
150
|
+
):
|
|
151
|
+
raise AgentPromptCliError(
|
|
152
|
+
f"{args.audience} materialization forbids --audit-source: this "
|
|
153
|
+
"audience writes one result and its audit sidecar derives from "
|
|
154
|
+
"that result's name. Name --result with the canonical `-worker-` "
|
|
155
|
+
"token instead (reverify: "
|
|
156
|
+
"<worker-id>-worker-reverify-r<N>-<task-type>-<seq>.md) and put "
|
|
157
|
+
"the same path in the jobs file's workerResultPath"
|
|
158
|
+
)
|
|
159
|
+
audit_source_path = result_path
|
|
160
|
+
elif args.audience == "report-writer":
|
|
161
|
+
if audit_source_path is None:
|
|
162
|
+
# The report writer is the one audience whose result path is not its
|
|
163
|
+
# own worker result: it writes the report body, while the audit
|
|
164
|
+
# sidecar is derived from its `.md`. With both collapsed into one
|
|
165
|
+
# value the prompt loses its `**Worker Result Path:**` anchor and the
|
|
166
|
+
# writer puts the report where the audit file belongs — silently,
|
|
167
|
+
# because every header is still present and well-formed. The roster
|
|
168
|
+
# path derives both from the manifest; a dynamic call has to name
|
|
169
|
+
# them.
|
|
170
|
+
#
|
|
171
|
+
# Which artifact `--result` names depends on the report contract, and
|
|
172
|
+
# `dispatch_state.dispatch_result_path` is what decides it. Naming
|
|
173
|
+
# only the v2 answer here sent v3 runs to the data.json, so the
|
|
174
|
+
# narrative `report-finalize` assembles from was never written and
|
|
175
|
+
# the phase failed later, at a place that does not point back here.
|
|
176
|
+
if uses_report_contract_v3(manifest):
|
|
177
|
+
expected = report_narrative_path(project_root, manifest)
|
|
178
|
+
raise AgentPromptCliError(
|
|
179
|
+
"report-writer materialization requires --audit-source: "
|
|
180
|
+
f"under report contract 3.0 --result is the narrative "
|
|
181
|
+
f"({expected}), which `report-finalize` assembles the report "
|
|
182
|
+
"data.json from, and --audit-source the worker-result .md "
|
|
183
|
+
"the audit sidecar is derived from"
|
|
184
|
+
)
|
|
185
|
+
raise AgentPromptCliError(
|
|
186
|
+
"report-writer materialization requires --audit-source: under "
|
|
187
|
+
"report contract 2.0 --result is the report data.json, and "
|
|
188
|
+
"--audit-source the worker-result .md the audit sidecar is "
|
|
189
|
+
"derived from"
|
|
190
|
+
)
|
|
191
|
+
_validate_report_writer_paths(
|
|
192
|
+
project_root,
|
|
193
|
+
manifest,
|
|
194
|
+
worker_id=args.worker_id,
|
|
195
|
+
result_path=result_path,
|
|
196
|
+
audit_source_path=audit_source_path,
|
|
197
|
+
)
|
|
198
|
+
elif audit_source_path is None:
|
|
199
|
+
audit_source_path = result_path
|
|
159
200
|
_validate_run_identity(
|
|
160
201
|
manifest,
|
|
161
202
|
worker_id=args.worker_id,
|
|
@@ -218,6 +259,17 @@ def _materialize_run(
|
|
|
218
259
|
manifest=manifest,
|
|
219
260
|
active_context=active_context,
|
|
220
261
|
))
|
|
262
|
+
body = instruction_path.read_text(encoding="utf-8")
|
|
263
|
+
if args.audience == "report-writer":
|
|
264
|
+
body = _with_inputs_section(
|
|
265
|
+
body,
|
|
266
|
+
_report_writer_input_lines(
|
|
267
|
+
project_root,
|
|
268
|
+
manifest,
|
|
269
|
+
active_context,
|
|
270
|
+
narrative_path=result_path,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
221
273
|
request = AgentInvocationRequest(
|
|
222
274
|
invocation_id=args.invocation_id,
|
|
223
275
|
worker_id=args.worker_id if identity is None else None,
|
|
@@ -227,7 +279,7 @@ def _materialize_run(
|
|
|
227
279
|
assignment=assignment,
|
|
228
280
|
instruction=AgentInstruction(
|
|
229
281
|
anchor_lines=anchor_lines,
|
|
230
|
-
body=
|
|
282
|
+
body=body,
|
|
231
283
|
source_paths=(AgentInstructionSource(
|
|
232
284
|
kind="project",
|
|
233
285
|
path=_relative(project_root, instruction_path),
|
|
@@ -259,6 +311,151 @@ def _materialize_run(
|
|
|
259
311
|
return prepare_agent_invocation(request)
|
|
260
312
|
|
|
261
313
|
|
|
314
|
+
def _normalized(path: Path) -> Path:
|
|
315
|
+
return Path(os.path.normpath(path))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _validate_report_writer_paths(
|
|
319
|
+
project_root: Path,
|
|
320
|
+
manifest: Mapping[str, Any],
|
|
321
|
+
*,
|
|
322
|
+
worker_id: str,
|
|
323
|
+
result_path: Path,
|
|
324
|
+
audit_source_path: Path,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""report-writer 의 두 경로를 run 이 이미 정한 값에 못 박는다.
|
|
327
|
+
|
|
328
|
+
`--result` 는 조립이 읽는 서술문 경로다: 계약 3.0 이면 매니페스트의
|
|
329
|
+
`reportNarrativePath`, 2.0 이면 `expectedReportRecordPath` 의 data.json.
|
|
330
|
+
다른 경로에 쓴 서술문은 `report-finalize` 가 읽지 않는다. `--audit-source`
|
|
331
|
+
는 명부(team-state `workers[].resultPath`)의 워커 결과다: `okstra team
|
|
332
|
+
await` 는 그 파일이 있어야 명부 행을 completed 로 적는다. 실측(2026-09-02,
|
|
333
|
+
fontsninja-v3-site dev-10626-1 r04): 리드가 재시도마다 `-a2`/`-a3` 접미를
|
|
334
|
+
붙인 경로를 넘겨 서술문 세 벌이 조립 밖에 쌓였고, 결국 손으로 `cp` 했다.
|
|
335
|
+
교정 디스패치는 프롬프트 경로와 invocation ID 만 새로 하고 이 두 경로는
|
|
336
|
+
그대로 쓴다.
|
|
337
|
+
"""
|
|
338
|
+
if uses_report_contract_v3(manifest):
|
|
339
|
+
expected_result = report_narrative_path(project_root, manifest)
|
|
340
|
+
what = "the narrative report-finalize assembles from (run manifest reportNarrativePath)"
|
|
341
|
+
else:
|
|
342
|
+
expected_result = final_report_data_path(_project_manifest_path(
|
|
343
|
+
project_root,
|
|
344
|
+
manifest.get("expectedReportRecordPath"),
|
|
345
|
+
"expected report record",
|
|
346
|
+
must_exist=False,
|
|
347
|
+
))
|
|
348
|
+
what = "the report data.json (run manifest expectedReportRecordPath)"
|
|
349
|
+
if _normalized(result_path) != _normalized(expected_result):
|
|
350
|
+
raise AgentPromptCliError(
|
|
351
|
+
f"report-writer --result must be {what}: expected {expected_result}, "
|
|
352
|
+
f"got {result_path}. A corrective dispatch reuses this path with a "
|
|
353
|
+
"fresh prompt path and invocation ID"
|
|
354
|
+
)
|
|
355
|
+
roster_result = _roster_result_path(project_root, manifest, worker_id)
|
|
356
|
+
if (
|
|
357
|
+
roster_result is not None
|
|
358
|
+
and _normalized(audit_source_path) != _normalized(roster_result)
|
|
359
|
+
):
|
|
360
|
+
raise AgentPromptCliError(
|
|
361
|
+
"report-writer --audit-source must be the roster's worker result "
|
|
362
|
+
f"(team-state workers[].resultPath): expected {roster_result}, got "
|
|
363
|
+
f"{audit_source_path}. `okstra team await` records the roster row "
|
|
364
|
+
"completed only when that file exists"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _load_team_state(project_root: Path, manifest: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
369
|
+
"""매니페스트가 가리키는 team-state. 없으면 빈 매핑."""
|
|
370
|
+
team_state_value = manifest.get("teamStatePath")
|
|
371
|
+
if not isinstance(team_state_value, str) or not team_state_value:
|
|
372
|
+
return {}
|
|
373
|
+
team_state_path = Path(team_state_value)
|
|
374
|
+
if not team_state_path.is_absolute():
|
|
375
|
+
team_state_path = project_root / team_state_path
|
|
376
|
+
if not team_state_path.is_file():
|
|
377
|
+
return {}
|
|
378
|
+
return _read_json_object(team_state_path, "team state")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _report_writer_input_lines(
|
|
382
|
+
project_root: Path,
|
|
383
|
+
manifest: Mapping[str, Any],
|
|
384
|
+
active_context: Mapping[str, Any],
|
|
385
|
+
*,
|
|
386
|
+
narrative_path: Path,
|
|
387
|
+
) -> list[str]:
|
|
388
|
+
"""report-writer 의 `## Inputs` 줄. 로스터 경로(`worker_prompt_body`)와 같은 규칙.
|
|
389
|
+
|
|
390
|
+
계약 3.0 이면 합성 묶음을 여기서 만들고 그 한 줄을 싣는다; 그 전 계약은
|
|
391
|
+
옛 목록이다. 실측(2026-09-02, fontsninja-v3-site dev-10626-1 r04): run
|
|
392
|
+
갈래의 프롬프트에는 이 절이 없어, 작성자가 "읽을 게 없다"며 서술문을
|
|
393
|
+
거부했고 리드가 경로 22개를 손으로 열거하고서야 통과했다. 두 갈래가 같은
|
|
394
|
+
입력 표면을 내야 하는 이유다.
|
|
395
|
+
"""
|
|
396
|
+
team_state = _load_team_state(project_root, manifest)
|
|
397
|
+
if not uses_report_contract_v3(manifest):
|
|
398
|
+
return report_writer_input_lines(manifest, active_context, team_state)
|
|
399
|
+
try:
|
|
400
|
+
_, markdown_path = materialize_report_synthesis_packet(
|
|
401
|
+
project_root=project_root,
|
|
402
|
+
manifest=manifest,
|
|
403
|
+
active_context=active_context,
|
|
404
|
+
team_state=team_state,
|
|
405
|
+
narrative_path=narrative_path,
|
|
406
|
+
)
|
|
407
|
+
except ReportSynthesisPacketError as exc:
|
|
408
|
+
defects = "; ".join(
|
|
409
|
+
f"owner={issue.owner} source={issue.label} path={issue.path} "
|
|
410
|
+
f"reason={issue.reason}"
|
|
411
|
+
for issue in exc.issues
|
|
412
|
+
)
|
|
413
|
+
raise AgentPromptCliError(
|
|
414
|
+
f"report synthesis packet contract defects: {defects}"
|
|
415
|
+
) from exc
|
|
416
|
+
return [f"- Report synthesis packet: `{_relative(project_root, markdown_path)}`"]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _with_inputs_section(body: str, input_lines: list[str]) -> str:
|
|
420
|
+
"""런타임 소유 입력 줄을 본문의 `## Inputs` 첫머리에 넣는다.
|
|
421
|
+
|
|
422
|
+
리드가 이미 그 절을 썼으면 그 제목 바로 아래에 끼워 한 절로 두고, 없으면
|
|
423
|
+
머리의 `**…:**` 헤더 블록 뒤에 절을 새로 연다.
|
|
424
|
+
"""
|
|
425
|
+
lines = body.splitlines()
|
|
426
|
+
for index, line in enumerate(lines):
|
|
427
|
+
if line.strip() == "## Inputs":
|
|
428
|
+
merged = lines[: index + 1] + input_lines + lines[index + 1 :]
|
|
429
|
+
return "\n".join(merged) + "\n"
|
|
430
|
+
insert_at = 0
|
|
431
|
+
while insert_at < len(lines) and (
|
|
432
|
+
not lines[insert_at].strip() or lines[insert_at].startswith("**")
|
|
433
|
+
):
|
|
434
|
+
insert_at += 1
|
|
435
|
+
section = ["## Inputs", *input_lines, ""]
|
|
436
|
+
if insert_at and lines[insert_at - 1].strip():
|
|
437
|
+
section = ["", *section]
|
|
438
|
+
return "\n".join(lines[:insert_at] + section + lines[insert_at:]) + "\n"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _roster_result_path(
|
|
442
|
+
project_root: Path, manifest: Mapping[str, Any], worker_id: str,
|
|
443
|
+
) -> Path | None:
|
|
444
|
+
"""명부가 이 워커에 적어 둔 `resultPath`. 행이 없으면 None."""
|
|
445
|
+
workers = _load_team_state(project_root, manifest).get("workers")
|
|
446
|
+
if not isinstance(workers, list):
|
|
447
|
+
return None
|
|
448
|
+
for row in workers:
|
|
449
|
+
if not isinstance(row, Mapping) or row.get("workerId") != worker_id:
|
|
450
|
+
continue
|
|
451
|
+
value = row.get("resultPath")
|
|
452
|
+
if not isinstance(value, str) or not value.strip():
|
|
453
|
+
return None
|
|
454
|
+
path = Path(value.strip())
|
|
455
|
+
return path if path.is_absolute() else project_root / path
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
262
459
|
def _materialize_standalone(
|
|
263
460
|
args: argparse.Namespace,
|
|
264
461
|
project_root: Path,
|
|
@@ -127,10 +127,23 @@ def _ledger(path: Path, task_key: str, task_type: str, run_seq: str) -> dict[str
|
|
|
127
127
|
expected = (task_key, task_type, run_seq)
|
|
128
128
|
actual = (ledger.get("taskKey"), ledger.get("taskType"), ledger.get("runSeq"))
|
|
129
129
|
if actual != expected:
|
|
130
|
-
|
|
130
|
+
# 원장은 `runSeq` 를 세 자리 문자열로 적는다. 정수 `4` 를 넘긴 호출은
|
|
131
|
+
# 원장을 덤프해 보기 전까지 무엇이 달랐는지 알 수 없었다.
|
|
132
|
+
raise ApprovalDecisionError(
|
|
133
|
+
"approval ledger identity does not match this run: ledger has "
|
|
134
|
+
f"taskKey={actual[0]!r} taskType={actual[1]!r} runSeq={actual[2]!r}; "
|
|
135
|
+
f"this call passed taskKey={expected[0]!r} taskType={expected[1]!r} "
|
|
136
|
+
f"runSeq={expected[2]!r}"
|
|
137
|
+
)
|
|
131
138
|
return ledger
|
|
132
139
|
|
|
133
140
|
|
|
141
|
+
def normalize_run_seq(value: str) -> str:
|
|
142
|
+
"""`4` → `004`. 원장과 run 산출물은 세 자리 0 패딩 문자열을 쓴다."""
|
|
143
|
+
text = str(value).strip()
|
|
144
|
+
return text.zfill(3) if text.isdigit() else text
|
|
145
|
+
|
|
146
|
+
|
|
134
147
|
def _validate_option_set(
|
|
135
148
|
options: Sequence[DecisionOption], classification: str,
|
|
136
149
|
recommended_disposition: str,
|
|
@@ -280,7 +293,18 @@ def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
|
|
|
280
293
|
args.option_added_work, args.option_direction_change,
|
|
281
294
|
)
|
|
282
295
|
if len({len(values) for values in fields}) != 1:
|
|
283
|
-
|
|
296
|
+
names = (
|
|
297
|
+
"role", "answer", "rationale", "disposition", "reach",
|
|
298
|
+
"scope-effect", "added-work", "direction-change",
|
|
299
|
+
)
|
|
300
|
+
counts = ", ".join(
|
|
301
|
+
f"--option-{name}={len(values)}" for name, values in zip(names, fields)
|
|
302
|
+
)
|
|
303
|
+
raise ApprovalDecisionError(
|
|
304
|
+
"every repeated option field needs the same count — repeat each "
|
|
305
|
+
f"--option-* flag once per option, including the ones --help marks "
|
|
306
|
+
f"optional: {counts}"
|
|
307
|
+
)
|
|
284
308
|
return tuple(
|
|
285
309
|
DecisionOption(role, answer, rationale, disposition, reach,
|
|
286
310
|
tuple(effect.split(",")) if effect else (), added, direction)
|
|
@@ -372,6 +396,7 @@ def _open_from_args(args: argparse.Namespace) -> None:
|
|
|
372
396
|
values = vars(args).copy()
|
|
373
397
|
values.pop("command")
|
|
374
398
|
values["ledger_path"] = values.pop("ledger")
|
|
399
|
+
values["run_seq"] = normalize_run_seq(values["run_seq"])
|
|
375
400
|
for key in tuple(values):
|
|
376
401
|
if key.startswith("option_"):
|
|
377
402
|
values.pop(key)
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import re
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
@@ -143,6 +144,19 @@ def _is_timestamped_legacy_artifact(path: Path) -> bool:
|
|
|
143
144
|
return bool(TIMESTAMPED_ARTIFACT_RE.search(path.name))
|
|
144
145
|
|
|
145
146
|
|
|
147
|
+
def _okstra_asset_home() -> Path:
|
|
148
|
+
"""자산을 읽을 okstra 홈. `OKSTRA_HOME` 이 있으면 그쪽이다.
|
|
149
|
+
|
|
150
|
+
종전에는 `Path.home() / ".okstra"` 를 그대로 박아, 홈을 갈아끼운 호출자가
|
|
151
|
+
실제 사용자 설치본을 읽었다. `find_asset_root` 의 docstring 이 규정한 대로
|
|
152
|
+
`OKSTRA_HOME` 이 유일한 격리 이음새이므로 여기서도 그것을 먼저 본다.
|
|
153
|
+
실측: e2e scenario-23 이 샌드박스 홈을 지정하고도 `~/.okstra/templates/`
|
|
154
|
+
를 보고해, 설치본이 있는 기계와 없는 기계의 출력이 달랐다.
|
|
155
|
+
"""
|
|
156
|
+
override = os.environ.get("OKSTRA_HOME")
|
|
157
|
+
return Path(override) if override else Path.home() / ".okstra"
|
|
158
|
+
|
|
159
|
+
|
|
146
160
|
def _installed_or_dev(installed: Path, dev_relative: str) -> Path:
|
|
147
161
|
"""Prefer the user-machine install (what production runs read); fall back
|
|
148
162
|
to the repo dev tree when running from an uninstalled checkout."""
|
|
@@ -153,7 +167,7 @@ def _installed_or_dev(installed: Path, dev_relative: str) -> Path:
|
|
|
153
167
|
|
|
154
168
|
def _runtime_template(filename: str) -> Path:
|
|
155
169
|
return _installed_or_dev(
|
|
156
|
-
|
|
170
|
+
_okstra_asset_home() / "templates" / filename,
|
|
157
171
|
f"templates/{filename}",
|
|
158
172
|
)
|
|
159
173
|
|
|
@@ -223,7 +237,7 @@ def _skill_assets_metric() -> dict:
|
|
|
223
237
|
skill bodies + worker agent specs. These dominate the fixed per-run
|
|
224
238
|
instruction footprint and are the prompt-diet ranking input."""
|
|
225
239
|
entries = []
|
|
226
|
-
okstra_home =
|
|
240
|
+
okstra_home = _okstra_asset_home()
|
|
227
241
|
claude_home = Path.home() / ".claude"
|
|
228
242
|
for name in HOT_PATH_LEAD_RESOURCES:
|
|
229
243
|
path = _installed_or_dev(
|
|
@@ -69,7 +69,12 @@ class RunArtifactAuthority:
|
|
|
69
69
|
|
|
70
70
|
def validated_run_authority(manifest_path: Path) -> RunArtifactAuthority:
|
|
71
71
|
supplied = Path(manifest_path)
|
|
72
|
-
if not supplied.is_absolute()
|
|
72
|
+
if not supplied.is_absolute():
|
|
73
|
+
# 같은 세션의 `team dispatch`·`agent-prompt materialize`·`convergence
|
|
74
|
+
# prepare-groups` 는 상대경로를 받는다. 이 명령만 거절하면 같은 값이
|
|
75
|
+
# 명령마다 다르게 취급된다. 심링크 없는 절대경로라는 요구는 그대로다.
|
|
76
|
+
supplied = Path.cwd() / supplied
|
|
77
|
+
if supplied.absolute() != supplied.resolve():
|
|
73
78
|
raise ConvergenceContractError("run manifest path is not canonical")
|
|
74
79
|
payload = load_owned_json_object(supplied)
|
|
75
80
|
execution_manifest = None
|
|
@@ -7,10 +7,12 @@ import subprocess
|
|
|
7
7
|
import time
|
|
8
8
|
from dataclasses import dataclass, field, replace
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
|
+
import os
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any, Mapping, Sequence
|
|
12
13
|
|
|
13
14
|
from .dispatch_state import (
|
|
15
|
+
CompletedWithoutResultError,
|
|
14
16
|
BACKEND_CLI_WRAPPER,
|
|
15
17
|
BACKEND_CMUX_PANE,
|
|
16
18
|
BACKEND_MIXED,
|
|
@@ -1017,28 +1019,20 @@ def _translator_job_from_reservation(
|
|
|
1017
1019
|
for row in collection
|
|
1018
1020
|
if isinstance(row, Mapping)
|
|
1019
1021
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
and reservation.get("audience") == "translator"
|
|
1029
|
-
and reservation.get("dispatchKind") == "translator"
|
|
1030
|
-
and reservation.get("dutyId") == "translator"
|
|
1031
|
-
and reservation.get("participantRef") == execution.get("participantRef")
|
|
1032
|
-
and reservation.get("roleExecutionRef") == execution.get("roleExecutionRef")
|
|
1033
|
-
and _build_dispatch_id(
|
|
1034
|
-
str(reservation.get("invocationId") or ""),
|
|
1035
|
-
int(reservation.get("attempt") or 0),
|
|
1036
|
-
) not in dispatched_ids
|
|
1037
|
-
):
|
|
1038
|
-
candidates.append(reservation)
|
|
1022
|
+
run_manifest_rel = _string_value(manifest.get("runManifestPath"))
|
|
1023
|
+
candidates, seen = _translator_reservation_candidates(
|
|
1024
|
+
project_root,
|
|
1025
|
+
reservation_root,
|
|
1026
|
+
execution=execution,
|
|
1027
|
+
dispatched_ids=dispatched_ids,
|
|
1028
|
+
run_manifest_rel=run_manifest_rel,
|
|
1029
|
+
)
|
|
1039
1030
|
if len(candidates) != 1:
|
|
1031
|
+
run_label = run_manifest_rel or "run manifest path unknown"
|
|
1040
1032
|
raise DispatchError(
|
|
1041
|
-
"translator dispatch requires exactly one canonical invocation
|
|
1033
|
+
"translator dispatch requires exactly one canonical invocation "
|
|
1034
|
+
f"reservation for this run ({run_label}); found {len(candidates)}. "
|
|
1035
|
+
"Translator reservations seen: " + ("; ".join(seen) if seen else "none")
|
|
1042
1036
|
)
|
|
1043
1037
|
reservation = candidates[0]
|
|
1044
1038
|
prompt_path = _resolve_project_path(
|
|
@@ -1098,6 +1092,86 @@ def _translator_job_from_reservation(
|
|
|
1098
1092
|
)
|
|
1099
1093
|
|
|
1100
1094
|
|
|
1095
|
+
def _translator_reservation_candidates(
|
|
1096
|
+
project_root: Path,
|
|
1097
|
+
reservation_root: Path,
|
|
1098
|
+
*,
|
|
1099
|
+
execution: Mapping[str, Any],
|
|
1100
|
+
dispatched_ids: set[str],
|
|
1101
|
+
run_manifest_rel: str,
|
|
1102
|
+
) -> tuple[list[Mapping[str, Any]], list[str]]:
|
|
1103
|
+
"""이 run 이 디스패치할 수 있는 translator 예약과, 본 예약 전부의 요약.
|
|
1104
|
+
|
|
1105
|
+
예약 디렉터리(`prompts/.agent-invocations/`)는 run 이 아니라 task type 단위라
|
|
1106
|
+
같은 task 의 이전 run 이 남긴 translator 예약이 함께 놓인다. translator 는
|
|
1107
|
+
`assignmentRef` 가 접미 없는 `translator` 하나이고, 이전 run 의 디스패치는
|
|
1108
|
+
이번 run 의 team-state 에 없으므로 "아직 디스패치 안 됨" 으로 읽혀 후보가
|
|
1109
|
+
둘이 됐다 — 두 번째 run 부터 번역이 영영 막혔다(2026-09-03 실측,
|
|
1110
|
+
fontsninja-v3-site dev-10626-1 r02·r04). 예약 자체는 run 을 모르지만 그 옆의
|
|
1111
|
+
`.meta.json` 은 `contractSource.runManifestPath` 로 자기 run 을 안다. 그
|
|
1112
|
+
값을 이번 run 의 `runManifestPath` 와 대조한다. 매니페스트가 그 경로를 안
|
|
1113
|
+
가진 레거시 run 은 종전대로 run 을 가리지 않는다.
|
|
1114
|
+
"""
|
|
1115
|
+
candidates: list[Mapping[str, Any]] = []
|
|
1116
|
+
seen: list[str] = []
|
|
1117
|
+
for path in sorted(reservation_root.glob("*.json")):
|
|
1118
|
+
reservation = _load_json_object(path, "agent invocation reservation")
|
|
1119
|
+
if not (
|
|
1120
|
+
set(reservation) == _TRANSLATOR_RESERVATION_KEYS
|
|
1121
|
+
and reservation.get("schemaVersion") == "2.0"
|
|
1122
|
+
and reservation.get("executionIdentityVersion") == 2
|
|
1123
|
+
and reservation.get("assignmentRef") == "translator"
|
|
1124
|
+
and reservation.get("audience") == "translator"
|
|
1125
|
+
and reservation.get("dispatchKind") == "translator"
|
|
1126
|
+
and reservation.get("dutyId") == "translator"
|
|
1127
|
+
):
|
|
1128
|
+
continue
|
|
1129
|
+
invocation_id = str(reservation.get("invocationId") or "")
|
|
1130
|
+
reservation_run = _reservation_run_manifest_rel(project_root, reservation)
|
|
1131
|
+
dispatched = _build_dispatch_id(
|
|
1132
|
+
invocation_id, int(reservation.get("attempt") or 0),
|
|
1133
|
+
) in dispatched_ids
|
|
1134
|
+
seen.append(
|
|
1135
|
+
f"{invocation_id} (run {reservation_run or 'unknown'}"
|
|
1136
|
+
+ (", dispatched" if dispatched else "")
|
|
1137
|
+
+ ")"
|
|
1138
|
+
)
|
|
1139
|
+
if dispatched:
|
|
1140
|
+
continue
|
|
1141
|
+
if (
|
|
1142
|
+
reservation.get("participantRef") != execution.get("participantRef")
|
|
1143
|
+
or reservation.get("roleExecutionRef") != execution.get("roleExecutionRef")
|
|
1144
|
+
):
|
|
1145
|
+
continue
|
|
1146
|
+
if run_manifest_rel and (
|
|
1147
|
+
reservation_run is None
|
|
1148
|
+
or os.path.normpath(reservation_run) != os.path.normpath(run_manifest_rel)
|
|
1149
|
+
):
|
|
1150
|
+
continue
|
|
1151
|
+
candidates.append(reservation)
|
|
1152
|
+
return candidates, seen
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _reservation_run_manifest_rel(
|
|
1156
|
+
project_root: Path, reservation: Mapping[str, Any],
|
|
1157
|
+
) -> str | None:
|
|
1158
|
+
"""예약 옆 `.meta.json` 의 `contractSource.runManifestPath`. 못 읽으면 None."""
|
|
1159
|
+
metadata_value = _string_value(reservation.get("metadataPath"))
|
|
1160
|
+
if not metadata_value:
|
|
1161
|
+
return None
|
|
1162
|
+
try:
|
|
1163
|
+
metadata = _load_json_object(
|
|
1164
|
+
_resolve_project_path(project_root, metadata_value),
|
|
1165
|
+
"agent invocation metadata",
|
|
1166
|
+
)
|
|
1167
|
+
except DispatchError:
|
|
1168
|
+
return None
|
|
1169
|
+
source = metadata.get("contractSource")
|
|
1170
|
+
if not isinstance(source, Mapping):
|
|
1171
|
+
return None
|
|
1172
|
+
return _string_value(source.get("runManifestPath")) or None
|
|
1173
|
+
|
|
1174
|
+
|
|
1101
1175
|
def _translator_prompt_outputs(
|
|
1102
1176
|
project_root: Path, prompt_path: Path,
|
|
1103
1177
|
) -> tuple[Path, Path]:
|
|
@@ -1928,10 +2002,10 @@ def _finish_attempt(
|
|
|
1928
2002
|
"mutation-present-unresolved",
|
|
1929
2003
|
}:
|
|
1930
2004
|
reason = _mutation_failure_reason(mutation)
|
|
1931
|
-
_transition_job_status(plan, job, "error", reason)
|
|
1932
2005
|
_update_dispatch_status(
|
|
1933
2006
|
plan.team_state_path, job, attempt, "error", reason
|
|
1934
2007
|
)
|
|
2008
|
+
_transition_job_status(plan, job, "error", reason)
|
|
1935
2009
|
details = _failure_details(job, attempt, outcome, reason)
|
|
1936
2010
|
details["mutationAudit"] = mutation.change_summary()
|
|
1937
2011
|
_append_event(plan, "worker-failed", details)
|
|
@@ -1940,13 +2014,20 @@ def _finish_attempt(
|
|
|
1940
2014
|
settlement = _settle(plan, job, attempt, outcome)
|
|
1941
2015
|
if settlement.completed:
|
|
1942
2016
|
result_link = _link_result(plan, job, attempt)
|
|
1943
|
-
|
|
2017
|
+
# 디스패치 행을 먼저 종결한다. 명부 전이가 거절될 수 있고, 그 거절이
|
|
2018
|
+
# 행보다 앞서면 행은 `running` 으로 남아 다음 await 가 같은 sidecar 를
|
|
2019
|
+
# 다시 읽고 같은 자리에서 죽는다(`_link_result` 와 같은 결함 형태).
|
|
2020
|
+
roster_refusal = _transition_roster_completed(plan, job, attempt)
|
|
1944
2021
|
_update_dispatch_status(
|
|
1945
2022
|
plan.team_state_path,
|
|
1946
2023
|
job,
|
|
1947
2024
|
attempt,
|
|
1948
2025
|
"completed",
|
|
1949
|
-
"; ".join(
|
|
2026
|
+
"; ".join(
|
|
2027
|
+
part
|
|
2028
|
+
for part in (settlement.note, result_link["reason"], roster_refusal)
|
|
2029
|
+
if part
|
|
2030
|
+
),
|
|
1950
2031
|
)
|
|
1951
2032
|
details = _result_details(job, attempt, outcome)
|
|
1952
2033
|
details["resultLink"] = result_link
|
|
@@ -1961,8 +2042,8 @@ def _finish_attempt(
|
|
|
1961
2042
|
return False
|
|
1962
2043
|
reason = settlement.reason
|
|
1963
2044
|
status = "timeout" if outcome.timeout else "error"
|
|
1964
|
-
_transition_job_status(plan, job, status, reason)
|
|
1965
2045
|
_update_dispatch_status(plan.team_state_path, job, attempt, status, reason)
|
|
2046
|
+
_transition_job_status(plan, job, status, reason)
|
|
1966
2047
|
details = _failure_details(job, attempt, outcome, reason)
|
|
1967
2048
|
if settlement.error_log_append is not None:
|
|
1968
2049
|
details["errorLogAppend"] = settlement.error_log_append
|
|
@@ -1973,6 +2054,35 @@ def _finish_attempt(
|
|
|
1973
2054
|
return mutation.retry_allowed if mutation is not None else True
|
|
1974
2055
|
|
|
1975
2056
|
|
|
2057
|
+
def _transition_roster_completed(
|
|
2058
|
+
plan: DispatchPlan, job: WorkerJob, attempt: int,
|
|
2059
|
+
) -> str:
|
|
2060
|
+
"""명부 행을 completed 로 올리되, 거절은 이벤트와 사유로 돌려세운다.
|
|
2061
|
+
|
|
2062
|
+
명부는 `resultPath` 파일이 있어야 completed 를 받는다
|
|
2063
|
+
(`_reject_completed_without_result`). 디스패치의 산출물은 전부 있는데 그
|
|
2064
|
+
파일이 다른 경우 — 리드가 명부와 다른 포인터 경로로 재디스패치한 뒤 —
|
|
2065
|
+
그 거절이 예외로 `await` 를 죽였고, 행이 `running` 으로 남아 다음 await 도
|
|
2066
|
+
같은 자리에서 죽었다. 실측(2026-09-02~03, fontsninja-v3-site dev-10626-1
|
|
2067
|
+
r04): report-writer a2·a3 행이 그렇게 남아, 정본 경로로 보낸 a4 가 도는
|
|
2068
|
+
동안에도 `await` 가 즉시 exit 2 였다.
|
|
2069
|
+
|
|
2070
|
+
명부는 손대지 않는다 — 그 불변식(completed ⇒ 파일 있음)은 그대로다. 정본
|
|
2071
|
+
경로를 쓰는 다음 디스패치가 그 파일을 쓰면 그때 completed 가 된다.
|
|
2072
|
+
"""
|
|
2073
|
+
try:
|
|
2074
|
+
_transition_job_status(plan, job, "completed", "")
|
|
2075
|
+
except CompletedWithoutResultError as exc:
|
|
2076
|
+
reason = f"roster not updated: {exc}"
|
|
2077
|
+
_append_event(plan, "worker-roster-not-updated", {
|
|
2078
|
+
**_event_execution_identity(job, attempt),
|
|
2079
|
+
"dispatchWorkerResultPath": str(job.worker_result_path),
|
|
2080
|
+
"reason": reason,
|
|
2081
|
+
})
|
|
2082
|
+
return reason
|
|
2083
|
+
return ""
|
|
2084
|
+
|
|
2085
|
+
|
|
1976
2086
|
def _audit_attempt(
|
|
1977
2087
|
job: WorkerJob,
|
|
1978
2088
|
outcome: WorkerOutcome,
|