okstra 0.201.2 → 0.202.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.
Files changed (49) hide show
  1. package/bin/okstra +1 -1
  2. package/dist/commands/lifecycle/setup.mjs +15 -0
  3. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  4. package/dist/lib/citation-guidance.d.mts +21 -0
  5. package/dist/lib/citation-guidance.mjs +79 -0
  6. package/dist/lib/citation-guidance.mjs.map +1 -0
  7. package/docs/architecture/storage-model.md +4 -0
  8. package/docs/architecture.md +1 -1
  9. package/docs/cli.md +2 -2
  10. package/docs/for-ai/skills/okstra-manager.md +21 -4
  11. package/docs/for-ai/skills/okstra-setup.md +9 -0
  12. package/docs/project-structure-overview.md +11 -5
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/prompts/launch.template.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +15 -3
  17. package/runtime/prompts/lead/team-contract.md +2 -0
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  20. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  21. package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
  22. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  23. package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
  24. package/runtime/python/okstra_ctl/dispatch_core.py +91 -17
  25. package/runtime/python/okstra_ctl/execution_identity.py +9 -2
  26. package/runtime/python/okstra_ctl/execution_manifest.py +6 -2
  27. package/runtime/python/okstra_ctl/execution_mutation_audit.py +134 -65
  28. package/runtime/python/okstra_ctl/manager_cli.py +85 -12
  29. package/runtime/python/okstra_ctl/manager_launch.py +40 -18
  30. package/runtime/python/okstra_ctl/manager_paths.py +8 -0
  31. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  32. package/runtime/python/okstra_ctl/manager_store.py +121 -18
  33. package/runtime/python/okstra_ctl/manager_sync.py +33 -15
  34. package/runtime/python/okstra_ctl/manager_view.py +216 -0
  35. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  36. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  37. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  38. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  39. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  40. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  41. package/runtime/skills/okstra-manager/SKILL.md +53 -4
  42. package/runtime/skills/okstra-run/SKILL.md +3 -1
  43. package/runtime/skills/okstra-setup/SKILL.md +9 -0
  44. package/runtime/templates/manager/view.template.html +108 -0
  45. package/runtime/templates/reports/html/i18n/en.json +2 -0
  46. package/runtime/templates/reports/html/i18n/ko.json +2 -0
  47. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  48. package/runtime/validators/validate-brief.py +7 -2
  49. package/runtime/validators/validate-run.py +7 -15
@@ -0,0 +1,78 @@
1
+ """종결된 호출의 결과를 보존하는 증거 보완 프롬프트 발행."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .invocation import (
7
+ AgentInstruction, AgentInstructionSource, AgentInvocationError,
8
+ AgentInvocationRequest, PreparedAgentInvocation, _extract_anchor_lines,
9
+ _split_prompt, agent_model_assignment_from_payload, invocation_metadata_identity,
10
+ prepare_agent_invocation, verify_agent_invocation,
11
+ )
12
+ from ..json_boundary import load_owned_object
13
+ from ..worker_artifact_paths import audit_sidecar_rel
14
+
15
+
16
+ def prepare_evidence_recovery(
17
+ metadata_path: Path, *, project_root: Path, result_path: Path,
18
+ source_attempt: int,
19
+ ) -> tuple[PreparedAgentInvocation, Path]:
20
+ """원래 모델·역할을 유지하고 새 결과 경로에 제한된 증거 보완을 발행한다."""
21
+ metadata = load_owned_object(metadata_path, artifact="recovery source metadata")
22
+ source = metadata["contractSource"]
23
+ manifest = project_root / source["runManifestPath"]
24
+ errors = verify_agent_invocation(
25
+ metadata_path, project_root=project_root, expected_run_manifest_path=manifest,
26
+ )
27
+ if errors:
28
+ raise AgentInvocationError("invalid recovery source: " + "; ".join(errors))
29
+ identity = invocation_metadata_identity(metadata)
30
+ if identity is None or not result_path.is_file():
31
+ raise AgentInvocationError("evidence recovery requires v2 identity and an existing result")
32
+ suffix = f"-evidence-recovery-{source_attempt}"
33
+ invocation_id = identity.invocation_ref + suffix
34
+ old_prompt = project_root / metadata["prompt"]["path"]
35
+ prompt = old_prompt.with_name(old_prompt.stem + suffix + old_prompt.suffix)
36
+ result = result_path.with_name(result_path.name.replace("-worker-", suffix + "-worker-", 1))
37
+ prefix, _ = _split_prompt(old_prompt.read_text(encoding="utf-8"))
38
+ replacements = {
39
+ "**Prompt History Path:**": str(prompt.relative_to(project_root)),
40
+ "Assigned worker prompt history path:": str(prompt),
41
+ "**Result Path:**": str(result.relative_to(project_root)),
42
+ "**Worker Result Path:**": str(result.relative_to(project_root)),
43
+ "**Audit sidecar path:**": audit_sidecar_rel(str(result)),
44
+ }
45
+ anchors = tuple(next((f"{key} {value}" for key, value in replacements.items()
46
+ if line.startswith(key)), line) for line in _extract_anchor_lines(prefix))
47
+ body = _recovery_instructions(identity.invocation_ref, source_attempt, result_path, old_prompt)
48
+ prepared = prepare_agent_invocation(AgentInvocationRequest(
49
+ invocation_id=invocation_id, worker_id=None, audience=metadata["audience"],
50
+ assignment_ref=metadata["assignmentRef"], purpose=None,
51
+ assignment=agent_model_assignment_from_payload(metadata["modelAssignment"]),
52
+ instruction=AgentInstruction(anchor_lines=anchors, body=body, source_paths=(
53
+ AgentInstructionSource("project", str(result_path.relative_to(project_root))),)),
54
+ project_root=project_root, run_manifest_path=manifest,
55
+ duty_root=project_root / source["dutyRootPath"], prompt_path=prompt,
56
+ metadata_path=prompt.with_name(prompt.name + ".meta.json"),
57
+ dispatch_kind=metadata["dispatchKind"], participant_ref=identity.participant_ref,
58
+ role_execution_ref=identity.role_execution_ref, duty_id=identity.duty_id,
59
+ invocation_ref=invocation_id, attempt=1,
60
+ ))
61
+ return prepared, result
62
+
63
+
64
+ def _recovery_instructions(invocation_ref: str, source_attempt: int, result_path: Path, old_prompt: Path) -> str:
65
+ """완료된 구현을 재실행하지 않는 보완 범위를 전달한다."""
66
+ return (
67
+ "## Evidence recovery scope\n\n"
68
+ f"Original invocation: {invocation_ref}; attempt: {source_attempt}.\n"
69
+ f"Read and preserve the original result: {result_path}.\n"
70
+ f"Read the original audit: {old_prompt}.mutation-audit.json.\n"
71
+ "The prior attempt failed only on a declaration discrepancy. Preserve existing source and QA work. "
72
+ "Do not repeat completed implementation steps or edit production source. Reconcile the declaration "
73
+ "and required evidence against current artifacts; retain historical validation as historical. "
74
+ "Do not invent a missing before-state or claim independent verification. If a substantive defect "
75
+ "or authority problem is found, report it for the lead instead of expanding this repair. "
76
+ "Write a new completion result in the original role's required format, citing the original result "
77
+ "and clearly separating recovered evidence from remaining independent checks.\n"
78
+ )
@@ -28,7 +28,7 @@ from dataclasses import dataclass
28
28
  from pathlib import Path
29
29
  from typing import Any, Mapping, Sequence
30
30
 
31
- from .convergence_provenance import worker_result_suffix
31
+ from .convergence_provenance import worker_result_paths, worker_result_suffix
32
32
  from .fixed_text import line
33
33
  from .json_boundary import JsonBoundaryError, load_owned_object
34
34
  from .paths import RunRef
@@ -132,9 +132,7 @@ def analyser_results(
132
132
  ) -> list[AnalyserResult]:
133
133
  """분석 audience 워커마다 결과 파일 경로와 finding id 를 짝지어 돌려준다.
134
134
 
135
- 결과 파일명은 `convergence_provenance` 가 이미 소유한 규칙을 그대로 쓴다 —
136
- `<worker>-worker-<task-type>-<workerResults seq>.md`. state 시퀀스와 다른
137
- 카운터라 그룹 파일명에서 접미사를 빌려 오면 빗나간다.
135
+ 출처 검사와 같은 실행 원장을 사용해야 교정 전 결과를 재검증하지 않는다.
138
136
  """
139
137
  suffix = worker_result_suffix(Path(run_dir), groups)
140
138
  if suffix is None:
@@ -142,13 +140,13 @@ def analyser_results(
142
140
  "cannot resolve the worker-result suffix from the grouping's "
143
141
  "runManifestPath"
144
142
  )
145
- results_dir = Path(run_dir) / "worker-results"
143
+ paths = worker_result_paths(Path(run_dir), groups, suffix)
146
144
  by_worker = _findings_by_worker(groups)
147
145
  return [
148
146
  AnalyserResult(
149
147
  worker_id=worker,
150
148
  result_path=_project_relative(
151
- project_root, results_dir / f"{worker}-worker-{suffix}.md"
149
+ project_root, paths[worker]
152
150
  ),
153
151
  finding_ids=tuple(by_worker.get(worker, ())),
154
152
  )
@@ -21,6 +21,8 @@ from pathlib import Path
21
21
  from typing import Mapping
22
22
 
23
23
  from okstra_ctl.convergence_engine import grouped_input_digest
24
+ from okstra_ctl.execution_identity import Attempt, ExecutionManifest
25
+ from okstra_ctl.execution_manifest import read_execution_manifest_view
24
26
  from okstra_ctl.json_boundary import (
25
27
  load_owned_object,
26
28
  )
@@ -97,6 +99,66 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
97
99
  return grouped_input_digest(document) == recorded
98
100
 
99
101
 
102
+ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
103
+ """교정 실행은 발견 결과를 대체하지만 재검증 투표는 발견 출처가 아니다."""
104
+ latest: dict[str, Attempt] = {}
105
+ for attempt in manifest.attempts:
106
+ current = latest.get(attempt.invocation_ref)
107
+ if current is None or attempt.attempt > current.attempt:
108
+ latest[attempt.invocation_ref] = attempt
109
+ by_role: dict[str, list[Attempt]] = {}
110
+ for invocation in manifest.invocations:
111
+ if invocation.dispatch_kind not in {"initial", "correction"}:
112
+ continue
113
+ attempt = latest.get(invocation.invocation_ref)
114
+ if attempt is not None:
115
+ by_role.setdefault(invocation.role_execution_ref, []).append(attempt)
116
+ return by_role
117
+
118
+
119
+ def worker_result_paths(
120
+ run_dir: Path, document: Mapping, suffix: str
121
+ ) -> dict[str, Path]:
122
+ """실행 원장의 채택된 발견 결과를 고른다. 구형 원장은 파일명 규칙을 쓴다."""
123
+ results_dir = run_dir / "worker-results"
124
+ workers = document.get("workers") or []
125
+ paths = {
126
+ row["workerId"]: results_dir / f"{row['workerId']}-worker-{suffix}.md"
127
+ for row in workers if isinstance(row, Mapping) and row.get("workerId")
128
+ }
129
+ if document.get("schemaVersion") != "2.0":
130
+ return paths
131
+ recorded = document.get("runManifestPath")
132
+ if not _nonempty_string(recorded):
133
+ return paths
134
+ manifest_path = run_dir / "manifests" / Path(recorded).name
135
+ manifest, authority = read_execution_manifest_view(manifest_path)
136
+ if manifest.legacy:
137
+ return paths
138
+ attempts = discovery_attempts(manifest)
139
+ for row in workers:
140
+ if not isinstance(row, Mapping) or not row.get("workerId"):
141
+ continue
142
+ accepted = [
143
+ attempt for attempt in attempts.get(row.get("sourceRoleExecutionRef"), [])
144
+ if attempt.status == "ok" and attempt.result_path
145
+ ]
146
+ if not accepted:
147
+ continue
148
+ candidate = Path(accepted[-1].result_path)
149
+ if not candidate.is_absolute():
150
+ project_root = authority.get("projectRoot")
151
+ if not _nonempty_string(project_root):
152
+ raise ValueError("relative accepted resultPath requires projectRoot")
153
+ candidate = Path(project_root) / candidate
154
+ if candidate.resolve().parent != results_dir.resolve():
155
+ raise ValueError(f"accepted resultPath is outside worker-results: {candidate}")
156
+ if not candidate.is_file():
157
+ raise ValueError(f"accepted resultPath is missing: {candidate}")
158
+ paths[row["workerId"]] = candidate
159
+ return paths
160
+
161
+
100
162
  def read_canonical_worker_result(
101
163
  worker_results_dir: Path, worker: str, suffix: str
102
164
  ) -> str | None:
@@ -140,20 +202,25 @@ def provenance_errors(
140
202
  claims = group_claims(document)
141
203
  if claims is None:
142
204
  return []
205
+ paths = worker_result_paths(worker_results_dir.parent, document, suffix)
143
206
  contents: dict[str, str | None] = {}
144
207
  errors: list[str] = []
145
208
  for finding_id, worker, item_id in claims:
146
209
  if worker not in contents:
147
- contents[worker] = read_canonical_worker_result(
148
- worker_results_dir, worker, suffix
149
- )
210
+ candidate = paths.get(worker)
211
+ if candidate is not None and candidate.name != f"{worker}-worker-{suffix}.md":
212
+ contents[worker] = candidate.read_text(encoding="utf-8")
213
+ else:
214
+ contents[worker] = read_canonical_worker_result(
215
+ worker_results_dir, worker, suffix
216
+ )
150
217
  text = contents[worker]
151
218
  if text is None or id_occurs_wordbounded(text, item_id):
152
219
  continue
153
220
  errors.append(
154
221
  f"convergence groups `{groups_label}` group {finding_id} "
155
222
  f"cites source item `{worker}:{item_id}`, but that ID does not "
156
- f"occur in the worker's result file `{worker}-worker-{suffix}.md` — a "
223
+ f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{suffix}.md')).name}` — a "
157
224
  "grouping may not invent a provenance link to an item the "
158
225
  "worker never reported."
159
226
  )
@@ -243,10 +310,10 @@ DISCARDED_ATTEMPT_STATUSES = frozenset(
243
310
 
244
311
 
245
312
  def discarded_workers(document: object, execution_manifest: object) -> dict[str, str]:
246
- """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 초기 attempt 가 전부
313
+ """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 발견 attempt 가 전부
247
314
  폐기돼 채택할 결과가 없는 분석 워커.
248
315
 
249
- 한 워커의 초기 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
316
+ 한 워커의 발견 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
250
317
  그중 하나라도 마지막 attempt 가 ``ok`` 면 채택 가능한 결과가 있는 것이므로
251
318
  폐기로 보지 않는다. legacy 매니페스트나 v1 roster 는 판정하지 않는다.
252
319
  """
@@ -254,17 +321,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
254
321
  return {}
255
322
  if execution_manifest is None or getattr(execution_manifest, "legacy", False):
256
323
  return {}
257
- initial_refs: dict[str, set[str]] = {}
258
- for invocation in getattr(execution_manifest, "invocations", ()):
259
- if invocation.dispatch_kind == "initial":
260
- initial_refs.setdefault(invocation.role_execution_ref, set()).add(
261
- invocation.invocation_ref
262
- )
263
- latest: dict[str, object] = {}
264
- for attempt in getattr(execution_manifest, "attempts", ()):
265
- current = latest.get(attempt.invocation_ref)
266
- if current is None or attempt.attempt > current.attempt:
267
- latest[attempt.invocation_ref] = attempt
324
+ attempts = discovery_attempts(execution_manifest)
268
325
  result: dict[str, str] = {}
269
326
  for row in document["workers"]:
270
327
  if not isinstance(row, dict):
@@ -273,7 +330,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
273
330
  role_ref = row.get("sourceRoleExecutionRef")
274
331
  if not _nonempty_string(worker) or not _nonempty_string(role_ref):
275
332
  continue
276
- rows = [latest[ref] for ref in sorted(initial_refs.get(role_ref, ())) if ref in latest]
333
+ rows = attempts.get(role_ref, [])
277
334
  if not rows or any(item.status == "ok" for item in rows):
278
335
  continue
279
336
  discarded = [item for item in rows if item.status in DISCARDED_ATTEMPT_STATUSES]
@@ -13,6 +13,7 @@ from typing import Any, Mapping, Sequence
13
13
 
14
14
  from .dispatch_state import abandon_agent_dispatch_attempt
15
15
  from .agent.invocation import invocation_input_digest
16
+ from .agent.evidence_recovery import prepare_evidence_recovery
16
17
  from .dispatch_state import (
17
18
  CompletedWithoutResultError,
18
19
  BACKEND_CLI_WRAPPER,
@@ -71,7 +72,7 @@ from .domain.worker_runtime import (
71
72
  WorkerSpawnRequest,
72
73
  )
73
74
  from .ports.worker_runtime import WorkerRuntimePort
74
- from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
75
+ from .execution_identity import Attempt, ExecutionManifest, Invocation, RoleExecution
75
76
  from .execution_manifest import (
76
77
  ExecutionManifestError,
77
78
  finish_attempt_mutation,
@@ -153,7 +154,6 @@ _WRAPPER_LOG_TAIL_BYTES = 800
153
154
  class RevalidatedBinding:
154
155
  role_execution_ref: str
155
156
  binding: HostModelBinding
156
- model_spec_digest: str
157
157
 
158
158
 
159
159
  def revalidate_role_execution(
@@ -165,7 +165,6 @@ def revalidate_role_execution(
165
165
  if (
166
166
  role_execution.model_ref is None
167
167
  or role_execution.binding is None
168
- or role_execution.model_spec_digest is None
169
168
  ):
170
169
  raise DispatchError("model binding changed: role execution is unbound")
171
170
  try:
@@ -188,18 +187,16 @@ def revalidate_role_execution(
188
187
  binding = assignment.binding
189
188
  if binding is None:
190
189
  raise AssignmentResolutionError("selected model has no worker binding")
191
- digest = model_spec_digest(pool.resolve(role_execution.model_ref), binding)
192
190
  except (ValueError, RuntimeError) as exc:
193
191
  raise DispatchError(f"model binding changed: {exc}") from exc
194
192
  identity_matches = (
195
193
  assignment.provider_id == role_execution.provider
196
194
  and assignment.model_id == role_execution.model_id
197
195
  and binding == role_execution.binding
198
- and digest == role_execution.model_spec_digest
199
196
  )
200
197
  if not identity_matches:
201
198
  raise DispatchError("model binding changed for selected role execution")
202
- return RevalidatedBinding(role_execution.role_execution_ref, binding, digest)
199
+ return RevalidatedBinding(role_execution.role_execution_ref, binding)
203
200
 
204
201
 
205
202
  def dispatch_revalidated_role_execution(
@@ -407,6 +404,9 @@ def build_dispatch_plan(
407
404
 
408
405
 
409
406
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
407
+ plan = _recover_declaration_failures(plan)
408
+ if not plan.jobs:
409
+ return 0
410
410
  plan = _ensure_runtime_chain(plan)
411
411
  _validate_report_writer_isolation(plan.jobs)
412
412
  _validate_implementation_phase_order(plan.jobs)
@@ -508,6 +508,9 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
508
508
  """Start one dependency-free CLI batch before collecting any worker."""
509
509
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
510
510
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
511
+ plan = _recover_declaration_failures(plan)
512
+ if not plan.jobs:
513
+ return 0
511
514
  plan = _ensure_runtime_chain(plan)
512
515
  _validate_report_writer_isolation(plan.jobs)
513
516
  _validate_implementation_phase_order(plan.jobs)
@@ -1617,6 +1620,74 @@ def _spawn_cli_job_nonblocking(
1617
1620
  )
1618
1621
 
1619
1622
 
1623
+ def _recover_declaration_failures(plan: DispatchPlan) -> DispatchPlan:
1624
+ """선언만으로 거부된 구현은 같은 호출을 재시도하지 않고 증거 보완으로 연결한다."""
1625
+ if not any(job.has_execution_identity for job in plan.jobs):
1626
+ return plan
1627
+ manifest = read_execution_manifest(plan.manifest_path)
1628
+ jobs: list[WorkerJob] = []
1629
+ for job in plan.jobs:
1630
+ source_attempt = _declaration_recovery_attempt(manifest, job)
1631
+ if source_attempt is None:
1632
+ jobs.append(job)
1633
+ continue
1634
+ recovered_ref = f"{job.invocation_ref}-evidence-recovery-{source_attempt}"
1635
+ recovered = [row for row in manifest.attempts if row.invocation_ref == recovered_ref]
1636
+ if recovered and max(recovered, key=lambda row: row.attempt).status == "ok":
1637
+ continue
1638
+ try:
1639
+ prepared, result = prepare_evidence_recovery(
1640
+ job.prompt_metadata_path, project_root=plan.project_root,
1641
+ result_path=job.result_path, source_attempt=source_attempt,
1642
+ )
1643
+ except AgentInvocationError as exc:
1644
+ raise DispatchError(f"evidence recovery could not be prepared: {exc}") from exc
1645
+ jobs.append(replace(
1646
+ job, invocation_id=prepared.invocation_id, invocation_ref=prepared.invocation_ref,
1647
+ attempt=1, prompt_path=prepared.prompt_path, prompt_metadata_path=prepared.metadata_path,
1648
+ result_path=result, worker_result_path=result, completion_paths=(result,),
1649
+ result_aliases=(), instruction_digest=prepared.instruction_digest,
1650
+ prompt_digest=prepared.prompt_digest,
1651
+ ))
1652
+ if not recovered:
1653
+ _append_event(plan, "worker-evidence-recovery-prepared", {
1654
+ "sourceInvocationRef": job.invocation_ref, "sourceAttempt": source_attempt,
1655
+ "invocationRef": prepared.invocation_ref, "resultPath": str(result),
1656
+ "modelExecutionValue": job.model_execution_value,
1657
+ })
1658
+ return replace(plan, jobs=tuple(jobs))
1659
+
1660
+
1661
+ def _declaration_recovery_attempt(manifest: ExecutionManifest, job: WorkerJob) -> int | None:
1662
+ """선언 오류만 있는 종결 시도를 고르고 이미 시작한 후속 작업은 보존한다."""
1663
+ prior = [row for row in manifest.attempts if row.invocation_ref == job.invocation_ref]
1664
+ last = max(prior, key=lambda row: row.attempt) if prior else None
1665
+ summary = last.change_summary if last else {}
1666
+ if not (
1667
+ job.audience == "implementation-executor" and last is not None
1668
+ and last.finished_at is not None and last.status == "contract-failed-unattributed"
1669
+ and summary.get("sourceChanged") is False and summary.get("gitChanged") is False
1670
+ and summary.get("violations") == ["declared out-of-plan path did not change"]
1671
+ and "-evidence-recovery-" not in job.invocation_ref
1672
+ ):
1673
+ return None
1674
+ recovered_ref = f"{job.invocation_ref}-evidence-recovery-{last.attempt}"
1675
+ later_refs = {
1676
+ row.invocation_ref for row in manifest.invocations
1677
+ if row.role_execution_ref == job.role_execution_ref
1678
+ and row.invocation_ref not in (job.invocation_ref, recovered_ref)
1679
+ }
1680
+ later = [row for row in manifest.attempts if row.invocation_ref in later_refs
1681
+ and row.started_at >= last.finished_at]
1682
+ if later:
1683
+ raise DispatchError(
1684
+ "a subsequent invocation already exists for this role; collect or await "
1685
+ f"{', '.join(sorted({row.invocation_ref for row in later}))} instead of redispatching "
1686
+ f"the historical failure {job.invocation_ref}"
1687
+ )
1688
+ return last.attempt
1689
+
1690
+
1620
1691
  def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1621
1692
  """이 invocation 이 다음에 청구할 attempt 번호.
1622
1693
 
@@ -1630,12 +1701,23 @@ def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1630
1701
  return job.attempt
1631
1702
  manifest = read_execution_manifest(plan.manifest_path)
1632
1703
  prior = [
1633
- row.attempt for row in manifest.attempts
1704
+ row for row in manifest.attempts
1634
1705
  if row.invocation_ref == job.invocation_ref
1635
1706
  ]
1636
1707
  if not prior:
1637
1708
  return job.attempt
1638
- spent = max(prior)
1709
+ last = max(prior, key=lambda row: row.attempt)
1710
+ if last.finished_at is None:
1711
+ raise DispatchError(f"invocation is still running: {job.invocation_ref}; use okstra team await")
1712
+ if last.status != "failed-no-mutation":
1713
+ raise DispatchError(
1714
+ f"invocation {job.invocation_ref} attempt {last.attempt} is already closed as {last.status}; "
1715
+ "preserve its result and audit. Use okstra agent-prompt materialize with a new "
1716
+ "--invocation-id and distinct --prompt/--result paths in the same run, retaining "
1717
+ "--assignment-ref and the selected model. Scope the corrective instruction to "
1718
+ "missing evidence or the verified defect; do not append another attempt or replay completed implementation."
1719
+ )
1720
+ spent = last.attempt
1639
1721
  if spent >= MAX_WORKER_ATTEMPTS:
1640
1722
  raise DispatchError(
1641
1723
  f"worker retry budget is spent: {job.invocation_ref} used "
@@ -2309,15 +2391,7 @@ def _audit_attempt(
2309
2391
 
2310
2392
 
2311
2393
  def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
2312
- """워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
2313
-
2314
- 감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
2315
- 마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
2316
- 리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
2317
- 않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
2318
- 판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
2319
- 토큰이 경로다.
2320
- """
2394
+ """지원하는 결과 표기에서 참고 선언을 읽는다. 권한 판정에는 사용하지 않는다."""
2321
2395
  if not result_path.is_file() or result_path.suffix != ".md":
2322
2396
  return ()
2323
2397
  try:
@@ -283,10 +283,17 @@ def model_spec_digest(
283
283
  write_policy_digest: str | None = None,
284
284
  served_model_attestation: ServedModelAttestation | None = None,
285
285
  ) -> str:
286
- """Digest preparation facts; invocation policy and observations are excluded."""
286
+ """실행 준비 사실을 해시하며 요금·호출 정책·관측 결과는 제외한다."""
287
287
  del write_policy_digest, served_model_attestation
288
+ specification = _canonical_model_spec(model_spec)
289
+ # 요금 미설정 실행의 기존 해시와 동일한 표현을 유지한다.
290
+ specification["pricing"] = None
291
+ return _digest_model_binding(specification, binding)
292
+
293
+
294
+ def _digest_model_binding(specification: Mapping[str, Any], binding: HostModelBinding) -> str:
288
295
  canonical = {
289
- "modelSpec": _canonical_model_spec(model_spec),
296
+ "modelSpec": specification,
290
297
  "binding": _binding_payload(binding),
291
298
  }
292
299
  encoded = json.dumps(
@@ -559,7 +559,7 @@ def _validate_role_identity(
559
559
  role = _required_text(row, "role")
560
560
  provider = _required_text(row, "provider")
561
561
  model_id = _required_text(row, "modelId")
562
- unknown = row.get("modelRef") is None or row.get("modelSpecDigest") is None
562
+ unknown = row.get("modelRef") is None
563
563
  if unknown and not _is_current_session_unknown_leader(payload, row, participant):
564
564
  raise ExecutionManifestError("unknown model is allowed only for current-session leader")
565
565
  if not unknown and row.get("binding") is None:
@@ -901,7 +901,11 @@ def _validate_next_attempt(manifest: ExecutionManifest, row: Attempt) -> None:
901
901
  raise ExecutionManifestError("previous attempt is not terminal")
902
902
  if last.status != "failed-no-mutation":
903
903
  raise ExecutionManifestError(
904
- "invocation cannot append another attempt after a terminal mutation"
904
+ "invocation cannot append another attempt after a terminal mutation; "
905
+ "preserve the existing result and audit. Materialize a new corrective invocation "
906
+ "with okstra agent-prompt materialize, a new --invocation-id and distinct "
907
+ "--prompt/--result paths, retaining the same assignment and selected model. "
908
+ "Do not replay completed implementation."
905
909
  )
906
910
 
907
911