Skip to content

Commit 0ae439b

Browse files
committed
🤖 fix: implement parentToolCallId usage for restart-safe foreground tasks
- Add injectToolOutputToParent method that updates the parent workspace's pending task tool call with the completion result - For foreground tasks (run_in_background=false), the tool result is now injected into the parent's partial.json or chat.jsonl - This enables restart recovery: if app restarts during a foreground task, the parent will have the tool output when it resumes - Background tasks continue to post report as a message to parent history - Task tool now correctly passes toolCallId from AI SDK execute context - Removed unused toolCallId from TaskToolConfig (now from execute context) - Added PartialService dependency to TaskService for updating partial.json Change-Id: Ide0a696b785628383d4678b9ee18943fefbfc434 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0674b20 commit 0ae439b

File tree

3 files changed

+106
-7
lines changed

3 files changed

+106
-7
lines changed

‎src/node/services/serviceContainer.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export class ServiceContainer {
115115
config,
116116
this.workspaceService,
117117
this.historyService,
118+
this.partialService,
118119
this.aiService
119120
);
120121
// Wire TaskService to AIService for task tool access

‎src/node/services/taskService.ts‎

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@ import { EventEmitter } from "events";
1313
import type { Config } from "@/node/config";
1414
import type { WorkspaceService } from "@/node/services/workspaceService";
1515
import type { HistoryService } from "@/node/services/historyService";
16+
import type { PartialService } from "@/node/services/partialService";
1617
import type { AIService } from "@/node/services/aiService";
17-
import type { AgentType, TaskState } from "@/common/types/task";
18+
import type { AgentType, TaskState, TaskToolResult } from "@/common/types/task";
1819
import { getAgentPreset } from "@/common/constants/agentPresets";
1920
import { log } from "@/node/services/log";
2021
import {
2122
createMuxMessage,
2223
type MuxMessage,
2324
type MuxFrontendMetadata,
2425
} from "@/common/types/message";
26+
import { isDynamicToolPart } from "@/common/types/toolParts";
2527
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
2628
import { detectDefaultTrunkBranch, listLocalBranches } from "@/node/git";
2729
import * as crypto from "crypto";
@@ -85,6 +87,7 @@ export class TaskService extends EventEmitter {
8587
private readonly config: Config,
8688
private readonly workspaceService: WorkspaceService,
8789
private readonly historyService: HistoryService,
90+
private readonly partialService: PartialService,
8891
private readonly aiService: AIService
8992
) {
9093
super();
@@ -324,8 +327,15 @@ export class TaskService extends EventEmitter {
324327
});
325328
}
326329

327-
// Post report to parent workspace history
328-
await this.postReportToParent(workspaceId, taskState, args);
330+
// If this was a foreground task, inject the result into the parent's tool call
331+
// This enables restart recovery - even if pendingCompletions was lost, the parent
332+
// will have the tool output when it resumes
333+
if (taskState.parentToolCallId) {
334+
await this.injectToolOutputToParent(taskState, args);
335+
} else {
336+
// Background task - post report as a message to parent history
337+
await this.postReportToParent(workspaceId, taskState, args);
338+
}
329339

330340
// Process queue (a slot freed up)
331341
await this.processQueue();
@@ -373,6 +383,95 @@ export class TaskService extends EventEmitter {
373383
}
374384
}
375385

386+
/**
387+
* Inject task tool output into the parent workspace's pending tool call.
388+
* This updates the parent's partial.json or chat.jsonl so the tool result
389+
* persists across restarts.
390+
*/
391+
private async injectToolOutputToParent(
392+
taskState: TaskState,
393+
report: { reportMarkdown: string; title?: string }
394+
): Promise<void> {
395+
const { parentWorkspaceId, parentToolCallId } = taskState;
396+
if (!parentToolCallId) return;
397+
398+
const toolOutput: TaskToolResult = {
399+
status: "completed",
400+
taskId: taskState.parentWorkspaceId, // The task workspace ID
401+
reportMarkdown: report.reportMarkdown,
402+
reportTitle: report.title,
403+
};
404+
405+
try {
406+
// Try to update partial.json first (most likely location for in-flight tool call)
407+
const partial = await this.partialService.readPartial(parentWorkspaceId);
408+
if (partial) {
409+
const updated = this.updateToolCallOutput(partial, parentToolCallId, toolOutput);
410+
if (updated) {
411+
await this.partialService.writePartial(parentWorkspaceId, updated);
412+
log.debug(`Injected task result into parent partial`, {
413+
parentWorkspaceId,
414+
parentToolCallId,
415+
});
416+
return;
417+
}
418+
}
419+
420+
// Fall back to chat history
421+
const historyResult = await this.historyService.getHistory(parentWorkspaceId);
422+
if (historyResult.success) {
423+
// Find the message with this tool call (search from newest to oldest)
424+
for (let i = historyResult.data.length - 1; i >= 0; i--) {
425+
const msg = historyResult.data[i];
426+
const updated = this.updateToolCallOutput(msg, parentToolCallId, toolOutput);
427+
if (updated) {
428+
await this.historyService.updateHistory(parentWorkspaceId, updated);
429+
log.debug(`Injected task result into parent history`, {
430+
parentWorkspaceId,
431+
parentToolCallId,
432+
});
433+
return;
434+
}
435+
}
436+
}
437+
438+
// Couldn't find the tool call - fall back to posting as message
439+
log.warn(`Could not find parent tool call ${parentToolCallId}, posting as message instead`);
440+
await this.postReportToParent(parentWorkspaceId, taskState, report);
441+
} catch (error) {
442+
log.error(`Failed to inject tool output to parent ${parentWorkspaceId}:`, error);
443+
// Fall back to posting as message
444+
await this.postReportToParent(parentWorkspaceId, taskState, report);
445+
}
446+
}
447+
448+
/**
449+
* Update a tool call's output in a message. Returns the updated message if found, null otherwise.
450+
*/
451+
private updateToolCallOutput(
452+
msg: MuxMessage,
453+
toolCallId: string,
454+
output: TaskToolResult
455+
): MuxMessage | null {
456+
let found = false;
457+
const updatedParts = msg.parts.map((part) => {
458+
if (!isDynamicToolPart(part) || part.toolCallId !== toolCallId) {
459+
return part;
460+
}
461+
if (part.toolName !== "task") {
462+
return part;
463+
}
464+
found = true;
465+
return {
466+
...part,
467+
state: "output-available" as const,
468+
output,
469+
};
470+
});
471+
472+
return found ? { ...msg, parts: updatedParts } : null;
473+
}
474+
376475
/**
377476
* Handle stream end - check if agent_report was called.
378477
*/

‎src/node/services/tools/task.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ export interface TaskToolConfig {
1616
workspaceId: string;
1717
/** TaskService for creating and managing agent tasks */
1818
taskService: TaskService;
19-
/** Tool call ID for this invocation (used for result injection) */
20-
toolCallId?: string;
2119
}
2220

2321
/**
@@ -27,7 +25,7 @@ export function createTaskTool(config: TaskToolConfig) {
2725
return tool({
2826
description: TOOL_DEFINITIONS.task.description,
2927
inputSchema: TOOL_DEFINITIONS.task.schema,
30-
execute: async (args): Promise<TaskToolResult> => {
28+
execute: async (args, { toolCallId }): Promise<TaskToolResult> => {
3129
const { subagent_type, prompt, description, run_in_background } = args;
3230

3331
try {
@@ -36,7 +34,8 @@ export function createTaskTool(config: TaskToolConfig) {
3634
agentType: subagent_type,
3735
prompt,
3836
description,
39-
parentToolCallId: config.toolCallId,
37+
// Pass toolCallId for foreground tasks so result can be injected on restart
38+
parentToolCallId: run_in_background ? undefined : toolCallId,
4039
runInBackground: run_in_background ?? false,
4140
});
4241

0 commit comments

Comments
 (0)