fix: #412/#413/#414 runtime stability and PR governance preflight #415

Merged
jihoson merged 2 commits from feature/issue-412-413-414-runtime-and-governance into main 2026-03-04 23:43:36 +09:00
8 changed files with 141 additions and 5 deletions
Showing only changes of commit 4200575c8e - Show all commits

View File

@@ -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` 문자열로 깨지지 않았는지 반드시 확인한다.

View File

@@ -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)
새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다.

View File

@@ -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() {

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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.

View File

@@ -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()

View File

@@ -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 추가