|
| 1 | +# AgentLocks.ps1 |
| 2 | +# Reusable Redis work-item locks for the issue-pr-loop skill (concurrent agents). |
| 3 | +# |
| 4 | +# Replaces the ~100 lines of inline PowerShell the skill used to redefine on every |
| 5 | +# acquire. Each verb emits ONE line (or nothing) so a loop iteration spends a |
| 6 | +# handful of tokens on locking instead of dumping function bodies + heartbeat noise |
| 7 | +# into the transcript. |
| 8 | +# |
| 9 | +# The lock is a machine-level mutex that AUTO-EXPIRES after TTL (see $lockTtlSeconds, |
| 10 | +# 2h). Agents do NOT heartbeat it periodically — they just `release` when done, or let |
| 11 | +# it expire if they die. The cross-process claim that outlives the lock is GitHub's |
| 12 | +# in-progress label / open PR, so a lock expiring under a slow-but-live agent is |
| 13 | +# backstopped there; `renew` exists only for the rare unit that runs past the TTL. |
| 14 | +# |
| 15 | +# The acquiring agent's token is cached under a stable owner-identity namespace, then |
| 16 | +# keyed by lock name, so renew/release do NOT need the token passed back. Owner identity |
| 17 | +# resolves from -OwnerId, EAP_AGENT_LOCK_OWNER_ID, or CODEX_THREAD_ID (in that order). |
| 18 | +# Non-Codex callers must provide one of the explicit overrides; there is deliberately |
| 19 | +# no machine-wide fallback. Redis remains the sole ownership authority (token-checked |
| 20 | +# EVAL on release/renew); the state file is only the agent's private token cache, never |
| 21 | +# an ownership signal. A losing acquire prints HELD and writes nothing. |
| 22 | +# |
| 23 | +# Verbs: |
| 24 | +# acquire -LockName pr-1234 [-Worktree <path>] |
| 25 | +# stdout = token, exit 0 -> acquired (auto-expires after TTL) |
| 26 | +# stderr = "HELD", exit 3 -> already locked by someone; skip |
| 27 | +# release [-LockName pr-1234] |
| 28 | +# exit 0 (silent) -> released |
| 29 | +# stderr = "STALE", exit 5 -> token mismatch/expired; do NOT delete or |
| 30 | +# overwrite; just skip |
| 31 | +# renew -LockName pr-1234 [-Worktree <path>] (OPTIONAL — NOT periodic) |
| 32 | +# exit 0 (silent) -> TTL re-armed. Call only for a unit that may |
| 33 | +# run past the TTL, or once with -Worktree to |
| 34 | +# write the auto-derive marker. |
| 35 | +# stderr = "LOST", exit 4 -> ownership lost; abandon the item |
| 36 | +# status [-LockName pr-1234] |
| 37 | +# stdout = HELD-BY-ME | HELD | FREE, exit 0 |
| 38 | +# |
| 39 | +# Common failure exit: 1 (docker/redis unavailable, or no cached token for |
| 40 | +# renew/release). 2 = bad usage / lock name or owner identity could not be resolved. |
| 41 | +# |
| 42 | +# Lock-name resolution (renew/release/status): -LockName is OPTIONAL there. When |
| 43 | +# omitted it is resolved from, in order: (1) a marker persisted in the current |
| 44 | +# worktree's per-worktree git config (`agent.lockName`), which |
| 45 | +# `acquire`/`renew -Worktree` writes after enabling `extensions.worktreeConfig`; |
| 46 | +# then (2) an `issue-<N>-*` branch name -> `issue-<N>`. `acquire` still REQUIRES an |
| 47 | +# explicit name (or an `issue-<N>-*` branch): it runs before the worktree exists, and |
| 48 | +# a PR lock's number (`pr-<N>`) is not derivable from the branch (`issue-<M>-...`). |
| 49 | +# For PR locks either pass -LockName to release, or write the marker once via |
| 50 | +# `renew -Worktree` (or `acquire -Worktree` if the worktree already exists). |
| 51 | +# |
| 52 | +# Usage: |
| 53 | +# $token = pwsh scripts/AgentLocks.ps1 acquire -LockName pr-1234 |
| 54 | +# if ($LASTEXITCODE -ne 0) { <skip this item> } |
| 55 | +# ... do the unit of work — NO periodic refresh ... |
| 56 | +# pwsh scripts/AgentLocks.ps1 release -LockName pr-1234 |
| 57 | +# # optional, only if the unit may exceed the TTL: |
| 58 | +# pwsh scripts/AgentLocks.ps1 renew -LockName pr-1234 |
| 59 | +# # outside Codex, set one stable value for every command from the same agent: |
| 60 | +# $env:EAP_AGENT_LOCK_OWNER_ID = 'my-agent-run-id' |
| 61 | + |
| 62 | +[CmdletBinding()] |
| 63 | +param( |
| 64 | + [Parameter(Mandatory, Position = 0)] |
| 65 | + [ValidateSet('acquire', 'release', 'renew', 'status')] |
| 66 | + [string]$Verb, |
| 67 | + |
| 68 | + [string]$LockName = '', |
| 69 | + [string]$Worktree = '', |
| 70 | + [string]$OwnerId = '' |
| 71 | +) |
| 72 | + |
| 73 | +$ErrorActionPreference = 'Stop' |
| 74 | + |
| 75 | +$redisContainer = 'eap-agent-locks-redis' |
| 76 | +$lockTtlSeconds = 7200 # 2 hours: the dead-man's switch. Longer than nearly every |
| 77 | +# unit of work; a dead agent frees the item within this window. No periodic |
| 78 | +# heartbeat — use `renew` only for the rare unit that runs past it. |
| 79 | +$stateRoot = Join-Path ([System.IO.Path]::GetTempPath()) 'eap-agent-locks' |
| 80 | + |
| 81 | +function Die([int]$code, [string]$msg) { if ($msg) { [Console]::Error.WriteLine($msg) }; exit $code } |
| 82 | + |
| 83 | +function Get-WorktreeLockMarker { |
| 84 | + # `git config --worktree` aliases shared local config until this extension is |
| 85 | + # enabled. Skip legacy shared markers entirely so they cannot shadow issue branch |
| 86 | + # derivation; every marker written by this version enables the extension first. |
| 87 | + $enabled = (git config --local --bool --get extensions.worktreeConfig 2>$null) |
| 88 | + if ($LASTEXITCODE -ne 0 -or -not $enabled -or $enabled.Trim() -ne 'true') { return $null } |
| 89 | + |
| 90 | + $marker = (git config --worktree --get agent.lockName 2>$null) |
| 91 | + if ($LASTEXITCODE -eq 0 -and $marker) { return $marker.Trim() } |
| 92 | + return $null |
| 93 | +} |
| 94 | + |
| 95 | +function Set-WorktreeLockMarker([string]$Path) { |
| 96 | + # Linked worktrees share --local config. Enable the extension in that common |
| 97 | + # config, then persist the marker in the target worktree's config.worktree file. |
| 98 | + git -C $Path config --local extensions.worktreeConfig true 2>$null | Out-Null |
| 99 | + if ($LASTEXITCODE -ne 0) { return } |
| 100 | + git -C $Path config --worktree agent.lockName $LockName 2>$null | Out-Null |
| 101 | +} |
| 102 | + |
| 103 | +function Resolve-OwnerIdentity([string]$Explicit) { |
| 104 | + if (-not [string]::IsNullOrWhiteSpace($Explicit)) { return $Explicit.Trim() } |
| 105 | + if (-not [string]::IsNullOrWhiteSpace($env:EAP_AGENT_LOCK_OWNER_ID)) { |
| 106 | + return $env:EAP_AGENT_LOCK_OWNER_ID.Trim() |
| 107 | + } |
| 108 | + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_THREAD_ID)) { return $env:CODEX_THREAD_ID.Trim() } |
| 109 | + return $null |
| 110 | +} |
| 111 | + |
| 112 | +function Resolve-LockName([string]$Explicit) { |
| 113 | + if ($Explicit) { return $Explicit } |
| 114 | + # 1. marker persisted in the current worktree by a prior `acquire`/`renew -Worktree`. |
| 115 | + $marker = Get-WorktreeLockMarker |
| 116 | + if ($marker) { return $marker } |
| 117 | + # 2. derive issue-<N> from an issue-<N>-<desc> branch (never yields pr-<N>). |
| 118 | + $branch = (git rev-parse --abbrev-ref HEAD 2>$null) |
| 119 | + if ($LASTEXITCODE -eq 0 -and $branch -match '^issue-(\d+)-') { return "issue-$($Matches[1])" } |
| 120 | + return $null |
| 121 | +} |
| 122 | + |
| 123 | +$LockName = Resolve-LockName $LockName |
| 124 | +if (-not $LockName) { |
| 125 | + Die 2 "cannot resolve lock name: pass -LockName (e.g. pr-1234 / issue-1234), or run inside an issue-<N>-* worktree" |
| 126 | +} |
| 127 | + |
| 128 | +$OwnerId = Resolve-OwnerIdentity $OwnerId |
| 129 | +if (-not $OwnerId) { |
| 130 | + Die 2 'cannot resolve lock owner identity: pass -OwnerId or set EAP_AGENT_LOCK_OWNER_ID (CODEX_THREAD_ID is used automatically in Codex)' |
| 131 | +} |
| 132 | + |
| 133 | +$ownerIdBytes = [System.Text.Encoding]::UTF8.GetBytes($OwnerId) |
| 134 | +$ownerIdHash = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($ownerIdBytes)).ToLowerInvariant() |
| 135 | +$stateDir = Join-Path $stateRoot $ownerIdHash |
| 136 | +$lockKey = "eap:agent-lock:$LockName" |
| 137 | +$metaKey = "eap:agent-lock-meta:$LockName" |
| 138 | +$tokenFile = Join-Path $stateDir ("{0}.token" -f ($LockName -replace '[\\/:*?"<>| ]', '_')) |
| 139 | + |
| 140 | +function Ensure-AgentRedis { |
| 141 | + # One inspect tells missing vs stopped vs running: error exit = missing, |
| 142 | + # 'false' = stopped, 'true' = running. Warm path (the common loop case) costs |
| 143 | + # this single call — no separate `docker ps` or a redundant PING; the verb's |
| 144 | + # own Redis command is the connectivity check. |
| 145 | + $running = docker inspect -f '{{.State.Running}}' $redisContainer 2>$null |
| 146 | + if ($LASTEXITCODE -eq 0 -and $running -eq 'true') { return } |
| 147 | + |
| 148 | + if ($LASTEXITCODE -ne 0) { |
| 149 | + docker run -d --name $redisContainer redis:7-alpine redis-server --save '' --appendonly no | Out-Null |
| 150 | + } |
| 151 | + else { |
| 152 | + docker start $redisContainer | Out-Null |
| 153 | + } |
| 154 | + # Cold start only: the container may not be answering yet, so confirm. |
| 155 | + if ((docker exec $redisContainer redis-cli PING) -ne 'PONG') { |
| 156 | + Die 1 "redis PING failed on $redisContainer" |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +function Redis { (& docker exec $redisContainer redis-cli @args | Out-String).Trim() } |
| 161 | + |
| 162 | +function New-Meta([string]$Token) { |
| 163 | + @{ |
| 164 | + lockVersion = 4 |
| 165 | + lockBackend = 'redis' |
| 166 | + lockName = $LockName |
| 167 | + token = $Token |
| 168 | + pid = $PID |
| 169 | + startedAt = (Get-Date).ToString('o') |
| 170 | + lastRenewedAt = (Get-Date).ToString('o') |
| 171 | + worktree = $Worktree |
| 172 | + } | ConvertTo-Json -Compress |
| 173 | +} |
| 174 | + |
| 175 | +function Read-Token { |
| 176 | + $t = (Get-Content -LiteralPath $tokenFile -Raw -ErrorAction SilentlyContinue) |
| 177 | + if ($t) { $t.Trim() } else { $null } |
| 178 | +} |
| 179 | + |
| 180 | +Ensure-AgentRedis |
| 181 | + |
| 182 | +switch ($Verb) { |
| 183 | + |
| 184 | + 'acquire' { |
| 185 | + $token = [Guid]::NewGuid().ToString('N') |
| 186 | + # Lock + meta set atomically in one round-trip: SET NX, then write meta only |
| 187 | + # if we won the lock. Returns 1 on acquire, 0 if already held. |
| 188 | + $script = "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[3]) then redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3]); return 1; else return 0; end" |
| 189 | + if ((Redis EVAL $script 2 $lockKey $metaKey $token (New-Meta $token) $lockTtlSeconds) -ne '1') { Die 3 'HELD' } |
| 190 | + New-Item -ItemType Directory -Force $stateDir | Out-Null |
| 191 | + Set-Content -LiteralPath $tokenFile -Value $token -NoNewline |
| 192 | + # If the worktree already exists at acquire time, write the auto-derive marker. |
| 193 | + if ($Worktree) { Set-WorktreeLockMarker $Worktree } |
| 194 | + Write-Output $token |
| 195 | + exit 0 |
| 196 | + } |
| 197 | + |
| 198 | + 'renew' { |
| 199 | + # OPTIONAL — not periodic. Re-arm the TTL for a unit that may outlive it, and/or |
| 200 | + # persist the auto-derive marker via -Worktree. Skip entirely for normal units. |
| 201 | + $token = Read-Token |
| 202 | + if (-not $token) { Die 1 "no cached token for $LockName" } |
| 203 | + # Re-arm TTL on lock + meta only if we still own the token. |
| 204 | + $script = "if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('EXPIRE', KEYS[1], ARGV[2]); redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[2]); return 1; else return 0; end" |
| 205 | + if ((Redis EVAL $script 2 $lockKey $metaKey $token $lockTtlSeconds (New-Meta $token)) -ne '1') { |
| 206 | + Die 4 'LOST' |
| 207 | + } |
| 208 | + if ($Worktree) { Set-WorktreeLockMarker $Worktree } |
| 209 | + exit 0 |
| 210 | + } |
| 211 | + |
| 212 | + 'release' { |
| 213 | + $token = Read-Token |
| 214 | + if (-not $token) { Die 1 "no cached token for $LockName" } |
| 215 | + # Delete lock + meta only if we still own the token; never clobber otherwise. |
| 216 | + $script = "if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[2]); return redis.call('DEL', KEYS[1]); else return 0; end" |
| 217 | + $released = Redis EVAL $script 2 $lockKey $metaKey $token |
| 218 | + Remove-Item -LiteralPath $tokenFile -ErrorAction SilentlyContinue |
| 219 | + if ($released -ne '1') { Die 5 'STALE' } |
| 220 | + exit 0 |
| 221 | + } |
| 222 | + |
| 223 | + 'status' { |
| 224 | + $held = Redis GET $lockKey |
| 225 | + if (-not $held) { Write-Output 'FREE'; exit 0 } |
| 226 | + if ($held -eq (Read-Token)) { Write-Output 'HELD-BY-ME' } else { Write-Output 'HELD' } |
| 227 | + exit 0 |
| 228 | + } |
| 229 | +} |
0 commit comments