recover still-running background tasks on reattach

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:17:05 +00:00
co-authored by Claude Opus 5
parent 6b4339052a
commit d5a3bae367
4 changed files with 85 additions and 5 deletions
+13
View File
@@ -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';
+43 -4
View File
@@ -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<string, RunningTask>();
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<WSData>, 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<WSData>, 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.
@@ -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. */
@@ -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;
}