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

  1. chown/chmod the store dir (needs 0700, daemon-readable) to the daemon user: chmod 700 <profile>/hermes-state/<agent>/<segment>.
  2. If the store is definitively empty/unwanted, remove it so ReadDir gets ENOENT and migration proceeds cleanly.
  3. 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

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


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/fa1b061a357bb432. Report an issue: GitHub.