Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 0c2952ad52 fix: FINAL trigger is not a completion; stale-FINAL recovery can re-post
- isClaudeFinalReplyComment ignores @claude requests: the FINAL trigger
  embeds the Phase 1 heading and is posted by an automation login, so it
  counted as its own completion (CodeRabbit).
- claudeFinalReady accepts staleRecovery to bypass the already-triggered
  check; both stale-recovery call sites pass it, otherwise recovery never
  posted (Greptile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:39:05 +00:00
MervinPraisonandClaude Opus 5.5 c381335016 fix(ci): unblock auto-merge — recognise the FINAL review reply, expire the recovery debounce
Nothing has auto-merged since 2026-10-08. Two defects combined into a
permanent deadlock:

1. On 2026-10-09 (0ab9f91f6a) claude.yml moved from
   anthropics/claude-code-action, which posted a "Claude finished" tracking
   comment, to the local composite action, which posts the review itself.
   isClaudeFinalReplyComment only matched "Claude finished", so every FINAL
   since then looked incomplete and every PR "stale FINAL". It now also
   accepts the review reply's mandatory "Phase 1 — AGENTS.md review" table
   and "Final architecture review" title, still only from automation logins.

2. shouldSkipStaleFinalRecovery skipped re-posting the FINAL when the head was
   pushed within 15 min of the FINAL trigger. That relation never changes, so
   the skip was permanent. It now holds only while the push is settling
   (30 min), after which recovery re-posts the FINAL.

The stale/missing-FINAL sweep moves from 4x/day to hourly; at push time the
debounce correctly holds back, and the sweep is what re-posts once the push
has settled. Replaying the fixed logic on the 18 open PRs: FINAL current on
HEAD goes from 2/18 to 13/18, and the 5 genuinely stale ones now get a
re-posted FINAL instead of a permanent skip. merge-gate-selftest: 101 ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:30:46 +01:00
5 changed files with 125 additions and 10 deletions

No files matched your search

+59 -2
View File
@@ -66,6 +66,49 @@ assert(
!mg.isStaleFinalAfterPush(finishedOnHead, '2026-06-12T09:00:00Z')
);
// The local Claude action (since 2026-10-09) posts the review itself rather than
// an "Claude finished" tracking comment. Real reply shapes from open PRs:
const reviewReplies = [
'## Final architecture review — PR #5772 (fixes #5771)\n\n#### Phase 1 — AGENTS.md review\n| Check | Result |',
'## Final architecture review\n\n#### Phase 1 — AGENTS.md review\n| Check | Result |',
'#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|',
'## Final architecture review: PR #5774\n\n#### Phase 1 — AGENTS.md review',
];
for (const body of reviewReplies) {
assert(
`FINAL review reply counts as completion: ${body.slice(0, 40)}`,
mg.isClaudeFinalReplyComment({ user: { login: 'praisonai-triage-agent[bot]' }, body })
);
}
assert(
'a human posting the review format is not a completion',
!mg.isClaudeFinalReplyComment({ user: { login: 'someone' }, body: reviewReplies[0] })
);
assert(
'unrelated triage-bot comment is not a completion',
!mg.isClaudeFinalReplyComment({
user: { login: 'praisonai-triage-agent[bot]' },
body: '## CI fix: `test_service_delete_all` (pushed 52c0fdb1e)',
})
);
assert(
'the FINAL @claude trigger (embeds the Phase 1 heading) is not a completion',
!mg.isClaudeFinalReplyComment({ user: { login: 'MervinPraison' }, body: mg.FINAL_CLAUDE_REVIEW_BODY })
);
const triggerOnly = [
{ user: { login: 'MervinPraison' }, body: mg.FINAL_CLAUDE_REVIEW_BODY, created_at: '2026-10-12T08:00:00Z' },
];
assert('real FINAL trigger alone is not a completed review',
!mg.finalClaudeCompletedOnSha(triggerOnly, '2026-10-12T07:55:00Z'));
// PR #5772 timeline: FINAL 07:49:49, reviewer pushes 07:56:13, review 07:56:28.
const pr5772 = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-10-12T07:49:49Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: reviewReplies[0], created_at: '2026-10-12T07:56:28Z' },
];
assert('PR #5772 shape: FINAL is current on HEAD', !mg.isStaleFinalAfterPush(pr5772, '2026-10-12T07:56:13Z'));
assert('PR #5772 shape: still stale if HEAD moves after the review',
mg.isStaleFinalAfterPush(pr5772, '2026-10-12T09:00:00Z'));
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
assert('optional pending recovery check name listed', mg.OPTIONAL_PENDING_WHEN_CORE_GREEN.has('claude-review-recovery'));
assert('ignorable pending when core green', mg.isIgnorablePendingCheck(
@@ -219,7 +262,7 @@ const firstFinal = {
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
iso(nowMs - 8 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
@@ -236,11 +279,25 @@ assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.i
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
iso(nowMs - 8 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
// The "pushed soon after FINAL" debounce must expire. It compares the push to
// the FINAL trigger, which never changes, so without a clock check a PR whose
// FINAL reviewer pushed a fix within 15 min of the trigger was skipped forever.
const oldFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 3 * 60 * 60 * 1000),
};
const settledHead = iso(nowMs - 3 * 60 * 60 * 1000 + 4 * 60 * 1000); // pushed 4 min after FINAL, 2h56m ago
const expired = mg.shouldSkipStaleFinalRecovery([oldFinal], settledHead);
assert('soon-after-FINAL debounce expires once the push has settled', !expired.skip);
const expiredBot = mg.shouldSkipStaleFinalRecovery([oldFinal], settledHead, 'praisonai-triage-agent[bot]');
assert('automation-push debounce expires once the push has settled', !expiredBot.skip);
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
+24 -3
View File
@@ -10,6 +10,10 @@ const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
// How long after a push the "pushed soon after FINAL" debounce keeps holding
// stale-FINAL recovery back (long enough for CI / a batch of fixes). After that
// the debounce expires, so a missed completion can no longer block a PR forever.
const PUSH_SETTLE_MS = 30 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
@@ -143,6 +147,16 @@ function isClaudeTriggerNoise(c) {
return false;
}
// Markers of a completed FINAL review. anthropics/claude-code-action posted a
// "Claude finished" tracking comment; the local action used since 2026-10-09
// posts the review itself, which always carries the mandatory Phase 1 table
// (see FINAL_CLAUDE_REVIEW_BODY) and usually a "Final architecture review" title.
const FINAL_REPLY_MARKERS = [
/Claude finished/,
/^#{1,4}\s*Final architecture review\b/im,
/^#{1,4}\s*Phase 1 — AGENTS\.md review/m,
];
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
@@ -150,7 +164,10 @@ function isClaudeFinalReplyComment(c) {
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
const body = c.body || '';
// The FINAL trigger embeds the Phase 1 heading; an @claude request is never a reply.
if (/^\s*@claude\b/i.test(body)) return false;
return FINAL_REPLY_MARKERS.some((re) => re.test(body));
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
@@ -354,7 +371,11 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin) && isPushSoonAfterLatestFinal(comments, headPushedAt)) {
// The debounces below compare the push to the FINAL trigger, a relation that
// never changes; only hold recovery back while the push is still settling.
const pushSettling =
!!headPushedAt && Date.now() - new Date(headPushedAt).getTime() < PUSH_SETTLE_MS;
if (pushSettling && headPusherLogin && isClaudeAutomationLogin(headPusherLogin) && isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed by Claude automation soon after FINAL (wait for CI)',
@@ -363,7 +384,7 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
if (pushSettling && isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
+33 -1
View File
@@ -107,5 +107,37 @@ assert(
'claude FINAL not re-posted when already triggered',
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
);
assert(
'stale-FINAL recovery may re-post over an existing trigger',
chain.claudeFinalReady([greptile, finalClaude], [], {
prCreatedAt: oldPr, requiredWaitMs: 0, skipCopilot: true, staleRecovery: true,
}).ready
);
process.exit(failed ? 1 : 0);
// maybeTriggerClaudeFinal actually posts the recovery comment.
(async () => {
const posted = [];
const fakeGithub = {
rest: {
pulls: {
get: async () => ({ data: { created_at: oldPr } }),
listReviews: async () => ({ data: [] }),
},
issues: {
listComments: async () => ({ data: [greptile, finalClaude] }),
createComment: async (args) => { posted.push(args); },
},
},
};
const stale = await chain.maybeTriggerClaudeFinal(
fakeGithub, 'o', 'r', 1, 'BODY', null,
{ skipCopilot: true, optionalWaitMs: 0, requiredWaitMs: 0, staleRecovery: true }
);
assert('stale-FINAL recovery posts the replacement comment', stale.posted && posted.length === 1);
const dup = await chain.maybeTriggerClaudeFinal(
fakeGithub, 'o', 'r', 1, 'BODY', null,
{ skipCopilot: true, optionalWaitMs: 0, requiredWaitMs: 0 }
);
assert('without staleRecovery an existing FINAL is not re-posted', !dup.posted && posted.length === 1);
process.exit(failed ? 1 : 0);
})();
+3 -1
View File
@@ -163,7 +163,9 @@ function claudeFinalAlreadyTriggered(comments) {
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (claudeFinalAlreadyTriggered(comments)) {
// staleRecovery: the caller already confirmed (shouldSkipStaleFinalRecovery)
// that the existing FINAL is stale on HEAD, so a re-post is intended.
if (!options.staleRecovery && claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
+6 -3
View File
@@ -16,7 +16,10 @@ on:
pull_request_target:
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
# Hourly stale/missing-FINAL sweep. A push is the only other recovery
# trigger, and at push time the "wait for CI" debounce correctly holds back;
# this sweep is what re-posts the FINAL once the push has settled.
- cron: '30 * * * *'
workflow_dispatch:
env:
@@ -163,7 +166,7 @@ jobs:
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, opts
github, owner, repo, pr, finalBody, core, { ...opts, staleRecovery: true }
);
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
@@ -241,7 +244,7 @@ jobs:
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr.number,
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core,
{ skipCopilot: true, optionalWaitMs: 0 }
{ skipCopilot: true, optionalWaitMs: 0, staleRecovery: hasFinal && isStale }
);
if (result.posted) {
posted++;