fix(create_project): idempotent rename write completes a stray write+delete split (#53)
The rename path (write new path, then delete old path) is two separate, non-atomic API calls. If a prior partial run's write succeeded but the paired delete failed — plausible under the same infra#179 flakiness resume (#50) exists to work around — a resume would recompute the identical rename and blind-create (no sha) at a path that already exists, hitting a conflict and getting stuck needing another resume cycle just to re-report the same thing. renameEntry now reads the new path first: if it already holds the correct content (prior write succeeded), the write is skipped and only the outstanding delete of the old path runs; if the new path exists but differs, it's updated with the fetched sha instead of blind-created; if the old path is already gone by delete time, that's treated as done, not a failure. Mirrors injectDispatchAllow's (#51) read-before-write idempotency pattern. Test: TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly — a stray old path plus an already-correct new path resolves to a single delete, zero redundant writes, no partial_failure. All prior create_project tests unaffected (backward compatible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -329,6 +329,29 @@ func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) {
|
||||
assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure")
|
||||
}
|
||||
|
||||
// A resume where a prior partial run's rename write SUCCEEDED but its paired
|
||||
// delete FAILED (both are separate, non-atomic API calls) must complete
|
||||
// cleanly: recognize the new path is already correct, skip re-writing it, and
|
||||
// just finish the outstanding delete of the stray old path (gitea-mcp#53).
|
||||
func TestCreateProject_Resume_StrayRenamedOldPath_CompletesCleanly(t *testing.T) {
|
||||
files := map[string]string{
|
||||
// stray: delete never completed in the prior run
|
||||
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||
// already correct: the write half of the same prior rename DID complete
|
||||
"cmd/new-svc/main.go": "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||
}
|
||||
f := newFakeTemplateServerResumed(files, "main")
|
||||
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||
defer srv.Close()
|
||||
|
||||
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`)
|
||||
|
||||
assert.Empty(t, out.PartialFailure)
|
||||
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||
assert.NotContains(t, f.puts, "cmd/new-svc/main.go", "already-correct new path must not be rewritten")
|
||||
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go", "the outstanding delete must still happen")
|
||||
}
|
||||
|
||||
// dispatch_allow injection is idempotent on resume: if .dispatch-allow already
|
||||
// has the correct content (from an earlier successful injection), re-invoking
|
||||
// must not attempt another write — and must not error the way a naive
|
||||
|
||||
Reference in New Issue
Block a user