Skip to content

Commit a367c13

Browse files
committed
🤖 fix: redeliver saved reports for 'reported' tasks on restart
Address Codex review comment: if Mux crashes after saving a task's report (taskStatus='reported') but before delivering it to the parent workspace, the report would be lost on restart. Now: - getActiveAgentTaskWorkspaces() includes 'reported' tasks - rehydrateTasks() calls redeliverSavedReport() for reported tasks - hasActiveDescendantTasks() filters out reported tasks so they don't block parent stream resumption Added test case to verify restart redelivery of saved reports. Change-Id: I2fa8baf8297dbb74c3ae94214c4816c94e424a81 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent f6d130f commit a367c13

File tree

3 files changed

+108
-5
lines changed

3 files changed

+108
-5
lines changed

src/node/config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,11 +653,15 @@ export class Config {
653653
for (const [_projectPath, projectConfig] of config.projects) {
654654
for (const workspace of projectConfig.workspaces) {
655655
if (workspace.taskState && workspace.id) {
656-
// Include queued, running, and awaiting_report tasks
656+
// Include queued, running, awaiting_report, and reported tasks.
657+
// "reported" tasks need rehydration too: if Mux crashed after saving
658+
// the report but before injecting it into the parent, we need to
659+
// re-deliver the saved report on restart.
657660
if (
658661
workspace.taskState.taskStatus === "queued" ||
659662
workspace.taskState.taskStatus === "running" ||
660-
workspace.taskState.taskStatus === "awaiting_report"
663+
workspace.taskState.taskStatus === "awaiting_report" ||
664+
workspace.taskState.taskStatus === "reported"
661665
) {
662666
result.push({
663667
workspaceId: workspace.id,

src/node/services/taskService.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,12 @@ class FakeConfig {
144144
const result: Array<{ workspaceId: string; taskState: TaskState }> = [];
145145
for (const [workspaceId, taskState] of this.taskStateById.entries()) {
146146
if (!taskState) continue;
147+
// Include "reported" tasks for restart redelivery
147148
if (
148149
taskState.taskStatus === "queued" ||
149150
taskState.taskStatus === "running" ||
150-
taskState.taskStatus === "awaiting_report"
151+
taskState.taskStatus === "awaiting_report" ||
152+
taskState.taskStatus === "reported"
151153
) {
152154
result.push({ workspaceId, taskState });
153155
}
@@ -824,6 +826,51 @@ describe("TaskService", () => {
824826
]);
825827
});
826828

829+
it("redelivers saved report for 'reported' tasks on restart", async () => {
830+
// This tests the scenario where Mux crashed after saving taskStatus="reported"
831+
// but before delivering the report to the parent workspace.
832+
const config = new FakeConfig();
833+
834+
const parent = createWorkspaceMetadata("parent");
835+
const reportedTask = createWorkspaceMetadata("task_reported", { parentWorkspaceId: "parent" });
836+
config.addWorkspace(parent);
837+
config.addWorkspace(reportedTask);
838+
839+
// Task was marked as reported with saved report content
840+
await config.setWorkspaceTaskState("task_reported", {
841+
taskStatus: "reported",
842+
agentType: "research",
843+
parentWorkspaceId: "parent",
844+
prompt: "research prompt",
845+
reportMarkdown: "This is the saved report content",
846+
reportTitle: "Saved Report",
847+
reportedAt: new Date().toISOString(),
848+
});
849+
850+
const aiService = new FakeAIService();
851+
const workspaceService = new FakeWorkspaceService(config, aiService);
852+
const historyService = new FakeHistoryService();
853+
const partialService = new FakePartialService();
854+
855+
const taskService = new TaskService(
856+
config as unknown as Config,
857+
workspaceService as unknown as WorkspaceService,
858+
historyService as unknown as HistoryService,
859+
partialService as unknown as PartialService,
860+
aiService as unknown as AIService
861+
);
862+
863+
await taskService.rehydrateTasks();
864+
865+
// The saved report should be re-delivered to the parent
866+
expect(workspaceService.appendedMessages).toHaveLength(1);
867+
expect(workspaceService.appendedMessages[0]?.workspaceId).toBe("parent");
868+
const msg = workspaceService.appendedMessages[0]?.message;
869+
const textPart = msg?.parts.find((p) => p.type === "text");
870+
assert(textPart?.type === "text", "expected text part");
871+
expect(textPart.text).toContain("This is the saved report content");
872+
});
873+
827874
it("does not remove a completed task workspace until its subtree is gone", async () => {
828875
const config = new FakeConfig();
829876
const parent = createWorkspaceMetadata("task_parent");

src/node/services/taskService.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,43 @@ export class TaskService extends EventEmitter {
472472
}
473473
}
474474

475+
/**
476+
* Re-deliver a saved report from a "reported" task on restart.
477+
* This handles the case where Mux crashed after saving taskStatus="reported"
478+
* but before delivering the report to the parent workspace.
479+
*/
480+
private async redeliverSavedReport(taskId: string, taskState: TaskState): Promise<void> {
481+
assert(taskState.reportMarkdown, "Cannot redeliver report without reportMarkdown");
482+
483+
const report = {
484+
reportMarkdown: taskState.reportMarkdown,
485+
title: taskState.reportTitle,
486+
};
487+
488+
// Re-post the report to the parent workspace history (idempotent from user perspective)
489+
await this.postReportToParent(taskId, taskState, report);
490+
491+
// If this was a foreground task, re-inject the tool output
492+
if (taskState.parentToolCallId) {
493+
const toolOutput: TaskToolResult = {
494+
status: "completed",
495+
taskId,
496+
reportMarkdown: report.reportMarkdown,
497+
reportTitle: report.title,
498+
};
499+
const injected = await this.injectToolOutputToParent(
500+
taskState.parentWorkspaceId,
501+
taskState.parentToolCallId,
502+
toolOutput
503+
);
504+
if (injected) {
505+
await this.maybeResumeParentStream(taskState.parentWorkspaceId);
506+
}
507+
}
508+
509+
log.debug(`Re-delivered saved report for task ${taskId}`);
510+
}
511+
475512
/**
476513
* Inject task tool output into the parent workspace's pending tool call.
477514
* Persists the output into partial.json or chat.jsonl and emits a synthetic
@@ -952,6 +989,15 @@ export class TaskService extends EventEmitter {
952989
} catch (error) {
953990
log.error(`Failed to send reminder to task ${workspaceId}:`, error);
954991
}
992+
} else if (taskState.taskStatus === "reported") {
993+
// Task already reported but may not have delivered to parent before crash.
994+
// Re-deliver the saved report to ensure parent gets the result.
995+
if (taskState.reportMarkdown) {
996+
log.debug(`Re-delivering saved report for task ${workspaceId}`);
997+
await this.redeliverSavedReport(workspaceId, taskState);
998+
} else {
999+
log.warn(`Task ${workspaceId} marked as reported but has no saved report`);
1000+
}
9551001
}
9561002
}
9571003

@@ -1036,7 +1082,13 @@ export class TaskService extends EventEmitter {
10361082

10371083
private async hasActiveDescendantTasks(parentWorkspaceId: string): Promise<boolean> {
10381084
const activeTasks = this.config.getActiveAgentTaskWorkspaces();
1039-
if (activeTasks.length === 0) {
1085+
// Filter out "reported" tasks - they're done and shouldn't block parent resumption.
1086+
// We still include them in getActiveAgentTaskWorkspaces for restart redelivery,
1087+
// but they shouldn't count as "active" for the purpose of blocking resumption.
1088+
const trulyActiveTasks = activeTasks.filter(
1089+
({ taskState }) => taskState.taskStatus !== "reported"
1090+
);
1091+
if (trulyActiveTasks.length === 0) {
10401092
return false;
10411093
}
10421094

@@ -1046,7 +1098,7 @@ export class TaskService extends EventEmitter {
10461098
parentMap.set(meta.id, meta.parentWorkspaceId);
10471099
}
10481100

1049-
for (const { workspaceId } of activeTasks) {
1101+
for (const { workspaceId } of trulyActiveTasks) {
10501102
let current: string | undefined = workspaceId;
10511103
while (current) {
10521104
const parent = parentMap.get(current);

0 commit comments

Comments
 (0)