Skip to content

Commit 70e43dc

Browse files
committed
chore: add issue-pr-loop skill and agent scripts from Dekaf
Copy the autonomous issue/PR loop skill, /issue-pr-loop command, and supporting scripts (Redis agent locks, fail-closed PR merge gate, merged-worktree cleanup) and adapt them for this repo: eap-prefixed lock container/keys/env var, EnumerableAsyncProcessor worktree root, TUnit test commands, and repo-specific gotchas.
1 parent 96afa0e commit 70e43dc

12 files changed

Lines changed: 3126 additions & 0 deletions

.claude/commands/issue-pr-loop.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
description: Loop on open issues/PRs autonomously until stopped
3+
---
4+
5+
Invoke the `issue-pr-loop` skill and start the loop.
6+
7+
Continue iterating (maintain open PRs first, then pick up the next issue) until I tell you to stop or there is nothing actionable left.

.claude/skills/issue-pr-loop/SKILL.md

Lines changed: 480 additions & 0 deletions
Large diffs are not rendered by default.

scripts/AgentLocks.ps1

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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+
}

scripts/Assert-PrGreen.ps1

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Assert-PrGreen.ps1
2+
# Fail-closed merge gate for the issue-pr-loop skill.
3+
#
4+
# Exits 0 ONLY when PR #<N> is provably safe to merge:
5+
# - state OPEN
6+
# - mergeable == MERGEABLE
7+
# - mergeStateStatus == CLEAN
8+
# - every status check is terminal AND passing (CheckRun: status=COMPLETED and
9+
# conclusion in SUCCESS/SKIPPED/NEUTRAL; StatusContext: state=SUCCESS)
10+
# - no current-head top-level review has actionable finding headings
11+
# - no unresolved review threads
12+
#
13+
# Any other state — pending/queued/in-progress checks, an empty rollup, a failed
14+
# check, an unresolved thread, or an API hiccup — exits 1 so the caller MUST NOT
15+
# merge. The default on uncertainty is DENY.
16+
#
17+
# Why this exists: the repo is on a tier WITHOUT branch protection, so GitHub
18+
# cannot enforce "green before merge" itself. This script is the deterministic
19+
# gate the prose rules alone cannot guarantee — the skill calls it immediately
20+
# before `gh pr merge`, and only merges if it exits 0.
21+
#
22+
# Usage: pwsh scripts/Assert-PrGreen.ps1 -Pr 1234 [-Repo owner/name]
23+
# Exit: 0 = safe to merge; 1 = DO NOT MERGE (reason printed to stderr).
24+
25+
[CmdletBinding()]
26+
param(
27+
[Parameter(Mandatory)][int]$Pr,
28+
[string]$Repo
29+
)
30+
31+
$ErrorActionPreference = 'Stop'
32+
. (Join-Path $PSScriptRoot 'AssertPrGreenReviewHeuristics.ps1')
33+
34+
function Deny([string]$reason) {
35+
[Console]::Error.WriteLine("DO NOT MERGE #${Pr} -- $reason")
36+
exit 1
37+
}
38+
39+
$repoArgs = @()
40+
if ($Repo) { $repoArgs = @('--repo', $Repo) }
41+
42+
# Re-fetch fresh — survey output goes stale within seconds.
43+
$raw = gh pr view $Pr @repoArgs --json number,state,mergeable,mergeStateStatus,statusCheckRollup,latestReviews,commits 2>$null
44+
if ($LASTEXITCODE -ne 0) { Deny "gh pr view failed (exit $LASTEXITCODE)" }
45+
try {
46+
$view = $raw | ConvertFrom-Json
47+
}
48+
catch {
49+
Deny "could not parse PR state: $($_.Exception.Message)"
50+
}
51+
52+
if ($view.state -ne 'OPEN') { Deny "state=$($view.state) (need OPEN)" }
53+
if ($view.mergeable -ne 'MERGEABLE') { Deny "mergeable=$($view.mergeable) (need MERGEABLE)" }
54+
if ($view.mergeStateStatus -ne 'CLEAN') { Deny "mergeStateStatus=$($view.mergeStateStatus) (need CLEAN)" }
55+
56+
$checks = @($view.statusCheckRollup | Where-Object { $null -ne $_ })
57+
if ($checks.Count -eq 0) {
58+
Deny "statusCheckRollup is empty -- no checks registered yet; refusing to merge blind"
59+
}
60+
61+
$allowedConclusions = @('SUCCESS', 'SKIPPED', 'NEUTRAL')
62+
$bad = @()
63+
foreach ($c in $checks) {
64+
if ($null -ne $c.status) {
65+
# CheckRun: must be terminal AND passing.
66+
if ($c.status -ne 'COMPLETED') {
67+
$bad += "$($c.name): status=$($c.status)"
68+
continue
69+
}
70+
if ($allowedConclusions -notcontains $c.conclusion) {
71+
$bad += "$($c.name): conclusion=$($c.conclusion)"
72+
}
73+
}
74+
elseif ($null -ne $c.state) {
75+
# StatusContext: state must be SUCCESS.
76+
if ($c.state -ne 'SUCCESS') {
77+
$bad += "$($c.context): state=$($c.state)"
78+
}
79+
}
80+
else {
81+
$bad += "unrecognized check entry: $($c | ConvertTo-Json -Compress)"
82+
}
83+
}
84+
if ($bad.Count -gt 0) {
85+
Deny ("checks not green: " + ($bad -join '; '))
86+
}
87+
88+
$headCommit = @($view.commits | Where-Object { $null -ne $_ }) | Select-Object -Last 1
89+
$headCommittedAt = if ($headCommit) { ConvertTo-UtcDateTimeOffset $headCommit.committedDate } else { $null }
90+
91+
# Top-level review comments are not review threads, so GitHub does not expose a
92+
# resolved flag for them. Fail closed on common review-finding shapes instead
93+
# of treating a COMMENTED review as automatically safe. A stale Claude review
94+
# from before the current head commit is ignored only when the current-head
95+
# claude-review check completed green.
96+
$latestReviews = @($view.latestReviews | Where-Object { $null -ne $_ })
97+
$requestedChanges = @($latestReviews | Where-Object { $_.state -eq 'CHANGES_REQUESTED' })
98+
if ($requestedChanges.Count -gt 0) {
99+
$authors = ($requestedChanges | ForEach-Object { $_.author.login }) -join ', '
100+
Deny "latest review requests changes from: $authors"
101+
}
102+
103+
$actionableReviews = @()
104+
foreach ($review in $latestReviews) {
105+
if ($review.state -ne 'COMMENTED') { continue }
106+
if (Test-StaleBotReviewCanBeIgnored -Review $review -HeadCommitCommittedAt $headCommittedAt -Checks $checks) {
107+
continue
108+
}
109+
110+
$body = [string]$review.body
111+
$reason = Get-ActionableReviewBodyReason -Body $body
112+
if ($reason) {
113+
$actionableReviews += "$($review.author.login): $reason"
114+
}
115+
}
116+
if ($actionableReviews.Count -gt 0) {
117+
Deny ("latest top-level review comment has actionable finding(s): " + ($actionableReviews -join '; '))
118+
}
119+
120+
# Unresolved review threads block merge even when a review's state is COMMENTED.
121+
if ($Repo) {
122+
$owner, $name = $Repo -split '/', 2
123+
}
124+
else {
125+
$nwo = gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null
126+
if ($LASTEXITCODE -ne 0 -or -not $nwo) { Deny "could not resolve repo for review-thread check" }
127+
$owner, $name = $nwo.Trim() -split '/', 2
128+
}
129+
130+
$query = 'query($owner:String!,$repo:String!,$pr:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage} nodes{isResolved}}}}}'
131+
$threadsRaw = gh api graphql -f query=$query -F owner=$owner -F repo=$name -F pr=$Pr 2>$null
132+
if ($LASTEXITCODE -ne 0) { Deny "could not fetch review threads (exit $LASTEXITCODE)" }
133+
try {
134+
$reviewThreads = ($threadsRaw | ConvertFrom-Json).data.repository.pullRequest.reviewThreads
135+
}
136+
catch {
137+
Deny "could not parse review threads: $($_.Exception.Message)"
138+
}
139+
# More than one page means we cannot see every thread; deny rather than pass blind.
140+
if ($reviewThreads.pageInfo.hasNextPage) { Deny "more than 100 review threads -- too many to inspect safely" }
141+
$unresolved = @($reviewThreads.nodes | Where-Object { -not $_.isResolved }).Count
142+
if ($unresolved -gt 0) { Deny "$unresolved unresolved review thread(s)" }
143+
144+
Write-Host "OK #${Pr} -- MERGEABLE, CLEAN, $($checks.Count) check(s) green, no unresolved threads. Safe to merge."
145+
exit 0

0 commit comments

Comments
 (0)