From 96d7fbc57a9cb694f00ceb06705f1237c41be974 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 13:33:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(run-ledger):=20=ED=8C=8C=EC=9D=BC=EC=9D=80?= =?UTF-8?q?=20=EC=95=88=20=EA=B9=A8=EC=A7=80=EB=8A=94=EB=8D=B0=20=EB=91=90?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=EC=9D=98=20=EA=B8=B0=EB=A1=9D=EC=9D=B4=20?= =?UTF-8?q?=EC=84=9E=EC=98=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이어받은 단계에 앞 세션이 관문을 써도 그대로 들어갔다. 원자성 문제가 아니다 — 파일은 온전한 채로 두 세션의 기록이 섞인다. 그리고 관문마다 누가 적었는지가 없어서 나중에 원장을 읽어도 가릴 수 없었다. begin 이 세대를 올리고 주인을 적는다. 낮은 세대나 다른 주인의 쓰기는 거절한다. bin/task.py 의 attempt 와 같은 자리다. gate 와 end 도 --session 을 받아 적는다. 관문마다 session·generation·at 이 남고 end 는 finishedBy 를 남긴다. 주인이 없는 단계는 그대로 받는다. 세션을 안 쓰는 단일 세션 사용이 깨지지 않는다. 회귀에 그 대조를 넣었다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/run-ledger.py | 49 ++++++++++++++++++++++++++++---- scripts/tests/test_run_ledger.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/scripts/run-ledger.py b/scripts/run-ledger.py index 2861089..fadd97f 100644 --- a/scripts/run-ledger.py +++ b/scripts/run-ledger.py @@ -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") diff --git a/scripts/tests/test_run_ledger.py b/scripts/tests/test_run_ledger.py index 2121ae3..e8b667c 100644 --- a/scripts/tests/test_run_ledger.py +++ b/scripts/tests/test_run_ledger.py @@ -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): """이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""