From 9bfe7d8a1dc75d6cebd3ab94f963150664a5fbf9 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 25 Dec 2025 22:14:02 +0900 Subject: [PATCH] fix(todo-continuation-enforcer): re-verify todos after countdown to prevent stale data injection (#239) Fixes the race condition where the todo continuation hook would inject a continuation prompt even when all todos had been completed during the countdown period. The root cause was that executeAfterCountdown() used stale todo data from the initial session.idle check without re-verifying that incomplete todos still existed after the countdown finished. Changes: - Add fresh todo verification in executeAfterCountdown() before prompt injection - Use fresh todo data in the continuation prompt message - Abort injection if no incomplete todos remain after countdown This properly handles the case where: 1. session.idle fires (e.g., user enters shell mode in TUI) 2. Initial check finds incomplete todos, starts countdown 3. During countdown, todos get completed 4. Countdown ends, fresh check detects no incomplete todos 5. Hook aborts instead of injecting stale prompt Fixes #234 Co-authored-by: sisyphus-dev-ai --- src/hooks/todo-continuation-enforcer.ts | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index c3dca1c..f75e58f 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -215,6 +215,30 @@ export function createTodoContinuationEnforcer( return } + let freshTodos: Todo[] = [] + try { + log(`[${HOOK_NAME}] Re-verifying todos after countdown`, { sessionID }) + const response = await ctx.client.session.todo({ + path: { id: sessionID }, + }) + freshTodos = (response.data ?? response) as Todo[] + log(`[${HOOK_NAME}] Fresh todo count`, { sessionID, todosCount: freshTodos?.length ?? 0 }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to re-verify todos`, { sessionID, error: String(err) }) + return + } + + const freshIncomplete = freshTodos.filter( + (t) => t.status !== "completed" && t.status !== "cancelled" + ) + + if (freshIncomplete.length === 0) { + log(`[${HOOK_NAME}] Abort: no incomplete todos after countdown`, { sessionID, total: freshTodos.length }) + return + } + + log(`[${HOOK_NAME}] Confirmed incomplete todos, proceeding with injection`, { sessionID, incomplete: freshIncomplete.length, total: freshTodos.length }) + remindedSessions.add(sessionID) try { @@ -237,7 +261,7 @@ export function createTodoContinuationEnforcer( parts: [ { type: "text", - text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`, + text: `${CONTINUATION_PROMPT}\n\n[Status: ${freshTodos.length - freshIncomplete.length}/${freshTodos.length} completed, ${freshIncomplete.length} remaining]`, }, ], },