Files
document-haness/scripts/run-ledger.py
T
DongHyeonkaandClaude Opus 5 8ca822dc4d feat(scripts): 런 원장을 원자적으로 쓰고 끊긴 자리에서 잇는다
원장을 손으로 써 왔다. 그래서 셋이 없었다. 쓰는 도중에 끊기면 반쪽 파일이 남고, 단계마다
시작·끝이 없어 「돌다 말았다」와 「아직 안 시작했다」가 PENDING 하나로 같아 보이고, 런 도중에
끼어든 일이 어디에도 안 남는다.

bin/task.py 가 이 계약의 초안이다 — flock 을 잡고 임시 파일에 완성한 뒤 os.replace 로 바꾸고
startedAt 부터 지금까지를 누적에 더한다. 그 규약을 runs/ 쪽으로 옮겼다. 새로 만든 것이 아니다.

끊긴 단계를 새 세션이 다시 열면 그 구간을 닫고 잇되, 누적에만 더하지 않고 interruptions 에
따로 적는다. 닫은 구간에는 세션이 죽어 있던 시간이 섞인다 — 「이 단계가 오래 걸렸다」와
「중간에 끊겼다」는 다른 말이다.

라이더에 자리를 만들었다. 이 배치에서 검증 한 작업의 누적 24분 가운데 17분 50초가 라이더였는데
상태 파일에 그 구분이 없었다. 단계가 아니라서 어느 칸에도 안 남던 것이다.

상태 전이를 거절한다 — 이미 있는 런을 덮어쓰기, 단계 겹쳐 열기, 건너뛸 수 없는 단계 건너뛰기,
사유 없는 SKIPPED, 영수증 없는 DONE, 종료 코드 없는 관문.

더한 칸이 있어도 기존 검사기가 그대로 읽는다. 통과한 원장에 새 칸을 더해도 통과하는 것을
확인했다. 손으로 쓴 원장도 계속 유효하고 이 도구는 선택적으로 부른다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 13:23:04 +09:00

292 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""런 원장을 쓰는 도구. 반쪽 상태를 만들지 않고, 끊긴 자리에서 다시 시작한다.
`runs/<프로젝트>/<runId>/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 subagent
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 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"}
class Contract(Exception):
"""계약 위반. 도구가 거절한 것이지 실패가 아니다."""
def _now() -> str:
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
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 _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})
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"
st["runBy"] = args.runby
st["startedAt"] = _now()
if args.session:
led.data.setdefault("sessions", []).append(
{"session": args.session, "stage": args.stage, "beganAt": _now()})
led.save()
print(f"{args.stage} RUNNING · 누적 {st.get('elapsedSeconds', 0)}초")
return 0
def cmd_gate(args) -> int:
with Ledger(args.ledger) as led:
st = led.stage(args.stage)
st.setdefault("gates", []).append({"cmd": args.cmd, "exit": args.exit_code})
led.save()
print(f"{args.stage} 관문 {len(st['gates'])}개 · 방금 것 exit={args.exit_code}")
return 0
def cmd_end(args) -> int:
with Ledger(args.ledger) as led:
st = led.stage(args.stage)
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()
if args.echo:
st["skillEcho"] = args.echo
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="subagent")
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.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.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())