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.
Files changed (38) hide show
  1. package/dist/commands/lifecycle/install.mjs +3 -1
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +2 -2
  4. package/docs/cli.md +2 -2
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-render-report-views.py +23 -5
  8. package/runtime/prompts/lead/adapters/cmux.md +3 -3
  9. package/runtime/prompts/lead/convergence.md +3 -1
  10. package/runtime/prompts/lead/report-writer.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
  12. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
  13. package/runtime/python/okstra_ctl/agent/activity.py +4 -1
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
  16. package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
  17. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  18. package/runtime/python/okstra_ctl/convergence.py +6 -1
  19. package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
  20. package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
  21. package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
  22. package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
  23. package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
  24. package/runtime/python/okstra_ctl/implementation_options.py +7 -2
  25. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  26. package/runtime/python/okstra_ctl/report_finalize.py +47 -2
  27. package/runtime/python/okstra_ctl/report_narrative.py +132 -33
  28. package/runtime/python/okstra_ctl/report_projections.py +3 -1
  29. package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
  30. package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
  31. package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
  32. package/runtime/python/okstra_ctl/worker_runner.py +42 -8
  33. package/runtime/python/okstra_token_usage/antigravity.py +50 -12
  34. package/runtime/python/okstra_token_usage/collect.py +139 -33
  35. package/runtime/python/okstra_token_usage/pricing.py +6 -3
  36. package/runtime/python/okstra_token_usage/report.py +1 -1
  37. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  38. 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 result_path
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=instruction_path.read_text(encoding="utf-8"),
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
- raise ApprovalDecisionError("approval ledger identity does not match this run")
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
- raise ApprovalDecisionError("every repeated option field needs the same count")
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
- Path.home() / ".okstra" / "templates" / filename,
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 = Path.home() / ".okstra"
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() or supplied.absolute() != supplied.resolve():
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
- candidates: list[Mapping[str, Any]] = []
1021
- for path in sorted(reservation_root.glob("*.json")):
1022
- reservation = _load_json_object(path, "agent invocation reservation")
1023
- if (
1024
- set(reservation) == _TRANSLATOR_RESERVATION_KEYS
1025
- and reservation.get("schemaVersion") == "2.0"
1026
- and reservation.get("executionIdentityVersion") == 2
1027
- and reservation.get("assignmentRef") == "translator"
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 reservation"
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
- _transition_job_status(plan, job, "completed", "")
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(part for part in (settlement.note, result_link["reason"]) if part),
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,