Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 197 additions & 47 deletions src/clawsweeper-apply-decision-workflow.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/clawsweeper-record-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export function createRecordMetadata({
markdown,
reviewedAt: frontMatterValue(markdown, "reviewed_at"),
itemUpdatedAt: frontMatterValue(markdown, "item_updated_at"),
automationItemUpdatedAt: frontMatterValue(markdown, "automation_item_updated_at"),
reviewCommentSyncedAt: frontMatterValue(markdown, "review_comment_synced_at"),
labelsSyncedAt: frontMatterValue(markdown, "labels_synced_at"),
decision: frontMatterValue(markdown, "decision"),
Expand Down Expand Up @@ -416,6 +417,7 @@ export function createRecordMetadata({
markdown,
reviewedAt: frontMatterValue(markdown, "reviewed_at"),
itemUpdatedAt: frontMatterValue(markdown, "item_updated_at"),
automationItemUpdatedAt: frontMatterValue(markdown, "automation_item_updated_at"),
reviewCommentSyncedAt: frontMatterValue(markdown, "review_comment_synced_at"),
labelsSyncedAt: frontMatterValue(markdown, "labels_synced_at"),
decision: frontMatterValue(markdown, "decision"),
Expand Down
12 changes: 5 additions & 7 deletions src/clawsweeper-report-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
prCloseCoverageProofCloseDecision,
prCloseCoverageProofEnvelopePath,
prCloseCoverageProofPromptSha256,
prCloseCoverageProofSnapshotSha256,
readPrCloseCoverageProofEnvelope,
runPrCloseCoverageProofModel,
validatePrCloseCoverageProofEnvelopeBinding,
Expand Down Expand Up @@ -1842,12 +1843,8 @@ export function createReportOrchestration(dependencies: CreateReportOrchestratio
};
}

function coveringPrCloseCoveragePullRequestUpdatedAt(number: number): string | null {
const pull = asRecord(ghJson<unknown>(["api", `repos/${targetRepo()}/pulls/${number}`]));
const pullUpdatedAt = stringOrUndefined(pull.updated_at);
if (pullUpdatedAt) return pullUpdatedAt;
const issue = asRecord(ghJson<unknown>(["api", `repos/${targetRepo()}/issues/${number}`]));
return stringOrUndefined(issue.updated_at) ?? null;
function coveringPrCloseCoveragePullRequestSnapshotSha256(number: number): string {
return prCloseCoverageProofSnapshotSha256(coveringPrCloseCoveragePullRequestView(number));
}

function prCloseCoverageProofSignalSnippets(
Expand Down Expand Up @@ -2005,6 +2002,7 @@ export function createReportOrchestration(dependencies: CreateReportOrchestratio
covering: {
number: covering.number,
provedAtMs: proofStartedAtMs,
snapshotSha256: prCloseCoverageProofSnapshotSha256(covering),
updatedAt: covering.updatedAt,
url: covering.url,
proof: closeDecision.proof,
Expand Down Expand Up @@ -2862,7 +2860,7 @@ export function createReportOrchestration(dependencies: CreateReportOrchestratio
configSurfaceReviewRequired,
contextHasNonAutomationActivityAfter,
contextHasNonAutomationActivityAfterForTest,
coveringPrCloseCoveragePullRequestUpdatedAt,
coveringPrCloseCoveragePullRequestSnapshotSha256,
currentReviewRevision,
dataModelSurfaceReviewRequired,
duplicateCanonicalPullRequestBlockReason,
Expand Down
1 change: 1 addition & 0 deletions src/clawsweeper-report-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,7 @@ review_semantic_eligible: ${options.semanticRecord?.eligible ?? false}
review_semantic_eligibility_reason: ${options.semanticRecord?.eligibilityReason ?? "unknown"}
review_semantic_cache_hit: false
item_source_revision: ${options.context.sourceRevision ?? "unknown"}
review_timeline_revision: ${options.context.timelineRevision ?? "unknown"}
review_activity_cursor: ${options.context.pullReviewActivityCursor ?? "unknown"}
close_comment_sha256: ${options.action.closeComment ? sha256(options.action.closeComment) : "none"}
review_comment_sha256: none
Expand Down
5 changes: 5 additions & 0 deletions src/clawsweeper-review-command-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,11 @@ export function createReviewCommandWorkflow(dependencies: CreateReviewCommandWor
"item_source_revision",
context.sourceRevision ?? "unknown",
);
carried = replaceFrontMatterValue(
carried,
"review_timeline_revision",
context.timelineRevision ?? "unknown",
);
carried = replaceFrontMatterValue(
carried,
"pull_head_sha",
Expand Down
22 changes: 11 additions & 11 deletions src/clawsweeper-review-planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,17 +251,15 @@ export function createReviewPlanning({
const updatedAt = Date.parse(item.updatedAt);
const reviewedAt = review.reviewedAt ? Date.parse(review.reviewedAt) : Number.NaN;
if (!Number.isFinite(updatedAt) || !Number.isFinite(reviewedAt)) return true;
if (review.itemUpdatedAt && item.updatedAt === review.itemUpdatedAt) return false;
if (updatedAt <= reviewedAt) return false;
const reviewCommentSyncedAt = review.reviewCommentSyncedAt
? Date.parse(review.reviewCommentSyncedAt)
: Number.NaN;
const labelsSyncedAt = review.labelsSyncedAt ? Date.parse(review.labelsSyncedAt) : Number.NaN;
const botOwnedSyncedAt = Math.max(
Number.isFinite(reviewCommentSyncedAt) ? reviewCommentSyncedAt : -Infinity,
Number.isFinite(labelsSyncedAt) ? labelsSyncedAt : -Infinity,
);
return !Number.isFinite(botOwnedSyncedAt) || updatedAt > botOwnedSyncedAt;
// Local synchronization clocks are not ownership receipts. GitHub rounds
// item activity to seconds, so target-side activity can share a timestamp
// with a bot comment or label mutation. Keep all post-review activity hot;
// the structural cache can still reuse the prior verdict after comparing a
// complete source and timeline receipt.
if (review.itemUpdatedAt && item.updatedAt === review.itemUpdatedAt) {
return updatedAt === Math.floor(reviewedAt / 1000) * 1000;
}
return updatedAt >= Math.floor(reviewedAt / 1000) * 1000;
}

function shouldSkipScheduledHotIntakeExactReview(
Expand Down Expand Up @@ -304,6 +302,7 @@ export function createReviewPlanning({
currentItemUpdatedAt?: string;
itemUpdatedAt?: string;
reviewItemUpdatedAt?: string;
automationItemUpdatedAt?: string;
reviewCommentSyncedAt?: string;
labelsSyncedAt?: string;
reviewPolicy?: string;
Expand All @@ -317,6 +316,7 @@ export function createReviewPlanning({
itemSourceRevision: options.reviewSourceRevision,
reviewPolicy: options.reviewPolicy,
itemUpdatedAt: options.reviewItemUpdatedAt,
automationItemUpdatedAt: options.automationItemUpdatedAt,
reviewCommentSyncedAt: options.reviewCommentSyncedAt,
labelsSyncedAt: options.labelsSyncedAt,
} as ExistingReview;
Expand Down
2 changes: 2 additions & 0 deletions src/clawsweeper-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export interface ExistingReview {
markdown: string;
reviewedAt: string | undefined;
itemUpdatedAt: string | undefined;
automationItemUpdatedAt?: string | undefined;
reviewCommentSyncedAt: string | undefined;
labelsSyncedAt: string | undefined;
decision: string | undefined;
Expand Down Expand Up @@ -1217,6 +1218,7 @@ export interface PrCloseCoverageProofGateBlock {
export interface PrCloseCoverageProofCoveringWitness {
number: number;
provedAtMs: number;
snapshotSha256: string;
updatedAt: string | null;
url: string;
proof: PrCloseCoverageProofModelResult;
Expand Down
4 changes: 2 additions & 2 deletions src/clawsweeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2188,7 +2188,7 @@ const {
completeStaleCanonicalCommentSyncReport,
configSurfaceReviewRequired,
contextHasNonAutomationActivityAfter,
coveringPrCloseCoveragePullRequestUpdatedAt,
coveringPrCloseCoveragePullRequestSnapshotSha256,
currentReviewRevision,
dataModelSurfaceReviewRequired,
duplicateCanonicalPullRequestBlockReason,
Expand Down Expand Up @@ -2741,7 +2741,7 @@ const { applyDecisionsCommandInner } = createApplyDecisionWorkflow({
completeStaleCanonicalCommentSyncReport,
contextHasNonAutomationActivityAfter,
coverageProofRetryExhaustedRuntimeBudget,
coveringPrCloseCoveragePullRequestUpdatedAt,
coveringPrCloseCoveragePullRequestSnapshotSha256,
decisionPacketsDirFromArgs,
defaultClosedDir,
defaultItemsDir,
Expand Down
16 changes: 15 additions & 1 deletion src/pr-close-coverage-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ function stringifyPrCloseCoverageProofPromptJson(value: unknown, space?: number)
return (serialized ?? "null").replace(/`/g, "\\u0060");
}

function prCloseCoverageProofReportMarkdown(markdown: string): string {
const match = markdown.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/);
if (!match) return markdown.trim();
const frontMatter = (match[2] ?? "")
.split(/\r?\n/)
.filter((line) => !/^automation_item_updated_at\s*:/.test(line))
.join("\n");
return `${match[1] ?? "---\n"}${frontMatter}${match[3] ?? "\n---\n"}${markdown.slice(
match[0].length,
)}`.trim();
}

export function buildPrCloseCoverageProofPrompt(options: {
source: PrCloseCoverageProofPullRequestView;
covering: PrCloseCoverageProofPullRequestView;
Expand All @@ -244,7 +256,9 @@ export function buildPrCloseCoverageProofPrompt(options: {
"",
"PR A source report JSON string:",
"```json",
stringifyPrCloseCoverageProofPromptJson(options.reportMarkdown.trim()),
stringifyPrCloseCoverageProofPromptJson(
prCloseCoverageProofReportMarkdown(options.reportMarkdown),
),
"```",
"",
"Current PR title, body, and comments:",
Expand Down
2 changes: 1 addition & 1 deletion src/repair/record-tuple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const VERSION_GROUPS = [
["reviewed_at", "last_full_review_at"],
["reconciled_at"],
["applied_at", "apply_checked_at"],
["review_comment_synced_at", "review_comment_checked_at"],
["review_comment_synced_at", "review_comment_checked_at", "automation_item_updated_at"],
["labels_synced_at"],
["failed_review_retry_last_at"],
] as const;
Expand Down
6 changes: 6 additions & 0 deletions src/review-structural-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface ReviewStructuralPriorReview {
reviewPolicy?: string | undefined;
reviewModel?: string | undefined;
itemSourceRevision?: string | undefined;
automationItemUpdatedAt?: string | undefined;
reviewCommentSyncedAt?: string | undefined;
labelsSyncedAt?: string | undefined;
}
Expand Down Expand Up @@ -1190,6 +1191,11 @@ function activityCoveredByReview(
review: ReviewStructuralPriorReview,
): boolean {
if (current.activityUpdatedAt === prior.activityUpdatedAt) return true;
// Timestamp equality only clears the activity-clock gate. The caller has
// already compared the complete structural source revision and still checks
// target and pull heads before returning a cache hit, so a same-second target
// mutation cannot be attributed to automation by this value alone.
if (current.activityUpdatedAt === review.automationItemUpdatedAt) return true;
const priorActivity = timestampMs(prior.activityUpdatedAt);
const currentActivity = timestampMs(current.activityUpdatedAt);
const latestOwnedSync = Math.max(
Expand Down
43 changes: 21 additions & 22 deletions src/scheduler-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface SchedulerItem {
export interface SchedulerExistingReview {
reviewedAt?: string | undefined;
itemUpdatedAt?: string | undefined;
automationItemUpdatedAt?: string | undefined;
reviewCommentSyncedAt?: string | undefined;
labelsSyncedAt?: string | undefined;
reviewStatus?: string | undefined;
Expand Down Expand Up @@ -88,32 +89,30 @@ function hasActivitySinceReview(
if (!review) return false;
const updatedAt = Date.parse(item.updatedAt);
const reviewedAt = reviewedAtMs(review);
const reviewCommentSyncedAt = timestampMs(review.reviewCommentSyncedAt);
const labelsSyncedAt = timestampMs(review.labelsSyncedAt);
const botOwnedSyncedAt = Math.max(
reviewCommentSyncedAt ?? -Infinity,
labelsSyncedAt ?? -Infinity,
);
// GitHub item timestamps have second-level precision. Comment and label
// synchronization clocks therefore cannot prove ownership of later item
// activity: an independent target update can share the same value. Keep
// post-review activity eligible here and let the structural cache verify a
// complete item receipt before suppressing the expensive review.
if (review.itemUpdatedAt) {
if (item.updatedAt === review.itemUpdatedAt) return false;
if (Number.isFinite(updatedAt) && reviewedAt !== null && updatedAt <= reviewedAt) return false;
if (
Number.isFinite(updatedAt) &&
Number.isFinite(botOwnedSyncedAt) &&
updatedAt <= botOwnedSyncedAt
) {
return false;
if (item.updatedAt === review.itemUpdatedAt) {
return (
Number.isFinite(updatedAt) &&
reviewedAt !== null &&
updatedAt === Math.floor(reviewedAt / 1000) * 1000
);
}
return true;
return (
Number.isFinite(updatedAt) &&
reviewedAt !== null &&
updatedAt >= Math.floor(reviewedAt / 1000) * 1000
);
}
if (
return (
reviewedAt !== null &&
Number.isFinite(updatedAt) &&
Number.isFinite(botOwnedSyncedAt) &&
updatedAt <= botOwnedSyncedAt
) {
return false;
}
return reviewedAt !== null && Number.isFinite(updatedAt) && updatedAt > reviewedAt;
updatedAt >= Math.floor(reviewedAt / 1000) * 1000
);
}

function isCreatedWithinDays(
Expand Down
10 changes: 7 additions & 3 deletions test/apply-label-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,11 @@ const oldBody = ${JSON.stringify(oldLiveComment)};
const newerBody = ${JSON.stringify(newerComment)};
const rawArgs = process.argv.slice(2);
const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs;
const path = args[1] || "";
const path = args[1] === "-i" ? args[2] || "" : args[1] || "";
appendFileSync(logPath, JSON.stringify(args) + "\\n");
if (args[0] === "api" && new RegExp("/issues/${number}/comments(?:\\\\?|$)").test(path) && !args.includes("--method")) {
if (args[0] === "api" && args[1] === "-i" && new RegExp("/issues/${number}/timeline(?:\\\\?|$)").test(path)) {
console.log("HTTP/2 200\\n\\n[]");
} else if (args[0] === "api" && new RegExp("/issues/${number}/comments(?:\\\\?|$)").test(path) && !args.includes("--method")) {
const count = (existsSync(countPath) ? Number(readFileSync(countPath, "utf8")) : 0) + 1;
writeFileSync(countPath, String(count));
const body = count >= 6 ? newerBody : oldBody;
Expand Down Expand Up @@ -1204,12 +1206,13 @@ const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs;
appendFileSync(logPath, JSON.stringify(args) + "\\n");
const path = args[1] || "";
if (args[0] === "api" && /\\/issues\\/74478$/.test(path)) {
const commentWasPosted = readFileSync(logPath, "utf8").includes("posted-comment-body");
console.log(JSON.stringify({
number: 74478,
title: "Record PR label churn",
html_url: "https://github.com/openclaw/clawsweeper/pull/74478",
created_at: "2026-05-19T19:00:00Z",
updated_at: "2026-05-19T20:00:00Z",
updated_at: commentWasPosted ? "2026-05-19T20:00:02Z" : "2026-05-19T20:00:00Z",
closed_at: null,
state: "open",
locked: false,
Expand Down Expand Up @@ -1269,6 +1272,7 @@ if (args[0] === "api" && /\\/issues\\/74478$/.test(path)) {

const report = readFileSync(itemPath, "utf8");
assert.match(report, /^labels_synced_at: /m);
assert.match(report, /^automation_item_updated_at: 2026-05-19T20:00:02Z$/m);
assert.match(report, /proof: sufficient/);
assert.match(report, /proof: 📸 screenshot/);
assert.match(report, /rating: 🦞 diamond lobster/);
Expand Down
15 changes: 10 additions & 5 deletions test/apply-pr-coverage-proof-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ test("apply-decisions permits a trusted deferred close-proof command status thro
title: "Provider route fallback",
action_taken: "retry_pr_close_coverage_proof",
item_source_revision: sourceRevisionForTest("Provider route fallback"),
pull_head_sha: "head-sha",
close_reason: "duplicate_or_superseded",
work_cluster_refs: JSON.stringify([
"Superseded by https://github.com/openclaw/openclaw/pull/400",
Expand All @@ -358,23 +359,23 @@ test("apply-decisions permits a trusted deferred close-proof command status thro
html_url: "https://github.com/openclaw/openclaw/pull/363#issuecomment-9363",
created_at: "2026-05-01T01:00:00Z",
updated_at: "2026-05-01T01:00:00Z",
user: { login: "clawsweeper" },
user: { login: "clawsweeper[bot]" },
body: synced.comment,
},
{
id: 9364,
html_url: "https://github.com/openclaw/openclaw/pull/363#issuecomment-9364",
created_at: "2026-05-01T01:30:00Z",
updated_at: "2026-05-01T02:00:00Z",
user: { login: "clawsweeper" },
user: { login: "clawsweeper[bot]" },
body: "<!-- clawsweeper-command-status: close-coverage-proof-pending -->",
},
],
timeline: [
{
event: "commented",
created_at: "2026-05-01T01:30:00Z",
actor: { login: "clawsweeper" },
actor: { login: "clawsweeper[bot]" },
},
],
linkedPulls: {
Expand Down Expand Up @@ -426,6 +427,7 @@ test("apply-decisions permits a trusted deferred close-proof command status thro
assert.equal(
report.some((entry) => entry.action === "closed"),
true,
JSON.stringify(report, null, 2),
);
assert.match(
report.find((entry) => entry.action === "closed")?.reason ?? "",
Expand Down Expand Up @@ -1192,7 +1194,7 @@ test("apply-decisions rechecks duplicate PR freshness after coverage proof passe
}
});

test("apply-decisions rechecks covering PR freshness after coverage proof passes", () => {
test("apply-decisions rejects a same-timestamp covering PR snapshot change after proof", () => {
const root = mkdtempSync(tmpPrefix);
try {
const itemsDir = join(root, "items");
Expand All @@ -1206,6 +1208,8 @@ test("apply-decisions rechecks covering PR freshness after coverage proof passes
lowSignalCloseReport({
number: 360,
title: "Provider route fallback",
item_source_revision: sourceRevisionForTest("Provider route fallback"),
pull_head_sha: "head-sha",
close_reason: "duplicate_or_superseded",
work_cluster_refs: JSON.stringify([
"Superseded by https://github.com/openclaw/openclaw/pull/400",
Expand Down Expand Up @@ -1246,7 +1250,7 @@ test("apply-decisions rechecks covering PR freshness after coverage proof passes
html_url: "https://github.com/openclaw/openclaw/pull/400",
state: "closed",
merged_at: "2026-05-02T00:00:00Z",
updated_at: "2026-05-01T00:05:00Z",
updated_at: "2026-05-01T00:00:00Z",
body: "Changed after proof ran.",
comments: [],
labels: [],
Expand Down Expand Up @@ -1293,6 +1297,7 @@ test("apply-decisions rechecks covering PR freshness after coverage proof passes
assert.match(
report.find((entry) => entry.action === "retry_pr_close_coverage_proof")?.reason ?? "",
/linked canonical PR #400 changed after coverage proof/,
JSON.stringify(report, null, 2),
);
assert.equal(existsSync(join(closedDir, "360.md")), false);
} finally {
Expand Down
Loading
Loading