okstra 0.202.0 → 0.204.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 (258) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/memory/memory.mjs +41 -8
  7. package/dist/commands/memory/memory.mjs.map +1 -1
  8. package/dist/lib/install-assets.mjs +3 -0
  9. package/dist/lib/install-assets.mjs.map +1 -1
  10. package/dist/lib/runtime-manifest.mjs +2 -1
  11. package/dist/lib/runtime-manifest.mjs.map +1 -1
  12. package/dist/lib/types.d.mts +2 -1
  13. package/docs/architecture/storage-model.md +14 -11
  14. package/docs/architecture.md +25 -19
  15. package/docs/cli.md +15 -12
  16. package/docs/contributor-change-matrix.md +3 -2
  17. package/docs/performance-improvement-plan-v2.md +2 -3
  18. package/docs/project-structure-overview.md +35 -9
  19. package/docs/task-process/README.md +1 -1
  20. package/docs/task-process/common-flow.md +1 -1
  21. package/docs/task-process/final-verification.md +3 -1
  22. package/docs/task-process/implementation.md +1 -1
  23. package/docs/task-process/release-handoff.md +36 -39
  24. package/package.json +1 -2
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/agents/common.json +28 -0
  27. package/runtime/agents/operations/code-review.json +6 -0
  28. package/runtime/agents/operations/report-translation.json +6 -0
  29. package/runtime/agents/operations/schedule-verification.json +6 -0
  30. package/runtime/agents/roles/analyser.json +18 -0
  31. package/runtime/agents/roles/critic.json +18 -0
  32. package/runtime/agents/roles/designer.json +18 -0
  33. package/runtime/agents/roles/implementer.json +20 -0
  34. package/runtime/agents/roles/leader.json +20 -0
  35. package/runtime/agents/roles/planner.json +18 -0
  36. package/runtime/agents/roles/report-writer.json +19 -0
  37. package/runtime/agents/roles/translator.json +19 -0
  38. package/runtime/agents/roles/verifier.json +18 -0
  39. package/runtime/bin/lib/okstra/usage.sh +5 -5
  40. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  41. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  42. package/runtime/prompts/duties/analysis-worker.json +32 -0
  43. package/runtime/prompts/duties/code-reviewer.json +32 -0
  44. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  45. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  46. package/runtime/prompts/duties/discovery-worker.json +32 -0
  47. package/runtime/prompts/duties/implementation-executor.json +32 -0
  48. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  49. package/runtime/prompts/duties/lead.json +32 -0
  50. package/runtime/prompts/duties/planning-worker.json +36 -0
  51. package/runtime/prompts/duties/report-writer.json +32 -0
  52. package/runtime/prompts/duties/reverification-worker.json +32 -0
  53. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  54. package/runtime/prompts/duties/scope-critic.json +32 -0
  55. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  56. package/runtime/prompts/duties/translator.json +32 -0
  57. package/runtime/prompts/launch.template.md +2 -1
  58. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  59. package/runtime/prompts/lead/convergence.md +4 -4
  60. package/runtime/prompts/lead/okstra-lead-contract.md +113 -4
  61. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  62. package/runtime/prompts/lead/report-writer.md +3 -3
  63. package/runtime/prompts/profiles/_common-contract.md +2 -2
  64. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  65. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  66. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  67. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  68. package/runtime/prompts/profiles/error-analysis.json +39 -0
  69. package/runtime/prompts/profiles/error-analysis.md +0 -25
  70. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  71. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  72. package/runtime/prompts/profiles/final-verification.json +30 -0
  73. package/runtime/prompts/profiles/final-verification.md +3 -22
  74. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  75. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  76. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  77. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  78. package/runtime/prompts/profiles/implementation-planning.md +4 -29
  79. package/runtime/prompts/profiles/implementation.json +30 -0
  80. package/runtime/prompts/profiles/implementation.md +1 -20
  81. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  82. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  83. package/runtime/prompts/profiles/project-analysis.json +31 -0
  84. package/runtime/prompts/profiles/project-analysis.md +0 -20
  85. package/runtime/prompts/profiles/release-handoff.json +5 -0
  86. package/runtime/prompts/profiles/release-handoff.md +71 -73
  87. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  88. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  89. package/runtime/prompts/profiles/technical-verification.json +39 -0
  90. package/runtime/prompts/profiles/technical-verification.md +0 -25
  91. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  92. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  93. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  94. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  95. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  96. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  97. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  100. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  101. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  102. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  103. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  104. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  105. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  106. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  107. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  108. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  109. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  110. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  111. package/runtime/python/okstra_ctl/cmux.py +69 -20
  112. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  113. package/runtime/python/okstra_ctl/conformance.py +43 -0
  114. package/runtime/python/okstra_ctl/consumers.py +6 -3
  115. package/runtime/python/okstra_ctl/container.py +31 -8
  116. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  117. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  118. package/runtime/python/okstra_ctl/convergence_provenance.py +7 -1
  119. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  120. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  121. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  122. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  123. package/runtime/python/okstra_ctl/error_report.py +4 -3
  124. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  125. package/runtime/python/okstra_ctl/handoff.py +167 -277
  126. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  127. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  128. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  129. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  130. package/runtime/python/okstra_ctl/manager_cli.py +92 -4
  131. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  132. package/runtime/python/okstra_ctl/manager_paths.py +14 -3
  133. package/runtime/python/okstra_ctl/manager_store.py +210 -3
  134. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  135. package/runtime/python/okstra_ctl/manager_view.py +2 -1
  136. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  137. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  138. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  139. package/runtime/python/okstra_ctl/models.py +1 -1
  140. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  141. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  142. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  143. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  144. package/runtime/python/okstra_ctl/paths.py +3 -0
  145. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  146. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  147. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  148. package/runtime/python/okstra_ctl/render.py +50 -0
  149. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  150. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  151. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  152. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  153. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  154. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  155. package/runtime/python/okstra_ctl/rollup.py +3 -1
  156. package/runtime/python/okstra_ctl/run.py +76 -29
  157. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  158. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  159. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  160. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  161. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  162. package/runtime/python/okstra_ctl/team.py +22 -13
  163. package/runtime/python/okstra_ctl/time_report.py +2 -1
  164. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  165. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  166. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  167. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  168. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  169. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  170. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  171. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  172. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  173. package/runtime/python/okstra_ctl/workflow.py +1 -1
  174. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  175. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  176. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  177. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  178. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  179. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  180. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  181. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  182. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  183. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  184. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  185. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  186. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  187. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  188. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  189. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  190. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  191. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  192. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  193. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  194. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  195. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  196. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  197. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  198. package/runtime/skills/okstra-manager/SKILL.md +18 -2
  199. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  200. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  201. package/runtime/skills/okstra-run/SKILL.md +31 -12
  202. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  203. package/runtime/skills/okstra-setup/SKILL.md +12 -10
  204. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  205. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  206. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  207. package/runtime/templates/manager/view.template.html +1 -0
  208. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  209. package/runtime/templates/reports/brief.template.md +14 -4
  210. package/runtime/templates/reports/html/i18n/en.json +5 -4
  211. package/runtime/templates/reports/html/i18n/ko.json +5 -4
  212. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  213. package/runtime/templates/reports/i18n/en.json +1 -1
  214. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  215. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  216. package/runtime/templates/translator-prompt-preamble.md +36 -0
  217. package/runtime/validators/checks/validate-assets-01.py +7 -8
  218. package/runtime/validators/validate-brief.py +70 -0
  219. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  220. package/runtime/validators/validate-run.py +59 -9
  221. package/runtime/validators/validate-schedule.py +9 -0
  222. package/docs/for-ai/README.md +0 -68
  223. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  224. package/docs/for-ai/skills/okstra-chat.md +0 -34
  225. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  226. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  227. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  228. package/docs/for-ai/skills/okstra-manager.md +0 -86
  229. package/docs/for-ai/skills/okstra-memory.md +0 -126
  230. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  231. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  232. package/docs/for-ai/skills/okstra-run.md +0 -250
  233. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  234. package/docs/for-ai/skills/okstra-setup.md +0 -167
  235. package/docs/for-ai/skills/okstra-usage.md +0 -29
  236. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  237. package/runtime/agents/workers/claude-worker.md +0 -128
  238. package/runtime/agents/workers/report-writer-worker.md +0 -37
  239. package/runtime/agents/workers/translator-worker.md +0 -63
  240. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  241. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  242. package/runtime/prompts/duties/analysis-worker.md +0 -44
  243. package/runtime/prompts/duties/code-reviewer.md +0 -44
  244. package/runtime/prompts/duties/common.md +0 -39
  245. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  246. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  247. package/runtime/prompts/duties/discovery-worker.md +0 -44
  248. package/runtime/prompts/duties/implementation-executor.md +0 -44
  249. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  250. package/runtime/prompts/duties/lead.md +0 -44
  251. package/runtime/prompts/duties/planning-worker.md +0 -52
  252. package/runtime/prompts/duties/report-writer.md +0 -44
  253. package/runtime/prompts/duties/reverification-worker.md +0 -44
  254. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  255. package/runtime/prompts/duties/scope-critic.md +0 -44
  256. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  257. package/runtime/prompts/duties/translator.md +0 -44
  258. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -0,0 +1,156 @@
1
+ """진행 중인 run 의 동결 계약을 현재 형식으로 다시 동결한다.
2
+
3
+ run 은 시작할 때 직무·역할·공통 계약을 자기 폴더로 동결하고, 그 다이제스트를
4
+ 매니페스트에 적는다. 계약 형식이 바뀐 릴리스를 설치하면 그 run 의 다이제스트는
5
+ 새 규칙으로 계산한 값과 영영 다르고, 프롬프트를 하나도 더 만들 수 없다 — 실측
6
+ 2026-09-23(fontradar-v2-api dev-10860 implementation-planning-002): 같은 run 에서
7
+ 8회 성공한 뒤 설치가 한 번 일어나자 그다음 materialize 가
8
+ `run duty snapshot catalog digest does not match` 로 멈췄다.
9
+
10
+ 이 모듈은 그 run 을 현재 형식으로 다시 동결하고 매니페스트의 다이제스트와 형식
11
+ 표식을 갱신한다. 계약 본문은 런타임이 소유하므로 새로 쓰는 것이 원래 값이고,
12
+ run 의 증거(프롬프트·결과·원장)는 건드리지 않는다. 사용자가 명시적으로 부를 때만
13
+ 도는 복구 경로다.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Mapping
23
+
24
+ from .agent.invocation import digest_duty_catalog
25
+ from .json_boundary import (
26
+ JsonBoundaryError,
27
+ load_owned_object,
28
+ write_owned_object_atomic,
29
+ )
30
+ from .run import (
31
+ CONTRACT_FORMAT_VERSION,
32
+ PrepareError,
33
+ _common_contract_source,
34
+ _duty_catalog_source,
35
+ )
36
+
37
+
38
+ class ContractRefreezeError(RuntimeError):
39
+ """다시 동결할 수 없는 run. 메시지가 그 자리를 이름한다."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RefreezeResult:
44
+ duty_root: str
45
+ previous_digest: str
46
+ digest: str
47
+ previous_format_version: int
48
+ format_version: int
49
+ changed: bool
50
+
51
+
52
+ def frozen_format_version(contract: Mapping[str, Any]) -> int:
53
+ """이 run 이 동결한 계약 형식. 표식이 없는 run 은 1(Markdown 직무)이다."""
54
+ value = contract.get("contractFormatVersion")
55
+ return value if isinstance(value, int) and not isinstance(value, bool) else 1
56
+
57
+
58
+ def refreeze_message(duty_root: str) -> str:
59
+ """형식이 어긋난 run 이 받아야 할 문장 — 원인과 다음 명령."""
60
+ return (
61
+ "run froze an older duty contract format and this install cannot read "
62
+ f"it: {duty_root}. Re-freeze this run's contracts with "
63
+ "`okstra agent-prompt refreeze-contracts --project-root <dir> "
64
+ "--run-manifest <path>`, then repeat the command that failed."
65
+ )
66
+
67
+
68
+ def refreeze_run_contracts(
69
+ *, project_root: Path, run_manifest_path: Path, workspace_root: Path,
70
+ ) -> RefreezeResult:
71
+ """이 run 의 계약 폴더를 현재 런타임 카탈로그로 갈아 끼운다."""
72
+ root = project_root.resolve()
73
+ manifest_path = _resolve(root, run_manifest_path)
74
+ manifest = _load(manifest_path, "run manifest")
75
+ contract = manifest.get("agentContract")
76
+ if not isinstance(contract, dict):
77
+ raise ContractRefreezeError(f"run manifest has no agentContract: {manifest_path}")
78
+ duty_root_rel = str(contract.get("dutyRootPath") or "")
79
+ if not duty_root_rel:
80
+ raise ContractRefreezeError(f"agentContract has no dutyRootPath: {manifest_path}")
81
+ duty_root = _resolve(root, Path(duty_root_rel))
82
+ previous_digest = str(contract.get("catalogDigest") or "")
83
+ previous_format = frozen_format_version(contract)
84
+ try:
85
+ source = _duty_catalog_source(workspace_root)
86
+ common_source = _common_contract_source(workspace_root)
87
+ except PrepareError as exc:
88
+ raise ContractRefreezeError(str(exc)) from exc
89
+ digest = _replace_snapshot(duty_root, source, common_source)
90
+ changed = (
91
+ digest != previous_digest or previous_format != CONTRACT_FORMAT_VERSION
92
+ )
93
+ if changed:
94
+ contract["catalogDigest"] = digest
95
+ contract["contractFormatVersion"] = CONTRACT_FORMAT_VERSION
96
+ _write_manifest(manifest_path, manifest)
97
+ return RefreezeResult(
98
+ duty_root=str(duty_root),
99
+ previous_digest=previous_digest,
100
+ digest=digest,
101
+ previous_format_version=previous_format,
102
+ format_version=CONTRACT_FORMAT_VERSION,
103
+ changed=changed,
104
+ )
105
+
106
+
107
+ def _replace_snapshot(duty_root: Path, source: Path, common_source: Path) -> str:
108
+ """계약 폴더를 통째로 새로 쓴다. 옛 형식 파일은 남기지 않는다.
109
+
110
+ 옛 파일을 남기면 두 형식이 한 폴더에 섞이고, 다이제스트가 세는 대상과 로더가
111
+ 읽는 대상이 다시 갈린다 — 이 결함이 처음 생긴 자리가 그 갈림이다.
112
+ """
113
+ duty_root.parent.mkdir(parents=True, exist_ok=True)
114
+ staged = Path(tempfile.mkdtemp(
115
+ dir=duty_root.parent, prefix=f".{duty_root.name}.", suffix=".refreeze",
116
+ ))
117
+ retired = duty_root.with_name(f".{duty_root.name}.retired")
118
+ try:
119
+ shutil.copytree(source, staged, dirs_exist_ok=True)
120
+ shutil.copyfile(common_source, staged / "common.json")
121
+ shutil.copytree(
122
+ common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
123
+ )
124
+ digest = digest_duty_catalog(staged)
125
+ if duty_root.exists():
126
+ shutil.rmtree(retired, ignore_errors=True)
127
+ os.rename(duty_root, retired)
128
+ os.rename(staged, duty_root)
129
+ except OSError as exc:
130
+ raise ContractRefreezeError(f"cannot re-freeze duty contracts: {exc}") from exc
131
+ finally:
132
+ shutil.rmtree(staged, ignore_errors=True)
133
+ shutil.rmtree(retired, ignore_errors=True)
134
+ return digest
135
+
136
+
137
+ def _resolve(project_root: Path, value: Path) -> Path:
138
+ path = Path(value).expanduser()
139
+ return path if path.is_absolute() else project_root / path
140
+
141
+
142
+ def _load(path: Path, label: str) -> dict[str, Any]:
143
+ try:
144
+ payload = load_owned_object(path, artifact=label)
145
+ except (OSError, JsonBoundaryError) as exc:
146
+ raise ContractRefreezeError(f"cannot read {label} {path}: {exc}") from exc
147
+ if not isinstance(payload, dict):
148
+ raise ContractRefreezeError(f"{label} is not an object: {path}")
149
+ return payload
150
+
151
+
152
+ def _write_manifest(path: Path, manifest: Mapping[str, Any]) -> None:
153
+ try:
154
+ write_owned_object_atomic(path, manifest, artifact="run manifest")
155
+ except JsonBoundaryError as exc:
156
+ raise ContractRefreezeError(f"cannot write run manifest {path}: {exc}") from exc
@@ -154,7 +154,13 @@ def worker_result_paths(
154
154
  if candidate.resolve().parent != results_dir.resolve():
155
155
  raise ValueError(f"accepted resultPath is outside worker-results: {candidate}")
156
156
  if not candidate.is_file():
157
- raise ValueError(f"accepted resultPath is missing: {candidate}")
157
+ # 이 모듈의 계약은 "없는 워커 결과는 조용히 건너뛴다" 이고, 그 판정은
158
+ # 아래 `read_canonical_worker_result` 가 None 을 돌려주는 것으로
159
+ # 이루어진다. 여기서 던지면 판정이 아니라 run 이 죽는다 — 실측
160
+ # 2026-09-23(jobs dev-10860 stage 1): 옛 run 의 원장이 ok 로 적은
161
+ # 결과 파일이 디스크에 없어 `report-finalize --only validate-run` 이
162
+ # 예외로 멈췄다. 파일명 규칙 경로를 그대로 두고 넘어간다.
163
+ continue
158
164
  paths[row["workerId"]] = candidate
159
165
  return paths
160
166
 
@@ -20,6 +20,7 @@ from fnmatch import fnmatchcase
20
20
  from pathlib import Path
21
21
  from typing import Any, Iterator
22
22
 
23
+ from .consumers import read_stage_consumer_state
23
24
  from .final_report_schema import load_schema, validate as validate_schema
24
25
  from .json_boundary import JsonBoundaryError, load_owned_object
25
26
  from okstra_project import ResolverError, resolve_project_root
@@ -466,6 +467,7 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
466
467
  # 충돌은 한 항목씩 알리지 않는다. 요청 12개가 모두 옛 리포트의 파일과 부딪히면
467
468
  # 첫 건에서 멈추는 구현은 사용자에게 12회 왕복을 시키고, 그때마다 이미 쓴
468
469
  # 앞쪽 파일이 남아 상태가 절반만 진행된다. 전부 렌더해 전부 검사한 뒤 쓴다.
470
+ done = _done_stages(data_path)
469
471
  rendered = [
470
472
  _render_request(
471
473
  data_path=data_path,
@@ -474,7 +476,7 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
474
476
  item=item,
475
477
  )
476
478
  for item in items
477
- if item.get("status") in _REQUEST_STATUSES
479
+ if item.get("status") in _REQUEST_STATUSES and not _is_implemented(item, done)
478
480
  ]
479
481
  conflicts = [
480
482
  reason
@@ -494,6 +496,37 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
494
496
  return written
495
497
 
496
498
 
499
+ def _done_stages(data_path: Path) -> frozenset[int]:
500
+ """이 태스크에서 이미 구현이 끝난 stage.
501
+
502
+ 원장을 못 읽으면 빈 집합이다 — 그때의 동작은 이 판정이 없던 때와 같고,
503
+ "모르니까 건너뛴다" 는 반대 방향의 실패보다 안전하다.
504
+ """
505
+ try:
506
+ state = read_stage_consumer_state(data_path.parent.parent)
507
+ except (OSError, UnicodeError, ValueError):
508
+ return frozenset()
509
+ return frozenset(state.done_stages)
510
+
511
+
512
+ def _is_implemented(item: Mapping[str, Any], done: frozenset[int]) -> bool:
513
+ """이 항목이 가리키는 stage 가 전부 구현 완료인가.
514
+
515
+ 구현이 끝난 stage 의 계획은 역사이지 계획이 아니다. 그런데 탐지기는 매
516
+ 계획 run 마다 이월된 stage 본문을 다시 훑고, 그 사이 선택 방향의
517
+ `fileStructure` 가 바뀌면 같은 PREP 항목이 다른 증거로 렌더된다. 요청
518
+ 파일은 태스크 단위 이름이라 불변 파일과 충돌하고, report-finalize 는
519
+ 빠져나갈 길 없이 멈춘다(2026-09-24, dev-10860: 9건). 이미 지어진 것에
520
+ 설계 준비를 다시 요구하는 것이므로, 답도 쓸모가 없다.
521
+ """
522
+ stages = [
523
+ stage
524
+ for stage in item.get("stageRefs") or ()
525
+ if isinstance(stage, int) and not isinstance(stage, bool)
526
+ ]
527
+ return bool(stages) and all(stage in done for stage in stages)
528
+
529
+
497
530
  def load_design_prep_items(report_path: Path) -> tuple[dict[str, Any], ...]:
498
531
  """Return validated snapshot items without exposing mutable report data."""
499
532
  data_path = _canonical_data_path(report_path)
@@ -96,6 +96,7 @@ from .initial_prompt_materialization import (
96
96
  InitialPromptMaterializationRequest,
97
97
  InitialPromptWorkerRequest,
98
98
  PromptDeliveryMode,
99
+ declared_delivery_mode,
99
100
  materialize_initial_prompts,
100
101
  )
101
102
  from .agent.invocation import AgentInvocationError, materialize_retry_invocation
@@ -183,6 +184,9 @@ def revalidate_role_execution(
183
184
  interaction_surface="role-execution-dispatch",
184
185
  ),
185
186
  environment=host,
187
+ # 이 run 은 이 모델을 이미 골랐다. 지금 고를 수 있는지가 아니라 그
188
+ # 배정이 그대로 실행 가능한지를 묻는 자리다.
189
+ require_selectable=False,
186
190
  )
187
191
  binding = assignment.binding
188
192
  if binding is None:
@@ -261,6 +265,7 @@ class WorkerHandle:
261
265
  completed_process: subprocess.CompletedProcess[str] | None
262
266
  status_sidecar_path: Path | None
263
267
  degraded_from: str
268
+ degraded_reason: str = ""
264
269
  running_process: subprocess.Popen[str] | None = None
265
270
  mutation_snapshot: MutationSnapshot | None = None
266
271
  mutation_policies: tuple[WritePolicy, ...] = ()
@@ -1439,31 +1444,11 @@ def _invocation_job_fields(
1439
1444
  def _initial_prompt_delivery_mode(
1440
1445
  manifest: Mapping[str, Any],
1441
1446
  ) -> PromptDeliveryMode:
1442
- """이 run 의 어댑터가 선언한 초기 프롬프트 배송 방식.
1443
-
1444
- 선언은 `leadAdapter.initialPromptDeliveryMode` 에 있고, 그 값은 render 가
1445
- 호스트 descriptor(어댑터가 워커 디스패치를 가져갔으면 그 어댑터)에서
1446
- 옮겨 적은 것이다. 선언이 없는 매니페스트는 이 필드가 생기기 전에 준비된
1447
- run 이므로 그때의 동작인 `eager-include` 로 간다. 아는 값이 아니면 멈춘다 —
1448
- 프롬프트를 발행하고 나면 그 프롬프트는 불변이라, 잘못 고른 배송 방식은
1449
- 나중에 고칠 수 없다.
1450
- """
1451
- adapter = manifest.get("leadAdapter")
1452
- declared = (
1453
- _string_value(adapter.get("initialPromptDeliveryMode"))
1454
- if isinstance(adapter, Mapping)
1455
- else ""
1456
- )
1457
- if not declared:
1458
- return PromptDeliveryMode.EAGER_INCLUDE
1447
+ """이 run 의 초기 프롬프트 배송 방식. 판정은 `declared_delivery_mode` 가 한다."""
1459
1448
  try:
1460
- return PromptDeliveryMode(declared)
1461
- except ValueError:
1462
- allowed = ", ".join(mode.value for mode in PromptDeliveryMode)
1463
- raise DispatchError(
1464
- "leadAdapter.initialPromptDeliveryMode is not a delivery mode: "
1465
- f"{declared!r} (allowed: {allowed})"
1466
- ) from None
1449
+ return declared_delivery_mode(manifest)
1450
+ except InitialPromptMaterializationError as exc:
1451
+ raise DispatchError(str(exc)) from None
1467
1452
 
1468
1453
 
1469
1454
  def _materialize_roster_prompts(
@@ -2034,8 +2019,20 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
2034
2019
  raise DispatchError(str(exc)) from exc
2035
2020
  degraded_from = job.backend if runtime.surface != job.backend else ""
2036
2021
  settled = replace(job, backend=runtime.surface) if degraded_from else job
2022
+ if degraded_from:
2023
+ print(
2024
+ f"DEGRADED {job.role}: {degraded_from} -> {runtime.surface} "
2025
+ f"({runtime.fallback_reason or 'no reason reported'})",
2026
+ flush=True,
2027
+ )
2037
2028
  completed = None
2038
- if runtime.waitable:
2029
+ # 강등된 job 은 여기서 기다리지 않는다. `dispatch_plan` 은 wait=True 에서
2030
+ # pane job 을 거절하므로(같은 함수의 pane_backends 검사) 강등은 비차단 배치
2031
+ # 에서만 일어난다. 그 배치에서 기다리면 라운드가 직렬화되고 — 실측 2026-09-21,
2032
+ # 워커 3명이 병렬 대신 37분 — dispatch 행이 워커가 끝난 뒤에야 써져
2033
+ # `okstra worker-liveness` 가 실행 중인 워커를 모른다. 정산은 상태 사이드카를
2034
+ # 읽는 `await_dispatches` 가 맡는다.
2035
+ if runtime.waitable and not degraded_from:
2039
2036
  from .adapters.runtime.assembly import port_for
2040
2037
  returncode = port_for(plan.runtime_chain, runtime.surface).wait(runtime)
2041
2038
  completed = subprocess.CompletedProcess(list(settled.command), returncode)
@@ -2046,6 +2043,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
2046
2043
  completed,
2047
2044
  status_path_for_prompt(settled.prompt_path),
2048
2045
  degraded_from,
2046
+ degraded_reason=runtime.fallback_reason,
2049
2047
  runtime_handle=runtime,
2050
2048
  started_at=started_at,
2051
2049
  )
@@ -2237,9 +2235,28 @@ def _retry_from_record(
2237
2235
  # 사유 한 줄에 실을 경로 개수. 전부 실으면 team-state 행이 부풀고, 하나도 안
2238
2236
  # 실으면 읽는 쪽이 다른 파일을 뒤져야 한다.
2239
2237
  _MUTATION_REASON_PATH_LIMIT = 5
2238
+ # 사유는 team-state 의 한 행이다. 래퍼 실패 문장은 길 수 있으므로 자른다.
2239
+ _MUTATION_REASON_FAILURE_LIMIT = 200
2240
+
2241
+
2242
+ def _wrapper_failure_text(outcome: WorkerOutcome | None) -> str:
2243
+ """워커 래퍼가 남긴 실패 문장. 없으면 빈 문자열.
2244
+
2245
+ `mutation-present-unresolved` 는 "시도가 실패했는데 소스가 바뀌어 있다" 는
2246
+ 뜻이고, 왜 실패했는지는 상태 사이드카에만 있었다. 읽는 쪽은 상태 이름만 보고
2247
+ 소스를 뒤져야 했다 — 실측 2026-09-23: 두 세션이 같은 자리에서 그렇게
2248
+ 진단했고, 원인은 카탈로그에 없는 served 모델이었다.
2249
+ """
2250
+ if outcome is None or outcome.status_sidecar_path is None:
2251
+ return ""
2252
+ status = read_wrapper_status(outcome.status_sidecar_path)
2253
+ if status is None:
2254
+ return ""
2255
+ failure = _string_value(status.raw.get("failure"))
2256
+ return failure[:_MUTATION_REASON_FAILURE_LIMIT]
2240
2257
 
2241
2258
 
2242
- def _mutation_failure_reason(mutation) -> str:
2259
+ def _mutation_failure_reason(mutation, outcome: WorkerOutcome | None = None) -> str:
2243
2260
  """왜 실패했는지와 무엇이 바뀌었는지를 한 줄에 담는다.
2244
2261
 
2245
2262
  종전에는 디스패치 행에 `readonly source changed` 만 남고 바뀐 경로는
@@ -2249,6 +2266,9 @@ def _mutation_failure_reason(mutation) -> str:
2249
2266
  사유 옆에 놓는다.
2250
2267
  """
2251
2268
  reason = "; ".join(mutation.violations) or mutation.status
2269
+ failure = _wrapper_failure_text(outcome)
2270
+ if failure:
2271
+ reason = f"{reason}: {failure}"
2252
2272
  paths = list(mutation.changed_paths)
2253
2273
  if not paths:
2254
2274
  return reason
@@ -2273,7 +2293,7 @@ def _finish_attempt(
2273
2293
  "contract-failed-unattributed",
2274
2294
  "mutation-present-unresolved",
2275
2295
  }:
2276
- reason = _mutation_failure_reason(mutation)
2296
+ reason = _mutation_failure_reason(mutation, outcome)
2277
2297
  _update_dispatch_status(
2278
2298
  plan.team_state_path, job, attempt, "error", reason
2279
2299
  )
@@ -2913,6 +2933,7 @@ def _record_dispatch(
2913
2933
  handle.pane_id,
2914
2934
  handle.degraded_from,
2915
2935
  reason,
2936
+ degraded_reason=handle.degraded_reason,
2916
2937
  mutation_snapshot_path=(
2917
2938
  _mutation_snapshot_path(handle.job)
2918
2939
  if handle.mutation_snapshot is not None
@@ -2932,6 +2953,7 @@ def _dispatch_record(
2932
2953
  degraded_from: str,
2933
2954
  reason: str = "",
2934
2955
  *,
2956
+ degraded_reason: str = "",
2935
2957
  mutation_snapshot_path: Path | None = None,
2936
2958
  write_policy_digest: str = "",
2937
2959
  write_enforcement: WriteEnforcement | None = None,
@@ -2958,6 +2980,9 @@ def _dispatch_record(
2958
2980
  ),
2959
2981
  "statusSidecarPath": str(status_path_for_prompt(job.prompt_path)),
2960
2982
  "degradedFrom": degraded_from,
2983
+ # `reason` 은 이 행의 정산 사유라 마감과 teardown 이 덮어쓴다. 강등 사유는
2984
+ # 디스패치 시점의 사실이므로 자기 칸을 쓴다.
2985
+ "degradedReason": degraded_reason,
2961
2986
  "reason": reason,
2962
2987
  "modelExecutionValue": job.model_execution_value,
2963
2988
  "wrapperPath": str(job.wrapper_path),
@@ -3186,6 +3211,7 @@ def _event_details(job: WorkerJob, handle: WorkerHandle) -> dict[str, Any]:
3186
3211
  "role": job.role,
3187
3212
  "paneId": handle.pane_id,
3188
3213
  "degradedFrom": handle.degraded_from,
3214
+ "degradedReason": handle.degraded_reason,
3189
3215
  }
3190
3216
  return details
3191
3217
 
@@ -51,6 +51,11 @@ class HostDescriptor:
51
51
  session_accounting: str
52
52
  has_claude_session: bool
53
53
  relay_contract: str
54
+ # 이 호스트 세션 안에서 도는 워커(runner=native-session)가 읽어야 하는
55
+ # 계약 파일의 절대 경로. 호스트가 그런 계약을 선언하지 않으면 빈 문자열이다.
56
+ # 호스트 전역 발견 경로에 설치되지 않는다 — 디스패치 프롬프트가 경로로
57
+ # 전달하고, 워커가 프리앰블과 같은 방식으로 연다.
58
+ worker_session_contract: str = ""
54
59
  # 이 호스트가 초기 워커 프롬프트를 어떻게 싣겠다고 선언하는가. 값은
55
60
  # `relay.md` 의 `initialPromptDeliveryMode` 행과 같아야 한다 — 그 표를
56
61
  # 읽고 dispatch 를 판단하는 것은 리드이고, 실제로 프롬프트를 만드는 것은
@@ -31,9 +31,19 @@ class WorkerSpawnRequest:
31
31
 
32
32
  @dataclass(frozen=True)
33
33
  class RuntimeHandle:
34
+ """An opened surface, and why it is not the one that was planned.
35
+
36
+ `fallback_reason` is empty when the planned surface opened. It carries the
37
+ last `SurfaceUnavailable` message when an earlier surface in the chain was
38
+ skipped, because that message exists nowhere else: the chain swallows the
39
+ exception it recovers from, and a degraded dispatch used to be recorded
40
+ with an empty reason.
41
+ """
42
+
34
43
  surface: str
35
44
  identifier: str
36
45
  waitable: bool
46
+ fallback_reason: str = ""
37
47
 
38
48
 
39
49
  @dataclass(frozen=True)
@@ -1,8 +1,8 @@
1
1
  """Read-side aggregator for okstra-run error logs.
2
2
 
3
3
  task 의 모든 run errors-<type>-<seq>.jsonl 을 모아 phase / worker / errorType
4
- 별로 집계하고 timestamp 누적 .md 리포트로 렌더한다. task 산출물을 mutate 하지
5
- 않는다.
4
+ 별로 집계하고 timestamp 누적 .md 리포트로 렌더한다. 리포트 외에 쓰는 것은
5
+ task 해석 중 `find_task_root` 가 끝난 implementation 결과를 기록하는 경우뿐이다.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
@@ -236,7 +236,8 @@ _CLI_EPILOG = r"""Usage:
236
236
 
237
237
  Writes a timestamped markdown report under <task-root>/error-reports/ and
238
238
  prints a JSON summary (counts by errorType / source / phase / agent) to stdout.
239
- This is read-only against task state; it never mutates task artifacts.
239
+ Resolving a task may record a finished implementation outcome in its task
240
+ manifest, the task catalog, and consumers.jsonl; nothing else is written.
240
241
  """
241
242
 
242
243
 
@@ -213,7 +213,9 @@ def reserve_derived_role_invocation(
213
213
  raise ExecutionManifestError(
214
214
  f"source role execution is unknown: {source_role_execution_ref}"
215
215
  )
216
- derived, created = _derived_role_execution(manifest, source, role)
216
+ derived, created, derived_participant = _derived_role_execution(
217
+ manifest, source, role
218
+ )
217
219
  invocation = invocation_factory(derived)
218
220
  if not isinstance(invocation, Invocation):
219
221
  raise ExecutionManifestError("invocation factory must return Invocation")
@@ -244,14 +246,22 @@ def reserve_derived_role_invocation(
244
246
  _write_json_atomic(task_manifest_path, recovered_task_payload)
245
247
  return derived, existing_invocation
246
248
 
249
+ base = manifest
250
+ if created and derived_participant is not None:
251
+ base = _replace_manifest(
252
+ manifest,
253
+ participant_assignments=(
254
+ *manifest.participant_assignments,
255
+ derived_participant,
256
+ ),
257
+ )
247
258
  roles = (
248
- (*manifest.role_executions, derived)
249
- if created
250
- else manifest.role_executions
259
+ (*base.role_executions, derived) if created else base.role_executions
251
260
  )
252
- with_role = _replace_manifest(manifest, role_executions=roles)
261
+ with_role = _replace_manifest(base, role_executions=roles)
253
262
  if created:
254
- _validate_next_role_execution(manifest, derived)
263
+ # 새 참가자가 실린 뒤라야 참가자 참조 검사가 성립한다.
264
+ _validate_next_role_execution(base, derived)
255
265
  _validate_next_invocation(with_role, invocation)
256
266
  updated = _replace_manifest(
257
267
  with_role,
@@ -540,11 +550,13 @@ def _validate_roles(
540
550
  "sourceRoleExecutionRef must reference an earlier different "
541
551
  "role execution"
542
552
  )
543
- if source_role.get("participantRef") != participant_ref:
544
- raise ExecutionManifestError(
545
- "sourceRoleExecutionRef participant must match derived role "
546
- "participant"
547
- )
553
+ # 참가자는 더 이상 공유하지 않는다(ADR-0017). 파생을 파생이게 하는
554
+ # 것은 같은 모델로 같은 일을 다시 읽는다는 것이므로, 거기를 검사한다.
555
+ for key in ("provider", "modelRef", "modelId"):
556
+ if source_role.get(key) != row.get(key):
557
+ raise ExecutionManifestError(
558
+ f"derived role execution {key} must match its source"
559
+ )
548
560
  earlier_roles[role_ref] = row
549
561
  for role, values in ordinals.items():
550
562
  if sorted(values) != list(range(1, len(values) + 1)):
@@ -815,20 +827,29 @@ def _derived_role_execution(
815
827
  manifest: ExecutionManifest,
816
828
  source: RoleExecution,
817
829
  role: str,
818
- ) -> tuple[RoleExecution, bool]:
830
+ ) -> tuple[RoleExecution, bool, ParticipantAssignment | None]:
831
+ """The dynamic role slot derived from *source*, creating it when absent.
832
+
833
+ A created slot is its own participant. It copies the source's provider,
834
+ model and binding — that is what makes it the same model re-reading the
835
+ same work — but not the source's identity or session. Sharing the
836
+ participant made the derived slot indistinguishable from its source
837
+ wherever a participant is what gets attributed or resumed.
838
+ """
819
839
  if source.role == role:
820
- return source, False
840
+ return source, False, None
821
841
  existing = [
822
842
  item
823
843
  for item in manifest.role_executions
824
- if item.participant_ref == source.participant_ref and item.role == role
844
+ if item.source_role_execution_ref == source.role_execution_ref
845
+ and item.role == role
825
846
  ]
826
847
  if len(existing) > 1:
827
848
  raise ExecutionManifestError(
828
- f"participant has multiple {role} role executions: {source.participant_ref}"
849
+ f"source has multiple {role} role executions: {source.role_execution_ref}"
829
850
  )
830
851
  if existing:
831
- return existing[0], False
852
+ return existing[0], False, None
832
853
  ordinal = max(
833
854
  (item.ordinal for item in manifest.role_executions if item.role == role),
834
855
  default=0,
@@ -840,9 +861,10 @@ def _derived_role_execution(
840
861
  raise ExecutionManifestError(
841
862
  f"derived roleExecutionRef is already reserved: {role_ref}"
842
863
  )
864
+ participant = _derived_participant(manifest, source)
843
865
  return RoleExecution(
844
866
  role_execution_ref=role_ref,
845
- participant_ref=source.participant_ref,
867
+ participant_ref=participant.participant_ref,
846
868
  source_role_execution_ref=source.role_execution_ref,
847
869
  role=role,
848
870
  provider=source.provider,
@@ -859,7 +881,38 @@ def _derived_role_execution(
859
881
  binding=source.binding,
860
882
  served_model_attestation=ServedModelAttestation.unknown(),
861
883
  status="prepared",
862
- ), True
884
+ ), True, participant
885
+
886
+
887
+ def _derived_participant(
888
+ manifest: ExecutionManifest,
889
+ source: RoleExecution,
890
+ ) -> ParticipantAssignment:
891
+ """A fresh participant carrying the source's model but none of its session."""
892
+ origin = next(
893
+ (
894
+ item
895
+ for item in manifest.participant_assignments
896
+ if item.participant_ref == source.participant_ref
897
+ ),
898
+ None,
899
+ )
900
+ if origin is None:
901
+ raise ExecutionManifestError(
902
+ f"source role execution references unknown participant: "
903
+ f"{source.participant_ref}"
904
+ )
905
+ used = {item.participant_ref for item in manifest.participant_assignments}
906
+ ordinal = len(used) + 1
907
+ while f"participant-{ordinal:03d}" in used:
908
+ ordinal += 1
909
+ return replace(
910
+ origin,
911
+ participant_ref=f"participant-{ordinal:03d}",
912
+ session_ref=None,
913
+ window_ref=None,
914
+ status="prepared",
915
+ )
863
916
 
864
917
 
865
918
  def _validate_next_invocation(manifest: ExecutionManifest, row: Invocation) -> None: