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:
co-authored by
Claude Opus 5
parent
8ca822dc4d
commit
96d7fbc57a
+43
-6
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
"""이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""
|
||||
|
||||
Reference in New Issue
Block a user