Compare commits

..

1 Commits

Author SHA1 Message Date
jenkins
2caad7e769 hermes: bind terminal commit to journal inode 2026-08-17 01:43:56 -03:00
6 changed files with 746 additions and 29 deletions

View File

@ -161,16 +161,36 @@ class AutomaticDecompositionSafetyTests(unittest.TestCase):
)
)
self.assertTrue(
kanban_db.complete_task(
self.connection,
task_id,
result="durable terminal result",
summary="durable terminal result",
replay_ended_run_id=run_id,
with mock.patch.object(
kanban_db, "_fire_kanban_lifecycle_hook"
) as lifecycle:
self.assertTrue(
kanban_db.complete_task(
self.connection,
task_id,
result="durable terminal result",
summary="durable terminal result",
replay_ended_run_id=run_id,
)
)
)
self.assertEqual(self._status(task_id), "done")
lifecycle.assert_called_once()
self.assertEqual(lifecycle.call_args.args[:2], ("kanban_task_completed", task_id))
self.assertEqual(lifecycle.call_args.kwargs["run_id"], run_id)
task = kanban_db.get_task(self.connection, task_id)
self.assertEqual(task.status, "done")
self.assertEqual(task.completed_run_id, run_id)
runs = kanban_db.list_runs(self.connection, task_id)
self.assertEqual(len(runs), 1)
self.assertEqual(runs[0].id, run_id)
self.assertEqual(runs[0].status, "done")
self.assertEqual(runs[0].outcome, "completed")
self.assertEqual(runs[0].summary, "durable terminal result")
completed_events = self.connection.execute(
"SELECT run_id FROM task_events "
"WHERE task_id = ? AND kind = 'completed' ORDER BY id",
(task_id,),
).fetchall()
self.assertEqual([row["run_id"] for row in completed_events], [run_id])
def test_older_ended_run_cannot_complete_over_a_replacement(self) -> None:
task_id = kanban_db.create_task(self.connection, title="replacement guard")
@ -209,6 +229,38 @@ class AutomaticDecompositionSafetyTests(unittest.TestCase):
)
self.assertIn(self._status(task_id), {"blocked", "triage"})
def test_equal_result_bytes_remain_bound_to_the_completing_run(self) -> None:
task_id = kanban_db.create_task(self.connection, title="same result retries")
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
first_run = kanban_db.get_task(self.connection, task_id).current_run_id
self.assertTrue(
kanban_db.block_task(
self.connection,
task_id,
reason="first attempt ended",
expected_run_id=first_run,
)
)
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
second_run = kanban_db.get_task(self.connection, task_id).current_run_id
self.assertTrue(
kanban_db.complete_task(
self.connection,
task_id,
result="identical result bytes",
summary="second run wins",
expected_run_id=second_run,
)
)
task = kanban_db.get_task(self.connection, task_id)
self.assertEqual(task.completed_run_id, second_run)
self.assertNotEqual(task.completed_run_id, first_run)
runs = {run.id: run for run in kanban_db.list_runs(self.connection, task_id)}
self.assertEqual(runs[first_run].outcome, "blocked")
self.assertEqual(runs[second_run].outcome, "completed")
def test_exact_run_reclaim_succeeds_for_the_authoritative_run(self) -> None:
task_id = kanban_db.create_task(self.connection, title="recover exact run")
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))

View File

@ -72,9 +72,11 @@ complete_branch_after = ''' # Journal recovery may arrive after a legacy erro
# accepted run. Verify that it is still the latest run in this same
# transaction; a replacement attempt must make the replay fail closed.
with write_txn(conn):
replayed_run_id = None
if expected_run_id is not None and replay_ended_run_id is not None:
raise ValueError("expected_run_id and replay_ended_run_id are mutually exclusive")
if replay_ended_run_id is not None:
replayed_run_id = int(replay_ended_run_id)
cur = conn.execute(
"""
UPDATE tasks
@ -95,8 +97,22 @@ complete_branch_after = ''' # Journal recovery may arrive after a legacy erro
ORDER BY id DESC
LIMIT 1
)
AND EXISTS (
SELECT 1 FROM task_runs
WHERE id = ?
AND task_id = ?
AND ended_at IS NOT NULL
)
""",
(result, now, task_id, int(replay_ended_run_id), task_id),
(
result,
now,
task_id,
replayed_run_id,
task_id,
replayed_run_id,
task_id,
),
)
elif expected_run_id is None:
cur = conn.execute(
@ -124,6 +140,80 @@ db = replace_once(
"transactional exact ended-run completion",
)
run_completion_before = ''' run_id = _end_run(
conn, task_id,
outcome="completed", status="done",
summary=summary if summary is not None else result,
metadata=metadata,
)
# If complete_task was called on a never-claimed task (ready or
# blocked → done with no run in flight), synthesize a
# zero-duration run so the handoff fields are persisted in
# attempt history instead of silently lost.
if run_id is None and (summary or metadata or result):
run_id = _synthesize_ended_run(
conn, task_id,
outcome="completed",
summary=summary if summary is not None else result,
metadata=metadata,
)
'''
run_completion_after = ''' if replayed_run_id is None:
run_id = _end_run(
conn, task_id,
outcome="completed", status="done",
summary=summary if summary is not None else result,
metadata=metadata,
)
# If complete_task was called on a never-claimed task (ready or
# blocked → done with no run in flight), synthesize a
# zero-duration run so the handoff fields are persisted in
# attempt history instead of silently lost.
if run_id is None and (summary or metadata or result):
run_id = _synthesize_ended_run(
conn, task_id,
outcome="completed",
summary=summary if summary is not None else result,
metadata=metadata,
)
else:
run_id = replayed_run_id
replayed = conn.execute(
"""
UPDATE task_runs
SET status = 'done',
outcome = 'completed',
summary = ?,
error = NULL,
metadata = ?,
claim_lock = NULL,
claim_expires = NULL,
worker_pid = NULL
WHERE id = ?
AND task_id = ?
AND ended_at IS NOT NULL
""",
(
summary if summary is not None else result,
json.dumps(metadata, ensure_ascii=False) if metadata else None,
run_id,
task_id,
),
)
if replayed.rowcount != 1:
raise RuntimeError("exact ended-run completion lost its run identity")
conn.execute(
"UPDATE tasks SET completed_run_id = ? WHERE id = ?",
(run_id, task_id),
)
'''
db = replace_once(
db,
run_completion_before,
run_completion_after,
"exact completion run provenance",
)
reclaim_before = '''def reclaim_task(
conn: sqlite3.Connection,
task_id: str,
@ -284,6 +374,81 @@ db = replace_once(
"transactional exact-run reclaim",
)
task_field_before = ''' current_run_id: Optional[int] = None
workflow_template_id: Optional[str] = None
'''
task_field_after = ''' current_run_id: Optional[int] = None
# Durable provenance for the run that authoritatively completed this task.
completed_run_id: Optional[int] = None
workflow_template_id: Optional[str] = None
'''
db = replace_once(
db,
task_field_before,
task_field_after,
"completed-run task field",
)
task_row_before = ''' current_run_id=(
row["current_run_id"] if "current_run_id" in keys else None
),
workflow_template_id=(
'''
task_row_after = ''' current_run_id=(
row["current_run_id"] if "current_run_id" in keys else None
),
completed_run_id=(
row["completed_run_id"] if "completed_run_id" in keys else None
),
workflow_template_id=(
'''
db = replace_once(
db,
task_row_before,
task_row_after,
"completed-run row mapping",
)
task_schema_before = ''' -- run is in-flight). Denormalised for cheap reads.
current_run_id INTEGER,
-- Forward-compat for v2 workflow routing. In v1 the kernel writes
'''
task_schema_after = ''' -- run is in-flight). Denormalised for cheap reads.
current_run_id INTEGER,
-- Immutable provenance for the task result's authoritative run.
completed_run_id INTEGER,
-- Forward-compat for v2 workflow routing. In v1 the kernel writes
'''
db = replace_once(
db,
task_schema_before,
task_schema_after,
"completed-run schema",
)
task_migration_before = ''' if "current_run_id" not in cols:
_add_column_if_missing(
conn, "tasks", "current_run_id", "current_run_id INTEGER"
)
if "workflow_template_id" not in cols:
'''
task_migration_after = ''' if "current_run_id" not in cols:
_add_column_if_missing(
conn, "tasks", "current_run_id", "current_run_id INTEGER"
)
if "completed_run_id" not in cols:
_add_column_if_missing(
conn, "tasks", "completed_run_id", "completed_run_id INTEGER"
)
if "workflow_template_id" not in cols:
'''
db = replace_once(
db,
task_migration_before,
task_migration_after,
"completed-run migration",
)
specify_signature_before = '''def specify_triage_task(
conn: sqlite3.Connection,
task_id: str,

View File

@ -425,6 +425,11 @@ _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$"
)
_RETIRE_NAME = re.compile(
r"^\.retire\.(?P<path_digest>[a-f0-9]{16})\."
r"(?P<source_digest>[a-f0-9]{16})\.(?P<sequence>[0-9]+)$"
)
_LEGACY_RETIRE_NAME = re.compile(r"^\.retire\.[a-f0-9]{32}\.[0-9]+$")
_SAFE_BOARD = re.compile(r"^[a-zA-Z0-9_.-]+$")
@ -1393,6 +1398,7 @@ def _retire_terminal_entry(
*,
board_descriptor: int,
quarantine_descriptor: int,
authority_name: str | None = None,
) -> str:
"""Retire only the inode previously inspected; preserve any replacement."""
try:
@ -1405,11 +1411,18 @@ def _retire_terminal_entry(
return "missing"
if current.st_dev != source_stat.st_dev or current.st_ino != source_stat.st_ino:
return "replacement"
identity = hashlib.sha256(
f"{path.name}\0{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode("utf-8")
).hexdigest()[:32]
# A replacement can itself be staged repeatedly while recovery races a
# writer. Keep every generation bound to the original canonical pending
# name instead of hashing an intermediate .retire name and hiding it from
# the next recovery pass.
path_digest = hashlib.sha256(
(authority_name or path.name).encode("utf-8")
).hexdigest()[:16]
source_digest = hashlib.sha256(
f"{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode("utf-8")
).hexdigest()[:16]
for sequence in range(32):
staging = f".retire.{identity}.{sequence}"
staging = f".retire.{path_digest}.{source_digest}.{sequence}"
try:
_rename_noreplace(
path.name,
@ -1858,13 +1871,19 @@ def _recover_exact_run(
return recovered
def _retire_snapshot(path: Path, snapshot: TerminalSnapshot) -> str:
def _retire_snapshot(
path: Path,
snapshot: TerminalSnapshot,
*,
authority_name: str | None = None,
) -> str:
"""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,
authority_name=authority_name,
)
@ -1946,7 +1965,11 @@ def _finalize_document_db(
if status == "done":
return (
"committed"
if str(_task_value(task, "result", "") or "") == document["result"]
if (
_task_value(task, "completed_run_id", None) == identity.run_id
and str(_task_value(task, "result", "") or "")
== document["result"]
)
else "stale"
)
current_run_id = _task_value(task, "current_run_id", None)
@ -1976,7 +1999,11 @@ def _finalize_document_db(
if latest is not None and str(_task_value(latest, "status", "")) == "done":
return (
"committed"
if str(_task_value(latest, "result", "") or "") == document["result"]
if (
_task_value(latest, "completed_run_id", None) == identity.run_id
and str(_task_value(latest, "result", "") or "")
== document["result"]
)
else "stale"
)
return "pending"
@ -2102,6 +2129,92 @@ def _terminal_entry_absent(path: Path) -> bool:
os.close(directory)
def _staged_terminal_authority(
path: Path,
snapshot: TerminalRecoverySnapshot,
) -> tuple[TerminalIdentity, Path] | None:
"""Bind a staged inode to the canonical pending name it was moved from."""
try:
relative = path.relative_to(STATE_ROOT)
except ValueError:
return None
if len(relative.parts) != 2:
return None
board, filename = relative.parts
match = _RETIRE_NAME.fullmatch(filename)
legacy = _LEGACY_RETIRE_NAME.fullmatch(filename)
document = snapshot.document
if (
(match is None and legacy is None)
or document is None
or not _SAFE_BOARD.fullmatch(board)
or board in {".", ".."}
or document.get("board") != board
or not isinstance(document.get("task_id"), str)
or not isinstance(document.get("expected_run_id"), int)
):
return None
identity = TerminalIdentity(
board,
document["task_id"],
document["expected_run_id"],
"pending",
)
canonical = _terminal_path(
state_path(identity.board, identity.task_id),
identity.run_id,
"pending",
)
if (
canonical.parent != path.parent
or (
match is not None
and hashlib.sha256(canonical.name.encode("utf-8")).hexdigest()[:16]
!= match.group("path_digest")
)
or not _terminal_record_valid(document, identity)
):
return None
return identity, canonical
def _recover_retirement_staging() -> int:
"""Promote valid hidden journals to durable prepared evidence."""
recovered = 0
for path in sorted(STATE_ROOT.glob("*/.retire.*")):
snapshot = _open_terminal_recovery_snapshot(path)
if snapshot is None:
continue
authority = _staged_terminal_authority(path, snapshot)
if authority is None:
try:
_quarantine_terminal(
path,
None,
"untrusted-retirement-staging",
snapshot=snapshot,
)
finally:
snapshot.close()
continue
identity, canonical = authority
try:
_persist_prepared_evidence(identity, snapshot.document or {})
retirement = _retire_snapshot(
path,
snapshot,
authority_name=canonical.name,
)
except Exception as error:
_record_board_access_error(identity.board, error)
continue
finally:
snapshot.close()
if retirement in {"retired", "missing"}:
recovered += 1
return recovered
def _recover_prepared_finalizations(kanban_db: Any) -> int:
"""Recover an accepted result staged before an interrupted DB boundary."""
recovered = 0
@ -2176,6 +2289,7 @@ def recover_pending_finalizations() -> int:
"""Replay accepted exact-run results before scheduling more provider work."""
from hermes_cli import kanban_db
_recover_retirement_staging()
recovered = _recover_prepared_finalizations(kanban_db)
for path in sorted(STATE_ROOT.glob("*/*.terminal.pending.json")):
identity = _terminal_identity(path)
@ -2273,6 +2387,21 @@ def _has_pending_finalization(board: str, task_id: str, run_id: Any) -> bool:
record = _load_small_json(prepared)
if _terminal_evidence_valid(record, identity, "prepared"):
return True
for staged in base.parent.glob(".retire.*"):
snapshot = _open_terminal_recovery_snapshot(staged)
if snapshot is None:
continue
try:
authority = _staged_terminal_authority(staged, snapshot)
if authority is not None and authority[0] == TerminalIdentity(
board,
task_id,
run_id,
"pending",
):
return True
finally:
snapshot.close()
return False
@ -2284,6 +2413,7 @@ def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
"*.candidate-*.json",
"*.terminal.committed.json",
"*.terminal.conflict-*.json",
".retire.*",
)
for pattern in patterns:
candidates.extend(board_dir.glob(pattern))
@ -2330,6 +2460,16 @@ def gc_lane_artifacts(
board_removed = 0
entries: list[tuple[Path, os.stat_result]] = []
for path in _artifact_gc_candidates(board_dir):
if path.name.startswith(".retire."):
snapshot = _open_terminal_recovery_snapshot(path)
if snapshot is not None:
try:
if _staged_terminal_authority(path, snapshot) is not None:
# Accepted authority is first promoted/replayed by
# recovery; retention never deletes it directly.
continue
finally:
snapshot.close()
try:
file_stat = path.stat(follow_symlinks=False)
except OSError:
@ -2836,12 +2976,15 @@ def recover_orphans() -> None:
if _external(task) and str(_task_value(task, "status", "")) == "running":
task_id = str(_task_value(task, "id"))
run_id = _task_value(task, "current_run_id", None)
if not isinstance(run_id, int):
continue
if _has_pending_finalization(board, task_id, run_id):
continue
kanban_db.reclaim_task(
conn,
task_id,
reason="direct CLI lane restarted; provider session will resume",
expected_run_id=run_id,
)
BOARD_CORRUPTION_ERRORS.pop(board, None)
except Exception as error:

View File

@ -24,10 +24,12 @@ def recover_running_tasks(kanban_db: Any) -> list[str]:
if str(getattr(task, "status", "")) != "running":
continue
task_id = str(getattr(task, "id", ""))
if not task_id or not kanban_db.reclaim_task(
run_id = getattr(task, "current_run_id", None)
if not task_id or not isinstance(run_id, int) or not kanban_db.reclaim_task(
connection,
task_id,
reason=REASON,
expected_run_id=run_id,
):
continue
kanban_db.add_comment(

View File

@ -11,6 +11,7 @@ import signal
import stat
import sys
import threading
import time
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
@ -686,6 +687,7 @@ def test_accepted_result_survives_failed_finalization_and_replays_exact_run(
return False
task.status = "done"
task.result = kwargs["result"]
task.completed_run_id = kwargs["expected_run_id"]
task.current_run_id = None
return True
@ -805,6 +807,56 @@ def test_restart_does_not_reclaim_an_exact_run_awaiting_finalization(
assert reclaimed == []
def test_orphan_recovery_passes_the_scanned_run_as_atomic_reclaim_guard(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
tasks = [
SimpleNamespace(
id="t_guarded",
status="running",
assignee="cli-auto",
current_run_id=91,
),
SimpleNamespace(
id="t_no_run",
status="running",
assignee="cli-auto",
current_run_id=None,
),
]
reclaimed = []
class Connection:
def close(self):
return None
fake_db = SimpleNamespace(
list_boards=lambda include_archived=False: [{"slug": "cassandra"}],
scoped_current_board=lambda _board: nullcontext(),
connect=lambda board: Connection(),
list_tasks=lambda _conn: tasks,
reclaim_task=lambda _conn, task_id, **kwargs: (
reclaimed.append((task_id, kwargs)) or True
),
)
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
lanes.recover_orphans()
assert reclaimed == [
(
"t_guarded",
{
"reason": "direct CLI lane restarted; provider session will resume",
"expected_run_id": 91,
},
)
]
def test_prepared_evidence_without_pending_still_pins_the_exact_run(
tmp_path: Path,
monkeypatch,
@ -1683,6 +1735,295 @@ def test_recovery_finalizes_the_valid_snapshot_it_classified_before_replacement(
assert conflict["summary"] == "second valid replacement"
def test_replacement_staged_journal_promotes_replays_and_never_reexecutes(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
path = lanes._terminal_path(lanes.state_path("cassandra", "t_staged"), 55)
path.parent.mkdir(parents=True)
path.write_bytes(b"malformed source")
source_stat = path.stat(follow_symlinks=False)
staged_winner = path.with_name("staged-winner.tmp")
canonical_replacement = path.with_name("canonical-replacement.tmp")
first = _pending_terminal_record(
"cassandra", "t_staged", 55, "staged accepted result"
)
second = _pending_terminal_record(
"cassandra", "t_staged", 55, "later canonical replacement"
)
lanes.atomic_json(staged_winner, first)
lanes.atomic_json(canonical_replacement, second)
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path.parent, directory_flags)
real_rename = lanes._rename_noreplace
rename_steps = []
def force_replacement_staging(source, destination, **kwargs):
rename_steps.append((source, destination))
if source == path.name:
os.replace(staged_winner, path)
elif source.startswith(".retire."):
os.replace(canonical_replacement, path)
return real_rename(source, destination, **kwargs)
try:
with monkeypatch.context() as race:
race.setattr(lanes, "_rename_noreplace", force_replacement_staging)
outcome = lanes._retire_terminal_entry(
path,
source_stat,
board_descriptor=descriptor,
quarantine_descriptor=descriptor,
)
finally:
os.close(descriptor)
assert outcome == "replacement-staged"
assert len(rename_steps) == 2
staged = list(path.parent.glob(".retire.*"))
assert len(staged) == 1
staged_snapshot = lanes._open_terminal_recovery_snapshot(staged[0])
assert staged_snapshot is not None
try:
authority = lanes._staged_terminal_authority(staged[0], staged_snapshot)
assert authority is not None
assert authority[0].run_id == 55
assert authority[1] == path
finally:
staged_snapshot.close()
assert lanes._has_pending_finalization("cassandra", "t_staged", 55) is True
assert lanes.gc_lane_artifacts(
now=staged[0].stat().st_mtime + 3600,
max_age_seconds=0,
max_count=0,
max_bytes=0,
) == 0
assert staged[0].exists()
task = SimpleNamespace(
id="t_staged",
status="running",
current_run_id=55,
completed_run_id=None,
result=None,
assignee="cli-auto",
)
completions = []
class Connection:
def close(self):
return None
def complete_task(_conn, _task_id, **kwargs):
task.status = "done"
task.result = kwargs["result"]
task.completed_run_id = kwargs["expected_run_id"]
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 not list(path.parent.glob(".retire.*"))
assert not path.exists()
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
assert len(conflicts) == 1
assert json.loads(conflicts[0].read_text())["result"] == second["result"]
assert lanes.recover_pending_finalizations() == 0
assert completions == [first["result"]]
def test_repeated_retirement_staging_preserves_every_valid_result_once(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
canonical = lanes._terminal_path(
lanes.state_path("cassandra", "t_staged_repeat"), 57
)
canonical.parent.mkdir(parents=True)
path_digest = hashlib.sha256(canonical.name.encode("utf-8")).hexdigest()[:16]
staged = canonical.parent / f".retire.{path_digest}.{'a' * 16}.0"
records = [
_pending_terminal_record(
"cassandra", "t_staged_repeat", 57, f"accepted result {number}"
)
for number in range(3)
]
lanes.atomic_json(staged, records[0])
second = canonical.with_name("second-staged-replacement.tmp")
third = canonical.with_name("third-staged-replacement.tmp")
lanes.atomic_json(second, records[1])
lanes.atomic_json(third, records[2])
real_rename = lanes._rename_noreplace
retirement_steps = []
def replace_each_retirement_generation(source, destination, **kwargs):
if source == staged.name and destination.startswith(".retire."):
retirement_steps.append("move")
os.replace(second, staged)
elif source.startswith(".retire.") and destination == staged.name:
retirement_steps.append("restore")
os.replace(third, staged)
return real_rename(source, destination, **kwargs)
with monkeypatch.context() as race:
race.setattr(lanes, "_rename_noreplace", replace_each_retirement_generation)
assert lanes._recover_retirement_staging() == 0
assert retirement_steps == ["move", "restore"]
staged_generations = list(canonical.parent.glob(".retire.*"))
assert len(staged_generations) == 2
for generation in staged_generations:
snapshot = lanes._open_terminal_recovery_snapshot(generation)
assert snapshot is not None
try:
authority = lanes._staged_terminal_authority(generation, snapshot)
assert authority is not None
assert authority[1] == canonical
finally:
snapshot.close()
task = SimpleNamespace(
id="t_staged_repeat",
status="running",
current_run_id=57,
completed_run_id=None,
result=None,
assignee="cli-auto",
)
completions = []
reclaims = []
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
assert lanes.recover_pending_finalizations() == 1
assert len(completions) == 1
assert reclaims == []
assert not list(canonical.parent.glob(".retire.*"))
conflicts = list(canonical.parent.glob("*.terminal.conflict-*.json"))
assert len(conflicts) == 2
preserved_results = {task.result}
preserved_results.update(json.loads(path.read_text())["result"] for path in conflicts)
assert preserved_results == {record["result"] for record in records}
assert lanes.recover_pending_finalizations() == 0
assert len(completions) == 1
def test_untrusted_retirement_staging_is_bounded_by_artifact_gc(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
board = lanes.STATE_ROOT / "cassandra"
board.mkdir(parents=True)
for sequence in range(10):
(board / f".retire.{'a' * 16}.{'b' * 16}.{sequence}").write_bytes(
b"not terminal authority"
)
(board / f".retire.{'c' * 32}.0").write_bytes(b"legacy hidden artifact")
removed = lanes.gc_lane_artifacts(
now=time.time(),
max_age_seconds=3600,
max_count=2,
max_bytes=1024 * 1024,
)
assert removed == 9
assert len(list(board.glob(".retire.*"))) == 2
def test_legacy_valid_retirement_staging_is_promoted_without_reexecution(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
board = lanes.STATE_ROOT / "cassandra"
board.mkdir(parents=True)
staged = board / f".retire.{'d' * 32}.0"
record = _pending_terminal_record(
"cassandra", "t_legacy_staged", 56, "legacy staged result"
)
lanes.atomic_json(staged, record)
task = SimpleNamespace(
id="t_legacy_staged",
status="running",
current_run_id=56,
completed_run_id=None,
result=None,
assignee="cli-auto",
)
completions = []
reclaims = []
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
assert lanes._has_pending_finalization(
"cassandra", "t_legacy_staged", 56
) is True
assert lanes.recover_pending_finalizations() == 1
assert completions == [record["result"]]
assert reclaims == []
assert not staged.exists()
assert lanes.recover_pending_finalizations() == 0
assert completions == [record["result"]]
def test_done_result_bytes_do_not_authorize_a_different_run_journal(
tmp_path: Path,
monkeypatch,
):
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
path, stale = lanes._write_terminal_record(
lanes.state_path("cassandra", "t_provenance"),
board="cassandra",
task_id="t_provenance",
run_id=1,
structured=_completed_result("same bytes"),
summary="same bytes",
metadata={},
)
task = SimpleNamespace(
id="t_provenance",
status="done",
current_run_id=None,
completed_run_id=2,
result=stale["result"],
assignee="cli-auto",
)
class Connection:
def close(self):
return None
fake_db = SimpleNamespace(
scoped_current_board=lambda _board: nullcontext(),
connect=lambda board: Connection(),
get_task=lambda _conn, _task_id: task,
complete_task=lambda *_args, **_kwargs: pytest.fail(
"a done task with different run provenance must not be completed"
),
)
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
assert lanes.recover_pending_finalizations() == 0
assert not path.exists()
assert not list(path.parent.glob("*.terminal.committed.json"))
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
assert len(conflicts) == 1
conflict = json.loads(conflicts[0].read_text())
assert conflict["expected_run_id"] == 1
assert conflict["result"] == task.result
@pytest.mark.parametrize(
"swap_point",
[
@ -1821,6 +2162,7 @@ def test_terminal_commit_directory_fsync_failure_recovers_from_prepared_evidence
status="running",
result=None,
current_run_id=28,
completed_run_id=None,
assignee="cli-auto",
)
replacement_structured = _completed_result("replacement after DB commit")
@ -1844,6 +2186,7 @@ def test_terminal_commit_directory_fsync_failure_recovers_from_prepared_evidence
def complete_task(_conn, _task_id, **kwargs):
task.status = "done"
task.result = kwargs["result"]
task.completed_run_id = kwargs["expected_run_id"]
task.current_run_id = None
os.replace(replacement, path)
return True
@ -1912,6 +2255,7 @@ def test_unknown_db_completion_outcome_replays_from_prepared_evidence(
status="running",
result=None,
current_run_id=35,
completed_run_id=None,
assignee="cli-auto",
)
fail_once = {"value": True}
@ -1923,6 +2267,7 @@ def test_unknown_db_completion_outcome_replays_from_prepared_evidence(
def complete_task(_conn, _task_id, **kwargs):
task.status = "done"
task.result = kwargs["result"]
task.completed_run_id = kwargs["expected_run_id"]
task.current_run_id = None
if fail_once["value"]:
fail_once["value"] = False
@ -2093,6 +2438,7 @@ def test_concurrent_duplicate_terminal_finalizers_are_idempotent(
"status": "running",
"result": None,
"current_run_id": 29,
"completed_run_id": None,
}
state_lock = threading.Lock()
readers = threading.Barrier(2)
@ -2118,10 +2464,11 @@ def test_concurrent_duplicate_terminal_finalizers_are_idempotent(
if task_state["status"] != "running":
return False
task_state.update(
status="done",
result=kwargs["result"],
current_run_id=None,
)
status="done",
result=kwargs["result"],
current_run_id=None,
completed_run_id=kwargs["expected_run_id"],
)
completions.append(kwargs["result"])
return True

View File

@ -42,7 +42,7 @@ class FakeKanban:
self.tasks = tasks
self.has_board = board_exists
self.connection = FakeConnection()
self.reclaimed: list[tuple[str, str]] = []
self.reclaimed: list[tuple[str, str, int]] = []
self.comments: list[tuple[str, str, str]] = []
def board_exists(self, board: str) -> bool:
@ -61,9 +61,16 @@ class FakeKanban:
assert connection is self.connection
return self.tasks
def reclaim_task(self, connection, task_id: str, *, reason: str) -> bool:
def reclaim_task(
self,
connection,
task_id: str,
*,
reason: str,
expected_run_id: int,
) -> bool:
assert connection is self.connection
self.reclaimed.append((task_id, reason))
self.reclaimed.append((task_id, reason, expected_run_id))
return task_id != "t_race"
def add_comment(self, connection, task_id: str, author: str, body: str) -> None:
@ -75,16 +82,17 @@ def test_recover_running_tasks_requeues_only_claimed_workers() -> None:
module = _load_module()
kanban = FakeKanban(
[
SimpleNamespace(id="t_running", status="running"),
SimpleNamespace(id="t_running", status="running", current_run_id=10),
SimpleNamespace(id="t_done", status="done"),
SimpleNamespace(id="t_race", status="running"),
SimpleNamespace(id="t_race", status="running", current_run_id=11),
SimpleNamespace(id="t_missing_run", status="running", current_run_id=None),
]
)
assert module.recover_running_tasks(kanban) == ["t_running"]
assert kanban.reclaimed == [
("t_running", module.REASON),
("t_race", module.REASON),
("t_running", module.REASON, 10),
("t_race", module.REASON, 11),
]
assert kanban.comments == [
(