Skip to content

Commit e25b212

Browse files
committed
fix: make review and claim proof atomic
1 parent 792dc80 commit e25b212

13 files changed

Lines changed: 186 additions & 74 deletions

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 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. If labeling fails, release the reservation only after verifying it still points to the exact base SHA. If a process crash nevertheless leaves a branch without a resumable checkpoint or open PR, the next trigger returns `claim_recovery`, wakes the loop, and immediately escalates the issue and branch to the owner instead of silently excluding it; never delete or reuse that reservation without explicit owner confirmation. 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.
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. If labeling fails, release the reservation with a single atomic `--force-with-lease=<exact-base-sha>` ref deletion; an advanced branch fails the lease and is never deleted. If a process crash nevertheless leaves a branch without a resumable checkpoint or open PR, the next trigger returns `claim_recovery`, wakes the loop, and immediately escalates the issue and branch to the owner instead of silently excluding it; never delete or reuse that reservation without explicit owner confirmation. 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/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Push only the issue branch and create a **draft** PR targeting `dev` using `temp
4343

4444
After the draft PR exists, spawn the project agent `echo_ui_pr_reviewer` with a fresh context. Give it only the issue snapshot, acceptance criteria, repository instructions, base SHA, head SHA, diff, CI results, and evidence manifest. Do not give it executor conversation history or rationale.
4545

46-
Publish every round through the installed wrapper with role `reviewer`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, and expected resolution. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. Follow `review/REVIEW.md` and `review/response-policy.md`.
46+
Publish every round through the installed wrapper with role `reviewer`; the executor identity may not author it. Every PR write must include `--repo codeacme17/echo-ui` (or use the full configured PR URL). Use `gh pr review --comment --body <body>` for a body-only review and include the exact run/cycle/round/head marker; the gate rejects body files, skipped rounds, duplicates, and publications outside the next durable cycle. When file/line findings require inline comments, use only the reviewer's gated `POST repos/codeacme17/echo-ui/pulls/<recorded-pr>/reviews` API shape with `event=COMMENT`, the exact durable head, and validated inline fields. Post findings verbatim as one review plus inline comments. Each finding needs a run-wide stable ID, severity, confidence, evidence, expected resolution, and the published GitHub inline comment ID or `null` for a body-only finding. Record the GitHub review URL for each round. Accepted repairs must start after that round was submitted, and executor replies must be posted through the automation wrapper after the corresponding `$implement` invocation finishes. One executor response comment may resolve only one finding. Follow `review/REVIEW.md` and `review/response-policy.md`.
4747

4848
The executor must classify every finding as `accepted`, `rejected`, `needs-human`, `stale`, or `already-fixed`:
4949

loops/issue-dev-loop/agents/echo-ui-pr-reviewer.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ package compatibility, Web Audio lifecycle behavior, React behavior, accessibili
99
security, regressions, and missing tests. Ignore executor conversation history and
1010
do not modify files. Avoid style-only or speculative findings. Return a structured
1111
review with run-wide `RVW-<cycle>-<round>-<sequence>` IDs, severity P0-P3, confidence, file and line, concrete
12-
evidence, reproduction when possible, and expected resolution. Return PASS when no
12+
evidence, reproduction when possible, expected resolution, and the GitHub inline comment ID after
13+
publication (`null` for body-only findings). Return PASS when no
1314
actionable findings exist. Publish GitHub reviews only through the installed executable
1415
`"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root
1516
"$ECHO_UI_LOOP_TARGET_ROOT" reviewer -- ...` launcher; the wrapper must verify the

loops/issue-dev-loop/references/github-operations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The shell launcher removes Node preload hooks before starting the router. The ro
2424

2525
1. Select only open issues labeled `codex-ready`.
2626
2. Exclude `loop:claimed` and any open PR that references or closes the issue. An existing `codex/issue-<number>` branch without a resumable checkpoint or open PR is `workType: claim_recovery`, not a quiet no-op: notify the owner and require confirmation before cleanup or reuse.
27-
3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, atomically create `codex/issue-<number>` at that base SHA, apply `loop:claimed`, and capture the authoritative issue; do not add the label or remote branch manually first. If labeling fails, the trusted start command verifies that the ref is still at the reservation SHA and releases it. A crash that leaves only the remote ref is detected on the next wake and escalated instead of being silently skipped.
27+
3. Let `loopctl start --base-sha <full-origin-dev-sha>` acquire the local claim, recheck every page of open PRs, atomically create `codex/issue-<number>` at that base SHA, apply `loop:claimed`, and capture the authoritative issue; do not add the label or remote branch manually first. If labeling fails, the trusted start command uses one exact `--force-with-lease` deletion, so an advanced ref fails atomically instead of being removed. A crash that leaves only the remote ref is detected on the next wake and escalated instead of being silently skipped.
2828
4. Record the issue snapshot and claim timestamp before implementation. A second active local run for the issue is rejected.
2929
5. Immediately publish and validate an active checkpoint after the claim. Repeat after each durable phase; the next phase re-fetches the exact state-journal comment and refuses to advance on local-only proof. On a fresh wake, `reconcile` returns `workType: resume`, branch, and expected head before considering a new issue. Fetch that branch, create a clean isolated worktree at the exact head, then run `restore-checkpoint`; restoration blocks on the wrong branch, head, or dirty state.
3030

@@ -49,7 +49,7 @@ The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only wh
4949
"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run download <run-database-id> --name issue-dev-loop-<run-id>-<head-sha> --dir "$ECHO_UI_LOOP_TARGET_ROOT/evidence/<run-id>"
5050
```
5151

52-
Push only through the installed `with-github-identity ... automation -- git push ...` route. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`.
52+
Push only through the installed `with-github-identity ... automation -- git push ...` route. The wrapper rejects reviewer pushes, force pushes, and explicit pushes to `dev` or `main`; the only lease-bearing exception is the trusted `start` command's exact-base atomic deletion of its just-created claim reservation after labeling fails.
5353

5454
Use the artifact URL emitted by `actions/upload-artifact` as `record-evidence --publication-url` and the downloaded artifact manifest as `--manifest`. From a worktree whose `HEAD` is the artifact's exact candidate head, the installed runtime first validates the protected diff, then independently downloads the artifact, requires a successful low-privilege `pull_request` run of the named unchanged workflow, validates workflow-run SHA and owner-merged base SHA, and byte-compares the manifest. Reject a run whose PR, base, branch, candidate head, workflow path, or manifest digests differ from the recorded run.
5555

loops/issue-dev-loop/review/REVIEW.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Return `PASS` or actionable findings. Each finding must include:
1414
- severity `P0`, `P1`, `P2`, or `P3`
1515
- confidence `high`, `medium`, or `low`
1616
- file and line when applicable
17+
- GitHub inline comment ID after publication, or `null` for a body-only finding
1718
- concrete impact and evidence
1819
- reproduction or failing test when practical
1920
- expected resolution
@@ -27,7 +28,7 @@ The fresh reviewer publishes each round verbatim through `reviewerGitHubLogin` a
2728

2829
Before the final round is published, write the complete cycle result with a temporary/unassigned final `reviewUrl` and run the installed `loopctl review-digest --result <path>`. Put its returned `<!-- issue-dev-loop:<run-id>:review-result-sha256:<digest> -->` marker in the final body. After GitHub assigns the review URL, replace the temporary value and rerun `review-digest`; it must be unchanged. This canonical publication digest replaces only review URLs with a fixed placeholder and therefore avoids a URL↔digest cycle. The later recorded full-file digest still protects the final URLs and every other byte. The reviewer must not downgrade severity or omit findings.
2930

30-
After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its final classification, response URL, and evidence. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner COMMENT publication containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`. Independent `REJECT_FINDING` adjudication is a separate body-only, non-approving GitHub review published through the reviewer identity; it is bound to an existing current-head reviewer finding, cannot be duplicated, and does not consume a review cycle round. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate.
31+
After all replies are posted, create one cycle result matching `result.schema.json`. Its `cycle` is the next durable review cycle number, and it contains every round's review URL, every finding, its GitHub `inlineCommentId` (or `null` for a body-only finding), final classification, unique response URL, and evidence. Each fetched inline comment ID/path/line/body must bind exactly one durable finding, and one executor response comment may classify only one finding. Executor replies include both the readable evidence (and accepted fix SHA) plus `<!-- issue-dev-loop:<run-id>:<finding-id>:<classification> -->`. Rejected P0/P1 findings additionally require an independent or owner COMMENT publication containing `<!-- issue-dev-loop:<run-id>:<finding-id>:adjudication:<verdict>:head:<sha> -->`. Independent `REJECT_FINDING` adjudication is a separate body-only, non-approving GitHub review published through the reviewer identity; it is bound to an existing current-head reviewer finding, cannot be duplicated, and does not consume a review cycle round. Run `record-review` with the final GitHub review URL; it paginates every reviewer-authored review on the recorded PR and requires one-to-one membership for the current run/cycle before it verifies replies, identities, timestamps, ancestry, adjudications, and markers. The publication gate rejects skipped or duplicate cycle rounds. Generic events cannot forge this reserved gate.
3132

3233
## Completion
3334

loops/issue-dev-loop/review/result.schema.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"severity",
4444
"confidence",
4545
"headSha",
46+
"inlineCommentId",
4647
"problem",
4748
"evidence",
4849
"expectedResolution",
@@ -57,6 +58,7 @@
5758
"severity": { "enum": ["P0", "P1", "P2", "P3"] },
5859
"confidence": { "enum": ["high", "medium", "low"] },
5960
"headSha": { "type": "string", "pattern": "^[0-9a-fA-F]{40}$" },
61+
"inlineCommentId": { "type": ["integer", "null"], "minimum": 1 },
6062
"path": { "type": ["string", "null"] },
6163
"line": { "type": ["integer", "null"], "minimum": 1 },
6264
"problem": { "type": "string", "minLength": 1 },

loops/issue-dev-loop/scripts/github-command-gate.mjs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,29 @@ async function main() {
4141
: args
4242
if (tool !== 'credential') {
4343
if (tool === 'git' && args[0] === 'push') {
44-
throw new Error('authenticated descendant processes cannot push')
44+
const branch = authorization?.issue?.branch
45+
const expectedRollback = [
46+
'push',
47+
`--force-with-lease=refs/heads/${branch}:${authorization?.issue?.baseSha}`,
48+
'origin',
49+
`:refs/heads/${branch}`,
50+
]
51+
if (
52+
authorization?.rootIntent !== 'start' ||
53+
authorization?.issue?.status !== 'starting' ||
54+
args.length !== expectedRollback.length ||
55+
args.some((argument, index) => argument !== expectedRollback[index])
56+
) {
57+
throw new Error('authenticated descendant processes cannot push')
58+
}
4559
}
4660
assertDescendantCommandPolicy({
4761
role,
4862
tool,
4963
args,
5064
authorization,
5165
})
52-
if (tool === 'git' && ['fetch', 'ls-remote'].includes(args[0])) {
66+
if (tool === 'git' && ['push', 'fetch', 'ls-remote'].includes(args[0])) {
5367
await assertSafeRemoteGitConfiguration({
5468
realGit: executable,
5569
environment: process.env,

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ function validateReviewEvidence(review, headSha) {
168168
if (!['high', 'medium', 'low'].includes(finding.confidence)) {
169169
throw new Error(`${findingId}.confidence must be high, medium, or low`)
170170
}
171+
const hasInlineLocation =
172+
typeof finding.path === 'string' &&
173+
finding.path.length > 0 &&
174+
Number.isInteger(finding.line) &&
175+
finding.line > 0
176+
const hasInlineCommentId =
177+
Number.isInteger(finding.inlineCommentId) && finding.inlineCommentId > 0
178+
if (
179+
(finding.path != null || finding.line != null) !== hasInlineLocation ||
180+
(finding.inlineCommentId !== null && !hasInlineCommentId) ||
181+
hasInlineLocation !== hasInlineCommentId
182+
) {
183+
throw new Error(
184+
`${findingId}.inlineCommentId must bind exactly one inline path and line, or be null`,
185+
)
186+
}
171187
assertNonEmpty(finding.problem, `${findingId}.problem`)
172188
assertNonEmpty(finding.evidence, `${findingId}.evidence`)
173189
assertNonEmpty(finding.expectedResolution, `${findingId}.expectedResolution`)
@@ -599,6 +615,7 @@ export async function recordReview({
599615
commentFindingIds.size !== 1 ||
600616
!finding ||
601617
inlineFindingIds.has(commentFindingId) ||
618+
finding.inlineCommentId !== Number(commentId) ||
602619
finding.path !== comment.path ||
603620
!Number.isInteger(finding.line) ||
604621
![comment.line, comment.original_line].includes(finding.line) ||
@@ -668,6 +685,7 @@ export async function recordReview({
668685
) {
669686
throw new Error('published review is not bound to the recorded live PR head')
670687
}
688+
const responseCommentIds = new Set()
671689
for (const round of reviewSummary.roundDetails) {
672690
const publication = publications.get(round.round)
673691
const reviewSubmittedAt = Date.parse(publication.submittedAt)
@@ -681,6 +699,11 @@ export async function recordReview({
681699
) {
682700
throw new Error(`${finding.findingId} response is not on the reviewed PR`)
683701
}
702+
const responseCommentId = `${responseTarget.kind}:${responseTarget.commentId}`
703+
if (responseCommentIds.has(responseCommentId)) {
704+
throw new Error('one executor response comment cannot adjudicate multiple findings')
705+
}
706+
responseCommentIds.add(responseCommentId)
684707
const responseEndpoint =
685708
responseTarget.kind === 'review_comment'
686709
? `repos/${responseTarget.owner}/${responseTarget.repo}/pulls/comments/${responseTarget.commentId}`

loops/issue-dev-loop/scripts/lib/finalization-proof.mjs

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import { createHash } from 'node:crypto'
2-
import path from 'node:path'
32

43
import {
54
assertNonEmpty,
65
defaultGitHubApi,
76
parseGitHubTarget,
87
parsePullCommentUrl,
9-
readJson,
108
sameGitHubLogin,
119
sameRepository,
1210
} from './common.mjs'
1311
import {
12+
checkpointJournalConfiguration,
1413
parseCheckpointRecord,
1514
verifyPublishedCheckpoint,
1615
} from './checkpoint-proof.mjs'
@@ -243,20 +242,7 @@ export async function verifyFailedOrBlockedNotification({
243242
return comment
244243
}
245244

246-
export async function finalizationJournalConfiguration(loopRoot) {
247-
const channel = await readJson(
248-
path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'),
249-
)
250-
if (
251-
!Number.isInteger(channel.stateIssueNumber) ||
252-
channel.stateIssueNumber < 1 ||
253-
!channel.automationGitHubLogin
254-
) {
255-
throw new Error('owner channel must configure stateIssueNumber and automationGitHubLogin')
256-
}
257-
const [owner, repo] = channel.repository.split('/')
258-
return { channel, owner, repo }
259-
}
245+
export const finalizationJournalConfiguration = checkpointJournalConfiguration
260246

261247
export async function verifyPullNotificationComment({
262248
url,

0 commit comments

Comments
 (0)