@@ -48,7 +48,7 @@ interface ScoredPr extends DetectedPr {
4848 score : number ;
4949}
5050
51- export type FixOutcome = 'fixed' | 'max-turns' | 'timeout' | 'error' | 'dry-run' ;
51+ export type FixOutcome = 'fixed' | 'no-op' | ' max-turns' | 'timeout' | 'error' | 'dry-run' ;
5252
5353export type MergeOutcome = 'merged' | 'dry-run' | 'error' ;
5454
@@ -204,6 +204,33 @@ function recordFailure(key: number | string): number {
204204 return count ;
205205}
206206
207+ function resetFailCount ( key : number | string ) : void {
208+ const file = join ( STATE_DIR , `failures-${ key } ` ) ;
209+ if ( existsSync ( file ) ) writeFileSync ( file , '0' ) ;
210+ }
211+
212+ /**
213+ * Detect when Claude exited cleanly but didn't actually fix anything
214+ * (e.g., followed "stop early" guidance for human-required issues).
215+ */
216+ const NO_OP_PATTERNS = [
217+ / n o a c t i o n n e e d e d / i,
218+ / n o c o d e c h a n g e s ? n e e d e d / i,
219+ / r e q u i r e s ? h u m a n i n t e r v e n t i o n / i,
220+ / n e e d s ? h u m a n / i,
221+ / c a n n o t b e f i x e d a u t o m a t i c a l l y / i,
222+ / p r e - e x i s t i n g .* ( f a i l u r e | i s s u e | p r o b l e m ) / i,
223+ / a l s o f a i l i n g o n m a i n / i,
224+ / s t o p p i n g e a r l y / i,
225+ / n o t h i n g t o f i x / i,
226+ ] ;
227+
228+ export function looksLikeNoOp ( output : string ) : boolean {
229+ // Only check the last portion of output (where the conclusion lives)
230+ const tail = output . slice ( - 1000 ) ;
231+ return NO_OP_PATTERNS . some ( ( p ) => p . test ( tail ) ) ;
232+ }
233+
207234function isAbandoned ( key : number | string ) : boolean {
208235 return getFailCount ( key ) >= 2 ;
209236}
@@ -351,8 +378,16 @@ async function fixMainBranch(status: MainBranchStatus, config: PatrolConfig): Pr
351378 log ( `${ cl . red } ✗ Main branch fix abandoned after ${ failCount } failures${ cl . reset } ` ) ;
352379 }
353380 } else if ( result . exitCode === 0 && ! result . hitMaxTurns ) {
354- outcome = 'fixed' ;
355- log ( `${ cl . green } ✓ Main branch CI fix processed${ cl . reset } (${ elapsedS } s)` ) ;
381+ const isNoOp = looksLikeNoOp ( result . output ) ;
382+ outcome = isNoOp ? 'no-op' : 'fixed' ;
383+ if ( isNoOp ) {
384+ recordFailure ( MAIN_BRANCH_KEY ) ;
385+ reason = 'No-op: agent determined issue needs human intervention' ;
386+ log ( `${ cl . yellow } ⚠ Main branch fix no-op — agent stopped early${ cl . reset } (${ elapsedS } s)` ) ;
387+ } else {
388+ resetFailCount ( MAIN_BRANCH_KEY ) ;
389+ log ( `${ cl . green } ✓ Main branch CI fix processed${ cl . reset } (${ elapsedS } s)` ) ;
390+ }
356391 } else if ( result . hitMaxTurns ) {
357392 const failCount = recordFailure ( MAIN_BRANCH_KEY ) ;
358393 outcome = 'max-turns' ;
@@ -1323,8 +1358,20 @@ async function fixPr(pr: ScoredPr, config: PatrolConfig): Promise<void> {
13231358 ) . catch ( ( ) => log ( ' Warning: could not post abandonment comment' ) ) ;
13241359 }
13251360 } else if ( result . exitCode === 0 && ! result . hitMaxTurns ) {
1326- log ( `${ cl . green } ✓ PR #${ pr . number } processed successfully${ cl . reset } (${ elapsedS } s)` ) ;
1327- outcome = 'fixed' ;
1361+ const isNoOp = looksLikeNoOp ( result . output ) ;
1362+ outcome = isNoOp ? 'no-op' : 'fixed' ;
1363+
1364+ if ( isNoOp ) {
1365+ // No-op: Claude determined the issue can't be fixed automatically.
1366+ // Don't reset fail count — treat like a soft failure so the PR
1367+ // gets skipped on future cycles instead of being retried forever.
1368+ const failCount = recordFailure ( pr . number ) ;
1369+ reason = `No-op: agent determined issue needs human intervention (attempt ${ failCount } )` ;
1370+ log ( `${ cl . yellow } ⚠ PR #${ pr . number } no-op — agent stopped early${ cl . reset } (${ elapsedS } s)` ) ;
1371+ } else {
1372+ resetFailCount ( pr . number ) ;
1373+ log ( `${ cl . green } ✓ PR #${ pr . number } processed successfully${ cl . reset } (${ elapsedS } s)` ) ;
1374+ }
13281375
13291376 // Post summary comment
13301377 const summary = result . output . slice ( - 500 ) ;
@@ -1450,19 +1497,35 @@ ${recentEntries}
14501497 timeoutMinutes : 5 , // Should complete quickly
14511498 } ) ;
14521499 const elapsedS = Math . floor ( ( Date . now ( ) - startTime ) / 1000 ) ;
1453- const filedIssue = / C r e a t e d i s s u e # | c r e a t e d .* # \d / . test ( result . output ) ;
1454-
1455- appendJsonl ( REFLECTION_FILE , {
1456- cycle_number : cycleCount ,
1457- elapsed_s : elapsedS ,
1458- filed_issue : filedIssue ,
1459- exit_code : result . exitCode ,
1460- summary : result . output . slice ( - 500 ) ,
1461- } ) ;
14621500
1463- log (
1464- `${ cl . green } ✓ Reflection complete${ cl . reset } (${ elapsedS } s, filed_issue=${ filedIssue } )` ,
1465- ) ;
1501+ if ( result . timedOut || result . hitMaxTurns ) {
1502+ const reason = result . timedOut ? 'timeout' : 'max-turns' ;
1503+ appendJsonl ( REFLECTION_FILE , {
1504+ cycle_number : cycleCount ,
1505+ elapsed_s : elapsedS ,
1506+ filed_issue : false ,
1507+ exit_code : result . exitCode ,
1508+ outcome : 'incomplete' ,
1509+ reason,
1510+ summary : result . output . slice ( - 500 ) ,
1511+ } ) ;
1512+ log (
1513+ `${ cl . yellow } ⚠ Reflection incomplete${ cl . reset } (${ elapsedS } s, ${ reason } )` ,
1514+ ) ;
1515+ } else {
1516+ const filedIssue = / C r e a t e d i s s u e # | c r e a t e d .* # \d / . test ( result . output ) ;
1517+ appendJsonl ( REFLECTION_FILE , {
1518+ cycle_number : cycleCount ,
1519+ elapsed_s : elapsedS ,
1520+ filed_issue : filedIssue ,
1521+ exit_code : result . exitCode ,
1522+ outcome : 'complete' ,
1523+ summary : result . output . slice ( - 500 ) ,
1524+ } ) ;
1525+ log (
1526+ `${ cl . green } ✓ Reflection complete${ cl . reset } (${ elapsedS } s, filed_issue=${ filedIssue } )` ,
1527+ ) ;
1528+ }
14661529 } catch ( e ) {
14671530 const elapsedS = Math . floor ( ( Date . now ( ) - startTime ) / 1000 ) ;
14681531 log (
0 commit comments