From 4200575c8ee1a059dd37d9654d65cdd958cefd93 Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 4 Mar 2026 23:27:12 +0900 Subject: [PATCH 1/2] fix: #412/#413/#414 runtime stability and PR governance preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #413 — runtime_verify_monitor.sh pipefail fix - find_live_pids() now captures pgrep output via local variable with || true so pipefail never triggers on no-match (pgrep exit 1) - Regression test added: monitor survives MAX_LOOPS=1 without crash ## #412 — startup crash logging improvement - Add asyncio.CancelledError catch in sync_positions_from_broker call so BaseException-level cancellations are logged before propagating - Provides evidence in run log if CancelledError causes future startup aborts ## #414 — PR governance preflight - validate_pr_body.py: add REQ-ID/TASK-ID/TEST-ID pattern checks (--no-governance flag to skip) - docs/workflow.md: new "PR Governance Preflight (Mandatory)" section - docs/commands.md: "PR Body Governance Preflight" section before tea pulls create - Tests: 4 new governance traceability tests in test_validate_pr_body.py Co-Authored-By: Claude Sonnet 4.6 --- docs/commands.md | 14 ++++++++ docs/workflow.md | 21 ++++++++++++ scripts/runtime_verify_monitor.sh | 5 ++- scripts/validate_pr_body.py | 21 ++++++++++-- src/main.py | 3 ++ tests/test_runtime_overnight_scripts.py | 43 +++++++++++++++++++++++++ tests/test_validate_pr_body.py | 31 +++++++++++++++++- workflow/session-handover.md | 8 +++++ 8 files changed, 141 insertions(+), 5 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 84c2aac..82e41ed 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,6 +59,20 @@ scripts/tea_comment.sh 374 /tmp/comment.md - `scripts/tea_comment.sh` accepts stdin with `-` as body source. - The helper fails fast when body looks like escaped-newline text only. +#### PR Body Governance Preflight (Mandatory before `tea pulls create`) + +PR 본문 파일 준비 후, **생성 전에** 아래 명령으로 형식 + 거버넌스 traceability를 검증한다. + +```bash +python3 scripts/validate_pr_body.py --body-file /tmp/pr_body.md +``` + +검증 항목: `\n` 이스케이프, 마크다운 헤더, 리스트, **REQ-ID, TASK-ID, TEST-ID** 포함. + +검증 실패 시: +- PR 본문에 실제 REQ-ID/TASK-ID/TEST-ID를 채운 뒤 재검증 통과 후에만 `tea pulls create` 실행 +- placeholder(`REQ-...`, `TASK-...`, `TEST-...`) 형태는 CI에서 실패 처리됨 + #### PR Body Post-Check (Mandatory) PR 생성 직후 본문이 `\n` 문자열로 깨지지 않았는지 반드시 확인한다. diff --git a/docs/workflow.md b/docs/workflow.md index 5549b20..c1300bb 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -30,6 +30,27 @@ Gitea 이슈/PR/코멘트 작업 전에 모든 에이전트는 아래를 먼저 - `tea` 실패 시 동일 명령 재시도 전에 원인/수정사항을 PR 코멘트에 남긴다. - 필요한 경우에만 Gitea API(`localhost:3000`)를 fallback으로 사용한다. +## PR Governance Preflight (Mandatory before `tea pr create`) + +`tea pulls create` 실행 전에 아래 명령으로 PR 본문을 검증해야 한다. + +```bash +# PR 본문을 파일로 저장한 뒤 검증 +python3 scripts/validate_pr_body.py --body-file /tmp/pr_body.md +``` + +검증 항목: +- `\n` 이스케이프 없음 +- 마크다운 섹션 헤더(`## ...`) 존재 +- 리스트 아이템 존재 +- **REQ-ID** (`REQ-XXX-NNN`) 포함 — 거버넌스 traceability 필수 +- **TASK-ID** (`TASK-XXX-NNN`) 포함 — 거버넌스 traceability 필수 +- **TEST-ID** (`TEST-XXX-NNN`) 포함 — 거버넌스 traceability 필수 + +강제 규칙: +- 검증 실패 시 PR 본문에 해당 ID를 보강하고 재검증 통과 후에만 PR 생성 +- REQ/TASK/TEST ID는 `docs/ouroboros/` 문서의 실제 ID 사용 (placeholder `REQ-...` 금지) + ## Session Handover Gate (Mandatory) 새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다. diff --git a/scripts/runtime_verify_monitor.sh b/scripts/runtime_verify_monitor.sh index 5ad92eb..d534e00 100755 --- a/scripts/runtime_verify_monitor.sh +++ b/scripts/runtime_verify_monitor.sh @@ -36,7 +36,10 @@ check_signal() { find_live_pids() { # Detect live-mode process even when run_overnight pid files are absent. - pgrep -af "[s]rc.main --mode=live" 2>/dev/null | awk '{print $1}' | tr '\n' ',' | sed 's/,$//' + # Use local variable to avoid pipefail triggering on pgrep no-match (exit 1). + local raw + raw=$(pgrep -af "[s]rc.main --mode=live" 2>/dev/null) || true + printf '%s\n' "$raw" | awk '{print $1}' | tr '\n' ',' | sed 's/,$//' } check_forbidden() { diff --git a/scripts/validate_pr_body.py b/scripts/validate_pr_body.py index 2d6ae4d..42609fb 100644 --- a/scripts/validate_pr_body.py +++ b/scripts/validate_pr_body.py @@ -16,6 +16,9 @@ HEADER_PATTERN = re.compile(r"^##\s+\S+", re.MULTILINE) LIST_ITEM_PATTERN = re.compile(r"^\s*(?:-|\*|\d+\.)\s+\S+", re.MULTILINE) FENCED_CODE_PATTERN = re.compile(r"```.*?```", re.DOTALL) INLINE_CODE_PATTERN = re.compile(r"`[^`]*`") +REQ_ID_PATTERN = re.compile(r"\bREQ-[A-Z0-9-]+-\d{3}\b") +TASK_ID_PATTERN = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b") +TEST_ID_PATTERN = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b") def _strip_code_segments(text: str) -> str: @@ -35,7 +38,7 @@ def resolve_tea_binary() -> str: raise RuntimeError("tea binary not found (checked PATH and ~/bin/tea)") -def validate_pr_body_text(text: str) -> list[str]: +def validate_pr_body_text(text: str, *, check_governance: bool = True) -> list[str]: errors: list[str] = [] searchable = _strip_code_segments(text) if "\\n" in searchable: @@ -46,6 +49,13 @@ def validate_pr_body_text(text: str) -> list[str]: errors.append("body is missing markdown section headers (e.g. '## Summary')") if not LIST_ITEM_PATTERN.search(text): errors.append("body is missing markdown list items") + if check_governance: + if not REQ_ID_PATTERN.search(text): + errors.append("body is missing REQ-ID traceability (e.g. REQ-OPS-001)") + if not TASK_ID_PATTERN.search(text): + errors.append("body is missing TASK-ID traceability (e.g. TASK-OPS-001)") + if not TEST_ID_PATTERN.search(text): + errors.append("body is missing TEST-ID traceability (e.g. TEST-OPS-001)") return errors @@ -80,11 +90,16 @@ def fetch_pr_body(pr_number: int) -> str: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Validate PR body markdown formatting and escaped-newline artifacts." + description="Validate PR body markdown formatting, escaped-newline artifacts, and governance traceability." ) group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--pr", type=int, help="PR number to fetch via `tea api`") group.add_argument("--body-file", type=Path, help="Path to markdown body file") + parser.add_argument( + "--no-governance", + action="store_true", + help="Skip REQ-ID/TASK-ID/TEST-ID governance traceability checks", + ) return parser.parse_args() @@ -100,7 +115,7 @@ def main() -> int: body = fetch_pr_body(args.pr) source = f"pr:{args.pr}" - errors = validate_pr_body_text(body) + errors = validate_pr_body_text(body, check_governance=not args.no_governance) if errors: print("[FAIL] PR body validation failed") print(f"- source: {source}") diff --git a/src/main.py b/src/main.py index 8c23e58..d19b172 100644 --- a/src/main.py +++ b/src/main.py @@ -4141,6 +4141,9 @@ async def run(settings: Settings) -> None: # Sync broker positions → DB to prevent double-buy on restart try: await sync_positions_from_broker(broker, overseas_broker, db_conn, settings) + except asyncio.CancelledError: + logger.error("Startup position sync cancelled — propagating shutdown") + raise except Exception as exc: logger.warning("Startup position sync failed (non-fatal): %s", exc) diff --git a/tests/test_runtime_overnight_scripts.py b/tests/test_runtime_overnight_scripts.py index a5b6182..7c640d2 100644 --- a/tests/test_runtime_overnight_scripts.py +++ b/tests/test_runtime_overnight_scripts.py @@ -158,3 +158,46 @@ def test_run_overnight_fails_when_process_exits_before_grace_period(tmp_path: Pa watchdog_pid = int(watchdog_pid_file.read_text(encoding="utf-8").strip()) with pytest.raises(ProcessLookupError): os.kill(watchdog_pid, 0) + + +def test_runtime_verify_monitor_survives_when_no_live_pid(tmp_path: Path) -> None: + """Regression test for #413: monitor loop must not exit when pgrep finds no live process. + + With set -euo pipefail, pgrep returning exit 1 (no match) would cause the + whole script to abort via the pipefail mechanism. The fix captures pgrep + output via a local variable with || true so pipefail is never triggered. + + Verifies that the script: (1) exits 0 after completing MAX_LOOPS=1, and + (2) logs a HEARTBEAT entry. Whether live_pids is 'none' or not depends on + what processes happen to be running; either way the script must not crash. + """ + log_dir = tmp_path / "overnight" + log_dir.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + env.update( + { + "ROOT_DIR": str(REPO_ROOT), + "LOG_DIR": str(log_dir), + "INTERVAL_SEC": "1", + "MAX_HOURS": "1", + "MAX_LOOPS": "1", + "POLICY_TZ": "UTC", + } + ) + completed = subprocess.run( + ["bash", str(RUNTIME_MONITOR)], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, ( + f"monitor exited non-zero (pipefail regression?): {completed.stderr}" + ) + log_text = _latest_runtime_log(log_dir) + assert "[INFO] runtime verify monitor started" in log_text + assert "[HEARTBEAT]" in log_text, "monitor did not complete a heartbeat cycle" + # live_pids may be 'none' (no match) or a pid (process found) — either is valid. + # The critical invariant is that the script survived the loop without pipefail abort. diff --git a/tests/test_validate_pr_body.py b/tests/test_validate_pr_body.py index f6c7b21..908995c 100644 --- a/tests/test_validate_pr_body.py +++ b/tests/test_validate_pr_body.py @@ -37,6 +37,7 @@ def test_validate_pr_body_text_allows_escaped_newline_in_code_blocks() -> None: [ "## Summary", "- example uses `\\n` for explanation", + "- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001", "```bash", "printf 'line1\\nline2\\n'", "```", @@ -63,7 +64,7 @@ def test_validate_pr_body_text_passes_with_valid_markdown() -> None: text = "\n".join( [ "## Summary", - "- item", + "- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001", "", "## Validation", "```bash", @@ -74,6 +75,34 @@ def test_validate_pr_body_text_passes_with_valid_markdown() -> None: assert module.validate_pr_body_text(text) == [] +def test_validate_pr_body_text_detects_missing_req_id() -> None: + module = _load_module() + text = "## Summary\n- TASK-OPS-001 / TEST-OPS-001 item\n" + errors = module.validate_pr_body_text(text) + assert any("REQ-ID" in err for err in errors) + + +def test_validate_pr_body_text_detects_missing_task_id() -> None: + module = _load_module() + text = "## Summary\n- REQ-OPS-001 / TEST-OPS-001 item\n" + errors = module.validate_pr_body_text(text) + assert any("TASK-ID" in err for err in errors) + + +def test_validate_pr_body_text_detects_missing_test_id() -> None: + module = _load_module() + text = "## Summary\n- REQ-OPS-001 / TASK-OPS-001 item\n" + errors = module.validate_pr_body_text(text) + assert any("TEST-ID" in err for err in errors) + + +def test_validate_pr_body_text_skips_governance_when_disabled() -> None: + module = _load_module() + text = "## Summary\n- item without any IDs\n" + errors = module.validate_pr_body_text(text, check_governance=False) + assert not any("REQ-ID" in err or "TASK-ID" in err or "TEST-ID" in err for err in errors) + + def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None: module = _load_module() diff --git a/workflow/session-handover.md b/workflow/session-handover.md index 66dcbda..5d0c578 100644 --- a/workflow/session-handover.md +++ b/workflow/session-handover.md @@ -145,3 +145,11 @@ - next_ticket: #409 - process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes - risks_or_notes: #409 코드수정/검증 후 프로그램 재시작 및 24h 런타임 모니터링 수행, 모니터 이상 징후는 별도 이슈 발행 + +### 2026-03-04 | session=claude-issues412-413-414 +- branch: feature/issue-412-413-414-runtime-and-governance +- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md +- open_issues_reviewed: #412, #413, #414 +- next_ticket: #412, #413, #414 +- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes +- risks_or_notes: #413 pipefail fix (find_live_pids), #412 startup crash 로깅 강화, #414 PR 거버넌스 preflight 추가 From 01e4e0f43fedb50b32ff4c427cc5cda33c7de71d Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 4 Mar 2026 23:37:10 +0900 Subject: [PATCH 2/2] fix: governance ID check must ignore code blocks (review #415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ/TASK/TEST ID 패턴 매칭을 _strip_code_segments() 결과에 적용하여 코드 펜스/인라인 코드 안에만 ID를 넣어 검증을 우회하는 케이스를 차단. 회귀 테스트 추가: test_validate_pr_body_text_rejects_governance_ids_in_code_block_only Co-Authored-By: Claude Sonnet 4.6 --- scripts/validate_pr_body.py | 8 +++++--- tests/test_validate_pr_body.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/validate_pr_body.py b/scripts/validate_pr_body.py index 42609fb..f0e1891 100644 --- a/scripts/validate_pr_body.py +++ b/scripts/validate_pr_body.py @@ -50,11 +50,13 @@ def validate_pr_body_text(text: str, *, check_governance: bool = True) -> list[s if not LIST_ITEM_PATTERN.search(text): errors.append("body is missing markdown list items") if check_governance: - if not REQ_ID_PATTERN.search(text): + # Check governance IDs against code-stripped text so IDs hidden in code + # blocks or inline code are not counted (prevents spoof via code fences). + if not REQ_ID_PATTERN.search(searchable): errors.append("body is missing REQ-ID traceability (e.g. REQ-OPS-001)") - if not TASK_ID_PATTERN.search(text): + if not TASK_ID_PATTERN.search(searchable): errors.append("body is missing TASK-ID traceability (e.g. TASK-OPS-001)") - if not TEST_ID_PATTERN.search(text): + if not TEST_ID_PATTERN.search(searchable): errors.append("body is missing TEST-ID traceability (e.g. TEST-OPS-001)") return errors diff --git a/tests/test_validate_pr_body.py b/tests/test_validate_pr_body.py index 908995c..b02bddc 100644 --- a/tests/test_validate_pr_body.py +++ b/tests/test_validate_pr_body.py @@ -103,6 +103,26 @@ def test_validate_pr_body_text_skips_governance_when_disabled() -> None: assert not any("REQ-ID" in err or "TASK-ID" in err or "TEST-ID" in err for err in errors) +def test_validate_pr_body_text_rejects_governance_ids_in_code_block_only() -> None: + """Regression for review comment: IDs inside code fences must not count.""" + module = _load_module() + text = "\n".join( + [ + "## Summary", + "- no governance IDs in narrative text", + "```text", + "REQ-FAKE-999", + "TASK-FAKE-999", + "TEST-FAKE-999", + "```", + ] + ) + errors = module.validate_pr_body_text(text) + assert any("REQ-ID" in err for err in errors) + assert any("TASK-ID" in err for err in errors) + assert any("TEST-ID" in err for err in errors) + + def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None: module = _load_module()