fix(todo-continuation-enforcer): track todo state changes for stagnation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-11 17:56:43 +09:00
parent 79c271ccf4
commit e1ea129ff6
@@ -1,4 +1,4 @@
import type { SessionState } from "./types" import type { SessionState, Todo } from "./types"
// TTL for idle session state entries (10 minutes) // TTL for idle session state entries (10 minutes)
const SESSION_STATE_TTL_MS = 10 * 60 * 1000 const SESSION_STATE_TTL_MS = 10 * 60 * 1000
@@ -8,6 +8,8 @@ const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000
interface TrackedSessionState { interface TrackedSessionState {
state: SessionState state: SessionState
lastAccessedAt: number lastAccessedAt: number
lastCompletedCount?: number
lastTodoStatusSignature?: string
} }
export interface ContinuationProgressUpdate { export interface ContinuationProgressUpdate {
@@ -19,7 +21,7 @@ export interface ContinuationProgressUpdate {
export interface SessionStateStore { export interface SessionStateStore {
getState: (sessionID: string) => SessionState getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined getExistingState: (sessionID: string) => SessionState | undefined
trackContinuationProgress: (sessionID: string, incompleteCount: number) => ContinuationProgressUpdate trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate
resetContinuationProgress: (sessionID: string) => void resetContinuationProgress: (sessionID: string) => void
cancelCountdown: (sessionID: string) => void cancelCountdown: (sessionID: string) => void
cleanup: (sessionID: string) => void cleanup: (sessionID: string) => void
@@ -27,6 +29,13 @@ export interface SessionStateStore {
shutdown: () => void shutdown: () => void
} }
function getTodoStatusSignature(todos: Todo[]): string {
return todos
.map((todo) => `${todo.id ?? `${todo.content}:${todo.priority}`}:${todo.status}`)
.sort()
.join("|")
}
export function createSessionStateStore(): SessionStateStore { export function createSessionStateStore(): SessionStateStore {
const sessions = new Map<string, TrackedSessionState>() const sessions = new Map<string, TrackedSessionState>()
@@ -46,19 +55,27 @@ export function createSessionStateStore(): SessionStateStore {
pruneInterval.unref() pruneInterval.unref()
} }
function getState(sessionID: string): SessionState { function getTrackedSession(sessionID: string): TrackedSessionState {
const existing = sessions.get(sessionID) const existing = sessions.get(sessionID)
if (existing) { if (existing) {
existing.lastAccessedAt = Date.now() existing.lastAccessedAt = Date.now()
return existing.state return existing
} }
const state: SessionState = { const state: SessionState = {
stagnationCount: 0, stagnationCount: 0,
consecutiveFailures: 0, consecutiveFailures: 0,
} }
sessions.set(sessionID, { state, lastAccessedAt: Date.now() }) const trackedSession: TrackedSessionState = {
return state state,
lastAccessedAt: Date.now(),
}
sessions.set(sessionID, trackedSession)
return trackedSession
}
function getState(sessionID: string): SessionState {
return getTrackedSession(sessionID).state
} }
function getExistingState(sessionID: string): SessionState | undefined { function getExistingState(sessionID: string): SessionState | undefined {
@@ -72,12 +89,30 @@ export function createSessionStateStore(): SessionStateStore {
function trackContinuationProgress( function trackContinuationProgress(
sessionID: string, sessionID: string,
incompleteCount: number incompleteCount: number,
todos?: Todo[]
): ContinuationProgressUpdate { ): ContinuationProgressUpdate {
const state = getState(sessionID) const trackedSession = getTrackedSession(sessionID)
const state = trackedSession.state
const previousIncompleteCount = state.lastIncompleteCount const previousIncompleteCount = state.lastIncompleteCount
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
const currentTodoStatusSignature = todos ? getTodoStatusSignature(todos) : undefined
const hasCompletedMoreTodos =
currentCompletedCount !== undefined
&& trackedSession.lastCompletedCount !== undefined
&& currentCompletedCount > trackedSession.lastCompletedCount
const hasTodoStatusChanged =
currentTodoStatusSignature !== undefined
&& trackedSession.lastTodoStatusSignature !== undefined
&& currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature
state.lastIncompleteCount = incompleteCount state.lastIncompleteCount = incompleteCount
if (currentCompletedCount !== undefined) {
trackedSession.lastCompletedCount = currentCompletedCount
}
if (currentTodoStatusSignature !== undefined) {
trackedSession.lastTodoStatusSignature = currentTodoStatusSignature
}
if (previousIncompleteCount === undefined) { if (previousIncompleteCount === undefined) {
state.stagnationCount = 0 state.stagnationCount = 0
@@ -88,7 +123,7 @@ export function createSessionStateStore(): SessionStateStore {
} }
} }
if (incompleteCount < previousIncompleteCount) { if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) {
state.stagnationCount = 0 state.stagnationCount = 0
return { return {
previousIncompleteCount, previousIncompleteCount,
@@ -114,11 +149,17 @@ export function createSessionStateStore(): SessionStateStore {
} }
function resetContinuationProgress(sessionID: string): void { function resetContinuationProgress(sessionID: string): void {
const state = getExistingState(sessionID) const trackedSession = sessions.get(sessionID)
if (!state) return if (!trackedSession) return
trackedSession.lastAccessedAt = Date.now()
const { state } = trackedSession
state.lastIncompleteCount = undefined state.lastIncompleteCount = undefined
state.stagnationCount = 0 state.stagnationCount = 0
trackedSession.lastCompletedCount = undefined
trackedSession.lastTodoStatusSignature = undefined
} }
function cancelCountdown(sessionID: string): void { function cancelCountdown(sessionID: string): void {