From d5a3bae367e929589e176d6ccb129b6831eb73ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 19:17:05 +0000 Subject: [PATCH] recover still-running background tasks on reattach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task row is officer's own invention, synthesised from the harness's system.task_started, and nothing corresponding to it is ever written to Claude's transcript. So rebuildTranscript can only produce user/tool/assistant rows, and sync:live deliberately carries no messages — which left the background-task tray empty after a mid-task refresh even though the work was still running. Fold the durable log on attach into started-minus-notified and hand that back on sync:live. The same read now supplies the cursor, so this costs one query rather than two. Finished tasks are excluded: replaying those would resurrect rows already seen to resolve. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/types.ts | 13 +++++ src/servers/api/chat/websocket.ts | 47 +++++++++++++++++-- .../officerdev/src/apps/Chat/types.ts | 17 ++++++- .../officerdev/src/hooks/useChat.ts | 13 +++++ 4 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index cf445539..b428467d 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -120,6 +120,9 @@ export type ClientMessage = // inside one bubble. Attributing them lets the UI file them under the Task row that spawned them. type Parented = { parentToolUseId?: string }; +/** A background task with no terminal notification yet. Same fields as `task:started`, by construction. */ +export type RunningTask = { taskId: string; description: string; taskType?: string }; + export type ServerMessage = | { type: 'session:init'; @@ -195,6 +198,16 @@ export type ServerMessage = * conversation. */ cursor: number; + /** + * Background tasks that were started and have not reported a terminal status. The one exception to + * "carries no messages", and for a concrete reason: a task row is officer's own invention — it is + * synthesised from the harness's `system.task_started`, and nothing corresponding to it is ever + * written to Claude's transcript. So unlike every other message, rebuilding the transcript cannot + * produce it, and a refresh mid-task left the tray empty while the work was still running. + * Finished tasks are deliberately excluded: replaying those would resurrect rows the user has + * already seen resolve, and only the running ones are still telling you anything. + */ + runningTasks: RunningTask[]; } | { type: 'error'; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 28edf8bf..eaa019a7 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -1,6 +1,14 @@ import type { ServerWebSocket } from 'bun'; import { randomUUID } from 'crypto'; -import type { ClientMessage, ServerMessage, Message, PromptImage, TurnMessage, UserSession } from './types'; +import type { + ClientMessage, + ServerMessage, + Message, + PromptImage, + RunningTask, + TurnMessage, + UserSession, +} from './types'; import { sessionManager } from './session-manager'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; @@ -8,7 +16,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; -import { getUserSettings, getEmailAccounts, getChatEventsSince, getLastChatEventSeq, appendChatEvent } from 'officerdb'; +import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent } from 'officerdb'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -605,6 +613,30 @@ async function handleResumeCursor( if (msg.generating) await endTurnIfAgentIsGone(ws, sessionId, model); } +/** + * Which background tasks are still outstanding, by replaying the durable log against itself. + * + * A task's whole life is two events — `task:started` and, eventually, `task:notification` with a terminal + * status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each. + * There is no third event: a task the agent abandoned without notifying stays here until the log is pruned, + * which is the honest reading of the record rather than a bug to paper over. + */ +function collectRunningTasks(events: ServerMessage[]): RunningTask[] { + const running = new Map(); + for (const event of events) { + if (event.type === 'task:started') { + running.set(event.taskId, { + taskId: event.taskId, + description: event.description, + taskType: event.taskType, + }); + } else if (event.type === 'task:notification') { + running.delete(event.taskId); + } + } + return [...running.values()]; +} + /** * Re-bind a socket that knows only Claude's transcript uuid. * @@ -656,11 +688,17 @@ async function handleAttach(ws: ServerWebSocket, msg: { claudeSessionId: const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId); session.isGenerating = isGenerating; + // One read serves both answers: the head of the log is the cursor, and folding the whole log gives the + // tasks still outstanding. Reading it all is affordable because attach happens once per socket and this + // server has one user; a `getLastChatEventSeq` would only have saved a second round trip. let cursor = 0; + let runningTasks: RunningTask[] = []; try { - cursor = (await getLastChatEventSeq(sessionId)) ?? 0; + const events = await getChatEventsSince(sessionId, 0); + cursor = events.at(-1)?.id ?? 0; + runningTasks = collectRunningTasks(events.map((e) => e.event as ServerMessage)); } catch (err) { - logger.error('Failed to read chat event head on attach', { sessionId, error: String(err) }); + logger.error('Failed to read the durable log on attach', { sessionId, error: String(err) }); } sendToClient(ws, { @@ -668,6 +706,7 @@ async function handleAttach(ws: ServerWebSocket, msg: { claudeSessionId: sessionId, isGenerating, cursor, + runningTasks, // Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot // supply it — the harness writes an assistant message only once it is complete — so this is the one // piece of the turn a refresh would otherwise genuinely lose. diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index ac79edbf..30788e18 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -71,6 +71,8 @@ export type ChatMessage = done?: boolean; }; +export type RunningTask = { taskId: string; description: string; taskType?: string }; + export type TaskInfo = { taskName: string; taskDirName: string; @@ -105,7 +107,20 @@ export type ServerMessage = * transcript came over HTTP a moment ago and there is no shared id to reconcile the two records by, so * this hands over the rest of the turn and the half-written paragraph, and nothing that would double up. */ - | { type: 'sync:live'; sessionId: string; isGenerating: boolean; streamingText: string; cursor: number } + | { + type: 'sync:live'; + sessionId: string; + isGenerating: boolean; + streamingText: string; + cursor: number; + /** + * Background tasks started and not yet finished. The single exception to "carries no messages": a + * task row has no counterpart in Claude's transcript — officer synthesises it — so rebuilding from + * the file cannot bring one back, and the tray came up empty on a refresh while work was still + * running. Finished tasks are excluded server-side. + */ + runningTasks: RunningTask[]; + } | { type: 'error'; message: string; errorCode?: string } | { type: 'stopped' } /** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */ diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 3d5eb1a4..0497e7b6 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -355,6 +355,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, streamingRef.current = msg.streamingText; flushStreaming(); } + // The one thing the transcript genuinely cannot supply. Appended rather than spliced into place: + // a task row carries no timestamp to sort by, so a recovered one lands at the end of the + // conversation instead of where it was started. The tray reads by taskId and renders correctly + // either way, and the alternative is inventing an ordering the record does not contain. + if (msg.runningTasks?.length) { + setMessages((prev) => { + const known = new Set(prev.filter((m) => m.role === 'task').map((m) => m.taskId)); + const recovered = msg.runningTasks + .filter((t) => !known.has(t.taskId)) + .map((t): ChatMessage => ({ role: 'task', ...t })); + return recovered.length ? [...prev, ...recovered] : prev; + }); + } break; }