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
@@ -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;
}