Skip to content

Commit 1ef5ef6

Browse files
committed
fix: serialize issue claims and paginate reviews
1 parent 28d3188 commit 1ef5ef6

7 files changed

Lines changed: 149 additions & 11 deletions

File tree

loops/issue-dev-loop/LOOP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Use a combo trigger. Run `triggers/detect-work.mjs` before starting a Codex impl
6565

6666
### 1. Claim and snapshot
6767

68-
Recheck the issue immediately before mutation. `loopctl start` atomically reserves the issue locally, rechecks GitHub, applies `loop:claimed`, rejects another active run or open issue branch PR, and then creates the run. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body.
68+
Recheck the issue immediately before mutation. `loopctl start` atomically creates the remote `codex/issue-N` branch at the verified base SHA as the cross-worktree reservation, then applies `loop:claimed` and creates the local run. GitHub permits only one creator for a new ref, so a concurrent loser exits before any checkpoint is published. Also reject another active run or open issue branch PR. Capture the issue title/body/labels/URL, base SHA, and acceptance criteria. Use one run ID across logs, handoffs, `screen-shots`, evidence, review comments, notifications, branch metadata, and the PR body.
6969

7070
### 2. Isolate
7171

loops/issue-dev-loop/scripts/lib/evidence.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ export async function recordReview({
549549
const roundEndpoint = `repos/${roundTarget.owner}/${roundTarget.repo}/pulls/${roundTarget.number}/reviews/${roundTarget.reviewId}`
550550
const [publishedRound, roundComments] = await Promise.all([
551551
githubApi(roundEndpoint),
552-
githubApi(`${roundEndpoint}/comments?per_page=100`),
552+
paginateGitHubApi(githubApi, `${roundEndpoint}/comments`),
553553
])
554554
const bodies = [
555555
publishedRound.body ?? '',

loops/issue-dev-loop/scripts/lib/github-identity.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,17 @@ function automationApiMutationAllowed(
822822
authorization,
823823
) {
824824
if (!endpoint || usesInput || usesFileExpansion) return false
825+
if (
826+
endpoint.match(/^repos\/[^/]+\/[^/]+\/git\/refs$/) &&
827+
method === 'POST' &&
828+
authorization?.rootIntent === 'start' &&
829+
authorization?.issue?.status === 'starting'
830+
) {
831+
return sameArguments(fields, [
832+
`ref=refs/heads/${authorization.issue.branch}`,
833+
`sha=${authorization.issue.baseSha}`,
834+
])
835+
}
825836
const labels = endpoint.match(/^repos\/[^/]+\/[^/]+\/issues\/(\d+)\/labels(?:\/([^/]+))?$/)
826837
if (labels && Number(labels[1]) === authorization?.issue?.issueNumber) {
827838
if (method === 'POST') {
@@ -1333,10 +1344,12 @@ function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) {
13331344
}
13341345
const issueNumber = Number(argumentAfter(args, '--issue'))
13351346
const issueUrl = argumentAfter(args, '--url')
1347+
const baseSha = argumentAfter(args, '--base-sha')
13361348
const target = parseGitHubTarget(issueUrl)
13371349
if (
13381350
!Number.isInteger(issueNumber) ||
13391351
issueNumber < 1 ||
1352+
!/^[0-9a-f]{40}$/i.test(baseSha ?? '') ||
13401353
target?.kind !== 'issues' ||
13411354
target.number !== issueNumber ||
13421355
!repositoryInScope(`${target.owner}/${target.repo}`, authorization.expectedRepository)
@@ -1351,6 +1364,7 @@ function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) {
13511364
prNumber: null,
13521365
runId: null,
13531366
status: 'starting',
1367+
baseSha: baseSha.toLowerCase(),
13541368
headSha: null,
13551369
implementationCommit: null,
13561370
},

loops/issue-dev-loop/scripts/lib/issue-claim.mjs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ async function defaultAddLabel({ target, issueNumber }) {
2424
)
2525
}
2626

27+
async function defaultReserveRemoteBranch({ target, issueNumber, baseSha }) {
28+
await execFileAsync(
29+
'gh',
30+
[
31+
'api',
32+
`repos/${target.owner}/${target.repo}/git/refs`,
33+
'--method',
34+
'POST',
35+
'-f',
36+
`ref=refs/heads/codex/issue-${issueNumber}`,
37+
'-f',
38+
`sha=${baseSha}`,
39+
],
40+
{ maxBuffer: 1024 * 1024 },
41+
)
42+
}
43+
2744
async function defaultRemoteBranchExists({ target, issueNumber }) {
2845
try {
2946
await execFileAsync(
@@ -55,15 +72,20 @@ export async function defaultClaimIssue({
5572
loopRoot = DEFAULT_LOOP_ROOT,
5673
issueUrl,
5774
issueNumber,
75+
baseSha,
5876
githubApi = defaultGitHubApi,
5977
githubPaginatedApi = defaultGitHubPaginatedApi,
6078
addLabel = defaultAddLabel,
79+
reserveRemoteBranch = defaultReserveRemoteBranch,
6180
remoteBranchExists = defaultRemoteBranchExists,
6281
}) {
6382
const target = parseGitHubTarget(issueUrl)
6483
if (!target || target.kind !== 'issues' || target.number !== issueNumber) {
6584
throw new Error('issueUrl must identify the issue being claimed')
6685
}
86+
if (!/^[0-9a-f]{40}$/i.test(baseSha ?? '')) {
87+
throw new Error('issue claim requires a full base SHA')
88+
}
6789
const issue = await githubApi(`repos/${target.owner}/${target.repo}/issues/${issueNumber}`)
6890
const labels = labelNames(issue)
6991
if (issue.state !== 'open' || !labels.has('codex-ready') || labels.has('loop:claimed')) {
@@ -78,9 +100,13 @@ export async function defaultClaimIssue({
78100
if (pulls.some((pullRequest) => pullRequestClaimsIssue(pullRequest, issueNumber))) {
79101
throw new Error(`an open pull request already claims issue ${issueNumber}`)
80102
}
81-
if (addLabel === defaultAddLabel) {
103+
if (
104+
addLabel === defaultAddLabel ||
105+
reserveRemoteBranch === defaultReserveRemoteBranch
106+
) {
82107
await assertAutomationIdentity({ loopRoot, githubApi })
83108
}
109+
await reserveRemoteBranch({ target, issueNumber, baseSha })
84110
await addLabel({ target, issueNumber })
85111
return issue
86112
}

loops/issue-dev-loop/scripts/lib/run-store.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export async function startRun({
166166
issueUrl: url,
167167
issueNumber: issue,
168168
branch: `codex/issue-${issue}`,
169+
baseSha: normalizedBaseSha,
169170
githubApi,
170171
})
171172
remoteClaimCreated = true

loops/issue-dev-loop/tests/github-identity-routing.test.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,23 @@ if (commandArguments[0] === 'spawn') {
328328
} else if (commandArguments[0] === 'start') {
329329
const issueIndex = commandArguments.indexOf('--issue')
330330
const issue = commandArguments[issueIndex + 1]
331+
const baseShaIndex = commandArguments.indexOf('--base-sha')
332+
const baseSha = commandArguments[baseShaIndex + 1]
333+
const reserveIssueIndex = commandArguments.indexOf('--reserve-issue')
334+
const reserveIssue =
335+
reserveIssueIndex === -1 ? issue : commandArguments[reserveIssueIndex + 1]
331336
for (const command of [
332337
['api', 'user'],
338+
[
339+
'api',
340+
'repos/example/repo/git/refs',
341+
'--method',
342+
'POST',
343+
'-f',
344+
\`ref=refs/heads/codex/issue-\${reserveIssue}\`,
345+
'-f',
346+
\`sha=\${baseSha}\`,
347+
],
333348
['api', \`repos/example/repo/issues/\${issue}/labels\`, '--method', 'POST', '-f', 'labels[]=loop:claimed']
334349
]) {
335350
const result = spawnSync('gh', command, { env: process.env, stdio: 'inherit' })
@@ -932,15 +947,49 @@ test('routed loopctl start may claim only its command-line issue', async () => {
932947
'123',
933948
'--url',
934949
'https://github.com/example/repo/issues/123',
950+
'--base-sha',
951+
'0'.repeat(40),
935952
'--loop-root',
936953
fixture.loopRoot,
937954
],
938955
{ env: fixture.env },
939956
)
940957
assert.match(stdout, /executor-user/)
958+
assert.match(stdout, /git\/refs/)
941959
assert.match(stdout, /issues\/123\/labels/)
942960
})
943961

962+
test('routed loopctl start rejects a remote reservation for another issue', async () => {
963+
const fixture = await createFixture({ activeRun: false })
964+
await assert.rejects(
965+
execFileAsync(
966+
process.execPath,
967+
[
968+
routerPath,
969+
'--loop-root',
970+
fixture.loopRoot,
971+
'automation',
972+
'--',
973+
process.execPath,
974+
fixture.loopctlPath,
975+
'start',
976+
'--issue',
977+
'123',
978+
'--url',
979+
'https://github.com/example/repo/issues/123',
980+
'--base-sha',
981+
'0'.repeat(40),
982+
'--reserve-issue',
983+
'124',
984+
'--loop-root',
985+
fixture.loopRoot,
986+
],
987+
{ env: fixture.env },
988+
),
989+
/GitHub action is prohibited for the automation role/,
990+
)
991+
})
992+
944993
test('reviewer identity cannot push code', async () => {
945994
const fixture = await createFixture()
946995
await assert.rejects(

loops/issue-dev-loop/tests/runtime.test.mjs

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,7 @@ test('authoritative claim rejects any paginated open PR that references the issu
11541154
defaultClaimIssue({
11551155
issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128',
11561156
issueNumber: 128,
1157+
baseSha: '0'.repeat(40),
11571158
githubApi: async () => ({
11581159
number: 128,
11591160
title: 'Issue',
@@ -1176,6 +1177,7 @@ test('authoritative claim rejects any paginated open PR that references the issu
11761177
defaultClaimIssue({
11771178
issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128',
11781179
issueNumber: 128,
1180+
baseSha: '0'.repeat(40),
11791181
githubApi: async () => ({
11801182
number: 128,
11811183
title: 'Issue',
@@ -1193,6 +1195,41 @@ test('authoritative claim rejects any paginated open PR that references the issu
11931195
assert.equal(labelAdded, false)
11941196
})
11951197

1198+
test('authoritative claim atomically reserves one remote issue branch across starters', async () => {
1199+
let reservationCreated = false
1200+
let labelCount = 0
1201+
const claim = () =>
1202+
defaultClaimIssue({
1203+
issueUrl: 'https://github.com/codeacme17/echo-ui/issues/128',
1204+
issueNumber: 128,
1205+
baseSha: '0'.repeat(40),
1206+
githubApi: async () => ({
1207+
number: 128,
1208+
title: 'Issue',
1209+
state: 'open',
1210+
labels: [{ name: 'codex-ready' }],
1211+
}),
1212+
githubPaginatedApi: async () => [],
1213+
remoteBranchExists: async () => false,
1214+
reserveRemoteBranch: async () => {
1215+
if (reservationCreated) throw new Error('remote ref already exists')
1216+
reservationCreated = true
1217+
},
1218+
addLabel: async () => {
1219+
labelCount += 1
1220+
},
1221+
})
1222+
1223+
const outcomes = await Promise.allSettled([claim(), claim()])
1224+
assert.equal(outcomes.filter(({ status }) => status === 'fulfilled').length, 1)
1225+
assert.equal(outcomes.filter(({ status }) => status === 'rejected').length, 1)
1226+
assert.match(
1227+
outcomes.find(({ status }) => status === 'rejected').reason.message,
1228+
/remote ref already exists/,
1229+
)
1230+
assert.equal(labelCount, 1)
1231+
})
1232+
11961233
test('startRun creates one correlated run, handoff, and evidence directories', async () => {
11971234
const { loopRoot } = await createFixture()
11981235
let workspaceValidation
@@ -2757,7 +2794,7 @@ test('review gate verifies published findings and classified replies', async ()
27572794
}),
27582795
]
27592796
}
2760-
if (endpoint.endsWith('/reviews/499/comments?per_page=100')) {
2797+
if (endpoint.endsWith('/reviews/499/comments?per_page=100&page=1')) {
27612798
return [
27622799
{
27632800
user: { login: 'echo-ui-reviewer[bot]' },
@@ -2775,7 +2812,7 @@ test('review gate verifies published findings and classified replies', async ()
27752812
},
27762813
]
27772814
}
2778-
if (endpoint.endsWith('/comments?per_page=100')) return []
2815+
if (endpoint.endsWith('/comments?per_page=100&page=1')) return []
27792816
if (endpoint.includes('/issues/comments/400')) {
27802817
return {
27812818
user: { login: 'echo-ui-loop[bot]' },
@@ -2880,6 +2917,7 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn
28802917
}),
28812918
/include every reviewer publication/,
28822919
)
2920+
let secondCommentPageFetched = false
28832921
await assert.rejects(
28842922
recordReview({
28852923
loopRoot,
@@ -2897,13 +2935,22 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn
28972935
}),
28982936
]
28992937
}
2900-
if (endpoint.endsWith('/comments?per_page=100')) {
2938+
if (endpoint.endsWith('/comments?per_page=100&page=1')) {
2939+
return Array.from({ length: 100 }, (_, index) => ({
2940+
user: { login: 'echo-ui-reviewer[bot]' },
2941+
path: 'src/context.ts',
2942+
line: index + 1,
2943+
body: `Reviewer context ${index + 1}`,
2944+
}))
2945+
}
2946+
if (endpoint.endsWith('/comments?per_page=100&page=2')) {
2947+
secondCommentPageFetched = true
29012948
return [
29022949
{
29032950
user: { login: 'echo-ui-reviewer[bot]' },
29042951
path: 'src/untracked.ts',
2905-
line: 3,
2906-
body: 'This inline finding was omitted and has no stable ID.',
2952+
line: 101,
2953+
body: 'RVW-1-1-1: omitted finding beyond the first page.',
29072954
},
29082955
]
29092956
}
@@ -2921,8 +2968,9 @@ test('review gate rejects GitHub findings omitted from the durable result', asyn
29212968
}
29222969
},
29232970
}),
2924-
/unrecorded reviewer inline comment/,
2971+
/unrecorded findings/,
29252972
)
2973+
assert.equal(secondCommentPageFetched, true)
29262974
})
29272975

29282976
test('accepted review fix must be after the finding head and inside the final head', async () => {
@@ -3039,7 +3087,7 @@ test('accepted review fix must be after the finding head and inside the final he
30393087
}),
30403088
]
30413089
}
3042-
if (endpoint.endsWith('/comments?per_page=100')) return []
3090+
if (endpoint.endsWith('/comments?per_page=100&page=1')) return []
30433091
if (endpoint.includes('/issues/comments/410')) {
30443092
return {
30453093
user: { login: 'echo-ui-loop[bot]' },
@@ -3168,7 +3216,7 @@ test('review gate binds high-severity adjudication verdict to the correct identi
31683216
},
31693217
]
31703218
}
3171-
if (endpoint.endsWith('/comments?per_page=100')) return []
3219+
if (endpoint.endsWith('/comments?per_page=100&page=1')) return []
31723220
if (endpoint.includes('/issues/comments/401')) {
31733221
return {
31743222
user: { login: 'echo-ui-loop[bot]' },

0 commit comments

Comments
 (0)