Skip to content

fix(artifact): bound docker save and isolate file upload attempts - #386

Closed
xh123637 wants to merge 1 commit into
scaleapi:mainfrom
xh123637:fix/88-artifact-publication
Closed

xh123637 wants to merge 1 commit into
scaleapi:mainfrom
xh123637:fix/88-artifact-publication

Conversation

@xh123637

@xh123637 xh123637 commented Oct 29, 2026 •

Copy link
Copy Markdown

Summary

Fixes #88. Artifact publication had two failure paths:

  • DockerImageArtifact.put() read the docker save process's stdout to EOF before waiting on it, and never drained stderr. A save whose stderr filled the pipe deadlocked, and a stalled save blocked the read itself, so the timeout — applied only in the wait() after EOF — never fired.
  • FileArtifact.put() / put_bytes() upload to the write-once path of the version they reserve, before the document is inserted. When the insert failed, the orphaned object stayed on that path and every retry chose the same version and collided with it (ObjectAlreadyExistsError); two writers reserving the same version collided the same way.

Changes

  • docker_image.py: stream the save through a new _stream_save_output helper. stderr drains on a thread into a bounded 64 KiB tail (kept for the failure message), and a watchdog timer kills the process at DOCKER_SAVE_TIMEOUT_SECONDS instead of timing out only the post-EOF wait. The process is reaped on every path, stdout is still streamed into the gzip file chunk by chunk (archive memory stays bounded), and the existing timeout/failure error messages are unchanged.
  • file.py: put() and put_bytes() now upload through a shared _upload. The happy path is unchanged — the object still lands on the reserved version's path, byte-identical to before. On a collision, _upload checks whether that version was actually published: if a concurrent writer won the reservation, it reserves the next version and retries (bounded); if no document exists for the version, the object is an orphan from a failed attempt, and since write-once objects are never deleted or overwritten, the upload falls back to a prefix of this attempt's own (ArtifactStore.attempt_prefix, the mechanism put_attempt and DockerImageArtifact.put already use). Published objects are never overwritten.

One honest limitation: an orphaned object left by a failed attempt is not garbage-collected (the object store has no delete); it is simply no longer in anyone's way.

Testing

New regression tests (all written against the failure modes above and confirmed to fail or hang on the unpatched code via a standalone reproduction):

  • tst/unit/artifact/test_file_artifact_attempts.py — retry after a simulated document-insert failure succeeds for both put and put_bytes; two concurrent uploads whose uploads are barrier-synchronized both publish, with distinct versions/URLs and intact contents; a second put never overwrites the first version's object.
  • tst/unit/artifact/test_docker_image_save.py — against a PATH-stubbed docker: a save flooding stderr with 300 KB completes and stores the archive; a stalled save is killed at a 2-second timeout with the existing "timed out" error; a failing save reports its stderr; the happy path stores the streamed archive (verified by decompressing what load() returns).

Results:

  • New tests: 9 passed (run 5 times, no flakiness).
  • tst/unit/artifact + tst/unit/store + tst/unit/bundle: 2538 passed, 10 skipped, 33 failed — all 33 failures are in tst/unit/store/local_grant_server_test.py and also fail on unmodified main in this environment (they need a loopback HTTP server this sandbox blocks), so they are unrelated to this change.
  • ruff check on the changed files: clean.

RetriggerView in GreptileConfidence Score: 4/5

Fix the remaining S3 overwrite path and satisfy the numeric-settings rule before merging.

Fix All in CursorFindings

  1. P1 Concurrent S3 uploads overwrite files ▶
  2. P2 Numeric settings break repository rules ▶
Fix with agent prompt
### Issue 1
src/agent_env/artifact/artifacts/file.py:143-150
Two concurrent `FileArtifact.put()` calls can still choose the same version and pass the existence check in `S3ObjectStore._put_file()` before either upload finishes. That method uploads without a create-only condition, so neither call raises `ObjectAlreadyExistsError` and the new fallback never runs. Both documents can publish with the same `object_url`, leaving one artifact with the other writer's contents.

This leaves the concurrency fix incomplete. Use an attempt-specific path before uploading files, or make S3 file creation atomic.

### Issue 2
src/agent_env/artifact/artifacts/docker_image.py:650
The new `drainer.join(timeout=5)` uses an unnamed five-second limit. The repository requires magic numbers to be stored as descriptive class or instance variables. The new stderr read size has the same issue, and the new module-level limits also do not follow that requirement.

Put these settings on the relevant classes and reference them from the helpers. This repository requirement must be satisfied before merging.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a watchdog and bounded stderr capture for docker save, plus collision recovery for file uploads.

  • File uploads avoid version collisions without overwriting objects.
  • Docker image saves drain errors and stop at the timeout.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[File upload] --> B[Choose next version]
  B --> C[Upload to version path]
  C -->|Success| D[Insert artifact document]
  C -->|Collision| E{Document exists?}
  E -->|Yes| B
  E -->|No| F[Upload to unique attempt path]
  F --> D
  C -.-> G[S3 file writes can overwrite without a collision error]
Loading

Reviews (1) · Last reviewed commit: "fix(artifact): bound docker save and iso..." · Reviewed by Greptile

@xh123637
xh123637 requested a review from a team as a code owner October 29, 2026 10:27
Comment on lines +143 to +150
if file_path is not None:
return store.put_object_file(
artifact_type="file",
id=id,
version=version,
object_name=filename,
file_path=file_path,
content_type=content_type,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent S3 uploads overwrite files

Two concurrent FileArtifact.put() calls can still choose the same version and pass the existence check in S3ObjectStore._put_file() before either upload finishes. That method uploads without a create-only condition, so neither call raises ObjectAlreadyExistsError and the new fallback never runs. Both documents can publish with the same object_url, leaving one artifact with the other writer's contents.

This leaves the concurrency fix incomplete. Use an attempt-specific path before uploading files, or make S3 file creation atomic.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/artifact/artifacts/file.py
Line: 143-150

Comment:
**Concurrent S3 uploads overwrite files**

Two concurrent `FileArtifact.put()` calls can still choose the same version and pass the existence check in `S3ObjectStore._put_file()` before either upload finishes. That method uploads without a create-only condition, so neither call raises `ObjectAlreadyExistsError` and the new fallback never runs. Both documents can publish with the same `object_url`, leaving one artifact with the other writer's contents.

This leaves the concurrency fix incomplete. Use an attempt-specific path before uploading files, or make S3 file creation atomic.

**Knowledge Base Used:**
- [Artifact management and delivery](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/artifact-management.md)
- [Storage, objects, documents, and secrets](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/storage-and-secrets.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

# (which is also what unblocks a read stuck on a stalled process's stdout).
save_proc.wait()
timer.cancel()
drainer.join(timeout=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Numeric settings break repository rules

The new drainer.join(timeout=5) uses an unnamed five-second limit. The repository requires magic numbers to be stored as descriptive class or instance variables. The new stderr read size has the same issue, and the new module-level limits also do not follow that requirement.

Put these settings on the relevant classes and reference them from the helpers. This repository requirement must be satisfied before merging.

Rule Used: Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. (source)

Learned From
scaleapi/scaleapi#126388

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/artifact/artifacts/docker_image.py
Line: 650

Comment:
**Numeric settings break repository rules**

The new `drainer.join(timeout=5)` uses an unnamed five-second limit. The repository requires magic numbers to be stored as descriptive class or instance variables. The new stderr read size has the same issue, and the new module-level limits also do not follow that requirement.

Put these settings on the relevant classes and reference them from the helpers. This repository requirement must be satisfied before merging.

**Rule Used:** Store magic numbers as class or instance variables with descriptive names rather than using them inline in the code. ([source](https://app.greptile.com/scale-ai/-/custom-context?memory=002e0051-41ad-46c1-9098-47433c580150))

**Learned From**
[scaleapi/scaleapi#126388](https://github.com/scaleapi/scaleapi/pull/126388)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

@earakely-scale

Copy link
Copy Markdown
Collaborator

🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review.

Context from outside this diff: open PR #89 also claims issue #88, and touches the same two files.

Only offering the cross-PR context; no opinion on which approach is right.


Generated by Claude Code

@xh123637

Copy link
Copy Markdown
Author

Closing this in favor of #89 — I only noticed after opening this PR that @morluto already has a fix for #88 underway there, and it is further along. Apologies for the duplicate effort. Thanks for the detailed write-up in #88; it made both failure modes straightforward to reproduce.

@xh123637 xh123637 closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(artifact): bound image saves and recover interrupted file publication

2 participants