Skip to content

fix(repo): repo run survives a crash mid-grade and a parallel run on one cache - #724

Open
vlad-perevezentsev wants to merge 7 commits into
intel:mainfrom
vlad-perevezentsev:fix/repo-grading-aside-and-clone-lock
Open

vlad-perevezentsev wants to merge 7 commits into
intel:mainfrom
vlad-perevezentsev:fix/repo-grading-aside-and-clone-lock

Conversation

@vlad-perevezentsev

Copy link
Copy Markdown
Contributor

Summary

Two Medium items of #687: repo run's on-disk state broke on a crash in the middle of setting the git config aside, and when two runs used one clone cache at once.

  • The agent's git config survives a crash before grading (repos/grading.py). _set_git_config_aside decided whether this was the first grade from whether {run_dir}/agent-git/ existed, and created that directory before the moves. A run killed after the mkdir and before the last move made the next grade of the cell take itself for a regrade: it deleted the agent's .git/config, hooks, config.worktree and commondir without archiving them. The moves now go to {run_dir}/.agent-git.partial/, which is renamed to agent-git/ after the last move. A grade that finds the partial directory finishes the moves from what is still in the workspace.
    • Each move is an os.rename: workspace/ is made inside the run dir, so the move is atomic on one file system. With shutil.move across file systems, a crash halfway left a copy that the redo could not trust.
    • A link left in place of .agent-git.partial (the grader's code has the run dir read-write) is replaced, not moved into. The leading dot means no image.env scratch entry can have that name.
    • Same function, same bug class: when the workspace's .git is a file, the move ignored the first-grade check. A regrade moved whatever .git file a grader left over the archived agent-git/dot-git. It is now removed, like the other config sources on a regrade.
  • Two runs can clone and fetch one remote source at once (repos/source.py). ensure_clone cloned into a fixed <git_dir>.partial. A second process deleted the first one's partial clone, or cloned into it after the first's rename and then failed renaming over the finished clone. The new clone_once gives each call its own <git_dir>.<pid>-<random>.partial. A call whose rename fails uses the clone already there when it has a HEAD. A partial directory older than its clone's git timeout belongs to no live clone and is removed, as the fixed .partial was before.
    • source_git_dir (repos/workspace.py, the full clone that task bases are checked out from) had the same race in the same cache. It goes through clone_once too, with its own timeout.
    • After the clone, a branch or tag source fetches every ref into the cache. Two such fetches after the origin moved fought over the ref locks, and one run failed with cannot lock ref 'refs/heads/b1': is at … but expected …. The fetch is now run again, up to three times, when it failed on a ref lock. By then the other fetch has updated the refs. The fetch runs with LC_ALL=C, as schema_compat's git calls do, so a translated git still prints the text that is matched. Any other fetch failure is raised as before, after one attempt.

No lock file and no waiting: a run that loses either race uses the winner's result, and this works the same on Windows.

Refs #687: closes the repos/grading.py:267 box and the repos/source.py:461 sub-item of "Concurrent writers collide". The other two sub-items there (caching.py, catalog_probe.py) are in #697.

Changed output

None for a run that does not crash or race. On a crash before grading, the run dir can hold .agent-git.partial/ until the cell's next grade. A killed clone leaves <name>.git.<pid>-<random>.partial in the repo cache ($DOKIMI_REPO_CACHE) instead of <name>.git.partial, and the next clone of that URL removes it once it is older than the git timeout (30 min). A failed ref fetch reports git's error in English.

Evidence

  • New tests on main 8e48ed7: 10 failed, 4 passed of 14. On this branch: 14 passed.
    • tests/test_repo_grading_aside.py: a crash after 0, 1 or 3 of the 4 moves, then a normal grade, archives every source (on main: the hooks and the rest are gone); a regrade keeps the first .git file (on main: 'gitdir: /grader/repo' == 'gitdir: /agent/repo'). These pass on main: a regrade keeps the first config and drops the grader's; a link in place of the partial archive is not moved into (that directory is new).
    • tests/test_repo_source_clone_race.py, on a local bare origin, no network: two threads past their checks before either clones (on main: Directory not empty on the rename); a second ensure_clone started while the first is between its clone and its rename (on main: FileNotFoundError, its partial deleted); the same for source_git_dir; a partial older than the timeout is removed and a young one kept; a fetch whose refs/heads/topic.lock is held by "another run" is run again, also under LC_ALL=de_DE.UTF-8 with git's text translated (on main: cannot lock ref). These pass on main: a fetch failing for another reason runs once; a full-clone partial is aged by workspace's own timeout.
  • The fetch race, run for real: a bare origin with 30 branches, the origin advanced, two ensure_rev(<branch>) at once, 20 rounds. Before the retry: 20 of 40 fetches failed. After: 0 of 40.
  • Mutations, each caught by a test: agent-git/ made before the moves; the first-grade check dropped; nothing cleared on a regrade; .agent-git.partial reused when it is a link; the rename failure raised even when HEAD exists; the per-process name made fixed again; any partial taken as stale; the partial not removed after a lost rename; source._GIT_TIMEOUT_S used for the full clone; no fetch retry; a retry on any fetch failure. One mutant survived and is equivalent: mkdir(exist_ok=True) on agent-git/ after the rename.
  • ensure_clone('file:///nonexistent/repo.git', …): SourceError: git clone failed: fatal: Could not read from remote repository., and nothing is left in the cache.
  • An independent review found four points, each fixed in its own commit: the ref-fetch race after the clone; shutil.move replaced with a rename; each clone's own timeout for the stale rule; LC_ALL=C for the matched fetch error.
  • make ci-local --offline: 0 failed, 21 passed, 19 skipped (19056 tests passed). check_growth.py: clean. Not run: the Windows leg (these tests are not in windows-smoke), and git with real translations installed (this machine has none; the test emulates one).

Not in this PR

Checklist

  • Tests added/updated and pytest passes: two new test files, 14 tests; the full suite in make ci-local
  • ruff, mypy, and schema checks pass: make ci-local --offline static and typecheck rows pass; no schema changes
  • Docs updated (or N/A): N/A, docs/repo-evaluation-contract.md says the config is moved to {run_dir}/agent-git/, which still holds
  • Self-reviewed per "Before opening a PR" in AGENTS.md: mutations, other sites of the bug class (source_git_dir fixed, _export listed), a bad URL run
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml): both test files have it; the changelog fragment is exempt

_set_git_config_aside decided `first` from whether agent-git/ existed,
and created it before the moves. A run killed after the mkdir and
before the last move made the next grade of the cell take itself for a
regrade and delete the agent's .git/config, hooks and pointers without
archiving them.

The moves now go to .agent-git.partial/, renamed to agent-git/ after
the last one; a grade that finds the partial directory finishes the
moves from what is still in the workspace. A link left in place of the
partial directory is replaced, not moved into. The .git file case
honours `first` too: a regrade no longer moves a grader's .git file
over the archived agent-git/dot-git.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
ensure_clone cloned into a fixed <git_dir>.partial: a second process
cloning the same URL deleted the first's partial clone, or cloned into
it after the first's rename and failed renaming over the finished
clone. source_git_dir had the same race on the full clone task bases
are checked out from.

Both go through clone_once: each call clones into its own
<git_dir>.<pid>-<random>.partial, and a call whose rename fails uses
the clone already there when it has a HEAD. A partial directory older
than the git timeout belongs to no live clone and is removed.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
shutil.move across file systems copies, then deletes the source; a
crash while deleting left a full copy in .agent-git.partial/ and a
subset in the workspace, and the redo replaced the copy with the
subset. workspace/ is made in the run dir, so each move is a rename on
one file system: atomic, and loud if that ever stops holding. The
clear before each move and the test of a copy cut short go with it.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
clone_once removed a partial directory older than source.py's git
timeout, which held for the full clone of source_git_dir only because
workspace.py's timeout has the same value. Each caller now passes the
timeout its clone runs under.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
Every call of ensure_rev for a branch or tag fetches all refs into the
shared cache. Two runs doing it at once after the origin moved fought
over the ref locks, and one of them failed with "cannot lock ref ...
is at X but expected Y" (20 of 40 fetches in 20 rounds of two at
once). The fetch is now run again, up to three times, when it failed on
a ref lock: by then the other fetch has updated the refs. Any other
fetch failure is raised as before.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
The fetch is run again when its error says "cannot lock ref", but git
ran in the user's locale: a translated git printed another text, and a
run that lost a ref to another run's fetch failed as before. The ref
fetch now runs with LC_ALL=C, as schema_compat's git calls do for the
messages they match.

Refs intel#687

Signed-off-by: Vladislav Perevezentsev <vladislav.perevezentsev@intel.com>
@vlad-perevezentsev
vlad-perevezentsev requested a review from a team as a code owner November 13, 2026 14:55
@vlad-perevezentsev vlad-perevezentsev self-assigned this Nov 13, 2026
@exai-bot

exai-bot commented Nov 13, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 1 issue(s) in this PR.

CI at 20c2efc: 29 passing, 3 skipped.

By severity: 1 medium

Severity Category Location Finding
medium bug source.py:478-481 Must-fix — Impact: abandoned partial clones remain indefinitely, potentially retaining a full repository's worth of cache storage.
Review details
  • Commit reviewed: 20c2efc against merge base 8e48ed7
  • Coverage: 6 files in 1 groups
  • Engine: OpenCodeReview v1.13.5-exai.4.8f73eada, us.openai.gpt-6.1-sol
  • Usage: 251k tokens (144k from cache), 48s
  • Tool calls: 22 (11 file_read, 6 code_search, 2 pr_comments, 2 pr_threads, 1 code_comment)
  • Repository run-aside isolation and clone-race fixes: 6 files

@codecov-commenter

codecov-commenter commented Nov 13, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 91.93548% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.73%. Comparing base (8e48ed7) to head (20c2efc).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
dokimi/dokimi/repos/source.py 88.09% 4 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #724      +/-   ##
==========================================
+ Coverage   95.71%   95.73%   +0.02%     
==========================================
  Files         397      408      +11     
  Lines       53247    54676    +1429     
  Branches     9161     9411     +250     
==========================================
+ Hits        50963    52346    +1383     
- Misses       1410     1442      +32     
- Partials      874      888      +14     
Files with missing lines Coverage Δ
dokimi/dokimi/repos/grading.py 95.98% <100.00%> (+2.05%) ⬆️
dokimi/dokimi/repos/workspace.py 92.81% <100.00%> (+0.12%) ⬆️
dokimi/dokimi/repos/source.py 95.56% <88.09%> (-0.40%) ⬇️

... and 59 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@napetrov napetrov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Change class: not computed (no receipt profile for this repo, or receipt.py failed)

Comment, one question. CI: 0 failing jobs on head. The rename-based set-aside and the per-call clone partials look right on the paths I could run. One question: a run dir the previous code left after a crash right after its mkdir still loses the agent's git config on the next grade, because the new check treats the empty agent-git/ as archived. The PR fixes new crashes only; I reproduced the old state on head.

Coverage: whole-PR and edge pass on both trees; the set-aside, clone and fetch paths; the new tests on head; a concurrent-fetch stress probe. Not run: the full suite (scipy missing here), Windows.

if dot_git.is_symlink() or dot_git.is_file(): # the agent's own repository elsewhere: the grader gets none
shutil.move(str(dot_git), str(aside / "dot-git"))
return
if not aside.exists():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question: a run dir left by the previous code after a crash right after its mkdir still loses the agent's git config on the next grade

if not aside.exists(): takes an empty agent-git/ as already archived, so the grade deletes the agent's .git/config and hooks and writes the plain config, with nothing archived. The new layout only prevents this for new crashes. Run dirs already on disk are not migrated.

How to check: an empty pre-existing agent-git/ and an agent [core] fsmonitor in .git/config, then one _set_git_config_aside call → archived: False, config replaced (probes/scripts/legacy_empty_aside.py, head; base has the same check).
Expected: a grade that finds an empty agent-git/ should finish the archive as a partial, or the PR should say runs from the previous version are out of scope.

A partial clone whose objects cannot be deleted was left in the cache without
an error. The removal makes entries writable and retries, and raises when the
partial is still there. Tests pin the removal, the raise, the raise on a failed
rename onto a directory with no HEAD, and the SourceError after the last fetch try.

Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com>
@xaleryb

xaleryb commented Nov 14, 2026

Copy link
Copy Markdown
Contributor
  • Run: main 60044a8 merged with bd9243c, Python 3.13. The full suite, ruff, mypy, bandit -ll and the growth and comment-block gates all pass. Two concurrent fetches against an origin that keeps advancing: 23/40 fail on main, 0/40 with this PR.
  • Open: the inline thread on dokimi/repos/grading.py:268 reproduces. Input: a run dir from the previous code with an empty agent-git/, plus the agent's .git/config and hooks/pre-commit. Observed: agent-git/ stays empty, and the config and hooks are deleted. Expected: archived, or this case documented as a known one-time leftover.
  • Not changed here: if an empty agent-git/ counted as unarchived, a regrade after a first grade that had nothing to archive would archive the grader's config as the agent's.
  • For the author: reply on that thread. Recommended: accept the one-time legacy state, add one line about it to the changelog.d fragment, and resolve the thread.

@vchamarthi

Copy link
Copy Markdown
Contributor

Pushed 1 commit to fix/repo-grading-aside-and-clone-lock (bd9243c..20c2efc).

  • Partial clones are removed through _remove_tree, which makes read-only entries writable and raises when the directory is still there.
  • Test for the raise after a failed rename onto a directory with no HEAD.
  • Test that holds the ref lock through every fetch try.
    Run: the full suite with main merged in, 19807 passed.

Questions for @vlad-perevezentsev

1. Should a grade that finds an empty agent-git/ finish the archive, or are run dirs from before this PR out of scope? (dokimi/dokimi/repos/grading.py:268)
An empty agent-git/ plus [core] fsmonitor in .git/config, then one _set_git_config_aside call: archived: False, config replaced. Treating an empty agent-git/ as a partial also makes a regrade archive a grader's config as the agent's when the first grade had no sources.

  • A: Treat an empty agent-git/ as unfinished and archive into it; a first grade with no config sources then archives on regrade.
  • B: Leave it and state in the PR text that run dirs from the previous version are out of scope.

Comment on lines +478 to +481
for old in git_dir.parent.glob(f"{git_dir.name}.*partial"):
try:
if time.time() - old.stat().st_mtime > timeout_s:
_remove_tree(old)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix — Impact: abandoned partial clones remain indefinitely, potentially retaining a full repository's worth of cache storage. Trigger: one concurrent clone publishes git_dir, while another process is killed before its finally removes its partial. Evidence: subsequent clone_once calls return at the preceding HEAD check, so this cleanup loop is never reached, even after timeout_s; there is no other repository-cache cleanup path. This affects both ensure_clone and source_git_dir. Move expired-partial cleanup before the cached-repository fast path and add a regression with a valid cached clone plus an expired partial. Workaround: manually delete confirmed-inactive partial directories. Severity: medium, because the storage leak is persistent but bounded to abandoned cache clones.

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review at 20c2efc. The set-aside rename logic holds up: a crash after any of the moves, or between the last move and the final rename, loses nothing. Two things are worth fixing before merge, both in repos/source.py: the ref-fetch retry does not fire on a current git, and an expired partial clone is never removed once a valid clone exists.

What I ran, on a Linux review host at 20c2efc:

  • The two new test files: 15 passed, 3 failed. The three failures are in test_repo_source_clone_race.py and are the retry tests (see the inline comment on source.py:545).
  • Reproductions for the code findings, on local bare origins, no network.
  • Mutations of the grading change: dropping the first-grade check, the regrade cleanup, the plain config, commondir or config.worktree handling each fail a test.

Still waiting for the author: @napetrov's question on run dirs left by the previous code with an empty agent-git/ (grading.py:268) is unanswered. The choice is the author's, and the merge waits for it.

Checked, no issues

  • Crash windows in the set-aside: after 0, 1 and 3 moves, and after the last move before partial.rename(aside), the next grade finishes the archive and leaves a plain .git/config.
  • Glob in clone_once: <name>.*partial cannot match another cache entry. The .full.git clone has a different name stem.
  • Imports and ratchet: shutil is still used in grading.py and workspace.py. test_growth_ratchet.py passes.
  • Tests are offline: they use tmp_path and local origins, and leave the tree clean.

"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*", env=_FETCH_ENV)
return
except SourceError as exc:
if "cannot lock ref" not in str(exc) or attempt == _FETCH_TRIES - 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fetch retry never fires on git 2.53, so the race the PR fixes still fails one fetch in two. The loop retries only when the error contains cannot lock ref. Git 2.53.0 prints a different text for the same collision.

  • Two ensure_rev(git_dir, "main") calls at once, 20 rounds, a bare origin with 30 branches advanced each round: 40 calls, 20 failed, all with SourceError: git fetch failed: error: fetching ref refs/heads/b9 failed: incorrect old value provided. That is the same 20 of 40 as before the retry. cannot lock ref was in none of them.
  • With the lock held by file, as the new tests do, the text is error: fetching ref refs/heads/topic failed: reference already exists. On this git test_a_branch_fetch_that_lost_a_ref_to_another_run_is_run_again[None], [de_DE.UTF-8] and test_a_branch_fetch_that_keeps_losing_its_ref_raises_after_the_last_try fail with it. The PR's own evidence (14 passed) was measured on an older git.
  • process.git_failure also keeps only the last error: line, so matching on one line of a multi-ref failure is fragile.

The tests are not worthless but they depend on the git version, because they make git produce the text. Mutation check: with _FETCH_TRIES = 1, the env= argument dropped, or LC_ALL removed, the same three tests fail, so on this git nothing separates a working retry from none.

Suggestion: treat any non-zero git fetch of the refspecs as retryable when the refs have moved since (for example, retry up to _FETCH_TRIES times on SourceError and re-raise the last), or match cannot lock ref, incorrect old value provided and reference already exists. Pin the loop's logic with a stubbed _git that raises a fixed message, and keep one real two-process test that does not depend on the message text.

Comment on lines 475 to +478
if (git_dir / "HEAD").is_file():
return git_dir
cache_dir.mkdir(parents=True, exist_ok=True)
tmp = git_dir.with_name(git_dir.name + ".partial")
if tmp.exists():
shutil.rmtree(tmp)
_git(None, "clone", "--bare", "--filter=blob:none", "--", url, str(tmp))
tmp.rename(git_dir)
git_dir.parent.mkdir(parents=True, exist_ok=True)
for old in git_dir.parent.glob(f"{git_dir.name}.*partial"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree with exai-bot here; the expired-partial cleanup sits after the HEAD early return, so it is skipped for good once a valid clone exists. Repro on a local bare origin: a valid clone x.git (with HEAD), plus x.git.1-abc.partial with mtime 4000 s old and a timeout of 1800 s. clone_once(x.git, ..., 1800) returns x.git and the partial is still there afterwards (old partial still exists: True). The same happens through ensure_clone and source_git_dir. This is the case the PR text describes: a run killed mid-clone while another run wins the rename. The PR text says the next clone of that URL removes it, but there is never a next clone.

Suggestion: run the loop at lines 478-484 before the HEAD check, or move it into a helper called from both paths, and add a test with a valid cached clone plus an aged partial.

This branch has not been deployed

No deployments
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.

7 participants