From c26a334e40484d43b9b28e85d56523d3e2be5978 Mon Sep 17 00:00:00 2001 From: Codex Review Date: Fri, 17 Apr 2026 08:28:51 +0000 Subject: [PATCH] Prevent scheduled wakeup resumes from self-cancelling persistent hooks ScheduleWakeup-style resume events are not real persistence work, but the Stop hook treated them like ordinary idle stops and could inject continuation text that tells Claude to run /oh-my-claudecode:cancel. That let scheduled /loop turns cancel themselves before the scheduled task executed. This change adds a narrow scheduled-wakeup bypass alongside the existing non-reinforceable stop guards and mirrors the same detector in the shipped script/template hook surfaces so installed runtime behavior stays aligned with the shared TypeScript path. Constraint: Installed Stop hook behavior is shipped via script/template surfaces as well as shared TypeScript logic Rejected: Tighten global persistent-mode freshness rules | broader behavioral change across legitimate long-running sessions Confidence: medium Scope-risk: narrow Directive: Keep scheduled-resume stop marker detection aligned across shared logic and script/template mirrors if Claude Code changes its native wakeup payloads Tested: ./node_modules/.bin/vitest run src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts src/hooks/persistent-mode/stop-hook-blocking.test.ts Tested: ./node_modules/.bin/eslint src/hooks/todo-continuation/index.ts src/hooks/persistent-mode/index.ts src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts src/hooks/persistent-mode/stop-hook-blocking.test.ts Tested: ./node_modules/.bin/tsc --noEmit --pretty false Not-tested: Live Claude Code /loop ScheduleWakeup reproduction on macOS --- scripts/persistent-mode.cjs | 34 ++++++++++++ scripts/persistent-mode.mjs | 34 ++++++++++++ .../__tests__/rate-limit-stop.test.ts | 53 +++++++++++++++++-- src/hooks/persistent-mode/index.ts | 15 +++++- .../stop-hook-blocking.test.ts | 48 +++++++++++++++++ .../__tests__/isRateLimitStop.test.ts | 25 ++++++++- src/hooks/todo-continuation/index.ts | 31 ++++++++++- templates/hooks/persistent-mode.mjs | 34 ++++++++++++ 8 files changed, 266 insertions(+), 8 deletions(-) diff --git a/scripts/persistent-mode.cjs b/scripts/persistent-mode.cjs index d42ef965..b5f838f9 100644 --- a/scripts/persistent-mode.cjs +++ b/scripts/persistent-mode.cjs @@ -788,6 +788,35 @@ function isAuthenticationError(data) { ); } +function isScheduledWakeupStop(data) { + const stopPatterns = [ + "schedulewakeup", + "schedule_wakeup", + "scheduled_wakeup", + "scheduled_task", + "scheduled_resume", + "loop_resume", + "loop_wakeup", + ]; + + const toolName = String(data.tool_name || data.toolName || "").toLowerCase().replace(/[\s-]+/g, "_"); + if (stopPatterns.some((pattern) => toolName.includes(pattern))) { + return true; + } + + const reasons = [ + data.stop_reason, + data.stopReason, + data.end_turn_reason, + data.endTurnReason, + data.reason, + ] + .filter((value) => typeof value === "string" && value.trim().length > 0) + .map((value) => value.toLowerCase().replace(/[\s-]+/g, "_")); + + return reasons.some((reason) => stopPatterns.some((pattern) => reason.includes(pattern))); +} + async function main() { try { const input = await readStdin(); @@ -827,6 +856,11 @@ async function main() { return; } + if (isScheduledWakeupStop(data)) { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); + return; + } + // Read all mode states (session-scoped with legacy fallback) const ralph = readStateFileWithSession(stateDir, "ralph-state.json", sessionId); const autopilot = readStateFileWithSession(stateDir, "autopilot-state.json", sessionId); diff --git a/scripts/persistent-mode.mjs b/scripts/persistent-mode.mjs index 7f40063e..5e0fef29 100644 --- a/scripts/persistent-mode.mjs +++ b/scripts/persistent-mode.mjs @@ -619,6 +619,35 @@ function isAuthenticationError(data) { ); } +function isScheduledWakeupStop(data) { + const stopPatterns = [ + "schedulewakeup", + "schedule_wakeup", + "scheduled_wakeup", + "scheduled_task", + "scheduled_resume", + "loop_resume", + "loop_wakeup", + ]; + + const toolName = String(data.tool_name || data.toolName || "").toLowerCase().replace(/[\s-]+/g, "_"); + if (stopPatterns.some((pattern) => toolName.includes(pattern))) { + return true; + } + + const reasons = [ + data.stop_reason, + data.stopReason, + data.end_turn_reason, + data.endTurnReason, + data.reason, + ] + .filter((value) => typeof value === "string" && value.trim().length > 0) + .map((value) => value.toLowerCase().replace(/[\s-]+/g, "_")); + + return reasons.some((reason) => stopPatterns.some((pattern) => reason.includes(pattern))); +} + async function main() { try { const input = await readStdin(); @@ -661,6 +690,11 @@ async function main() { return; } + if (isScheduledWakeupStop(data)) { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); + return; + } + // Read all mode states (session-scoped when sessionId provided) const ralph = readStateFileWithSession( stateDir, diff --git a/src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts b/src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts index b8a55371..85bfc4d5 100644 --- a/src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts +++ b/src/hooks/persistent-mode/__tests__/rate-limit-stop.test.ts @@ -1,10 +1,10 @@ /** - * Integration test for rate-limit stop guard in checkPersistentModes - * Fix for: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/777 + * Integration tests for stop-guard bypasses in checkPersistentModes. * - * Verifies that when Claude Code stops due to a rate limit (HTTP 429), - * the persistent-mode hook does NOT block the stop — preventing an - * infinite retry loop. + * Fixes: + * - #777: rate-limit stop should not re-enter persistent continuation + * - #2693: ScheduleWakeup / scheduled resume should not re-enter stale + * persistent continuation or inject cancel guidance ahead of scheduled work */ import { describe, it, expect } from 'vitest'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; @@ -54,6 +54,13 @@ describe('persistent-mode rate-limit stop guard (fix #777)', () => { 'oauth_expired', ]; + const scheduledWakeupReasons = [ + 'ScheduleWakeup', + 'scheduled_task', + 'scheduled_resume', + 'loop_resume', + ]; + for (const reason of rateLimitReasons) { it(`should NOT block stop when stop_reason is "${reason}"`, async () => { const sessionId = `session-777-${reason.replace(/[^a-z0-9]/g, '-')}`; @@ -91,6 +98,42 @@ describe('persistent-mode rate-limit stop guard (fix #777)', () => { }); } + for (const reason of scheduledWakeupReasons) { + it(`should NOT block stop when stop_reason is scheduled wakeup-related ("${reason}")`, async () => { + const sessionId = `session-2693-${reason.replace(/[^a-z0-9]/gi, '-')}`; + const tempDir = makeRalphWorktree(sessionId); + try { + const result = await checkPersistentModes( + sessionId, + tempDir, + { stop_reason: reason } + ); + expect(result.shouldBlock).toBe(false); + expect(result.mode).toBe('none'); + expect(result.message).toBe(''); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + } + + it('should NOT block stop when ScheduleWakeup arrives as tool_name', async () => { + const sessionId = 'session-2693-tool-name'; + const tempDir = makeRalphWorktree(sessionId); + try { + const result = await checkPersistentModes( + sessionId, + tempDir, + { tool_name: 'ScheduleWakeup', stop_reason: 'end_turn' } + ); + expect(result.shouldBlock).toBe(false); + expect(result.mode).toBe('none'); + expect(result.message).toBe(''); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('should still block stop for active ralph with no rate-limit context', async () => { const sessionId = 'session-777-no-rate-limit'; const tempDir = makeRalphWorktree(sessionId); diff --git a/src/hooks/persistent-mode/index.ts b/src/hooks/persistent-mode/index.ts index 1b206154..4b922606 100644 --- a/src/hooks/persistent-mode/index.ts +++ b/src/hooks/persistent-mode/index.ts @@ -46,7 +46,7 @@ import { clearVerificationState, type VerificationState, } from '../ralph/index.js'; -import { checkIncompleteTodos, getNextPendingTodo, StopContext, isUserAbort, isContextLimitStop, isRateLimitStop, isExplicitCancelCommand, isAuthenticationError } from '../todo-continuation/index.js'; +import { checkIncompleteTodos, getNextPendingTodo, StopContext, isUserAbort, isContextLimitStop, isRateLimitStop, isExplicitCancelCommand, isAuthenticationError, isScheduledWakeupStop } from '../todo-continuation/index.js'; import { TODO_CONTINUATION_PROMPT } from '../../installer/hooks.js'; import { isAutopilotActive @@ -1630,6 +1630,19 @@ export async function checkPersistentModes( }; } + // CRITICAL: Never block scheduled wake-up resumptions. + // Native ScheduleWakeup-triggered `/loop` turns are resumptions, not signals + // to continue or clean up a prior persistent mode. Re-enforcing here can + // inject `/cancel` guidance from stale state and cause the scheduled turn to + // cancel itself before the real work runs. + if (isScheduledWakeupStop(stopContext)) { + return { + shouldBlock: false, + message: '', + mode: 'none' + }; + } + // First, check for incomplete todos (we need this info for ultrawork) // Note: stopContext already checked above, but pass it for consistency const todoResult = await checkIncompleteTodos(sessionId, workingDir, stopContext); diff --git a/src/hooks/persistent-mode/stop-hook-blocking.test.ts b/src/hooks/persistent-mode/stop-hook-blocking.test.ts index 8bd277a0..136ea2ae 100644 --- a/src/hooks/persistent-mode/stop-hook-blocking.test.ts +++ b/src/hooks/persistent-mode/stop-hook-blocking.test.ts @@ -868,6 +868,30 @@ describe("Stop Hook Blocking Contract", () => { expect(output.continue).toBe(true); }); + it("returns continue: true for ScheduleWakeup-triggered stop", () => { + const sessionId = "scheduled-wakeup-mjs"; + const sessionDir = join(tempDir, ".omc", "state", "sessions", sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, "ralph-state.json"), + JSON.stringify({ + active: true, + iteration: 1, + max_iterations: 50, + session_id: sessionId, + started_at: new Date().toISOString(), + last_checked_at: new Date().toISOString(), + }) + ); + + const output = runScript({ + directory: tempDir, + sessionId, + stop_reason: "ScheduleWakeup", + }); + expect(output.continue).toBe(true); + }); + it("returns continue: true when no modes are active", () => { const output = runScript({ directory: tempDir, sessionId: "no-modes" }); expect(output.continue).toBe(true); @@ -1060,6 +1084,30 @@ describe("Stop Hook Blocking Contract", () => { expect(output.continue).toBe(true); }); + it("returns continue: true for ScheduleWakeup-triggered stop", () => { + const sessionId = "scheduled-wakeup-cjs"; + const sessionDir = join(tempDir, ".omc", "state", "sessions", sessionId); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, "ralph-state.json"), + JSON.stringify({ + active: true, + iteration: 1, + max_iterations: 50, + session_id: sessionId, + started_at: new Date().toISOString(), + last_checked_at: new Date().toISOString(), + }) + ); + + const output = runScript({ + directory: tempDir, + sessionId, + stop_reason: "ScheduleWakeup", + }); + expect(output.continue).toBe(true); + }); + it("returns continue: true when skill state is active but delegated subagents are still running", () => { const sessionId = "skill-active-subagents-cjs"; const sessionDir = join(tempDir, ".omc", "state", "sessions", sessionId); diff --git a/src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts b/src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts index 59c4685f..da228497 100644 --- a/src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts +++ b/src/hooks/todo-continuation/__tests__/isRateLimitStop.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isRateLimitStop, type StopContext } from '../index.js'; +import { isRateLimitStop, isScheduledWakeupStop, type StopContext } from '../index.js'; describe('isRateLimitStop (fix #777 - ralph infinite retry loop)', () => { it('should return false for undefined context', () => { @@ -108,3 +108,26 @@ describe('isRateLimitStop (fix #777 - ralph infinite retry loop)', () => { expect(isRateLimitStop(context)).toBe(false); }); }); + +describe('isScheduledWakeupStop (fix #2693 - scheduled resume should bypass persistent continuation)', () => { + it('should return false for undefined context', () => { + expect(isScheduledWakeupStop()).toBe(false); + }); + + it('should detect ScheduleWakeup stop_reason variants', () => { + expect(isScheduledWakeupStop({ stop_reason: 'ScheduleWakeup' })).toBe(true); + expect(isScheduledWakeupStop({ stop_reason: 'scheduled_task' })).toBe(true); + expect(isScheduledWakeupStop({ endTurnReason: 'loop_resume' })).toBe(true); + }); + + it('should detect scheduled wakeup from tool name', () => { + expect(isScheduledWakeupStop({ tool_name: 'ScheduleWakeup' })).toBe(true); + expect(isScheduledWakeupStop({ toolName: 'native.schedule_wakeup' })).toBe(true); + }); + + it('should return false for ordinary stop reasons', () => { + expect(isScheduledWakeupStop({ stop_reason: 'end_turn' })).toBe(false); + expect(isScheduledWakeupStop({ stop_reason: 'rate_limit' })).toBe(false); + expect(isScheduledWakeupStop({ tool_name: 'Task' })).toBe(false); + }); +}); diff --git a/src/hooks/todo-continuation/index.ts b/src/hooks/todo-continuation/index.ts index 408ae801..ed4a7b73 100644 --- a/src/hooks/todo-continuation/index.ts +++ b/src/hooks/todo-continuation/index.ts @@ -231,7 +231,7 @@ export function isExplicitCancelCommand(context?: StopContext): boolean { return true; } - const toolName = String(context.tool_name ?? context.toolName ?? '').toLowerCase(); + const toolName = String(context.tool_name ?? context.toolName ?? '').toLowerCase().replace(/[\s-]+/g, '_'); const toolInput = (context.tool_input ?? context.toolInput) as Record | undefined; if (toolName.includes('skill') && toolInput && typeof toolInput.skill === 'string') { const skill = toolInput.skill.toLowerCase(); @@ -290,6 +290,35 @@ export function isRateLimitStop(context?: StopContext): boolean { return rateLimitPatterns.some(p => reason.includes(p) || endTurnReason.includes(p)); } +/** + * Scheduled wake-up stops should not trigger persistent-mode re-enforcement. + * Claude Code can resume `/loop` work through the native ScheduleWakeup path, + * and stale prior-mode state must not inject continuation/cancel prompts into + * that scheduled resume turn. + */ +export function isScheduledWakeupStop(context?: StopContext): boolean { + if (!context) return false; + + const stopPatterns = [ + 'schedulewakeup', + 'schedule_wakeup', + 'scheduled_wakeup', + 'scheduled_task', + 'scheduled_resume', + 'loop_resume', + 'loop_wakeup', + ]; + + const toolName = String(context.tool_name ?? context.toolName ?? '').toLowerCase(); + if (stopPatterns.some((pattern) => toolName.includes(pattern))) { + return true; + } + + return getStopReasonFields(context).some((value) => + stopPatterns.some((pattern) => value.includes(pattern)) + ); +} + /** * Auth-related stop reasons that should bypass continuation re-enforcement. * Keep exactly 16 entries in sync with script/template variants. diff --git a/templates/hooks/persistent-mode.mjs b/templates/hooks/persistent-mode.mjs index 96df02e0..74f9d5db 100644 --- a/templates/hooks/persistent-mode.mjs +++ b/templates/hooks/persistent-mode.mjs @@ -605,6 +605,35 @@ function isAuthenticationError(data) { ); } +function isScheduledWakeupStop(data) { + const stopPatterns = [ + "schedulewakeup", + "schedule_wakeup", + "scheduled_wakeup", + "scheduled_task", + "scheduled_resume", + "loop_resume", + "loop_wakeup", + ]; + + const toolName = String(data.tool_name || data.toolName || "").toLowerCase().replace(/[\s-]+/g, "_"); + if (stopPatterns.some((pattern) => toolName.includes(pattern))) { + return true; + } + + const reasons = [ + data.stop_reason, + data.stopReason, + data.end_turn_reason, + data.endTurnReason, + data.reason, + ] + .filter((value) => typeof value === "string" && value.trim().length > 0) + .map((value) => value.toLowerCase().replace(/[\s-]+/g, "_")); + + return reasons.some((reason) => stopPatterns.some((pattern) => reason.includes(pattern))); +} + async function main() { try { const input = await readStdin(); @@ -644,6 +673,11 @@ async function main() { return; } + if (isScheduledWakeupStop(data)) { + console.log(JSON.stringify({ continue: true, suppressOutput: true })); + return; + } + // Read all mode states (session-scoped when sessionId provided) const ralph = readStateFileWithSession( stateDir,