multica-ai/multica · error
read hermes memory store %s: %w
Error message
read hermes memory store %s: %w
What it means
Returned by migrateHermesTaskMemories when os.ReadDir on the destination store fails with anything other than not-exist. The code must distinguish 'empty store, safe to migrate into' from 'unreadable store'; the comment notes that treating an unread error as empty would let the caller later delete the source memories dir that was never copied — so it fails closed.
Source
Thrown at server/internal/daemon/execenv/hermes_memory.go:217
// error reading either directory, or an entry it will not copy (symlink,
// device node — a link copied verbatim could point the store outside
// itself), is an error, not a silent skip.
// - It publishes with a single atomic rename from a staging directory, so
// two tasks of the same agent migrating at once cannot interleave into one
// half-populated store. First writer wins; the loser discards its staging
// and takes the winner's store, which is the same outcome as finding a
// store that was already populated.
//
// Only an absent or empty store is migrated into, so an upgrade never
// overwrites memory the agent has already accumulated.
func migrateHermesTaskMemories(taskDir, storeDir string, logger *slog.Logger) error {
stored, err := os.ReadDir(storeDir)
switch {
case err == nil && len(stored) > 0:
return nil // store already holds this agent's memory — never overwrite it
case err != nil && !os.IsNotExist(err):
// Treating this as "nothing to migrate" would delete the source below.
return fmt.Errorf("read hermes memory store %s: %w", storeDir, err)
}
entries, err := os.ReadDir(taskDir)
if err != nil {
return fmt.Errorf("read task-local hermes memories %s: %w", taskDir, err)
}
if len(entries) == 0 {
return nil
}
staging, err := newHermesStoreStaging(storeDir)
if err != nil {
return err
}
defer os.RemoveAll(staging) // no-op once the staging dir has been promoted
for _, entry := range entries {
src := filepath.Join(taskDir, entry.Name())View on GitHub (pinned to 2c0912b6ec)
Solutions
- chown/chmod the store dir (needs 0700, daemon-readable) to the daemon user: chmod 700 <profile>/hermes-state/<agent>/<segment>.
- If the store is definitively empty/unwanted, remove it so ReadDir gets ENOENT and migration proceeds cleanly.
- Verify only one daemon user writes to the profile dir.
Defensive patterns
Strategy: validation
Validate before calling
// Before triggering migration, confirm the store is readable:
if ents, err := os.ReadDir(storeDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("store unreadable — fix %s before upgrade: %w", storeDir, err)
} else if err == nil && len(ents) > 0 {
// populated: migration will be skipped, nothing to do
} Try / catch
if err := migrateHermesTaskMemories(src, store, logger); err != nil {
if strings.Contains(err.Error(), "read hermes memory store") {
// fail-closed by design: NEVER swallow and delete the source; fix perms and retry
}
} Prevention
- Never change the OS user that runs the daemon mid-upgrade
- Keep store dirs 0700 daemon-owned
- Do not treat this error as 'nothing to migrate' — the source would be deleted
When it happens
Trigger: os.ReadDir(storeDir) returns EACCES/EPERM (store dir exists with 0700 owned by another user) or EIO; any non-ENOENT errno on the store directory read.
Common situations: Daemon runs under a different user than the one that created the store during an earlier upgrade; a partially-restored backup where the store dir exists but has broken mode bits.
Related errors
- remove stale memories path %s: %w
- read task-local hermes memories %s: %w
- create hermes memory store parent %s: %w
- mint PAT: response missing token
- daemon profile is not resolved yet; token sync skipped
AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15).
Data as JSON: /api/errors/fa1b061a357bb432.
Report an issue: GitHub.