Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: plugin-ci

on:
pull_request:
push:
workflow_dispatch:

permissions:
contents: read

jobs:
validate:
name: validate (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run plugin validation and lifecycle smoke
shell: bash
run: PYTHON=python bash scripts/ci-local.sh
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ The validation script compiles the plugin, loads the Hermes registration
surface, exercises the original and 0.2 methods, checks lifecycle hooks, and
checks the public distribution surface.

GitHub Actions runs this same validation on current Ubuntu and Windows runners.
The matrix protects the POSIX `fcntl` backend used on macOS and Linux and the
Windows `msvcrt` backend used by the installed Hermes plugin.

## Documentation

- [Hermes plugin operation](docs/HERMES_PLUGIN.md)
Expand Down
2 changes: 1 addition & 1 deletion aem_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def _atomic_replace_text(path: Path, text: str) -> None:
handle, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(handle, "w", encoding="utf-8") as temporary:
if path.exists():
if path.exists() and hasattr(os, "fchmod"):
os.fchmod(temporary.fileno(), path.stat().st_mode & 0o777)
temporary.write(text)
temporary.flush()
Expand Down
70 changes: 63 additions & 7 deletions aethermind_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,30 @@ def _layer_hash(layer: "AetherLayer") -> str:
except ImportError: # pragma: no cover - Windows or unsupported host
fcntl = None # type: ignore[assignment]

try:
import msvcrt
except ImportError: # pragma: no cover - POSIX or unsupported host
msvcrt = None # type: ignore[assignment]


def _lock_backend() -> str:
return "fcntl" if fcntl is not None else "unavailable"
if fcntl is not None:
return "fcntl"
if msvcrt is not None:
return "msvcrt"
return "unavailable"


def _locking_scope() -> str:
if fcntl is not None:
return "local-posix-advisory"
if msvcrt is not None:
return "local-windows-byte-range"
return "unavailable"


def _require_lock_backend() -> None:
if fcntl is None:
if _lock_backend() == "unavailable":
raise LockUnavailableError(
"no safe local file locking backend is available; refusing to write"
)
Expand All @@ -265,16 +282,55 @@ def _require_lock_backend() -> None:
class _FileLock:
def __init__(self, file_obj):
self.file_obj = file_obj
self._windows_lock_file = None

def _windows_lock_path(self) -> Path:
path = Path(self.file_obj.name)
return path.with_name(f"{path.name}.lock")

def _lock_with_msvcrt(self) -> None:
"""Lock a dedicated byte so ledger data files remain readable on Windows.

``msvcrt.locking`` operates on byte ranges. Locking a sibling file keeps
the data ledger free of mandatory byte-range locks while serializing all
cooperating AetherMind writers for that ledger.
"""
assert msvcrt is not None
lock_file = self._windows_lock_path().open("a+b")
self._windows_lock_file = lock_file
try:
lock_file.seek(0, io.SEEK_END)
if lock_file.tell() == 0:
lock_file.write(b"\0")
lock_file.flush()
os.fsync(lock_file.fileno())
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry beyond msvcrt's fixed lock window

On Windows, msvcrt.LK_LOCK is not an indefinitely blocking lock: it retries ten times at one-second intervals and then raises OSError. If another append holds this sibling lock for longer than roughly ten seconds—for example while LayerStore.append parses and read-verifies a large ledger—a concurrent write fails instead of waiting and serializing as the POSIX backend does. Use an explicit retry loop (typically around LK_NBLCK) or otherwise document and handle a deliberate timeout.

Useful? React with 👍 / 👎.

except BaseException:
lock_file.close()
self._windows_lock_file = None
raise

def __enter__(self):
_require_lock_backend()
assert fcntl is not None
fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_EX)
if fcntl is not None:
fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_EX)
else:
self._lock_with_msvcrt()
return self.file_obj

def __exit__(self, _exc_type, _exc, _tb):
assert fcntl is not None
fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_UN)
if fcntl is not None:
fcntl.flock(self.file_obj.fileno(), fcntl.LOCK_UN)
return
assert msvcrt is not None
assert self._windows_lock_file is not None
try:
self._windows_lock_file.seek(0)
msvcrt.locking(self._windows_lock_file.fileno(), msvcrt.LK_UNLCK, 1)
finally:
self._windows_lock_file.close()
self._windows_lock_file = None

# --- Data Models ---

Expand Down Expand Up @@ -1830,7 +1886,7 @@ def runtime_capabilities(
"implementation_sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(),
"safe_default_create": True,
"lock_backend": _lock_backend(),
"locking_scope": "local-posix-advisory" if fcntl is not None else "unavailable",
"locking_scope": _locking_scope(),
"capabilities": [
"acknowledged-layer-append",
"explicit-store-init",
Expand Down
3 changes: 3 additions & 0 deletions plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: aethermind
version: 0.2.0
description: Project-local continuity layers for Hermes agents.
provides_hooks:
- on_session_start
- pre_llm_call
provides_tools:
- aethermind_init_store
- aethermind_write_layer
Expand Down
11 changes: 10 additions & 1 deletion scripts/ci-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ for forbidden in ("src", "tests", "tools", "plugins", "pyproject.toml"):
raise SystemExit(f"forbidden publish surface still present: {forbidden}")

manifest = (root / "plugin.yaml").read_text(encoding="utf-8")
for expected in ("name: aethermind", "aethermind_write_layer", "aethermind_reorient"):
for expected in (
"name: aethermind",
"aethermind_write_layer",
"aethermind_reorient",
"provides_hooks:",
"on_session_start",
"pre_llm_call",
):
if expected not in manifest:
raise SystemExit(f"plugin.yaml missing {expected!r}")

Expand Down Expand Up @@ -186,6 +193,8 @@ if not (
and imported["ok"]
and imported_report["valid"]
and capabilities["runtime_version"] == "0.2.0"
and capabilities["lock_backend"] in {"fcntl", "msvcrt"}
and capabilities["locking_scope"] in {"local-posix-advisory", "local-windows-byte-range"}
and len(currentness["active_heads"]) == 1
and event["event_id"] == "0001"
and len(events["events"]) == 1
Expand Down
Loading