hermes: bind terminal commit to journal inode
This commit is contained in:
parent
3ce59ee1b4
commit
9793b1435e
@ -148,6 +148,21 @@ class TerminalIdentity:
|
|||||||
state: str
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TerminalSnapshot:
|
||||||
|
"""One inode-bound, bounded journal read held open across finalization."""
|
||||||
|
|
||||||
|
document: dict[str, Any]
|
||||||
|
file_stat: os.stat_result
|
||||||
|
descriptor: int
|
||||||
|
directory_descriptor: int
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the pinned file and directory descriptors."""
|
||||||
|
os.close(self.descriptor)
|
||||||
|
os.close(self.directory_descriptor)
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
@ -385,6 +400,10 @@ _TERMINAL_NAME = re.compile(
|
|||||||
r"^(?P<task>[a-zA-Z0-9_.-]+)\.run-(?P<run>[0-9]+)\."
|
r"^(?P<task>[a-zA-Z0-9_.-]+)\.run-(?P<run>[0-9]+)\."
|
||||||
r"terminal\.(?P<state>pending|committed)\.json$"
|
r"terminal\.(?P<state>pending|committed)\.json$"
|
||||||
)
|
)
|
||||||
|
_TERMINAL_EVIDENCE_NAME = re.compile(
|
||||||
|
r"^(?P<task>[a-zA-Z0-9_.-]+)\.run-(?P<run>[0-9]+)\."
|
||||||
|
r"terminal\.(?P<state>prepared|conflict)-(?P<digest>[a-f0-9]{32})\.json$"
|
||||||
|
)
|
||||||
_SAFE_BOARD = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
_SAFE_BOARD = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
@ -418,6 +437,36 @@ def _terminal_identity(path: Path) -> TerminalIdentity | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_evidence_identity(path: Path) -> TerminalIdentity | None:
|
||||||
|
"""Parse immutable prepared/conflict evidence authority from its path."""
|
||||||
|
try:
|
||||||
|
relative = path.relative_to(STATE_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if len(relative.parts) != 2:
|
||||||
|
return None
|
||||||
|
board, filename = relative.parts
|
||||||
|
match = _TERMINAL_EVIDENCE_NAME.fullmatch(filename)
|
||||||
|
try:
|
||||||
|
board_stat = path.parent.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
not match
|
||||||
|
or not stat.S_ISDIR(board_stat.st_mode)
|
||||||
|
or not _SAFE_BOARD.fullmatch(board)
|
||||||
|
or board in {".", ".."}
|
||||||
|
or match.group("task") in {".", ".."}
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return TerminalIdentity(
|
||||||
|
board=board,
|
||||||
|
task_id=match.group("task"),
|
||||||
|
run_id=int(match.group("run")),
|
||||||
|
state=match.group("state"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_bounded(descriptor: int, limit: int) -> bytes:
|
def _read_bounded(descriptor: int, limit: int) -> bytes:
|
||||||
"""Read at most ``limit`` bytes from a regular file descriptor."""
|
"""Read at most ``limit`` bytes from a regular file descriptor."""
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
@ -431,10 +480,8 @@ def _read_bounded(descriptor: int, limit: int) -> bytes:
|
|||||||
return b"".join(chunks)
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
def _load_terminal_json(path: Path, identity: TerminalIdentity) -> dict[str, Any]:
|
def _open_small_json_snapshot(path: Path) -> TerminalSnapshot | None:
|
||||||
"""Read one small, singly-linked journal through its validated directory."""
|
"""Open and pin one bounded, singly-linked JSON document."""
|
||||||
if _terminal_identity(path) != identity:
|
|
||||||
return {}
|
|
||||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
@ -449,10 +496,10 @@ def _load_terminal_json(path: Path, identity: TerminalIdentity) -> dict[str, Any
|
|||||||
or opened.st_nlink != 1
|
or opened.st_nlink != 1
|
||||||
or opened.st_size > MAX_TERMINAL_RECORD_BYTES
|
or opened.st_size > MAX_TERMINAL_RECORD_BYTES
|
||||||
):
|
):
|
||||||
return {}
|
return None
|
||||||
payload = _read_bounded(descriptor, MAX_TERMINAL_RECORD_BYTES + 1)
|
payload = _read_bounded(descriptor, MAX_TERMINAL_RECORD_BYTES + 1)
|
||||||
if len(payload) > MAX_TERMINAL_RECORD_BYTES:
|
if len(payload) > MAX_TERMINAL_RECORD_BYTES:
|
||||||
return {}
|
return None
|
||||||
current = os.stat(
|
current = os.stat(
|
||||||
path.name,
|
path.name,
|
||||||
dir_fd=directory,
|
dir_fd=directory,
|
||||||
@ -464,16 +511,53 @@ def _load_terminal_json(path: Path, identity: TerminalIdentity) -> dict[str, Any
|
|||||||
or current.st_size != opened.st_size
|
or current.st_size != opened.st_size
|
||||||
or len(payload) != opened.st_size
|
or len(payload) != opened.st_size
|
||||||
):
|
):
|
||||||
return {}
|
return None
|
||||||
value = json.loads(payload.decode("utf-8"))
|
value = json.loads(payload.decode("utf-8"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
snapshot = TerminalSnapshot(value, opened, descriptor, directory)
|
||||||
|
descriptor = None
|
||||||
|
directory = None
|
||||||
|
return snapshot
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
return {}
|
return None
|
||||||
finally:
|
finally:
|
||||||
if descriptor is not None:
|
if descriptor is not None:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
if directory is not None:
|
if directory is not None:
|
||||||
os.close(directory)
|
os.close(directory)
|
||||||
return value if isinstance(value, dict) else {}
|
|
||||||
|
|
||||||
|
def _open_terminal_snapshot(
|
||||||
|
path: Path,
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
) -> TerminalSnapshot | None:
|
||||||
|
"""Open a terminal journal only after its lexical identity is verified."""
|
||||||
|
if _terminal_identity(path) != identity:
|
||||||
|
return None
|
||||||
|
return _open_small_json_snapshot(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_terminal_json(path: Path, identity: TerminalIdentity) -> dict[str, Any]:
|
||||||
|
"""Read one small, singly-linked journal through its validated directory."""
|
||||||
|
snapshot = _open_terminal_snapshot(path, identity)
|
||||||
|
if snapshot is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return snapshot.document
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_small_json(path: Path) -> dict[str, Any]:
|
||||||
|
"""Read one bounded immutable evidence document without following links."""
|
||||||
|
snapshot = _open_small_json_snapshot(path)
|
||||||
|
if snapshot is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return snapshot.document
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
|
||||||
|
|
||||||
def _persist_candidate(
|
def _persist_candidate(
|
||||||
@ -580,7 +664,7 @@ def _terminal_record_valid(
|
|||||||
and structured["blockers"] == []
|
and structured["blockers"] == []
|
||||||
and cli_lane_goal.unfinished_result_reason(structured) is None
|
and cli_lane_goal.unfinished_result_reason(structured) is None
|
||||||
and record["summary"] == structured["summary"]
|
and record["summary"] == structured["summary"]
|
||||||
and record.get("kanban_state") in {"pending", "committed"}
|
and record.get("kanban_state") in {"pending", "prepared", "committed"}
|
||||||
)
|
)
|
||||||
if not valid or identity is None:
|
if not valid or identity is None:
|
||||||
return valid
|
return valid
|
||||||
@ -588,6 +672,7 @@ def _terminal_record_valid(
|
|||||||
record.get("board") == identity.board
|
record.get("board") == identity.board
|
||||||
and record.get("task_id") == identity.task_id
|
and record.get("task_id") == identity.task_id
|
||||||
and record.get("expected_run_id") == identity.run_id
|
and record.get("expected_run_id") == identity.run_id
|
||||||
|
and record.get("kanban_state") == identity.state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1183,7 +1268,7 @@ def _retire_terminal_entry(
|
|||||||
f"{path.name}\0{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode("utf-8")
|
f"{path.name}\0{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode("utf-8")
|
||||||
).hexdigest()[:32]
|
).hexdigest()[:32]
|
||||||
for sequence in range(32):
|
for sequence in range(32):
|
||||||
staging = f".retire.{path.name}.{identity}.{sequence}"
|
staging = f".retire.{identity}.{sequence}"
|
||||||
try:
|
try:
|
||||||
_rename_noreplace(
|
_rename_noreplace(
|
||||||
path.name,
|
path.name,
|
||||||
@ -1222,6 +1307,182 @@ def _retire_terminal_entry(
|
|||||||
return "replacement"
|
return "replacement"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json_noreplace(path: Path, value: dict[str, Any]) -> bool:
|
||||||
|
"""Durably create one immutable JSON artifact without replacing a peer."""
|
||||||
|
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
if len(payload) > MAX_TERMINAL_RECORD_BYTES + 4096:
|
||||||
|
raise ValueError("terminal evidence exceeds the bounded artifact limit")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
os.chmod(path.parent, 0o700, follow_symlinks=False)
|
||||||
|
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||||
|
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
create_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
directory = os.open(path.parent, directory_flags)
|
||||||
|
temporary = f".{path.name}.{uuid.uuid4().hex}.tmp"
|
||||||
|
temporary_stat = None
|
||||||
|
descriptor = None
|
||||||
|
try:
|
||||||
|
descriptor = os.open(temporary, create_flags, 0o600, dir_fd=directory)
|
||||||
|
temporary_stat = os.fstat(descriptor)
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
descriptor = None
|
||||||
|
stream.write(payload)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
written = os.fstat(stream.fileno())
|
||||||
|
if stat.S_IMODE(written.st_mode) != 0o600 or written.st_nlink != 1:
|
||||||
|
raise OSError("terminal evidence is not private and singly linked")
|
||||||
|
try:
|
||||||
|
_rename_noreplace(
|
||||||
|
temporary,
|
||||||
|
path.name,
|
||||||
|
source_dir=directory,
|
||||||
|
destination_dir=directory,
|
||||||
|
)
|
||||||
|
except FileExistsError:
|
||||||
|
return False
|
||||||
|
os.fsync(directory)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
if temporary_stat is not None:
|
||||||
|
try:
|
||||||
|
_retire_terminal_entry(
|
||||||
|
Path(temporary),
|
||||||
|
temporary_stat,
|
||||||
|
board_descriptor=directory,
|
||||||
|
quarantine_descriptor=directory,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.close(directory)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_document_digest(document: dict[str, Any]) -> str:
|
||||||
|
"""Return the stable identity for one exact accepted result document."""
|
||||||
|
immutable = {
|
||||||
|
key: document.get(key)
|
||||||
|
for key in (
|
||||||
|
"board",
|
||||||
|
"task_id",
|
||||||
|
"expected_run_id",
|
||||||
|
"result",
|
||||||
|
"summary",
|
||||||
|
"metadata",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
canonical = json.dumps(immutable, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_evidence_path(
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
state: str,
|
||||||
|
document: dict[str, Any],
|
||||||
|
) -> Path:
|
||||||
|
"""Return a deterministic immutable prepared or conflict evidence path."""
|
||||||
|
if state not in {"prepared", "conflict"}:
|
||||||
|
raise ValueError(f"invalid terminal evidence state: {state}")
|
||||||
|
base = state_path(identity.board, identity.task_id)
|
||||||
|
digest = _terminal_document_digest(document)
|
||||||
|
return base.with_name(
|
||||||
|
f"{base.stem}.run-{identity.run_id}.terminal.{state}-{digest}.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_terminal_document(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||||
|
"""Compare the immutable result-bearing fields of terminal evidence."""
|
||||||
|
keys = ("board", "task_id", "expected_run_id", "result", "summary", "metadata")
|
||||||
|
return all(left.get(key) == right.get(key) for key in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_evidence_valid(
|
||||||
|
document: dict[str, Any],
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
state: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Validate semantic terminal content plus its immutable evidence state."""
|
||||||
|
if document.get("kanban_state") != state:
|
||||||
|
return False
|
||||||
|
semantic = dict(document)
|
||||||
|
semantic_identity = identity
|
||||||
|
if state == "conflict":
|
||||||
|
semantic["kanban_state"] = "prepared"
|
||||||
|
semantic_identity = TerminalIdentity(
|
||||||
|
identity.board,
|
||||||
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
|
"prepared",
|
||||||
|
)
|
||||||
|
return _terminal_record_valid(semantic, semantic_identity)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_prepared_evidence(
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
document: dict[str, Any],
|
||||||
|
) -> Path:
|
||||||
|
"""Fsync an immutable accepted result before entering the DB boundary."""
|
||||||
|
prepared = dict(document)
|
||||||
|
prepared["kanban_state"] = "prepared"
|
||||||
|
prepared["prepared_at"] = utc_now()
|
||||||
|
path = _terminal_evidence_path(identity, "prepared", document)
|
||||||
|
if _write_json_noreplace(path, prepared):
|
||||||
|
return path
|
||||||
|
if _terminal_evidence_identity(path) != TerminalIdentity(
|
||||||
|
identity.board, identity.task_id, identity.run_id, "prepared"
|
||||||
|
):
|
||||||
|
raise OSError("prepared terminal evidence identity is invalid")
|
||||||
|
existing = _load_small_json(path)
|
||||||
|
prepared_identity = TerminalIdentity(
|
||||||
|
identity.board,
|
||||||
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
|
"prepared",
|
||||||
|
)
|
||||||
|
if not _terminal_evidence_valid(
|
||||||
|
existing,
|
||||||
|
prepared_identity,
|
||||||
|
"prepared",
|
||||||
|
) or not _same_terminal_document(existing, prepared):
|
||||||
|
raise OSError("prepared terminal evidence path contains a conflicting result")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_conflict_evidence(
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
document: dict[str, Any],
|
||||||
|
reason: str,
|
||||||
|
) -> Path:
|
||||||
|
"""Preserve a losing valid result in full under deterministic retention."""
|
||||||
|
conflict = dict(document)
|
||||||
|
conflict["kanban_state"] = "conflict"
|
||||||
|
conflict["conflict_reason"] = reason
|
||||||
|
conflict["conflicted_at"] = utc_now()
|
||||||
|
path = _terminal_evidence_path(identity, "conflict", document)
|
||||||
|
if _write_json_noreplace(path, conflict):
|
||||||
|
return path
|
||||||
|
if _terminal_evidence_identity(path) != TerminalIdentity(
|
||||||
|
identity.board, identity.task_id, identity.run_id, "conflict"
|
||||||
|
):
|
||||||
|
raise OSError("terminal conflict evidence identity is invalid")
|
||||||
|
existing = _load_small_json(path)
|
||||||
|
conflict_identity = TerminalIdentity(
|
||||||
|
identity.board,
|
||||||
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
|
"conflict",
|
||||||
|
)
|
||||||
|
if not _terminal_evidence_valid(
|
||||||
|
existing,
|
||||||
|
conflict_identity,
|
||||||
|
"conflict",
|
||||||
|
) or not _same_terminal_document(existing, conflict):
|
||||||
|
raise OSError("terminal conflict evidence path contains a different result")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _quarantine_terminal(path: Path, identity: TerminalIdentity | None, reason: str) -> Path:
|
def _quarantine_terminal(path: Path, identity: TerminalIdentity | None, reason: str) -> Path:
|
||||||
"""Record bounded metadata, then retire only the inspected journal inode."""
|
"""Record bounded metadata, then retire only the inspected journal inode."""
|
||||||
board_dir = path.parent
|
board_dir = path.parent
|
||||||
@ -1429,36 +1690,85 @@ def _recover_exact_run(
|
|||||||
return recovered
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
def _commit_terminal_file(path: Path, identity: TerminalIdentity, document: dict[str, Any]) -> Path:
|
def _retire_snapshot(path: Path, snapshot: TerminalSnapshot) -> str:
|
||||||
"""Persist committed state, then atomically retire a pending journal."""
|
"""Retire only the directory entry still naming an opened snapshot inode."""
|
||||||
|
return _retire_terminal_entry(
|
||||||
|
path,
|
||||||
|
snapshot.file_stat,
|
||||||
|
board_descriptor=snapshot.directory_descriptor,
|
||||||
|
quarantine_descriptor=snapshot.directory_descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _retire_snapshot_after_db(path: Path, snapshot: TerminalSnapshot) -> str:
|
||||||
|
"""Keep a committed DB result recoverable across retirement fsync errors."""
|
||||||
|
try:
|
||||||
|
return _retire_snapshot(path, snapshot)
|
||||||
|
except OSError as error:
|
||||||
|
print(
|
||||||
|
f"terminal journal retirement deferred after DB commit: "
|
||||||
|
f"{type(error).__name__}: {error}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return "deferred"
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_evidence(path: Path) -> str:
|
||||||
|
"""Identity-safely remove one immutable prepared evidence artifact."""
|
||||||
|
snapshot = _open_small_json_snapshot(path)
|
||||||
|
if snapshot is None:
|
||||||
|
return "missing"
|
||||||
|
try:
|
||||||
|
return _retire_snapshot(path, snapshot)
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_prepared_evidence(
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
document: dict[str, Any],
|
||||||
|
prepared: Path,
|
||||||
|
) -> tuple[Path, str]:
|
||||||
|
"""Publish the DB-winning result without overwriting first-writer evidence."""
|
||||||
committed = _terminal_path(
|
committed = _terminal_path(
|
||||||
state_path(identity.board, identity.task_id),
|
state_path(identity.board, identity.task_id),
|
||||||
identity.run_id,
|
identity.run_id,
|
||||||
"committed",
|
"committed",
|
||||||
)
|
)
|
||||||
document["kanban_state"] = "committed"
|
committed_document = dict(document)
|
||||||
document["committed_at"] = utc_now()
|
committed_document["kanban_state"] = "committed"
|
||||||
atomic_json(path, document)
|
committed_document["committed_at"] = utc_now()
|
||||||
os.replace(path, committed)
|
state = "committed"
|
||||||
os.chmod(committed, 0o600, follow_symlinks=False)
|
if not _write_json_noreplace(committed, committed_document):
|
||||||
_fsync_directory(committed.parent)
|
committed_identity = TerminalIdentity(
|
||||||
return committed
|
identity.board,
|
||||||
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
|
"committed",
|
||||||
|
)
|
||||||
|
existing = _load_terminal_json(committed, committed_identity)
|
||||||
|
if (
|
||||||
|
not _terminal_record_valid(existing, committed_identity)
|
||||||
|
or existing.get("kanban_state") != "committed"
|
||||||
|
or not _same_terminal_document(existing, committed_document)
|
||||||
|
):
|
||||||
|
_persist_conflict_evidence(
|
||||||
|
identity,
|
||||||
|
document,
|
||||||
|
"canonical committed evidence already contains a different result",
|
||||||
|
)
|
||||||
|
state = "conflict"
|
||||||
|
_discard_evidence(prepared)
|
||||||
|
return committed, state
|
||||||
|
|
||||||
|
|
||||||
def _finalize_terminal_record(
|
def _finalize_document_db(
|
||||||
kanban_db: Any,
|
kanban_db: Any,
|
||||||
path: Path,
|
identity: TerminalIdentity,
|
||||||
_record: dict[str, Any] | None = None,
|
document: dict[str, Any],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Commit one exact-run terminal journal, or leave it safely pending."""
|
"""Apply one validated exact-run result to Kanban under DB run guards."""
|
||||||
identity = _terminal_identity(path)
|
|
||||||
if identity is None or identity.state != "pending":
|
|
||||||
return "invalid"
|
|
||||||
document = _load_terminal_json(path, identity)
|
|
||||||
if not _terminal_record_valid(document):
|
|
||||||
return "invalid"
|
|
||||||
if not _terminal_record_valid(document, identity):
|
|
||||||
return "foreign"
|
|
||||||
|
|
||||||
def operation(conn: Any) -> str:
|
def operation(conn: Any) -> str:
|
||||||
task = kanban_db.get_task(conn, identity.task_id)
|
task = kanban_db.get_task(conn, identity.task_id)
|
||||||
@ -1490,11 +1800,111 @@ def _finalize_terminal_record(
|
|||||||
metadata=document["metadata"],
|
metadata=document["metadata"],
|
||||||
**completion_guard,
|
**completion_guard,
|
||||||
)
|
)
|
||||||
return "committed" if completed else "pending"
|
if completed:
|
||||||
|
return "committed"
|
||||||
|
# A duplicate finalizer can lose the guarded UPDATE to an identical
|
||||||
|
# first writer. Re-read rather than reporting a false pending state.
|
||||||
|
latest = kanban_db.get_task(conn, identity.task_id)
|
||||||
|
if latest is not None and str(_task_value(latest, "status", "")) == "done":
|
||||||
|
return (
|
||||||
|
"committed"
|
||||||
|
if str(_task_value(latest, "result", "") or "") == document["result"]
|
||||||
|
else "stale"
|
||||||
|
)
|
||||||
|
return "pending"
|
||||||
|
|
||||||
outcome = str(_board_call(kanban_db, identity.board, operation))
|
return str(_board_call(kanban_db, identity.board, operation))
|
||||||
if outcome == "committed":
|
|
||||||
_commit_terminal_file(path, identity, document)
|
|
||||||
|
def _resolve_pending_after_winner(
|
||||||
|
path: Path,
|
||||||
|
identity: TerminalIdentity,
|
||||||
|
winning_document: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Retire duplicates and preserve differing replacements as conflicts."""
|
||||||
|
for _attempt in range(8):
|
||||||
|
snapshot = _open_terminal_snapshot(path, identity)
|
||||||
|
if snapshot is None:
|
||||||
|
return False
|
||||||
|
document = snapshot.document
|
||||||
|
if not _terminal_record_valid(document, identity):
|
||||||
|
snapshot.close()
|
||||||
|
return False
|
||||||
|
differing = not _same_terminal_document(document, winning_document)
|
||||||
|
try:
|
||||||
|
if differing:
|
||||||
|
_persist_conflict_evidence(
|
||||||
|
identity,
|
||||||
|
document,
|
||||||
|
"different valid result lost the exact-run first-writer race",
|
||||||
|
)
|
||||||
|
retirement = _retire_snapshot_after_db(path, snapshot)
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
if retirement in {"retired", "missing"}:
|
||||||
|
if differing:
|
||||||
|
print(
|
||||||
|
f"preserved terminal result conflict for {identity.board}/"
|
||||||
|
f"{identity.task_id} run {identity.run_id}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return differing
|
||||||
|
if retirement not in {"replacement", "replacement-staged", "collision"}:
|
||||||
|
return differing
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_terminal_record(
|
||||||
|
kanban_db: Any,
|
||||||
|
path: Path,
|
||||||
|
_record: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Commit one exact-run terminal journal, or leave it safely pending."""
|
||||||
|
identity = _terminal_identity(path)
|
||||||
|
if identity is None or identity.state != "pending":
|
||||||
|
return "invalid"
|
||||||
|
snapshot = _open_terminal_snapshot(path, identity)
|
||||||
|
if snapshot is None:
|
||||||
|
return "invalid"
|
||||||
|
document = snapshot.document
|
||||||
|
try:
|
||||||
|
if not _terminal_record_valid(document):
|
||||||
|
return "invalid"
|
||||||
|
if not _terminal_record_valid(document, identity):
|
||||||
|
return "foreign"
|
||||||
|
prepared = _persist_prepared_evidence(identity, document)
|
||||||
|
outcome = _finalize_document_db(kanban_db, identity, document)
|
||||||
|
if outcome == "committed":
|
||||||
|
_committed, evidence_state = _promote_prepared_evidence(
|
||||||
|
identity,
|
||||||
|
document,
|
||||||
|
prepared,
|
||||||
|
)
|
||||||
|
retirement = _retire_snapshot_after_db(path, snapshot)
|
||||||
|
elif outcome == "stale":
|
||||||
|
_persist_conflict_evidence(
|
||||||
|
identity,
|
||||||
|
document,
|
||||||
|
"valid terminal result no longer matches the authoritative task run",
|
||||||
|
)
|
||||||
|
_discard_evidence(prepared)
|
||||||
|
retirement = _retire_snapshot_after_db(path, snapshot)
|
||||||
|
outcome = "conflict"
|
||||||
|
evidence_state = "conflict"
|
||||||
|
else:
|
||||||
|
return outcome
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
if outcome == "committed" and retirement not in {"retired", "missing"}:
|
||||||
|
_resolve_pending_after_winner(path, identity, document)
|
||||||
|
if evidence_state == "conflict":
|
||||||
|
print(
|
||||||
|
f"terminal first-writer conflict recorded for {identity.board}/"
|
||||||
|
f"{identity.task_id} run {identity.run_id}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
@ -1515,11 +1925,81 @@ def _replay_replacement_terminal(
|
|||||||
return True, outcome == "committed"
|
return True, outcome == "committed"
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_prepared_finalizations(kanban_db: Any) -> int:
|
||||||
|
"""Recover an accepted result staged before an interrupted DB boundary."""
|
||||||
|
recovered = 0
|
||||||
|
prepared_paths = list(STATE_ROOT.glob("*/*.terminal.prepared-*.json"))
|
||||||
|
|
||||||
|
def durable_order(path: Path) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
return path.stat(follow_symlinks=False).st_mtime_ns, str(path)
|
||||||
|
except OSError:
|
||||||
|
return 2**63 - 1, str(path)
|
||||||
|
|
||||||
|
# When no DB winner exists yet, the first durably prepared result owns the
|
||||||
|
# run. The DB transaction remains the final arbiter across runner processes.
|
||||||
|
for path in sorted(prepared_paths, key=durable_order):
|
||||||
|
identity = _terminal_evidence_identity(path)
|
||||||
|
snapshot = _open_small_json_snapshot(path)
|
||||||
|
if identity is None or identity.state != "prepared" or snapshot is None:
|
||||||
|
if snapshot is not None:
|
||||||
|
snapshot.close()
|
||||||
|
_quarantine_terminal(path, identity, "malformed-prepared-evidence")
|
||||||
|
continue
|
||||||
|
document = snapshot.document
|
||||||
|
try:
|
||||||
|
expected = _terminal_evidence_path(identity, "prepared", document)
|
||||||
|
if (
|
||||||
|
expected.name != path.name
|
||||||
|
or not _terminal_evidence_valid(document, identity, "prepared")
|
||||||
|
):
|
||||||
|
_quarantine_terminal(path, identity, "foreign-prepared-evidence")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
outcome = _finalize_document_db(kanban_db, identity, document)
|
||||||
|
except Exception as error:
|
||||||
|
_record_board_access_error(identity.board, error)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if outcome == "committed":
|
||||||
|
_promote_prepared_evidence(identity, document, path)
|
||||||
|
pending = _terminal_path(
|
||||||
|
state_path(identity.board, identity.task_id),
|
||||||
|
identity.run_id,
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
pending_identity = TerminalIdentity(
|
||||||
|
identity.board,
|
||||||
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
_resolve_pending_after_winner(
|
||||||
|
pending,
|
||||||
|
pending_identity,
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
recovered += 1
|
||||||
|
elif outcome == "stale":
|
||||||
|
_persist_conflict_evidence(
|
||||||
|
identity,
|
||||||
|
document,
|
||||||
|
"prepared result no longer matches the authoritative task run",
|
||||||
|
)
|
||||||
|
_discard_evidence(path)
|
||||||
|
except Exception as error:
|
||||||
|
_record_board_access_error(identity.board, error)
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
snapshot.close()
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
def recover_pending_finalizations() -> int:
|
def recover_pending_finalizations() -> int:
|
||||||
"""Replay accepted exact-run results before scheduling more provider work."""
|
"""Replay accepted exact-run results before scheduling more provider work."""
|
||||||
from hermes_cli import kanban_db
|
from hermes_cli import kanban_db
|
||||||
|
|
||||||
recovered = 0
|
recovered = _recover_prepared_finalizations(kanban_db)
|
||||||
for path in sorted(STATE_ROOT.glob("*/*.terminal.pending.json")):
|
for path in sorted(STATE_ROOT.glob("*/*.terminal.pending.json")):
|
||||||
identity = _terminal_identity(path)
|
identity = _terminal_identity(path)
|
||||||
record = _load_terminal_json(path, identity) if identity is not None else {}
|
record = _load_terminal_json(path, identity) if identity is not None else {}
|
||||||
@ -1573,14 +2053,26 @@ def _has_pending_finalization(board: str, task_id: str, run_id: Any) -> bool:
|
|||||||
try:
|
try:
|
||||||
path.stat()
|
path.stat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False
|
pass
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
identity = _terminal_identity(path)
|
else:
|
||||||
if identity is None:
|
identity = _terminal_identity(path)
|
||||||
return False
|
if identity is not None:
|
||||||
record = _load_terminal_json(path, identity)
|
record = _load_terminal_json(path, identity)
|
||||||
return _terminal_record_valid(record, identity)
|
if _terminal_record_valid(record, identity):
|
||||||
|
return True
|
||||||
|
base = state_path(board, task_id)
|
||||||
|
for prepared in base.parent.glob(
|
||||||
|
f"{base.stem}.run-{run_id}.terminal.prepared-*.json"
|
||||||
|
):
|
||||||
|
identity = _terminal_evidence_identity(prepared)
|
||||||
|
if identity is None or identity.state != "prepared":
|
||||||
|
continue
|
||||||
|
record = _load_small_json(prepared)
|
||||||
|
if _terminal_evidence_valid(record, identity, "prepared"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
|
def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
|
||||||
@ -1590,6 +2082,7 @@ def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
|
|||||||
"*.provider-*.result.json",
|
"*.provider-*.result.json",
|
||||||
"*.candidate-*.json",
|
"*.candidate-*.json",
|
||||||
"*.terminal.committed.json",
|
"*.terminal.committed.json",
|
||||||
|
"*.terminal.conflict-*.json",
|
||||||
)
|
)
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
candidates.extend(board_dir.glob(pattern))
|
candidates.extend(board_dir.glob(pattern))
|
||||||
@ -1969,9 +2462,10 @@ def execute_claim(board: str, task_id: str) -> None:
|
|||||||
identity = _terminal_identity(terminal_file)
|
identity = _terminal_identity(terminal_file)
|
||||||
replayable = bool(
|
replayable = bool(
|
||||||
identity is not None
|
identity is not None
|
||||||
and _terminal_record_valid(
|
and _has_pending_finalization(
|
||||||
_load_terminal_json(terminal_file, identity),
|
identity.board,
|
||||||
identity,
|
identity.task_id,
|
||||||
|
identity.run_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not replayable:
|
if not replayable:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@ -804,6 +805,28 @@ def test_restart_does_not_reclaim_an_exact_run_awaiting_finalization(
|
|||||||
assert reclaimed == []
|
assert reclaimed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_evidence_without_pending_still_pins_the_exact_run(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
pending, record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_prepared_pin"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_prepared_pin",
|
||||||
|
run_id=14,
|
||||||
|
structured=_completed_result("accepted and prepared"),
|
||||||
|
summary="accepted and prepared",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
identity = lanes._terminal_identity(pending)
|
||||||
|
assert identity is not None
|
||||||
|
lanes._persist_prepared_evidence(identity, record)
|
||||||
|
pending.unlink()
|
||||||
|
|
||||||
|
assert lanes._has_pending_finalization("cassandra", "t_prepared_pin", 14) is True
|
||||||
|
|
||||||
|
|
||||||
def test_terminal_replay_never_crosses_into_a_replacement_run(
|
def test_terminal_replay_never_crosses_into_a_replacement_run(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@ -851,8 +874,11 @@ def test_terminal_replay_never_crosses_into_a_replacement_run(
|
|||||||
assert lanes.recover_pending_finalizations() == 0
|
assert lanes.recover_pending_finalizations() == 0
|
||||||
assert completions == []
|
assert completions == []
|
||||||
assert not terminal_path.exists()
|
assert not terminal_path.exists()
|
||||||
quarantined = list((state_root / "cassandra/quarantine").glob("*.quarantine"))
|
conflicts = list((state_root / "cassandra").glob("*.terminal.conflict-*.json"))
|
||||||
assert len(quarantined) == 1
|
assert len(conflicts) == 1
|
||||||
|
conflict = json.loads(conflicts[0].read_text(encoding="utf-8"))
|
||||||
|
assert conflict["result"] == _record["result"]
|
||||||
|
assert conflict["kanban_state"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
def _completed_result(summary: str = "done") -> dict:
|
def _completed_result(summary: str = "done") -> dict:
|
||||||
@ -1417,6 +1443,555 @@ def test_quarantine_preserves_and_replays_an_atomic_replacement(
|
|||||||
assert not path.exists()
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"swap_point",
|
||||||
|
[
|
||||||
|
"during-complete",
|
||||||
|
"after-db-before-promote",
|
||||||
|
"before-committed-create",
|
||||||
|
"before-pending-retire",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_terminal_first_writer_preserves_valid_replacement_conflicts(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
swap_point: str,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
path, old_record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_first_writer"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_first_writer",
|
||||||
|
run_id=27,
|
||||||
|
structured=_completed_result("first result"),
|
||||||
|
summary="first result",
|
||||||
|
metadata={"writer": "first"},
|
||||||
|
)
|
||||||
|
replacement_structured = _completed_result("replacement result")
|
||||||
|
replacement_record = {
|
||||||
|
"board": "cassandra",
|
||||||
|
"task_id": "t_first_writer",
|
||||||
|
"expected_run_id": 27,
|
||||||
|
"result": json.dumps(replacement_structured, sort_keys=True),
|
||||||
|
"summary": "replacement result",
|
||||||
|
"metadata": {"writer": "replacement"},
|
||||||
|
"kanban_state": "pending",
|
||||||
|
"recorded_at": lanes.utc_now(),
|
||||||
|
}
|
||||||
|
replacement = path.with_name(f"replacement-{swap_point}.tmp")
|
||||||
|
lanes.atomic_json(replacement, replacement_record)
|
||||||
|
swapped = {"value": False}
|
||||||
|
|
||||||
|
def swap_pending():
|
||||||
|
if not swapped["value"]:
|
||||||
|
os.replace(replacement, path)
|
||||||
|
swapped["value"] = True
|
||||||
|
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_first_writer",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=27,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
if swap_point == "during-complete":
|
||||||
|
swap_pending()
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
if swap_point == "after-db-before-promote":
|
||||||
|
real_promote = lanes._promote_prepared_evidence
|
||||||
|
|
||||||
|
def swap_then_promote(*args, **kwargs):
|
||||||
|
swap_pending()
|
||||||
|
return real_promote(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "_promote_prepared_evidence", swap_then_promote)
|
||||||
|
elif swap_point == "before-committed-create":
|
||||||
|
real_create = lanes._write_json_noreplace
|
||||||
|
|
||||||
|
def swap_before_committed(path_arg, value):
|
||||||
|
if path_arg.name.endswith(".terminal.committed.json"):
|
||||||
|
swap_pending()
|
||||||
|
return real_create(path_arg, value)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "_write_json_noreplace", swap_before_committed)
|
||||||
|
elif swap_point == "before-pending-retire":
|
||||||
|
real_retire = lanes._retire_snapshot
|
||||||
|
|
||||||
|
def swap_before_retire(path_arg, snapshot):
|
||||||
|
if path_arg == path:
|
||||||
|
swap_pending()
|
||||||
|
return real_retire(path_arg, snapshot)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "_retire_snapshot", swap_before_retire)
|
||||||
|
|
||||||
|
assert lanes._finalize_terminal_record(fake_db, path, old_record) == "committed"
|
||||||
|
|
||||||
|
assert swapped["value"] is True
|
||||||
|
assert task.result == old_record["result"]
|
||||||
|
assert not path.exists()
|
||||||
|
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||||
|
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||||
|
prepared = list(path.parent.glob("*.terminal.prepared-*.json"))
|
||||||
|
assert len(committed) == 1
|
||||||
|
assert len(conflicts) == 1
|
||||||
|
assert prepared == []
|
||||||
|
assert committed[0].stat().st_mode & 0o777 == 0o600
|
||||||
|
assert conflicts[0].stat().st_mode & 0o777 == 0o600
|
||||||
|
committed_document = json.loads(committed[0].read_text(encoding="utf-8"))
|
||||||
|
conflict_document = json.loads(conflicts[0].read_text(encoding="utf-8"))
|
||||||
|
assert committed_document["result"] == old_record["result"]
|
||||||
|
assert committed_document["kanban_state"] == "committed"
|
||||||
|
assert conflict_document["result"] == replacement_record["result"]
|
||||||
|
assert conflict_document["metadata"] == {"writer": "replacement"}
|
||||||
|
assert conflict_document["kanban_state"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_commit_directory_fsync_failure_recovers_from_prepared_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
path, record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_post_db_fsync"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_post_db_fsync",
|
||||||
|
run_id=28,
|
||||||
|
structured=_completed_result("durable DB winner"),
|
||||||
|
summary="durable DB winner",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_post_db_fsync",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=28,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
replacement_structured = _completed_result("replacement after DB commit")
|
||||||
|
replacement_record = {
|
||||||
|
"board": "cassandra",
|
||||||
|
"task_id": "t_post_db_fsync",
|
||||||
|
"expected_run_id": 28,
|
||||||
|
"result": json.dumps(replacement_structured, sort_keys=True),
|
||||||
|
"summary": "replacement after DB commit",
|
||||||
|
"metadata": {"writer": "replacement"},
|
||||||
|
"kanban_state": "pending",
|
||||||
|
"recorded_at": lanes.utc_now(),
|
||||||
|
}
|
||||||
|
replacement = path.with_name("post-db-fsync-replacement.tmp")
|
||||||
|
lanes.atomic_json(replacement, replacement_record)
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
os.replace(replacement, path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
real_fsync = lanes.os.fsync
|
||||||
|
failed = {"value": False}
|
||||||
|
|
||||||
|
def fail_committed_directory_fsync(descriptor):
|
||||||
|
if (
|
||||||
|
stat.S_ISDIR(os.fstat(descriptor).st_mode)
|
||||||
|
and list(path.parent.glob("*.terminal.committed.json"))
|
||||||
|
and not failed["value"]
|
||||||
|
):
|
||||||
|
failed["value"] = True
|
||||||
|
raise OSError(errno.ENOSPC, "post-DB committed directory fsync failed")
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes.os, "fsync", fail_committed_directory_fsync)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="post-DB committed"):
|
||||||
|
lanes._finalize_terminal_record(fake_db, path, record)
|
||||||
|
|
||||||
|
assert task.status == "done"
|
||||||
|
assert task.result == record["result"]
|
||||||
|
assert path.exists()
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8"))["result"] == (
|
||||||
|
replacement_record["result"]
|
||||||
|
)
|
||||||
|
assert len(list(path.parent.glob("*.terminal.prepared-*.json"))) == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes.os, "fsync", real_fsync)
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||||
|
assert lanes.recover_pending_finalizations() == 1
|
||||||
|
assert not path.exists()
|
||||||
|
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||||
|
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||||
|
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||||
|
assert len(committed) == 1
|
||||||
|
assert len(conflicts) == 1
|
||||||
|
assert json.loads(committed[0].read_text())["result"] == record["result"]
|
||||||
|
assert json.loads(conflicts[0].read_text())["result"] == replacement_record["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_db_completion_outcome_replays_from_prepared_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
path, record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_unknown_db_outcome"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_unknown_db_outcome",
|
||||||
|
run_id=35,
|
||||||
|
structured=_completed_result("DB committed before transport error"),
|
||||||
|
summary="DB committed before transport error",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_unknown_db_outcome",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=35,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
fail_once = {"value": True}
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
if fail_once["value"]:
|
||||||
|
fail_once["value"] = False
|
||||||
|
raise RuntimeError("transport failed after DB commit")
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="after DB commit"):
|
||||||
|
lanes._finalize_terminal_record(fake_db, path, record)
|
||||||
|
|
||||||
|
assert task.status == "done"
|
||||||
|
assert path.exists()
|
||||||
|
assert len(list(path.parent.glob("*.terminal.prepared-*.json"))) == 1
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||||
|
assert lanes.recover_pending_finalizations() == 1
|
||||||
|
assert not path.exists()
|
||||||
|
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||||
|
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||||
|
assert len(committed) == 1
|
||||||
|
assert json.loads(committed[0].read_text())["result"] == record["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_retirement_fsync_failure_keeps_db_winner_committed(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
path, record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_retire_fsync"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_retire_fsync",
|
||||||
|
run_id=33,
|
||||||
|
structured=_completed_result("retirement fsync winner"),
|
||||||
|
summary="retirement fsync winner",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_retire_fsync",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=33,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
real_fsync = lanes.os.fsync
|
||||||
|
failed = {"value": False}
|
||||||
|
|
||||||
|
def fail_pending_retirement_fsync(descriptor):
|
||||||
|
if (
|
||||||
|
stat.S_ISDIR(os.fstat(descriptor).st_mode)
|
||||||
|
and not path.exists()
|
||||||
|
and list(path.parent.glob("*.terminal.committed.json"))
|
||||||
|
and not failed["value"]
|
||||||
|
):
|
||||||
|
failed["value"] = True
|
||||||
|
raise OSError(errno.ENOSPC, "pending retirement fsync failed")
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes.os, "fsync", fail_pending_retirement_fsync)
|
||||||
|
|
||||||
|
assert lanes._finalize_terminal_record(fake_db, path, record) == "committed"
|
||||||
|
assert failed["value"] is True
|
||||||
|
assert task.status == "done"
|
||||||
|
assert task.result == record["result"]
|
||||||
|
assert not path.exists()
|
||||||
|
assert len(list(path.parent.glob("*.terminal.committed.json"))) == 1
|
||||||
|
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_committed_first_writer_is_never_overwritten_by_a_db_winner(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
state_file = lanes.state_path("cassandra", "t_committed_collision")
|
||||||
|
pending, winner = lanes._write_terminal_record(
|
||||||
|
state_file,
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_committed_collision",
|
||||||
|
run_id=34,
|
||||||
|
structured=_completed_result("DB winner"),
|
||||||
|
summary="DB winner",
|
||||||
|
metadata={"writer": "db"},
|
||||||
|
)
|
||||||
|
committed_path = lanes._terminal_path(state_file, 34, "committed")
|
||||||
|
first_writer = {
|
||||||
|
"board": "cassandra",
|
||||||
|
"task_id": "t_committed_collision",
|
||||||
|
"expected_run_id": 34,
|
||||||
|
"result": json.dumps(_completed_result("evidence first writer"), sort_keys=True),
|
||||||
|
"summary": "evidence first writer",
|
||||||
|
"metadata": {"writer": "evidence"},
|
||||||
|
"kanban_state": "committed",
|
||||||
|
"recorded_at": lanes.utc_now(),
|
||||||
|
}
|
||||||
|
lanes.atomic_json(committed_path, first_writer)
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_committed_collision",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=34,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lanes._finalize_terminal_record(fake_db, pending, winner) == "committed"
|
||||||
|
assert task.result == winner["result"]
|
||||||
|
assert json.loads(committed_path.read_text())["result"] == first_writer["result"]
|
||||||
|
conflicts = list(pending.parent.glob("*.terminal.conflict-*.json"))
|
||||||
|
assert len(conflicts) == 1
|
||||||
|
assert json.loads(conflicts[0].read_text())["result"] == winner["result"]
|
||||||
|
assert not pending.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_duplicate_terminal_finalizers_are_idempotent(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
path, record = lanes._write_terminal_record(
|
||||||
|
lanes.state_path("cassandra", "t_duplicate"),
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_duplicate",
|
||||||
|
run_id=29,
|
||||||
|
structured=_completed_result("same accepted result"),
|
||||||
|
summary="same accepted result",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
task_state = {
|
||||||
|
"status": "running",
|
||||||
|
"result": None,
|
||||||
|
"current_run_id": 29,
|
||||||
|
}
|
||||||
|
state_lock = threading.Lock()
|
||||||
|
readers = threading.Barrier(2)
|
||||||
|
completions = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_task(_conn, _task_id):
|
||||||
|
with state_lock:
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
id="t_duplicate",
|
||||||
|
assignee="cli-auto",
|
||||||
|
**task_state,
|
||||||
|
)
|
||||||
|
if snapshot.status == "running":
|
||||||
|
readers.wait(timeout=5)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
with state_lock:
|
||||||
|
if task_state["status"] != "running":
|
||||||
|
return False
|
||||||
|
task_state.update(
|
||||||
|
status="done",
|
||||||
|
result=kwargs["result"],
|
||||||
|
current_run_id=None,
|
||||||
|
)
|
||||||
|
completions.append(kwargs["result"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=get_task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
outcomes = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def finalize():
|
||||||
|
try:
|
||||||
|
outcomes.append(lanes._finalize_terminal_record(fake_db, path, record))
|
||||||
|
except Exception as error: # pragma: no cover - assertion reports detail
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
workers = [threading.Thread(target=finalize) for _index in range(2)]
|
||||||
|
for worker in workers:
|
||||||
|
worker.start()
|
||||||
|
for worker in workers:
|
||||||
|
worker.join(timeout=10)
|
||||||
|
|
||||||
|
assert all(not worker.is_alive() for worker in workers)
|
||||||
|
assert errors == []
|
||||||
|
assert outcomes == ["committed", "committed"]
|
||||||
|
assert len(completions) == 1
|
||||||
|
assert not path.exists()
|
||||||
|
assert len(list(path.parent.glob("*.terminal.committed.json"))) == 1
|
||||||
|
assert list(path.parent.glob("*.terminal.conflict-*.json")) == []
|
||||||
|
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_uses_first_durable_prepared_result_as_db_writer(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||||
|
state_file = lanes.state_path("cassandra", "t_prepared_order")
|
||||||
|
pending, first = lanes._write_terminal_record(
|
||||||
|
state_file,
|
||||||
|
board="cassandra",
|
||||||
|
task_id="t_prepared_order",
|
||||||
|
run_id=30,
|
||||||
|
structured=_completed_result("first durable result"),
|
||||||
|
summary="first durable result",
|
||||||
|
metadata={"writer": "first"},
|
||||||
|
)
|
||||||
|
identity = lanes._terminal_identity(pending)
|
||||||
|
assert identity is not None
|
||||||
|
first_prepared = lanes._persist_prepared_evidence(identity, first)
|
||||||
|
os.utime(first_prepared, ns=(100, 100))
|
||||||
|
second_structured = _completed_result("second durable result")
|
||||||
|
second = {
|
||||||
|
"board": "cassandra",
|
||||||
|
"task_id": "t_prepared_order",
|
||||||
|
"expected_run_id": 30,
|
||||||
|
"result": json.dumps(second_structured, sort_keys=True),
|
||||||
|
"summary": "second durable result",
|
||||||
|
"metadata": {"writer": "second"},
|
||||||
|
"kanban_state": "pending",
|
||||||
|
"recorded_at": lanes.utc_now(),
|
||||||
|
}
|
||||||
|
second_prepared = lanes._persist_prepared_evidence(identity, second)
|
||||||
|
os.utime(second_prepared, ns=(200, 200))
|
||||||
|
lanes.atomic_json(pending, second)
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_prepared_order",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=30,
|
||||||
|
assignee="cli-auto",
|
||||||
|
)
|
||||||
|
completions = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def complete_task(_conn, _task_id, **kwargs):
|
||||||
|
if task.status != "running":
|
||||||
|
return False
|
||||||
|
task.status = "done"
|
||||||
|
task.result = kwargs["result"]
|
||||||
|
task.current_run_id = None
|
||||||
|
completions.append(kwargs["result"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
scoped_current_board=lambda _board: nullcontext(),
|
||||||
|
connect=lambda board: Connection(),
|
||||||
|
get_task=lambda _conn, _task_id: task,
|
||||||
|
complete_task=complete_task,
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||||
|
|
||||||
|
assert lanes.recover_pending_finalizations() == 1
|
||||||
|
assert completions == [first["result"]]
|
||||||
|
assert task.result == first["result"]
|
||||||
|
assert not pending.exists()
|
||||||
|
assert list(pending.parent.glob("*.terminal.prepared-*.json")) == []
|
||||||
|
committed = list(pending.parent.glob("*.terminal.committed.json"))
|
||||||
|
conflicts = list(pending.parent.glob("*.terminal.conflict-*.json"))
|
||||||
|
assert len(committed) == 1
|
||||||
|
assert len(conflicts) == 1
|
||||||
|
assert json.loads(committed[0].read_text())["result"] == first["result"]
|
||||||
|
assert json.loads(conflicts[0].read_text())["result"] == second["result"]
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_utf8_journal_quarantines_and_does_not_stop_later_replay(
|
def test_invalid_utf8_journal_quarantines_and_does_not_stop_later_replay(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@ -1651,6 +2226,7 @@ def test_artifact_gc_prunes_by_age_without_touching_pending_journals(
|
|||||||
board / "t.run-1.candidate-1.json",
|
board / "t.run-1.candidate-1.json",
|
||||||
board / "t.run-1.provider-1.result.json",
|
board / "t.run-1.provider-1.result.json",
|
||||||
board / "t.run-1.terminal.committed.json",
|
board / "t.run-1.terminal.committed.json",
|
||||||
|
board / f"t.run-1.terminal.conflict-{'a' * 32}.json",
|
||||||
quarantine / "t.invalid.1234.quarantine",
|
quarantine / "t.invalid.1234.quarantine",
|
||||||
]
|
]
|
||||||
for artifact in old_artifacts:
|
for artifact in old_artifacts:
|
||||||
@ -1659,10 +2235,13 @@ def test_artifact_gc_prunes_by_age_without_touching_pending_journals(
|
|||||||
quarantine_symlink.symlink_to(tmp_path / "missing-target")
|
quarantine_symlink.symlink_to(tmp_path / "missing-target")
|
||||||
pending = board / "t.run-1.terminal.pending.json"
|
pending = board / "t.run-1.terminal.pending.json"
|
||||||
pending.write_text("{}", encoding="utf-8")
|
pending.write_text("{}", encoding="utf-8")
|
||||||
|
prepared = board / f"t.run-1.terminal.prepared-{'b' * 32}.json"
|
||||||
|
prepared.write_text("{}", encoding="utf-8")
|
||||||
old_time = 100.0
|
old_time = 100.0
|
||||||
for artifact in old_artifacts:
|
for artifact in old_artifacts:
|
||||||
os.utime(artifact, (old_time, old_time))
|
os.utime(artifact, (old_time, old_time))
|
||||||
os.utime(pending, (old_time, old_time))
|
os.utime(pending, (old_time, old_time))
|
||||||
|
os.utime(prepared, (old_time, old_time))
|
||||||
|
|
||||||
assert lanes.gc_lane_artifacts(
|
assert lanes.gc_lane_artifacts(
|
||||||
now=1000.0, max_age_seconds=10, max_count=100, max_bytes=10000
|
now=1000.0, max_age_seconds=10, max_count=100, max_bytes=10000
|
||||||
@ -1670,6 +2249,7 @@ def test_artifact_gc_prunes_by_age_without_touching_pending_journals(
|
|||||||
assert not any(artifact.exists() for artifact in old_artifacts)
|
assert not any(artifact.exists() for artifact in old_artifacts)
|
||||||
assert not quarantine_symlink.is_symlink()
|
assert not quarantine_symlink.is_symlink()
|
||||||
assert pending.exists()
|
assert pending.exists()
|
||||||
|
assert prepared.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_gc_prunes_oldest_by_count_and_total_bytes(
|
def test_artifact_gc_prunes_oldest_by_count_and_total_bytes(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user