okstra 0.201.2 → 0.201.3
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/docs/project-structure-overview.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +13 -1
- 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 +6 -0
- package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
- 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 +115 -46
- package/runtime/skills/okstra-run/SKILL.md +2 -0
- package/runtime/validators/validate-run.py +7 -15
package/bin/okstra
CHANGED
|
@@ -29,7 +29,7 @@ Current baseline:
|
|
|
29
29
|
- Python orchestration authority: `scripts/okstra_ctl/run.py::prepare_task_bundle`
|
|
30
30
|
- lifecycle: `requirements-discovery → error-analysis → implementation-option-selection → implementation-planning → implementation → final-verification → release-handoff`
|
|
31
31
|
- installed skills: 13
|
|
32
|
-
- provider workers: `claude`, `codex`, `antigravity`, `grok`, `kimi
|
|
32
|
+
- provider workers: `claude`, `codex`, `antigravity`, `grok`, `kimi`, `zai` (GLM via Claude Code); functional report writer: `report-writer`
|
|
33
33
|
- final report SSOT: `schemas/final-report-v2.0.schema.json` + `*.data.json`
|
|
34
34
|
|
|
35
35
|
Design principles:
|
|
@@ -224,7 +224,7 @@ Top-level scripts:
|
|
|
224
224
|
| File | Role |
|
|
225
225
|
|---|---|
|
|
226
226
|
| `okstra.sh` | Bash CLI wrapper around `prepare_task_bundle`, optionally launches `claude` |
|
|
227
|
-
| `okstra-{claude,codex,antigravity,grok,kimi}-exec.sh` | Worker CLI entrypoints — four lines each, `exec`ing `okstra-provider-exec.py` with the provider id. They hold no provider logic; adding a flag to one of these instead of to the provider adapter is exactly the drift this shape exists to prevent |
|
|
227
|
+
| `okstra-{claude,codex,antigravity,grok,kimi,zai}-exec.sh` | Worker CLI entrypoints — four lines each, `exec`ing `okstra-provider-exec.py` with the provider id (`zai` selects the Z.ai GLM provider, which reuses the installed `claude` executable with process-local connection settings). They hold no provider logic; adding a flag to one of these instead of to the provider adapter is exactly the drift this shape exists to prevent |
|
|
228
228
|
| `okstra-provider-exec.py` | The one worker entrypoint: parses the shared positional contract plus `--presentation`, resolves the provider's `ExecutionStrategy` from the registry, refuses a missing CLI before any artifact is written, then hands the run to `okstra_ctl.worker_runner` |
|
|
229
229
|
| `okstra-wrapper-status.py` | Standalone writer for one worker status sidecar. No longer on the dispatch path — `worker_runner.py` writes the same document in-process |
|
|
230
230
|
| `okstra-token-usage.py` | Token usage CLI entrypoint |
|
|
@@ -245,6 +245,8 @@ Important modules:
|
|
|
245
245
|
| `exact_coverage.py` | Shared pure calculator for requirement coverage and scope precision in option selection and selected-direction planning |
|
|
246
246
|
| `implementation_options.py` | Option-selection criteria, weighting, candidate fingerprint convergence, ranking, and semantic validation |
|
|
247
247
|
| `implementation_direction.py` | Selected report/response validation, direction snapshot materialization, and selected-direction reference validation |
|
|
248
|
+
| `technical_verification.py` | Optional `technical-verification` phase backend — resolves and freezes the explicitly classified unresolved facts from the same task's implementation-option-selection report into `state/technical-verification-input-<seq>.json` (`write_technical_verification_input` / `resolve_technical_verification_input`, driven from `run.py`). No selected direction is required and unresolved user decisions still block entry; it links test inputs to observed results and never produces adoption approval or changes candidate feasibility |
|
|
249
|
+
| `verification_target.py` | Shared reader for the prepared final-verification target snapshot (`verification-target.md`) written by `run.write_verification_target_snapshot`. One implementation of the digest/scope rule serves both consumers — report assembly (records `verificationScope`) and `validators/validate-run.py` (re-checks the published report against the target) — so the two cannot drift |
|
|
248
250
|
| `implementation_stage.py` | `implementation` single-stage run orchestration — read the Stage Lifecycle Snapshot → pick an available Stage Map entry → provision an isolated stage worktree → publish the selected stage as run context (extracted from `run.py`) |
|
|
249
251
|
| `stage_targets.py` | Stage readiness/verification policy SSOT — from the Stage Lifecycle Snapshot (`consumers.jsonl` ledger + carry sidecar backfill + active registry reservation) it decides which stage is runnable, which commit it branches from, and what final-verification checks. `acquire_final_verification_target()` acquires the ledger, registry, worktree, Git, and optional whole-task integration facts behind one task-key mutex and returns a typed target without render-context coupling. `order_stage_closure` topologically sorts (Kahn) the dependency closure of the wizard's multi-selected stage set to produce the unattended `chain-stages` chaining order |
|
|
250
252
|
| `stage_fix_carry.py` | fix-run carry derivation for a re-run on an `implementation` stage whose latest final-report data.json carries verifier `FAIL` verdicts — collects the previous report path, previous run HEAD, failed verifiers, carried blocking findings, and a routing recommendation, which `run.py` renders into the analysis profile through the `{{FIX_RUN_CONTEXT}}` token. A first run, or a re-run after `PASS`, yields no carry and renders the token empty |
|
|
@@ -300,7 +302,8 @@ Important modules:
|
|
|
300
302
|
| `wizard/confirmation.py` | `confirmation_block` and the confirm step |
|
|
301
303
|
| `wizard/registry.py` | the ordered `STEPS` literal (order is question order), `STEP_BY_ID`, `_reset_from`, and the steps that rewind the registry (edit target, brief carry) |
|
|
302
304
|
| `wizard/engine.py` | public API — `init_state`, `next_prompt`, `submit`, progress / simulation, host interaction payloads |
|
|
303
|
-
| `wizard/render.py` | `render_args`, `render_role_args`,
|
|
305
|
+
| `wizard/render.py` | `render_args`, `render_role_args`, and the stage-intent helpers |
|
|
306
|
+
| `wizard/outcome.py` | `wizard_outcome` — assembles the run argv, confirmation screen, and persistence actions from an approved `WizardState` (extracted from `render.py`) |
|
|
304
307
|
| `wizard/cli.py`, `wizard/__main__.py` | `okstra wizard` argparse entrypoint (`main`); `python3 -m okstra_ctl.wizard` |
|
|
305
308
|
| `wizard_stage_intent.py` | stage-related intent projection of the `okstra-run` wizard output — normalizes whole-task (`__whole_task__`) vs single/multi stage selection into render-args (`resolve_wizard_stage_intent`) |
|
|
306
309
|
| `index.py`, `jsonl.py`, `reconcile.py`, `listing.py`, `backfill.py` | `~/.okstra` run index and history operations — `record_start` (index.py) writes a run's start; the end is closed either by `settle_run_row` (reconcile.py, records a verdict the caller already knows — used by `validate-run.py`) or by `reconcile_home` (infers one from disk — used by `run._reconcile_prior_runs` as the backstop for runs that died before validation) |
|
|
@@ -392,7 +395,7 @@ Project resolver and read-only state helpers:
|
|
|
392
395
|
|
|
393
396
|
Token/cost accounting:
|
|
394
397
|
|
|
395
|
-
- provider adapters: `claude.py`, `codex.py`, `antigravity.py`, `grok.py` (`grok.py` reads the Grok Build session docs under `~/.grok/sessions/<percent-encoded-cwd>/`, taking cumulative tokens from the last `params.update.usage.modelUsage` snapshot in `updates.jsonl`)
|
|
398
|
+
- provider adapters: `claude.py`, `codex.py`, `antigravity.py`, `grok.py`, `kimi.py`, `zai/adapter.py` (`grok.py` reads the Grok Build session docs under `~/.grok/sessions/<percent-encoded-cwd>/`, taking cumulative tokens from the last `params.update.usage.modelUsage` snapshot in `updates.jsonl`; `zai/adapter.py` wraps the Claude Code executor for GLM workers — reads `ZAI_API_KEY` from the process env or the current user's `~/.env`, runs each child against `https://api.z.ai/api/anthropic` with `--setting-sources ""`, attests the served model only from response `message.model`, and prices usage at Z.ai's public API rates for `glm-5.3` / `glm-5.3-flash`)
|
|
396
399
|
- aggregation: `collect.py`, `blocks.py`, `jsonl_io.py`, `paths.py`
|
|
397
400
|
- incremental scan cache: `cursor.py` (`$OKSTRA_HOME/cache/token-usage/` byte cursor + usage event extracts; bypass with `--no-cache`)
|
|
398
401
|
- pricing: `pricing.py`
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -343,6 +343,8 @@ For `improvement-discovery`, Lead records `## Primary Pass Assignments` in the P
|
|
|
343
343
|
|
|
344
344
|
### Phase 4 / Phase 5 — Dispatch, await, and error-log recording
|
|
345
345
|
|
|
346
|
+
Before redispatch, inspect the invocation's last attempt in the run manifest. A running attempt is awaited; only `failed-no-mutation` can append another attempt within its retry budget. Other terminal states retain their original result and audit. For a legacy implementer rejected solely by `declared out-of-plan path did not change`, `okstra team dispatch` prepares a separate evidence-recovery invocation with the same assignment and model and distinct prompt/result paths. Collect that recovery result, then continue the selected independent verifier. Do not replay completed implementation or ask the user to reauthorize unchanged scope. For other terminal failures, use `okstra agent-prompt materialize` with a new `--invocation-id` and distinct `--prompt`/`--result` paths for the bounded verified defect or missing evidence. These retry restrictions are enforced by `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
|
|
347
|
+
|
|
346
348
|
For each selected worker assignment, persist the exact prompt history, emit the per-worker Phase 4 checkpoint, and call `dispatch_worker(assignment, promptPath)` through the selected adapter — the dispatch hands the worker the persisted prompt's path, never a re-inlined copy of its body. Then call `await_workers(handles)`. A dispatch acknowledgement or process/pane creation is never completion: verify the terminal status, Result Path, and worker-results audit path required by `team-contract` before emitting the Phase 5 collection checkpoint.
|
|
347
349
|
|
|
348
350
|
Retries and convergence re-verification always call `redispatch_worker` to create a fresh one-shot session. Never reuse a worker conversation or switch adapters/providers to hide a failed assignment.
|
|
@@ -357,7 +359,7 @@ The launch prompt's `## Run Logs (error-log wiring)` section gives Lead the reso
|
|
|
357
359
|
|
|
358
360
|
Workers are contractually required to extract this line and abort with `<WORKER>_ERRORS_PATH_MISSING` if it is absent (see each worker definition's "Path extraction (BLOCKING)" block). A worker records its tool failure through the typed `okstra error-log append-observed` form in that contract; it does not write an intermediate JSON file.
|
|
359
361
|
|
|
360
|
-
After each worker terminates,
|
|
362
|
+
After each worker terminates, verify the canonical result file at `**Result Path:**` and inspect the recorded mutation status. A missing result permits the same worker/prompt retry only for `failed-no-mutation` within its retry budget. A terminal state with changes or unresolved attribution requires a separate bounded corrective invocation, preserving existing work and evidence. Full rules: [team-contract](./team-contract.md) "Lead Redispatch Policy on Result-Missing".
|
|
361
363
|
|
|
362
364
|
`--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
|
|
363
365
|
|
|
@@ -568,3 +570,13 @@ The run-level error log lives at `<runDir>/logs/errors-<task-type>-<seq>.jsonl`.
|
|
|
568
570
|
| Re-sending a finding absent from the persisted round plan | Dispatch exactly the engine-returned `findingIds`; see [convergence](./convergence.md) "Re-verification Dispatch" |
|
|
569
571
|
| Aggregating a `timeout`/`error` reverify dispatch as `DISAGREE` | Put the terminal outcome in round results; `apply-round` records `verification-error`. See [convergence](./convergence.md) "Worker failure handling in reverify" |
|
|
570
572
|
| Bypassing `report-finalize` and running its Phase 7 steps manually | Run `okstra report-finalize ...`; it owns token substitution and the remaining persistence order. |
|
|
573
|
+
|
|
574
|
+
## Recoverable completion discrepancies
|
|
575
|
+
|
|
576
|
+
Treat `plannedPaths` as an expected change inventory, not an exhaustive authorization list. `ExecutionMutationAudit.compare` enforces write authority and emits plan discrepancies as warnings; `dispatch_state.missing_completion_paths` checks required result availability and usability. Collect usable results with warnings and pass the observed changes and unresolved evidence to independent verification before declaring task completion.
|
|
577
|
+
|
|
578
|
+
Preserve original results and attempt identities when correcting declaration formatting, missing rationale, or required artifacts. Continue the smallest affected repair within the approved objectives. Do not restart implementation or request renewed permission solely for a plan-path mismatch. If a required artifact is unusable, repair that artifact and keep only its dependent steps waiting. If the same repair produces no new evidence, report the unresolved requirement and resume point instead of repeating the whole implementation.
|
|
579
|
+
|
|
580
|
+
Ask for user judgment only when the proposed work changes approved objectives or acceptance conditions, crosses an explicit authority boundary, or adds an unapproved external action. A warning does not waive read-only roles, protected paths, independent verification, or final acceptance checks. Never convert an old closed failed attempt into success by overwriting its audit; use a recorded recovery with independent evidence.
|
|
581
|
+
|
|
582
|
+
For a closed attempt whose only failure was a declaration discrepancy, preserve that attempt and its original result. Use the existing `agent-prompt materialize` and dispatch workflow to create a bounded evidence-recovery invocation in the same run, with a distinct result path and explicit references to the original invocation, result, and audit. Ask it to reconcile the declaration and required artifacts against current evidence, not repeat completed implementation. Then dispatch the selected independent verifiers against the current source and artifacts. Adopt the new verified result through normal result collection; retain the original failure as history. Do not use this route to bypass a real authority violation or unresolved code defect.
|
|
@@ -167,6 +167,8 @@ Branch on the exit code, not the JSON: without `--wait`, `0` = every probe healt
|
|
|
167
167
|
|
|
168
168
|
## Lead Redispatch Policy on Result-Missing
|
|
169
169
|
|
|
170
|
+
For V2 calls, check the recorded attempt before applying any retry below. Only `failed-no-mutation` may append an attempt within its budget. Await an unfinished attempt. Preserve other terminal attempts and use a separate bounded corrective invocation with the same assignment/model and distinct prompt/result paths; do not replay implementation that already changed source or artifacts. `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt` enforce this restriction. A legacy declaration-only implementer rejection is routed by `okstra team dispatch` to evidence recovery before independent verification.
|
|
171
|
+
|
|
170
172
|
After each worker attempt returns (regardless of role), Lead MUST verify the canonical result file exists at the absolute path resolved from the `**Result Path:**` anchor header (against `**Project Root:**`). The check is identical for host-native workers and deterministic CLI processes.
|
|
171
173
|
|
|
172
174
|
**Triggers (any of):**
|
|
@@ -92,8 +92,8 @@ template's check; that template is gone.
|
|
|
92
92
|
|
|
93
93
|
## Allowed actions during the run
|
|
94
94
|
|
|
95
|
-
- **Edit / Write on approved project source files**: scope is bounded
|
|
96
|
-
-
|
|
95
|
+
- **Edit / Write on approved project source files**: scope is bounded by the shared Resource boundary and the approved objectives and acceptance conditions; the plan's file list predicts expected changes. Editing files outside the plan's list is permitted only when strictly needed to satisfy a step, and MUST be recorded in your worker result's `Out-of-plan edits` block with rationale.
|
|
96
|
+
- Use `## Out-of-plan edits` with a backticked path and rationale per item when convenient. The dispatcher reads this shape as supporting evidence, not write authorization. `ExecutionMutationAudit.compare` records observed changes independently and treats missing declarations or declaration/diff mismatches as warnings. Do not rerun implementation merely to repair this block; reconcile the explanation while preserving the original result. Required result usability is checked by `dispatch_state.missing_completion_paths`; source-readonly, protected paths, assigned roots and Git authority remain enforced by the write audit.
|
|
97
97
|
- read-only inspection commands: `git status`, `git diff`, `git log`, `grep`, `rg`, `find`, `cat`, `ls`, file Read tools
|
|
98
98
|
- build, lint, type-check, and test commands (`npm test`, `pytest`, `go build`, `cargo test`, `bash -n`, etc.)
|
|
99
99
|
- **local git operations only**: `git add`, `git commit`. Prefer small commits keyed to plan steps.
|
|
@@ -256,3 +256,9 @@ If every verifier present in the resolved roster ends with a non-result terminal
|
|
|
256
256
|
## Executor completion self-check (not this role's gate)
|
|
257
257
|
|
|
258
258
|
- The executor's `Implementation self-check` gate (`prompts/profiles/_implementation-self-check.md`) belongs to the worker that owns the diff, and its body is deliberately not delivered here: it asks for in-place fixes and break-then-restore mutation checks, every one of which this verifier is forbidden to perform. Do not re-derive its items or claim to have run it. What grades the same defects from this side is the blocking taxonomy above, applied to the diff you re-read yourself. When the executor's `Coverage:` / `Self-check coverage:` lines are among the inputs this prompt enumerates, a missing line or one whose file list does not reconcile with the diff is a blocking finding — the gate was skipped or partially run.
|
|
259
|
+
|
|
260
|
+
## Plan differences and completion recovery
|
|
261
|
+
|
|
262
|
+
Review the observed source and artifact changes, including paths absent from the planned file list or the executor's declaration. Link necessary changes to the approved objectives and acceptance conditions. A file-list discrepancy, declaration spelling, or a restored file is not an implementation failure by itself. Record missing rationale as evidence to reconcile. Reject unrelated scope expansion or weakened acceptance checks on their substantive impact, with the affected requirement and evidence.
|
|
263
|
+
|
|
264
|
+
When the audit reports unavailable historical artifact coverage, verify the current artifact content and its acceptance checks independently. Do not reconstruct missing before-state from the current file or claim that a source-only diff proves an artifact did not change. Preserve the original result and qualify any unverified attribution. Required independent validation remains enforced by `validate-run.py::_validate_verifier_reran_independently` and the existing conformance gates.
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
|
@@ -44,6 +44,7 @@ class MutationSnapshot:
|
|
|
44
44
|
# 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
|
|
45
45
|
# 통째로 error 가 된다. 두 루트가 같으면 None.
|
|
46
46
|
artifact_git_head: str | None = None
|
|
47
|
+
artifact_observation_roots: tuple[str, ...] = ()
|
|
47
48
|
|
|
48
49
|
def to_payload(self) -> dict[str, Any]:
|
|
49
50
|
return {
|
|
@@ -57,6 +58,7 @@ class MutationSnapshot:
|
|
|
57
58
|
"digest": self.digest,
|
|
58
59
|
"orchestratorPaths": list(self.orchestrator_paths),
|
|
59
60
|
"artifactGitHead": self.artifact_git_head,
|
|
61
|
+
"artifactObservationRoots": list(self.artifact_observation_roots),
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
@classmethod
|
|
@@ -75,6 +77,7 @@ class MutationSnapshot:
|
|
|
75
77
|
str(payload["artifactGitHead"])
|
|
76
78
|
if payload.get("artifactGitHead") else None
|
|
77
79
|
),
|
|
80
|
+
artifact_observation_roots=tuple(payload.get("artifactObservationRoots", ())),
|
|
78
81
|
)
|
|
79
82
|
expected = _snapshot_digest(
|
|
80
83
|
snapshot.root,
|
|
@@ -85,6 +88,7 @@ class MutationSnapshot:
|
|
|
85
88
|
snapshot.scratch_digests,
|
|
86
89
|
snapshot.git_projection,
|
|
87
90
|
snapshot.orchestrator_paths,
|
|
91
|
+
snapshot.artifact_observation_roots,
|
|
88
92
|
)
|
|
89
93
|
if snapshot.digest != expected:
|
|
90
94
|
raise MutationAuditError("mutation snapshot digest does not match payload")
|
|
@@ -115,6 +119,9 @@ class MutationAuditResult:
|
|
|
115
119
|
# 에러 원장(contract-violation)이 따로 잡는다.
|
|
116
120
|
untracked_artifact_paths: tuple[str, ...] = ()
|
|
117
121
|
warnings: tuple[str, ...] = ()
|
|
122
|
+
changed_artifact_paths: tuple[str, ...] = ()
|
|
123
|
+
declared_out_of_plan_paths: tuple[str, ...] = ()
|
|
124
|
+
unobserved_artifact_paths: tuple[str, ...] = ()
|
|
118
125
|
|
|
119
126
|
def change_summary(self) -> dict[str, Any]:
|
|
120
127
|
return {
|
|
@@ -128,6 +135,9 @@ class MutationAuditResult:
|
|
|
128
135
|
"retryAllowed": self.retry_allowed,
|
|
129
136
|
"violations": list(self.violations),
|
|
130
137
|
"warnings": list(self.warnings),
|
|
138
|
+
"changedArtifactPaths": list(self.changed_artifact_paths),
|
|
139
|
+
"declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
|
|
140
|
+
"unobservedArtifactPaths": list(self.unobserved_artifact_paths),
|
|
131
141
|
"beforeDigest": self.before_digest,
|
|
132
142
|
"afterDigest": self.after_digest,
|
|
133
143
|
}
|
|
@@ -147,12 +157,14 @@ class ExecutionMutationAudit:
|
|
|
147
157
|
generated = _generated_paths(rows)
|
|
148
158
|
file_digests = source_content_snapshot(root, generated)
|
|
149
159
|
artifact_digests = (
|
|
150
|
-
file_digests
|
|
160
|
+
dict(file_digests)
|
|
151
161
|
if artifact_root == root
|
|
152
162
|
else _content_snapshot(
|
|
153
163
|
artifact_root, generated | _non_source_paths(artifact_root)
|
|
154
164
|
)
|
|
155
165
|
)
|
|
166
|
+
observation_roots = _artifact_observation_roots(rows)
|
|
167
|
+
artifact_digests.update(_observed_artifact_contents(artifact_root, observation_roots))
|
|
156
168
|
scratch_digests = _scratch_snapshot(rows)
|
|
157
169
|
git_projection = _git_projection(root)
|
|
158
170
|
orchestrator = tuple(
|
|
@@ -168,6 +180,7 @@ class ExecutionMutationAudit:
|
|
|
168
180
|
scratch_digests,
|
|
169
181
|
git_projection,
|
|
170
182
|
orchestrator,
|
|
183
|
+
observation_roots,
|
|
171
184
|
)
|
|
172
185
|
return MutationSnapshot(
|
|
173
186
|
root=root,
|
|
@@ -182,6 +195,7 @@ class ExecutionMutationAudit:
|
|
|
182
195
|
artifact_git_head=(
|
|
183
196
|
None if artifact_root == root else _git_head(artifact_root)
|
|
184
197
|
),
|
|
198
|
+
artifact_observation_roots=observation_roots,
|
|
185
199
|
)
|
|
186
200
|
|
|
187
201
|
def compare(
|
|
@@ -203,6 +217,13 @@ class ExecutionMutationAudit:
|
|
|
203
217
|
artifact_changed = _changed_keys(
|
|
204
218
|
before.artifact_digests, after.artifact_digests
|
|
205
219
|
)
|
|
220
|
+
unobserved = {
|
|
221
|
+
path for path in artifact_changed
|
|
222
|
+
if path not in before.artifact_digests
|
|
223
|
+
and any(_is_within(path, root) for root in after.artifact_observation_roots)
|
|
224
|
+
and not any(_is_within(path, root) for root in before.artifact_observation_roots)
|
|
225
|
+
}
|
|
226
|
+
artifact_changed -= unobserved
|
|
206
227
|
scratch_changed = _changed_keys(
|
|
207
228
|
before.scratch_digests, after.scratch_digests
|
|
208
229
|
)
|
|
@@ -218,22 +239,27 @@ class ExecutionMutationAudit:
|
|
|
218
239
|
after,
|
|
219
240
|
rows,
|
|
220
241
|
source_changes,
|
|
221
|
-
out_of_plan_edits,
|
|
222
242
|
)
|
|
223
243
|
artifact_failures, untracked_artifact_changes, switched = (
|
|
224
244
|
_artifact_policy_failures(before, rows, artifact_changed, after=after)
|
|
225
245
|
)
|
|
226
246
|
violations.extend(artifact_failures)
|
|
247
|
+
worker_artifact_changes = {
|
|
248
|
+
path for path in artifact_changed
|
|
249
|
+
if path not in untracked_artifact_changes and path not in switched
|
|
250
|
+
and not any(_is_relative_to(before.artifact_root / path, Path(item))
|
|
251
|
+
for item in before.orchestrator_paths)
|
|
252
|
+
}
|
|
227
253
|
status = _terminal_status(
|
|
228
254
|
rows,
|
|
229
|
-
source_changed=bool(source_changes),
|
|
255
|
+
source_changed=bool(source_changes or worker_artifact_changes),
|
|
230
256
|
git_changed=git_changed,
|
|
231
257
|
attempt_succeeded=attempt_succeeded,
|
|
232
258
|
result_present=result_present,
|
|
233
259
|
violations=violations,
|
|
234
260
|
)
|
|
235
261
|
retry_allowed = _retry_allowed(
|
|
236
|
-
status, bool(source_changes), git_changed, bool(scratch_changed)
|
|
262
|
+
status, bool(source_changes or worker_artifact_changes), git_changed, bool(scratch_changed)
|
|
237
263
|
)
|
|
238
264
|
return MutationAuditResult(
|
|
239
265
|
status=status,
|
|
@@ -249,10 +275,84 @@ class ExecutionMutationAudit:
|
|
|
249
275
|
git_projection=after.git_projection,
|
|
250
276
|
untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
|
|
251
277
|
warnings=_audit_warnings(untracked_artifact_changes)
|
|
252
|
-
+ _branch_switch_warnings(before, after, switched)
|
|
278
|
+
+ _branch_switch_warnings(before, after, switched)
|
|
279
|
+
+ _plan_change_warnings(rows, source_changes | untracked_changes,
|
|
280
|
+
artifact_changed, out_of_plan_edits, unobserved),
|
|
281
|
+
changed_artifact_paths=tuple(sorted(artifact_changed)),
|
|
282
|
+
declared_out_of_plan_paths=tuple(out_of_plan_edits),
|
|
283
|
+
unobserved_artifact_paths=tuple(sorted(unobserved)),
|
|
253
284
|
)
|
|
254
285
|
|
|
255
286
|
|
|
287
|
+
def _observed_artifact_contents(root: Path, observation_roots: Sequence[str]) -> dict[str, str]:
|
|
288
|
+
"""관측 경계의 링크 자체를 기록하고 링크 목적지는 탐색하지 않는다."""
|
|
289
|
+
artifact_digests: dict[str, str] = {}
|
|
290
|
+
for relative in observation_roots:
|
|
291
|
+
target = root / relative
|
|
292
|
+
link = next((parent for parent in (*target.parents, target)
|
|
293
|
+
if parent != root and _is_relative_to(parent, root)
|
|
294
|
+
and parent.is_symlink()), None)
|
|
295
|
+
if link is not None:
|
|
296
|
+
artifact_digests[link.relative_to(root).as_posix()] = _path_digest(link)
|
|
297
|
+
continue
|
|
298
|
+
if target.is_symlink() or target.is_file():
|
|
299
|
+
artifact_digests[relative] = _path_digest(target)
|
|
300
|
+
else:
|
|
301
|
+
artifact_digests.update({
|
|
302
|
+
f"{relative}/{key}": value
|
|
303
|
+
for key, value in _content_snapshot(target, frozenset()).items()
|
|
304
|
+
})
|
|
305
|
+
return artifact_digests
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _artifact_observation_roots(policies: Sequence[WritePolicy]) -> tuple[str, ...]:
|
|
309
|
+
"""현재 배정의 QA와 실행 산출물은 Git 무시 규칙과 무관하게 관측한다."""
|
|
310
|
+
roots: set[str] = set()
|
|
311
|
+
for path in _allowed_artifact_paths(policies):
|
|
312
|
+
parts = Path(path).parts
|
|
313
|
+
if len(parts) < 5 or parts[:2] != (".okstra", "tasks"):
|
|
314
|
+
continue
|
|
315
|
+
if parts[4] == "qa":
|
|
316
|
+
roots.add(Path(*parts[:5]).as_posix())
|
|
317
|
+
elif parts[4] == "runs":
|
|
318
|
+
boundary = next((i for i, part in enumerate(parts)
|
|
319
|
+
if part in {"worker-results", "prompts"}), len(parts) - 1)
|
|
320
|
+
roots.add(Path(*parts[:boundary]).as_posix())
|
|
321
|
+
else:
|
|
322
|
+
roots.add(path)
|
|
323
|
+
return tuple(sorted(roots))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _plan_change_warnings(
|
|
327
|
+
policies: Sequence[WritePolicy], source_changes: set[str],
|
|
328
|
+
artifact_changes: set[str], declared: Sequence[str], unobserved: set[str],
|
|
329
|
+
) -> tuple[str, ...]:
|
|
330
|
+
"""예상 목록과의 차이는 검토 자료이며 쓰기 권한 위반이 아니다."""
|
|
331
|
+
warnings: list[str] = []
|
|
332
|
+
for policy in policies:
|
|
333
|
+
if policy.source_mode != "project-mutation":
|
|
334
|
+
continue
|
|
335
|
+
planned = policy.source_policy.get("plannedPaths", ())
|
|
336
|
+
extra = sorted(path for path in source_changes
|
|
337
|
+
if not any(_is_within(path, item) for item in planned))
|
|
338
|
+
if extra:
|
|
339
|
+
warnings.append("source changes outside expected plan; review scope: " + ", ".join(extra))
|
|
340
|
+
observed = {Path(str(policy.source_policy["allowedRoot"])) / path
|
|
341
|
+
for policy in policies for path in source_changes}
|
|
342
|
+
observed.update(Path(str(policy.artifact_policy["allowedRoot"])) / path
|
|
343
|
+
for policy in policies for path in artifact_changes)
|
|
344
|
+
roots = {Path(str(policy.source_policy["allowedRoot"])) for policy in policies}
|
|
345
|
+
roots.update(Path(str(policy.artifact_policy["allowedRoot"])) for policy in policies)
|
|
346
|
+
missing = sorted(path for path in declared
|
|
347
|
+
if not any(root / path in observed for root in roots))
|
|
348
|
+
if missing:
|
|
349
|
+
warnings.append("declared changes not observed; reconcile evidence: " + ", ".join(missing))
|
|
350
|
+
if unobserved:
|
|
351
|
+
warnings.append(f"artifact baseline coverage unavailable ({len(unobserved)} paths); "
|
|
352
|
+
"independently verify current artifacts; see unobservedArtifactPaths")
|
|
353
|
+
return tuple(warnings)
|
|
354
|
+
|
|
355
|
+
|
|
256
356
|
def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
|
|
257
357
|
if not untracked_artifact_changes:
|
|
258
358
|
return ()
|
|
@@ -595,6 +695,7 @@ def _snapshot_digest(
|
|
|
595
695
|
scratch: Mapping[str, str],
|
|
596
696
|
git_projection: Mapping[str, Any],
|
|
597
697
|
orchestrator_paths: Sequence[str],
|
|
698
|
+
observation_roots: Sequence[str] = (),
|
|
598
699
|
) -> str:
|
|
599
700
|
encoded = json.dumps(
|
|
600
701
|
{
|
|
@@ -606,6 +707,7 @@ def _snapshot_digest(
|
|
|
606
707
|
"scratch": scratch,
|
|
607
708
|
"gitProjection": git_projection,
|
|
608
709
|
"orchestratorPaths": list(orchestrator_paths),
|
|
710
|
+
**({"artifactObservationRoots": list(observation_roots)} if observation_roots else {}),
|
|
609
711
|
},
|
|
610
712
|
sort_keys=True,
|
|
611
713
|
separators=(",", ":"),
|
|
@@ -680,7 +782,7 @@ def _artifact_policy_failures(
|
|
|
680
782
|
and not any(_is_within(path, item) for item in orchestrator)
|
|
681
783
|
}
|
|
682
784
|
if snapshot.artifact_root == snapshot.root:
|
|
683
|
-
|
|
785
|
+
unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
|
|
684
786
|
tracked = _tracked_paths(snapshot.artifact_root)
|
|
685
787
|
if tracked is None:
|
|
686
788
|
untracked_outside: set[str] = set()
|
|
@@ -761,7 +863,6 @@ def _policy_violations(
|
|
|
761
863
|
after: MutationSnapshot,
|
|
762
864
|
policies: tuple[WritePolicy, ...],
|
|
763
865
|
source_changes: set[str],
|
|
764
|
-
out_of_plan_edits: Sequence[str],
|
|
765
866
|
) -> list[str]:
|
|
766
867
|
if all(policy.source_mode == "source-readonly" for policy in policies):
|
|
767
868
|
failures = ["readonly source changed"] if source_changes else []
|
|
@@ -769,8 +870,8 @@ def _policy_violations(
|
|
|
769
870
|
failures.append("gitPolicy disabled but Git projection changed")
|
|
770
871
|
return failures
|
|
771
872
|
policy = next(row for row in policies if row.source_mode == "project-mutation")
|
|
772
|
-
failures = _source_policy_failures(policy, source_changes
|
|
773
|
-
failures.extend(_git_policy_failures(before, after, policy
|
|
873
|
+
failures = _source_policy_failures(policy, source_changes)
|
|
874
|
+
failures.extend(_git_policy_failures(before, after, policy))
|
|
774
875
|
return failures
|
|
775
876
|
|
|
776
877
|
|
|
@@ -797,37 +898,14 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
|
|
|
797
898
|
}
|
|
798
899
|
|
|
799
900
|
|
|
800
|
-
def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
|
|
801
|
-
"""옛 계획의 미선언 목록만 면제하고, 산출물 전용 계획의 빈 소스 목록은 집행한다.
|
|
802
|
-
|
|
803
|
-
선언 여부가 저장되기 전 정책은 기존의 빈 목록 해석을 유지한다.
|
|
804
|
-
새 정책은 경로 분류 후에도 선언 여부를 보존하므로 산출물만 남은 계획이
|
|
805
|
-
임의의 소스 변경이나 커밋을 허용하지 않는다.
|
|
806
|
-
"""
|
|
807
|
-
source = policy.source_policy
|
|
808
|
-
return (
|
|
809
|
-
source.get("mode") == "project-mutation"
|
|
810
|
-
and not source.get("plannedPaths")
|
|
811
|
-
and not source.get("plannedPathsDeclared", False)
|
|
812
|
-
)
|
|
813
|
-
|
|
814
|
-
|
|
815
901
|
def _source_policy_failures(
|
|
816
902
|
policy: WritePolicy,
|
|
817
903
|
changed: set[str],
|
|
818
|
-
out_of_plan_edits: Sequence[str],
|
|
819
904
|
) -> list[str]:
|
|
820
|
-
planned = set(policy.source_policy.get("plannedPaths", ()))
|
|
821
|
-
declared = set(out_of_plan_edits)
|
|
822
905
|
protected = set(policy.source_policy.get("protectedPaths", ()))
|
|
823
906
|
failures: list[str] = []
|
|
824
|
-
if
|
|
825
|
-
|
|
826
|
-
and not changed <= planned | declared
|
|
827
|
-
):
|
|
828
|
-
failures.append("source changes exceed planned and declared out-of-plan paths")
|
|
829
|
-
if not declared <= changed:
|
|
830
|
-
failures.append("declared out-of-plan path did not change")
|
|
907
|
+
if policy.source_mode == "source-readonly" and changed:
|
|
908
|
+
failures.append("readonly source changed")
|
|
831
909
|
if any(_is_within(path, item) for path in changed for item in protected):
|
|
832
910
|
failures.append("protected path changed")
|
|
833
911
|
return failures
|
|
@@ -837,8 +915,6 @@ def _git_policy_failures(
|
|
|
837
915
|
before: MutationSnapshot,
|
|
838
916
|
after: MutationSnapshot,
|
|
839
917
|
policy: WritePolicy,
|
|
840
|
-
changed: set[str],
|
|
841
|
-
out_of_plan_edits: Sequence[str],
|
|
842
918
|
) -> list[str]:
|
|
843
919
|
git = policy.git_policy
|
|
844
920
|
root = before.root
|
|
@@ -857,23 +933,16 @@ def _git_policy_failures(
|
|
|
857
933
|
failures.append("final Git index contains staged changes")
|
|
858
934
|
if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
|
|
859
935
|
failures.append("final HEAD is not a fast-forward descendant")
|
|
860
|
-
|
|
861
|
-
if _path_ledger_is_unenforceable(policy):
|
|
862
|
-
# The plan predates the declared path column, so there is no ledger
|
|
863
|
-
# anyone can be held to. Every other check above still applies; only
|
|
864
|
-
# the path comparison stands down.
|
|
865
|
-
return failures
|
|
936
|
+
protected = policy.source_policy.get("protectedPaths", ())
|
|
866
937
|
if any(
|
|
867
|
-
|
|
938
|
+
any(_is_within(path, item) for path in paths for item in protected)
|
|
868
939
|
for paths in _commit_paths_by_commit(
|
|
869
940
|
root,
|
|
870
941
|
str(git.get("expectedBaseCommit")),
|
|
871
942
|
str(after.git_projection.get("head")),
|
|
872
943
|
)
|
|
873
944
|
):
|
|
874
|
-
failures.append("commit chain changed paths
|
|
875
|
-
if not changed <= allowed:
|
|
876
|
-
failures.append("working tree changed paths outside source policy")
|
|
945
|
+
failures.append("commit chain changed protected paths")
|
|
877
946
|
return failures
|
|
878
947
|
|
|
879
948
|
|
|
@@ -244,6 +244,8 @@ Output: `{ok: true, outcome: {renderArgv: ["--lead-runtime", "...", ...], render
|
|
|
244
244
|
|
|
245
245
|
`userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. Include the translator's disclosed provider/model and report material when citing this record in host execution requests, including `report-finalize`. The record documents task scope; it does not grant host execution privileges.
|
|
246
246
|
|
|
247
|
+
Before retrying a worker in an existing run, inspect its last invocation attempt: await running work; append an attempt only after `failed-no-mutation` within the retry budget. Preserve every other terminal attempt. `okstra team dispatch` routes the legacy declaration-only implementer failure to a separate same-model evidence-recovery invocation; collect its result before independent verification. Other terminal failures need `okstra agent-prompt materialize` with a new `--invocation-id`, distinct `--prompt`/`--result` paths, and unchanged assignment/model for a bounded correction. Do not restart completed implementation or request renewed approval for unchanged scope. Enforcement: `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
|
|
248
|
+
|
|
247
249
|
Run every `outcome.persistActions[]` entry BEFORE `render-bundle`. The only supported action is:
|
|
248
250
|
|
|
249
251
|
```json
|
|
@@ -5443,19 +5443,8 @@ def _validate_plan_body_state_rounds(
|
|
|
5443
5443
|
)
|
|
5444
5444
|
|
|
5445
5445
|
|
|
5446
|
-
def
|
|
5447
|
-
"""
|
|
5448
|
-
|
|
5449
|
-
입력 템플릿의 §"Forbidden In This Run" 은 계획 목록 밖에서 건드린 파일을 전부
|
|
5450
|
-
`Out-of-plan edits` 에 적으라고 요구한다. 그 목록이 실제 diff 와 무관하면 기록은
|
|
5451
|
-
검토자에게 성실해 보이는 문자열일 뿐이다 — 실제로 바꾼 적 없는 파일이 적혀
|
|
5452
|
-
있으면 나머지 행도 믿을 근거가 없다.
|
|
5453
|
-
|
|
5454
|
-
계획 목록 자체와의 대조(계획 밖 파일이 여기 빠졌는지)는 하지 않는다. 그러려면
|
|
5455
|
-
`approvedPlanReference.planFile` 이 가리키는 다른 리포트를 열어 stage 범위까지
|
|
5456
|
-
풀어야 하고, 그 경로 해소를 검증할 실물 run 이 저장소에 없다. 여기서는 리포트
|
|
5457
|
-
안에서 닫히는 방향만 본다.
|
|
5458
|
-
"""
|
|
5446
|
+
def _warn_out_of_plan_edits_not_in_diff(data: dict, warnings: list[str]) -> None:
|
|
5447
|
+
"""소스 차이 목록은 별도 QA 산출물의 변경 여부를 증명하지 못한다."""
|
|
5459
5448
|
implementation = data.get("implementation")
|
|
5460
5449
|
if not isinstance(implementation, dict):
|
|
5461
5450
|
return
|
|
@@ -5470,7 +5459,7 @@ def _validate_out_of_plan_edits_are_real(data: dict, failures: list[str]) -> Non
|
|
|
5470
5459
|
continue
|
|
5471
5460
|
target = row.get("file")
|
|
5472
5461
|
if isinstance(target, str) and target and target not in changed:
|
|
5473
|
-
|
|
5462
|
+
warnings.append(
|
|
5474
5463
|
f"out-of-plan-edit: {row.get('id') or 'OOP-???'} 가 `{target}` 을 "
|
|
5475
5464
|
"계획 밖 편집으로 신고했지만 diffSummary 에 그 파일이 없다"
|
|
5476
5465
|
)
|
|
@@ -9755,7 +9744,10 @@ def main() -> int:
|
|
|
9755
9744
|
_validate_verifier_command_log_is_read_only(validation_data, failures)
|
|
9756
9745
|
_validate_verifier_reran_independently(validation_data, failures)
|
|
9757
9746
|
_validate_verifier_discrepancy_is_not_passed(validation_data, failures)
|
|
9758
|
-
|
|
9747
|
+
declaration_warnings: list[str] = []
|
|
9748
|
+
_warn_out_of_plan_edits_not_in_diff(validation_data, declaration_warnings)
|
|
9749
|
+
for warning in declaration_warnings:
|
|
9750
|
+
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
9759
9751
|
if task_type == "implementation-planning":
|
|
9760
9752
|
_validate_plan_body_state_file(
|
|
9761
9753
|
validation_data,
|