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
This commit is contained in:
DongHyeonka
2026-09-10 13:23:04 +09:00
co-authored by Claude Opus 5
parent a5215eda31
commit 8ca822dc4d
2 changed files with 454 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
#!/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())