2026-03-11 17:56:43 +09:00
|
|
|
import type { SessionState, Todo } from "./types"
|
2026-02-08 15:01:42 +09:00
|
|
|
|
2026-02-12 11:38:11 +09:00
|
|
|
// TTL for idle session state entries (10 minutes)
|
|
|
|
|
const SESSION_STATE_TTL_MS = 10 * 60 * 1000
|
|
|
|
|
// Prune interval (every 2 minutes)
|
|
|
|
|
const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000
|
|
|
|
|
|
|
|
|
|
interface TrackedSessionState {
|
|
|
|
|
state: SessionState
|
|
|
|
|
lastAccessedAt: number
|
2026-03-11 17:56:43 +09:00
|
|
|
lastCompletedCount?: number
|
|
|
|
|
lastTodoStatusSignature?: string
|
2026-02-12 11:38:11 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 02:17:51 +09:00
|
|
|
export interface ContinuationProgressUpdate {
|
|
|
|
|
previousIncompleteCount?: number
|
|
|
|
|
stagnationCount: number
|
|
|
|
|
hasProgressed: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:01:42 +09:00
|
|
|
export interface SessionStateStore {
|
|
|
|
|
getState: (sessionID: string) => SessionState
|
|
|
|
|
getExistingState: (sessionID: string) => SessionState | undefined
|
2026-03-11 17:56:43 +09:00
|
|
|
trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate
|
2026-03-08 02:17:51 +09:00
|
|
|
resetContinuationProgress: (sessionID: string) => void
|
2026-02-08 15:01:42 +09:00
|
|
|
cancelCountdown: (sessionID: string) => void
|
|
|
|
|
cleanup: (sessionID: string) => void
|
|
|
|
|
cancelAllCountdowns: () => void
|
2026-02-12 11:38:11 +09:00
|
|
|
shutdown: () => void
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-11 17:56:43 +09:00
|
|
|
function getTodoStatusSignature(todos: Todo[]): string {
|
|
|
|
|
return todos
|
|
|
|
|
.map((todo) => `${todo.id ?? `${todo.content}:${todo.priority}`}:${todo.status}`)
|
|
|
|
|
.sort()
|
|
|
|
|
.join("|")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:01:42 +09:00
|
|
|
export function createSessionStateStore(): SessionStateStore {
|
2026-02-12 11:38:11 +09:00
|
|
|
const sessions = new Map<string, TrackedSessionState>()
|
|
|
|
|
|
|
|
|
|
// Periodic pruning of stale session states to prevent unbounded Map growth
|
|
|
|
|
let pruneInterval: ReturnType<typeof setInterval> | undefined
|
|
|
|
|
pruneInterval = setInterval(() => {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
for (const [sessionID, tracked] of sessions.entries()) {
|
|
|
|
|
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
|
|
|
|
|
cancelCountdown(sessionID)
|
|
|
|
|
sessions.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, SESSION_STATE_PRUNE_INTERVAL_MS)
|
|
|
|
|
// Allow process to exit naturally even if interval is running
|
|
|
|
|
if (typeof pruneInterval === "object" && "unref" in pruneInterval) {
|
|
|
|
|
pruneInterval.unref()
|
|
|
|
|
}
|
2026-02-08 15:01:42 +09:00
|
|
|
|
2026-03-11 17:56:43 +09:00
|
|
|
function getTrackedSession(sessionID: string): TrackedSessionState {
|
2026-02-12 11:38:11 +09:00
|
|
|
const existing = sessions.get(sessionID)
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.lastAccessedAt = Date.now()
|
2026-03-11 17:56:43 +09:00
|
|
|
return existing
|
2026-02-12 11:38:11 +09:00
|
|
|
}
|
2026-02-08 15:01:42 +09:00
|
|
|
|
2026-02-16 15:00:41 +09:00
|
|
|
const state: SessionState = {
|
2026-03-08 02:17:51 +09:00
|
|
|
stagnationCount: 0,
|
2026-02-16 15:00:41 +09:00
|
|
|
consecutiveFailures: 0,
|
|
|
|
|
}
|
2026-03-11 17:56:43 +09:00
|
|
|
const trackedSession: TrackedSessionState = {
|
|
|
|
|
state,
|
|
|
|
|
lastAccessedAt: Date.now(),
|
|
|
|
|
}
|
|
|
|
|
sessions.set(sessionID, trackedSession)
|
|
|
|
|
return trackedSession
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getState(sessionID: string): SessionState {
|
|
|
|
|
return getTrackedSession(sessionID).state
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getExistingState(sessionID: string): SessionState | undefined {
|
2026-02-12 11:38:11 +09:00
|
|
|
const existing = sessions.get(sessionID)
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.lastAccessedAt = Date.now()
|
|
|
|
|
return existing.state
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 02:17:51 +09:00
|
|
|
function trackContinuationProgress(
|
|
|
|
|
sessionID: string,
|
2026-03-11 17:56:43 +09:00
|
|
|
incompleteCount: number,
|
|
|
|
|
todos?: Todo[]
|
2026-03-08 02:17:51 +09:00
|
|
|
): ContinuationProgressUpdate {
|
2026-03-11 17:56:43 +09:00
|
|
|
const trackedSession = getTrackedSession(sessionID)
|
|
|
|
|
const state = trackedSession.state
|
2026-03-08 02:17:51 +09:00
|
|
|
const previousIncompleteCount = state.lastIncompleteCount
|
2026-03-11 17:56:43 +09:00
|
|
|
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
|
2026-03-11 18:39:54 +09:00
|
|
|
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
2026-03-08 02:17:51 +09:00
|
|
|
|
|
|
|
|
state.lastIncompleteCount = incompleteCount
|
2026-03-11 17:56:43 +09:00
|
|
|
if (currentCompletedCount !== undefined) {
|
|
|
|
|
trackedSession.lastCompletedCount = currentCompletedCount
|
|
|
|
|
}
|
|
|
|
|
if (currentTodoStatusSignature !== undefined) {
|
|
|
|
|
trackedSession.lastTodoStatusSignature = currentTodoStatusSignature
|
|
|
|
|
}
|
2026-03-08 02:17:51 +09:00
|
|
|
|
|
|
|
|
if (previousIncompleteCount === undefined) {
|
|
|
|
|
state.stagnationCount = 0
|
|
|
|
|
return {
|
|
|
|
|
previousIncompleteCount,
|
|
|
|
|
stagnationCount: state.stagnationCount,
|
|
|
|
|
hasProgressed: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 17:56:43 +09:00
|
|
|
if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) {
|
2026-03-08 02:17:51 +09:00
|
|
|
state.stagnationCount = 0
|
2026-03-11 18:39:54 +09:00
|
|
|
state.awaitingPostInjectionProgressCheck = false
|
2026-03-08 02:17:51 +09:00
|
|
|
return {
|
|
|
|
|
previousIncompleteCount,
|
|
|
|
|
stagnationCount: state.stagnationCount,
|
|
|
|
|
hasProgressed: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 18:39:54 +09:00
|
|
|
if (!hadSuccessfulInjectionAwaitingProgressCheck) {
|
2026-03-08 02:17:51 +09:00
|
|
|
return {
|
|
|
|
|
previousIncompleteCount,
|
|
|
|
|
stagnationCount: state.stagnationCount,
|
|
|
|
|
hasProgressed: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 18:39:54 +09:00
|
|
|
state.awaitingPostInjectionProgressCheck = false
|
2026-03-08 02:17:51 +09:00
|
|
|
state.stagnationCount += 1
|
|
|
|
|
return {
|
|
|
|
|
previousIncompleteCount,
|
|
|
|
|
stagnationCount: state.stagnationCount,
|
|
|
|
|
hasProgressed: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetContinuationProgress(sessionID: string): void {
|
2026-03-11 17:56:43 +09:00
|
|
|
const trackedSession = sessions.get(sessionID)
|
|
|
|
|
if (!trackedSession) return
|
|
|
|
|
|
|
|
|
|
trackedSession.lastAccessedAt = Date.now()
|
|
|
|
|
|
|
|
|
|
const { state } = trackedSession
|
2026-03-08 02:17:51 +09:00
|
|
|
|
|
|
|
|
state.lastIncompleteCount = undefined
|
|
|
|
|
state.stagnationCount = 0
|
2026-03-11 18:39:54 +09:00
|
|
|
state.awaitingPostInjectionProgressCheck = false
|
2026-03-11 17:56:43 +09:00
|
|
|
trackedSession.lastCompletedCount = undefined
|
|
|
|
|
trackedSession.lastTodoStatusSignature = undefined
|
2026-03-08 02:17:51 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:01:42 +09:00
|
|
|
function cancelCountdown(sessionID: string): void {
|
2026-02-12 11:38:11 +09:00
|
|
|
const tracked = sessions.get(sessionID)
|
|
|
|
|
if (!tracked) return
|
2026-02-08 15:01:42 +09:00
|
|
|
|
2026-02-12 11:38:11 +09:00
|
|
|
const state = tracked.state
|
2026-02-08 15:01:42 +09:00
|
|
|
if (state.countdownTimer) {
|
|
|
|
|
clearTimeout(state.countdownTimer)
|
|
|
|
|
state.countdownTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.countdownInterval) {
|
|
|
|
|
clearInterval(state.countdownInterval)
|
|
|
|
|
state.countdownInterval = undefined
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 11:52:31 +09:00
|
|
|
state.inFlight = false
|
2026-02-08 15:01:42 +09:00
|
|
|
state.countdownStartedAt = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanup(sessionID: string): void {
|
|
|
|
|
cancelCountdown(sessionID)
|
|
|
|
|
sessions.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelAllCountdowns(): void {
|
|
|
|
|
for (const sessionID of sessions.keys()) {
|
|
|
|
|
cancelCountdown(sessionID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:38:11 +09:00
|
|
|
function shutdown(): void {
|
|
|
|
|
clearInterval(pruneInterval)
|
|
|
|
|
cancelAllCountdowns()
|
|
|
|
|
sessions.clear()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:01:42 +09:00
|
|
|
return {
|
|
|
|
|
getState,
|
|
|
|
|
getExistingState,
|
2026-03-08 02:17:51 +09:00
|
|
|
trackContinuationProgress,
|
|
|
|
|
resetContinuationProgress,
|
2026-02-08 15:01:42 +09:00
|
|
|
cancelCountdown,
|
|
|
|
|
cleanup,
|
|
|
|
|
cancelAllCountdowns,
|
2026-02-12 11:38:11 +09:00
|
|
|
shutdown,
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
}
|