init-session.ps1: concurrent named-plan inits can fail on the pointer replacement and leave a ReplaceFile temp artifact
Problem
Several init-session.ps1 "<name>" runs started at the same moment in the same project can fail while updating .planning/.active_plan, and one of them can leave a .active_plan~RF*.TMP file behind in .planning/.
Scope: named-plan mode on Windows only, and only when more than one init runs concurrently (for example parallel agents initialising plans in one repository). The default zero-argument root mode is not affected. A single init is not affected.
How to reproduce
1..4 | ForEach-Object {
Start-Process pwsh -ArgumentList '-NoProfile','-File','.\scripts\init-session.ps1','Race Case' -NoNewWindow
}Observed on Windows 11 with pwsh 7.6 and Windows PowerShell 5.1: one to three of the four runs print Error: could not safely update the active plan pointer at ...\.planning\.active_plan and exit 1. Their plan directories (<date>-race-case, -2, -3, -4) all exist. Sometimes a .planning\.active_plan~RF<hex>.TMP file remains.
Cause: set-active-plan.ps1 writes the new pointer to an exclusive temporary file and then calls [IO.File]::Replace($tempFile, $ActiveFile, ...) (or [IO.File]::Move when no pointer exists yet). ReplaceFile fails with an IOException when another process is replacing or moving the same target at that instant, and ReplaceFile can leave its own intermediate ~RF*.TMP file when it is interrupted. The script catches the exception, reports the error, and only removes its own temporary file in finally, not the ~RF artifact.
The shell twin's mv -f is a single rename and succeeds under the same race.
Suggested fix
- Retry
ReplaceorMovea few times with a short back-off onIOExceptionbefore giving up. The pointer content of the last writer wins, which matches the shell twin. - In the
finallyblock, also remove any.active_plan~RF*.TMPthat the current call created (or any stale one older than a few seconds), so the planning root stays clean. - Optionally, when the final pointer update fails,
init-session.ps1could remove the plan directory it just created, so a failed run leaves nothing behind. This is a separate design choice and can be left out of a first fix.
A regression test can drive the race with Start-Process or System.Threading.Tasks and assert that every run exits 0 and that no ~RF file remains.
Origin
Found by the v3.19.0 release review (PRs #247, #248, #249). Tracked as item 2 of #250 before that issue was split into one issue per item.
Source: OthmanAdi/planning-with-files