From 2d406426ebd5f3c8015f1c76425c922f4e119597 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 29 Jul 2026 13:43:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(agent-ops):=20PLAN=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 생성과 리뷰 후속 상태 기록 전에 동일한 디스패처 검증을 적용해 broad path가 다시 활성 작업을 차단하지 않도록 한다. --- agent-ops/skills/common/code-review/SKILL.md | 5 +- agent-ops/skills/common/plan/SKILL.md | 8 +- .../orchestrate-agent-task-loop/SKILL.md | 2 +- .../scripts/dispatch.py | 157 ++++++++++++++---- .../scripts/dispatcher_observation.py | 5 + .../tests/test_dispatch.py | 85 +++++++++- 6 files changed, 226 insertions(+), 36 deletions(-) diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md index 08c7c6d..058ecbb 100644 --- a/agent-ops/skills/common/code-review/SKILL.md +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -226,8 +226,9 @@ The follow-up handoff contains the selected `{task_name}`, the current plan's re - `prepare-follow-up` must return `status: routed`, the exact routed basenames, `prepared_plan`, `prepared_review`, `plan_number`, `current_plan_archive_name`, `current_plan_archive_number`, `current_review_archive_name`, `current_review_archive_number`, `plan_log_number`, `review_log_number`, and `gitignore_repair_needed`. It must have executed `finalize-task-routing` in `isolated-reassessment` mode. - Verify that the returned current archive names/numbers equal the values derived before preparation, and that `plan_log_number` / `review_log_number` are the post-archive counts embedded in the new review stub for its future archive. +- Materialize `prepared_plan` only as a temporary candidate outside the repository and run `python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --workspace --validate-plan `. Require exit code `0` before archiving either active file. The candidate must contain exactly one non-empty `Modified Files Summary` with only exact workspace files; globs, directories, workspace root, URLs, outside-workspace paths, malformed paths, and placeholders are invalid. Remove the temporary candidate after validation. - If preparation returns `needs_evidence`, collect all named new evidence and rerun after the input changes; never rerun with unchanged evidence. If the evidence cannot be obtained in the current scope, leave the verdict-appended pair in place and report the exact finalization blocker. -- If preparation returns `blocked`, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict. +- If preparation returns `blocked` or prepared PLAN validation fails, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict. After the required next state is prepared, archive is mandatory for `PASS`, `WARN`, and `FAIL`. Ensure `.gitignore` has the Agent-Ops managed gitignore block for task artifacts before writing `*.log` outputs. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. Apply the repair here when `prepare-follow-up` returned `gitignore_repair_needed: true`. @@ -259,6 +260,7 @@ For `WARN` or `FAIL`, materialize the next state prepared in Step 5 immediately - If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use only `milestone-lock`, contain every archived loop entry plus the exact linked decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`. - Otherwise write `prepared_plan` and `prepared_review` byte-for-byte to their routed basenames. Do not rerun, adjust, compare, or upgrade their lane/G after archive. - Verify the written follow-up pair contains the predicted archived plan/review paths in identical `Archive Evidence Snapshot` sections and contains no unresolved token from the review-stub template inventory. Unrelated braces in commands or code are allowed. +- Re-run `dispatch.py --workspace --validate-plan ` and require exit code `0` to confirm that the byte-for-byte materialized PLAN retained the validated write claim. - Do not adjust the prepared route after finalization. For a `local-fit` base, `review_rework_count >= 2` or `evidence_integrity_failure=true` must produce `recovery-boundary`; `capability-gap` and `grade-boundary` keep their own basis. If the task group is `m-` and the user-review gate triggered, report that the milestone task is blocked on user review; do not emit PASS completion metadata and do not call `update-roadmap`. @@ -318,6 +320,7 @@ Report Required/Suggested counts, archive names, the final task archive path for - PASS with `Roadmap Targets`: `complete.log` contains `Roadmap Completion` with Milestone path, Task ids, archived plan/review evidence, and verification evidence. - PASS without `Roadmap Targets`: `complete.log` omits `Roadmap Completion` and reported metadata says `roadmap-completion=none`. - WARN/FAIL without user-review gate: the plan skill was invoked for the exact task path with verified `review_rework_count` and `evidence_integrity_failure`, completed `finalize-task-routing`, and created new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` files matching the fresh routed output; no `complete.log`. +- WARN/FAIL prepared PLAN passed `dispatch.py --validate-plan` before active-pair archive and again after byte-for-byte materialization; invalid write claims leave the verdict-appended prior pair active. - WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair. - Follow-up plans and review stubs keep implementation agents limited to implementation/test/evidence and contain no implementation-owned user-review request section. - USER_REVIEW: `USER_REVIEW.md` exists from template, no active `PLAN-*.md` or `CODE_REVIEW-*.md` remains, and no `complete.log` was written. diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index b7eb8e4..00b8911 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -264,7 +264,12 @@ Required sections: - `Final Routing`: record `evaluation_mode`, finalizer, both targets' closure/grade/route, `large_indivisible_context`, positive loop-risk names/count, recovery signals, capability-gap evidence, and canonical filenames. Do not include or compare a previous loop's lane/G. - `Implementation Checklist`: a top-level checklist the implementing agent must follow while coding. Include one item per implementation/verification unit; if the roadmap feature Task has `검증:`, keep that verification in the same checklist item instead of making a separate completion-criteria item. Include one item for whole-plan intermediate/final verification only when it is not already covered by the feature items. Make the last item exactly `- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.` Copy this checklist into the review stub's `Implementation Checklist` section with the same item text and order. - One item per change: `### [TAG-1] Title`, `TAG-2`, etc. -- `Modified Files Summary`: table mapping files to item ids. +- `Modified Files Summary`: table mapping files to item ids. This section is the dispatcher's workspace write-claim source of truth. + - Include exactly one `## Modified Files Summary` section and at least one exact workspace file path. + - Use repository-relative or canonical absolute file paths. Never use a glob (`*`, `?`, `[]`), directory path, workspace root, URL, path outside the workspace, malformed path, or prose placeholder as a claim. + - Enumerate only implementer- or reviewer-owned workspace files, including the active review evidence file and deterministic workspace evidence artifacts. + - For generated verification artifacts, choose deterministic exact workspace filenames or write them under a task-specific temporary directory outside the repository. Never substitute a directory or glob claim for dynamic filenames. + - Before writing or returning a prepared pair, validate the rendered PLAN with `python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --workspace --validate-plan `. A prepared in-memory PLAN may be materialized only as a temporary candidate outside the repository for this validation. Require exit code `0`; on failure, do not write or return the pair. - `Final Verification`: runnable commands and expected outcome. Prefer commands from verified handoff facts when supplied; fill missing coverage from repository manifests, scripts, workflows, domain rules, and related tests, and record the source in `Analysis > Verification Context`. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. End this section with **"After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`."** Each plan item must include: @@ -337,6 +342,7 @@ Do not write or return a prepared pair when either routing target is not `routed ## Final Checklist - In `write` mode, the routed `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. In `prepare-follow-up` mode, neither routed file was written; both exact bodies and basenames were returned while the verdict-appended current pair remained active. +- The rendered PLAN passed `dispatch.py --validate-plan` before the pair was written or returned; its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim. - In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`. - Single-plan work stores active files directly under `agent-task/{task_group}/`. - Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`. diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md index 6840d11..638aa93 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md @@ -144,7 +144,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin - If existing `state.json` cannot be read or validated as a JSON object, block the dispatcher. Never replace it with empty state or reset the 10-attempt budget. Repair or explicitly handle it before rerunning. - When a new user turn arrives, continue tracking the same overall request unless it explicitly cancels the previous request. - Let the execution layer display `작업시작`, `자가검증시작`, `리뷰시작`, `리뷰재시도`, `Pi복구재시도`, `세션응답복구재시도`, `세션연결재시도`, `리뷰결과`, `작업대기`, `작업차단`, `디스패치추적대기`, and `작업완료` directly from dispatcher stdout. Never duplicate them in model-authored `commentary`. Use `commentary` only when an attention event actually requires caller reasoning or a user decision. Event silence never grants `final`; only the two permissions in the absolute-priority section do. -- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and a workspace-namespaced per-attempt process environment marker in the locator. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit. +- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and the per-attempt process environment marker in the locator; namespace that marker by workspace. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit. - Detect a local-model `repetition-loop` only when the same normalized chunk repeats three consecutive times with no new tool event or file/state change. Do not infer it from similarity or semantic duplication in `thinking_delta`/`text_delta`. This signal alone must not terminate the process, block the task, trigger recovery/retry, or escalate the model; keep observing for substantive progress or an actual terminal error. - Keep `provider-connection`, `provider-stream-disconnect`, `session-stall`, `generic-error`, `process-terminated`, context/quota/model errors, and review-control violations distinct, but make them share a budget of 10 consecutive automatic recovery failures for the same task stage. On the 10th failure, block that task and do not auto-resume after cooldown. Reset the stage counter after success. - Record an explicit terminal blocker when Pi self-check leaves implementation fields incomplete 10 consecutive times or official review makes no change 10 consecutive times. diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py index cb8d00a..7fa6010 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py @@ -49,6 +49,7 @@ observation = load_sibling_observation_module() SEP = observation.SEP banner = observation.banner attempt_event = observation.attempt_event +validation_claim = observation.validation_claim PLAN_RE = re.compile(r"^PLAN-(local|cloud)-G(0[1-9]|10)\.md$") REVIEW_RE = re.compile(r"^CODE_REVIEW-(local|cloud)-G(0[1-9]|10)\.md$") @@ -60,6 +61,11 @@ REVIEW_LOG_RE = re.compile( ) SUBTASK_RE = re.compile(r"^(?P\d{2})(?:\+(?P\d{2}(?:,\d{2})*))?_[a-z0-9_]+$") MODIFIED_FILES_HEADINGS = ("Modified Files Summary", "수정 파일 요약") +MODIFIED_FILES_HEADER_CELLS = frozenset({"file", "files", "path", "paths", "파일", "경로"}) +PLACEHOLDER_PATH_RE = re.compile( + r"(?:[<>{}]|\.\.\.|(?:^|[/_.-])(?:tbd|todo|placeholder)(?:$|[/_.-]))", + re.IGNORECASE, +) IMPLEMENTATION_CHECKLIST_HEADINGS = ("Implementation Checklist", "구현 체크리스트") # The canonical English and legacy Korean verdict contracts are paired: a # heading only accepts the verdict label of its own schema. Mixed pairs are not @@ -1088,52 +1094,115 @@ def parse_task_name(task_root: Path, directory: Path) -> str: return directory.relative_to(task_root).as_posix() -def extract_write_set(plan: Path | None, workspace: Path) -> tuple[set[str], bool]: - if plan is None or not plan.exists(): - return set(), False +def inspect_write_set( + plan: Path | None, + workspace: Path, +) -> tuple[set[str], list[str]]: + if plan is None: + return set(), ["PLAN 경로가 없다"] + if not plan.is_file(): + return set(), [f"PLAN 파일이 없다: {plan}"] workspace = workspace.resolve() - text = plan.read_text(encoding="utf-8", errors="replace") + try: + text = plan.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return set(), [f"PLAN 파일을 읽을 수 없다: {plan}: {exc}"] matches = [] for heading in MODIFIED_FILES_HEADINGS: pattern = rf"^##\s*{re.escape(heading)}[ \t]*$([\s\S]*?)(?=^##\s|\Z)" for m in re.finditer(pattern, text, re.MULTILINE): matches.append(m) + if not matches: + return set(), ["Modified Files Summary 섹션이 없다"] if len(matches) != 1: - return set(), False + return set(), [ + f"Modified Files Summary 섹션은 정확히 1개여야 한다: count={len(matches)}" + ] match = matches[0] result: set[str] = set() - invalid = False + diagnostics: list[str] = [] for line in match.group(1).splitlines(): if not line.lstrip().startswith("|"): continue cells = [cell.strip() for cell in line.strip().strip("|").split("|")] if not cells: continue - for value in re.findall(r"`([^`]+)`", cells[0]): + if all(set(cell) <= {":", "-"} for cell in cells): + continue + if cells[0].casefold() in MODIFIED_FILES_HEADER_CELLS: + continue + claims = re.findall(r"`([^`]+)`", cells[0]) + if not claims: + diagnostics.append( + f"정확한 backtick workspace 파일 경로가 없는 claim 행: {cells[0]}" + ) + continue + for value in claims: normalized = re.sub(r":\d+(?::\d+)?$", "", value.strip()) - if normalized and not normalized.startswith(("http://", "https://")): - if ( - any(character in normalized for character in "*?[]") - or normalized.endswith(("/", "\\")) - ): - invalid = True - continue - candidate = Path(normalized) + if not normalized: + diagnostics.append("빈 경로 claim은 허용되지 않는다") + continue + if PLACEHOLDER_PATH_RE.search(normalized): + diagnostics.append( + f"placeholder 또는 malformed path claim은 허용되지 않는다: {normalized}" + ) + continue + if normalized.startswith(("http://", "https://")): + diagnostics.append(f"URL claim은 허용되지 않는다: {normalized}") + continue + if "\\" in normalized: + diagnostics.append( + f"malformed path claim은 허용되지 않는다: {normalized}" + ) + continue + if any(character in normalized for character in "*?[]"): + diagnostics.append( + f"glob 또는 broad path claim은 허용되지 않는다: {normalized}" + ) + continue + if normalized.endswith(("/", "\\")): + diagnostics.append( + f"디렉터리 claim은 허용되지 않는다: {normalized}" + ) + continue + candidate = Path(normalized) + try: resolved = ( candidate.resolve() if candidate.is_absolute() else (workspace / candidate).resolve() ) - try: - resolved.relative_to(workspace) - except ValueError: - invalid = True - continue - if resolved == workspace or resolved.is_dir(): - invalid = True - continue - result.add(str(resolved)) - return result, bool(result) and not invalid + except (OSError, RuntimeError) as exc: + diagnostics.append( + f"경로를 canonicalize할 수 없다: {normalized}: {exc}" + ) + continue + try: + resolved.relative_to(workspace) + except ValueError: + diagnostics.append( + f"workspace 밖 claim은 허용되지 않는다: {normalized}" + ) + continue + if resolved == workspace: + diagnostics.append("workspace root claim은 허용되지 않는다") + continue + if resolved.is_dir(): + diagnostics.append( + f"디렉터리 claim은 허용되지 않는다: {normalized}" + ) + continue + result.add(str(resolved)) + if not result: + diagnostics.append("정확한 workspace 파일 claim이 하나 이상 필요하다") + return result, diagnostics + + +def extract_write_set(plan: Path | None, workspace: Path) -> tuple[set[str], bool]: + write_set, diagnostics = inspect_write_set(plan, workspace) + if diagnostics: + return set(), False + return write_set, True def latest_verdict_log(directory: Path) -> Path | None: @@ -1206,12 +1275,21 @@ def read_task_directory(workspace: Path, directory: Path) -> Task | None: lane, grade = parse_route(plan) recovery_plan = matching_plan_log(directory, recovery_log) write_set_source = plan or recovery_plan - write_set, write_set_known = extract_write_set(write_set_source, workspace) - if (write_set_source is not None and not write_set_known) or ( - recovery_log is not None and recovery_plan is None - ): + write_set: set[str] = set() + write_set_known = False + if recovery_log is not None and recovery_plan is None: errors.append( - "PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다" + "PLAN Modified Files Summary를 복구할 matching PLAN log가 없다" + ) + elif write_set_source is not None: + write_set, write_set_diagnostics = inspect_write_set( + write_set_source, + workspace, + ) + write_set_known = bool(write_set) and not write_set_diagnostics + errors.extend( + f"PLAN Modified Files Summary가 유효하지 않다: {diagnostic}" + for diagnostic in write_set_diagnostics ) if plan is not None: metadata = PLAN_IDENTITY_RE.search( @@ -6360,11 +6438,30 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--task-group", help="run only agent-task/") parser.add_argument("--dry-run", action="store_true", help="classify and print without launching CLIs") parser.add_argument("--retry-blocked", action="store_true", help="clear dispatcher-local blocked state") + parser.add_argument( + "--validate-plan", + metavar="PATH", + help="validate one PLAN Modified Files Summary without starting the dispatcher", + ) return parser.parse_args() def main() -> int: args = parse_args() + validate_plan = getattr(args, "validate_plan", None) + if validate_plan: + workspace = Path(args.workspace).resolve() + candidate = Path(validate_plan) + if not candidate.is_absolute(): + candidate = (Path.cwd() / candidate).resolve() + write_set, diagnostics = inspect_write_set(candidate, workspace) + if diagnostics: + for diagnostic in diagnostics: + print(f"plan write-set error: {diagnostic}", file=sys.stderr) + return 2 + for path in sorted(write_set): + validation_claim(path) + return 0 try: return asyncio.run(dispatch(args)) except KeyboardInterrupt: diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py index 0120283..3d2aa6f 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py @@ -19,3 +19,8 @@ def banner(event: str, task: str, lines: list[str] | None = None) -> None: def attempt_event(prefix: str, message: str) -> None: print(f"{prefix} {message}", flush=True) + + +def validation_claim(path: str) -> None: + """Emit one canonical write claim for standalone PLAN validation.""" + print(path, flush=True) diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py index efb9765..0618046 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, timezone, timedelta import importlib.util import inspect +import io import json import os import re @@ -5009,7 +5010,7 @@ class WriteSetTest(unittest.TestCase): self.assertFalse(scanned.write_set_known) self.assertEqual( scanned.errors, - ["PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다"], + ["PLAN Modified Files Summary를 복구할 matching PLAN log가 없다"], ) def test_rejects_broad_or_outside_workspace_write_sets(self): @@ -5019,14 +5020,45 @@ class WriteSetTest(unittest.TestCase): plan = workspace / "unsafe.md" plan.write_text( "## 수정 파일 요약\n\n| 파일 | 항목 |\n|---|---|\n" + "| `src/exact.go` | TEST-0 |\n" "| `src/` | TEST-1 |\n" "| `../outside.go` | TEST-2 |\n" - "| `src/*.go` | TEST-3 |\n", + "| `src/*.go` | TEST-3 |\n" + "| | TEST-4 |\n" + "| `` | TEST-5 |\n" + "| `src\\windows.go` | TEST-6 |\n", encoding="utf-8", ) write_set, known = dispatch.extract_write_set(plan, workspace) + inspected, diagnostics = dispatch.inspect_write_set(plan, workspace) self.assertFalse(known) self.assertEqual(write_set, set()) + self.assertEqual(inspected, {str((workspace / "src/exact.go").resolve())}) + self.assertIn( + "디렉터리 claim은 허용되지 않는다: src/", + diagnostics, + ) + self.assertIn( + "workspace 밖 claim은 허용되지 않는다: ../outside.go", + diagnostics, + ) + self.assertIn( + "glob 또는 broad path claim은 허용되지 않는다: src/*.go", + diagnostics, + ) + self.assertIn( + "정확한 backtick workspace 파일 경로가 없는 claim 행: " + "", + diagnostics, + ) + self.assertIn( + "placeholder 또는 malformed path claim은 허용되지 않는다: ", + diagnostics, + ) + self.assertIn( + r"malformed path claim은 허용되지 않는다: src\windows.go", + diagnostics, + ) def test_active_plan_without_valid_modified_files_summary_fails_closed(self): with tempfile.TemporaryDirectory() as temporary: @@ -5048,7 +5080,38 @@ class WriteSetTest(unittest.TestCase): self.assertFalse(task.write_set_known) self.assertEqual( task.errors, - ["PLAN Modified Files Summary가 없거나 비어 있거나 유효하지 않다"], + [ + "PLAN Modified Files Summary가 유효하지 않다: " + "Modified Files Summary 섹션이 없다" + ], + ) + + def test_validate_plan_mode_reports_precise_invalid_claim(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + plan = workspace / "PLAN-cloud-G10.md" + plan.write_text( + "## Modified Files Summary\n\n" + "| File | Items |\n|---|---|\n" + "| `agent-test/runs/output-filter-recovery/**` | TEST-1 |\n", + encoding="utf-8", + ) + with mock.patch.object( + sys, + "argv", + [ + str(SCRIPT), + "--workspace", + str(workspace), + "--validate-plan", + str(plan), + ], + ), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + self.assertEqual(dispatch.main(), 2) + self.assertIn( + "glob 또는 broad path claim은 허용되지 않는다: " + "agent-test/runs/output-filter-recovery/**", + stderr.getvalue(), ) def test_workspace_claims_persist_replace_wait_and_release_on_completion(self): @@ -11020,6 +11083,22 @@ class ArtifactLanguageContractTest(unittest.TestCase): with self.subTest(document=name, alias_pair=pair): self.assertIn(pair, documents[name]) + def test_plan_and_review_share_dispatch_write_set_contract(self): + documents = self.contract_documents() + plan_skill = documents["plan_skill"] + review_skill = documents["review_skill"] + orchestrator_skill = documents["orchestrator_skill"] + + for document in (plan_skill, review_skill): + self.assertIn("dispatch.py --workspace --validate-plan", document) + self.assertIn("exact workspace", document) + self.assertIn("Never use a glob (`*`, `?`, `[]`)", plan_skill) + self.assertIn("globs, directories, workspace root", review_skill) + self.assertIn( + "Fail the task closed when any path is broad", + orchestrator_skill, + ) + # Legacy Korean artifact labels are allowed only as explicit aliases. # Korean roadmap, USER_REVIEW.md, runtime banner, and user-facing # response literals are deliberately outside this assertion.