fix(run-ledger): 파일은 안 깨지는데 두 세션의 기록이 섞였다

이어받은 단계에 앞 세션이 관문을 써도 그대로 들어갔다. 원자성 문제가 아니다 — 파일은
온전한 채로 두 세션의 기록이 섞인다. 그리고 관문마다 누가 적었는지가 없어서 나중에
원장을 읽어도 가릴 수 없었다.

begin 이 세대를 올리고 주인을 적는다. 낮은 세대나 다른 주인의 쓰기는 거절한다.
bin/task.py 의 attempt 와 같은 자리다.

gate 와 end 도 --session 을 받아 적는다. 관문마다 session·generation·at 이 남고
end 는 finishedBy 를 남긴다.

주인이 없는 단계는 그대로 받는다. 세션을 안 쓰는 단일 세션 사용이 깨지지 않는다.
회귀에 그 대조를 넣었다.

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:33:10 +09:00
co-authored by Claude Opus 5
parent 8ca822dc4d
commit 96d7fbc57a
2 changed files with 87 additions and 6 deletions
+43 -6
View File
@@ -99,6 +99,28 @@ class Ledger:
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")
@@ -155,26 +177,38 @@ def cmd_begin(args) -> int:
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()})
# 이어받을 때마다 세대를 올리고 주인을 적는다. `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.get('elapsedSeconds', 0)}")
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)
st.setdefault("gates", []).append({"cmd": args.cmd, "exit": args.exit_code})
_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}")
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:
@@ -186,6 +220,7 @@ def cmd_end(args) -> int:
used = _accrue(st)
st["status"] = args.status
st["finishedAt"] = _now()
st["finishedBy"] = args.session or None
if args.echo:
st["skillEcho"] = args.echo
if args.why:
@@ -264,6 +299,7 @@ def main() -> int:
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")
@@ -271,6 +307,7 @@ def main() -> int:
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")
+44
View File
@@ -140,6 +140,50 @@ class LedgerTest(unittest.TestCase):
_cli("rider", self.led, "--id", "R12", "--why", "x", "--seconds", "60")
self.assertEqual("S3", self._read()["riders"][0]["duringStage"])
# ── 두 세션의 기록이 섞이지 않는다 ────────────────────────────────
def test_a_superseded_session_cannot_write(self):
"""파일이 안 깨지는 것과 두 세션의 기록이 안 섞이는 것은 다른 일이다.
`plan/02` B-005 — 「이전 작업자의 종료를 확인하고 재배정한 시도 번호와 맞는
결과만 받는다.」 `bin/task.py` 의 attempt 와 같은 자리다.
"""
_cli("begin", self.led, "--stage", "S3", "--session", "sess-1")
_cli("begin", self.led, "--stage", "S3", "--session", "sess-2")
for args in (("gate", self.led, "--stage", "S3", "--cmd", "stale", "--exit", "0"),
("end", self.led, "--stage", "S3", "--status", "DONE", "--echo", "x")):
with self.subTest(cmd=args[0]):
p = _cli(*args, "--session", "sess-1")
self.assertEqual(2, p.returncode)
self.assertIn("sess-2", p.stderr)
def test_a_stale_generation_is_refused(self):
_cli("begin", self.led, "--stage", "S3", "--session", "sess-1")
_cli("begin", self.led, "--stage", "S3", "--session", "sess-2")
p = _cli("gate", self.led, "--stage", "S3", "--cmd", "x", "--exit", "0",
"--session", "sess-2", "--generation", "1")
self.assertEqual(2, p.returncode)
def test_an_anonymous_write_to_an_owned_stage_is_refused(self):
"""누가 썼는지 안 밝히면 나중에 원장을 읽을 수 없다."""
_cli("begin", self.led, "--stage", "S3", "--session", "sess-1")
p = _cli("gate", self.led, "--stage", "S3", "--cmd", "x", "--exit", "0")
self.assertEqual(2, p.returncode)
def test_the_current_owner_writes_and_is_recorded(self):
"""대조군. 지금 주인의 쓰기는 들어가고, 누가 어느 세대에 썼는지 남는다."""
_cli("begin", self.led, "--stage", "S3", "--session", "sess-1")
p = _cli("gate", self.led, "--stage", "S3", "--cmd", "ok", "--exit", "0",
"--session", "sess-1", "--generation", "1")
self.assertEqual(0, p.returncode, p.stderr)
gate = self._stage("S3")["gates"][0]
self.assertEqual(("sess-1", 1), (gate["session"], gate["generation"]))
def test_an_unowned_stage_still_accepts_writes(self):
"""주인이 없으면 (세션을 안 쓰는 단일 세션 사용) 그대로 쓴다. 기존 쓰임을 안 깬다."""
_cli("begin", self.led, "--stage", "S3")
self.assertEqual(0, _cli("gate", self.led, "--stage", "S3",
"--cmd", "x", "--exit", "0").returncode)
# ── 옛 원장을 깨지 않는다 ─────────────────────────────────────────
def test_the_added_fields_do_not_break_the_verifier(self):
"""이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""