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
This commit is contained in:
Codex Review
2026-04-17 08:28:51 +00:00
parent e9856ebc2f
commit c26a334e40
8 changed files with 266 additions and 8 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> | 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.

View File

@@ -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,