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
47 changes: 38 additions & 9 deletions stacklets/memory/bot/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@

from loguru import logger # noqa: E402
from memory.lib import ( # noqa: E402
MIRROR_SHA_NAME,
MIRROR_TRIGGER_NAME,
PRESERVE_LOCAL,
RESET_LOCAL,
SyncResult,
Expand Down Expand Up @@ -103,10 +105,11 @@
# removal operations.
_GENERATED_NAMES = {"about.md", "index.md"}

# Trigger file `stack memory sync` drops into the state dir to request
# an immediate tick. The curator stays brain's only writer; the CLI
# only asks and waits (one-writer invariant, ADR-011).
TRIGGER_NAME = "mirror-now"
# Both ends of the trigger protocol live in `memory.lib`: every writer
# (the write seam on its way out, `stack memory sync` by hand) asks
# there, and this loop is what answers. The curator stays brain's only
# writer; callers only ask and wait (one-writer invariant, ADR-011).
TRIGGER_NAME = MIRROR_TRIGGER_NAME

# The curator's own git remote. `origin` belongs to the host plane
# (hooks and the host CLI reach Forgejo on a localhost/LAN port); this
Expand All @@ -132,24 +135,42 @@ def only_own_commits(subjects: list[str]) -> bool:

class Debounce:
"""Quiet-window tracker: `observe(head, now)` returns True once the
same head has been seen unchanged for `quiet_secs`."""
same head has been seen unchanged for the current window.

The window is `quiet_secs` while rebuilds are succeeding, and doubles
with each consecutive failure up to `max_backoff_secs`. That second
part exists because the usual reason a rebuild fails is the AI
endpoint being down, and being down is a condition that outlives one
quiet window: retrying on the flat window spends a subprocess and an
LLM call every three minutes, all day, and prints a line each time
that is indistinguishable from a healthy heartbeat.
"""

def __init__(self, quiet_secs: float):
def __init__(self, quiet_secs: float, max_backoff_secs: float = 3600.0):
self.quiet_secs = quiet_secs
self.max_backoff_secs = max_backoff_secs
self.failures = 0
self._head: str | None = None
self._since = 0.0

def window(self) -> float:
"""The gap currently being waited out. Reported in the log."""
return min(self.quiet_secs * (2 ** self.failures), self.max_backoff_secs)

def observe(self, head: str, now: float) -> bool:
if head != self._head:
self._head, self._since = head, now
return False
return (now - self._since) >= self.quiet_secs
return (now - self._since) >= self.window()

def reset(self) -> None:
"""Nothing pending, or a rebuild worked: back to the flat window."""
self._head = None
self.failures = 0

def retry_later(self, now: float) -> None:
"""Failed rebuild: keep the head, restart the quiet window."""
"""Failed rebuild: keep the head, restart the window, wider."""
self.failures += 1
self._since = now


Expand Down Expand Up @@ -790,7 +811,7 @@ async def main() -> None:
shared_bucket = os.environ.get("SHARED_BUCKET", "family")
state_dir = Path(os.environ.get("CURATOR_STATE_DIR", "/data/memory/curator"))
sha_file = state_dir / "last-rebuilt-sha"
mirror_file = state_dir / "last-mirrored-sha"
mirror_file = state_dir / MIRROR_SHA_NAME
nightly_file = state_dir / "last-nightly-date"

while not (vault_dir / ".git").exists():
Expand Down Expand Up @@ -977,6 +998,14 @@ async def mirror_reconcile() -> bool:
debounce.reset()
else:
debounce.retry_later(time.monotonic())
# Say the shape of the failure, not just that there was one. A
# per-cycle line at the same interval reads as a heartbeat; a
# widening gap with a count on it reads as an outage.
logger.warning(
"[curator] rebuild failed {}x in a row; next attempt in {}m "
"(source is still mirrored, only generated pages are stale)",
debounce.failures, round(debounce.window() / 60),
)


if __name__ == "__main__":
Expand Down
138 changes: 38 additions & 100 deletions stacklets/memory/cli/sync.py
Original file line number Diff line number Diff line change
@@ -1,121 +1,57 @@
"""stack memory sync - mirror memory source into the brain projection now.

Every write already asks for this on its own way out (`propagate_write`,
called from the write seam) and waits about five seconds. This command is
the same two steps with an operator's patience instead of an agent's: it
waits long enough to sit through a nightly sweep, and it says out loud
how far the projection got.

The curator is the ONLY writer of the brain projection (one-writer
invariant, ADR-011). This command therefore mirrors nothing itself: it
drops a `mirror-now` trigger file the curator's tick loop watches,
then waits until the curator records the current memory HEAD as
mirrored. The explicit fast path for tests and operators who need
read-your-writes in the rendered wiki without waiting for the next
curator tick - without becoming a second git writer on brain.

Failure shape: if the curator is down (memory stacklet stopped) or
busy past the wait cap (a nightly LLM sweep can hold a cycle for
minutes), this times out with the lag printed and a nonzero exit.
The trigger file survives either way; the curator consumes it on its
next free tick, so the request is never lost.
drops a `mirror-now` trigger file the curator's tick loop watches, then
waits until the curator records the current memory HEAD as mirrored.

Failure shape: if the curator is down (memory stacklet stopped) or busy
past the wait cap (a nightly LLM sweep can hold a cycle for minutes),
this times out with the lag printed and a nonzero exit. The trigger file
survives either way; the curator consumes it on its next free tick, so
the request is never lost.
"""

from __future__ import annotations

import subprocess
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lib import vault_path_for # noqa: E402
from lib import ( # noqa: E402
curator_state_dir_for,
request_mirror,
vault_local_head,
vault_path_for,
vault_remote_head,
wait_for_mirror,
)

HELP = "Mirror memory source into the brain projection now"

TRIGGER_NAME = "mirror-now"
MIRROR_SHA_NAME = "last-mirrored-sha"

# Wait cap per the test-loop rule: polls are cheap, caps are hard.
# Wait cap per the test-loop rule: polls are cheap, caps are hard. Far
# longer than a write's own wait, because a person at a terminal asked
# for this by hand and would rather wait than re-run it.
WAIT_SECS = 40.0
POLL_INTERVAL = 0.75


def request_mirror(state_dir: Path) -> Path:
"""Drop the trigger file the curator's tick loop watches.
def target_head(memory: Path) -> str:
"""The commit the mirror has to reach.

Content is a timestamp purely for debuggability; the curator only
cares that the file exists and consumes it by deletion.
Prefers the remote's HEAD: a hand edit pushed from Obsidian is
exactly the change an operator runs this to bring through, and it is
not in the local clone yet. Falls back to the local HEAD when Forgejo
is unreachable - the curator pulls before mirroring, so a local-only
target is still a valid best-effort floor.
"""
state_dir.mkdir(parents=True, exist_ok=True)
trigger = state_dir / TRIGGER_NAME
trigger.write_text(str(time.time()), encoding="utf-8")
return trigger


def remote_head(memory: Path) -> str:
"""Memory's remote HEAD - the commit the mirror must reach.

The origin URL of the working copy already embeds credentials (set
at clone time), so `ls-remote` needs no token plumbing here. Falls
back to the local HEAD when Forgejo is unreachable: the curator
pulls before mirroring, so a local-only target is still a valid
best-effort floor.
"""
out = _git(memory, "ls-remote", "origin", "HEAD")
if out and out.split():
return out.split()[0]
return (_git(memory, "rev-parse", "HEAD") or "").strip()


def mirrored_contains(memory: Path, target: str, mirrored: str) -> bool:
"""True when the curator's recorded mirror sha includes `target`.

Checked against the memory clone's history: the curator pulls that
same working copy before mirroring, so once `target` is mirrored
both commits exist there. A sha git does not know yet is simply
"not yet" - the caller keeps polling.
"""
if not mirrored:
return False
if mirrored == target:
return True
rc = subprocess.run(
["git", "-C", str(memory), "merge-base", "--is-ancestor", target, mirrored],
capture_output=True,
).returncode
return rc == 0


def wait_for_mirror(
state_dir: Path,
memory: Path,
target: str,
*,
timeout: float = WAIT_SECS,
interval: float = POLL_INTERVAL,
) -> str | None:
"""Poll `last-mirrored-sha` until it contains `target`.

Returns the mirrored sha on success, None on timeout. Checks the
condition once before looking at the clock, so an already-current
mirror succeeds even with a zero timeout.
"""
sha_file = state_dir / MIRROR_SHA_NAME
deadline = time.monotonic() + timeout
while True:
mirrored = ""
if sha_file.exists():
mirrored = sha_file.read_text(encoding="utf-8").strip()
if mirrored_contains(memory, target, mirrored):
return mirrored
if time.monotonic() >= deadline:
return None
time.sleep(interval)


def _git(repo: Path, *args: str) -> str | None:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True, text=True,
)
if result.returncode != 0:
return None
return result.stdout
return vault_remote_head(memory) or vault_local_head(memory) or ""


def run(args, stacklet, config):
Expand All @@ -124,17 +60,19 @@ def run(args, stacklet, config):
return {"error": "stack data_dir not configured"}

memory = vault_path_for(Path(data_dir))
state_dir = Path(data_dir) / "memory" / "curator"
state_dir = curator_state_dir_for(Path(data_dir))

if not (memory / ".git").exists():
return {"error": f"memory vault not cloned at {memory}"}

target = remote_head(memory)
target = target_head(memory)
if not target:
return {"error": "cannot resolve memory HEAD"}

request_mirror(state_dir)
mirrored = wait_for_mirror(state_dir, memory, target)
mirrored = wait_for_mirror(
state_dir, memory, target, timeout=WAIT_SECS, interval=POLL_INTERVAL,
)
if mirrored is None:
return {"error": (
f"curator did not mirror {target[:10]} within {int(WAIT_SECS)}s "
Expand Down
10 changes: 8 additions & 2 deletions stacklets/memory/cli/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,16 @@ def _replace(prior: str) -> str:
print(f"{repo_path} was already exactly this; nothing to commit")
return {"ok": True, "committed": False, "path": repo_path}

print(f"Wrote {repo_path} (by {actor})\n {told}")
# Forgejo has the commit; the agent's mount and the wiki are fed by the
# curator and may be a beat behind. Say that as a delay, never as a
# doubt: a model that reads anything short of "done" writes the page
# again, and the second write is a duplicate nobody asked for.
mirrored = bool(result.get("mirrored"))
lag = "" if mirrored else "\n The wiki and the vault mount catch up shortly."
print(f"Wrote {repo_path} (by {actor})\n {told}{lag}")
return {
"ok": True, "committed": True, "path": repo_path, "by": actor,
"summary": told,
"summary": told, "mirrored": mirrored,
"destructive": bool(change and change.destructive()),
"removed": list(change.removed) if change else [],
}
Expand Down
Loading
Loading