From c6fb0c701b5c20e8fc91f35a2661922b15140d7e Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Mon, 19 Jan 2026 10:35:47 +0900 Subject: [PATCH] fix(types): add null checks for optional sessionID and startedAt fields --- src/features/background-agent/manager.ts | 58 +++++++++++++++++------- src/hooks/background-compaction/index.ts | 4 +- src/tools/background-task/tools.ts | 22 +++++---- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 2d8a3d832..e6233a0d6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -311,8 +311,10 @@ export class BackgroundManager { for (const child of directChildren) { result.push(child) - const descendants = this.getAllDescendantTasks(child.sessionID) - result.push(...descendants) + if (child.sessionID) { + const descendants = this.getAllDescendantTasks(child.sessionID) + result.push(...descendants) + } } return result @@ -363,7 +365,9 @@ export class BackgroundManager { existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent } - subagentSessions.add(existingTask.sessionID) + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } this.startPolling() // Track for batched notifications if task is pending or running @@ -428,6 +432,10 @@ export class BackgroundManager { throw new Error(`Task not found for session: ${input.sessionId}`) } + if (!existingTask.sessionID) { + throw new Error(`Task has no sessionID: ${existingTask.id}`) + } + if (existingTask.status === "running") { log("[background-agent] Resume skipped - task already running:", { taskId: existingTask.id, @@ -460,7 +468,9 @@ export class BackgroundManager { } this.startPolling() - subagentSessions.add(existingTask.sessionID) + if (existingTask.sessionID) { + subagentSessions.add(existingTask.sessionID) + } if (input.parentSessionID) { const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() @@ -571,9 +581,12 @@ export class BackgroundManager { const task = this.findBySession(sessionID) if (!task || task.status !== "running") return + + const startedAt = task.startedAt + if (!startedAt) return // Edge guard: Require minimum elapsed time (5 seconds) before accepting idle - const elapsedMs = Date.now() - task.startedAt.getTime() + const elapsedMs = Date.now() - startedAt.getTime() const MIN_IDLE_TIME_MS = 5000 if (elapsedMs < MIN_IDLE_TIME_MS) { log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id }) @@ -885,7 +898,7 @@ export class BackgroundManager { // Note: Callers must release concurrency before calling this method // to ensure slots are freed even if notification fails - const duration = this.formatDuration(task.startedAt, task.completedAt) + const duration = this.formatDuration(task.startedAt ?? new Date(), task.completedAt) log("[background-agent] notifyParentSession called for task:", task.id) @@ -1057,7 +1070,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.cleanupPendingByParent(task) this.clearNotificationsForTask(taskId) this.tasks.delete(taskId) - subagentSessions.delete(task.sessionID) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } } } @@ -1067,6 +1082,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea continue } const validNotifications = notifications.filter((task) => { + if (!task.startedAt) return false const age = now - task.startedAt.getTime() return age <= TASK_TTL_MS }) @@ -1085,8 +1101,12 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea for (const task of this.tasks.values()) { if (task.status !== "running") continue if (!task.progress?.lastUpdate) continue + + const startedAt = task.startedAt + const sessionID = task.sessionID + if (!startedAt || !sessionID) continue - const runtime = now - task.startedAt.getTime() + const runtime = now - startedAt.getTime() if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime() @@ -1105,7 +1125,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } this.client.session.abort({ - path: { id: task.sessionID }, + path: { id: sessionID }, }).catch(() => {}) log(`[background-agent] Task ${task.id} interrupted: stale timeout`) @@ -1127,14 +1147,17 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea for (const task of this.tasks.values()) { if (task.status !== "running") continue + + const sessionID = task.sessionID + if (!sessionID) continue try { - const sessionStatus = allStatuses[task.sessionID] + const sessionStatus = allStatuses[sessionID] // Don't skip if session not in status - fall through to message-based detection if (sessionStatus?.type === "idle") { // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(task.sessionID) + const hasValidOutput = await this.validateSessionHasOutput(sessionID) if (!hasValidOutput) { log("[background-agent] Polling idle but no valid output yet, waiting:", task.id) continue @@ -1143,7 +1166,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea // Re-check status after async operation if (task.status !== "running") continue - const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) + const hasIncompleteTodos = await this.checkSessionTodos(sessionID) if (hasIncompleteTodos) { log("[background-agent] Task has incomplete todos via polling, waiting:", task.id) continue @@ -1154,7 +1177,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } const messagesResult = await this.client.session.messages({ - path: { id: task.sessionID }, + path: { id: sessionID }, }) if (!messagesResult.error && messagesResult.data) { @@ -1196,14 +1219,17 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea // Stability detection: complete when message count unchanged for 3 polls const currentMsgCount = messages.length - const elapsedMs = Date.now() - task.startedAt.getTime() + const startedAt = task.startedAt + if (!startedAt) continue + + const elapsedMs = Date.now() - startedAt.getTime() if (elapsedMs >= MIN_STABILITY_TIME_MS) { if (task.lastMsgCount === currentMsgCount) { task.stablePolls = (task.stablePolls ?? 0) + 1 if (task.stablePolls >= 3) { // Edge guard: Validate session has actual output before completing - const hasValidOutput = await this.validateSessionHasOutput(task.sessionID) + const hasValidOutput = await this.validateSessionHasOutput(sessionID) if (!hasValidOutput) { log("[background-agent] Stability reached but no valid output, waiting:", task.id) continue @@ -1212,7 +1238,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea // Re-check status after async operation if (task.status !== "running") continue - const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID) + const hasIncompleteTodos = await this.checkSessionTodos(sessionID) if (!hasIncompleteTodos) { await this.tryCompleteTask(task, "stability detection") continue diff --git a/src/hooks/background-compaction/index.ts b/src/hooks/background-compaction/index.ts index b978ee1a4..3bb32a460 100644 --- a/src/hooks/background-compaction/index.ts +++ b/src/hooks/background-compaction/index.ts @@ -55,7 +55,9 @@ export function createBackgroundCompactionHook(manager: BackgroundManager) { sections.push("## Running Background Tasks") sections.push("") for (const t of running) { - const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1000) + const elapsed = t.startedAt + ? Math.floor((Date.now() - t.startedAt.getTime()) / 1000) + : 0 sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`) } sections.push("") diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 8e7abaa8d..ca23a8b1f 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -194,6 +194,10 @@ ${promptPreview} } async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise { + if (!task.sessionID) { + return `Error: Task has no sessionID` + } + const messagesResult = await client.session.messages({ path: { id: task.sessionID }, }) @@ -219,7 +223,7 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P Task ID: ${task.id} Description: ${task.description} -Duration: ${formatDuration(task.startedAt, task.completedAt)} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} Session ID: ${task.sessionID} --- @@ -238,7 +242,7 @@ Session ID: ${task.sessionID} Task ID: ${task.id} Description: ${task.description} -Duration: ${formatDuration(task.startedAt, task.completedAt)} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} Session ID: ${task.sessionID} --- @@ -255,7 +259,7 @@ Session ID: ${task.sessionID} const newMessages = consumeNewMessages(task.sessionID, sortedMessages) if (newMessages.length === 0) { - const duration = formatDuration(task.startedAt, task.completedAt) + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) return `Task Result Task ID: ${task.id} @@ -299,7 +303,7 @@ Session ID: ${task.sessionID} .filter((text) => text.length > 0) .join("\n\n") - const duration = formatDuration(task.startedAt, task.completedAt) + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) return `Task Result @@ -408,7 +412,7 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc // Pending task: use manager method (no session to abort) manager.cancelPendingTask(task.id) results.push(`- ${task.id}: ${task.description} (pending)`) - } else { + } else if (task.sessionID) { // Running task: abort session client.session.abort({ path: { id: task.sessionID }, @@ -452,9 +456,11 @@ Status: ${task.status}` // Running task: abort session // Fire-and-forget: abort 요청을 보내고 await 하지 않음 // await 하면 메인 세션까지 abort 되는 문제 발생 - client.session.abort({ - path: { id: task.sessionID }, - }).catch(() => {}) + if (task.sessionID) { + client.session.abort({ + path: { id: task.sessionID }, + }).catch(() => {}) + } task.status = "cancelled" task.completedAt = new Date()