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.
- package/bin/okstra +1 -1
- package/dist/commands/lifecycle/setup.mjs +15 -0
- package/dist/commands/lifecycle/setup.mjs.map +1 -1
- package/dist/lib/citation-guidance.d.mts +21 -0
- package/dist/lib/citation-guidance.mjs +79 -0
- package/dist/lib/citation-guidance.mjs.map +1 -0
- package/docs/architecture/storage-model.md +4 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/for-ai/skills/okstra-manager.md +21 -4
- package/docs/for-ai/skills/okstra-setup.md +9 -0
- package/docs/project-structure-overview.md +11 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +15 -3
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
- package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
- package/runtime/python/okstra_ctl/dispatch_core.py +91 -17
- package/runtime/python/okstra_ctl/execution_identity.py +9 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +6 -2
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +134 -65
- package/runtime/python/okstra_ctl/manager_cli.py +85 -12
- package/runtime/python/okstra_ctl/manager_launch.py +40 -18
- package/runtime/python/okstra_ctl/manager_paths.py +8 -0
- package/runtime/python/okstra_ctl/manager_split.py +474 -0
- package/runtime/python/okstra_ctl/manager_store.py +121 -18
- package/runtime/python/okstra_ctl/manager_sync.py +33 -15
- package/runtime/python/okstra_ctl/manager_view.py +216 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
- package/runtime/python/okstra_ctl/qa_commands.py +15 -0
- package/runtime/python/okstra_ctl/report_finalize.py +13 -6
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
- package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
- package/runtime/python/okstra_ctl/verification_target.py +13 -2
- package/runtime/skills/okstra-manager/SKILL.md +53 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +9 -0
- package/runtime/templates/manager/view.template.html +108 -0
- package/runtime/templates/reports/html/i18n/en.json +2 -0
- package/runtime/templates/reports/html/i18n/ko.json +2 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
- package/runtime/validators/validate-brief.py +7 -2
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
148
|
-
|
|
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>"}`` —
|
|
313
|
+
"""``{workerId: "<invocationRef>#<attempt> <status>"}`` — 발견 attempt 가 전부
|
|
247
314
|
폐기돼 채택할 결과가 없는 분석 워커.
|
|
248
315
|
|
|
249
|
-
한 워커의
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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":
|
|
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
|
|
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
|
|