Skip to content

v0.5.0-ev.1

Pre-release
Pre-release

Choose a tag to compare

@gitgat gitgat released this 30 Oct 04:59

Known issue: on a host with many sync repos (seen with 36; the threshold depends on the host), the per-prompt changes hook (claude-memsync changes --hook, which the memory-notices plugin runs) can miss its 1.5 s deadline on every call. It records its progress only when it finishes, so it never catches up: each prompt waits about 1.5 s and no change notice is shown. Syncing is not affected. On such hosts, keep the memory-notices plugin disabled until the fix in the next release. To check a host, time one call: time (echo '{"session_id":"check","cwd":"/","hook_event_name":"UserPromptSubmit"}' | claude-memsync changes --hook). If it takes about 1.5 s and prints "hook deadline exceeded" to stderr, the host is affected. A call that returns at once may have found another hook running, so run it twice.

Upgrading from v0.4.0-ev.1:

  • Routing now fails closed. A project folder whose name passes through a directory with more than 256 entries (on macOS, the per-user temp dir) is synced only when what lies below that directory is a single name without -. Anything else is left unresolved rather than guessed, so some folders under very large directories now show as "ambiguous, not synced" in claude-memsync status instead of syncing. To sync such a folder, pin it with a folder rule placed before the path and remote rules in routes.json, then restart the daemon.
  • A privateOnly repo needs a token the daemon can use. With no token source at all, or recheckTokenCmd: false with only a command, the repo is held at daemon start. A configured token source that gives no token (a failing command, an empty token file, an unset variable) now also holds the repo; in v0.4.0-ev.1 such a repo started anyway. Hosts bootstrapped without --check-app and without a token file therefore hold every privateOnly repo until a check App or token file is configured (for a token file: create it, then re-run bootstrap, which writes githubTokenFile into each config.json only if the file exists) and the service is restarted (claude-memsync stop, then claude-memsync start). claude-memsync status shows the hold. A hold outlives switching privateOnly off; status then gives the manual release step (stop the service, delete the repo's .state/loop-failed, start it).
  • A folder that routes to deny now has none of its memory files mirrored, and related suggest never lists or opens its transcripts.
  • Each release now also carries four budget archives, claude-utils-<tag>-budget-<os>-<arch>.tar.gz (linux and darwin, amd64 and arm64), covered by SHA256SUMS. Unpack the matching product archive, then run the test binaries in the budget archive with MEMSYNC_BUDGET_BIN_DIR=<product dir> MEMSYNC_BUDGET_TAG=<tag> ./claude-memsync-budget.test -test.run '^TestBudgetB' -test.v -test.timeout 900s (and the same for ./route-budget.test). No Go or checkout is needed. The archive's README and docs/releases.md have the details.

Changelog:

  • A denied folder is fully contained: a folder that routes to deny has none of its memory files mirrored, and related suggest (and the daemon's suggestion refresh) never lists or opens its transcripts and counts it toward no signal; tests pin both, including a deny pin listed ahead of a broader rule.
  • Auto maintenance no longer escapes a deadline: after a commit, fetch, merge or rebase git may start maintenance run --auto --detach (or a detached gc --auto), which leaves the process group via setsid and survives proc's group kill and related fetch's 60 s timeout. Every git that can start it (the sync loops' and CLI's gitwt calls, and related fetch) now carries -c gc.auto=0 -c maintenance.auto=false, and a push to a remote that is a local path or file:// URL also passes --receive-pack with receive.autogc=false (a local transport drops -c and GIT_CONFIG_* before receive-pack; a push to an ssh or https remote runs receive-pack on the server and is not ours to configure). To keep sync dirs from growing without bound, each daemon loop runs git gc --auto itself at most once per 24 hours per sync dir (recorded in .state/maintained, so a restart does not repeat it; foreground, gc.autoDetach=false; one at a time across all loops; stopped with the daemon's context and killed with its process group at the daemon's stop or after 10 minutes; skipped while the loop is held; the changes hook never runs it). Read clones are not housekept. Upstream candidate.
  • The interrupt guard's stop() waits for its goroutine: guardChildren's stop func now joins the goroutine (idempotent; it does not wait for one already exiting the process or swallowing a signal the sync flow owns), so nothing of the guard reads package state after it returns. Fixes a data race seen in TestInterruptDuringSyncFlow_SecondSignalKillsGitAndExits130 under -race; harmless in production. Upstream candidate.
  • privateOnly needs a token the daemon can use: with no token source at all (or recheckTokenCmd: false with only a command) a privateOnly repo is now held at daemon start instead of starting on the init-time check. Release note: hosts bootstrapped without --check-app and without a token file now hold every privateOnly repo until a check App or token file is configured (for a token file: create it, then re-run bootstrap, which writes githubTokenFile into each config.json only if the file exists) and the service restarted (claude-memsync stop, then claude-memsync start (they run launchctl on macOS and systemctl --user on Linux)); claude-memsync status shows it. A hold also outlives switching privateOnly off: status says held, released by hand only: … held from an earlier run and gives the manual release step (stop the service, delete the repo's .state/loop-failed, start it). Fork-only.
  • Private-only check holds a repo, not the daemon: a repo whose private-only check fails at daemon start (not private, or the check cannot run, for example a GitHub App with no installation) is held: its loop does not start, status shows waiting for the private-only check, re-checking with backoff, and the check is retried from 10 s, doubling to every 30 min; the loop starts, with its push guard, only after a passing check. A configured token source that gives no token (failing command, empty file) now fails the check instead of reading as "no token configured", and a push guard built then blocks pushes until the source works and the API says private; a held repo is released only by an API answer, never by a skipped check. Other repos sync meanwhile, and the daemon no longer exits, so a service manager no longer restarts it every few seconds. Upstream candidate.
  • Agents hear about a held repo: changes --hook leads its output with one line per repo the private-only check holds (memsync: repo <name> is HELD by the private-only check since <time>: <reason>. …), once per session and hold, from local state files only, and also when the hook's deadline defers the change notices; text from state files is cut to one line with control and invisible format characters removed, the hold record and the per-session marker are read bounded and as regular files only (a FIFO or symlink is skipped without waiting), and the marker is written without following links. Upstream candidate.
  • A performance budget for the hook and the daemon, enforced by tests: go test -tags budget measures the changes hook (p95 under 200 ms over 20 runs), the daemon's start-up pass (under 10 s), idle CPU (under 1% of a core) and resident memory (under 64 MB) on a synthetic tree with a 10k-entry temp dir and 1,000 project folders, plus counted gates that load cannot hide: directory reads and bytes stay flat as unrelated directories grow, at most one of 20 overlapping hooks does the work, a hung git never outlives the hook's deadline, a hook with nothing new starts no git process, and start-up spawns at most one git per existing project directory. Timing gates print the load average and report INVALID above 8 instead of passing or failing. On an Apple-silicon Mac the start-up pass went from 18.7 s to 2.7 s and the hook p95 from 474 ms to 74 ms. Upstream candidate.
  • Resolution reads .git directly, and asks git on any doubt: whether a directory is inside a work tree, its common dir and its HEAD come from the files (a .git directory or a gitdir: file with commondir, loose and packed refs), with git as the fallback for anything unusual (symlinked paths, submodules, bare or core.worktree repos, includes, reftable, unreadable or corrupt files, another owner). A directory is only ever called "not a repo" after git confirms it, once per routing generation; a missing directory costs nothing. Start-up resolves folders side by side, at most 4 git children at a time, each with a 10 s timeout, and the daemon's loops start after at most 5 s while slow folders finish in the background. Upstream candidate.
  • Routing fails closed under a directory too large to list: a project folder whose name passes through a directory with more than 256 entries (on macOS, the per-user temp dir) resolves only when the part below it is a single name without -; anything else is left unresolved, never guessed, because - may stand for /, ., _, a space or a character the folder name cannot spell, and a wrong guess would sync one project's notes into another project's repo. Such a folder is logged once and listed by claude-memsync status as "ambiguous, not synced" when it looks live (modified within 14 days). A path that resolves in a huge directory keeps its on-disk spelling on case-folding hosts. Behaviour change. Upstream candidate.
  • The changes hook runs one at a time and never leaves git behind: overlapping hooks skip at once when another holds the hook lock in the first repo's state dir, and at the hook's deadline every git it started (including related-repo calls) is killed with its process group; expiries are logged at most once a minute with a count. Interactive commands kill their git children on Ctrl-C or SIGTERM (a command that is stopping the service keeps its "first signal lets the change finish" behaviour). Upstream candidate.
  • The budget gates run against the shipped binaries: each release now also carries claude-utils-<tag>-budget-<os>-<arch>.tar.gz (linux and darwin) with the gates as test binaries, covered by SHA256SUMS. With MEMSYNC_BUDGET_BIN_DIR and MEMSYNC_BUDGET_TAG set they test the release's own claude-memsync and claude-memmerge (version checked, HOME isolated) with no Go and no checkout. Every archive's contents, including the printable strings of every binary, are now scanned with gitleaks before SHA256SUMS is written, after a control that proves the scan can see inside binaries. See docs/releases.md. Fork-only.
  • Routing no longer re-reads a huge directory for every stale folder: finding the directory a project folder name stands for read every directory along its path, so on a Mac with hundreds of folders for deleted $TMPDIR checkouts the daemon's startup reconcile re-read the same very large temp directory for each one (about 10 s apiece, in parallel across the loops) and never finished. The router now reads each directory once per routing generation (until routes.json reloads or the pull tick), re-reading one only when its modification time or size changes, shares the read between concurrent callers, and walks once per folder instead of twice. May also help the changes hook overrunning its deadline (#12). Upstream candidate.

Verify every archive against SHA256SUMS before extracting; see docs/releases.md.