From 36f7b85ce70f7b1cdddf80745cfbc7553ff8dfee Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Wed, 15 Jul 2026 21:09:44 +0100 Subject: [PATCH] fix: preserve exact review leases and requeue legacy artifacts --- .github/workflows/sweep.yml | 79 +++++++- package.json | 1 + src/clawsweeper.ts | 258 +++++++++++++++++++++++++- src/repair/event-apply-proof.ts | 20 ++ src/repair/publish-event-result.ts | 16 +- test/apply-label-sync.test.ts | 156 ++++++++++++++++ test/command.test.ts | 120 ++++++++++++ test/repair/event-apply-proof.test.ts | 44 +++++ test/sweep-workflow.test.ts | 54 +++++- 9 files changed, 735 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index d95a302555..9d915204d7 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -523,9 +523,15 @@ jobs: if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(targetRepo)) process.exit(1); if (!Number.isInteger(itemNumber) || itemNumber < 1) process.exit(1); + // The review-start lease permits at most two hours, including its ten-minute + // cushion. The 120-minute job leaves 64 minutes for target checkout/setup and + // artifact/publication finalizers after this cap and the 11-minute review reserve. + const maxExactReviewCodexTimeoutMs = 2_700_000; const configuredValue = Number(process.env.CONFIGURED_CODEX_TIMEOUT_MS); const configuredTimeout = - Number.isInteger(configuredValue) && configuredValue > 0 ? configuredValue : 1_200_000; + Number.isInteger(configuredValue) && configuredValue > 0 + ? Math.min(maxExactReviewCodexTimeoutMs, configuredValue) + : 1_200_000; const adaptiveValue = Number(decision.codexTimeoutMs); const adaptiveTimeout = Number.isInteger(adaptiveValue) && adaptiveValue > 0 @@ -545,7 +551,10 @@ jobs: target_repo_name: name, target_checkout_dir: checkoutDir, item_number: itemNumber, - codex_timeout_ms: Math.max(configuredTimeout, adaptiveTimeout), + codex_timeout_ms: Math.min( + maxExactReviewCodexTimeoutMs, + Math.max(configuredTimeout, adaptiveTimeout), + ), media_proof_timeout_ms: mediaTimeout, has_command_context: hasCommandContext, target_enabled: targetEnabled, @@ -767,15 +776,70 @@ jobs: with: login-status: "true" + - name: Reserve exact review lease + id: reserve-exact-review-lease + if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.live-item.outputs.proceed == 'true' }} + env: + GH_TOKEN: ${{ steps.target-write-token.outputs.token }} + TARGET_REPO: ${{ steps.target.outputs.target_repo }} + ITEM_NUMBER: ${{ steps.target.outputs.item_number }} + CODEX_TIMEOUT_MS: ${{ steps.target.outputs.codex_timeout_ms }} + MEDIA_PROOF_TIMEOUT_MS: ${{ steps.target.outputs.media_proof_timeout_ms }} + run: | + set -euo pipefail + test -n "$GH_TOKEN" + if ! [[ "$CODEX_TIMEOUT_MS" =~ ^[0-9]+$ ]] || [ "$CODEX_TIMEOUT_MS" -lt 1 ]; then + echo "Invalid Codex timeout: $CODEX_TIMEOUT_MS" >&2 + exit 1 + fi + if ! [[ "$MEDIA_PROOF_TIMEOUT_MS" =~ ^[0-9]+$ ]] || [ "$MEDIA_PROOF_TIMEOUT_MS" -lt 0 ]; then + echo "Invalid media proof timeout: $MEDIA_PROOF_TIMEOUT_MS" >&2 + exit 1 + fi + codex_timeout_seconds=$(((CODEX_TIMEOUT_MS + 999) / 1000)) + media_preprocessing_reserve_seconds=480 + review_timeout_ms=$(((codex_timeout_seconds + media_preprocessing_reserve_seconds + 180) * 1000)) + reservation="$(pnpm run --silent reserve-review-lease -- \ + --target-repo "$TARGET_REPO" \ + --item-number "$ITEM_NUMBER" \ + --review-timeout-ms "$review_timeout_ms")" + echo "$reservation" + RESERVATION="$reservation" node <<'NODE' + const fs = require("node:fs"); + const reservation = JSON.parse(process.env.RESERVATION || "{}"); + const append = (key, value) => fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); + if (reservation.status === "posted") { + const owner = String(reservation.owner || ""); + const commentId = Number(reservation.commentId); + if (!/^[a-zA-Z0-9._-]{1,200}$/.test(owner) || !Number.isInteger(commentId) || commentId <= 0) { + process.exit(1); + } + append("status", "posted"); + append("owner", owner); + append("comment_id", String(commentId)); + process.exit(0); + } + if (reservation.status === "held") { + const retryAt = String(reservation.retryAt || ""); + if (!Number.isFinite(Date.parse(retryAt))) process.exit(1); + append("status", "held"); + append("retry_at", new Date(retryAt).toISOString()); + process.exit(0); + } + process.exit(1); + NODE + - name: Review exact event item id: review-exact-event-item - if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.live-item.outputs.proceed == 'true' }} + if: ${{ steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.live-item.outputs.proceed == 'true' && steps.reserve-exact-review-lease.outputs.status == 'posted' }} continue-on-error: true env: GH_TOKEN: ${{ steps.target-read-token.outputs.token }} CLAWSWEEPER_PROOF_INSPECTION_TOKEN: ${{ steps.target-read-token.outputs.token || github.token }} ADDITIONAL_PROMPT: ${{ fromJSON(steps.claim-exact-review-queue.outputs.decision).additionalPrompt || '' }} CLAWSWEEPER_RELATED_GITHUB_SEARCH: ${{ vars.CLAWSWEEPER_RELATED_GITHUB_SEARCH || '1' }} + REVIEW_LEASE_OWNER: ${{ steps.reserve-exact-review-lease.outputs.owner }} + REVIEW_LEASE_COMMENT_ID: ${{ steps.reserve-exact-review-lease.outputs.comment_id }} run: | set -euo pipefail test -n "$GH_TOKEN" @@ -808,6 +872,8 @@ jobs: --item-numbers "${{ steps.target.outputs.item_number }}" \ --readonly-openclaw \ --skip-start-comment \ + --review-lease-owner "$REVIEW_LEASE_OWNER" \ + --review-lease-comment-id "$REVIEW_LEASE_COMMENT_ID" \ --shard-index 0 \ --shard-count 1 \ "${additional_prompt_arg[@]}" @@ -978,7 +1044,7 @@ jobs: exit 1 - name: Release unsuccessful workflow-owned review lease - if: ${{ always() && steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.live-item.outputs.proceed == 'true' && steps.queue-exact-review-publication.outcome != 'success' }} + if: ${{ always() && steps.claim-exact-review-queue.outputs.claimed == 'true' && steps.live-item.outputs.proceed == 'true' && steps.reserve-exact-review-lease.outputs.status != 'held' && steps.queue-exact-review-publication.outcome != 'success' }} env: GH_TOKEN: ${{ steps.target-write-token.outputs.token }} TARGET_REPO: ${{ steps.target.outputs.target_repo }} @@ -1017,7 +1083,7 @@ jobs: STATUS_COMMENT_ID: ${{ fromJSON(steps.claim-exact-review-queue.outputs.decision).statusCommentId || '' }} RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} REVIEW_OUTCOME: ${{ steps.review-exact-event-item.outcome }} - RETRY_AT: ${{ steps.review-exact-event-item.outputs.retry_at }} + RETRY_AT: ${{ steps.reserve-exact-review-lease.outputs.retry_at || steps.review-exact-event-item.outputs.retry_at }} CLAWSWEEPER_ACTION_LEDGER_DISABLED: "1" run: | state="Failed" @@ -1066,7 +1132,7 @@ jobs: QUEUE_LEASE_ID: ${{ steps.claim-exact-review-queue.outputs.lease_id }} QUEUE_LEASE_REVISION: ${{ steps.claim-exact-review-queue.outputs.lease_revision }} QUEUE_URL: ${{ vars.CLAWSWEEPER_EXACT_REVIEW_QUEUE_URL || 'https://clawsweeper.openclaw.ai' }} - RETRY_AT: ${{ steps.review-exact-event-item.outputs.retry_at }} + RETRY_AT: ${{ steps.reserve-exact-review-lease.outputs.retry_at || steps.review-exact-event-item.outputs.retry_at }} RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail @@ -1383,6 +1449,7 @@ jobs: CLOSE_REASONS: implemented_on_main,duplicate_or_superseded,low_signal_unmergeable_pr MIN_AGE_MINUTES: "0" REVIEW_ONLY: ${{ fromJSON(steps.publication-context.outputs.decision).sourceAction == 'failed_review_shard_recovery' && 'true' || 'false' }} + EXACT_EVENT_PUBLICATION: "true" LIVE_PROCEEDED: ${{ steps.publication-context.outputs.live_proceeded }} LIVE_TERMINAL_NOOP: ${{ steps.publication-context.outputs.live_terminal_noop }} LIVE_TERMINAL_MISSING: ${{ steps.publication-context.outputs.live_terminal_missing }} diff --git a/package.json b/package.json index 738c785bbd..d4783d9dd3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:dashboard": "tsc -p tsconfig.dashboard.json", "build:all": "pnpm run build && pnpm run build:repair && pnpm run build:dashboard", "plan": "node dist/clawsweeper.js plan", + "reserve-review-lease": "node dist/clawsweeper.js reserve-review-lease", "review": "node dist/clawsweeper.js review", "codex:local:check": "node scripts/check-local-codex.mjs", "retry-failed-reviews": "node dist/clawsweeper.js retry-failed-reviews", diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index a75e7809df..c558b6fcc4 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -475,6 +475,39 @@ type ReviewStartStatusCommentResult = | { status: "posted"; lease: AcquiredReviewStartLease; didMutate: true } | { status: "held"; lease: null; retryAt: string; didMutate: boolean }; +function suppliedReviewStartLeaseFromArgs( + args: Args, +): Pick | null { + const owner = stringArg(args.review_lease_owner, "").trim(); + const commentId = numberArg(args.review_lease_comment_id, 0); + if (!owner && commentId === 0) return null; + if (!owner || !Number.isInteger(commentId) || commentId <= 0) { + throw new UserFacingCommandError( + "--review-lease-owner and --review-lease-comment-id must be supplied together.", + ); + } + if (!/^[a-zA-Z0-9._-]{1,200}$/.test(owner)) { + throw new UserFacingCommandError("--review-lease-owner contains unsupported characters."); + } + return { owner, commentId }; +} + +function reviewLeaseStillMatchesContext( + itemKind: "issue" | "pull_request", + contextPullHeadSha: string | null, + leaseHeadSha: string, +): boolean { + return itemKind !== "pull_request" || contextPullHeadSha?.trim().toLowerCase() === leaseHeadSha; +} + +export function reviewLeaseStillMatchesContextForTest( + itemKind: "issue" | "pull_request", + contextPullHeadSha: string | null, + leaseHeadSha: string, +): boolean { + return reviewLeaseStillMatchesContext(itemKind, contextPullHeadSha, leaseHeadSha); +} + function heldReviewStartStatusCommentResult( retryAt: string, didMutate: boolean, @@ -3038,6 +3071,20 @@ export function itemSourceRevisionSha256ForTest(issue: unknown, comments: unknow return itemSourceRevisionSha256(issue, comments); } +export function isExactEventSourceRevisionChange(itemKind: Item["kind"], reason: string): boolean { + if (itemKind === "pull_request") { + return ( + reason.startsWith("PR head changed since context capture") || + reason === "PR head changed while holding the apply mutation lease" + ); + } + return ( + reason.startsWith("issue source revision changed since context capture") || + reason.startsWith("live issue source revision ") || + reason === "issue source revision changed while holding the apply mutation lease" + ); +} + function reviewCommentDigestParts(entries: unknown): unknown { if (!Array.isArray(entries)) return null; return entries @@ -6925,6 +6972,52 @@ function replaceFrontMatterValue(markdown: string, key: string, value: string): return markdown.replace(/^---\n/, `---\n${line}\n`); } +type ExactEventReviewLeaseDisposition = + | { status: "current" } + | { status: "legacy_tupleless"; reason: string } + | { status: "source_drift"; reportRevision: string; liveRevision: string } + | { status: "invalid"; reason: string }; + +function exactEventReviewLeaseDisposition( + markdown: string, + liveRevision: string, +): ExactEventReviewLeaseDisposition { + const reportRevision = reviewLeaseRevisionFromReport(markdown); + if (!reportRevision) { + return { + status: "invalid", + reason: "exact event review artifact lacks a durable reviewed revision", + }; + } + if (!liveRevision || reportRevision !== liveRevision) { + return { status: "source_drift", reportRevision, liveRevision }; + } + const leaseOwner = frontMatterValue(markdown, "review_lease_owner"); + const leaseCommentId = Number(frontMatterValue(markdown, "review_lease_comment_id")); + const missingOwner = !leaseOwner || leaseOwner === "unknown"; + const missingCommentId = !Number.isInteger(leaseCommentId) || leaseCommentId <= 0; + if (missingOwner && missingCommentId) { + return { + status: "legacy_tupleless", + reason: "local report has no durable lease identity", + }; + } + if (missingOwner || missingCommentId) { + return { + status: "invalid", + reason: "exact event review artifact has an incomplete durable review lease tuple", + }; + } + return { status: "current" }; +} + +export function exactEventReviewLeaseDispositionForTest( + markdown: string, + liveRevision: string, +): ExactEventReviewLeaseDisposition { + return exactEventReviewLeaseDisposition(markdown, liveRevision); +} + function sectionValue(markdown: string, heading: string): string { const match = markdown.match( new RegExp(`(?:^|\\n)## ${heading}\\n\\n([\\s\\S]*?)(?=\\n## |\\n?$)`), @@ -21351,6 +21444,54 @@ function finishReviewActionLedger(options: { options.ledger.terminal = true; } +function reserveReviewLeaseCommand(args: Args): void { + repoFromArgs(args); + const itemNumber = numberArg(args.item_number, 0); + const reviewTimeoutMs = numberArg(args.review_timeout_ms, 0); + if (!Number.isInteger(itemNumber) || itemNumber <= 0) { + throw new UserFacingCommandError("--item-number must be a positive integer."); + } + if (!Number.isInteger(reviewTimeoutMs) || reviewTimeoutMs <= 0) { + throw new UserFacingCommandError("--review-timeout-ms must be a positive integer."); + } + const { item, state } = fetchItem(itemNumber); + if (state !== "open") { + throw new UserFacingCommandError( + `Cannot reserve a review lease for #${itemNumber}: state is ${state}.`, + ); + } + const currentRevision = + item.kind === "pull_request" + ? pullRequestHeadSha(itemNumber) + : collectItemContext(item, { fullTimelineForRelations: true }).sourceRevision; + if (!currentRevision || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(currentRevision)) { + throw new UserFacingCommandError( + `Could not resolve the current review revision for #${itemNumber}.`, + ); + } + const result = postReviewStartStatusComment({ + item, + headSha: currentRevision, + reviewTimeoutMs, + position: 1, + total: 1, + shardIndex: 0, + shardCount: 1, + }); + if (result.status === "held") { + console.log(JSON.stringify({ status: "held", retryAt: result.retryAt })); + return; + } + console.log( + JSON.stringify({ + status: "posted", + owner: result.lease.owner, + commentId: result.lease.commentId, + headSha: result.lease.headSha, + }), + ); +} + function reviewCommand(args: Args): void { const profile = repoFromArgs(args); // `--local-range` is inherently a local, offline operation, so it implies `--local-only` @@ -21466,6 +21607,17 @@ function reviewCommand(args: Args): void { const hotIntake = boolArg(args.hot_intake); const readonlyOpenclaw = boolArg(args.readonly_openclaw); const skipStartComment = boolArg(args.skip_start_comment) || localOnly || localRange; + const suppliedReviewLease = suppliedReviewStartLeaseFromArgs(args); + if (suppliedReviewLease && !skipStartComment) { + throw new UserFacingCommandError( + "A supplied review lease requires --skip-start-comment to prevent a second lease from being created.", + ); + } + if (suppliedReviewLease && localOnly) { + throw new UserFacingCommandError( + "A supplied review lease cannot be used with local-only review.", + ); + } const forcedLoginMethod = reviewCodexForcedLoginMethod(args); const loadReviewGitInfo = (): GitInfo => checkout.gitTargetBranch @@ -21507,6 +21659,11 @@ function reviewCommand(args: Args): void { const { candidates, scannedPages } = localRangeData ? { candidates: [localRangeData.item], scannedPages: 0 } : selectCandidates(selectionOptions); + if (suppliedReviewLease && candidates.length !== 1) { + throw new UserFacingCommandError( + "A supplied review lease requires exactly one selected item.", + ); + } if (expectedSourceRevision && candidates.length !== 1) { throw new UserFacingCommandError( `--expected-source-revision requires exactly one selected issue; selected ${candidates.length}.`, @@ -21877,6 +22034,63 @@ function reviewCommand(args: Args): void { const contextElapsedMs = Date.now() - contextStartedAt; const contextItemUpdatedAt = stringOrUndefined(asRecord(context.issue).updatedAt); if (contextItemUpdatedAt) item.updatedAt = contextItemUpdatedAt; + if (suppliedReviewLease) { + const currentRevision = + item.kind === "pull_request" + ? pullHeadShaFromContext(context) + : context.sourceRevision ?? null; + if (!currentRevision) { + coordinationHeldRetryAt = new Date(Date.now() + 60_000).toISOString(); + leaseAcquisitionFailures += 1; + leaseAcquisitionFailureDetails.push( + `#${item.number}: current revision could not be resolved for the reserved review lease`, + ); + console.error( + `[review] ${new Date().toISOString()} shard=${shardIndex}/${shardCount} start-comment=stale-reservation #${item.number}`, + ); + continue; + } + const freshLeases = freshDedicatedReviewStartLeases({ + comments: issueReviewCommentState(item.number).leaseComments, + itemNumber: item.number, + headSha: currentRevision, + nowMs: Date.now(), + }); + const winner = freshLeases[0]; + const supplied = freshLeases.find( + (lease) => + commentId(lease.comment) === suppliedReviewLease.commentId && + lease.owner === suppliedReviewLease.owner, + ); + if (!supplied || !winner) { + coordinationHeldRetryAt = new Date(Date.now() + 60_000).toISOString(); + leaseAcquisitionFailures += 1; + leaseAcquisitionFailureDetails.push( + `#${item.number}: reserved review lease is no longer fresh for the current revision`, + ); + console.error( + `[review] ${new Date().toISOString()} shard=${shardIndex}/${shardCount} start-comment=stale-reservation #${item.number}`, + ); + continue; + } + if ( + commentId(winner.comment) !== suppliedReviewLease.commentId || + winner.owner !== suppliedReviewLease.owner + ) { + coordinationHeldRetryAt = winner.expiresAt; + console.error( + `[review] ${new Date().toISOString()} shard=${shardIndex}/${shardCount} start-comment=held #${item.number}`, + ); + continue; + } + const claimedLease: AcquiredReviewStartLease = { + owner: suppliedReviewLease.owner, + commentId: suppliedReviewLease.commentId, + headSha: currentRevision, + }; + acquiredReviewLease = claimedLease; + acquiredReviewLeases.push({ itemNumber: item.number, lease: claimedLease }); + } if (!localRangeData && contextItemUpdatedAt && preHydrationStructuralRecord) { structuralCacheRevalidations += 1; const structuralRevalidationStartedAt = Date.now(); @@ -21960,7 +22174,11 @@ function reviewCommand(args: Args): void { }; if ( acquiredReviewLease && - pullHeadShaFromContext(context)?.trim().toLowerCase() !== acquiredReviewLease.headSha + !reviewLeaseStillMatchesContext( + item.kind, + pullHeadShaFromContext(context), + acquiredReviewLease.headSha, + ) ) { leaseAcquisitionFailures += 1; leaseAcquisitionFailureDetails.push( @@ -24958,6 +25176,7 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg const syncCommentsOnly = boolArg(args.sync_comments_only); const suppressAutomationMarkers = boolArg(args.suppress_automation_markers); const emitEventApplyProof = boolArg(args.event_apply_proof); + const exactEventPublication = boolArg(args.exact_event_publication); const commentSyncMinAgeDays = numberArg(args.comment_sync_min_age_days, 0); const maxRuntimeMs = numberArg(args.max_runtime_ms, 0); const reportPath = resolve(stringArg(args.report_path, join(ROOT, "apply-report.json"))); @@ -25502,6 +25721,35 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg item.kind === "pull_request" ? (pullHeadShaFromContext(currentItemContext()) ?? "") : liveIssueSourceRevision(number); + if (state === "open" && exactEventPublication) { + const exactLeaseDisposition = exactEventReviewLeaseDisposition( + markdownBeforeApplyDecisionMutations, + initialReviewHeadSha, + ); + if (exactLeaseDisposition.status === "source_drift") { + const reason = + item.kind === "pull_request" + ? `live PR head ${exactLeaseDisposition.liveRevision || "unknown"} differs from reviewed head ${exactLeaseDisposition.reportRevision}` + : `live issue source revision ${exactLeaseDisposition.liveRevision || "unknown"} differs from reviewed revision ${exactLeaseDisposition.reportRevision}`; + if (markApplySkipped("skipped_changed_since_review", reason)) break; + continue; + } + if (exactLeaseDisposition.status === "legacy_tupleless") { + if ( + markApplySkipped( + "skipped_stale_review_comment_sync", + exactLeaseDisposition.reason, + ) + ) { + break; + } + continue; + } + if (exactLeaseDisposition.status === "invalid") { + if (markApplySkipped("kept_open", exactLeaseDisposition.reason)) break; + continue; + } + } const reviewStartLeaseStateForComments = ( leaseComments: Record[], reviewComment: Record | undefined, @@ -25533,6 +25781,9 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg leaseCommentId: lease.commentId, }), ); + // A matching report tuple deliberately returns `preserve: false`: the exact publisher + // adopts that completed review lease as its mutation lock. Any different or incomplete + // live lease remains preserved and blocks the older artifact. return { comment: reviewComment, leaseComments, @@ -25702,8 +25953,10 @@ function applyDecisionsCommandInner(args: Args, runtimeBudget: GitHubRuntimeBudg const reviewActivitySourceChanged = (reason: string): boolean => reason === "pull request review activity changed since review" || reason === "pull request review activity exceeds the bounded reviewed cursor"; + const exactEventSourceRevisionChanged = (reason: string): boolean => + exactEventPublication && isExactEventSourceRevisionChange(item.kind, reason); const recordReviewLeaseSkip = (reason: string, restoreOriginal = true): boolean => - reviewActivitySourceChanged(reason) + reviewActivitySourceChanged(reason) || exactEventSourceRevisionChanged(reason) ? markApplySkipped("skipped_changed_since_review", reason) : staleCanonicalCommentSyncPending ? markApplySkipped( @@ -30529,6 +30782,7 @@ export async function main( let commandError: unknown; try { if (command === "plan") planCommand(args); + else if (command === "reserve-review-lease") reserveReviewLeaseCommand(args); else if (command === "review") reviewCommand(args); else if (command === "retry-failed-reviews") retryFailedReviewsCommand(args); else if (command === "apply-artifacts") applyArtifactsCommand(args); diff --git a/src/repair/event-apply-proof.ts b/src/repair/event-apply-proof.ts index 1bf2a246f9..f374975d1e 100644 --- a/src/repair/event-apply-proof.ts +++ b/src/repair/event-apply-proof.ts @@ -3,6 +3,7 @@ import type { LooseRecord } from "./json-types.js"; export type EventApplyAction = { number: number | null; action: string; + reason: string; durableReviewSynced: boolean; terminalMissingVerified: boolean; terminalStateVerified: boolean; @@ -28,6 +29,8 @@ const GUARDED_OPEN_ACTIONS = new Set([ "skipped_same_author_pair", ]); +const LEGACY_TUPLELESS_REVIEW_LEASE_REASON = "local report has no durable lease identity"; + export function exactEventPublishDisposition({ candidateMatchesCurrentTuple, candidateTupleState, @@ -67,6 +70,18 @@ export type ExactEventApplyDisposition = | "source_drift" | "unproven"; +export function eventApplyRequeueLatestExpected({ + disposition, + exactEventPublication, + legacyTuplelessReviewLease, +}: { + disposition: ExactEventApplyDisposition; + exactEventPublication: boolean; + legacyTuplelessReviewLease: boolean; +}): boolean { + return disposition === "source_drift" || (exactEventPublication && legacyTuplelessReviewLease); +} + export function exactEventApplyProof( actions: readonly EventApplyAction[], itemNumber: number, @@ -78,6 +93,7 @@ export function exactEventApplyProof( terminalCount: number; guardedOpenAction: string | null; latestRevisionRequeueRequired: boolean; + legacyTuplelessReviewLease: boolean; disposition: ExactEventApplyDisposition; } { const exactActions = actions.filter((entry) => entry.number === itemNumber); @@ -118,6 +134,9 @@ export function exactEventApplyProof( latestRevisionRequeueRequired: snapshotActionTaken === "skipped_changed_since_review" && soleExactAction === "skipped_changed_since_review", + legacyTuplelessReviewLease: + soleExactAction === "skipped_stale_review_comment_sync" && + soleExactResult?.reason.includes(LEGACY_TUPLELESS_REVIEW_LEASE_REASON) === true, disposition: hasSourceDrift ? sourceDrift ? "source_drift" @@ -147,6 +166,7 @@ export function eventApplyAction(value: LooseRecord): EventApplyAction { return { number: typeof value.number === "number" ? value.number : null, action: typeof value.action === "string" ? value.action : "", + reason: typeof value.reason === "string" ? value.reason : "", durableReviewSynced: value.durableReviewSynced === true, terminalMissingVerified: value.terminalMissingVerified === true, terminalStateVerified: value.terminalStateVerified === true, diff --git a/src/repair/publish-event-result.ts b/src/repair/publish-event-result.ts index 7eafec5d70..3d8a2f1ad6 100644 --- a/src/repair/publish-event-result.ts +++ b/src/repair/publish-event-result.ts @@ -14,6 +14,7 @@ import { eventRecordActionTaken, eventApplyAction, exactEventApplyProof, + eventApplyRequeueLatestExpected, exactEventPublishDisposition, type EventApplyAction, } from "./event-apply-proof.js"; @@ -40,6 +41,7 @@ type EventOptions = { closeReasons: string; minAgeMinutes: string; reviewOnly: boolean; + exactEventPublication: boolean; reportPath: string; snapshotDir: string; }; @@ -156,9 +158,19 @@ async function publishEventResult(options: EventOptions): Promise { terminalMissingCount: missingCount, terminalCount: closedCount, guardedOpenAction, + legacyTuplelessReviewLease, disposition: applyDisposition, } = exactEventApplyProof(actions, Number(options.itemNumber), snapshotActionTaken); - const requeueLatestExpected = applyDisposition === "source_drift"; + const requeueLatestExpected = eventApplyRequeueLatestExpected({ + disposition: applyDisposition, + exactEventPublication: options.exactEventPublication, + legacyTuplelessReviewLease, + }); + if (options.exactEventPublication && legacyTuplelessReviewLease) { + console.log( + `Requeueing ${options.targetRepo}#${options.itemNumber}: legacy exact artifact lacks its durable review lease tuple`, + ); + } if ( syncedCount + closedCount + missingCount === 0 && guardedOpenAction === null && @@ -258,6 +270,7 @@ function runApplyDecisions(options: EventOptions): void { "--progress-every", "1", "--event-apply-proof", + "--exact-event-publication", "--skip-dashboard", "--report-path", options.reportPath, @@ -420,6 +433,7 @@ function eventOptionsFromEnv(): EventOptions { "implemented_on_main,duplicate_or_superseded,low_signal_unmergeable_pr", minAgeMinutes: process.env.MIN_AGE_MINUTES || "0", reviewOnly: process.env.REVIEW_ONLY === "true", + exactEventPublication: process.env.EXACT_EVENT_PUBLICATION === "true", reportPath: ".artifacts/event-apply-report.json", snapshotDir: ".artifacts/event-record-snapshot", }; diff --git a/test/apply-label-sync.test.ts b/test/apply-label-sync.test.ts index 4615a07ecf..8b6826c67a 100644 --- a/test/apply-label-sync.test.ts +++ b/test/apply-label-sync.test.ts @@ -5,6 +5,7 @@ import test from "node:test"; import { contextHasNonAutomationActivityAfterForTest, + isExactEventSourceRevisionChange, itemSourceRevisionSha256ForTest, renderReviewStartStatusComment, } from "../dist/clawsweeper.js"; @@ -50,6 +51,161 @@ test("command-only timeline activity is ignored only through the completed revie ); }); +test("exact event source drift includes a revision change while its apply lease is held", () => { + assert.equal( + isExactEventSourceRevisionChange( + "pull_request", + "PR head changed while holding the apply mutation lease", + ), + true, + ); + assert.equal( + isExactEventSourceRevisionChange( + "issue", + "issue source revision changed while holding the apply mutation lease", + ), + true, + ); + assert.equal( + isExactEventSourceRevisionChange("issue", "apply mutation lease is not held"), + false, + ); +}); + +test("exact publication consumes its matching completed issue review lease", () => { + const root = mkdtempSync(tmpPrefix); + try { + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + const reportPath = join(root, "apply-report.json"); + const number = 103599; + const reviewedAt = new Date(Date.now() - 5 * 60_000).toISOString(); + const leaseUpdatedAt = new Date(Date.now() - 60_000).toISOString(); + const leaseExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); + const leaseOwner = `exact-issue-${number}`; + const leaseCommentId = 700_000 + number; + const issue = { + number, + title: `Incident issue ${number}`, + body: "The reviewed issue source remains unchanged.", + html_url: `https://github.com/openclaw/openclaw/issues/${number}`, + created_at: "2026-04-01T00:00:00Z", + updated_at: leaseUpdatedAt, + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + comments: 2, + pull_request: null, + }; + const sourceRevision = itemSourceRevisionSha256ForTest(issue, []); + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + const closeReport = implementedCloseReport({ + repository: "openclaw/clawsweeper", + number, + type: "issue", + title: issue.title, + reviewed_at: reviewedAt, + item_updated_at: reviewedAt, + item_source_revision: sourceRevision, + review_lease_owner: leaseOwner, + review_lease_comment_id: String(leaseCommentId), + labels: JSON.stringify([]), + }); + const synced = reportWithSyncedReviewComment(closeReport, number, "implemented_on_main"); + writeFileSync(join(itemsDir, `${number}.md`), synced.report, "utf8"); + const leaseComment = renderReviewStartStatusComment({ + number, + kind: "issue", + title: issue.title, + headSha: sourceRevision, + startedAt: leaseUpdatedAt, + leaseExpiresAt, + leaseOwner, + }); + const comments = [ + { + id: 9000 + number, + html_url: `https://github.com/openclaw/openclaw/issues/${number}#issuecomment-${9000 + number}`, + created_at: reviewedAt, + updated_at: reviewedAt, + user: { login: "clawsweeper[bot]" }, + body: synced.comment, + }, + { + id: leaseCommentId, + html_url: `https://github.com/openclaw/openclaw/issues/${number}#issuecomment-${leaseCommentId}`, + created_at: leaseUpdatedAt, + updated_at: leaseUpdatedAt, + user: { login: "clawsweeper[bot]" }, + body: leaseComment, + }, + ]; + const ghMock = ` +const issue = ${JSON.stringify(issue)}; +const comments = ${JSON.stringify(comments)}; +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs; +const path = args.includes("-i") ? args[args.indexOf("-i") + 1] : args[1] || ""; +const slurp = args.includes("--slurp"); +if (args[0] === "api" && new RegExp("/issues/${number}/comments(?:\\\\?|$)").test(path)) { + console.log(JSON.stringify(slurp ? [comments] : comments)); +} else if (args[0] === "api" && new RegExp("/issues/${number}/timeline(?:\\\\?|$)").test(path)) { + console.log(JSON.stringify(slurp ? [[]] : [])); +} else if (args[0] === "api" && new RegExp("/issues/${number}$").test(path)) { + console.log(JSON.stringify(issue)); +} else if (args[0] === "api" && path.startsWith("search/issues?")) { + console.log(JSON.stringify({ items: [] })); +} else if (args[0] === "issue" && args[1] === "view") { + console.log(JSON.stringify({ closedByPullRequestsReferences: [] })); +} else if (args[0] === "label" || args[0] === "issue") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: [ + "--dry-run", + "--event-apply-proof", + "--exact-event-publication", + "--item-numbers", + String(number), + "--processed-limit", + "2", + ], + }); + }); + + assert.deepEqual(JSON.parse(readFileSync(reportPath, "utf8")), [ + { + number, + action: "review_comment_synced", + reason: "would update durable Codex review comment", + durableReviewSynced: true, + }, + { + number, + action: "closed", + reason: "dry-run: would close as already implemented on main", + }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("issue apply CAS blocks a newer durable review tuple published after preflight", () => { const root = mkdtempSync(tmpPrefix); try { diff --git a/test/command.test.ts b/test/command.test.ts index ef360b821a..2a7af13db7 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -16,7 +16,9 @@ import { fileURLToPath } from "node:url"; import { defaultReviewArtifactDirForTest, + exactEventReviewLeaseDispositionForTest, prepareManagedLocalReviewCheckoutForTest, + reviewLeaseStillMatchesContextForTest, } from "../dist/clawsweeper.js"; import { runText, UserFacingCommandError } from "../dist/command.js"; import { mockGhBinEnv } from "./helpers.ts"; @@ -94,6 +96,124 @@ test("local exact reviews default to item-specific artifacts", () => { assert.equal(defaultReviewArtifactDirForTest(false, 357, undefined), "artifacts/reviews"); }); +test("exact event publication requeues legacy tuples and source drift before mutation", () => { + const revision = "0123456789abcdef0123456789abcdef01234567"; + const base = `---\nitem_source_revision: ${revision}\n---\n`; + assert.deepEqual(exactEventReviewLeaseDispositionForTest(base, revision), { + status: "legacy_tupleless", + reason: "local report has no durable lease identity", + }); + assert.deepEqual(exactEventReviewLeaseDispositionForTest(base, "f".repeat(40)), { + status: "source_drift", + reportRevision: revision, + liveRevision: "f".repeat(40), + }); + assert.deepEqual( + exactEventReviewLeaseDispositionForTest( + `---\nitem_source_revision: ${revision}\nreview_lease_owner: run-123\nreview_lease_comment_id: 99\n---\n`, + revision, + ), + { status: "current" }, + ); +}); + +test("reserved exact-review leases compare a head only for pull requests", () => { + const revision = "0123456789abcdef0123456789abcdef01234567"; + assert.equal(reviewLeaseStillMatchesContextForTest("issue", null, revision), true); + assert.equal(reviewLeaseStillMatchesContextForTest("pull_request", revision, revision), true); + assert.equal( + reviewLeaseStillMatchesContextForTest("pull_request", "f".repeat(40), revision), + false, + ); +}); + +test("reserve-review-lease creates and confirms a durable pre-review tuple", () => { + const root = mkdtempSync(join(tmpdir(), "cmd-reserve-lease-")); + const binDir = join(root, "bin"); + const ghPath = join(binDir, "gh.js"); + const leasePath = join(root, "lease.json"); + const headSha = "0123456789abcdef0123456789abcdef01234567"; + try { + mkdirSync(binDir, { recursive: true }); + writeFileSync( + ghPath, + ` +const { existsSync, readFileSync, writeFileSync } = require("node:fs"); +const leasePath = ${JSON.stringify(leasePath)}; +const headSha = ${JSON.stringify(headSha)}; +const args = process.argv.slice(2); +const path = args[1] || ""; +const comments = () => existsSync(leasePath) ? [JSON.parse(readFileSync(leasePath, "utf8"))] : []; +if (args[0] === "api" && path === "repos/openclaw/openclaw/issues/357") { + console.log(JSON.stringify({ + number: 357, + title: "Reserve durable exact review lease", + html_url: "https://github.com/openclaw/openclaw/pull/357", + created_at: "2026-07-15T00:00:00Z", + updated_at: "2026-07-15T00:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "reporter" }, + labels: [], + pull_request: {} + })); +} else if (args[0] === "api" && path === "repos/openclaw/openclaw/pulls/357") { + console.log(JSON.stringify({ head: { sha: headSha } })); +} else if (args[0] === "api" && path.startsWith("repos/openclaw/openclaw/issues/357/comments") && !args.includes("--method")) { + const value = comments(); + console.log(JSON.stringify(args.includes("--slurp") ? [value] : value)); +} else if (args[0] === "api" && path === "repos/openclaw/openclaw/issues/357/comments" && args.includes("--method")) { + const body = JSON.parse(readFileSync(args[args.indexOf("--input") + 1], "utf8")).body; + const lease = { + id: 9991, + html_url: "https://github.com/openclaw/openclaw/pull/357#issuecomment-9991", + created_at: "2026-07-15T00:00:00Z", + updated_at: "2026-07-15T00:00:00Z", + user: { login: "clawsweeper[bot]" }, + body + }; + writeFileSync(leasePath, JSON.stringify(lease)); + console.log(JSON.stringify(lease)); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`, + "utf8", + ); + const result = spawnSync( + process.execPath, + [ + CLI, + "reserve-review-lease", + "--target-repo", + "openclaw/openclaw", + "--item-number", + "357", + "--review-timeout-ms", + "600000", + ], + { encoding: "utf8", env: { ...process.env, ...mockGhBinEnv(ghPath) } }, + ); + + assert.equal(result.status, 0, result.stderr); + const reservation = JSON.parse(result.stdout); + assert.equal(reservation.status, "posted"); + assert.match(reservation.owner, /^[a-zA-Z0-9._-]{1,200}$/); + assert.equal(reservation.commentId, 9991); + assert.equal(reservation.headSha, headSha); + const lease = JSON.parse(readFileSync(leasePath, "utf8")); + assert.match(lease.body, /clawsweeper-review-status:started/); + assert.match(lease.body, /clawsweeper-review-lease item=357/); + assert.match(lease.body, new RegExp(`sha=${headSha}`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("managed local review checkout fetches the pull request ref", () => { const root = mkdtempSync(join(tmpdir(), "cmd-")); const origin = join(root, "origin.git"); diff --git a/test/repair/event-apply-proof.test.ts b/test/repair/event-apply-proof.test.ts index a7d79750e1..b99b99b27d 100644 --- a/test/repair/event-apply-proof.test.ts +++ b/test/repair/event-apply-proof.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { eventApplyAction, + eventApplyRequeueLatestExpected, eventRecordActionTaken, exactEventApplyProof, exactEventPublishDisposition, @@ -293,6 +294,49 @@ test("exact event proof keeps changed-since-review on the latest-revision requeu assert.equal(proof.latestRevisionRequeueRequired, true); }); +test("exact event proof requeues only the guarded legacy tuple-less artifact path", () => { + const legacy = exactEventApplyProof( + [ + eventApplyAction({ + number: 42, + action: "skipped_stale_review_comment_sync", + reason: + "live durable review tuple has lease comment 4979165844, but the local report has no durable lease identity", + }), + ], + 42, + ); + const newerVerdict = exactEventApplyProof( + [ + eventApplyAction({ + number: 42, + action: "skipped_stale_review_comment_sync", + reason: "live durable review comment is newer than the local report", + }), + ], + 42, + ); + + assert.equal(legacy.legacyTuplelessReviewLease, true); + assert.equal(newerVerdict.legacyTuplelessReviewLease, false); + assert.equal( + eventApplyRequeueLatestExpected({ + disposition: legacy.disposition, + exactEventPublication: true, + legacyTuplelessReviewLease: legacy.legacyTuplelessReviewLease, + }), + true, + ); + assert.equal( + eventApplyRequeueLatestExpected({ + disposition: newerVerdict.disposition, + exactEventPublication: true, + legacyTuplelessReviewLease: newerVerdict.legacyTuplelessReviewLease, + }), + false, + ); +}); + test("guarded-open proof rejects mismatches, extra results, and transient skips", () => { const snapshotAction = "skipped_same_author_pair"; const transientActions = [ diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts index 2529e6576c..81d314f012 100644 --- a/test/sweep-workflow.test.ts +++ b/test/sweep-workflow.test.ts @@ -337,6 +337,7 @@ test("exact event review hands immutable artifacts to one dedicated publisher", type Job = { needs?: string | string[]; if?: string; + "timeout-minutes"?: number; permissions?: Record; concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string }; steps: Step[]; @@ -353,6 +354,7 @@ test("exact event review hands immutable artifacts to one dedicated publisher", }; assert.equal(reviewer.permissions?.contents, "read"); + assert.equal(reviewer["timeout-minutes"], 120); assert.equal(reviewer.permissions?.issues, "read"); assert.equal( reviewer.steps.some((candidate) => candidate.uses?.endsWith("/setup-state")), @@ -369,6 +371,26 @@ test("exact event review hands immutable artifacts to one dedicated publisher", "${{ steps.target-read-token.outputs.token }}", ); assert.match(step(reviewer, "Review exact event item").run ?? "", /--skip-start-comment/); + const reserveLease = step(reviewer, "Reserve exact review lease"); + assert.equal(reserveLease.env?.GH_TOKEN, "${{ steps.target-write-token.outputs.token }}"); + assert.match(reserveLease.run ?? "", /pnpm run --silent reserve-review-lease/); + assert.match(reserveLease.run ?? "", /review-timeout-ms/); + const resolvePayload = step(reviewer, "Resolve event payload"); + assert.match(resolvePayload.run ?? "", /maxExactReviewCodexTimeoutMs = 2_700_000/); + assert.match( + resolvePayload.run ?? "", + /Math\.min\(maxExactReviewCodexTimeoutMs, configuredValue\)/, + ); + assert.match( + resolvePayload.run ?? "", + /codex_timeout_ms: Math\.min\(\s*maxExactReviewCodexTimeoutMs/, + ); + assert.match( + step(reviewer, "Review exact event item").if ?? "", + /reserve-exact-review-lease\.outputs\.status == 'posted'/, + ); + assert.match(step(reviewer, "Review exact event item").run ?? "", /--review-lease-owner/); + assert.match(step(reviewer, "Review exact event item").run ?? "", /--review-lease-comment-id/); const create = step(reviewer, "Create exact review artifact bundle"); const upload = step(reviewer, "Upload exact review artifact bundle"); @@ -392,6 +414,7 @@ test("exact event review hands immutable artifacts to one dedicated publisher", assert.match(queuePublication.run ?? "", /for attempt in 1 2 3/); assert.match(queuePublication.run ?? "", /\.queued == true or \.deduped == true/); assert.match(complete.env?.PRIMARY_OUTCOME ?? "", /exact-review-generation-result/); + assert.match(releaseGeneration.if ?? "", /reserve-exact-review-lease\.outputs\.status != 'held'/); assert.match(releaseGeneration.run ?? "", /content == "eyes"/); assert.ok(reviewer.steps.indexOf(upload) < reviewer.steps.indexOf(complete)); @@ -442,6 +465,7 @@ test("exact event review hands immutable artifacts to one dedicated publisher", assert.match(publish.run ?? "", /open\)[\s\S]*?requeue_latest=true/); assert.match(publish.run ?? "", /test -f "artifacts\/event\/\$ITEM_NUMBER\.md"/); assert.match(publish.run ?? "", /repair:publish-event-result/); + assert.equal(publish.env?.EXACT_EVENT_PUBLICATION, "true"); const route = step(publisher, "Route synced ClawSweeper verdict"); assert.match(route.if ?? "", /publish-event-result\.outcome == 'success'/); assert.match(route.if ?? "", /remote_tuple_verified == 'true'/); @@ -480,6 +504,16 @@ test("exact event review hands immutable artifacts to one dedicated publisher", assert.ok(publisher.steps.indexOf(publishResult) < publisher.steps.indexOf(publishComplete)); const publisherSource = readText("src/repair/publish-event-result.ts"); + assert.match( + publisherSource, + /exactEventPublication: process\.env\.EXACT_EVENT_PUBLICATION === "true"/, + ); + assert.match(publisherSource, /"--exact-event-publication"/); + assert.match(publisherSource, /legacyTuplelessReviewLease/); + const reviewSource = readText("src/clawsweeper.ts"); + assert.match(reviewSource, /reserveReviewLeaseCommand/); + assert.match(reviewSource, /suppliedReviewStartLeaseFromArgs/); + assert.match(reviewSource, /exactEventReviewLeaseDisposition/); const completeStart = publisherSource.indexOf("const complete ="); assert.ok(publisherSource.indexOf("hardResetToRemoteMain();", completeStart) > completeStart); assert.ok( @@ -2208,9 +2242,14 @@ test("sweep exact event reviews consume only the immutable claimed decision", () /CONFIGURED_CODEX_TIMEOUT_MS: \$\{\{ vars\.CLAWSWEEPER_CODEX_TIMEOUT_MS \|\| '1200000' \}\}/, ); assert.match(resolveBlock, /const decision = JSON\.parse\(process\.env\.CLAIM_DECISION/); + assert.match(resolveBlock, /const maxExactReviewCodexTimeoutMs = 2_700_000/); + assert.match(resolveBlock, /Math\.min\(maxExactReviewCodexTimeoutMs, configuredValue\)/); assert.match(resolveBlock, /Math\.min\(1_800_000, Math\.max\(600_000, adaptiveValue\)\)/); assert.match(resolveBlock, /Math\.min\(480_000, mediaValue\)/); - assert.match(resolveBlock, /codex_timeout_ms: Math\.max\(configuredTimeout, adaptiveTimeout\)/); + assert.match( + resolveBlock, + /codex_timeout_ms: Math\.min\(\s*maxExactReviewCodexTimeoutMs,\s*Math\.max\(configuredTimeout, adaptiveTimeout\)/, + ); assert.match(resolveBlock, /media_proof_timeout_ms: mediaTimeout/); assert.doesNotMatch(resolveBlock, /github\.event\.client_payload/); assert.match( @@ -2265,7 +2304,7 @@ test("every action-ledger publication authenticates the expected producer job", assert.match(workflow, /--expected-producer-job apply-proof/); }); -test("sweep exact event reviews preserve the configured fallback without an adaptive payload", () => { +test("sweep exact event reviews cap the configured fallback within the lease and job budgets", () => { const workflow = readText(".github/workflows/sweep.yml"); const resolveBlock = workflow.slice( workflow.indexOf("- name: Resolve event payload"), @@ -2276,8 +2315,15 @@ test("sweep exact event reviews preserve the configured fallback without an adap resolveBlock, /CONFIGURED_CODEX_TIMEOUT_MS: \$\{\{ vars\.CLAWSWEEPER_CODEX_TIMEOUT_MS \|\| '1200000' \}\}/, ); - assert.match(resolveBlock, /configuredValue > 0 \? configuredValue : 1_200_000/); - assert.match(resolveBlock, /codex_timeout_ms: Math\.max\(configuredTimeout, adaptiveTimeout\)/); + assert.match(resolveBlock, /const maxExactReviewCodexTimeoutMs = 2_700_000/); + assert.match( + resolveBlock, + /Number\.isInteger\(configuredValue\) && configuredValue > 0\s*\? Math\.min\(maxExactReviewCodexTimeoutMs, configuredValue\)\s*: 1_200_000/, + ); + assert.match( + resolveBlock, + /codex_timeout_ms: Math\.min\(\s*maxExactReviewCodexTimeoutMs,\s*Math\.max\(configuredTimeout, adaptiveTimeout\)/, + ); }); test("github activity workflow scopes cancellation to matching item activity", () => {