fix(checkpoint): self-heal stale index.lock from crashed git process - #13232
fix(checkpoint): self-heal stale index.lock from crashed git process#13232thapecroth wants to merge 1 commit into
Conversation
_run_git now clears any index.lock older than 60s in the shadow repo before invoking git. Checkpoint ops per shadow are strictly serial (one gateway agent per session), so an old lock at entry is unambiguously orphaned from a crashed or killed git subprocess. Observed impact without this: a single crashed git add wedged checkpointing on one shadow for 6 days (56+ identical errors in the log, all fatal: Unable to create '.../index.lock': File exists). 60s threshold is conservative — real git ops finish in milliseconds, so any lock that old is a zombie. Adds 4 tests: removal of stale lock, preservation of fresh lock, noop when no lock, and an end-to-end that shows git add -A succeeds after cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the targeted recovery work. The stale-lock bug class remains relevant, but the original patch predates the checkpoint v2 storage redesign.
Problems
tools/checkpoint_manager.py:175removesshadow_repo / "index.lock". On current main, staging uses a per-project index (_index_path()attools/checkpoint_manager.py:223-224) passed asGIT_INDEX_FILE(tools/checkpoint_manager.py:266-269and_take()attools/checkpoint_manager.py:889-925). The proposed target is therefore not the active index used by checkpointgit add.- The E2E test at
tests/tools/test_checkpoint_manager.py:743-752creates that retired root-level lock shape, so it does not cover the current v2 path.
Suggested changes
- Rework the cleanup to derive the lock from the actual
index_fileused for index-mutating calls, and add a v2 regression test that plants that lock before a real checkpoint/add operation. - The linked salvage PR #52887 carries the same root-store target, so it needs the same correction.
This is an automated hermes-sweeper review.
| a single crashed ``git add`` wedges checkpointing for that repo until | ||
| manual cleanup (seen in logs: 56+ errors over 6 days on one shadow). | ||
| """ | ||
| lock_path = shadow_repo / "index.lock" |
There was a problem hiding this comment.
Current main no longer stages through a per-shadow-repo root index: _take() passes store/indexes/<project-hash> as GIT_INDEX_FILE. This should target the lock associated with the actual index_file, otherwise a stale lock from the active git add path remains untouched.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Three PRs address stale Git index locks that can permanently wedge checkpoint creation. #13232 and #52887 clean the retired root/store-level index.lock rather than the active v2 per-project GIT_INDEX_FILE lock, while closed #45871 targets the correct per-project lock and adds replacement-race checks but was withdrawn over a remaining unlink race.
Related pull requests
- #13232
related— (+90/-0) — rework: The diff adds age-based cleanup and tests for shadow_repo/index.lock, but checkpoint v2 git add uses a per-project GIT_INDEX_FILE and therefore creates <index_file>.lock instead. Consistent with the keep_open review on #13232, retain it only as the consolidation vehicle and retarget both implementation and regression coverage to the actual v2 checkpoint path. - #45871 [closed]
duplicate— (+136/-0) — reference implementation, not merge-ready: Although closed, this diff remains relevant because it derives the lock from the active per-project index_file and attempts to preserve a lock replaced between stat checks. It was withdrawn after a local review identified a remaining race between the final identity check and unlink, so that concurrency objection must be resolved before its approach is reused. - #52887
duplicate— (+89/-5) — duplicate requiring the same rework: This salvage largely reproduces #13232 and still calls cleanup on store/index.lock, while its end-to-end test omits index_file and therefore does not exercise the runtime snapshot route. Despite the keep_open review on #52887, the diff provides no distinct working fix; its useful salvage context can be consolidated into #13232.
Duplicates
#13232 and #52887 are substantially the same root/store-level stale-lock cleanup and test the same inactive lock shape. #45871 addresses the same bug class but is a technically distinct, closer reference because it targets the v2 per-project index lock and adds replacement-race checks.
Suggested consolidation
Merge #13232 only after reworking it to clean the lock adjacent to the actual per-project GIT_INDEX_FILE, adding a real _take or _run_git(..., index_file=...) regression test with fresh-lock coverage, and resolving the remaining check-to-unlink race documented when #45871 was closed. Then #52887 can be closed as a duplicate; keep closed #45871 as the reference for correct v2 lock-path derivation rather than reopening or merging it with its documented race unresolved.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup13232 ["PRs duplicating each other"]
P13232["PR #13232 (open)"]
P45871["PR #45871 (closed)"]
P52887["PR #52887 (open)"]
end
class P13232 open
class P45871 closed
class P52887 open
class P13232 target
click P13232 "https://github.com/NousResearch/hermes-agent/pull/13232"
click P45871 "https://github.com/NousResearch/hermes-agent/pull/45871"
click P52887 "https://github.com/NousResearch/hermes-agent/pull/52887"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 17 kB of PR diffs, 6 kB of issue/PR text, 3 kB of discussion (4 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
|
Production reproduction against checkpoint v2, confirming the active lock path discussed in review: Observed failure
This directly corroborates the review finding: recovery needs to derive the lock from the actual Locally validated recovery behaviorWe tested timeout handling with both cases:
We also added a pre-stage worktree-size guard because the triggering checkpoint attempted to inventory a workspace container substantially larger than the checkpoint store's configured capacity. With the guard, the 1.4 GiB worktree is skipped in about 0.6 seconds with no lock and no checkpoint-store growth. Relevant checkpoint tests plus CLI/path/config coverage passed locally (111 tests). Concurrency caveat / suggested upstream shapeA simple exists-before / unlink-after-timeout implementation still has a narrow replacement race if another process creates a new lock between timeout handling and unlink. For an upstream implementation, I agree with using the actual per-project Suggested split:
|
|
Follow-up evidence from the same host, in a second profile-scoped checkpoint store:
This is a separate concrete example of why the repair must include aged pre-existing lock recovery, not only cleanup after the direct child times out. That recovery should run only while holding a crash-releasing cross-process coordination lock for the relevant per-project index/transaction. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Delta since our previous triage comment
@0xdfi added two production observations that corroborate the previous review: checkpoint v2 leaves the orphan at the per-project <GIT_INDEX_FILE>.lock, not at the root/store-level path targeted by #13232. The second observation also extends the analysis by showing that recovery must handle aged locks predating the current invocation, while using crash-releasing cross-process coordination to avoid racing an active writer.
Changed pull requests
- #13232
related— (+90/-0) — rework, with stronger production evidence: The new reports confirm that the current diff still cleans the wrong lock path and that timeout-only cleanup is insufficient; #13232 should derive the lock from the supplied per-projectindex_fileand coordinate aged pre-existing-lock recovery across processes.
Suggested consolidation
The previous consolidation recommendation is unchanged: retain #13232 as the rework vehicle and do not merge it until the v2 lock path and documented concurrency race are addressed.
Complex graph unchanged since our previous triage comment.
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 17 kB of PR diffs, 6 kB of issue/PR text, 7 kB of discussion (6 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
Extends the recovery from the per-project index lock to whatever lock git itself reports, after two findings while comparing against NousResearch#13232/NousResearch#52887. 1. Locale. Detection previously matched the English "unable to create" in stderr. git localizes that prose — under a Turkish locale the same failure reads "onulmaz: '<path>' oluşturulamıyor: File exists." — so recovery would have silently stopped working for every non-English install. Detection now matches the quoted *path* ending in .lock, which git does not translate. 2. Coverage. The store takes more than one lock. `git add -A` passes index_file and so takes store/indexes/<hash>.lock, but the 43 calls that pass no index_file use git's default $GIT_DIR/index, whose lock is store/index.lock, and update-ref/maintenance calls take ref locks under store/refs/. A killed process wedges whichever one it held. Reclaiming the path git names in its error covers all of them with one mechanism and no guessing, so _index_lock_path() is now total (per-project index when index_file is given, store root otherwise) and the retry path reads its target out of stderr. Reclaimed paths are validated to resolve *inside* the checkpoint store, so a surprising or hostile message can never point the cleanup at a file we do not own; relative paths are ignored. The staleness proof is unchanged. Adds TestLockReclaimCoversEveryLockClass: both index-lock targets, ref-lock extraction, a localized-git stderr, refusal of out-of-store and relative paths, and an end-to-end real-git ref-lock recovery through _run_git. That last one fails against an index-only implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(salvage of NousResearch#13232 by @thapecroth) Rebuilt on latest main (Bartok9 hygiene 2026-08-01). Original: NousResearch#52887
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Delta since our previous triage comment
@Bartok9 rebuilt #52887 onto the latest main, changing its current diff to +34/-5. The refreshed diff confirms rather than corrects the prior finding: _clear_stale_lock(store) still targets store/index.lock instead of the active per-project <GIT_INDEX_FILE>.lock.
Changed pull requests
- #52887
duplicate— (+34/-5) — rework unchanged after rebuild: Despite the keep_open review on #52887, the refreshed diff still calls_clear_stale_lock(store)and therefore misses the per-project lock used by checkpoint v2; the unrelated formatting/comment edits do not address that root cause.
Suggested consolidation
The consolidation recommendation is unchanged, consistent with the backlog lane: retain #13232 as the rework vehicle and close #52887 as its duplicate once the corrected v2 fix is consolidated.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup13232 ["PRs duplicating each other"]
P13232["PR #13232 (open)"]
P45871["PR #45871 (closed)"]
P52887["PR #52887 (open)"]
end
class P13232 open
class P45871 closed
class P52887 open
class P13232 target
click P13232 "https://github.com/NousResearch/hermes-agent/pull/13232"
click P45871 "https://github.com/NousResearch/hermes-agent/pull/45871"
click P52887 "https://github.com/NousResearch/hermes-agent/pull/52887"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 14 kB of PR diffs, 6 kB of issue/PR text, 7 kB of discussion (7 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
Summary
tools/checkpoint_manager._run_gitnow removes anyindex.lockolder than 60s in the shadow repo before invoking git. Checkpoint ops per shadow are strictly serial (one gateway agent per session), so a lock file that old is unambiguously orphaned from a crashed or killed prior git subprocess.The bug
A single crashed
git addleavesindex.lockbehind. Every subsequent checkpoint for that shadow then fails with:Observed in prod logs: 56 identical errors over 6 days on a single shadow repo until manual cleanup. No recovery path — the agent just keeps retrying and failing forever.
Why 60s
Real git ops on a shadow repo finish in milliseconds. 60s is ~1000x the realistic upper bound for an in-flight op, so any lock older than that is a zombie. Conservative enough to never race against a legitimate concurrent op, tight enough to recover within one retry cycle.
Scope
_clear_stale_lock(shadow_repo)— new helper, called from_run_gitentry.WARNINGwith the lock age when it removes one, so the underlying crash pattern stays visible.Test plan
4 new tests in
tests/tools/test_checkpoint_manager.py::TestStaleLockCleanup:git add -Asucceeds after stale-lock cleanupFull
tests/tools/test_checkpoint_manager.py+tests/test_batch_runner_checkpoint.py: 69/69 pass.🤖 Generated with Claude Code