feat: add Gitea Actions workflow to run agent YAMLs via AgentBackend API
- .gitea/workflows/run-agent.yml: workflow_dispatch with yaml_path input; runs a specified agent YAML through upload-agent + SSE (SKILL.md §0.6 path A) - scripts/run_agent_api.py: SSE runner with auto-confirm on stage_complete (with retry), stage_error -> cancel + fail, reconnect on drops, --max-runtime guard under the 3h Gitea/act_runner task caps, SIGTERM-safe session cancellation, events.jsonl/final_output/summary artifacts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
# 지정한 Agent YAML 을 AgentBackend API 로 실행하는 Gitea Actions 워크플로우.
|
||||||
|
#
|
||||||
|
# 사용법: Gitea 저장소 → Actions 탭 → "Run Agent YAML via API" → Run workflow
|
||||||
|
# yaml_path 에 repo 상대경로 입력, 예:
|
||||||
|
# Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/v.5/Stage_1_Part_4.yml
|
||||||
|
#
|
||||||
|
# 요구사항:
|
||||||
|
# - 저장소 설정에서 Actions 활성화 + act_runner 등록 (runs-on: ubuntu-latest 라벨)
|
||||||
|
# - runner 에서 legalpoc.eroomai.com 접근 가능해야 함
|
||||||
|
#
|
||||||
|
# 실행 흐름 (SKILL.md §0.6 경로 A):
|
||||||
|
# upload-agent 등록 → SSE start → stage_complete 자동 confirm → execution_complete
|
||||||
|
# stage_error 발생 시 세션을 cancel 하고 실패 처리한다.
|
||||||
|
# 전체 이벤트 로그 / final_output / summary 는 아티팩트로 저장된다.
|
||||||
|
#
|
||||||
|
# 실행 시간 상한 (주의):
|
||||||
|
# Gitea 의 [actions] ENDLESS_TASK_TIMEOUT 과 act_runner 의 runner.timeout 기본값이
|
||||||
|
# 각각 3h 라서, timeout-minutes 를 아무리 크게 줘도 3h 에서 강제 종료된다.
|
||||||
|
# 강제 종료되면 if: always() 아티팩트 업로드도 건너뛰므로,
|
||||||
|
# 스크립트의 --max-runtime(기본 9000s=150m) 이 먼저 세션을 정리하고 종료하도록
|
||||||
|
# max_runtime < timeout-minutes(175m) < 3h 순서를 유지한다.
|
||||||
|
# 3h 이상 돌려야 하면: app.ini 의 ENDLESS_TASK_TIMEOUT, act_runner config 의
|
||||||
|
# runner.timeout, 아래 timeout-minutes, max_runtime 입력을 모두 함께 올릴 것.
|
||||||
|
|
||||||
|
name: Run Agent YAML via API
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
yaml_path:
|
||||||
|
description: '실행할 Agent YAML 경로 (repo 상대경로)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
user_id:
|
||||||
|
description: 'AgentBackend user_id'
|
||||||
|
required: false
|
||||||
|
default: 'jsahn'
|
||||||
|
type: string
|
||||||
|
workspace_id:
|
||||||
|
description: 'AgentBackend workspace_id'
|
||||||
|
required: false
|
||||||
|
default: 'e1d0baed-cec9-44af-9989-b7aab09bcfd1'
|
||||||
|
type: string
|
||||||
|
user_input:
|
||||||
|
description: 'Agent 에 전달할 user_input'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
type: string
|
||||||
|
start_stage_index:
|
||||||
|
description: '시작 stage 인덱스 (0-based)'
|
||||||
|
required: false
|
||||||
|
default: '0'
|
||||||
|
type: string
|
||||||
|
api_base:
|
||||||
|
description: 'AgentBackend API base URL'
|
||||||
|
required: false
|
||||||
|
default: 'https://legalpoc.eroomai.com/api'
|
||||||
|
type: string
|
||||||
|
max_runtime_seconds:
|
||||||
|
description: '총 실행 시간 상한(초). Gitea/act_runner 3h 상한보다 짧게'
|
||||||
|
required: false
|
||||||
|
default: '9000'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-agent:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 175
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: python -m pip install --quiet httpx httpx-sse
|
||||||
|
|
||||||
|
- name: Run agent via API
|
||||||
|
env:
|
||||||
|
YAML_PATH: ${{ inputs.yaml_path }}
|
||||||
|
API_BASE: ${{ inputs.api_base }}
|
||||||
|
AGENT_USER_ID: ${{ inputs.user_id }}
|
||||||
|
AGENT_WORKSPACE_ID: ${{ inputs.workspace_id }}
|
||||||
|
AGENT_USER_INPUT: ${{ inputs.user_input }}
|
||||||
|
START_STAGE_INDEX: ${{ inputs.start_stage_index }}
|
||||||
|
MAX_RUNTIME_SECONDS: ${{ inputs.max_runtime_seconds }}
|
||||||
|
# '=' 형식 필수: user_input 이 '-' 로 시작하는 자유 텍스트여도 argparse 가 값으로 인식
|
||||||
|
run: |
|
||||||
|
python scripts/run_agent_api.py \
|
||||||
|
--yaml-path="$YAML_PATH" \
|
||||||
|
--api-base="$API_BASE" \
|
||||||
|
--user-id="$AGENT_USER_ID" \
|
||||||
|
--workspace-id="$AGENT_WORKSPACE_ID" \
|
||||||
|
--user-input="$AGENT_USER_INPUT" \
|
||||||
|
--start-stage-index="$START_STAGE_INDEX" \
|
||||||
|
--max-runtime="$MAX_RUNTIME_SECONDS" \
|
||||||
|
--output-dir=agent_run_output
|
||||||
|
|
||||||
|
- name: Upload run artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: agent-run-${{ github.run_number }}
|
||||||
|
path: agent_run_output/
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Upload an Agent YAML to AgentBackend and execute it via SSE.
|
||||||
|
|
||||||
|
Flow (SKILL.md §0.6 — 경로 A):
|
||||||
|
1. POST {api_base}/upload-agent?user_id=... (multipart YAML 등록)
|
||||||
|
2. POST {api_base}/sse/agent/{name}/start (SSE 실행)
|
||||||
|
3. stage_complete 이벤트 수신 시 자동 confirm (CI 무인 실행, 재시도 포함)
|
||||||
|
4. 연결 끊김 시 /sse/agent/{name}/reconnect/{sid} (재연결)
|
||||||
|
5. stage_error / --max-runtime 초과 / SIGTERM 시 세션 cancel 후 종료
|
||||||
|
|
||||||
|
Exit code: 0 = execution_complete / 1 = 그 외 (stage_error, stopped, timeout, ...)
|
||||||
|
|
||||||
|
--output-dir 에 저장되는 파일:
|
||||||
|
events.jsonl — 수신한 모든 SSE 이벤트 (한 줄당 1개)
|
||||||
|
final_output.txt — execution_complete 이벤트의 final_outputs (stage 별 출력 dict)
|
||||||
|
summary.md — 실행 요약 (GITHUB_STEP_SUMMARY 에도 기록)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from httpx_sse import SSEError, connect_sse
|
||||||
|
|
||||||
|
PRINT_TRUNCATE = 500
|
||||||
|
|
||||||
|
|
||||||
|
class RunTimeout(Exception):
|
||||||
|
"""--max-runtime 초과."""
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
print(f"[{_now()}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(value: object, limit: int = PRINT_TRUNCATE) -> str:
|
||||||
|
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
||||||
|
return text if len(text) <= limit else text[:limit] + f"... (+{len(text) - limit} chars)"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunner:
|
||||||
|
def __init__(self, args: argparse.Namespace) -> None:
|
||||||
|
self.api_base = args.api_base.rstrip("/")
|
||||||
|
self.yaml_path = Path(args.yaml_path)
|
||||||
|
self.user_id = args.user_id
|
||||||
|
self.workspace_id = args.workspace_id
|
||||||
|
self.user_input = args.user_input
|
||||||
|
self.start_stage_index = args.start_stage_index
|
||||||
|
self.read_timeout = args.read_timeout
|
||||||
|
self.max_reconnects = args.max_reconnects
|
||||||
|
self.max_runtime = args.max_runtime
|
||||||
|
self.output_dir = Path(args.output_dir)
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.agent_name: str = ""
|
||||||
|
self.session_id: str = ""
|
||||||
|
self.final_status: str = "unknown"
|
||||||
|
self.final_error: str = ""
|
||||||
|
self.event_counts: dict[str, int] = {}
|
||||||
|
self.stages_confirmed = 0
|
||||||
|
self.started_at = time.monotonic()
|
||||||
|
self.deadline = self.started_at + self.max_runtime
|
||||||
|
|
||||||
|
self.sse_client = httpx.Client(timeout=httpx.Timeout(30.0))
|
||||||
|
# action/cancel 등 단발 요청은 SSE 스트림과 분리된 클라이언트로 보낸다
|
||||||
|
self.api_client = httpx.Client(timeout=60.0)
|
||||||
|
|
||||||
|
self.events_file = (self.output_dir / "events.jsonl").open("a", encoding="utf-8")
|
||||||
|
|
||||||
|
def _remaining(self) -> float:
|
||||||
|
return self.deadline - time.monotonic()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ upload
|
||||||
|
|
||||||
|
def upload(self) -> None:
|
||||||
|
if not self.yaml_path.is_file():
|
||||||
|
raise SystemExit(f"ERROR: YAML not found: {self.yaml_path}")
|
||||||
|
|
||||||
|
log(f"Uploading agent YAML: {self.yaml_path}")
|
||||||
|
with self.yaml_path.open("rb") as f:
|
||||||
|
resp = self.api_client.post(
|
||||||
|
f"{self.api_base}/upload-agent",
|
||||||
|
params={"user_id": self.user_id},
|
||||||
|
files={"file": (self.yaml_path.name, f, "application/x-yaml")},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise SystemExit(f"ERROR: upload-agent failed HTTP {resp.status_code}: {resp.text[:1000]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if not data.get("success") or not data.get("agent_name"):
|
||||||
|
raise SystemExit(f"ERROR: upload-agent rejected: {json.dumps(data, ensure_ascii=False)[:1000]}")
|
||||||
|
|
||||||
|
self.agent_name = data["agent_name"]
|
||||||
|
log(f"Agent registered: {self.agent_name} (stages={data.get('stages')})")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def cancel_session(self) -> None:
|
||||||
|
if not (self.agent_name and self.session_id):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.api_client.post(
|
||||||
|
f"{self.api_base}/sse/agent/{quote(self.agent_name, safe='')}"
|
||||||
|
f"/cancel/{self.session_id}"
|
||||||
|
)
|
||||||
|
log(f"Cancel requested for session {self.session_id}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log(f"WARNING: cancel request failed: {exc}")
|
||||||
|
|
||||||
|
def _send_action(self, action: str, attempts: int = 5) -> bool:
|
||||||
|
"""Send a stage action; retry transient failures inside the server's 600s wait window."""
|
||||||
|
url = (
|
||||||
|
f"{self.api_base}/sse/agent/{quote(self.agent_name, safe='')}"
|
||||||
|
f"/action/{self.session_id}"
|
||||||
|
)
|
||||||
|
for i in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
resp = self.api_client.post(url, json={"action": action})
|
||||||
|
if resp.status_code == 200:
|
||||||
|
log(f" -> action '{action}' sent")
|
||||||
|
return True
|
||||||
|
if resp.status_code in (400, 404):
|
||||||
|
# 세션 소멸/거부 — 재시도 무의미
|
||||||
|
log(f" -> action '{action}' failed HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return False
|
||||||
|
log(f" -> action '{action}' attempt {i}/{attempts} HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
log(f" -> action '{action}' attempt {i}/{attempts} request error: {exc}")
|
||||||
|
if i < attempts:
|
||||||
|
time.sleep(min(2 ** i, 60))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ events
|
||||||
|
|
||||||
|
def _record(self, event: dict) -> None:
|
||||||
|
event["_received_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.events_file.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||||
|
self.events_file.flush()
|
||||||
|
etype = event.get("type", "unknown")
|
||||||
|
self.event_counts[etype] = self.event_counts.get(etype, 0) + 1
|
||||||
|
|
||||||
|
def _handle(self, event: dict) -> bool:
|
||||||
|
"""Returns True when execution reached a terminal state."""
|
||||||
|
self._record(event)
|
||||||
|
etype = event.get("type", "unknown")
|
||||||
|
|
||||||
|
if etype == "session_started":
|
||||||
|
self.session_id = event.get("session_id", self.session_id)
|
||||||
|
log(
|
||||||
|
f"Session started: {self.session_id} "
|
||||||
|
f"(total_stages={event.get('total_stages')}, start_index={event.get('start_stage_index')})"
|
||||||
|
)
|
||||||
|
elif etype == "stage_start":
|
||||||
|
log(f"=== Stage start: index={event.get('stage_index')} {event.get('stage_name', '')}")
|
||||||
|
elif etype == "task_start":
|
||||||
|
log(f" task start: {event.get('task_name', '?')}")
|
||||||
|
elif etype == "task_iteration":
|
||||||
|
log(f" task iter : {event.get('task_name', '?')} #{event.get('iteration', '?')}")
|
||||||
|
elif etype == "task_complete":
|
||||||
|
log(f" task done : {event.get('task_name', '?')}")
|
||||||
|
elif etype == "stage_complete":
|
||||||
|
log(f"=== Stage complete: index={event.get('stage_index')} — auto-confirming")
|
||||||
|
if self._send_action("confirm"):
|
||||||
|
self.stages_confirmed += 1
|
||||||
|
else:
|
||||||
|
# confirm 미전달 상태로 방치하면 서버 600s action-timeout 까지 세션이 잠긴다
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = "confirm action could not be delivered — cancelling session"
|
||||||
|
log(f"ERROR: {self.final_error}")
|
||||||
|
self.cancel_session()
|
||||||
|
return True
|
||||||
|
elif etype == "stage_error":
|
||||||
|
# 서버는 stage_error 후 클라이언트 개입 없이는 같은 stage 를 재실행하거나
|
||||||
|
# 실패 stage 를 건너뛰므로, CI 에서는 즉시 세션을 취소하고 실패 처리한다.
|
||||||
|
self.final_status = "stage_error"
|
||||||
|
self.final_error = str(event.get("error", ""))
|
||||||
|
log(f"=== Stage ERROR: index={event.get('stage_index')} — {truncate(self.final_error)}")
|
||||||
|
self.cancel_session()
|
||||||
|
return True
|
||||||
|
elif etype == "execution_complete":
|
||||||
|
self.final_status = "completed"
|
||||||
|
# 현행 백엔드는 final_outputs(stage 별 dict), 구버전 문서는 final_output
|
||||||
|
final_output = event.get("final_outputs") or event.get("final_output", "")
|
||||||
|
if not final_output:
|
||||||
|
log("WARNING: execution_complete has neither final_outputs nor final_output")
|
||||||
|
out_path = self.output_dir / "final_output.txt"
|
||||||
|
out_path.write_text(
|
||||||
|
final_output if isinstance(final_output, str)
|
||||||
|
else json.dumps(final_output, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
log(f"Execution complete. final_output saved to {out_path}")
|
||||||
|
return True
|
||||||
|
elif etype == "execution_stopped":
|
||||||
|
self.final_status = "stopped"
|
||||||
|
self.final_error = str(event.get("reason") or event.get("message") or "")
|
||||||
|
log(f"Execution STOPPED: {self.final_error}")
|
||||||
|
return True
|
||||||
|
elif etype == "error":
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = str(event.get("error", ""))
|
||||||
|
log(f"Execution ERROR: {truncate(self.final_error)}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log(f" event [{etype}]: {truncate({k: v for k, v in event.items() if k != 'type'})}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ stream
|
||||||
|
|
||||||
|
def _stream(self, mode: str) -> bool:
|
||||||
|
"""Open one SSE connection and consume events. Returns True on terminal event."""
|
||||||
|
remaining = self._remaining()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise RunTimeout()
|
||||||
|
|
||||||
|
if mode == "start":
|
||||||
|
url = f"{self.api_base}/sse/agent/{quote(self.agent_name, safe='')}/start"
|
||||||
|
body = {
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"user_input": self.user_input,
|
||||||
|
"start_stage_index": self.start_stage_index,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
url = (
|
||||||
|
f"{self.api_base}/sse/agent/{quote(self.agent_name, safe='')}"
|
||||||
|
f"/reconnect/{self.session_id}"
|
||||||
|
)
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
# read timeout: 이벤트 간 무응답 상한이며 남은 실행 예산을 넘지 않게 잡는다
|
||||||
|
timeout = httpx.Timeout(
|
||||||
|
connect=30.0,
|
||||||
|
read=min(self.read_timeout, max(remaining, 30.0)),
|
||||||
|
write=30.0,
|
||||||
|
pool=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
log(f"SSE {mode}: {url}")
|
||||||
|
with connect_sse(self.sse_client, "POST", url, json=body, timeout=timeout) as event_source:
|
||||||
|
for sse in event_source.iter_sse():
|
||||||
|
if self._remaining() <= 0:
|
||||||
|
raise RunTimeout()
|
||||||
|
if not sse.data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(sse.data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log(f" (unparseable SSE data) {truncate(sse.data)}")
|
||||||
|
continue
|
||||||
|
if self._handle(event):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
try:
|
||||||
|
self.upload()
|
||||||
|
|
||||||
|
mode = "start"
|
||||||
|
reconnects = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if self._stream(mode):
|
||||||
|
break
|
||||||
|
# 스트림이 종료 이벤트 없이 닫힘 → 재연결 시도
|
||||||
|
raise ConnectionError("SSE stream ended without a terminal event")
|
||||||
|
except RunTimeout:
|
||||||
|
self.final_status = "timeout"
|
||||||
|
self.final_error = (
|
||||||
|
f"--max-runtime {int(self.max_runtime)}s exceeded — cancelling session"
|
||||||
|
)
|
||||||
|
log(f"ERROR: {self.final_error}")
|
||||||
|
self.cancel_session()
|
||||||
|
break
|
||||||
|
except SSEError as exc:
|
||||||
|
# 서버가 SSE 가 아닌 응답을 반환 (404/500 등)
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = f"SSE handshake failed ({mode}): {exc}"
|
||||||
|
log(f"ERROR: {self.final_error}")
|
||||||
|
break
|
||||||
|
except (httpx.HTTPError, ConnectionError) as exc:
|
||||||
|
if self.final_status != "unknown":
|
||||||
|
break
|
||||||
|
reconnects += 1
|
||||||
|
if not self.session_id or reconnects > self.max_reconnects:
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = (
|
||||||
|
f"connection lost ({exc}); reconnect attempts exhausted "
|
||||||
|
f"({reconnects - 1}/{self.max_reconnects}). "
|
||||||
|
f"과거 세션 조회: GET {self.api_base}/history/sessions"
|
||||||
|
f"?user_id={self.user_id}&agent_name={self.agent_name}"
|
||||||
|
)
|
||||||
|
log(f"ERROR: {self.final_error}")
|
||||||
|
self.cancel_session()
|
||||||
|
break
|
||||||
|
wait = min(10 * reconnects, 60)
|
||||||
|
log(f"Connection lost ({exc}); reconnect {reconnects}/{self.max_reconnects} in {wait}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
mode = "reconnect"
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# SIGINT / SIGTERM(핸들러가 KeyboardInterrupt 로 변환) — 세션 정리 후 종료
|
||||||
|
self.final_status = "interrupted"
|
||||||
|
self.final_error = "interrupted by SIGINT/SIGTERM — session cancelled"
|
||||||
|
log(self.final_error)
|
||||||
|
self.cancel_session()
|
||||||
|
except SystemExit as exc:
|
||||||
|
# upload() 의 ERROR 경로 — summary 에 남기고 실패 처리
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = str(exc)
|
||||||
|
log(self.final_error)
|
||||||
|
except Exception as exc: # noqa: BLE001 — 어떤 실패든 summary 를 남긴다
|
||||||
|
self.final_status = "error"
|
||||||
|
self.final_error = f"unexpected {type(exc).__name__}: {exc}"
|
||||||
|
log(f"ERROR: {self.final_error}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._write_summary()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f"WARNING: summary write failed: {exc}")
|
||||||
|
self.events_file.close()
|
||||||
|
|
||||||
|
return 0 if self.final_status == "completed" else 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ report
|
||||||
|
|
||||||
|
def _write_summary(self) -> None:
|
||||||
|
elapsed = int(time.monotonic() - self.started_at)
|
||||||
|
status_icon = "✅" if self.final_status == "completed" else "❌"
|
||||||
|
lines = [
|
||||||
|
"# Agent Run Summary",
|
||||||
|
"",
|
||||||
|
"| 항목 | 값 |",
|
||||||
|
"|------|-----|",
|
||||||
|
f"| Agent | `{self.agent_name or '-'}` |",
|
||||||
|
f"| YAML | `{self.yaml_path}` |",
|
||||||
|
f"| Session | `{self.session_id or '-'}` |",
|
||||||
|
f"| user_id / workspace_id | `{self.user_id}` / `{self.workspace_id}` |",
|
||||||
|
f"| 최종 상태 | {status_icon} `{self.final_status}` |",
|
||||||
|
f"| 소요 시간 | {elapsed // 60}m {elapsed % 60}s (상한 {int(self.max_runtime) // 60}m) |",
|
||||||
|
f"| 확인(confirm)한 stage 수 | {self.stages_confirmed} |",
|
||||||
|
]
|
||||||
|
if self.final_error:
|
||||||
|
lines += ["", f"**오류/중단 사유:** {self.final_error}"]
|
||||||
|
lines += ["", "## 이벤트 수신 통계", "", "| type | count |", "|------|-------|"]
|
||||||
|
for etype, count in sorted(self.event_counts.items()):
|
||||||
|
lines.append(f"| {etype} | {count} |")
|
||||||
|
summary = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
(self.output_dir / "summary.md").write_text(summary, encoding="utf-8")
|
||||||
|
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||||
|
if step_summary:
|
||||||
|
with open(step_summary, "a", encoding="utf-8") as f:
|
||||||
|
f.write(summary)
|
||||||
|
log(f"Summary written ({self.final_status}, {elapsed}s)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("--yaml-path", required=True, help="실행할 Agent YAML 경로 (repo 상대/절대)")
|
||||||
|
p.add_argument("--api-base", default="https://legalpoc.eroomai.com/api")
|
||||||
|
p.add_argument("--user-id", default="jsahn")
|
||||||
|
p.add_argument("--workspace-id", default="e1d0baed-cec9-44af-9989-b7aab09bcfd1")
|
||||||
|
p.add_argument("--user-input", default="")
|
||||||
|
p.add_argument("--start-stage-index", type=int, default=0)
|
||||||
|
p.add_argument("--output-dir", default="agent_run_output")
|
||||||
|
p.add_argument("--read-timeout", type=float, default=1800.0,
|
||||||
|
help="SSE 이벤트 간 최대 대기 초 (초과 시 재연결)")
|
||||||
|
p.add_argument("--max-reconnects", type=int, default=5)
|
||||||
|
p.add_argument("--max-runtime", type=float, default=9000.0,
|
||||||
|
help="총 실행 시간 상한 초. 초과 시 세션 cancel 후 exit 1. "
|
||||||
|
"Gitea/act_runner 의 3h 태스크 상한 및 job timeout-minutes 보다 짧게 잡을 것")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_interrupt(signum, frame): # noqa: ARG001
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
# job timeout/취소 시 SIGTERM 이 오므로 KeyboardInterrupt 경로로 합류시켜 세션을 정리한다
|
||||||
|
signal.signal(signal.SIGTERM, _raise_interrupt)
|
||||||
|
runner = AgentRunner(args)
|
||||||
|
sys.exit(runner.run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user