feat(install): lock destinations during sync

Create <destination>/.ai-config.lock exclusively while syncing so parallel
installs or updates fail instead of interleaving writes. A lock left by an
exited process of the same shell on the same host is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian lechnerandClaude Opus 5.5 committed 2026-10-01 12:45:39 +02:00
1 parent d4143ae2ac
commit 96313f4056
4 files changed
+97 -1

No files matched your search

+44
View File
@@ -86,6 +86,26 @@ function Write-ManagedManifest {
Write-ManagedFileAtomically -Path $ManifestPath -Bytes ((New-Object System.Text.UTF8Encoding($false)).GetBytes($content))
}
function Enter-ManagedLock {
param([Parameter(Mandatory=$true)][string]$Destination)
New-Item $Destination -ItemType Directory -Force | Out-Null
$lockPath = Join-Path $Destination '.ai-config.lock'
$owner = "powershell $([Environment]::MachineName) $PID"
foreach ($attempt in 1, 2) {
try {
$stream = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try { $bytes = [System.Text.Encoding]::UTF8.GetBytes("$owner`n"); $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() }
return $lockPath
} catch [System.IO.IOException] {
$holder = @(([System.IO.File]::ReadAllText($lockPath)).Trim() -split ' ')
# ponytail: only same-shell, same-host owners are checked for staleness; other stale locks need manual removal.
$stale = $attempt -eq 1 -and $holder.Count -eq 3 -and $holder[0] -eq 'powershell' -and $holder[1] -eq [Environment]::MachineName -and -not (Get-Process -Id ([int]$holder[2]) -ErrorAction SilentlyContinue)
if (-not $stale) { throw "Another installation or update holds $lockPath; wait for it to finish, or remove the file if no update is running." }
[System.IO.File]::Delete($lockPath)
}
}
}
function Invoke-InstallOptions {
param([Parameter(Mandatory=$true)][string[]]$Arguments, [byte[]]$Managed, [Parameter(Mandatory=$true)][string]$ErrorMessage)
# Exchange content through UTF-8 files: Windows PowerShell pipes re-encode text by console code page.
@@ -107,6 +127,30 @@ function Invoke-InstallOptions {
}
function Sync-ManagedDestination {
<#
.SYNOPSIS
Runs Invoke-ManagedSync while holding the destination lock, so parallel
installs or updates cannot interleave writes to the same destination.
#>
param(
[Parameter(Mandatory=$true)][string]$Source,
[Parameter(Mandatory=$true)][string]$Destination,
[Parameter(Mandatory=$true)][string]$Stamp,
[string]$AiConfigRoot,
[string]$ShellCommand,
[string]$PowerShellCommand,
[string]$ClaudeConcurrency = '5',
[bool]$FlashbangEnabled = $true,
[bool]$StatusLineEnabled = $true,
[switch]$DryRun,
[switch]$Summary
)
if ($DryRun) { return Invoke-ManagedSync @PSBoundParameters }
$lockPath = Enter-ManagedLock $Destination
try { Invoke-ManagedSync @PSBoundParameters } finally { [System.IO.File]::Delete($lockPath) }
}
function Invoke-ManagedSync {
<#
.SYNOPSIS
Installs one generated source tree into a destination directory, tracking
+27 -1
View File
@@ -94,8 +94,34 @@ write_managed_manifest() {
return "$status"
}
# sync_managed_destination <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline]
# acquire_managed_lock <destination>: create <destination>/.ai-config.lock exclusively.
acquire_managed_lock() {
local lock="$1/.ai-config.lock" owner holder_shell holder_host holder_pid
mkdir -p -- "$1"
owner="bash $(uname -n) $$"
if (set -o noclobber; printf '%s\n' "$owner" > "$lock") 2>/dev/null; then return 0; fi
read -r holder_shell holder_host holder_pid < "$lock" 2>/dev/null || true
# ponytail: only same-shell, same-host owners are checked for staleness; other stale locks need manual removal.
if [ "$holder_shell" = bash ] && [ "$holder_host" = "$(uname -n)" ] && [[ "$holder_pid" =~ ^[0-9]+$ ]] && ! kill -0 "$holder_pid" 2>/dev/null; then
rm -f -- "$lock"
if (set -o noclobber; printf '%s\n' "$owner" > "$lock") 2>/dev/null; then return 0; fi
fi
printf 'Another installation or update holds %s; wait for it to finish, or remove the file if no update is running.\n' "$lock" >&2
return 1
}
# sync_managed_destination: same arguments as run_managed_sync; holds the destination lock unless dry-running.
sync_managed_destination() {
[ "$7" != true ] || { run_managed_sync "$@"; return; }
acquire_managed_lock "$2" || return 1
local status=0
run_managed_sync "$@" || status=$?
rm -f -- "$2/.ai-config.lock"
return "$status"
}
# run_managed_sync <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline]
run_managed_sync() {
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} claude_concurrency=${10:-5} statusline_enabled=${11:-true}
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
local manifest