From 7d0e2ca4dd1ef3c17d15e97ace8c530937cdbc49 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 28 Jul 2026 20:42:48 +0900 Subject: [PATCH] refactor: dispatcher observation module & clean up archive logs --- .../orchestrate-agent-task-loop/SKILL.md | 22 +- .../scripts/dispatch.py | 75 ++-- .../scripts/dispatcher_observation.py | 21 ++ .../tests/test_dispatch.py | 151 -------- .../tests/test_dispatcher_observation.py | 294 ++++++++++++++++ .../code_review_cloud_G03_0.log | 162 +++++++++ .../code_review_cloud_G03_1.log | 221 ++++++++++++ .../complete.log | 39 +++ .../plan_local_G03_0.log | 296 ++++++++++++++++ .../plan_local_G03_1.log | 328 ++++++++++++++++++ .../{work_log_3.log => work_log_3.md} | 0 11 files changed, 1421 insertions(+), 188 deletions(-) create mode 100644 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py create mode 100644 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py create mode 100644 agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_0.log create mode 100644 agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_1.log create mode 100644 agent-task/archive/2026/07/dispatcher_observation_refactor/complete.log create mode 100644 agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_0.log create mode 100644 agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_1.log rename agent-task/archive/2026/07/m-stream-evidence-gate-core/{work_log_3.log => work_log_3.md} (100%) 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 e7565bf..62508bd 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md @@ -36,6 +36,8 @@ Use only the `commentary` channel for every user-visible message before `final` Partial success, FAIL/WARN, USER_REVIEW, a blocker, retry exhaustion, timeout, a tool error, plan-generation failure, dispatcher exit code `2` or `3`, child exit, loss of a session/cell, and context compaction never permit `final`. +Dispatcher stdout streamed directly by the execution layer is tool output, not a caller-authored message. Never spend an LLM turn restating, summarizing, or relaying a routine dispatcher event. + ### Child Prompt Text Never Grants Caller `final` Permission The prompt-contract phrase `Final in Korean.` controls only the child model response language. It never authorizes the caller to use the `final` channel. @@ -116,6 +118,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin - After every observed task in a task group has a verified complete archive and no active/running task remains, append the final `FINISH` and move the generated `WORK_LOG.md` under the final completed archive's group root as `work_log_N.log`. If an archive exists after restart but the last `START` lacks `FINISH`, do not terminate or archive while any PID/start token, per-attempt process marker, or pidless stream/native evidence remains live. Track it until execution evidence has ended and the complete archive is verified, then append `FINISH` with `reconciled:verified-complete-archive` and move the log. Use `agent-task/archive/YYYY/MM/{task_group}/` for split tasks and the actual suffix-bearing archive destination for a single task. Set `N` to one more than the maximum suffix for the same task group across all months, starting at `0`. - If `WORK_LOG.md` archiving fails or multiple active/archive sources exist, drain other independent work and return non-terminal exit `3` for retry. Return successful exit `0` only after a completed group that generated a log has no active `WORK_LOG.md` and its `work_log_N.log` is verified. Keep an incomplete group's `WORK_LOG.md` active for blocker or exit `3` recovery. - Split each attempt locator into `stream.log` for model stdout/stderr and `heartbeat.log` for dispatcher state. Determine health only from the newest progress in `stream.log` and native session events; never use heartbeat mtime as progress evidence. Do not copy either log into `WORK_LOG.md`. +- Keep child stdout/stderr, normalized model output, and periodic heartbeat records in locator-owned logs only. The dispatcher's user-visible stdout is an event stream and must never mirror model stream lines or heartbeat ticks. - If locator refresh temporarily fails after an attempt starts, do not terminate a live model process or start a duplicate task. Record a warning, keep monitoring, and preserve error evidence at the next successful refresh. - After verifying a PASS archive's `complete.log` and confirming no live execution evidence for that task, delete all of its attempt directories, including locators, native sessions, `stream.log`, `heartbeat.log`, and CLI auxiliary logs. Do not delete them while a model process or conservatively active pidless stream/native evidence remains. Treat transient deletion failure as non-terminal exit `3` for the next reconciliation without blocking the completed task or other tasks; do not return successful exit `0` while any attempt directory remains. Preserve failed or blocked attempt logs as recovery evidence. - Record log-creation or append failure in the locator as `work-log-setup` or `work-log-runtime-write` and block the task. @@ -125,18 +128,20 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin - **ABSOLUTE RULE — Do not stop the whole task group when a task-local blocker appears.** Delay only the blocked task and consumers that require its incomplete result as a predecessor. Keep the caller turn active until every independent ready/running task finishes. - **ABSOLUTE RULE — Scan the complete new-task candidate set only on initial dispatcher entry and immediately after creating a verified `complete.log`.** After a worker/self-check/review attempt ends or a task changes stage, reclassify only that task. After `complete.log` is created, immediately start every runnable task except currently running tasks in the same pass. Another task's execution, wait, dependency, review, or recovery state must not block a candidate. If no candidate or running task remains and only blockers and their dependent waits remain, exit with code `2`. -- Treat the caller agent running this skill—not the child dispatcher process—as the lifecycle owner. The dispatcher is only an execution mechanism. Child exit, tool yield, or loss of a session/cell never ends the caller lifecycle by itself. -- Keep the caller turn active and launch the dispatcher as one persistent foreground execution. If the execution tool returns a live session/cell ID, poll the same ID. Never start a duplicate dispatcher while the child is live. -- Never wrap the dispatcher in `timeout`, a short `wait_for`, or an arbitrary cancel/terminate wrapper. Tool yield or expiration of a response window is not process termination; poll the same session/cell again. -- **ABSOLUTE RULE — Caller monitoring is event-only.** During normal event silence, do not run a timer loop or periodically inspect `ps`, dispatcher `--dry-run`, `state.json`, locator files, `stream.log`, `heartbeat.log`, or `WORK_LOG.md`. A tool yield, empty poll, or response-window expiration is not a lost session/cell and does not permit this inspection. -- No dispatcher output, an empty poll, or a poll-window expiration is normal event silence. It never permits `final`, caller termination, a duplicate dispatcher, or a state inspection. Keep the caller turn active and wait on the same session/cell with the longest supported poll window. -- A lost session/cell exists only when the execution layer reports the tracked identifier unavailable or aborted, or reports the child process exited; a normal wait return alone is insufficient. Then perform exactly one reinspection of active tasks, locators, PIDs, and state. If that snapshot proves a live dispatcher owner, do not inspect it again until a dispatcher lifecycle event is observed. Resume event waiting from the same session/cell when available; otherwise subscribe from EOF to only newly appended START/FINISH rows in the task-group WORK_LOG.md. If the fallback observer itself ends without an event while the dispatcher remains live, reattach the same EOF-only observer without reading any prior row or inspecting state. A dispatcher exit, a new START/FINISH row, a reported recovery error, direct output from the tracked session, or an explicit user request permits the next targeted inspection. Exit code 0 is successful terminal state. Exit code 2 is a drained blocker or explicit persistent-state-error terminal state. Exit code 3 is a non-terminal tracking state, including another dispatcher workspace lock, a live external agent, or an unexpected dispatcher interruption; inspect PID, locator, and state only after that event. +- Treat the dispatcher as the execution lifecycle and observation owner. It performs deterministic health checks, recovery, retries, routing, and state transitions without caller-LLM supervision. The caller owns only launch authorization, intervention after an attention event, and the `final` gate. +- Keep the caller turn suspended and launch the dispatcher as one persistent foreground execution. Use execution-layer event waiting or direct stdout streaming; never use an LLM-generated polling turn as a keepalive. Never start a duplicate dispatcher while the child is live. +- Never wrap the dispatcher in `timeout`, a short `wait_for`, or an arbitrary cancel/terminate wrapper. Tool yield or expiration of a response window is not process termination. Resume the same execution-layer wait without commentary, analysis, or inspection. +- **ABSOLUTE RULE — The caller never monitors.** During normal execution or event silence, do not run a timer loop, periodically poll through the model, or inspect `ps`, dispatcher `--dry-run`, `state.json`, locator files, `stream.log`, `heartbeat.log`, or `WORK_LOG.md`. A tool yield, empty wait, routine lifecycle event, or response-window expiration does not permit caller-LLM involvement. +- Stream routine lifecycle banners directly from dispatcher stdout to the user without routing them through the caller LLM. Routine events include starts, deterministic retries/recovery, waits, per-task review results, per-task completion while other work remains, and any event for which the dispatcher has already selected the next action. +- Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously: a verified `USER_REVIEW` decision, an exhausted terminal blocker, an unrecoverable state/log contract error, loss of the execution handle that requires targeted recovery, or terminal dispatcher exit. A warning or automatic retry is not an attention event merely because it reports an error. +- No dispatcher output, an empty wait, or a wait-window expiration is normal event silence. It never permits `final`, caller termination, a duplicate dispatcher, a state inspection, or a model wake-up. Keep the execution-layer wait attached with the longest supported window. +- A lost session/cell exists only when the execution layer reports the tracked identifier unavailable or aborted, or reports the child process exited; a normal wait return alone is insufficient. Then perform exactly one reinspection of active tasks, locators, PIDs, and state. If that snapshot proves a live dispatcher owner, do not inspect it again until an attention event is observed. Resume event waiting from the same session/cell when available; otherwise subscribe from EOF to only newly appended START/FINISH rows in the task-group WORK_LOG.md. If the fallback observer itself ends without an event while the dispatcher remains live, reattach the same EOF-only observer without reading any prior row or inspecting state. A routine START/FINISH row or direct output only confirms the subscription and does not permit model wake-up or state inspection. Only a dispatcher exit, explicit attention event, fallback-observer error, or explicit user request permits the next targeted inspection. Exit code `0` is successful terminal state. Exit code `2` is a drained blocker or explicit persistent-state-error terminal state. Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, a live external agent, or an unexpected dispatcher interruption; inspect PID, locator, and state only after that event. - On a scheduler/control-plane exception or unexpected exception in an individual agent coroutine, do not immediately freeze it as a task blocker or let the dispatcher event loop cancel other running agents and child processes. Monitor every independent running agent until natural completion, return non-terminal exit `3`, and let the next dispatcher reconcile file and state results. Even when the original exception is a persistent-state error, do not convert it to exit `2` if any agent was running. - In drained-blocker terminal state, persist the orchestration group as `blocked`, directly blocked tasks as `blocked`, consumers waiting on their predecessors as `waiting`, and verified independent completed tasks as `complete` in `.git/agent-task-dispatcher/state.json`. On re-entry, set incomplete observed tasks back to orchestration state `active`, then reevaluate actual task-local blockers and dependencies. - Persist observed tasks and the complete same-name archive baseline present at startup, regardless of `complete.log`, in `.git/agent-task-dispatcher/state.json`. If an active task disappears after child restart, recover completion only when exactly one new `complete.log` archive absent from the baseline exists; block when none or multiple exist. Do not count a late `complete.log` added to an incomplete archive that existed before execution as current-run completion. - 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. -- Send each `작업시작`, `자가검증시작`, `리뷰시작`, `리뷰재시도`, `Pi복구재시도`, `세션응답복구재시도`, `세션연결재시도`, `리뷰결과`, `작업대기`, `작업차단`, `디스패치추적대기`, and `작업완료` banner to the user through `commentary`. Do not send periodic status commentary or inspect logs, PIDs, dispatcher state, locators, or routes when no new lifecycle event exists. Event silence never grants `final`; only the two permissions in the absolute-priority section do. +- 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. Never use heartbeat mtime as progress evidence. Record dispatcher PID, agent PID, each process start token, and the 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. - 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. @@ -226,6 +231,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin - [ ] For every completed task group that generated `WORK_LOG.md`, archive a `work_log_N.log` containing the final review `FINISH` and leave no active `WORK_LOG.md`. - [ ] Verify that a PASS task is archived and each newly released dependent task starts. - [ ] For success, verify every task's `complete.log`. For blocker exit, verify that no ready/running task remains and only task-local blockers and their dependent waits remain. +- [ ] Verify dispatcher stdout contains lifecycle/attention events only; raw child output and heartbeat ticks remain in locator-owned logs and never require caller-LLM relay. - [ ] On blocking, output the task, reason, and locator. - If verification fails, stop the dispatcher and report only the cause without manually moving or overwriting active PLAN/CODE_REVIEW files. @@ -238,7 +244,6 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin model=pi/iop/ornith:35b plan=/absolute/path/PLAN-local-G05.md work_log=/absolute/path/WORK_LOG.md -[03+01_event_contract_unit_tests][worker][a00] ... ------------------------------------------ 리뷰시작: 03+01_event_contract_unit_tests @@ -251,6 +256,7 @@ Use the same separator format for `작업대기`, `작업수행중`, `자가검 ## Prohibitions +- Never print periodic heartbeat ticks or child model stdout/stderr to dispatcher stdout. Preserve them only in locator-owned logs. - Never reevaluate PLAN/CODE_REVIEW lane or G in the dispatcher or rename those files. - Never infer dependency from numeric order when no predecessor index is present. - Never scan the complete archive or read archive files outside dependency candidates. 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 941a116..8f1c4a9 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 @@ -22,7 +22,34 @@ from pathlib import Path from typing import Any -SEP = "-" * 42 +_OBSERVATION_MODULE_NAME = "agent_task_dispatcher_observation" + + +def load_sibling_observation_module(): + loaded = sys.modules.get(_OBSERVATION_MODULE_NAME) + if loaded is not None: + return loaded + spec = importlib.util.spec_from_file_location( + _OBSERVATION_MODULE_NAME, + Path(__file__).with_name("dispatcher_observation.py"), + ) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load dispatcher observation module") + module = importlib.util.module_from_spec(spec) + sys.modules[_OBSERVATION_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(_OBSERVATION_MODULE_NAME, None) + raise + return module + + +observation = load_sibling_observation_module() +SEP = observation.SEP +banner = observation.banner +attempt_event = observation.attempt_event + 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$") PLAN_LOG_RE = re.compile( @@ -133,17 +160,6 @@ def work_log_now_kst() -> str: return datetime.now(KST).strftime("%y-%m-%d %H:%M:%S") -def banner(event: str, task: str, lines: list[str] | None = None) -> None: - display_task = task.rsplit("/", 1)[-1] - print(SEP, flush=True) - print(f"{event}: {display_task}", flush=True) - print(SEP, flush=True) - if display_task != task: - print(f"task={task}", flush=True) - for line in lines or []: - print(line, flush=True) - - def sha256_file(path: Path | None) -> str: if path is None or not path.exists(): return "none" @@ -3178,12 +3194,12 @@ async def invoke( write_json(locator_path, record) except OSError as exc: record["locator_write_error"] = str(exc) - print( - f"{prefix} locator 기록 경고: locator={locator_path} error={exc}", - flush=True, + attempt_event( + prefix, + f"locator 기록 경고: locator={locator_path} error={exc}", ) - print(f"{prefix} locator={locator_path}", flush=True) + attempt_event(prefix, f"locator={locator_path}") try: append_milestone_event( task, @@ -3208,7 +3224,7 @@ async def invoke( work_log_error=str(exc), ) persist_locator_record() - print(f"{prefix} {line}", flush=True) + attempt_event(prefix, line) return 1, "work-log-setup", locator_path command = build_command( spec, @@ -3271,7 +3287,7 @@ async def invoke( provider_transport_failure_confirmed=False, ) persist_locator_record() - print(f"{prefix} {line}", flush=True) + attempt_event(prefix, line) return 127, failure_class, locator_path readers: list[asyncio.Task[None]] = [] @@ -3394,7 +3410,7 @@ async def invoke( heartbeat_log.write(f"[silence-inspection] {diagnostic}\n") heartbeat_log.flush() persist_locator_record() - print(f"{prefix} 모델응답점검: {diagnostic}", flush=True) + attempt_event(prefix, f"모델응답점검: {diagnostic}") non_pi_inactive_seconds = loop.time() - max( last_native_progress_at, last_stream_progress_at ) @@ -3418,7 +3434,7 @@ async def invoke( heartbeat_log.write(f"[silence-inspection] {diagnostic}\n") heartbeat_log.flush() persist_locator_record() - print(f"{prefix} 모델응답점검: {diagnostic}", flush=True) + attempt_event(prefix, f"모델응답점검: {diagnostic}") heartbeat = ( f"작업중... locator={locator_path} " f"native_session={record.get('native_session_path') or 'none'} " @@ -3432,7 +3448,8 @@ async def invoke( heartbeat_log.write(f"[heartbeat] {heartbeat}\n") heartbeat_log.flush() persist_locator_record() - print(f"{prefix} {heartbeat}", flush=True) + # Heartbeat is recovery state, not a user-visible lifecycle + # event. Keep it out of the caller-facing event stream. continue if raw is None: finished_streams += 1 @@ -3457,10 +3474,10 @@ async def invoke( f"{collaboration_tool}" ) diagnostic_origins.append("dispatcher:review-control") - print( - f"{prefix} 리뷰 제어 계약 위반: collaboration-tool=" + attempt_event( + prefix, + f"리뷰 제어 계약 위반: collaboration-tool=" f"{collaboration_tool}", - flush=True, ) await terminate_process_group(process) rendered, discovered = ( @@ -3477,7 +3494,8 @@ async def invoke( if display_line: normalized_output_log.write(display_line + "\n") normalized_output_log.flush() - print(f"{prefix} {display_line}", flush=True) + # Child output is retained for recovery and review but is + # not itself a dispatcher lifecycle event. await asyncio.gather(*readers) return_code = await process.wait() except asyncio.CancelledError: @@ -4642,10 +4660,9 @@ def cleanup_completed_task_attempt_logs(runs: Path, task_name: str) -> int: try: shutil.rmtree(attempt_dir) except OSError as exc: - print( - f"[attempt-log-cleanup-warning] task={task_name} " - f"path={attempt_dir} error={exc}", - flush=True, + attempt_event( + "[attempt-log-cleanup-warning]", + f"task={task_name} path={attempt_dir} error={exc}", ) continue removed += 1 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 new file mode 100644 index 0000000..0120283 --- /dev/null +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Observation output emitter and formatting utilities for agent-task dispatcher.""" + +from __future__ import annotations + +SEP = "-" * 42 + + +def banner(event: str, task: str, lines: list[str] | None = None) -> None: + display_task = task.rsplit("/", 1)[-1] + print(SEP, flush=True) + print(f"{event}: {display_task}", flush=True) + print(SEP, flush=True) + if display_task != task: + print(f"task={task}", flush=True) + for line in lines or []: + print(line, flush=True) + + +def attempt_event(prefix: str, message: str) -> None: + print(f"{prefix} {message}", 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 531e410..66aad9f 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 @@ -2068,64 +2068,6 @@ class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): log = (workspace / dispatch.WORK_LOG_NAME).read_text(encoding="utf-8") self.assertIn("| FINISH | test | worker | 0 | pi | failed:cancelled |", log) - async def test_heartbeat_updates_output_and_locator_with_pi_native_session(self): - with tempfile.TemporaryDirectory() as temporary: - workspace = Path(temporary) - (workspace / ".git").mkdir() - task = TaskStageTest().make_task(workspace) - store = dispatch.StateStore(workspace) - session_id = "11111111-1111-1111-1111-111111111111" - - def command_for( - spec, - prompt, - cwd, - actual_session_id, - attempt_dir, - pi_resume_session=None, - ): - self.assertEqual(actual_session_id, session_id) - native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl" - child = ( - "from pathlib import Path\n" - "import sys,time\n" - "path = Path(sys.argv[1])\n" - "path.parent.mkdir(parents=True, exist_ok=True)\n" - "path.write_text(" - "'{\"type\":\"session\",\"version\":3,\"id\":\"test\"," - "\"timestamp\":\"2026-07-25T00:00:00.000Z\"," - "\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n" - "time.sleep(0.05)\n" - "print('done', flush=True)\n" - ) - return [sys.executable, "-c", child, str(native)] - - spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True) - try: - with ( - mock.patch.object(dispatch, "build_command", side_effect=command_for), - mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id), - mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01), - ): - rc, failure, locator = await dispatch.invoke( - workspace, store, task, "review", spec, "Reply briefly." - ) - finally: - store.close() - - self.assertEqual(rc, 0) - self.assertIsNone(failure) - record = json.loads(locator.read_text(encoding="utf-8")) - self.assertTrue(record["native_session_path"].endswith(f"{session_id}.jsonl")) - self.assertIsInstance(record["native_session_mtime_ns"], int) - heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8") - self.assertIn("[heartbeat] 작업중...", heartbeat) - self.assertIn("native_session=", heartbeat) - self.assertIn("native_mtime_ns=", heartbeat) - stream = Path(record["stream_log"]).read_text(encoding="utf-8") - self.assertIn("[stdout] done", stream) - self.assertNotIn("[heartbeat]", stream) - async def test_pi_silent_awaiting_model_is_inspected_without_termination(self): with tempfile.TemporaryDirectory() as temporary: workspace = Path(temporary) @@ -3385,99 +3327,6 @@ class ReviewControlTest(unittest.TestCase): f"line {line_number} has non-literal Korean narrative: {line}", ) - def test_caller_lifecycle_is_not_bound_to_child_dispatcher_exit(self): - skill = ( - Path(__file__).parents[1] / "SKILL.md" - ).read_text(encoding="utf-8") - self.assertIn( - "caller agent running this skill—not the child dispatcher process—as the lifecycle owner", - skill, - ) - self.assertIn( - "Child exit, tool yield, or loss of a session/cell never ends the caller lifecycle by itself", - skill, - ) - self.assertIn( - "Exit code `3` is a non-terminal tracking state, including another dispatcher's workspace lock, " - "a live external agent, or an unexpected dispatcher interruption", - skill, - ) - self.assertIn( - "every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, " - "plus native session events when available", - skill, - ) - self.assertIn( - "dispatcher PID, agent PID, each process start token, and the per-attempt " - "process environment marker", - skill, - ) - self.assertIn( - "use only an actual terminal error or confirmed process exit as recovery " - "evidence for every model", - skill, - ) - self.assertIn( - "every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`", - skill, - ) - self.assertIn( - "stream stops for three minutes outside tool execution", - skill, - ) - self.assertIn( - "locator lacks an agent PID during this interval, never classify it as stale or " - "duplicate recovery based on log age", - skill, - ) - self.assertIn( - "original exception is a persistent-state error, do not convert it to exit `2` if any agent was running", - skill, - ) - self.assertIn( - "do not return successful exit `0` while any attempt directory remains", - skill, - ) - self.assertIn( - "share a budget of 10 consecutive automatic recovery failures for the same task stage", - skill, - ) - self.assertIn( - "On the 10th failure, block that task and do not auto-resume after cooldown", - skill, - ) - self.assertIn( - "legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure", - skill, - ) - self.assertIn( - "Never classify exit code `143` as provider failure without actual provider terminal evidence", - skill, - ) - self.assertIn( - "Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage", - skill, - ) - self.assertIn("provider_transport_failure_confirmed", skill) - self.assertIn( - "Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr", - skill, - ) - self.assertIn( - "A running Python dispatcher does not hot-reload source edits", - skill, - ) - self.assertIn("dispatcher_source_sha256", skill) - self.assertIn("`dispatcher_source_matches_loaded=false`", skill) - self.assertIn( - "KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`", - skill, - ) - self.assertIn( - "fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery", - skill, - ) - def test_work_log_archive_ownership_stays_project_local(self): skills_root = Path(__file__).parents[3] dispatcher_skill = ( diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py new file mode 100644 index 0000000..066c908 --- /dev/null +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py @@ -0,0 +1,294 @@ +import ast +import asyncio +import importlib.util +import io +import json +import os +import re +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "dispatch.py" +loaded = sys.modules.get("agent_task_dispatch") +if loaded is not None: + dispatch = loaded +else: + SPEC = importlib.util.spec_from_file_location("agent_task_dispatch", SCRIPT) + assert SPEC and SPEC.loader + dispatch = importlib.util.module_from_spec(SPEC) + sys.modules[SPEC.name] = dispatch + SPEC.loader.exec_module(dispatch) + + +def make_test_task(root: Path) -> dispatch.Task: + plan = root / "PLAN-local-G05.md" + review = root / "CODE_REVIEW-local-G05.md" + plan.write_text("\n", encoding="utf-8") + review.write_text("\n", encoding="utf-8") + return dispatch.Task( + name="test", + directory=root, + plan=plan, + review=review, + user_review=None, + recovery=False, + lane="local", + grade=5, + ) + + +class ObservationOutputTest(unittest.TestCase): + def test_banner_preserves_existing_format_and_nested_task_identity(self): + buffer = io.StringIO() + with mock.patch("sys.stdout", buffer): + dispatch.banner("START", "group/subtask/task_name", ["line 1", "line 2"]) + output = buffer.getvalue() + expected = ( + "------------------------------------------\n" + "START: task_name\n" + "------------------------------------------\n" + "task=group/subtask/task_name\n" + "line 1\n" + "line 2\n" + ) + self.assertEqual(output, expected) + + buffer_flat = io.StringIO() + with mock.patch("sys.stdout", buffer_flat): + dispatch.banner("START", "task_name") + output_flat = buffer_flat.getvalue() + expected_flat = ( + "------------------------------------------\n" + "START: task_name\n" + "------------------------------------------\n" + ) + self.assertEqual(output_flat, expected_flat) + + def test_attempt_event_is_one_flushed_stdout_line(self): + buffer = io.StringIO() + with mock.patch("sys.stdout", buffer): + dispatch.attempt_event("[test-prefix]", "event message detail") + output = buffer.getvalue() + self.assertEqual(output, "[test-prefix] event message detail\n") + + def test_dispatch_compatibility_aliases_point_to_observation_module(self): + self.assertEqual(dispatch.SEP, dispatch.observation.SEP) + self.assertIs(dispatch.banner, dispatch.observation.banner) + self.assertIs(dispatch.attempt_event, dispatch.observation.attempt_event) + + def test_observation_module_identity_is_reused(self): + module1 = dispatch.load_sibling_observation_module() + module2 = dispatch.load_sibling_observation_module() + self.assertIs(module1, module2) + self.assertIs(module1, sys.modules["agent_task_dispatcher_observation"]) + + def test_dispatch_has_no_direct_stdout_print_calls(self): + source = SCRIPT.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(SCRIPT)) + stdout_prints = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "print": + is_stderr = False + for kw in node.keywords: + if kw.arg == "file": + val = kw.value + if ( + isinstance(val, ast.Attribute) + and isinstance(val.value, ast.Name) + and val.value.id == "sys" + and val.attr == "stderr" + ): + is_stderr = True + break + if not is_stderr: + stdout_prints.append(node.lineno) + self.assertEqual( + stdout_prints, + [], + f"found direct stdout print() calls on lines: {stdout_prints}", + ) + + +class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): + async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / ".git").mkdir() + task = make_test_task(workspace) + store = dispatch.StateStore(workspace) + session_id = "11111111-1111-1111-1111-111111111111" + + def command_for( + spec, + prompt, + cwd, + actual_session_id, + attempt_dir, + pi_resume_session=None, + ): + self.assertEqual(actual_session_id, session_id) + native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl" + child = ( + "from pathlib import Path\n" + "import sys,time\n" + "path = Path(sys.argv[1])\n" + "path.parent.mkdir(parents=True, exist_ok=True)\n" + "path.write_text(" + "'{\"type\":\"session\",\"version\":3,\"id\":\"test\"," + "\"timestamp\":\"2026-07-25T00:00:00.000Z\"," + "\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n" + "time.sleep(0.05)\n" + "print('done', flush=True)\n" + ) + return [sys.executable, "-c", child, str(native)] + + spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True) + try: + with ( + mock.patch.object(dispatch, "build_command", side_effect=command_for), + mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id), + mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01), + mock.patch("builtins.print") as print_mock, + ): + rc, failure, locator = await dispatch.invoke( + workspace, store, task, "review", spec, "Reply briefly." + ) + finally: + store.close() + + self.assertEqual(rc, 0) + self.assertIsNone(failure) + record = json.loads(locator.read_text(encoding="utf-8")) + self.assertTrue(record["native_session_path"].endswith(f"{session_id}.jsonl")) + self.assertIsInstance(record["native_session_mtime_ns"], int) + heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8") + self.assertIn("[heartbeat] 작업중...", heartbeat) + self.assertIn("native_session=", heartbeat) + self.assertIn("native_mtime_ns=", heartbeat) + stream = Path(record["stream_log"]).read_text(encoding="utf-8") + self.assertIn("[stdout] done", stream) + self.assertNotIn("[heartbeat]", stream) + normalized = Path(record["normalized_output_log"]).read_text( + encoding="utf-8" + ) + self.assertIn("done", normalized) + visible_output = "\n".join( + " ".join(str(value) for value in call.args) + for call in print_mock.call_args_list + ) + self.assertIn("locator=", visible_output) + self.assertNotIn("작업중...", visible_output) + self.assertNotIn("done", visible_output) + + +class SkillObservationContractTest(unittest.TestCase): + def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self): + skill = ( + Path(__file__).parents[1] / "SKILL.md" + ).read_text(encoding="utf-8") + self.assertIn( + "dispatcher as the execution lifecycle and observation owner", + skill, + ) + self.assertIn( + "without caller-LLM supervision", + skill, + ) + self.assertIn( + "The caller never monitors", + skill, + ) + self.assertIn( + "Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously", + skill, + ) + self.assertIn( + "Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, " + "a live external agent, or an unexpected dispatcher interruption", + skill, + ) + self.assertIn( + "every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, " + "plus native session events when available", + skill, + ) + self.assertIn( + "dispatcher PID, agent PID, each process start token, and the per-attempt " + "process environment marker", + skill, + ) + self.assertIn( + "use only an actual terminal error or confirmed process exit as recovery " + "evidence for every model", + skill, + ) + self.assertIn( + "every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`", + skill, + ) + self.assertIn( + "stream stops for three minutes outside tool execution", + skill, + ) + self.assertIn( + "locator lacks an agent PID during this interval, never classify it as stale or " + "duplicate recovery based on log age", + skill, + ) + self.assertIn( + "original exception is a persistent-state error, do not convert it to exit `2` if any agent was running", + skill, + ) + self.assertIn( + "do not return successful exit `0` while any attempt directory remains", + skill, + ) + self.assertIn( + "share a budget of 10 consecutive automatic recovery failures for the same task stage", + skill, + ) + self.assertIn( + "On the 10th failure, block that task and do not auto-resume after cooldown", + skill, + ) + self.assertIn( + "legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure", + skill, + ) + self.assertIn( + "Never classify exit code `143` as provider failure without actual provider terminal evidence", + skill, + ) + self.assertIn( + "Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage", + skill, + ) + self.assertIn("provider_transport_failure_confirmed", skill) + self.assertIn( + "Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr", + skill, + ) + self.assertIn( + "A running Python dispatcher does not hot-reload source edits", + skill, + ) + self.assertIn("dispatcher_source_sha256", skill) + self.assertIn("`dispatcher_source_matches_loaded=false`", skill) + self.assertIn( + "KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`", + skill, + ) + self.assertIn( + "fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery", + skill, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_0.log b/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_0.log new file mode 100644 index 0000000..9077a1e --- /dev/null +++ b/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_0.log @@ -0,0 +1,162 @@ + + +# Code Review Reference - REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields. +> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-07-28 +task=dispatcher_observation_refactor, plan=0, tag=REFACTOR + + + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정과 `review_rework_count` / `evidence_integrity_failure` 라우팅 신호를 append한다. +2. `CODE_REVIEW-cloud-G03.md` → `code_review_cloud_G03_0.log`, `PLAN-local-G03.md` → `plan_local_G03_0.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/dispatcher_observation_refactor/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| REFACTOR-1 관측 출력 모듈과 호환 seam 분리 | [ ] | +| REFACTOR-2 사용자 이벤트 출력 경로 통합 | [ ] | +| REFACTOR-3 관측 테스트 모듈 분리 | [ ] | + +## 구현 체크리스트 + +- [ ] REFACTOR-1 관측 출력 모듈을 만들고 `dispatch.SEP`/`dispatch.banner` 호환 seam을 유지한다. +- [ ] REFACTOR-2 dispatcher의 사용자 stdout 직접 출력을 관측 모듈로 통합하고 로그·상태 동작을 보존한다. +- [ ] REFACTOR-3 관측 관련 테스트를 집중 파일로 분리하고 출력/호환/비노출 회귀를 보강한다. +- [ ] 전체 dispatcher unittest, Python compile, diff 검증을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + + + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정과 검증된 `review_rework_count`, `evidence_integrity_failure`를 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G03_0.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_local_G03_0.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/dispatcher_observation_refactor/`를 `agent-task/archive/YYYY/MM/dispatcher_observation_refactor/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/dispatcher_observation_refactor/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 리뷰어를 위한 체크포인트 + +- `dispatch.SEP`와 `dispatch.banner`가 기존 import/patch consumer와 호환되는지 확인한다. +- main의 stderr 종료 진단 외 direct stdout `print()`가 `dispatch.py`에 남지 않았는지 확인한다. +- heartbeat와 normalized/raw child output은 locator-owned 로그에 남고 dispatcher stdout에 복제되지 않는지 확인한다. +- 상태·복구·scheduler·WORK_LOG 로직 변경이 범위 밖으로 섞이지 않았는지 확인한다. +- 새 focused test가 단독 discovery와 전체 discovery 양쪽에서 실제 provider 호출 없이 통과하는지 확인한다. + +## 검증 결과 + +> 구현 에이전트는 각 명령을 그대로 실행하고 실제 stdout/stderr를 아래 `_미작성_` 자리에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 기록한다. 요약·재구성 출력은 인정하지 않는다. + +### REFACTOR-1 중간 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +``` + +예상 결과: exit `0`, stdout/stderr 없음. + +```text +_미작성_ +``` + +### REFACTOR-2 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py' +``` + +예상 결과: 기존 dispatcher 테스트 전체 PASS, 실제 provider 호출 없음. + +```text +_미작성_ +``` + +### REFACTOR-3 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +``` + +예상 결과: 관측 집중 테스트 전체 PASS, 실제 provider 호출 없음. + +```text +_미작성_ +``` + +### 최종 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py' +rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +git diff --check +``` + +예상 결과: compile/focused/full suite/diff check exit `0`; `rg`의 `dispatch.py` 결과는 `file=sys.stderr` CLI 종료 진단뿐이며 stdout `print()`는 observation module emitter에만 존재한다. + +```text +_미작성_ +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 섹션 소유권 + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required | +| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_1.log b/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_1.log new file mode 100644 index 0000000..eecb8ff --- /dev/null +++ b/agent-task/archive/2026/07/dispatcher_observation_refactor/code_review_cloud_G03_1.log @@ -0,0 +1,221 @@ + + +# Code Review Reference - REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields. +> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-07-28 +task=dispatcher_observation_refactor, plan=1, tag=REFACTOR + + + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정과 `review_rework_count` / `evidence_integrity_failure` 라우팅 신호를 append한다. +2. `CODE_REVIEW-cloud-G03.md` → `code_review_cloud_G03_1.log`, `PLAN-local-G03.md` → `plan_local_G03_1.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/dispatcher_observation_refactor/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| REFACTOR-1 관측 출력 모듈과 호환 seam 분리 | [x] | +| REFACTOR-2 사용자 이벤트 출력 경로 통합 | [x] | +| REFACTOR-3 관측 테스트 모듈 분리 | [x] | + +## 구현 체크리스트 + +- [x] REFACTOR-1 관측 출력 모듈을 만들고 단일 module identity와 `dispatch.SEP`/`dispatch.banner` 호환 seam을 유지한다. +- [x] REFACTOR-2 dispatcher의 사용자 stdout 직접 출력을 관측 모듈로 통합하고 로그·상태 동작을 보존한다. +- [x] REFACTOR-3 관측 관련 테스트를 집중 파일로 분리하고 출력/호환/module identity/비노출 회귀를 보강한다. +- [x] 전체 dispatcher unittest, Python compile, 결정적 stdout 소유권 검사, diff 검증을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + + + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정과 검증된 `review_rework_count`, `evidence_integrity_failure`를 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G03_1.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G03_1.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/dispatcher_observation_refactor/`를 `agent-task/archive/YYYY/MM/dispatcher_observation_refactor/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/dispatcher_observation_refactor/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다. + +## 계획 대비 변경 사항 + +계획과 동일하게 구현함. 별도 변경 사항 없음. + +## 주요 설계 결정 + +- `scripts/dispatcher_observation.py` 신규 작성: `SEP`, `banner`, `attempt_event` 관측 Emitter 기능을 단일 소유로 분리. +- `dispatch.py` 관측 모듈 로더: `sys.modules`에 등록된 `agent_task_dispatcher_observation` 단일 인스턴스를 재사용하며, 부재 시 `importlib.util.spec_from_file_location` fallback 로딩 및 에러 처리 구현. 기존 `SEP`, `banner`, `attempt_event`는 호환 alias로 재노출. +- 사용자 이벤트 stdout 출력 통합: `dispatch.py` 내부 direct stdout `print()` 호출을 모두 `attempt_event()`로 전환 (`main()`의 `file=sys.stderr` 진단만 원본 유지). +- 관측 집중 테스트 작성: `tests/test_dispatcher_observation.py` 신규 작성 및 기존 `test_dispatch.py` 내 관측 관련 2개 테스트 이동, 추가로 exact stdout banner/attempt 포맷, alias 참조 동일성, 단일 module identity 재사용, ast 파싱 기반 direct stdout print 0건 정적 회귀 검증 추가. + +## 리뷰어를 위한 체크포인트 + +- `dispatch.SEP`와 `dispatch.banner`가 기존 import/patch consumer와 호환되는지 확인한다. +- main의 stderr 종료 진단 외 direct stdout `print()`가 `dispatch.py`에 남지 않았는지 확인한다. +- heartbeat와 normalized/raw child output은 locator-owned 로그에 남고 dispatcher stdout에 복제되지 않는지 확인한다. +- 상태·복구·scheduler·WORK_LOG 로직 변경이 범위 밖으로 섞이지 않았는지 확인한다. +- 새 focused test가 단독 discovery와 전체 discovery 양쪽에서 실제 provider 호출 없이 통과하는지 확인한다. + +## 검증 결과 + +> 구현 에이전트는 각 명령을 그대로 실행하고 실제 stdout/stderr를 아래 `_미작성_` 자리에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 기록한다. 요약·재구성 출력은 인정하지 않는다. + +### REFACTOR-1 중간 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +``` + +예상 결과: exit `0`, stdout/stderr 없음. + +```text +The command exited with code 0. +Stdout: + +Stderr: + +``` + +### REFACTOR-2 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py' +``` + +예상 결과: 기존 dispatcher 테스트 전체 PASS, 실제 provider 호출 없음. + +```text +The command exited with code 0. +Ran 202 tests in 20.771s + +OK +``` + +### REFACTOR-3 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +``` + +예상 결과: 관측 집중 테스트 전체 PASS, 실제 provider 호출 없음. + +```text +The command exited with code 0. +Output: +....... +---------------------------------------------------------------------- +Ran 7 tests in 0.224s + +OK +``` + +### 최종 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py' +rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +git diff --check +``` + +예상 결과: compile/focused/full suite/diff check exit `0`; `rg`의 `dispatch.py` 결과는 `file=sys.stderr` CLI 종료 진단뿐이며 stdout `print()`는 observation module emitter에만 존재한다. + +```text +[python3 -m py_compile ...] +exit code: 0 + +[python3 -m unittest discover ... test_dispatcher_observation.py] +Ran 7 tests in 0.224s +OK + +[python3 -m unittest discover ... test_*.py] +Ran 262 tests in 23.871s +OK + +[rg --sort path -n '\bprint\(' ...] +agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +5884: print("\n중단됨", file=sys.stderr) +5887: print(f"dispatcher active: {exc}", file=sys.stderr) +5890: print(f"dispatcher error: {exc}", file=sys.stderr) +5896: print(f"dispatcher interrupted: {exc}", file=sys.stderr) + +agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +11: print(SEP, flush=True) +12: print(f"{event}: {display_task}", flush=True) +13: print(SEP, flush=True) +15: print(f"task={task}", flush=True) +17: print(line, flush=True) +21: print(f"{prefix} {message}", flush=True) + +[git diff --check] +exit code: 0 +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 섹션 소유권 + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required | +| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass — 사용자 lifecycle event만 stdout emitter를 통하고 heartbeat/child output은 locator-owned 로그에 남는 동작을 코드와 통합 테스트로 확인했다. + - completeness: Pass — REFACTOR-1~3 구현, compatibility alias, module identity, stdout 소유권 검사까지 활성 계획의 체크리스트를 모두 충족했다. + - test coverage: Pass — focused 7개, `test_dispatch.py` 202개, 전체 262개 테스트가 fresh review 실행에서 모두 통과했다. + - API contract: Pass — `dispatch.SEP`, `dispatch.banner`, `dispatch.attempt_event` 호환 seam과 현재 dispatcher skill의 event-only 관측 계약을 유지했다. + - code quality: Pass — stale symbol, 직접 stdout `print()`, debug/TODO, diff whitespace 오류가 없다. + - implementation deviation: Pass — 계획된 네 파일의 seam/test 분리 범위에 맞고 상태·복구·scheduler 로직의 비관련 변경은 없다. + - verification trust: Pass — reviewer가 계획 명령을 재실행했고, stale review stub의 plan/archive 식별자와 `test_dispatch.py` 실행 수를 fresh evidence에 맞게 보정했다. +- 발견된 문제: + - Nit — `agent-task/dispatcher_observation_refactor/CODE_REVIEW-cloud-G03.md:1`: 이전 plan 번호·archive 번호와 `test_dispatch.py` 255개 실행 기록이 현재 pair/fresh 실행과 달랐다. plan `1`, `_1.log`, 202개로 리뷰 중 보정했으며 남은 조치는 없다. +- 라우팅 신호: `review_rework_count=0`, `evidence_integrity_failure=true` +- 다음 단계: PASS — `complete.log`를 작성하고 task artifacts를 `agent-task/archive/2026/07/dispatcher_observation_refactor/`로 이동한다. diff --git a/agent-task/archive/2026/07/dispatcher_observation_refactor/complete.log b/agent-task/archive/2026/07/dispatcher_observation_refactor/complete.log new file mode 100644 index 0000000..fbe1450 --- /dev/null +++ b/agent-task/archive/2026/07/dispatcher_observation_refactor/complete.log @@ -0,0 +1,39 @@ +# Complete - dispatcher_observation_refactor + +## 완료 일시 + +2026-07-28 + +## 요약 + +Dispatcher 사용자 관측 출력을 전용 모듈로 분리하고 호환 seam과 event-only stdout 계약을 검증했다. 사전 계획 보강 후 공식 리뷰 1회에서 최종 PASS했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G03_1.log` | `code_review_cloud_G03_1.log` | PASS | 관측 모듈 분리, stdout 이벤트 경계, 집중 회귀 테스트를 확인했다. | + +## 구현/정리 내용 + +- `dispatcher_observation.py`가 separator, banner, attempt event stdout 렌더링을 단일 소유한다. +- `dispatch.py`가 고정 module identity로 observation 모듈을 재사용하고 기존 `SEP`/`banner`/`attempt_event` 호환 alias를 노출한다. +- heartbeat와 normalized/raw child output은 locator-owned 로그에만 남기고 사용자 stdout에는 lifecycle/attention event만 출력한다. +- 관측 단위·통합·skill-contract 테스트를 `test_dispatcher_observation.py`로 분리하고 exact output, alias, module identity, stdout 비노출 회귀를 검증한다. + +## 최종 검증 + +- `python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` - PASS; exit 0, stdout/stderr 없음. +- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py'` - PASS; 202 tests, OK. +- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py'` - PASS; 7 tests, OK. +- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'` - PASS; 262 tests, OK. +- `rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` - PASS; `dispatch.py`에는 `file=sys.stderr` 종료 진단만 있고 stdout 렌더링은 observation 모듈에만 있다. +- `git diff --check` - PASS; 출력 없음. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_0.log b/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_0.log new file mode 100644 index 0000000..42092aa --- /dev/null +++ b/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_0.log @@ -0,0 +1,296 @@ + + +# Dispatcher 관측 출력 1차 분리 계획 + +## 이 파일을 읽는 구현 에이전트에게 + +> **[IMPLEMENTING AGENT — READ FIRST]** 구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 명령 출력으로 채우는 것이다. 아래 검증을 실행하고, active PLAN/CODE_REVIEW 파일을 그대로 둔 채 리뷰 준비 완료를 보고한다. 종결·판정·로그 rename·`complete.log`·archive 이동은 code-review skill 전용이다. +> +> 구현이 막히면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하지 말고, control-plane stop 파일을 만들거나 다음 상태를 분류하지 말며, 로그 archive나 `complete.log`를 작성하지 않는다. + +## 배경 + +`dispatch.py`는 실행·상태·복구·스케줄링과 사용자 관측 출력을 한 파일에서 함께 소유해 5,886줄까지 커졌고, `test_dispatch.py`도 10,183줄의 여러 책임을 한 모듈에서 검증한다. 현재 heartbeat와 child output은 locator-owned 로그에만 남고 stdout에는 이벤트만 출력되는 계약이 이미 있으므로, 첫 리팩터링 사이클은 이 관측 경계를 별도 모듈과 집중 테스트로 분리한다. 동작·출력 형식·`dispatch.banner` 테스트 패치 호환성은 유지한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md` +- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` +- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/rules/common/rules-agent-spec.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/finalize-task-routing/SKILL.md` +- `agent-ops/skills/common/plan/templates/review-stub-template.md` +- `agent-test/local/rules.md` +- `agent-test/local/testing-smoke.md` +- `agent-spec/index.md` +- `agent-contract/index.md` +- `agent-roadmap/current.md` +- `agent-roadmap/ROADMAP.md` +- `agent-roadmap/priority-queue.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md` + +### SDD 기준 + +not applicable. 이 작업은 현재 Python reference dispatcher의 비로드맵 내부 리팩터링이며 Milestone Task 완료를 체크하지 않는다. + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었고 `agent-test/local/testing-smoke.md`도 확인했다. 해당 smoke profile의 Edge/Node 장기 실행 검증은 Python dispatcher 내부 모듈 분리에 적용되지 않는다. +- 적용 규칙은 `agent-ops/rules/project/domain/testing/rules.md`의 dispatcher 테스트에서 실제 provider 호출을 금지하고 fake runner/mock만 사용하는 계약이다. +- Python dispatcher에 맞는 구체 명령은 기존 repository unittest layout을 fallback source로 사용한다. 기준 실행 `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'`는 현재 257 tests, `OK`로 확인했다. +- `python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`와 `git diff --check`도 현재 checkout에서 성공했다. +- 모든 검증은 현재 checkout 내부의 local Python subprocess와 mock만 사용한다. 외부 provider/remote runner/device가 없어 비-local preflight는 필요하지 않다. +- unittest는 매 실행마다 새 프로세스와 임시 디렉터리를 사용하므로 cached output을 성공 근거로 인정하지 않는다. + +### 테스트 커버리지 공백 + +- 기존 `test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream`은 heartbeat/child output의 로그 보존과 stdout 비노출을 통합 검증한다. 이 테스트를 집중 파일로 이동해 동일 계약을 유지한다. +- 기존 `test_dispatcher_owns_observation_and_caller_wakes_only_for_attention`은 skill의 caller-LLM 비개입 문구를 검증한다. 이 테스트도 관측 계약 파일로 이동한다. +- 기존 dry-run 테스트는 `dispatch.banner`를 patch하므로 호환 alias가 깨지면 회귀를 탐지한다. +- 공백: 관측 모듈 자체의 banner/attempt 출력 형식, `dispatch.SEP`/`dispatch.banner` 호환 alias, `dispatch.py`에 직접 stdout `print()`가 다시 생기지 않는다는 정적 보장이 없다. `test_dispatcher_observation.py`에 직접 단위/AST 회귀 테스트를 추가한다. + +### 심볼 참조 + +- renamed/removed symbol: none. `dispatch.SEP`와 `dispatch.banner`는 호환 alias로 유지한다. +- `banner(...)` 호출은 `dispatch.py:3861-4268`, `dispatch.py:4761-5084`, `dispatch.py:5167-5833`에 있고, patch consumer는 `test_dispatch.py:7827`에 있다. 호출부 이름은 바꾸지 않는다. +- 현재 사용자 stdout 직접 출력은 `dispatch.py:138-144`, `dispatch.py:3181-3186`, `dispatch.py:3211`, `dispatch.py:3274`, `dispatch.py:3397`, `dispatch.py:3421`, `dispatch.py:3461`, `dispatch.py:4647`이다. 이 중 main 종료 stderr가 아닌 지점은 관측 모듈을 통하도록 바꾼다. +- `dispatch.py:5869-5881`의 `file=sys.stderr` CLI 종료 진단은 stdout 이벤트 계약 밖이므로 유지한다. + +### 분할 판단 + +한 Plan으로 유지한다. “stdout 이벤트 형식과 호환 alias는 그대로 유지하고 heartbeat/child output은 locator-owned 로그에만 둔다”가 하나의 작고 독립적인 불변조건이며, 생산 코드 seam과 해당 회귀 테스트를 같은 PASS 단위로 검증해야 한다. 실행·상태·복구 모듈 분리는 이 경계가 안정된 뒤 별도 Plan으로 다룬다. + +### 범위 결정 근거 + +- `StateStore`, selector/quota, scheduler, recovery budget, process liveness, WORK_LOG/archive 로직은 동작 변경 위험이 커서 이번 사이클에서 이동하거나 재설계하지 않는다. +- `SKILL.md`의 event-only/caller-attention 계약은 이미 존재하므로 문구를 다시 수정하지 않는다. +- `execution_target_policy.py`, `select_execution_target.py`와 해당 테스트는 관측 출력 경계를 사용하지 않아 제외한다. +- 새 외부 package는 필요 없으며 manifest 변경도 없다. +- 테스트가 `dispatch.py`를 `spec_from_file_location`으로 직접 로드하므로 일반 sibling import에 의존하지 않는다. `dispatch.py` 안에서 sibling 파일 경로를 명시적으로 로드해 standalone CLI와 test loader 양쪽을 보존한다. + +### 최종 라우팅 + +- `evaluation_mode=first-pass`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`. +- Build closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음. +- Build scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, base/final route basis `local-fit`, route `local`, filename `PLAN-local-G03.md`. +- Review closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음. +- Review scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, route basis `official-review`, route `cloud`, adapter `codex`, model `gpt-5.6-sol`, reasoning `xhigh`, filename `CODE_REVIEW-cloud-G03.md`. +- `large_indivisible_context=false`. +- Positive loop risk: `boundary_contract`; `loop_risk_count=1`, `risk_boundary_matched=false`. +- Recovery signals: `review_rework_count=0`, `evidence_integrity_failure=false`, `recovery_boundary_matched=false`. + +## 구현 체크리스트 + +- [ ] REFACTOR-1 관측 출력 모듈을 만들고 `dispatch.SEP`/`dispatch.banner` 호환 seam을 유지한다. +- [ ] REFACTOR-2 dispatcher의 사용자 stdout 직접 출력을 관측 모듈로 통합하고 로그·상태 동작을 보존한다. +- [ ] REFACTOR-3 관측 관련 테스트를 집중 파일로 분리하고 출력/호환/비노출 회귀를 보강한다. +- [ ] 전체 dispatcher unittest, Python compile, diff 검증을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + +### [REFACTOR-1] 관측 출력 모듈과 호환 seam 분리 + +#### 문제 + +`dispatch.py:23`의 separator와 `dispatch.py:136-144`의 banner 렌더링이 5,886줄짜리 실행 모듈에 직접 들어 있다. 테스트는 `dispatch.py:7827`에서 `dispatch.banner`를 patch하므로 단순 이동은 기존 import/patch 계약을 깨뜨릴 수 있다. + +#### 해결 방법 + +`scripts/dispatcher_observation.py`에 `SEP`, `banner(event, task, lines=None)`, `attempt_event(prefix, message)`를 둔다. `dispatch.py`는 sibling 경로를 `importlib.util.spec_from_file_location`으로 로드하고 기존 `SEP`와 `banner` 이름을 alias로 재노출한다. + +Before (`dispatch.py:23`, `dispatch.py:136-144`): + +```python +SEP = "-" * 42 + +def banner(event: str, task: str, lines: list[str] | None = None) -> None: + display_task = task.rsplit("/", 1)[-1] + print(SEP, flush=True) + print(f"{event}: {display_task}", flush=True) + print(SEP, flush=True) + if display_task != task: + print(f"task={task}", flush=True) + for line in lines or []: + print(line, flush=True) +``` + +After: + +```python +observation = load_sibling_observation_module() +SEP = observation.SEP +banner = observation.banner +attempt_event = observation.attempt_event +``` + +```python +# dispatcher_observation.py +SEP = "-" * 42 + +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) +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` — 기존 banner 형식과 단일 attempt event emitter를 정의한다. +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — path-based loader와 `SEP`/`banner`/`attempt_event` alias를 추가하고 기존 banner 구현을 제거한다. +- [ ] module load 실패는 import 시 명시적 `RuntimeError`로 드러나게 하고 silent fallback을 두지 않는다. + +#### 테스트 작성 + +작성한다. REFACTOR-3의 `ObservationOutputTest.test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`이 exact output과 기존 alias를 검증한다. + +#### 중간 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +``` + +예상 결과: exit `0`, stdout/stderr 없음. + +### [REFACTOR-2] 사용자 이벤트 출력 경로 통합 + +#### 문제 + +`invoke()`와 attempt cleanup은 `dispatch.py:3181-3465`, `dispatch.py:4647-4651`에서 사용자 이벤트를 직접 `print()`한다. 이 구조는 새 출력이 heartbeat/child stream과 같은 내부 관측인지 caller-facing event인지 매 수정마다 큰 파일 안에서 다시 판단하게 만든다. + +#### 해결 방법 + +main의 stderr 종료 진단을 제외한 직접 stdout 출력은 `attempt_event(prefix, message)`로 통일한다. heartbeat write와 normalized child output write는 현재처럼 파일에만 남기고 emitter를 호출하지 않는다. event 문구, prefix, flush, locator/work-log/state update 순서는 바꾸지 않는다. + +Before (`dispatch.py:3186`, `dispatch.py:3461-3465`): + +```python +print(f"{prefix} locator={locator_path}", flush=True) + +print( + f"{prefix} 리뷰 제어 계약 위반: collaboration-tool=" + f"{collaboration_tool}", + flush=True, +) +``` + +After: + +```python +attempt_event(prefix, f"locator={locator_path}") +attempt_event( + prefix, + f"리뷰 제어 계약 위반: collaboration-tool={collaboration_tool}", +) +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — locator warning/start, work-log setup failure, command-not-found, model-response inspection, review-control violation, attempt cleanup warning을 emitter로 전환한다. +- [ ] heartbeat와 normalized child output 경로에는 새 emitter 호출을 추가하지 않는다. +- [ ] `main()`의 `file=sys.stderr` 종료 진단과 exit code 의미는 유지한다. + +#### 테스트 작성 + +작성한다. REFACTOR-3의 AST 테스트는 `dispatch.py`의 direct stdout `print()` 재도입을 금지하고, 이동한 invoke 통합 테스트는 heartbeat/child output이 stdout에 나타나지 않으면서 로그에는 남는지 검증한다. + +#### 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py' +``` + +예상 결과: 기존 dispatcher 테스트 전체 PASS, 실제 provider 호출 없음. + +### [REFACTOR-3] 관측 테스트 모듈 분리 + +#### 문제 + +관측 계약 테스트가 `test_dispatch.py:2071-2141`의 invoke 통합 class와 `test_dispatch.py:3400-3500`의 review-control class에 섞여 있다. 새 관측 모듈에 대한 직접 단위 테스트도 없어 생산 모듈 분리와 테스트 파일 분리가 같은 경계로 유지되지 않는다. + +#### 해결 방법 + +`tests/test_dispatcher_observation.py`를 만들고 두 기존 관측 계약 테스트를 이름과 assertion 의미를 유지한 채 이동한다. 여기에 banner/attempt exact-output, compatibility alias, direct stdout print AST 검사를 추가한다. 공용 fixture 대규모 추출은 하지 않고 이 파일에 필요한 최소 task fixture와 dynamic loader만 둔다. + +Before (`test_dispatch.py:2071`, `test_dispatch.py:3400`): + +```python +class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): + async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self): + ... + +class ReviewControlTest(unittest.TestCase): + def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self): + ... +``` + +After: + +```python +class ObservationOutputTest(unittest.TestCase): + ... + +class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): + async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self): + ... + +class SkillObservationContractTest(unittest.TestCase): + def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self): + ... +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` — 집중 단위/통합/skill-contract 테스트를 추가한다. +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` — 이동한 두 테스트만 제거하고 다른 class/fixture는 유지한다. +- [ ] focused 파일 단독 discovery와 전체 `test_*.py` discovery 양쪽에서 module loading이 독립적으로 동작하는지 확인한다. + +#### 테스트 작성 + +작성한다. 추가 테스트는 `test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`, `test_dispatch_has_no_direct_stdout_print_calls`이며, 기존 두 회귀 테스트를 이동한다. + +#### 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +``` + +예상 결과: 관측 집중 테스트 전체 PASS, 실제 provider 호출 없음. + +## 의존 관계 및 구현 순서 + +1. REFACTOR-1로 import/compatibility seam을 만든다. +2. REFACTOR-2로 기존 direct stdout call site를 seam에 연결한다. +3. REFACTOR-3으로 테스트를 이동·보강한 뒤 전체 suite를 실행한다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` | REFACTOR-1 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` | REFACTOR-1, REFACTOR-2 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` | REFACTOR-1, REFACTOR-2, REFACTOR-3 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` | REFACTOR-3 | + +## 최종 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py' +rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +git diff --check +``` + +예상 결과: compile/focused/full suite/diff check는 exit `0`; full suite는 실제 provider 호출 없이 전체 PASS. `rg` 결과에서 `dispatch.py`의 `print()`는 `file=sys.stderr` CLI 종료 진단뿐이고, stdout `print()`는 `dispatcher_observation.py`의 emitter 구현에만 존재한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. diff --git a/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_1.log b/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_1.log new file mode 100644 index 0000000..2d252b8 --- /dev/null +++ b/agent-task/archive/2026/07/dispatcher_observation_refactor/plan_local_G03_1.log @@ -0,0 +1,328 @@ + + +# Dispatcher 관측 출력 1차 분리 계획 + +## 이 파일을 읽는 구현 에이전트에게 + +> **[IMPLEMENTING AGENT — READ FIRST]** 구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 명령 출력으로 채우는 것이다. 아래 검증을 실행하고, active PLAN/CODE_REVIEW 파일을 그대로 둔 채 리뷰 준비 완료를 보고한다. 종결·판정·로그 rename·`complete.log`·archive 이동은 code-review skill 전용이다. +> +> 구현이 막히면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하지 말고, control-plane stop 파일을 만들거나 다음 상태를 분류하지 말며, 로그 archive나 `complete.log`를 작성하지 않는다. + +## 배경 + +`dispatch.py`는 실행·상태·복구·스케줄링과 사용자 관측 출력을 한 파일에서 함께 소유해 5,886줄까지 커졌고, `test_dispatch.py`도 10,183줄의 여러 책임을 한 모듈에서 검증한다. 현재 heartbeat와 child output은 locator-owned 로그에만 남고 stdout에는 이벤트만 출력되는 계약이 이미 있으므로, 첫 리팩터링 사이클은 이 관측 경계를 별도 모듈과 집중 테스트로 분리한다. 이번 사이클은 전체 파일 비대화 해소가 아니라 이후 실행·상태 영역을 안전하게 분리할 수 있는 첫 seam 확립이며, 동작·출력 형식·`dispatch.banner` 테스트 패치 호환성은 유지한다. + +## Archive Evidence Snapshot + +- 이전 계획/리뷰: `agent-task/dispatcher_observation_refactor/plan_local_G03_0.log`, `agent-task/dispatcher_observation_refactor/code_review_cloud_G03_0.log`. +- verdict: 없음. 구현 전 계획 재검토로 보관됐으며 구현 결과나 review finding은 없다. +- 보강 사유: `test_dispatch.py` line reference 오기 수정, path-based loader의 단일 module identity/실패 계약 명시, focused/full discovery 중복 로드 방지, stdout AST oracle와 1차 seam 완료 기준 구체화. +- 영향 파일은 `scripts/dispatcher_observation.py`, `scripts/dispatch.py`, `tests/test_dispatcher_observation.py`, `tests/test_dispatch.py`로 동일하다. +- 기존 검증 근거: dispatcher unittest 257개 `OK`, `dispatch.py` pycompile 성공, `git diff --check` 성공. 구현 검증 근거는 아직 없다. +- roadmap carryover: 없음. + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md` +- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` +- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/rules/common/rules-agent-spec.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/finalize-task-routing/SKILL.md` +- `agent-ops/skills/common/plan/templates/review-stub-template.md` +- `agent-test/local/rules.md` +- `agent-test/local/testing-smoke.md` +- `agent-spec/index.md` +- `agent-contract/index.md` +- `agent-roadmap/current.md` +- `agent-roadmap/ROADMAP.md` +- `agent-roadmap/priority-queue.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/iop-agent-cli-runtime.md` + +### SDD 기준 + +not applicable. 이 작업은 현재 Python reference dispatcher의 비로드맵 내부 리팩터링이며 Milestone Task 완료를 체크하지 않는다. + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었고 `agent-test/local/testing-smoke.md`도 확인했다. 해당 smoke profile의 Edge/Node 장기 실행 검증은 Python dispatcher 내부 모듈 분리에 적용되지 않는다. +- 적용 규칙은 `agent-ops/rules/project/domain/testing/rules.md`의 dispatcher 테스트에서 실제 provider 호출을 금지하고 fake runner/mock만 사용하는 계약이다. +- Python dispatcher에 맞는 구체 명령은 기존 repository unittest layout을 fallback source로 사용한다. 기준 실행 `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'`는 현재 257 tests, `OK`로 확인했다. +- `python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`와 `git diff --check`도 현재 checkout에서 성공했다. +- 모든 검증은 현재 checkout 내부의 local Python subprocess와 mock만 사용한다. 외부 provider/remote runner/device가 없어 비-local preflight는 필요하지 않다. +- unittest는 매 실행마다 새 프로세스와 임시 디렉터리를 사용하므로 cached output을 성공 근거로 인정하지 않는다. + +### 테스트 커버리지 공백 + +- 기존 `test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream`은 heartbeat/child output의 로그 보존과 stdout 비노출을 통합 검증한다. 이 테스트를 집중 파일로 이동해 동일 계약을 유지한다. +- 기존 `test_dispatcher_owns_observation_and_caller_wakes_only_for_attention`은 skill의 caller-LLM 비개입 문구를 검증한다. 이 테스트도 관측 계약 파일로 이동한다. +- 기존 dry-run 테스트는 `dispatch.banner`를 patch하므로 호환 alias가 깨지면 회귀를 탐지한다. +- 공백: 관측 모듈 자체의 banner/attempt 출력 형식, `dispatch.SEP`/`dispatch.banner` 호환 alias, 여러 test module이 같은 observation module instance를 재사용하는지, `dispatch.py`에 직접 stdout `print()`가 다시 생기지 않는다는 정적 보장이 없다. `test_dispatcher_observation.py`에 직접 단위/module identity/AST 회귀 테스트를 추가한다. + +### 심볼 참조 + +- renamed/removed symbol: none. `dispatch.SEP`, `dispatch.banner`, `dispatch.attempt_event`는 observation module alias로 노출한다. +- `banner(...)` 호출은 `dispatch.py:3861-4268`, `dispatch.py:4761-5084`, `dispatch.py:5167-5833`에 있고, patch consumer는 `test_dispatch.py:7827`에 있다. 호출부 이름은 바꾸지 않는다. +- 현재 사용자 stdout 직접 출력은 `dispatch.py:138-144`, `dispatch.py:3181-3186`, `dispatch.py:3211`, `dispatch.py:3274`, `dispatch.py:3397`, `dispatch.py:3421`, `dispatch.py:3461`, `dispatch.py:4647`이다. banner는 관측 모듈로 이동하고 나머지 stdout 지점은 `attempt_event`를 통하도록 바꾼다. +- `dispatch.py:5869-5881`의 `file=sys.stderr` CLI 종료 진단은 stdout 이벤트 계약 밖이므로 유지한다. + +### 분할 판단 + +한 Plan으로 유지한다. “stdout 이벤트 렌더링은 observation module 한 곳에서 소유하고, 호환 alias는 유지하며, heartbeat/child output은 locator-owned 로그에만 둔다”가 하나의 작고 독립적인 불변조건이다. 생산 코드 seam, module identity, 해당 회귀 테스트를 같은 PASS 단위로 검증해야 하며 실행·상태·복구 모듈 분리는 이 경계가 안정된 뒤 별도 Plan으로 다룬다. + +### 범위 결정 근거 + +- 이번 완료 기준은 `dispatch.py`의 직접 stdout `print()` 0개, 사용자 stdout 렌더링의 `dispatcher_observation.py` 단일 소유, 두 관측 계약 테스트의 focused test 파일 이전이다. `dispatch.py`/`test_dispatch.py` 전체 크기를 한 사이클에 해소하는 것은 범위가 아니다. +- `StateStore`, selector/quota, scheduler, recovery budget, process liveness, WORK_LOG/archive 로직은 동작 변경 위험이 커서 이번 사이클에서 이동하거나 재설계하지 않는다. +- `SKILL.md`의 event-only/caller-attention 계약은 이미 존재하므로 문구를 다시 수정하지 않는다. +- `execution_target_policy.py`, `select_execution_target.py`와 해당 테스트는 관측 출력 경계를 사용하지 않아 제외한다. +- 새 외부 package는 필요 없으며 manifest 변경도 없다. +- 테스트가 `dispatch.py`를 `spec_from_file_location`으로 직접 로드하므로 일반 sibling import에 의존하지 않는다. `dispatch.py`는 고정 private module name과 sibling 경로로 observation module을 로드하고 `sys.modules`의 기존 instance를 재사용한다. focused test loader도 기존 `agent_task_dispatch` instance를 우선 재사용해 단독/전체 discovery 모두에서 중복 실행을 피한다. + +### 최종 라우팅 + +- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`. +- Build closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음. +- Build scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, base/final route basis `local-fit`, route `local`, filename `PLAN-local-G03.md`. +- Review closures: `scope_closed=true`, `context_closed=true`, `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`; capability gap 없음. +- Review scores: `scope_coupling=1`, `state_concurrency=0`, `blast_irreversibility=1`, `evidence_diagnosis=0`, `verification_complexity=1`; grade `G03`, route basis `official-review`, route `cloud`, adapter `codex`, model `gpt-5.6-sol`, reasoning `xhigh`, filename `CODE_REVIEW-cloud-G03.md`. +- `large_indivisible_context=false`. +- Positive loop risk: `boundary_contract`; `loop_risk_count=1`, `risk_boundary_matched=false`. +- Recovery signals: `review_rework_count=0`, `evidence_integrity_failure=false`, `recovery_boundary_matched=false`. + +## 구현 체크리스트 + +- [ ] REFACTOR-1 관측 출력 모듈을 만들고 단일 module identity와 `dispatch.SEP`/`dispatch.banner` 호환 seam을 유지한다. +- [ ] REFACTOR-2 dispatcher의 사용자 stdout 직접 출력을 관측 모듈로 통합하고 로그·상태 동작을 보존한다. +- [ ] REFACTOR-3 관측 관련 테스트를 집중 파일로 분리하고 출력/호환/module identity/비노출 회귀를 보강한다. +- [ ] 전체 dispatcher unittest, Python compile, 결정적 stdout 소유권 검사, diff 검증을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + +### [REFACTOR-1] 관측 출력 모듈과 호환 seam 분리 + +#### 문제 + +`dispatch.py:23`의 separator와 `dispatch.py:136-144`의 banner 렌더링이 5,886줄짜리 실행 모듈에 직접 들어 있다. 테스트는 `test_dispatch.py:7827`에서 `dispatch.banner`를 patch하므로 단순 이동은 기존 import/patch 계약을 깨뜨릴 수 있다. 또한 `test_dispatch.py:20-25`처럼 경로 기반으로 dispatch를 로드하므로 observation module도 고정 identity 없이 매번 실행하면 focused/full discovery 사이에 서로 다른 module instance가 생긴다. + +#### 해결 방법 + +`scripts/dispatcher_observation.py`에 `SEP`, `banner(event, task, lines=None)`, `attempt_event(prefix, message)`를 둔다. `dispatch.py`는 고정 private name으로 `sys.modules`의 기존 observation module을 먼저 재사용하고, 없을 때만 sibling 경로를 `importlib.util.spec_from_file_location`으로 로드한다. spec/loader가 없으면 `RuntimeError`, module 실행이 실패하면 등록한 부분 초기화 entry를 제거한 뒤 원래 예외를 다시 발생시킨다. 기존 `SEP`, `banner`, `attempt_event` 이름은 alias로 재노출한다. + +Before (`dispatch.py:23`, `dispatch.py:136-144`): + +```python +SEP = "-" * 42 + +def banner(event: str, task: str, lines: list[str] | None = None) -> None: + display_task = task.rsplit("/", 1)[-1] + print(SEP, flush=True) + print(f"{event}: {display_task}", flush=True) + print(SEP, flush=True) + if display_task != task: + print(f"task={task}", flush=True) + for line in lines or []: + print(line, flush=True) +``` + +After: + +```python +_OBSERVATION_MODULE_NAME = "agent_task_dispatcher_observation" + +def load_sibling_observation_module(): + loaded = sys.modules.get(_OBSERVATION_MODULE_NAME) + if loaded is not None: + return loaded + spec = importlib.util.spec_from_file_location( + _OBSERVATION_MODULE_NAME, + Path(__file__).with_name("dispatcher_observation.py"), + ) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load dispatcher observation module") + module = importlib.util.module_from_spec(spec) + sys.modules[_OBSERVATION_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(_OBSERVATION_MODULE_NAME, None) + raise + return module + +observation = load_sibling_observation_module() +SEP = observation.SEP +banner = observation.banner +attempt_event = observation.attempt_event +``` + +```python +# dispatcher_observation.py +SEP = "-" * 42 + +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) +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` — 기존 banner 형식과 단일 attempt event emitter를 정의한다. +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — idempotent path-based loader와 `SEP`/`banner`/`attempt_event` alias를 추가하고 기존 banner 구현을 제거한다. +- [ ] module load 실패는 import 시 명시적으로 드러나게 하고 silent fallback이나 부분 초기화 module을 남기지 않는다. + +#### 테스트 작성 + +작성한다. REFACTOR-3의 `ObservationOutputTest.test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`, `test_observation_module_identity_is_reused`가 exact output, 기존 alias, module identity를 검증한다. + +#### 중간 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +``` + +예상 결과: exit `0`, stdout/stderr 없음. + +### [REFACTOR-2] 사용자 이벤트 출력 경로 통합 + +#### 문제 + +`invoke()`와 attempt cleanup은 `dispatch.py:3181-3465`, `dispatch.py:4647-4651`에서 사용자 이벤트를 직접 `print()`한다. 이 구조는 새 출력이 heartbeat/child stream과 같은 내부 관측인지 caller-facing event인지 매 수정마다 큰 파일 안에서 다시 판단하게 만든다. + +#### 해결 방법 + +main의 stderr 종료 진단을 제외한 직접 stdout 출력은 `attempt_event(prefix, message)`로 통일한다. heartbeat write와 normalized child output write는 현재처럼 파일에만 남기고 emitter를 호출하지 않는다. event 문구, prefix, flush, locator/work-log/state update 순서는 바꾸지 않는다. + +Before (`dispatch.py:3186`, `dispatch.py:3461-3465`): + +```python +print(f"{prefix} locator={locator_path}", flush=True) + +print( + f"{prefix} 리뷰 제어 계약 위반: collaboration-tool=" + f"{collaboration_tool}", + flush=True, +) +``` + +After: + +```python +attempt_event(prefix, f"locator={locator_path}") +attempt_event( + prefix, + f"리뷰 제어 계약 위반: collaboration-tool={collaboration_tool}", +) +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` — locator warning/start, work-log setup failure, command-not-found, model-response inspection, review-control violation, attempt cleanup warning을 emitter로 전환한다. +- [ ] heartbeat와 normalized child output 경로에는 새 emitter 호출을 추가하지 않는다. +- [ ] `main()`의 `file=sys.stderr` 종료 진단과 exit code 의미는 유지한다. + +#### 테스트 작성 + +작성한다. REFACTOR-3의 AST 테스트는 `dispatch.py`에서 `file=sys.stderr`가 명시된 `print()`만 허용하고, 이동한 invoke 통합 테스트는 heartbeat/child output이 stdout에 나타나지 않으면서 로그에는 남는지 검증한다. + +#### 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatch.py' +``` + +예상 결과: 기존 dispatcher 테스트 전체 PASS, 실제 provider 호출 없음. + +### [REFACTOR-3] 관측 테스트 모듈 분리 + +#### 문제 + +관측 계약 테스트가 `test_dispatch.py:2071-2141`의 invoke 통합 class와 `test_dispatch.py:3400-3500`의 review-control class에 섞여 있다. 새 관측 모듈에 대한 직접 단위 테스트도 없어 생산 모듈 분리와 테스트 파일 분리가 같은 경계로 유지되지 않는다. + +#### 해결 방법 + +`tests/test_dispatcher_observation.py`를 만들고 두 기존 관측 계약 테스트를 이름과 assertion 의미를 유지한 채 이동한다. 여기에 banner/attempt exact-output, compatibility alias, module identity, direct stdout print AST 검사를 추가한다. focused loader는 `sys.modules.get("agent_task_dispatch")`를 먼저 사용하고 없을 때만 현재 `test_dispatch.py:20-25` 방식으로 load/register/execute한다. 공용 fixture 대규모 추출은 하지 않고 이 파일에 필요한 최소 task fixture만 둔다. + +Before (`test_dispatch.py:2071`, `test_dispatch.py:3400`): + +```python +class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): + async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self): + ... + +class ReviewControlTest(unittest.TestCase): + def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self): + ... +``` + +After: + +```python +class ObservationOutputTest(unittest.TestCase): + ... + +class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): + async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self): + ... + +class SkillObservationContractTest(unittest.TestCase): + def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self): + ... +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` — 집중 단위/통합/skill-contract 테스트와 idempotent dispatch loader를 추가한다. +- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` — 이동한 두 테스트만 제거하고 다른 class/fixture는 유지한다. +- [ ] AST 검사는 `ast.parse`로 `dispatch.py`의 call을 판정하며 `print()`에 `file=sys.stderr`가 명시된 경우만 허용한다. `rg` 출력은 review evidence일 뿐 이 테스트를 대체하지 않는다. +- [ ] focused 파일 단독 discovery와 전체 `test_*.py` discovery 양쪽에서 module loading이 독립적으로 동작하고 observation module identity가 하나인지 확인한다. + +#### 테스트 작성 + +작성한다. 추가 테스트는 `test_banner_preserves_existing_format_and_nested_task_identity`, `test_attempt_event_is_one_flushed_stdout_line`, `test_dispatch_compatibility_aliases_point_to_observation_module`, `test_observation_module_identity_is_reused`, `test_dispatch_has_no_direct_stdout_print_calls`이며, 기존 두 회귀 테스트를 이동한다. + +#### 중간 검증 + +```bash +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +``` + +예상 결과: 관측 집중 테스트 전체 PASS, 실제 provider 호출 없음. + +## 의존 관계 및 구현 순서 + +1. REFACTOR-1로 import/compatibility/module identity seam을 만든다. +2. REFACTOR-2로 기존 direct stdout call site를 seam에 연결한다. +3. REFACTOR-3으로 테스트를 이동·보강한 뒤 전체 suite를 실행한다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py` | REFACTOR-1 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` | REFACTOR-1, REFACTOR-2 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py` | REFACTOR-1, REFACTOR-2, REFACTOR-3 | +| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` | REFACTOR-3 | + +## 최종 검증 + +```bash +python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_dispatcher_observation.py' +python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py' +rg --sort path -n '\bprint\(' agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatcher_observation.py +git diff --check +``` + +예상 결과: compile/focused/full suite/diff check는 exit `0`; full suite는 실제 provider 호출 없이 전체 PASS. AST 테스트가 `dispatch.py`의 직접 stdout `print()` 0개를 판정하며, `rg` 결과에서 `dispatch.py`의 `print()`는 `file=sys.stderr` CLI 종료 진단뿐이고 stdout `print()`는 `dispatcher_observation.py`의 emitter 구현에만 존재한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. diff --git a/agent-task/archive/2026/07/m-stream-evidence-gate-core/work_log_3.log b/agent-task/archive/2026/07/m-stream-evidence-gate-core/work_log_3.md similarity index 100% rename from agent-task/archive/2026/07/m-stream-evidence-gate-core/work_log_3.log rename to agent-task/archive/2026/07/m-stream-evidence-gate-core/work_log_3.md