fix: #412/#413/#414 runtime stability and PR governance preflight #415
@@ -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` 문자열로 깨지지 않았는지 반드시 확인한다.
|
||||
|
||||
@@ -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)
|
||||
|
||||
새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,15 @@ 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:
|
||||
# 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(searchable):
|
||||
errors.append("body is missing TASK-ID traceability (e.g. TASK-OPS-001)")
|
||||
if not TEST_ID_PATTERN.search(searchable):
|
||||
errors.append("body is missing TEST-ID traceability (e.g. TEST-OPS-001)")
|
||||
return errors
|
||||
|
||||
|
||||
@@ -80,11 +92,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 +117,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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,54 @@ 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_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()
|
||||
|
||||
|
||||
@@ -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 추가
|
||||
|
||||
Reference in New Issue
Block a user