fix(todo-continuation-enforcer): stop looping after all todos complete (#4013)

Two P0 fixes for the assistant loop that repeats its final summary 3-5 times
after all todos are marked completed before stagnation detection finally halts it.

P0.1 — session-level stop flag: when handleSessionIdle detects incompleteCount===0
it now sets state.allTodosCompletedAt. Subsequent idle events for the same session
bail out immediately at the top of the function before any HTTP fetch or injection
logic runs, preventing the re-entry loop regardless of todo-fetch caching latency.
The flag is cleared by resetContinuationProgress so sessions that receive new todos
after completion resume enforcement normally.

P0.2 — snapshot comparison scope: getTodoSnapshot now only serialises the
{id → status} mapping (sorted by key). Content and priority changes are excluded
from the comparison. Previously those fields were included, causing hasTodoSnapshotChanged
to return true whenever the LLM re-wrote todo text with identical status — which
reported progressSource="todo" and reset stagnationCount to 0, preventing
MAX_STAGNATION_COUNT=3 from ever being reached.

P1 fixes (CONTINUATION_PROMPT adversarial wording, 10 s completion grace period)
are deferred to a follow-up PR as noted in the issue.
This commit is contained in:
ZeyuFu
2026-05-16 06:31:18 -04:00
parent fbc5768f9a
commit 5e4d45a3ca
5 changed files with 85 additions and 20 deletions
@@ -43,29 +43,17 @@ export interface SessionStateStore {
}
function getTodoSnapshot(todos: Todo[]): string {
const normalizedTodos = todos
// Only compare {id → status} mappings. Content/priority changes do not represent
// meaningful progress and must not reset the stagnation counter (issue #4013 P0.2).
const entries = todos
.map((todo) => ({
id: todo.id ?? null,
content: todo.content,
priority: todo.priority,
key: todo.id ?? `${todo.content}:${todo.priority}`,
status: todo.status,
}))
.sort((left, right) => {
const leftKey = left.id ?? `${left.content}:${left.priority}:${left.status}`
const rightKey = right.id ?? `${right.content}:${right.priority}:${right.status}`
if (leftKey !== rightKey) {
return leftKey.localeCompare(rightKey)
}
if (left.content !== right.content) {
return left.content.localeCompare(right.content)
}
if (left.priority !== right.priority) {
return left.priority.localeCompare(right.priority)
}
return left.status.localeCompare(right.status)
})
.sort((left, right) => left.key.localeCompare(right.key))
.map(({ key, status }) => `${key}=${status}`)
return JSON.stringify(normalizedTodos)
return entries.join("|")
}
export function createSessionStateStore(): SessionStateStore {
@@ -213,6 +201,7 @@ export function createSessionStateStore(): SessionStateStore {
state.lastIncompleteCount = undefined
state.stagnationCount = 0
state.awaitingPostInjectionProgressCheck = false
state.allTodosCompletedAt = undefined
trackedSession.lastCompletedCount = undefined
trackedSession.lastTodoSnapshot = undefined
}