Repository navigation
fix(execution): follow-ups from the review of #309, relative artifact paths, chat attachment lease - #313
Conversation
… report a failed mkdir or open as a repository error The job id names the job's artifact directory but was held only to the artifact name shape. "nul", "con", "aux" and the other device names, with or without an extension, are accepted by create_job and then fail at the first publish with a raw OSError that carries the absolute path; "a." shares a directory with "a" because Windows drops trailing dots. Use a stricter job id shape (no trailing dot, no device name) at create_job and publish_artifact. OSError from mkdir, resolve and the temporary file's open is now reported as ExecutionRepositoryError without the path, and a failing unlink in the failure handler no longer replaces the error being raised. The tests also pin the containment check that runs before mkdir: with link detection blind (older interpreters cannot see a junction) a linked job directory must still be refused before mkdir is pointed at its target. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d a raising cancel probe The docstring says nothing raises because this runs in a worker's error path, but repository.get_job and the cancel_requested callable sat outside the try, so a store failure while reading the job (or a raising probe) replaced the outcome the caller was about to report. Move both inside the guard; the failure is logged by exception type only and the job is left as it was. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…retention pass that finished The supervisor ran the sweep inside the same try as the retention pass, so a failure in the sweep (connect() on a locked store, an unreadable directory) after the rows had been reclaimed recorded the pass as failed and dropped its artifact, job and event counters, as if the work had not happened. Give the sweep its own guard: the pass still counts as a success with its counters, the failure is logged by exception type only and kept in last_error, and the sweep is retried on the next pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d test its entry budget The limit was checked only between top-level entries, so a single job directory could exceed it: with limit=1, one directory of 64 stale files lost all 64 files and then itself in one pass (65 removals). The entry-budget clause had no test that would fail without it. Pass each entry the removals it has left. A job directory stops at the limit, reports itself incomplete and is resumed by the next pass at that same directory, so nothing is skipped. The docstring now states exactly what is bounded: removals are exact, and the entry budget is checked between top-level entries so it can be overshot by one directory's children. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…hange found surviving Each of these was verified by removing the guard and watching the new test fail. - Dropping owner= from read_artifact in attachment staging's repeated-request path and in the recipe coordinator's input load left every test green, because get_artifact is already owner-scoped; a recording read now pins the owner that is passed. - Removing the reparse-point check from the artifact-root sweep survived because the link test used a symbolic link, which lstat reports as a link and the directory check rejects anyway. A junction reads as a plain directory to lstat, so the new test makes a real one (Windows only) whose target holds a stale temporary file the mutant deletes. The fourth survivor, the containment clause before mkdir in publish_artifact, is pinned in tests/test_execution_path_safety.py. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…a job whose stager died Chat attachment staging creates a job, publishes the bytes and then completes the job, with nothing holding the job in between. A stager killed in that window left a queued job that recovery cannot see (it only looks at jobs with a lease) and that nothing retires, so it stayed in the task tray for good and its request id answered a conflict on every retry. The execution attachment staging closed the same window earlier. Take a short lease while the bytes are staged and release it on every exit that runs code, and let a retry of the same request retire a job nobody is working on (no live lease, no write for ABANDONED_STAGE_SECONDS). The answer to the retry is unchanged; only the job's state is. A request that conflicts with the job never retires it, and a live or recent stager is left alone. Not covered: the startup recovery in local_runtime does not yet fail a chat-attachment job whose lease it reclaims, so a stager killed and never retried is still left queued until that is added. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…artifact root (schema version 4) A row recorded the absolute path it was written with, so moving the data directory left every existing row pointing at nothing: live attachments stopped reading, and expired ones were never reclaimed. Per-row containment already keeps one bad row from stopping retention; this is the rest. New artifact rows and new cleanup tombstones record their path relative to the artifact root (forward slashes). Both forms are read: a path column that is absolute is used as written, a relative one is joined to the current root, and the existing validators still confine either one, so a relative path that climbs out with .., names quarantine, or is drive-relative is refused exactly as an absolute one outside the root is. get_artifact still reports an absolute path. The change goes through the schema ladder as version 4, and nothing is rewritten on upgrade: version 3 rows keep their absolute paths and keep working, and no table changes. The version-3 step (installation principal and owner migration) now runs for stores below 3 only, as before; every older store is then stamped 4. Rollback: a build that only knows version 3 sets a version 4 store aside as "newer" (kept for inspection, then swept) and starts with an empty one, so job history and artifact rows are lost while the artifact files stay on disk for the sweep to reclaim once this build is back. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er can recognise a junction Path.is_junction arrived in Python 3.12, so on 3.10 and 3.11 the reparse-point check cannot see a junction and the sweep relies on what lstat reports for one, which was only verified on 3.12 and 3.14. Skip the test there instead of asserting a behaviour that was not checked, and assert the junction is still present without calling is_junction. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eaves its artifact behind
_stage_created ignored the job that transition(..., "succeeded") returns. When a
retry with the same request id retired the job as abandoned (the 60 s lease had
lapsed and nothing had written for 120 s, as after a suspend/resume or a clock
jump), the transition on the now-failed job wrote nothing and returned it, and the
original call still handed back a descriptor. The published artifact row stayed
behind with no result naming it, and resolve() then raised
attachment_integrity_failed at send time.
Capture the returned job. When it is not succeeded, remove the published artifact
(best effort, never replacing the error and never carrying a path) and raise
ChatAttachmentError("attachment_persist_failed"), the error the caller already
maps. execution/attachment_staging.py has the same guard.
Tests retire the job between publish and completion, assert the error, the failed
/ attachment_interrupted job with no result, and no artifact row or file; and
that a refused removal neither replaces the error nor leaks the store location.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…keeps the store
Recording artifact paths relative to the artifact root adds no table and no
column, so it is not a schema step. Bumping the version to 4 made the previous
release treat the store as newer than itself and set the whole file aside: every
job and the installation principal were lost, and its own artifact-root sweep,
run later, then removed every artifact file because no row named them.
Leave SCHEMA_VERSION at 3 and the ladder as it was. New rows still record relative
paths and the read path still accepts both forms. The previous build keeps every
job, fails closed on reading a new artifact ("Artifact path is unavailable"), and
discards only expired rows it cannot resolve; its sweep matches files to rows by
artifact id, so a live file that still has a row is kept. Comments and docstrings
that described a version-4 step now say so truthfully.
The three tests that pinned the version are back to the literal 3, and the
relative-path tests pin it against a constant spelled out in the test. A new test
opens a version-3 store in which two owners share one request id and asserts the
owner migration does not run again (it would raise "ambiguous" or rewrite owners),
which also pins the ladder's step condition. Another opens a store holding
absolute and relative rows and tombstones together.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…und surviving Ignoring the "complete" flag in sweep_artifact_root still passed test_an_entry_cut_short_by_the_limit_is_resumed_not_skipped, because the total per pass is the same when the rest of an entry is skipped and picked up on the wrap. The test now asserts, pass by pass, that job-a is finished before job-b is touched. Dropping owner= from the get_artifact call in AttachmentStagingService._existing survived because only read_artifact's owner was pinned. Both calls are pinned now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review fixes (head a75ca87)Three commits on top of the branch, plus merges of main (#312, #317, #314, #316). The merges had no conflicts, and 1. Major: a retired chat stager reported success (fixed,
|
Problem
An independent review of #309 found six small defects in the execution store, and one backlog item was only partly done.
nul,con,aux,com1,nul.txt, ...) or end in a dot.create_jobaccepted it and the firstpublish_artifactthen failed with a rawOSErrorcarrying the absolute path;a.shared a directory witha.finish_unsuccessful_jobsays nothing raises, but the job read and the cancel probe sat outside itstry.connect()on a locked store) recorded a retention pass that had already finished as failed and dropped its counters.limit=1, one directory of 64 stale files lost 65 things in one pass. The entry-budget clause had no test that would fail without it.Root cause
nuland a trailing dot; those are not usable directory names on Windows.get_joband the probe were simply written outside the guard.run_oncecalled the sweep inside the sametryas the retention pass.ChatAttachmentService.stagecreated the job and published without holding a lease, and a retry with the same request id treated any non-succeeded job as a conflict without checking whether anyone was working on it.publish_artifactand_record_artifact_cleanupstoredstr(path).Change
One commit per concern.
fix(execution)job ids: a stricter shape (no trailing dot, no device name, with or without an extension) atcreate_jobandpublish_artifact.OSErrorfrommkdir,resolveand the temporary file'sopenis reported asExecutionRepositoryErrorwithout the path, and a failingunlinkin the failure handler no longer replaces the error being raised.fix(execution)finish_unsuccessful_job: the job read and the probe are inside the guard; failures are logged by exception type only.fix(execution)supervisor: the sweep has its own guard. The pass still counts as a success with its counters, the type is logged and kept inlast_error, and the sweep is retried next pass.fix(execution)sweep bound: each entry is handed the removals left. A job directory stops at the limit and is resumed at the same directory by the next pass. The docstring states exactly what is bounded (removals exactly; the entry budget between top-level entries, so it can be overshot by one directory's children).test(execution)mutants: recording-read tests forowner=inattachment_staging._existingandrecipe_coordinator._load_input, and a real-junction test for the sweep's reparse-point check. The fourth survivor (the containment clause beforemkdir) is pinned in the job-id commit's test file.fix(chat): a short lease is held while a chat attachment is staged and released on every exit that runs code; a retry of the same request retires a job nobody is working on (no live lease, no write forABANDONED_STAGE_SECONDS). The answer to the retry is unchanged.feat(execution)relative paths: new artifact rows and new tombstones record their path relative to the artifact root. Both forms are read;get_artifactstill reports an absolute path. This commit's own message says "schema version 4"; that bump is removed again by item 10, so the version stays 3.test(execution): the junction test is skipped wherePath.is_junctiondoes not exist (Python before 3.12).fix(chat)(review fix):ChatAttachmentService._stage_creatednow checks the job that the final transition returns. A stager whose job a retry had already retired as abandoned used to return a descriptor and leave its artifact row behind; it now removes the artifact (best effort) and raisesattachment_persist_failed.fix(execution)(review fix): the schema version is left at 3 and the ladder is back to its original form, so the previous release can open a store this build wrote. The three tests that pin the version are back to the literal 3.test(execution)(review fix): tests that kill three surviving mutants (the sweep'scompleteflag, the ladder's step condition,owner=onget_artifactinattachment_staging._existing).Main was merged into the branch several times (no conflicts); nothing was rebased or force-pushed after review.
Compatibility and rollback
SCHEMA_VERSIONstays 3 and the migration ladder is unchanged. Rows written by an earlier build keep their absolute paths and keep working; new rows are relative; the read path accepts both.skipped=1). That build's artifact-root sweep matches files to rows by artifact id, so it removes only the expired row's file and the directory that emptied, and keeps the live file.uuid4hex. A hand-made id that ends in a dot or is a device name is now refused at publish with "Execution job does not exist."Checks
At the current head (after the review fixes and the merges of main):
python -m ruff check backend tests tools main.py app_factory.py scripts: all checks passed.python -m mypy: no issues in 97 source files.python -m pytest -qon Python 3.14.0 (after merging test(execution): stop the cleanup tests depending on a one-second window #312, ci: run the dependency refresh on Node 24 #317 and feat(frontend): pick GGUF files from a repository, cancel and resume model downloads #314): 2834 passed, 25 subtests passed. After merging fix(api): small API defects - drop the raw message write, kind-checked /generations, can_cancel, client route check, one-query task list, 1 MiB body ceiling #316, the store, execution API, chat attachment, relative-path, installation-principal, reclamation, attachment-staging and allow-once modules: 189 passed.python tools/generate_contracts.py --check: exit 0.python tools/artifact_boundary_review.py --json --strict: 12 of 12 passed.owner=inattachment_staging._existing(bothget_artifactandread_artifact), dropping it inrecipe_coordinator._load_input, removing_is_reparse_pointfrom_sweep_entry, removingplanned.parent != rootbeforemkdir, removing the entry-budget clause, removing theremoved >= limitclause, replacingif not complete:in the sweep, widening the ladder's step condition, settingSCHEMA_VERSION = 4, removing the returned-job check in_stage_created, and narrowing theexceptaround the artifact removal. The test names for the review fixes are in the review-fix comment below.From before the review fixes (earlier head, not re-run as a whole):
ModuleNotFoundError: No module named 'gguf'on that interpreter (82 intest_gguf_download.py, 1 intest_repository_conventions.py); none is in a module this change touches.finish, supervisor, sweep-bound, chat-lease and relative-path changes, in that order). The containment and failure-path tests for relative rows also pass on the old code, by design: they pin behaviour that must not change.Security / data-loss / concurrency
.., a rooted path, a path on another drive, one that names the quarantine directory, one that names a directory, and a quarantine path that points at another job's live file: each is refused or discarded and the file it points at is untouched.OSErrorreaches an error message or a log; exception types only.finally, and expires by itself if the process is killed. A retry can only retire a job that matches its own request and has neither a live lease nor a recent write.Limits
local_runtimedoes not yet fail achat.attachment.v1job whose lease it reclaims, so a stager killed and then never retried is still left queued. That file is being changed by another pull request, so it is left for a follow-up.OSErrorwrap coversmkdir,resolveandopen. A failure in the write,fsyncoros.replacestill propagates as a rawOSError(existing behaviour, with tests written for it).ArtifactBoundary.publish_outputscatches onlyExecutionRepositoryError, so such a failure escapes it after earlier outputs were published; not changed here._is_reparse_pointcannot see a junction; what the sweep does with one there was not verified.resolvelater refuses. Ifpublish_artifactsucceeds and the completingtransitionthen raises, the artifact row waits for retention, as it did before.ChatAttachmentService.resolveand_existingstill read the artifact withoutowner=(the owner-scopedget_artifactruns first);create_jobinstageis not wrapped in a repository-error translation. Neither is changed.Plan item: EX-05
🤖 Generated with Claude Code