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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user