diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 081b069974..a758f1333e 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -250,10 +250,37 @@ jobs: set -euo pipefail test -n "$CLAWSWEEPER_WEBHOOK_SECRET" queue_url="${QUEUE_URL%/}" - IFS=$'\t' read -r target_repo target_branch < <(node <<'NODE' + IFS=$'\t' read -r target_repo target_branch use_source_authority < <(node <<'NODE' const payload = JSON.parse(process.env.CLIENT_PAYLOAD || "{}"); + const itemKind = payload.item_kind === "pull_request" ? "pull_request" : "issue"; + const queueClaim = payload.queue_claim && typeof payload.queue_claim === "object" + ? payload.queue_claim + : {}; + const installationId = Number(queueClaim.installation_id ?? payload.installation_id); + const sourceHeadSha = String( + queueClaim.source_head_sha ?? payload.source_head_sha ?? "", + ) + .trim() + .toLowerCase(); + const sourceBaseSha = String( + queueClaim.source_base_sha ?? payload.source_base_sha ?? "", + ) + .trim() + .toLowerCase(); + const sourceIsDraft = queueClaim.source_is_draft ?? payload.source_is_draft; + const sourceContentRevision = String( + queueClaim.source_content_revision ?? payload.source_content_revision ?? "", + ) + .trim() + .toLowerCase(); + const ingressFingerprint = String(payload.ingress_fingerprint || "").trim().toLowerCase(); + const targetDispatcherIngress = + payload.ingress_route === "target_dispatcher" && + itemKind === "pull_request" && + (payload.source_event === "pull_request" || payload.source_event === "pull_request_target") && + /^[0-9a-f]{64}$/.test(ingressFingerprint); process.stdout.write( - `${String(payload.target_repo || "openclaw/openclaw").trim()}\t${String(payload.target_branch || "").trim()}\n`, + `${String(payload.target_repo || "openclaw/openclaw").trim()}\t${String(payload.target_branch || "").trim()}\t${itemKind === "pull_request" && payload.source_event === "pull_request" && payload.source_action === "edited" && Number.isInteger(installationId) && installationId > 0 && /^[0-9a-f]{40}$/.test(sourceHeadSha) && /^[0-9a-f]{40}$/.test(sourceBaseSha) && typeof sourceIsDraft === "boolean" && /^[0-9a-f]{64}$/.test(sourceContentRevision) && !targetDispatcherIngress ? "1" : "0"}\n`, ); NODE ) @@ -268,6 +295,10 @@ jobs: echo "Invalid legacy target branch for $target_repo: $target_branch" >&2 exit 1 fi + queue_path="/internal/exact-review/enqueue" + if [ "$use_source_authority" = "1" ]; then + queue_path="/internal/exact-review/source-authority" + fi payload="$(TARGET_REPO="$target_repo" TARGET_BRANCH="$target_branch" node <<'NODE' const payload = JSON.parse(process.env.CLIENT_PAYLOAD || "{}"); const itemKind = payload.item_kind === "pull_request" ? "pull_request" : "issue"; @@ -275,6 +306,9 @@ jobs: payload.source_event === "pull_request" || payload.source_event === "pull_request_target" ? "pull_request" : "issues"; + const queueClaim = payload.queue_claim && typeof payload.queue_claim === "object" + ? payload.queue_claim + : {}; const dispatchKey = String(payload.dispatch_key || "").trim(); const ingressFingerprint = String(payload.ingress_fingerprint || "").trim().toLowerCase(); const ingress = @@ -284,11 +318,20 @@ jobs: /^[0-9a-f]{64}$/.test(ingressFingerprint) ? { route: "target_dispatcher", fingerprint: ingressFingerprint } : undefined; + const sourceHeadSha = String(queueClaim.source_head_sha ?? payload.source_head_sha ?? "").trim().toLowerCase(); + const sourceBaseSha = String(queueClaim.source_base_sha ?? payload.source_base_sha ?? "").trim().toLowerCase(); + const sourceIsDraft = queueClaim.source_is_draft ?? payload.source_is_draft; + const sourceContentRevision = String(queueClaim.source_content_revision ?? payload.source_content_revision ?? "").trim().toLowerCase(); + const sourceUpdatedAt = String(queueClaim.source_updated_at ?? payload.source_updated_at ?? "").trim(); + const installationId = Number(queueClaim.installation_id ?? payload.installation_id); process.stdout.write( JSON.stringify({ delivery_id: dispatchKey ? `router:${dispatchKey}` : `legacy:${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}`, + ...(itemKind === "pull_request" && Number.isInteger(installationId) && installationId > 0 + ? { installation_id: installationId } + : {}), decision: { targetRepo: process.env.TARGET_REPO, targetBranch: process.env.TARGET_BRANCH, @@ -297,14 +340,30 @@ jobs: sourceEvent, sourceAction: payload.source_action || "legacy_dispatch", supersedesInProgress: payload.supersedes_in_progress === true, - ...(/^[0-9a-f]{40}$/.test(String(payload.queue_claim?.source_head_sha || payload.source_head_sha || "").trim().toLowerCase()) - ? { sourceHeadSha: String(payload.queue_claim?.source_head_sha || payload.source_head_sha).trim().toLowerCase() } + ...(/^[0-9a-f]{40}$/.test(sourceHeadSha) + ? { sourceHeadSha } + : {}), + ...(/^[0-9a-f]{40}$/.test(sourceBaseSha) + ? { sourceBaseSha } + : {}), + ...(typeof sourceIsDraft === "boolean" ? { sourceIsDraft } : {}), + ...(/^[0-9a-f]{64}$/.test(sourceContentRevision) + ? { sourceContentRevision } + : {}), + ...(sourceUpdatedAt && Number.isFinite(Date.parse(sourceUpdatedAt)) + ? { sourceUpdatedAt } : {}), - ...(Number.isFinite(Number(payload.codex_timeout_ms)) - ? { codexTimeoutMs: Number(payload.codex_timeout_ms) } + ...(Number.isFinite(Number(queueClaim.codex_timeout_ms ?? payload.codex_timeout_ms)) + ? { codexTimeoutMs: Number(queueClaim.codex_timeout_ms ?? payload.codex_timeout_ms) } : {}), - ...(Number.isFinite(Number(payload.media_proof_timeout_ms)) - ? { mediaProofTimeoutMs: Number(payload.media_proof_timeout_ms) } + ...(Number.isFinite( + Number(queueClaim.media_proof_timeout_ms ?? payload.media_proof_timeout_ms), + ) + ? { + mediaProofTimeoutMs: Number( + queueClaim.media_proof_timeout_ms ?? payload.media_proof_timeout_ms, + ), + } : {}), ...(Object.hasOwn(payload, "command_status_marker") ? { commandStatusMarker: payload.command_status_marker } @@ -327,7 +386,7 @@ jobs: --header "content-type: application/json" \ --header "x-clawsweeper-exact-review-signature: $signature" \ --data "$payload" \ - "$queue_url/internal/exact-review/enqueue" >/dev/null + "$queue_url$queue_path" >/dev/null event-review-apply: name: Review exact event item diff --git a/dashboard/exact-review-queue.ts b/dashboard/exact-review-queue.ts index 4937af5e2c..4267891ae9 100644 --- a/dashboard/exact-review-queue.ts +++ b/dashboard/exact-review-queue.ts @@ -67,6 +67,9 @@ export type ExactReviewBaseDecision = { sourceAction: string; supersedesInProgress: boolean; sourceHeadSha?: string; + sourceBaseSha?: string; + sourceIsDraft?: boolean; + sourceContentRevision?: string; sourceHeadVerified?: boolean; sourceAuthoritySeq?: number; sourceUpdatedAt?: string; @@ -103,6 +106,7 @@ export type ExactReviewQueueItem = { decision: ExactReviewDecision; ingressFingerprint?: string; leaseDecision?: ExactReviewDecision; + sourceAuthorityWatermark?: { sequence: number; updatedAt?: string }; state: "pending" | "dispatching" | "leased" | "parked"; revision: number; createdAt: number; @@ -267,7 +271,7 @@ type ExactReviewQueueStorageMeta = { shed_since_reset?: number; }; type ExactReviewQueueMetricTotals = { - review: { enqueued: number; completed: number; superseded: number }; + review: { enqueued: number; completed: number; superseded: number; semanticDeduped: number }; publication: { enqueued: number; completed: number; @@ -288,10 +292,19 @@ type ExactReviewSourceAuthorityReservation = { attempts: number; nextAttemptAt: number; }; +type ExactReviewEditedSemanticInput = { + // Queue state preserves the repository's display casing, while this durable + // semantic cursor canonicalizes it so equivalent GitHub repository spellings + // share one fingerprint. + queueKey: string; + storageKey: string; + fingerprint: string; +}; type ExactReviewQueueMetricDelta = { reviewEnqueued?: number; reviewCompleted?: number; reviewSuperseded?: number; + reviewSemanticDeduped?: number; reviewRetried?: number; reviewShed?: number; publicationEnqueued?: number; @@ -410,6 +423,7 @@ const EXACT_REVIEW_QUEUE_META_TABLE = "exact_review_queue_meta"; const EXACT_REVIEW_QUEUE_ITEM_TABLE = "exact_review_queue_items"; const EXACT_REVIEW_QUEUE_DELIVERY_TABLE = "exact_review_queue_deliveries"; const EXACT_REVIEW_QUEUE_INGRESS_TABLE = "exact_review_queue_ingress"; +const EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE = "exact_review_queue_edit_semantic"; const EXACT_REVIEW_QUEUE_METRICS_TABLE = "exact_review_queue_metrics"; const EXACT_REVIEW_QUEUE_METRIC_BUCKET_TABLE = "exact_review_queue_metric_buckets"; const EXACT_REVIEW_QUEUE_SUPERSESSION_TABLE = "exact_review_queue_supersessions"; @@ -534,7 +548,7 @@ export class ExactReviewQueue { existing.deliveryId !== deliveryId || existing.installationId !== installationId || stableJson(exactReviewDecisionWithoutSourceAuthority(existing.decision)) !== - stableJson(decision) || + stableJson(exactReviewDecisionWithoutSourceAuthority(decision)) || stableJson(existing.ingress || null) !== stableJson(ingress || null) ) { throw new Error("conflicting exact-review source authority reservation"); @@ -929,6 +943,7 @@ export class ExactReviewQueue { } const now = Date.now(); + const semanticEdited = await exactReviewEditedSemanticInput(decision); const incomingPublicationRevision = exactReviewPublicationRevision(decision); const activeBatchItemKeys = incomingPublicationRevision ? new Set(this.batchStore.activeLeaseSnapshot(now).itemKeys) @@ -936,6 +951,7 @@ export class ExactReviewQueue { const accepted = this.storage.transactionSync(() => { this.pruneDeliveryReceiptsSync(now); this.pruneIngressReceiptsSync(now); + this.pruneEditedSemanticInputsSync(now); this.storage.sql.exec( `DELETE FROM ${EXACT_REVIEW_QUEUE_DELIVERY_TABLE} WHERE delivery_id = ? AND received_at <= ?`, @@ -959,6 +975,34 @@ export class ExactReviewQueue { ? this.recordIngressSync(ingress, decision.targetBranch, now) : null; + if (semanticEdited && this.isDuplicateEditedSemanticInputSync(semanticEdited, now)) { + const state = this.readStateSync(); + const current = + state.items[semanticEdited.queueKey] ?? + Object.values(state.items).find( + (item) => + !exactReviewQueueIsPublication(item) && + item.key.toLowerCase() === semanticEdited.storageKey, + ); + if ( + current && + !exactReviewQueueIsPublication(current) && + exactReviewDecisionCanSupersedeReview(current, decision) + ) { + advanceExactReviewSourceAuthorityWatermark(current, decision); + this.writeStateSync(state); + } else { + this.syncLegacyCompatibilitySync(state); + } + this.incrementQueueMetricsSync({ reviewSemanticDeduped: 1 }); + return { + deduped: true as const, + semanticEdited: true as const, + key: current?.key || semanticEdited.queueKey, + state, + }; + } + const state = this.readStateSync(); // A delayed or lost alarm must not let an expired one-shot recovery // suppress the next failed shard's recovery delivery. @@ -1253,6 +1297,7 @@ export class ExactReviewQueue { current.firstFailureAt = undefined; current.lastFailureReason = undefined; } + advanceExactReviewSourceAuthorityWatermark(current, decision); ingressAdmitted = true; } } else { @@ -1276,6 +1321,9 @@ export class ExactReviewQueue { updatedAt: now, nextAttemptAt: exactReviewQueueDebouncedAttemptAt(state, decision, now, now, this.env), attempts: 0, + ...(exactReviewSourceAuthorityWatermark(decision) + ? { sourceAuthorityWatermark: exactReviewSourceAuthorityWatermark(decision)! } + : {}), }; ingressAdmitted = true; } @@ -1294,6 +1342,7 @@ export class ExactReviewQueue { delete state.items[key].ingressFingerprint; } } + if (semanticEdited) this.recordEditedSemanticInputSync(semanticEdited, now); this.writeStateSync(state); if (supersededPublications) { this.incrementQueueMetricsSync({ @@ -1322,13 +1371,21 @@ export class ExactReviewQueue { item_key: "semantic" in accepted && accepted.semantic ? accepted.key - : exactReviewItemKey(decision), + : "semanticEdited" in accepted && accepted.semanticEdited + ? accepted.key + : exactReviewItemKey(decision), ...("semantic" in accepted && accepted.semantic ? { semantic_deduped: true, semantic_duplicates_removed: accepted.semanticDuplicatesRemoved, } : {}), + ...("semanticEdited" in accepted && accepted.semanticEdited + ? { + dedupe_scope: "semantic_edited", + dedupe_reason: "unchanged_pull_request_edit", + } + : {}), ...("staleSource" in accepted && accepted.staleSource ? { stale_source: true } : {}), ...(accepted.superseded ? { @@ -2252,6 +2309,7 @@ export class ExactReviewQueue { const now = Date.now(); const snapshot = this.storage.transactionSync(() => { this.pruneDeliveryReceiptsSync(now); + this.pruneEditedSemanticInputsSync(now); this.pruneStateAppendReceiptsSync(now); this.reclaimExpiredStateAppendDrainsSync(now); this.pruneQueueTelemetrySync(now); @@ -2362,6 +2420,7 @@ export class ExactReviewQueue { enqueued_total: metrics.review.enqueued, completed_total: metrics.review.completed, superseded_total: metrics.review.superseded, + semantic_deduped_total: metrics.review.semanticDeduped, flow: reviewFlow, }, publication: { @@ -5056,6 +5115,20 @@ export class ExactReviewQueue { received_at INTEGER NOT NULL ) STRICT`, ); + // Store only the current SHA-256 tuple for a PR. That lets a later genuine + // change back to an older tuple enqueue normally, while duplicate webhook + // deliveries of the current edit stay idempotent even after queue handoff. + this.storage.sql.exec( + `CREATE TABLE IF NOT EXISTS ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} ( + item_key TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + observed_at INTEGER NOT NULL + ) STRICT`, + ); + this.storage.sql.exec( + `CREATE INDEX IF NOT EXISTS exact_review_queue_edit_semantic_observed_at + ON ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} (observed_at, item_key)`, + ); this.storage.sql.exec( `CREATE TABLE IF NOT EXISTS ${EXACT_REVIEW_PUBLICATION_HEAD_TABLE} ( target_key TEXT PRIMARY KEY, @@ -5230,6 +5303,8 @@ export class ExactReviewQueue { review_completed_total INTEGER NOT NULL DEFAULT 0 CHECK (review_completed_total >= 0), review_superseded_total INTEGER NOT NULL DEFAULT 0 CHECK (review_superseded_total >= 0), + review_semantic_deduped_total INTEGER NOT NULL DEFAULT 0 + CHECK (review_semantic_deduped_total >= 0), publication_enqueued_total INTEGER NOT NULL DEFAULT 0 CHECK (publication_enqueued_total >= 0), publication_completed_total INTEGER NOT NULL CHECK (publication_completed_total >= 0) @@ -5239,6 +5314,7 @@ export class ExactReviewQueue { "review_enqueued_total", "review_completed_total", "review_superseded_total", + "review_semantic_deduped_total", "publication_enqueued_total", "publication_published_total", "publication_superseded_total", @@ -5863,6 +5939,7 @@ export class ExactReviewQueue { const row = Array.from( this.storage.sql.exec( `SELECT review_enqueued_total, review_completed_total, review_superseded_total, + review_semantic_deduped_total, publication_enqueued_total, publication_completed_total, publication_published_total, publication_superseded_total, publication_semantic_deduped_total, @@ -5876,6 +5953,7 @@ export class ExactReviewQueue { review_enqueued_total?: number; review_completed_total?: number; review_superseded_total?: number; + review_semantic_deduped_total?: number; publication_enqueued_total?: number; publication_completed_total?: number; publication_published_total?: number; @@ -5891,6 +5969,7 @@ export class ExactReviewQueue { enqueued: exactReviewMetricTotal(row?.review_enqueued_total), completed: exactReviewMetricTotal(row?.review_completed_total), superseded: exactReviewMetricTotal(row?.review_superseded_total), + semanticDeduped: exactReviewMetricTotal(row?.review_semantic_deduped_total), }, publication: { enqueued: exactReviewMetricTotal(row?.publication_enqueued_total), @@ -5909,6 +5988,7 @@ export class ExactReviewQueue { const reviewEnqueued = exactReviewMetricDelta(delta.reviewEnqueued); const reviewCompleted = exactReviewMetricDelta(delta.reviewCompleted); const reviewSuperseded = exactReviewMetricDelta(delta.reviewSuperseded); + const reviewSemanticDeduped = exactReviewMetricDelta(delta.reviewSemanticDeduped); const reviewRetried = exactReviewMetricDelta(delta.reviewRetried); const reviewShed = exactReviewMetricDelta(delta.reviewShed); const publicationEnqueued = exactReviewMetricDelta(delta.publicationEnqueued); @@ -5923,6 +6003,7 @@ export class ExactReviewQueue { !reviewEnqueued && !reviewCompleted && !reviewSuperseded && + !reviewSemanticDeduped && !reviewRetried && !reviewShed && !publicationEnqueued && @@ -5941,6 +6022,7 @@ export class ExactReviewQueue { SET review_enqueued_total = review_enqueued_total + ?, review_completed_total = review_completed_total + ?, review_superseded_total = review_superseded_total + ?, + review_semantic_deduped_total = review_semantic_deduped_total + ?, publication_enqueued_total = publication_enqueued_total + ?, publication_completed_total = publication_completed_total + ?, publication_published_total = publication_published_total + ?, @@ -5953,6 +6035,7 @@ export class ExactReviewQueue { reviewEnqueued, reviewCompleted, reviewSuperseded, + reviewSemanticDeduped, publicationEnqueued, publicationCompleted, publicationPublished, @@ -6814,6 +6897,27 @@ export class ExactReviewQueue { } } + private pruneEditedSemanticInputsSync(now: number) { + const cutoff = now - EXACT_REVIEW_QUEUE_DELIVERY_TTL_MS; + for (let batch = 0; batch < EXACT_REVIEW_QUEUE_DELIVERY_PRUNE_MAX_BATCHES; batch += 1) { + const deleted = Array.from( + this.storage.sql.exec( + `DELETE FROM ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} + WHERE item_key IN ( + SELECT item_key + FROM ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} + WHERE observed_at <= ? + ORDER BY observed_at, item_key + LIMIT ${EXACT_REVIEW_QUEUE_DELIVERY_PRUNE_BATCH} + ) + RETURNING item_key`, + cutoff, + ), + ); + if (deleted.length < EXACT_REVIEW_QUEUE_DELIVERY_PRUNE_BATCH) break; + } + } + private recordIngressSync(ingress: ExactReviewIngress, targetBranch: string, now: number) { const counterpart = ingress.route === "direct_webhook" ? "target_dispatcher" : "direct_webhook"; const matched = Array.from( @@ -6853,6 +6957,38 @@ export class ExactReviewQueue { ); } + private isDuplicateEditedSemanticInputSync(input: ExactReviewEditedSemanticInput, now: number) { + const previous = Array.from( + this.storage.sql.exec( + `SELECT fingerprint FROM ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} + WHERE item_key = ?`, + input.storageKey, + ), + )[0] as { fingerprint?: string } | undefined; + if (previous?.fingerprint !== input.fingerprint) return false; + this.storage.sql.exec( + `UPDATE ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} + SET observed_at = ? + WHERE item_key = ?`, + now, + input.storageKey, + ); + return true; + } + + private recordEditedSemanticInputSync(input: ExactReviewEditedSemanticInput, now: number) { + this.storage.sql.exec( + `INSERT INTO ${EXACT_REVIEW_QUEUE_EDIT_SEMANTIC_TABLE} (item_key, fingerprint, observed_at) + VALUES (?, ?, ?) + ON CONFLICT(item_key) DO UPDATE SET + fingerprint = excluded.fingerprint, + observed_at = excluded.observed_at`, + input.storageKey, + input.fingerprint, + now, + ); + } + private deliveryReceiptCountSync() { const row = Array.from( this.storage.sql.exec( @@ -7167,6 +7303,12 @@ function exactReviewDecisionWithoutSourceAuthority(decision: ExactReviewDecision const { sourceAuthoritySeq: _sourceAuthoritySeq, sourceHeadVerified: _sourceHeadVerified, + // These semantic edit fields were added after source-authority reservations + // already existed. They affect queue admission, not the identity of an + // already-reserved delivery, so omit them while matching a redelivery. + sourceBaseSha: _sourceBaseSha, + sourceIsDraft: _sourceIsDraft, + sourceContentRevision: _sourceContentRevision, ...rest } = decision; return rest; @@ -7258,6 +7400,19 @@ function exactReviewBaseDecisionFrom(value): ExactReviewBaseDecision | null { .trim() .toLowerCase() : undefined; + const hasSourceBaseSha = Object.hasOwn(decision, "sourceBaseSha"); + const sourceBaseSha = hasSourceBaseSha + ? String(decision.sourceBaseSha || "") + .trim() + .toLowerCase() + : undefined; + const hasSourceIsDraft = Object.hasOwn(decision, "sourceIsDraft"); + const hasSourceContentRevision = Object.hasOwn(decision, "sourceContentRevision"); + const sourceContentRevision = hasSourceContentRevision + ? String(decision.sourceContentRevision || "") + .trim() + .toLowerCase() + : undefined; const hasSourceHeadVerified = Object.hasOwn(decision, "sourceHeadVerified"); const hasSourceAuthoritySeq = Object.hasOwn(decision, "sourceAuthoritySeq"); const sourceAuthoritySeq = hasSourceAuthoritySeq @@ -7280,6 +7435,11 @@ function exactReviewBaseDecisionFrom(value): ExactReviewBaseDecision | null { if (sourceEvent !== "issues" && sourceEvent !== "pull_request") return null; if (!sourceAction) return null; if (hasSourceHeadSha && !/^[0-9a-f]{40}$/.test(sourceHeadSha || "")) return null; + if (hasSourceBaseSha && !/^[0-9a-f]{40}$/.test(sourceBaseSha || "")) return null; + if (hasSourceIsDraft && typeof decision.sourceIsDraft !== "boolean") return null; + if (hasSourceContentRevision && !/^[0-9a-f]{64}$/.test(sourceContentRevision || "")) { + return null; + } if (hasSourceHeadVerified && typeof decision.sourceHeadVerified !== "boolean") return null; if ( hasSourceAuthoritySeq && @@ -7318,6 +7478,9 @@ function exactReviewBaseDecisionFrom(value): ExactReviewBaseDecision | null { sourceAction, supersedesInProgress: Boolean(decision.supersedesInProgress), ...(hasSourceHeadSha ? { sourceHeadSha } : {}), + ...(hasSourceBaseSha ? { sourceBaseSha } : {}), + ...(hasSourceIsDraft ? { sourceIsDraft: decision.sourceIsDraft } : {}), + ...(hasSourceContentRevision ? { sourceContentRevision } : {}), ...(hasSourceHeadVerified ? { sourceHeadVerified: decision.sourceHeadVerified } : {}), ...(hasSourceAuthoritySeq ? { sourceAuthoritySeq } : {}), ...(hasSourceUpdatedAt ? { sourceUpdatedAt } : {}), @@ -7333,6 +7496,56 @@ function exactReviewBaseDecisionFrom(value): ExactReviewBaseDecision | null { }; } +async function exactReviewEditedSemanticInput( + decision: ExactReviewDecision, +): Promise { + if ( + decision.itemKind !== "pull_request" || + decision.sourceEvent !== "pull_request" || + decision.sourceAction !== "edited" || + decision.publication + ) { + return null; + } + const headSha = String(decision.sourceHeadSha || "").toLowerCase(); + const baseSha = String(decision.sourceBaseSha || "").toLowerCase(); + const contentRevision = String(decision.sourceContentRevision || "").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(headSha) || !/^[0-9a-f]{40}$/.test(baseSha)) return null; + if (typeof decision.sourceIsDraft !== "boolean") return null; + if (!/^[0-9a-f]{64}$/.test(contentRevision)) return null; + + const tuple = stableJson({ + version: 1, + target_repo: decision.targetRepo.toLowerCase(), + target_branch: decision.targetBranch, + item_number: decision.itemNumber, + head_sha: headSha, + base_sha: baseSha, + is_draft: decision.sourceIsDraft, + content_revision: contentRevision, + request: { + codex_timeout_ms: Number.isFinite(decision.codexTimeoutMs) ? decision.codexTimeoutMs : null, + media_proof_timeout_ms: Number.isFinite(decision.mediaProofTimeoutMs) + ? decision.mediaProofTimeoutMs + : null, + command_status_marker: decision.commandStatusMarker || null, + status_comment_id: Number.isSafeInteger(decision.statusCommentId) + ? decision.statusCommentId + : null, + additional_prompt: decision.additionalPrompt || null, + }, + }); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tuple)); + const queueKey = exactReviewItemKey(decision); + return { + queueKey, + storageKey: queueKey.toLowerCase(), + fingerprint: Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""), + }; +} + function exactReviewPublicationRevision(decision: ExactReviewDecision): { targetKey: string; sourceRevision: number; @@ -7492,7 +7705,8 @@ function exactReviewDecisionCanSupersedeReview( const incomingHead = String(incoming.sourceHeadSha || "").toLowerCase(); if (!/^[0-9a-f]{40}$/.test(incomingHead)) return false; const incomingAuthoritySeq = Number(incoming.sourceAuthoritySeq || 0); - const activeSourceAuthoritySeq = Number(active.sourceAuthoritySeq || 0); + const watermark = current.sourceAuthorityWatermark; + const activeSourceAuthoritySeq = Number(watermark?.sequence || active.sourceAuthoritySeq || 0); const activeHasAuthority = Number.isSafeInteger(activeSourceAuthoritySeq) && activeSourceAuthoritySeq > 0; if (!/^[0-9a-f]{40}$/.test(activeHead)) { @@ -7513,7 +7727,7 @@ function exactReviewDecisionCanSupersedeReview( } if (!Number.isSafeInteger(incomingAuthoritySeq) || incomingAuthoritySeq <= 0) return false; - const activeUpdatedAt = Date.parse(String(active.sourceUpdatedAt || "")); + const activeUpdatedAt = Date.parse(String(watermark?.updatedAt || active.sourceUpdatedAt || "")); const incomingUpdatedAt = Date.parse(String(incoming.sourceUpdatedAt || "")); if ( Number.isFinite(activeUpdatedAt) && @@ -7526,6 +7740,27 @@ function exactReviewDecisionCanSupersedeReview( return incomingAuthoritySeq > activeSourceAuthoritySeq; } +function exactReviewSourceAuthorityWatermark( + decision: ExactReviewDecision, +): ExactReviewQueueItem["sourceAuthorityWatermark"] | null { + if (decision.itemKind !== "pull_request") return null; + const sequence = Number(decision.sourceAuthoritySeq || 0); + if (!Number.isSafeInteger(sequence) || sequence <= 0) return null; + const updatedAt = String(decision.sourceUpdatedAt || ""); + return { + sequence, + ...(Number.isFinite(Date.parse(updatedAt)) ? { updatedAt } : {}), + }; +} + +function advanceExactReviewSourceAuthorityWatermark( + item: ExactReviewQueueItem, + decision: ExactReviewDecision, +) { + const watermark = exactReviewSourceAuthorityWatermark(decision); + if (watermark) item.sourceAuthorityWatermark = watermark; +} + function exactReviewItemKey(decision: ExactReviewDecision) { const base = `${decision.targetRepo}#${decision.itemNumber}`; return decision.publication diff --git a/dashboard/worker.ts b/dashboard/worker.ts index 590fab3f37..a9ec7f6fb8 100644 --- a/dashboard/worker.ts +++ b/dashboard/worker.ts @@ -542,6 +542,8 @@ export default { return githubWebhook(request, env, ctx); if (url.pathname === "/internal/exact-review/enqueue" && request.method === "POST") return authenticatedExactReviewEnqueue(request, env); + if (url.pathname === "/internal/exact-review/source-authority" && request.method === "POST") + return authenticatedExactReviewQueueRequest(request, env, "/source-authority"); const canonicalRecordPath = request.method === "GET" ? /^\/internal\/state\/records\/[^/]+\/(?:items|closed|plans|decision-packets)\/[1-9]\d*$/.exec( @@ -1071,7 +1073,12 @@ async function githubWebhook(request, env, ctx) { if ("type" in decision && decision.type === "item") { const deliveryId = request.headers.get("x-github-delivery") || ""; - const itemDecision = decision as ExactReviewDecision & { installationId?: number }; + let itemDecision = decision as ExactReviewDecision & { installationId?: number }; + itemDecision = await withPullRequestEditContentRevision({ + event, + payload, + decision: itemDecision, + }); const ingress = await exactReviewPullRequestIngress({ event, payload, @@ -1341,6 +1348,29 @@ function exactWebhookTimestamp(value) { return text && Number.isFinite(Date.parse(text)) ? text : null; } +async function withPullRequestEditContentRevision({ event, payload, decision }) { + if ( + event !== "pull_request" || + decision.itemKind !== "pull_request" || + decision.sourceAction !== "edited" + ) { + return decision; + } + const pullRequest = objectValue(payload.pull_request); + if ( + typeof pullRequest.title !== "string" || + (pullRequest.body !== null && typeof pullRequest.body !== "string") + ) { + return decision; + } + const title = pullRequest.title; + const body = pullRequest.body || ""; + return { + ...decision, + sourceContentRevision: await sha256Text(JSON.stringify({ version: 1, title, body })), + }; +} + function classifyGithubItemWebhook({ event, payload }) { const action = String(payload.action || ""); const repo = objectValue(payload.repository); @@ -1394,6 +1424,10 @@ function classifyGithubItemWebhook({ event, payload }) { const sourceHeadSha = String(objectValue(pullRequest.head).sha || "") .trim() .toLowerCase(); + const sourceBaseSha = String(objectValue(pullRequest.base).sha || "") + .trim() + .toLowerCase(); + const sourceIsDraft = pullRequest.draft; const sourceUpdatedAt = exactWebhookTimestamp(pullRequest.updated_at); return { accepted: true, @@ -1406,6 +1440,8 @@ function classifyGithubItemWebhook({ event, payload }) { sourceEvent: "pull_request", sourceAction: action, ...(/^[0-9a-f]{40}$/.test(sourceHeadSha) ? { sourceHeadSha } : {}), + ...(/^[0-9a-f]{40}$/.test(sourceBaseSha) ? { sourceBaseSha } : {}), + ...(typeof sourceIsDraft === "boolean" ? { sourceIsDraft } : {}), ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), supersedesInProgress: [ "edited", diff --git a/src/repair/comment-webhook.ts b/src/repair/comment-webhook.ts index 8a6d702873..3b53fbfe49 100644 --- a/src/repair/comment-webhook.ts +++ b/src/repair/comment-webhook.ts @@ -58,6 +58,10 @@ type AcceptedItemWebhook = { sourceAction: string; supersedesInProgress: boolean; sourceHeadSha?: string; + sourceBaseSha?: string; + sourceIsDraft?: boolean; + sourceContentRevision?: string; + sourceUpdatedAt?: string; codexTimeoutMs?: number; mediaProofTimeoutMs?: number; }; @@ -306,6 +310,11 @@ export function classifyItemWebhook({ event, payload }: { event: string; payload const sourceHeadSha = String(asRecord(pull.head).sha ?? "") .trim() .toLowerCase(); + const sourceBaseSha = String(asRecord(pull.base).sha ?? "") + .trim() + .toLowerCase(); + const sourceContentRevision = pullRequestEditedContentRevision(action, pull); + const sourceUpdatedAt = exactWebhookTimestamp(pull.updated_at); const reviewBudget = adaptiveReviewBudgetForPullRequest(pull); return { accepted: true, @@ -318,6 +327,10 @@ export function classifyItemWebhook({ event, payload }: { event: string; payload sourceEvent: "pull_request", sourceAction: action, ...(/^[0-9a-f]{40}$/.test(sourceHeadSha) ? { sourceHeadSha } : {}), + ...(/^[0-9a-f]{40}$/.test(sourceBaseSha) ? { sourceBaseSha } : {}), + ...(typeof pull.draft === "boolean" ? { sourceIsDraft: pull.draft } : {}), + ...(sourceContentRevision ? { sourceContentRevision } : {}), + ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), supersedesInProgress: [ "edited", "synchronize", @@ -333,6 +346,20 @@ export function classifyItemWebhook({ event, payload }: { event: string; payload return { accepted: false, reason: "unsupported event" }; } +function pullRequestEditedContentRevision(action: string, pull: LooseRecord) { + if ( + action !== "edited" || + typeof pull.title !== "string" || + (pull.body !== null && typeof pull.body !== "string") + ) { + return null; + } + return crypto + .createHash("sha256") + .update(JSON.stringify({ version: 1, title: pull.title, body: pull.body || "" })) + .digest("hex"); +} + function isCloseGuardLabel(value: JsonValue) { const label = String(asRecord(value).name ?? "") .trim() @@ -730,11 +757,22 @@ async function dispatchItemReview({ source_event: accepted.sourceEvent, source_action: accepted.sourceAction, supersedes_in_progress: accepted.supersedesInProgress, - ...(accepted.sourceHeadSha ? { source_head_sha: accepted.sourceHeadSha } : {}), - ...(accepted.codexTimeoutMs ? { codex_timeout_ms: accepted.codexTimeoutMs } : {}), - ...(accepted.mediaProofTimeoutMs - ? { media_proof_timeout_ms: accepted.mediaProofTimeoutMs } - : {}), + queue_claim: { + ...(accepted.sourceHeadSha ? { source_head_sha: accepted.sourceHeadSha } : {}), + ...(accepted.sourceBaseSha ? { source_base_sha: accepted.sourceBaseSha } : {}), + ...(typeof accepted.sourceIsDraft === "boolean" + ? { source_is_draft: accepted.sourceIsDraft } + : {}), + ...(accepted.sourceContentRevision + ? { source_content_revision: accepted.sourceContentRevision } + : {}), + ...(accepted.sourceUpdatedAt ? { source_updated_at: accepted.sourceUpdatedAt } : {}), + installation_id: accepted.installationId, + ...(accepted.codexTimeoutMs ? { codex_timeout_ms: accepted.codexTimeoutMs } : {}), + ...(accepted.mediaProofTimeoutMs + ? { media_proof_timeout_ms: accepted.mediaProofTimeoutMs } + : {}), + }, }, }, }); diff --git a/test/dashboard-worker.test.ts b/test/dashboard-worker.test.ts index 54c238c11a..a978ce27b3 100644 --- a/test/dashboard-worker.test.ts +++ b/test/dashboard-worker.test.ts @@ -42,7 +42,11 @@ test("exact-review queue defaults to 64 of the 128 global workers", () => { test("exact-review source authority sequence survives queue restarts", async () => { const storage = new MemoryDurableStorage(); const queue = new ExactReviewQueue({ storage }, {}); - const reserve = (target: ExactReviewQueue, deliveryId: string) => + const reserve = ( + target: ExactReviewQueue, + deliveryId: string, + decisionOverrides: Record = {}, + ) => target.fetch( new Request("https://clawsweeper-exact-review-queue/source-authority", { method: "POST", @@ -59,6 +63,7 @@ test("exact-review source authority sequence survives queue restarts", async () supersedesInProgress: true, sourceHeadSha: "a".repeat(40), sourceUpdatedAt: "2026-07-23T13:00:02Z", + ...decisionOverrides, }, }), }), @@ -72,6 +77,21 @@ test("exact-review source authority sequence survives queue restarts", async () ok: true, source_authority_seq: 1, }); + // A reservation made before semantic edited-event fields were deployed must + // remain idempotent when the original delivery is redelivered afterwards. + assert.deepEqual( + await ( + await reserve(queue, "authority-delivery-1", { + sourceBaseSha: "b".repeat(40), + sourceIsDraft: false, + sourceContentRevision: "c".repeat(64), + }) + ).json(), + { + ok: true, + source_authority_seq: 1, + }, + ); assert.deepEqual(await (await reserve(queue, "authority-delivery-2")).json(), { ok: true, source_authority_seq: 2, @@ -84,6 +104,213 @@ test("exact-review source authority sequence survives queue restarts", async () }); }); +test("exact-review queue durably coalesces concurrent unchanged pull request edits", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue({ storage }, {}); + const targetRepo = "Steipete/Nameplate"; + const request = (deliveryId: string, sourceAuthoritySeq: number, repo = targetRepo) => + buildExactReviewQueueRequest(deliveryId, 750, "edited", "pull_request", repo, { + sourceHeadSha: "a".repeat(40), + sourceBaseSha: "b".repeat(40), + sourceIsDraft: false, + sourceContentRevision: "c".repeat(64), + sourceHeadVerified: true, + sourceAuthoritySeq, + sourceUpdatedAt: "2026-07-25T09:00:00Z", + }); + + const [first, duplicate] = await Promise.all([ + queue.fetch(request("semantic-edit-1", 1)), + queue.fetch(request("semantic-edit-2", 2, targetRepo.toLowerCase())), + ]); + const responses = await Promise.all([first.json(), duplicate.json()]); + assert.equal(responses.filter((response) => response.queued === true).length, 1); + assert.deepEqual( + responses.find((response) => response.deduped === true), + { + ok: true, + deduped: true, + item_key: "Steipete/Nameplate#750", + dedupe_scope: "semantic_edited", + dedupe_reason: "unchanged_pull_request_edit", + }, + ); + + const state = (await storage.get("exact-review-queue")) as { + items: Record< + string, + { + revision: number; + decision: { sourceAuthoritySeq?: number }; + sourceAuthorityWatermark?: { sequence: number; updatedAt?: string }; + } + >; + }; + assert.equal(state.items["Steipete/Nameplate#750"].revision, 1); + assert.equal(state.items["Steipete/Nameplate#750"].decision.sourceAuthoritySeq, 1); + assert.deepEqual(state.items["Steipete/Nameplate#750"].sourceAuthorityWatermark, { + sequence: 2, + updatedAt: "2026-07-25T09:00:00Z", + }); + const stats = await queue.fetch(new Request("https://clawsweeper-exact-review-queue/stats")); + assert.equal((await stats.json()).lanes.review.semantic_deduped_total, 1); +}); + +test("semantic edit suppression advances the pull request authority watermark", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue({ storage }, {}); + const request = ( + deliveryId: string, + sourceAuthoritySeq: number, + sourceUpdatedAt: string, + overrides: Record = {}, + ) => + buildExactReviewQueueRequest(deliveryId, 752, "edited", "pull_request", "openclaw/openclaw", { + sourceHeadSha: "a".repeat(40), + sourceBaseSha: "b".repeat(40), + sourceIsDraft: false, + sourceContentRevision: "c".repeat(64), + sourceHeadVerified: true, + sourceAuthoritySeq, + sourceUpdatedAt, + ...overrides, + }); + + assert.equal( + (await queue.fetch(request("watermark-original", 1, "2026-07-25T09:00:01Z"))).status, + 202, + ); + // An older compatible producer that lacks a content digest still queues + // normally. Its authority tuple must not allow a delayed revision to win + // after a newer duplicate of the original semantic edit is suppressed. + assert.equal( + ( + await queue.fetch( + request("watermark-undigested", 2, "2026-07-25T09:00:02Z", { + sourceContentRevision: undefined, + }), + ) + ).status, + 202, + ); + const duplicate = await queue.fetch(request("watermark-duplicate", 3, "2026-07-25T09:00:03Z")); + assert.equal((await duplicate.json()).dedupe_scope, "semantic_edited"); + + const delayed = await queue.fetch( + request("watermark-delayed", 4, "2026-07-25T09:00:02Z", { + sourceContentRevision: "d".repeat(64), + }), + ); + assert.deepEqual(await delayed.json(), { + ok: true, + deduped: true, + item_key: "openclaw/openclaw#752", + stale_source: true, + }); + const state = (await storage.get("exact-review-queue")) as { + items: Record< + string, + { revision: number; sourceAuthorityWatermark?: { sequence: number; updatedAt?: string } } + >; + }; + assert.equal(state.items["openclaw/openclaw#752"].revision, 2); + assert.deepEqual(state.items["openclaw/openclaw#752"].sourceAuthorityWatermark, { + sequence: 3, + updatedAt: "2026-07-25T09:00:03Z", + }); +}); + +test("exact-review queue retains edits with a changed review tuple", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue({ storage }, {}); + const headSha = "a".repeat(40); + const baseSha = "b".repeat(40); + const request = ( + deliveryId: string, + sourceAuthoritySeq: number, + overrides: Record = {}, + ) => + buildExactReviewQueueRequest(deliveryId, 751, "edited", "pull_request", "openclaw/openclaw", { + sourceHeadSha: headSha, + sourceBaseSha: baseSha, + sourceIsDraft: false, + sourceContentRevision: "c".repeat(64), + sourceHeadVerified: true, + sourceAuthoritySeq, + sourceUpdatedAt: "2026-07-25T09:00:00Z", + ...overrides, + }); + + for (const [deliveryId, sourceAuthoritySeq, overrides] of [ + ["semantic-tuple-1", 1, {}], + ["semantic-tuple-base", 2, { sourceBaseSha: "c".repeat(40) }], + ["semantic-tuple-draft", 3, { sourceBaseSha: "c".repeat(40), sourceIsDraft: true }], + [ + "semantic-tuple-content", + 4, + { + sourceBaseSha: "c".repeat(40), + sourceIsDraft: true, + sourceContentRevision: "d".repeat(64), + }, + ], + [ + "semantic-tuple-command", + 5, + { + sourceBaseSha: "c".repeat(40), + sourceIsDraft: true, + sourceContentRevision: "d".repeat(64), + additionalPrompt: "Review the revised request.", + }, + ], + [ + "semantic-tuple-head", + 6, + { + sourceHeadSha: "d".repeat(40), + sourceBaseSha: "c".repeat(40), + sourceIsDraft: true, + sourceContentRevision: "d".repeat(64), + additionalPrompt: "Review the revised request.", + }, + ], + ] as const) { + const response = await queue.fetch(request(deliveryId, sourceAuthoritySeq, overrides)); + assert.deepEqual(await response.json(), { + ok: true, + queued: true, + item_key: "openclaw/openclaw#751", + superseded_publications: 0, + }); + } + + const state = (await storage.get("exact-review-queue")) as { + items: Record< + string, + { + revision: number; + decision: { + sourceHeadSha?: string; + sourceBaseSha?: string; + sourceIsDraft?: boolean; + sourceContentRevision?: string; + additionalPrompt?: string; + }; + } + >; + }; + assert.equal(state.items["openclaw/openclaw#751"].revision, 6); + assert.equal(state.items["openclaw/openclaw#751"].decision.sourceHeadSha, "d".repeat(40)); + assert.equal(state.items["openclaw/openclaw#751"].decision.sourceBaseSha, "c".repeat(40)); + assert.equal(state.items["openclaw/openclaw#751"].decision.sourceIsDraft, true); + assert.equal(state.items["openclaw/openclaw#751"].decision.sourceContentRevision, "d".repeat(64)); + assert.equal( + state.items["openclaw/openclaw#751"].decision.additionalPrompt, + "Review the revised request.", + ); +}); + test("exact-review supersession audit migration preserves legacy records", () => { const storage = new MemoryDurableStorage(); storage.sql.exec(`CREATE TABLE exact_review_queue_supersessions ( @@ -7957,6 +8184,47 @@ test("authenticated legacy exact-review intake enters the durable queue", async assert.equal(denied.status, 401); }); +test("authenticated legacy pull request intake reserves source authority", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue({ storage }, {}); + const payload = JSON.stringify({ + delivery_id: "legacy:edited:857:1", + installation_id: 123, + decision: { + targetRepo: "openclaw/gogcli", + targetBranch: "main", + itemNumber: 857, + itemKind: "pull_request", + sourceEvent: "pull_request", + sourceAction: "edited", + supersedesInProgress: true, + sourceHeadSha: "a".repeat(40), + sourceBaseSha: "b".repeat(40), + sourceIsDraft: false, + sourceContentRevision: "c".repeat(64), + sourceUpdatedAt: "2026-07-26T09:00:00Z", + }, + }); + const signature = `sha256=${createHmac("sha256", "test-secret").update(payload).digest("hex")}`; + + const accepted = await worker.fetch( + new Request("https://clawsweeper.openclaw.ai/internal/exact-review/source-authority", { + method: "POST", + headers: { + "content-type": "application/json", + "x-clawsweeper-exact-review-signature": signature, + }, + body: payload, + }), + { + CLAWSWEEPER_WEBHOOK_SECRET: "test-secret", + EXACT_REVIEW_QUEUE: new MemoryDurableNamespace(queue), + }, + ); + assert.equal(accepted.status, 200); + assert.deepEqual(await accepted.json(), { ok: true, source_authority_seq: 1 }); +}); + test("exact-review queue rejects unbounded or unsafe command context", async () => { const queue = new ExactReviewQueue({ storage: new MemoryDurableStorage() }, {}); const invalidDecisions = [ @@ -14980,7 +15248,7 @@ test("hosted issue webhook enqueues without completing pull request authority", assert.equal(authorityCompletionCalls, 0); }); -test("hosted synchronize webhook binds and enqueues only the live pull request head", async () => { +test("hosted edited webhook binds and enqueues only the live pull request head", async () => { const originalFetch = globalThis.fetch; const originalNow = Date.now; const now = 3_000_000; @@ -14989,6 +15257,11 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h const queue = new ExactReviewQueue({ storage }, {}); const staleHeadSha = "a".repeat(40); const sourceHeadSha = "b".repeat(40); + const sourceContentRevision = createHash("sha256") + .update( + JSON.stringify({ version: 1, title: "Document the edit", body: "Fresh review context." }), + ) + .digest("hex"); let verificationCalls = 0; globalThis.fetch = async (input) => { verificationCalls += 1; @@ -15005,7 +15278,7 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h secret: "test-secret", deliveryId, payload: { - action: "synchronize", + action: "edited", repository: { full_name: "openclaw/gogcli", default_branch: "trunk", @@ -15017,6 +15290,10 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h pull_request: { number: 596, head: { sha: headSha }, + base: { sha: "c".repeat(40) }, + draft: false, + title: "Document the edit", + body: "Fresh review context.", updated_at: updatedAt, }, installation: { id: 123 }, @@ -15030,7 +15307,7 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h ); try { - const response = await send(sourceHeadSha, "2026-07-23T13:00:02Z", "synchronize-current-596"); + const response = await send(sourceHeadSha, "2026-07-23T13:00:02Z", "edited-current-596"); assert.equal(response.status, 202); const duplicateResponse = await send( sourceHeadSha, @@ -15041,8 +15318,10 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h ok: true, deduped: true, item_key: "openclaw/gogcli#596", + dedupe_scope: "semantic_edited", + dedupe_reason: "unchanged_pull_request_edit", }); - assert.equal(verificationCalls, 1); + assert.equal(verificationCalls, 2); const staleResponse = await send(staleHeadSha, "2026-07-23T13:00:01Z", "synchronize-stale-596"); assert.equal(staleResponse.status, 202); assert.deepEqual(await staleResponse.json(), { @@ -15050,7 +15329,7 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h accepted: false, reason: "stale pull request head", }); - assert.equal(verificationCalls, 2); + assert.equal(verificationCalls, 3); const staleDuplicateResponse = await send( staleHeadSha, "2026-07-23T13:00:01Z", @@ -15061,8 +15340,8 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h deduped: true, item_key: "openclaw/gogcli#596", }); - assert.equal(verificationCalls, 2); - assert.equal(storage.rawGet("exact-review-source-authority-sequence:v1"), 2); + assert.equal(verificationCalls, 3); + assert.equal(storage.rawGet("exact-review-source-authority-sequence:v1"), 3); const stored = (await storage.get("exact-review-queue")) as { items: Record< string, @@ -15082,9 +15361,12 @@ test("hosted synchronize webhook binds and enqueues only the live pull request h itemNumber: 596, itemKind: "pull_request", sourceEvent: "pull_request", - sourceAction: "synchronize", + sourceAction: "edited", supersedesInProgress: true, sourceHeadSha, + sourceBaseSha: "c".repeat(40), + sourceIsDraft: false, + sourceContentRevision, sourceHeadVerified: true, sourceAuthoritySeq: 1, sourceUpdatedAt: "2026-07-23T13:00:02Z", diff --git a/test/repair/comment-webhook.test.ts b/test/repair/comment-webhook.test.ts index e4aaaa5bdd..7c7abf1912 100644 --- a/test/repair/comment-webhook.test.ts +++ b/test/repair/comment-webhook.test.ts @@ -368,6 +368,47 @@ test("webhook accepts eligible pull request events for generic steipete reposito }); }); +test("webhook carries the semantic tuple through edited pull request fallback intake", () => { + const title = "Clarify the review request"; + const body = "The revised context is ready for review."; + const result = classifyWebhook({ + event: "pull_request", + payload: { + action: "edited", + repository: { + full_name: "openclaw/openclaw", + private: false, + archived: false, + fork: false, + has_issues: true, + }, + pull_request: { + number: 857, + head: { sha: "a".repeat(40) }, + base: { sha: "b".repeat(40) }, + draft: false, + title, + body, + updated_at: "2026-07-26T09:00:00Z", + }, + installation: { id: 456 }, + }, + }); + + assert.equal(result.accepted, true); + if (!result.accepted || result.type !== "item") return; + assert.equal(result.sourceBaseSha, "b".repeat(40)); + assert.equal(result.sourceIsDraft, false); + assert.equal(result.sourceUpdatedAt, "2026-07-26T09:00:00Z"); + assert.equal( + result.sourceContentRevision, + crypto + .createHash("sha256") + .update(JSON.stringify({ version: 1, title, body })) + .digest("hex"), + ); +}); + test("adaptive Codex timeout preserves the default for small non-media PRs", () => { assert.equal( adaptiveCodexTimeoutMsForTest({ @@ -448,7 +489,7 @@ test("pull request webhooks dispatch adaptive Codex timeout payload", async () = const result = await handleGitHubWebhook({ event: "pull_request", payload: { - action: "synchronize", + action: "edited", repository: { full_name: "openclaw/openclaw", default_branch: "main", @@ -460,6 +501,9 @@ test("pull request webhooks dispatch adaptive Codex timeout payload", async () = pull_request: { number: 91093, head: { sha: "b".repeat(40) }, + base: { sha: "c".repeat(40) }, + draft: false, + title: "Add direct fallback semantic ingress coverage", changed_files: 71, additions: 4176, deletions: 0, @@ -468,6 +512,7 @@ test("pull request webhooks dispatch adaptive Codex timeout payload", async () = "https://uploads.example.invalid/proof-a.mov", "https://uploads.example.invalid/proof-b.mp4", ].join("\n"), + updated_at: "2026-07-26T09:00:00Z", }, installation: { id: 123 }, }, @@ -478,18 +523,34 @@ test("pull request webhooks dispatch adaptive Codex timeout payload", async () = body: { ok: true, dispatched: "clawsweeper_item" }, }); assert.equal(dispatchedBody?.event_type, "clawsweeper_item"); + const clientPayload = dispatchedBody?.client_payload as Record; + const queueClaim = clientPayload.queue_claim as Record; + assert.ok(Object.keys(clientPayload).length <= 10, JSON.stringify(clientPayload)); + assert.equal(queueClaim.codex_timeout_ms, 1_268_800); + assert.equal(queueClaim.media_proof_timeout_ms, 240_000); + assert.equal(clientPayload.source_head_sha, undefined); + assert.equal(queueClaim.source_head_sha, "b".repeat(40)); + assert.equal(queueClaim.source_base_sha, "c".repeat(40)); + assert.equal(queueClaim.source_is_draft, false); assert.equal( - (dispatchedBody?.client_payload as Record)?.codex_timeout_ms, - 1_268_800, - ); - assert.equal( - (dispatchedBody?.client_payload as Record)?.media_proof_timeout_ms, - 240_000, - ); - assert.equal( - (dispatchedBody?.client_payload as Record)?.source_head_sha, - "b".repeat(40), + queueClaim.source_content_revision, + crypto + .createHash("sha256") + .update( + JSON.stringify({ + version: 1, + title: "Add direct fallback semantic ingress coverage", + body: [ + "Proof:", + "https://uploads.example.invalid/proof-a.mov", + "https://uploads.example.invalid/proof-b.mp4", + ].join("\n"), + }), + ) + .digest("hex"), ); + assert.equal(queueClaim.source_updated_at, "2026-07-26T09:00:00Z"); + assert.equal(queueClaim.installation_id, 123); } finally { globalThis.fetch = previousFetch; restoreEnv("CLAWSWEEPER_APP_ID", previousAppId); diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts index bdc342db0a..6d42deae89 100644 --- a/test/sweep-workflow.test.ts +++ b/test/sweep-workflow.test.ts @@ -2646,9 +2646,19 @@ test("sweep event reviews and target fanout avoid storm amplification", () => { assert.match(eventBlock, /cancel-in-progress: false/); assert.match(legacyIntakeBlock, /legacy-event-queue-intake:/); assert.match(legacyIntakeBlock, /\/internal\/exact-review\/enqueue/); + assert.match(legacyIntakeBlock, /\/internal\/exact-review\/source-authority/); assert.match(legacyIntakeBlock, /gh api "repos\/\$target_repo" --jq \.default_branch/); assert.match(legacyIntakeBlock, /targetBranch: process\.env\.TARGET_BRANCH/); assert.doesNotMatch(legacyIntakeBlock, /targetBranch: payload\.target_branch \|\| "main"/); + assert.match(legacyIntakeBlock, /sourceBaseSha/); + assert.match(legacyIntakeBlock, /sourceIsDraft/); + assert.match(legacyIntakeBlock, /sourceContentRevision/); + assert.match(legacyIntakeBlock, /sourceUpdatedAt/); + assert.match(legacyIntakeBlock, /queueClaim\.installation_id \?\? payload\.installation_id/); + assert.match(legacyIntakeBlock, /payload\.source_action === "edited"/); + assert.match(legacyIntakeBlock, /\^\[0-9a-f\]\{40\}\$/); + assert.match(legacyIntakeBlock, /typeof sourceIsDraft === "boolean"/); + assert.match(legacyIntakeBlock, /\^\[0-9a-f\]\{64\}\$/); assert.match(legacyIntakeBlock, /commandStatusMarker: payload\.command_status_marker/); assert.match(legacyIntakeBlock, /statusCommentId: payload\.status_comment_id/); assert.match(legacyIntakeBlock, /additionalPrompt: payload\.additional_prompt/);