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.
|
- `scripts/tea_comment.sh` accepts stdin with `-` as body source.
|
||||||
- The helper fails fast when body looks like escaped-newline text only.
|
- 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 Body Post-Check (Mandatory)
|
||||||
|
|
||||||
PR 생성 직후 본문이 `\n` 문자열로 깨지지 않았는지 반드시 확인한다.
|
PR 생성 직후 본문이 `\n` 문자열로 깨지지 않았는지 반드시 확인한다.
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ Gitea 이슈/PR/코멘트 작업 전에 모든 에이전트는 아래를 먼저
|
|||||||
- `tea` 실패 시 동일 명령 재시도 전에 원인/수정사항을 PR 코멘트에 남긴다.
|
- `tea` 실패 시 동일 명령 재시도 전에 원인/수정사항을 PR 코멘트에 남긴다.
|
||||||
- 필요한 경우에만 Gitea API(`localhost:3000`)를 fallback으로 사용한다.
|
- 필요한 경우에만 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)
|
## Session Handover Gate (Mandatory)
|
||||||
|
|
||||||
새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다.
|
새 세션에서 구현/검증을 시작하기 전에 아래를 선행해야 한다.
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ check_signal() {
|
|||||||
|
|
||||||
find_live_pids() {
|
find_live_pids() {
|
||||||
# Detect live-mode process even when run_overnight pid files are absent.
|
# 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() {
|
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)
|
LIST_ITEM_PATTERN = re.compile(r"^\s*(?:-|\*|\d+\.)\s+\S+", re.MULTILINE)
|
||||||
FENCED_CODE_PATTERN = re.compile(r"```.*?```", re.DOTALL)
|
FENCED_CODE_PATTERN = re.compile(r"```.*?```", re.DOTALL)
|
||||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]*`")
|
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:
|
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)")
|
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] = []
|
errors: list[str] = []
|
||||||
searchable = _strip_code_segments(text)
|
searchable = _strip_code_segments(text)
|
||||||
if "\\n" in searchable:
|
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')")
|
errors.append("body is missing markdown section headers (e.g. '## Summary')")
|
||||||
if not LIST_ITEM_PATTERN.search(text):
|
if not LIST_ITEM_PATTERN.search(text):
|
||||||
errors.append("body is missing markdown list items")
|
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
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -80,11 +90,16 @@ def fetch_pr_body(pr_number: int) -> str:
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
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 = parser.add_mutually_exclusive_group(required=True)
|
||||||
group.add_argument("--pr", type=int, help="PR number to fetch via `tea api`")
|
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")
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +115,7 @@ def main() -> int:
|
|||||||
body = fetch_pr_body(args.pr)
|
body = fetch_pr_body(args.pr)
|
||||||
source = f"pr:{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:
|
if errors:
|
||||||
print("[FAIL] PR body validation failed")
|
print("[FAIL] PR body validation failed")
|
||||||
print(f"- source: {source}")
|
print(f"- source: {source}")
|
||||||
|
|||||||
@@ -4141,6 +4141,9 @@ async def run(settings: Settings) -> None:
|
|||||||
# Sync broker positions → DB to prevent double-buy on restart
|
# Sync broker positions → DB to prevent double-buy on restart
|
||||||
try:
|
try:
|
||||||
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
|
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:
|
except Exception as exc:
|
||||||
logger.warning("Startup position sync failed (non-fatal): %s", 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())
|
watchdog_pid = int(watchdog_pid_file.read_text(encoding="utf-8").strip())
|
||||||
with pytest.raises(ProcessLookupError):
|
with pytest.raises(ProcessLookupError):
|
||||||
os.kill(watchdog_pid, 0)
|
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",
|
"## Summary",
|
||||||
"- example uses `\\n` for explanation",
|
"- example uses `\\n` for explanation",
|
||||||
|
"- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001",
|
||||||
"```bash",
|
"```bash",
|
||||||
"printf 'line1\\nline2\\n'",
|
"printf 'line1\\nline2\\n'",
|
||||||
"```",
|
"```",
|
||||||
@@ -63,7 +64,7 @@ def test_validate_pr_body_text_passes_with_valid_markdown() -> None:
|
|||||||
text = "\n".join(
|
text = "\n".join(
|
||||||
[
|
[
|
||||||
"## Summary",
|
"## Summary",
|
||||||
"- item",
|
"- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001",
|
||||||
"",
|
"",
|
||||||
"## Validation",
|
"## Validation",
|
||||||
"```bash",
|
"```bash",
|
||||||
@@ -74,6 +75,34 @@ def test_validate_pr_body_text_passes_with_valid_markdown() -> None:
|
|||||||
assert module.validate_pr_body_text(text) == []
|
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:
|
def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None:
|
||||||
module = _load_module()
|
module = _load_module()
|
||||||
|
|
||||||
|
|||||||
@@ -145,3 +145,11 @@
|
|||||||
- next_ticket: #409
|
- next_ticket: #409
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
- risks_or_notes: #409 코드수정/검증 후 프로그램 재시작 및 24h 런타임 모니터링 수행, 모니터 이상 징후는 별도 이슈 발행
|
- 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