#!/usr/bin/env python3 """런 원장을 쓰는 도구. 반쪽 상태를 만들지 않고, 끊긴 자리에서 다시 시작한다. `runs/<프로젝트>//run.json` 은 지금까지 손으로 썼다. 그래서 셋이 없었다. - **반쪽 파일.** `json.dump` 로 바로 쓰면 중간에 끊긴 원장이 남는다. 다음 세션이 그것을 읽으면 런이 통째로 사라진 것처럼 보인다 - **어디서 끊겼는지.** 단계마다 시작·끝이 없어 「S5 를 돌다 말았다」와 「S5 를 아직 안 시작했다」가 `PENDING` 하나로 같아 보인다 - **원장 밖의 시간.** 런 도중에 끼어든 일(라이더)이 어디에도 안 남는다. 이 배치에서 검증 한 작업의 누적 24분 가운데 17분 50초가 라이더였는데 상태 파일에 그 구분이 없었다 `bin/task.py` 가 이 계약의 초안이다 — flock 을 잡고 임시 파일에 완성한 뒤 `os.replace` 로 바꾸고, `startedAt` 부터 지금까지를 누적에 더한다. 여기서는 그것을 `runs/` 쪽으로 넓힌다. **새로 만드는 것이 아니라 같은 규약을 옮기는 것이다.** python3 scripts/run-ledger.py open <원장> --project P --record R python3 scripts/run-ledger.py begin <원장> --stage S3 # runBy 는 계약값을 쓴다 python3 scripts/run-ledger.py gate <원장> --stage S3 --cmd "..." --exit 0 python3 scripts/run-ledger.py end <원장> --stage S3 --status DONE --echo "..." --output ... python3 scripts/run-ledger.py rider <원장> --id R11 --why "..." --seconds 1070 python3 scripts/run-ledger.py status <원장> """ from __future__ import annotations import argparse import datetime import fcntl import json import os import subprocess import sys import tempfile ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TEMPLATE = os.path.join( ROOT, ".agents/skills/running-tech-log-pipeline/templates/run.json") STAGES = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"] UNSKIPPABLE = {"S3", "S5", "S6"} OPEN_STATES = {"RUNNING"} REVISION_FIELD = "skillRevision" class Contract(Exception): """계약 위반. 도구가 거절한 것이지 실패가 아니다.""" def _now() -> str: return datetime.datetime.now().astimezone().isoformat(timespec="seconds") def _git(*args: str) -> str | None: """저장소에 묻는다. 실패는 빈 문자열이 아니라 `None` 이다.""" try: p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True, encoding="utf-8", errors="replace", timeout=30) except (OSError, subprocess.SubprocessError): return None return p.stdout if p.returncode == 0 else None def _skill_revision(skill: str) -> str | None: """지금 이 스킬의 글자를 담고 있는 커밋. 영수증(`skillEcho`)은 그때 SKILL.md 에 있던 문장인데, 스킬은 나중에 고쳐진다. 그러면 검사기가 「위조」와 「그 뒤에 고쳐졌다」를 가르려고 이력을 훑어야 한다. 그 시점 커밋을 여기서 적어 두면 훑지 않고 그 커밋 하나만 본다. 작업 트리가 그 커밋과 다르면 `None` 이다 — 모르는 리비전을 지어내지 않는다. 그런 원장은 검사기에서 이력 훑기로 떨어지고, 그것이 맞는 결과다. """ if not skill: return None rel = f".agents/skills/{skill}" dirty = _git("status", "--porcelain", "--", rel) if dirty is None or dirty.strip(): return None out = _git("log", "-n", "1", "--format=%H", "--", rel) return (out or "").strip() or None class Ledger: """flock 을 잡고 읽어서, 임시 파일에 완성한 뒤 원자적으로 바꾼다. `os.replace` 는 같은 파일 시스템에서 원자적이다. 중간에 끊겨도 읽는 쪽은 **이전 판이나 다음 판 중 하나**를 본다. 반쪽 파일을 볼 수 없다. """ def __init__(self, path: str, create: bool = False) -> None: self.path = os.path.abspath(path) if not create and not os.path.exists(self.path): raise Contract(f"그런 원장이 없다: {path}") os.makedirs(os.path.dirname(self.path), exist_ok=True) self._lockpath = self.path + ".lock" self._fh = open(self._lockpath, "a+") def __enter__(self) -> "Ledger": fcntl.flock(self._fh, fcntl.LOCK_EX) self.data = (json.load(open(self.path, encoding="utf-8")) if os.path.exists(self.path) else {}) return self def __exit__(self, *exc) -> None: fcntl.flock(self._fh, fcntl.LOCK_UN) self._fh.close() def save(self) -> None: self.data["revision"] = int(self.data.get("revision", 0)) + 1 self.data["updatedAt"] = _now() fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path), prefix=".run.", suffix=".json") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(self.data, fh, ensure_ascii=False, indent=2) fh.write("\n") fh.flush() os.fsync(fh.fileno()) os.replace(tmp, self.path) except BaseException: os.path.exists(tmp) and os.unlink(tmp) raise def stage(self, sid: str) -> dict: for st in self.data.get("stages", []): if st["id"] == sid: return st raise Contract(f"그런 단계가 없다: {sid}. 단계는 {', '.join(STAGES)} 뿐이다") def running(self) -> dict | None: return next((s for s in self.data.get("stages", []) if s.get("status") in OPEN_STATES), None) def _require_owner(st: dict, session: str | None, generation: int | None) -> None: """이 단계를 지금 쥔 세션인지 본다. `plan/02` B-005 — 「이전 작업자의 종료를 확인하고 재배정한 시도 번호와 맞는 결과만 받는다.」 원장에는 그것이 없어서, 이어받은 단계에 앞 세션이 관문을 써도 들어갔다. **파일은 안 깨진다. 깨지지 않은 채로 두 세션의 기록이 섞인다.** """ cur_gen = int(st.get("generation", 0)) if generation is not None and generation != cur_gen: raise Contract( f"{st['id']} 의 현재 세대는 {cur_gen} 인데 쓰기는 {generation} 이다. " f"그 사이에 다른 세션이 이어받았다 — begin 으로 다시 잡는다") owner = st.get("owner") if owner and session and session != owner: raise Contract( f"{st['id']} 은(는) 지금 {owner} 가 쥐고 있다. {session} 의 쓰기는 받지 않는다 — " f"이어받으려면 begin 으로 세대를 올린다") if owner and not session: raise Contract( f"{st['id']} 은(는) {owner} 가 쥐고 있다. --session 으로 누가 쓰는지 밝힌다") def _accrue(node: dict) -> int: """`startedAt` 부터 지금까지를 누적에 더한다. 세션이 다시 떠도 이어진다.""" started = node.get("startedAt") if started: delta = (datetime.datetime.now().astimezone() - datetime.datetime.fromisoformat(started)).total_seconds() node["elapsedSeconds"] = int(node.get("elapsedSeconds", 0) + max(0.0, delta)) node["startedAt"] = None return int(node.get("elapsedSeconds", 0)) def cmd_open(args) -> int: if os.path.exists(os.path.abspath(args.ledger)) and not args.force: raise Contract(f"이미 있는 원장이다: {args.ledger}. 이어서 하려면 status 로 본다") with Ledger(args.ledger, create=True) as led: led.data = json.load(open(TEMPLATE, encoding="utf-8")) led.data.update({ "runId": args.run_id or os.path.basename(os.path.dirname( os.path.abspath(args.ledger))), "project": args.project, "record": args.record, "startedAt": _now(), "finishedAt": None, "riders": [], "sessions": [{"session": args.session, "openedAt": _now()}], }) for st in led.data["stages"]: st.update({"startedAt": None, "finishedAt": None, "elapsedSeconds": 0, REVISION_FIELD: _skill_revision(str(st.get("skill") or ""))}) led.save() print(f"런을 열었다: {args.ledger} (runId={led.data['runId']})") return 0 def cmd_begin(args) -> int: with Ledger(args.ledger) as led: open_stage = led.running() if open_stage and open_stage["id"] != args.stage: raise Contract( f"{open_stage['id']} 이(가) 아직 RUNNING 이다. 단계를 겹쳐 열지 않는다 — " f"end 로 닫거나 status 로 어디서 끊겼는지 본다") st = led.stage(args.stage) if st.get("status") in ("DONE", "SKIPPED"): raise Contract(f"{args.stage} 은(는) 이미 {st['status']} 다") if st.get("startedAt"): # 앞 세션이 이 단계를 열어 둔 채 끊겼다. 그 구간을 닫고 새로 연다. # **닫은 구간에는 세션이 죽어 있던 시간이 섞인다.** 그래서 누적에만 더하지 않고 # 따로 적는다 — 「이 단계가 오래 걸렸다」와 「중간에 끊겼다」는 다른 말이다 was = st["startedAt"] span = _accrue(st) st.setdefault("interruptions", []).append({ "openedAt": was, "resumedAt": _now(), "accruedSeconds": span, "note": "앞 세션이 닫지 않고 끊겼다. 이 구간에는 세션이 없던 시간이 섞여 있다", }) print(f"{args.stage} 이(가) 열린 채였다 — 그 구간을 닫고 잇는다") st["status"] = "RUNNING" # `--runby` 를 안 주면 **틀이 적어 둔 계약값을 그대로 둔다.** 여기서 기본값으로 # 덮어쓰면 단계마다 배정된 관리 에이전트 이름이 매번 지워지고, 원장은 다시 # 「서브에이전트가 돌렸다」까지만 말하게 된다 — 누가 돌렸는지는 안 남는다 if args.runby: st["runBy"] = args.runby st["startedAt"] = _now() # 이어받을 때마다 세대를 올리고 주인을 적는다. `bin/task.py` 의 attempt 와 같은 자리다 — # 앞 세션이 아직 살아 있어도 낮은 세대의 쓰기는 이 단계에 못 들어온다. # 파일이 안 깨지는 것과 두 세션의 기록이 안 섞이는 것은 다른 일이다 st["generation"] = int(st.get("generation", 0)) + 1 st["owner"] = args.session or None led.data.setdefault("sessions", []).append( {"session": args.session or None, "stage": args.stage, "generation": st["generation"], "beganAt": _now()}) led.save() print(f"{args.stage} RUNNING · 세대 {st['generation']} · 주인 {st['owner']}" f" · 누적 {st.get('elapsedSeconds', 0)}초") return 0 def cmd_gate(args) -> int: with Ledger(args.ledger) as led: st = led.stage(args.stage) _require_owner(st, args.session, args.generation) st.setdefault("gates", []).append({ "cmd": args.cmd, "exit": args.exit_code, "session": args.session or None, "generation": int(st.get("generation", 0)), "at": _now()}) led.save() print(f"{args.stage} 관문 {len(st['gates'])}개 · 방금 것 exit={args.exit_code}" f" · {args.session or '세션 미기재'}") return 0 def cmd_end(args) -> int: with Ledger(args.ledger) as led: st = led.stage(args.stage) _require_owner(st, args.session, args.generation) if args.status == "SKIPPED" and args.stage in UNSKIPPABLE: raise Contract(f"{args.stage} 은(는) 건너뛸 수 없다") if args.status == "SKIPPED" and not args.why: raise Contract("건너뛴 단계는 사유를 적는다. --why 를 준다") if args.status == "DONE" and not (args.echo or st.get("skillEcho")): raise Contract( "DONE 인 단계는 스킬 영수증을 적는다. --echo 로 그 SKILL.md 의 문장을 " "원문 그대로 준다 — 안 연 스킬의 영수증을 적으면 그것이 지어낸 것이다") used = _accrue(st) st["status"] = args.status st["finishedAt"] = _now() st["finishedBy"] = args.session or None if args.echo: st["skillEcho"] = args.echo # 영수증을 적는 자리에서 그 영수증이 어느 판의 스킬에서 나왔는지도 적는다. # 여는 시각이 아니라 읽은 시각이라 이쪽이 더 맞다 st[REVISION_FIELD] = _skill_revision(str(st.get("skill") or "")) if args.why: st["skipReason"] = args.why for key, values in (("inputs", args.input), ("outputs", args.output)): if values: st.setdefault(key, []).extend(values) if args.note: st["notes"] = (st.get("notes", "") + " " + args.note).strip() if all(s.get("status") in ("DONE", "SKIPPED") for s in led.data["stages"]): led.data["finishedAt"] = _now() led.save() print(f"{args.stage} {args.status} · 이 단계 누적 {used}초") return 0 def cmd_rider(args) -> int: """런 도중에 끼어든 일. 단계가 아니라서 어느 칸에도 안 남던 것이다.""" with Ledger(args.ledger) as led: led.data.setdefault("riders", []).append({ "id": args.id, "why": args.why, "seconds": args.seconds, "addedAt": _now(), "duringStage": (led.running() or {}).get("id"), }) total = sum(r.get("seconds") or 0 for r in led.data["riders"]) led.save() print(f"라이더 {len(led.data['riders'])}건 · 합계 {total}초 ({total // 60}분)") return 0 def cmd_status(args) -> int: with Ledger(args.ledger) as led: d = led.data print(f"{d.get('runId')} · {d.get('project')} · revision {d.get('revision', 0)}") print(f"기록 {d.get('record')}") for st in d.get("stages", []): mark = {"DONE": "✓", "SKIPPED": "–", "RUNNING": "▶", "FAILED": "✗"}.get( st.get("status"), " ") gates = st.get("gates") or [] bad = [g for g in gates if g.get("exit") not in (0, "0")] print(f" {mark} {st['id']} {st.get('status'):<8} " f"{st.get('elapsedSeconds', 0):>5}초 · 관문 {len(gates)}" + (f" (exit≠0 {len(bad)})" if bad else "")) riders = d.get("riders") or [] if riders: total = sum(r.get("seconds") or 0 for r in riders) print(f" 라이더 {len(riders)}건 · {total}초 ({total // 60}분) — 단계 밖의 시간이다") for r in riders: print(f" {r['id']} · {r.get('seconds') or 0}초 · {r.get('why', '')[:60]}") run = led.running() if run: print(f"\n끊긴 자리: {run['id']} 이(가) RUNNING 이다 " f"(시작 {run.get('startedAt')})") print(f"이어서 하려면: run-ledger.py end {args.ledger} --stage {run['id']} …") return 1 pending = [s["id"] for s in d.get("stages", []) if s.get("status") == "PENDING"] if pending: print(f"\n아직 안 연 단계: {', '.join(pending)}") return 1 print("\n일곱 단계가 다 닫혔다") return 0 def main() -> int: ap = argparse.ArgumentParser(description="런 원장을 원자적으로 쓰고 끊긴 자리에서 잇는다.") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("open"); p.add_argument("ledger") p.add_argument("--project", required=True); p.add_argument("--record", required=True) p.add_argument("--run-id"); p.add_argument("--session", default=os.environ.get("USER", "")) p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_open) p = sub.add_parser("begin"); p.add_argument("ledger") p.add_argument("--stage", required=True); p.add_argument("--runby", default=None) p.add_argument("--session", default=""); p.set_defaults(fn=cmd_begin) p = sub.add_parser("gate"); p.add_argument("ledger") p.add_argument("--stage", required=True); p.add_argument("--cmd", required=True) p.add_argument("--exit", dest="exit_code", type=int, required=True) p.add_argument("--session", default=""); p.add_argument("--generation", type=int) p.set_defaults(fn=cmd_gate) p = sub.add_parser("end"); p.add_argument("ledger") p.add_argument("--stage", required=True) p.add_argument("--status", required=True, choices=["DONE", "SKIPPED", "FAILED"]) p.add_argument("--echo"); p.add_argument("--why"); p.add_argument("--note") p.add_argument("--input", action="append"); p.add_argument("--output", action="append") p.add_argument("--session", default=""); p.add_argument("--generation", type=int) p.set_defaults(fn=cmd_end) p = sub.add_parser("rider"); p.add_argument("ledger") p.add_argument("--id", required=True); p.add_argument("--why", required=True) p.add_argument("--seconds", type=int); p.set_defaults(fn=cmd_rider) p = sub.add_parser("status"); p.add_argument("ledger"); p.set_defaults(fn=cmd_status) args = ap.parse_args() try: return args.fn(args) except Contract as e: print(f"거절: {e}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())