Repository navigation
fix(store): the local object store writes atomically and keeps content types - #9
Conversation
…t types A write now lands whole or not at all. Bytes are staged in a reserved .agentenv-tmp directory inside the root and put in place with a rename (an overwrite) or a hard link (a write-once put), so a crash or a concurrent reader never sees a partial object, and a write-once put that loses a race raises ObjectAlreadyExistsError instead of replacing the winner. Filesystems without hard links fall back to an exclusive create. A write's content type is recorded in a reserved .agentenv-meta tree and returned by get_object_metadata. The default type, which a writer that names none gets, reads back as unknown, so readers keep guessing from the name as before. Both directories are refused as keys and left out of listings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…a write-once put never exposes an empty object A content type is recorded with the identity of the file it was written for (inode, size, mtime, which a rename keeps), so a type recorded for another write - a racing overwrite that renamed first, or an old record an overwrite could not remove - reads back as unknown, never as the wrong type. On a filesystem without hard links a write-once put now checks for the key and renames into place, instead of reserving the key with an empty file that readers could see and a crash could leave. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hard links Where no hard link can claim the key, a write-once put now claims it with a marker created exclusively in the staging directory, held only from the existence check to the rename. A racing writer waits for the marker to go and then finds the winner's object; a marker a crash left behind is taken over once it is ten seconds old. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a copy that keeps modification times Without hard links, a write-once put's claim marker now names its owner's pid and is taken over only once that process has exited, not after ten seconds, so a slow live writer never loses it. On Windows, where rename refuses an existing target, the rename itself is the claim. A recorded content type is now paired with the file's size and modification time, and each write stamps its staged file with a modification time no other write shares (now, to the millisecond, plus a random remainder). Racing writes still never pair bytes with another write's type, and a copy of the store that keeps modification times keeps its types, where the inode it no longer paired with would not survive one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… its holder A write-once put on a filesystem without hard links now claims its key with an exclusive flock on a file in the staging directory rather than a marker naming a pid. The kernel drops the lock when its holder exits, so a crashed writer never blocks the key and a reused pid can never pass for a live one; a live holder is waited for however long it takes. The file stays in place so every writer locks the same one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…k, and staged files of dead writes are swept Chaos testing on exFAT, which keeps modification times to 10 ms, showed two same-size overwrites of one key could leave one's type recorded against the other's bytes (5 in 4,129 races): the per-write time stamp that tells writes apart is rounded away there. A write now holds the key's lock (an exclusive flock) across placing its bytes and recording its type, and a metadata read holds it shared, so no write lands between another's two steps and no reader sees one write's type with another's bytes, whatever the filesystem's time resolution. An overwrite drops the old type first, so one cut short leaves the type unknown, never wrong. Keys share 64 lock files, which also replace the per-key claim files the no-hard-link path used; Windows, which has no flock, keeps relying on the stamp, which NTFS's 100 ns times preserve. A store's first write also removes files staged by writes that died (untouched for an hour), which crashes left behind in .agentenv-tmp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es any file a live write holds An overwrite now sets the old type record aside while its bytes land and puts it back if they do not, so a rename that fails keeps the old object's type; one cut short after its bytes land still reads as unknown, never as the old type. Each staged file is held under an exclusive flock by its writer for as long as the write lives, and the sweep removes only staged files no process holds (and none younger than a minute, which may not be held yet), so a write paused for any length of time never loses its file. Windows, which has no flock, is not swept. Keys now share 256 lock files rather than 64, so fewer unrelated keys wait on one another, and the tests that hold a lock with flock skip on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…a staged file that cannot be locked is cleaned up Removing the set-aside type record after an overwrite's bytes land, and refreshing its time when it is set aside, are now best effort: a failure there leaves a file the sweep removes, rather than reporting a completed write as failed. A staged file whose lock cannot be taken is closed and removed like any other. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| with contextlib.suppress(OSError): # the bytes have landed; a sweep takes what is left | ||
| aside.unlink(missing_ok=True) |
There was a problem hiding this comment.
If removing a saved type record fails after an overwrite, the write succeeds but leaves that record in .agentenv-tmp. This store sweeps only on its first write, so a long-lived store will not remove it. Windows does not sweep at all. Repeated cleanup failures can leave old records behind and use disk space.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/store/object_store/local_object_store.py
Line: 211-212
Comment:
**Old type records linger**
If removing a saved type record fails after an overwrite, the write succeeds but leaves that record in `.agentenv-tmp`. This store sweeps only on its first write, so a long-lived store will not remove it. Windows does not sweep at all. Repeated cleanup failures can leave old records behind and use disk space.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Leaving this as is. A record is left behind only when deleting a file the store itself just renamed into its own staging directory fails. That means the filesystem is refusing deletes in a directory the store otherwise writes freely. The cost is one small hidden file per failure, and it never makes a type wrong. The next store instance on a POSIX filesystem sweeps it. On Windows it stays until removed by hand. Sweeping on every write, or retrying in the background, would add cost to the common path for a fault that already breaks writes elsewhere. On the summary line: an overwrite whose rename fails puts the old type back. The type is lost (read as unknown, never wrong) only if the set-aside record also vanished in the meantime, which requires another process's sweep while this commit was paused for over a minute.
Summary
This is the first of three PRs that let agents running in local sandboxes exchange files with the local object store over short-lived HTTPS grants. Those grants are what the S3 store already issues. This PR makes the local store's writes safe to serve over HTTP. It doesn't serve anything yet.
Atomic writes.
putandput_filewrite into a reserved.agentenv-tmpdirectory inside the root, then move the file into place:ObjectAlreadyExistsErrorand leaves the winner in place;os.renameis the claim.Readers never see a partial object, and a crash mid-write leaves nothing at the key.
Each key has a lock. It is an exclusive
flockfor a write, held across placing the bytes and recording the type, and a shared one for a metadata read. So no write lands between another's two steps, and no reader sees one write's type with another's bytes, whatever the filesystem's timestamp resolution.flockand relies on the per-write timestamp below, which NTFS's 100 ns times preserve.Content types are kept. A write's content type is recorded under a reserved
.agentenv-metatree and returned byget_object_metadata, as S3 returns it. The record carries the identity of the file it describes: its size, and a modification time each write makes unique (now, to the millisecond, plus a random remainder), which a rename keeps and so does a copy that keeps modification times. So a type recorded for another write reads back as unknown rather than wrong. An overwrite also drops the old type before its bytes land, so one cut short leaves the type unknown, never the previous one. The defaultapplication/octet-streamis not recorded, so a write that names no type still reads back as unknown. That keepsFileArtifact.put_existingand other readers guessing the type from the file name, as they do today. Objects written before this change read back as unknown too.Leftover staged files are swept. A store's first write removes staged files that a dead write left behind, meaning ones untouched for an hour.
Reserved names.
.agentenv-tmpand.agentenv-metaare refused as keys, like.gitignore, and left out oflist/list_at.Test plan
pytest tst/unit packages/agentenv-protocol/tests -n auto: 5635 passed. The new tests cover:pytest -m 'not int_test_slow' tst/integration --ignore=tst/integration/env/gateway/gateway_test.py: 141 passed, run with the branch's venv first onPATHfor the CLI tests.ENOSPC, leaving no partial object.🤖 Generated with Claude Code
The PR does not yet appear safe to merge because a failed overwrite can still lose the saved content type of the unchanged object.
Fix with agent prompt
Summary
The local object store now stages writes before placing complete objects at their keys and saves non-default content types with the files they describe. It also keeps its staging and metadata paths out of object keys and listings.
Reviews (8) · Last reviewed commit: "fix(store): an overwrite that lands is n..."