A Hermes cron write waits on a memory lock the operator note says is unused

John Burns

The operator note and the constructor do not describe the same cron session. In this install, cron/AGENTS.md says cron sessions pass skip_memory=True, and that memory providers intentionally do not run during cron. The function that builds the agent, _construct_cron_agent in cron/scheduler.py, passes skip_memory=False. A comment in agent/agent_init.py agrees with the constructor, not with the note: cron agents now take the normal memory path.

That disagreement decides whether a scheduled agent job can sit forever inside a file lock. The note is what a reader of the install tree is likely to trust. The call is what the process runs. On this binary they are not the same sentence.

The install this measurement used

hermes --version reported Hermes Agent v0.22.5+471.g5894e75, dated 2026.9.24, commit 5894e750c3db, and Python 3.14.9. The lock probe did not use that 3.14.9 interpreter. It imported MemoryStore from the same install tree with the tree’s virtualenv interpreter, which is Python 3.11.15, on Linux x86_64. The source lines below were read from that tree. No cron job was created, paused, or fired. The live memory files were not opened.

The probe pointed HERMES_HOME at a new directory created for the run. The safety check refused the live home and the live memories directory. The only memory file written was the disposable one. After the run it held four synthetic probe lines and nothing else.

hermes cron create --help on this binary lists schedule, prompt, name, delivery, repeat, skills, script, --no-agent, monitors, workdir, model, pin, provider, reasoning effort, interpreter, continuity, and paused. It has no flag that sets skip_memory. The cron defaults block in hermes_cli/config_defaults.py, from catch_up_missed through failure_repeat_alert_hours, includes a 10-second session-database timeout, a script timeout, and a media-send timeout. It has no memory key.

The installed config enables the built-in memory store and the user-profile store. It sets memory.provider to a name outside the built-in sentinel set ("", default, builtin, built-in, none). It does not set platform_toolsets.cron, and it does not set agent.disabled_toolsets. Those are config facts. The probe did not load that config file into MemoryStore.

What skip_memory false loads

skip_memory is not a switch for the memory tool alone. _init_memory treats it as a switch for the external provider. The comment above the call says a true value skips the external provider, while an enabled memory toolset still gets the built-in store so the tool never sees store=None. Cron now passes false, so it takes the branch the comment calls the normal path.

On that path, two things happen when the stores are enabled. The built-in MemoryStore is constructed and load_from_disk() runs, so MEMORY.md and USER.md are read into the session. Separately, because skip_memory is false, a memory.provider value that is not a built-in sentinel is handed to the provider loader. A fresh config whose provider is unset, or set to builtin or none, does not take that plugin branch. This install’s provider name does. The probe did not initialize that provider. The branch is what the source does when the constructor argument is false.

The memory tool is also on the default cron toolset. hermes-cron is the platform default, and that bundle is the shared core list, which includes the memory tool. An empty config passed to _get_platform_tools({}, "cron") returned 18 names, and memory was one of them. An explicit list of terminal and file returned only those two names. The installed config has no platform_toolsets.cron entry, so a job that does not set its own enabled_toolsets resolves through that default and gets the tool.

Omitting memory from an explicit cron toolset list removes the tool from that resolution. It does not change the constructor. skip_memory=False is hardcoded in _construct_cron_agent. A narrower tool list can stop the model from calling memory. It does not stop the normal-path load, and it does not stop the external-provider branch. There is no create flag and no cron-defaults key that flips the argument.

The lock a write waits on

Writes go through _mutate. That method takes _file_lock on the memory path before it re-reads the file, applies the change, or returns an error dict. The lock is a separate MEMORY.md.lock file, not the memory file itself, so the memory file can still be replaced atomically. On this tree the POSIX branch calls fcntl.flock with LOCK_EX and without LOCK_NB. There is no deadline around that call. The function returns a success or error dict only after the lock is held and the mutation has finished, or after a later check fails. A caller blocked in flock has not reached those dicts.

The same file’s non-fcntl branch calls msvcrt.locking once. That path was not run. Other lock sites in this tree already use LOCK_EX | LOCK_NB, including the MCP tool loop, MCP OAuth, the bot relay, and the wake-word holder. The memory store is the site that waits.

load_from_disk does not take that lock. A session that only reads memory can finish while another process holds MEMORY.md.lock. The hang is a write: add, and the replace and remove paths that also call _mutate. A cron agent that never calls the memory tool does not enter _file_lock just by starting. A cron agent that does call it, and finds the lock held, does not get a tool error back. The call has not returned.

The parallel cron pool is a ThreadPoolExecutor in the scheduler process. flock conflicts between separate open calls, including two threads in one process. A second cron thread does not need a second OS process to join the wait. The probe checked that with two threads. It did not submit two jobs to the live scheduler.

Verification on a disposable home

The disposable home started empty. An uncontended MemoryStore.add of a synthetic line returned in 0.0162 seconds with success true. The keys on that result were done, entry_count, message, note, success, target, and usage.

A second thread then held MEMORY.md.lock with LOCK_EX. While that holder waited, load_from_disk returned in 0.0006 seconds and saw the one entry already written. The load did not join the write queue.

A second add, started while the holder still had the lock, was still running at 3.0 seconds. It had produced no result and no exception. The holder then released. A new add after that release returned in 0.0333 seconds, again with success true. The line from the call that had been blocked at 3.0 seconds was on disk when the process exited, so that call completed only after the release. It did not fail, and it did not time out on its own.

A second same-process open of the same lock file blocked another add the same way. At 2.0 seconds that call still had no result and no exception. The probe stopped the wait by releasing the holder. Nothing in the store printed a lock-busy error, because the code path that would build an error dict is after the flock.

Those timings are one run on this tree, with the limits passed explicitly as 2200 and 1375 characters. They are not a benchmark of a live gateway, and they are not a measurement of two scheduled jobs. They are the write path the cron constructor leaves enabled, exercised against a home that was not the live one.

Do not repeat the contended case against the live MEMORY.md.lock. A holder there blocks every later memory write in every agent that shares that file, including a cron thread, until the holder exits. The disposable directory is the whole point of the check.

What a script job skips

run_job returns the no-agent result before it imports the agent runner or opens the session database, when the job has no_agent set. That short-circuit is in the scheduler, above _construct_cron_agent. A job created with hermes cron create --no-agent --script ... never builds the AIAgent that passes skip_memory=False. It also never reaches _file_lock through the memory tool, because there is no agent tool call.

The help text describes that mode as skipping the LLM and delivering the script’s stdout. The --no-agent wording even uses memory alerts as an example of a script job. That example is a script printing a number. It is not the built-in memory store. A script job can mention memory in its output and still never open MEMORY.md.lock. An agent job cannot make the same claim, because the constructor no longer skips the store.

--no-agent is the supported way, on this binary, to keep a scheduled task off the memory write path. It is a different kind of job. If the task needs the model, the flag does not apply, and the help offers no substitute flag that leaves the model in place while turning memory off.

An explicit platform_toolsets.cron list that omits memory is a narrower control, and it was the control the empty-config resolver honored: terminal and file stayed, memory did not. Use that when the goal is to stop the model from seeing the tool. Do not treat it as skip_memory=True. The constructor argument is still false, the built-in load still runs when the stores are enabled, and a non-sentinel memory.provider still takes the plugin branch. A per-job enabled_toolsets list has the same limit. An empty per-job list is a zero-toolset allowlist, which the resolver preserves rather than falling back to the platform default. That empty list removes the tool. It still does not edit the constructor argument.

What to read before the next agent job

Read the operator note and the constructor before trusting either. On this install the note is the bullet in cron/AGENTS.md that names skip_memory=True. The constructor is the skip_memory=False keyword in _construct_cron_agent. If a later package makes them agree, the disagreement this article measured is gone. Until that diff exists, the call is the one that runs.

Then decide which scheduled work is an agent. A script job with --no-agent does not build the agent and does not take this lock through the memory tool. An agent job does build it, and on the default toolset the model can call memory. The installed config does not override platform_toolsets.cron, so that default is the resolution an unpinned job gets here.

If you need to see the wait yourself, point HERMES_HOME at a new directory, hold memories/MEMORY.md.lock with an exclusive flock from a second thread or process, and time MemoryStore.add. A successful check is the one this run recorded: the load returns immediately, and the add is still blocked at a few seconds with no exception. Release the holder and confirm the add then returns success true. If the add returns a lock-busy error instead, the tree you are on is not the tree measured here.

A public report against this project describes the same mismatch: the operator note still says skip_memory=True, and the scheduler passes false, with a blocking LOCK_EX and no deadline. The line numbers in that report match the lines read here. The timings above are from this install’s tree and a disposable home, not from that report’s reproduction.

The session-database open in cron has a 10-second default timeout. The memory write lock does not. A job that looks stuck after the session database has already opened can be waiting in flock on MEMORY.md.lock, with no tool error and no log line from the lock itself. Find the holder. Do not start another writer against the same live file to “test” it.

Sources

  • Install tree at commit 5894e750c3db: cron/AGENTS.md (the skip_memory=True bullet), cron/scheduler.py (_construct_cron_agent, the no-agent short-circuit, and the parallel ThreadPoolExecutor), agent/agent_init.py (_init_memory), tools/memory_tool_store.py (_file_lock, _mutate), toolsets.py (hermes-cron), and hermes_cli/config_defaults.py (the cron defaults block).
  • hermes --version and hermes cron create --help on that install, 31 October 2026.
  • Disposable-home probe of MemoryStore.add and load_from_disk under Python 3.11.15, same date. The live memory directory was not used.
  • Nous Research, Hermes Agent issue 146076, “Memory store takes a blocking fcntl.flock(LOCK_EX) with no timeout”: https://github.com/NousResearch/hermes-agent/issues/146076
  • Linux flock(2): https://man7.org/linux/man-pages/man2/flock.2.html