fix(update): cover every flat-install <db>.db.* artifact, cron stores too, one test module

Fold the dot-suffixed database-artifact class into the existing flat-install
ignore block and test module instead of a second fixture:

- `/*.db.*` now replaces `/*.db.retired-wal-*/` (which it subsumes) and the
  same class rule is added for the cron SQLite stores (`/cron/*.db.*`), so a
  quarantine/repair/rebuild/maintenance/init/dispatch lock, the
  repair-attempts ledger or a malformed-backup copy next to ANY root or cron
  store survives `hermes update`'s `git stash push --include-untracked`.
- The runtime-state list in test_update_flat_install_state_gitignore.py gains
  one representative per artifact producer (hermes_state_dbfile,
  hermes_state_repair, hermes_state_common, hermes_cli/kanban_db_*), and the
  inode/flock invariant from #111667 moves into that module, driving the real
  updater seam `_stash_local_changes_if_needed` rather than a bare `git stash`.

Why: a swept lock pathname is re-created on a new inode and a second
exclusive flock succeeds while the first holder is still live (#112974);
naming one file would leave the sibling locks exposed.
This commit is contained in:
teknium1
2026-09-16 10:07:08 -07:00
committed by Teknium
parent 1d0292eb6c
commit 6049ef0fb7
3 changed files with 46 additions and 96 deletions

11
.gitignore vendored
View File

@@ -171,7 +171,11 @@ docs/superpowers/*
# Flat-install runtime state (checkout root == $HERMES_HOME, e.g. installs made
# with HERMES_INSTALL_DIR=$HERMES_HOME or by older installers): every root-level
# SQLite store (state.db, kanban.db, response_store.db, ...) with its
# WAL/SHM/journal sidecars and retired-WAL capture dirs, the legacy transcripts,
# WAL/SHM/journal sidecars and every dot-suffixed `<db>.db.*` runtime artifact
# (retired-WAL capture dirs, quarantine/repair/rebuild/maintenance/init/dispatch
# lock files, the repair-attempts ledger, malformed-backup copies — a swept lock
# path is re-created on a new inode and a second exclusive flock succeeds while
# the first holder is still live, #112974), the legacy transcripts,
# the cron job store (jobs.json), its lock/heartbeat/output files and all three
# cron SQLite stores (executions/deliveries/notepad, WAL-mode like the root
# ones), gateway lock/pid/state files and per-launch markers, cache/spill
@@ -184,11 +188,10 @@ docs/superpowers/*
# gateway (#110648). Nested installs keep all of this under $HERMES_HOME outside
# the checkout, where the `.hermes/` rule above already applies.
/*.db
/*.db.*
/*.db-wal
/*.db-shm
/*.db-journal
/*.db.retired-wal-*/
/*.db.*
/gateway/discord_message_recovery.db*
/sessions/
/browser-profile/
@@ -196,7 +199,7 @@ docs/superpowers/*
/cron/*.db-wal
/cron/*.db-shm
/cron/*.db-journal
/cron/*.db.retired-wal-*/
/cron/*.db.*
/cron/jobs.json
/cron/.jobs.lock
/cron/ticker_heartbeat

View File

@@ -1,92 +0,0 @@
"""Flat-install database-adjacent runtime artifacts must survive update autostash."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
)
@pytest.fixture
def flat_install_repo(tmp_path: Path) -> Path:
repo = tmp_path / "flat-install-checkout"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
shutil.copyfile(REPO_ROOT / ".gitignore", repo / ".gitignore")
(repo / "app.py").write_text("print('hermes')\n")
_git(repo, "add", ".gitignore", "app.py")
_git(
repo,
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
)
return repo
def test_database_adjacent_runtime_artifacts_are_ignored(flat_install_repo: Path):
artifacts = (
"state.db.quarantine.lock",
"state.db.repair.lock",
"state.db.fts_rebuild.lock",
"state.db.auto-maintenance.lock",
"state.db.repair-attempts.json",
"state.db.malformed-backup-20260915_060000",
"state.db.pre-update-emergency-2026-09-15T06-00-00-000Z.bak",
"kanban.db.init.lock",
"kanban.db.dispatch.lock",
"kanban.db.corrupt.20260915.bak",
)
for name in artifacts:
(flat_install_repo / name).write_bytes(b"runtime state")
status = _git(
flat_install_repo,
"status",
"--porcelain",
"--untracked-files=all",
)
assert status.stdout == "", status.stdout
@pytest.mark.skipif(os.name == "nt", reason="fcntl is POSIX-only")
def test_autostash_cannot_split_live_database_lock_inode(flat_install_repo: Path):
import fcntl
lock_path = flat_install_repo / "state.db.quarantine.lock"
with lock_path.open("a+b") as first:
fcntl.flock(first.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
first_stat = os.fstat(first.fileno())
first_identity = first_stat.st_dev, first_stat.st_ino
(flat_install_repo / "app.py").write_text("print('changed')\n")
_git(
flat_install_repo,
"stash",
"push",
"--include-untracked",
"-m",
"hermes-update-autostash",
)
assert lock_path.exists()
assert (lock_path.stat().st_dev, lock_path.stat().st_ino) == first_identity
with lock_path.open("a+b") as second:
with pytest.raises(BlockingIOError):
fcntl.flock(second.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

View File

@@ -9,6 +9,7 @@ every transcript when the restore is declined or fails its health check. The
tracked .gitignore must cover the runtime state set, mirroring the
.hermes-bootstrap-complete / .install_method precedent (#38529 / #66189).
"""
import os
import shutil
import sqlite3
import subprocess
@@ -28,7 +29,18 @@ FLAT_INSTALL_RUNTIME_STATE = (
"state.db-shm",
"state.db-journal",
"state.db.retired-wal-20260914T000000Z-1234/manifest.json",
# Dot-suffixed `<db>.db.*` runtime artifacts (#112974): cross-process lock
# files from hermes_state_dbfile / hermes_state_repair / hermes_state_common /
# hermes_cli/kanban_db_*, the repair-attempts ledger and malformed backups.
"state.db.quarantine.lock",
"state.db.repair.lock",
"state.db.fts_rebuild.lock",
"state.db.auto-maintenance.lock",
"state.db.repair-attempts.json",
"state.db.malformed-backup-20260914_060000",
"kanban.db",
"kanban.db.init.lock",
"kanban.db.dispatch.lock",
"response_store.db",
"response_store.db-wal",
"gateway/discord_message_recovery.db",
@@ -174,3 +186,30 @@ def test_untracked_autostash_leaves_open_wal_database_readable(flat_install_repo
assert reader.execute("SELECT status FROM executions").fetchall() == [("ok",)]
finally:
conn.close()
@pytest.mark.skipif(os.name == "nt", reason="fcntl is POSIX-only")
def test_untracked_autostash_cannot_split_live_database_lock_inode(flat_install_repo):
"""The updater's real stash step must leave a held ``state.db.quarantine.lock``
on its original inode (#112974). Unlinking a flocked path does not release the
lock, so a swept pathname would be re-created on a new inode and a second
exclusive lock would succeed while the first holder is still live."""
import fcntl
from hermes_cli.update_cmd_stash import _stash_local_changes_if_needed
lock_path = flat_install_repo / "state.db.quarantine.lock"
with lock_path.open("a+b") as first:
fcntl.flock(first.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
first_stat = os.fstat(first.fileno())
# A tracked local change makes the updater actually enter its stash step.
(flat_install_repo / "app.py").write_text("print('changed')\n")
assert _stash_local_changes_if_needed(["git"], flat_install_repo)
assert lock_path.exists()
after = lock_path.stat()
assert (after.st_dev, after.st_ino) == (first_stat.st_dev, first_stat.st_ino)
with lock_path.open("a+b") as second:
with pytest.raises(BlockingIOError):
fcntl.flock(second.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)