feat(HAI-033): add dependency-chain worktree reuse and conditional cleanup

- Add executor logic to resolve and reuse dependency worktrees for warm build caches
- Add reuseWorktree method that creates a new branch in an existing worktree
- Add findWorktreeUser helper to check if a worktree is shared across tasks
- Update executor and merger cleanup to skip worktree removal when still in use
- Add comprehensive tests for worktree reuse and conditional cleanup paths
This commit is contained in:
Dustin Byrne
2026-03-25 22:51:43 -04:00
parent 2fc6cba0b5
commit b41e215246
4 changed files with 554 additions and 17 deletions

View File

@@ -8,6 +8,9 @@ vi.mock("./pi.js", () => ({
vi.mock("./reviewer.js", () => ({ vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(), reviewStep: vi.fn(),
})); }));
vi.mock("./merger.js", () => ({
findWorktreeUser: vi.fn().mockResolvedValue(null),
}));
// Mock node modules used by executor // Mock node modules used by executor
vi.mock("node:child_process", () => ({ vi.mock("node:child_process", () => ({
@@ -20,6 +23,8 @@ vi.mock("node:fs", () => ({
import { TaskExecutor } from "./executor.js"; import { TaskExecutor } from "./executor.js";
import { createHaiAgent } from "./pi.js"; import { createHaiAgent } from "./pi.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { findWorktreeUser } from "./merger.js";
import type { Column, Task } from "@hai/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent); const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
@@ -310,3 +315,248 @@ describe("TaskExecutor worktreeInitCommand", () => {
expect(store.getSettings).not.toHaveBeenCalled(); expect(store.getSettings).not.toHaveBeenCalled();
}); });
}); });
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
describe("TaskExecutor worktree reuse", () => {
const makeTask = (overrides: Partial<Task> = {}): Task => ({
id: "HAI-020",
title: "Dependent task",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(false);
mockedFindWorktreeUser.mockResolvedValue(null);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("reuses dependency worktree when dep has existing worktree on disk", async () => {
const store = createMockStore();
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
// Dep task is in-review with an existing worktree
store.listTasks.mockResolvedValue([
makeTask({
id: "HAI-019",
column: "in-review",
worktree: depWorktreePath,
dependencies: [],
}),
makeTask({
id: "HAI-020",
column: "in-progress",
dependencies: ["HAI-019"],
}),
]);
// existsSync: dep worktree exists on disk
mockedExistsSync.mockImplementation((p: any) => {
return p === depWorktreePath;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
// Should call `git checkout -b` (reuse), NOT `git worktree add`
const checkoutCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
);
expect(checkoutCall).toBeDefined();
expect(checkoutCall![0]).toContain("hai/hai-020");
const worktreeAddCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
);
expect(worktreeAddCall).toBeUndefined();
// Task's worktree should be set to the reused path
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depWorktreePath });
});
it("creates fresh worktree when dependency worktree does NOT exist on disk", async () => {
const store = createMockStore();
// Dep is done but worktree was removed (cleared on done)
store.listTasks.mockResolvedValue([
makeTask({ id: "HAI-019", column: "done", dependencies: [] }),
makeTask({ id: "HAI-020", column: "in-progress", dependencies: ["HAI-019"] }),
]);
mockedExistsSync.mockReturnValue(false);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
// Should call `git worktree add`, NOT `git checkout -b`
const worktreeAddCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
);
expect(worktreeAddCall).toBeDefined();
const checkoutCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
);
expect(checkoutCall).toBeUndefined();
});
it("creates fresh worktree when task has NO dependencies", async () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
mockedExistsSync.mockReturnValue(false);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: [] }));
const worktreeAddCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
);
expect(worktreeAddCall).toBeDefined();
});
it("does NOT run worktreeInitCommand when reusing a worktree", async () => {
const store = createMockStore();
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
store.listTasks.mockResolvedValue([
makeTask({
id: "HAI-019",
column: "in-review",
worktree: depWorktreePath,
dependencies: [],
}),
]);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: "pnpm install",
});
mockedExistsSync.mockImplementation((p: any) => p === depWorktreePath);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
// Init command should NOT have been run
const initCall = mockedExecSync.mock.calls.find(
(call) => call[0] === "pnpm install",
);
expect(initCall).toBeUndefined();
});
it("resolveDependencyWorktree picks first dependency with existing worktree", async () => {
const store = createMockStore();
const depAPath = "/tmp/test/.worktrees/HAI-018";
const depBPath = "/tmp/test/.worktrees/HAI-019";
// Two deps: A has no worktree on disk, B does
store.listTasks.mockResolvedValue([
makeTask({ id: "HAI-018", column: "in-review" as Column, worktree: depAPath, dependencies: [] }),
makeTask({ id: "HAI-019", column: "in-review" as Column, worktree: depBPath, dependencies: [] }),
]);
mockedExistsSync.mockImplementation((p: any) => p === depBPath);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-018", "HAI-019"] }));
// Should reuse HAI-019's worktree (the one that exists)
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depBPath });
const checkoutCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
);
expect(checkoutCall).toBeDefined();
expect(checkoutCall![1]).toMatchObject({ cwd: depBPath });
});
});
describe("TaskExecutor cleanup — chain-aware", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(false);
mockedFindWorktreeUser.mockResolvedValue(null);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("does NOT remove worktree if another task still uses it", async () => {
const store = createMockStore();
mockedFindWorktreeUser.mockResolvedValue("HAI-021");
const executor = new TaskExecutor(store, "/tmp/test");
// Execute a task to register a worktree
mockedExistsSync.mockReturnValue(false);
await executor.execute({
id: "HAI-020",
title: "Test",
description: "Test",
column: "in-progress" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Now cleanup — should skip removal because findWorktreeUser returns "HAI-021"
await executor.cleanup("HAI-020");
const removeCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
);
expect(removeCall).toBeUndefined();
});
it("removes worktree when no other task uses it", async () => {
const store = createMockStore();
mockedFindWorktreeUser.mockResolvedValue(null);
const executor = new TaskExecutor(store, "/tmp/test");
mockedExistsSync.mockReturnValue(false);
await executor.execute({
id: "HAI-020",
title: "Test",
description: "Test",
column: "in-progress" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await executor.cleanup("HAI-020");
const removeCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
);
expect(removeCall).toBeDefined();
});
});

View File

@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core"; import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core";
import { findWorktreeUser } from "./merger.js";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import { createHaiAgent } from "./pi.js"; import { createHaiAgent } from "./pi.js";
import { reviewStep } from "./reviewer.js"; import { reviewStep } from "./reviewer.js";
@@ -165,6 +166,43 @@ export class TaskExecutor {
} }
} }
/**
* Find a dependency task that has an existing worktree directory on disk.
* Returns the worktree path if found, null otherwise.
* Prefers dependencies whose worktree is still on disk so build caches
* (node_modules, target/, dist/) can be reused by dependent tasks.
*/
private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
if (task.dependencies.length === 0) return null;
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (
dep &&
dep.worktree &&
(dep.column === "done" || dep.column === "in-review") &&
existsSync(dep.worktree)
) {
return dep.worktree;
}
}
return null;
}
/**
* Reuse an existing worktree directory from a dependency task.
* Instead of creating a new worktree with `git worktree add`, this creates
* a new branch in the existing worktree via `git checkout -b`. The worktree
* directory (and its build caches) are preserved.
*/
private reuseWorktree(branch: string, worktreePath: string): void {
execSync(`git checkout -b "${branch}"`, {
cwd: worktreePath,
stdio: "pipe",
});
console.log(`[executor] Reused worktree at ${worktreePath}, created branch ${branch}`);
}
async execute(task: Task): Promise<void> { async execute(task: Task): Promise<void> {
if (this.executing.has(task.id)) return; if (this.executing.has(task.id)) return;
this.executing.add(task.id); this.executing.add(task.id);
@@ -184,14 +222,34 @@ export class TaskExecutor {
return; return;
} }
// Check if a dependency has a reusable worktree (warm cache)
const depWorktree = this.resolveDependencyWorktree(task, allTasks);
// Create or reuse worktree // Create or reuse worktree
const branchName = `hai/${task.id.toLowerCase()}`; const branchName = `hai/${task.id.toLowerCase()}`;
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id); let worktreePath: string;
const isResume = existsSync(worktreePath); let isResume: boolean;
this.createWorktree(branchName, worktreePath); let isReuse: boolean;
if (depWorktree && !task.worktree) {
// Reuse dependency worktree for warm build cache
worktreePath = depWorktree;
isResume = false;
isReuse = true;
const depTask = allTasks.find((t) => t.worktree === depWorktree);
const depId = depTask?.id ?? "unknown";
console.log(`[executor] Reusing worktree from ${depId} at ${depWorktree} (warm cache)`);
this.reuseWorktree(branchName, worktreePath);
} else {
worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
isResume = existsSync(worktreePath);
isReuse = false;
this.createWorktree(branchName, worktreePath);
}
this.activeWorktrees.set(task.id, worktreePath); this.activeWorktrees.set(task.id, worktreePath);
if (!isResume) { if (!isResume && !isReuse) {
await this.store.updateTask(task.id, { worktree: worktreePath }); await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`); await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
@@ -210,6 +268,10 @@ export class TaskExecutor {
await this.store.logEntry(task.id, `Worktree init command failed: ${message}`); await this.store.logEntry(task.id, `Worktree init command failed: ${message}`);
} }
} }
} else if (isReuse) {
// Update task's worktree field to point to the reused directory
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Reusing worktree at ${worktreePath} (warm cache)`);
} }
this.options.onStart?.(task, worktreePath); this.options.onStart?.(task, worktreePath);
@@ -457,12 +519,26 @@ export class TaskExecutor {
console.log(`[executor] Worktree created: ${path}`); console.log(`[executor] Worktree created: ${path}`);
} }
/**
* Remove a task's worktree, but only if no other in-progress or todo task
* shares the same worktree path (dependency-chain reuse). The branch is
* always cleaned up by the merger on a per-task basis.
*/
async cleanup(taskId: string): Promise<void> { async cleanup(taskId: string): Promise<void> {
const worktreePath = this.activeWorktrees.get(taskId); const worktreePath = this.activeWorktrees.get(taskId);
if (!worktreePath) return; if (!worktreePath) return;
this.activeWorktrees.delete(taskId);
// Check if another task still needs this worktree
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
if (otherUser) {
console.log(`[executor] Worktree retained for ${taskId} — still needed by ${otherUser}`);
return;
}
try { try {
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" }); execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" });
this.activeWorktrees.delete(taskId);
console.log(`[executor] Cleaned up worktree for ${taskId}`); console.log(`[executor] Cleaned up worktree for ${taskId}`);
} catch (err: any) { } catch (err: any) {
console.error(`[executor] Failed to clean up worktree for ${taskId}:`, err.message); console.error(`[executor] Failed to clean up worktree for ${taskId}:`, err.message);

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock external dependencies
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { aiMergeTask, findWorktreeUser } from "./merger.js";
import { createHaiAgent } from "./pi.js";
import { execSync } from "node:child_process";
import type { TaskStore, Task, MergeResult } from "@hai/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
const mockedExecSync = vi.mocked(execSync);
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "HAI-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/HAI-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
on: vi.fn(),
} as unknown as TaskStore;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached, branch -d
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBeNull();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBe("HAI-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBeNull();
});
});
describe("aiMergeTask — conditional worktree cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("does NOT remove worktree when another task references the same path", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
// Worktree should NOT be removed
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeUndefined();
expect(result.worktreeRemoved).toBe(false);
});
it("removes worktree when no other task references it", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeDefined();
expect(result.worktreeRemoved).toBe(true);
});
it("always deletes the branch regardless of worktree sharing", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
// Branch should be deleted even though worktree is shared
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d") || String(call[0]).includes("branch -D"),
);
expect(branchDeleteCall).toBeDefined();
expect(result.branchDeleted).toBe(true);
});
it("result.worktreeRemoved is false when worktree is retained", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "todo" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
expect(result.worktreeRemoved).toBe(false);
expect(result.merged).toBe(true);
});
});

View File

@@ -44,6 +44,27 @@ git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route wi
Do NOT use generic messages like "merge branch" or "resolve conflicts". Do NOT use generic messages like "merge branch" or "resolve conflicts".
Base the message on the ACTUAL work done in the branch commits.`; Base the message on the ACTUAL work done in the branch commits.`;
/**
* Check if any non-done task (other than `excludeTaskId`) references the given
* worktree path. Returns the first matching task ID, or null if the worktree
* is safe to remove. Used by both the merger and executor cleanup to avoid
* deleting worktrees that are shared across dependent tasks.
*/
export async function findWorktreeUser(
store: TaskStore,
worktreePath: string,
excludeTaskId: string,
): Promise<string | null> {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.id === excludeTaskId) continue;
if (t.worktree === worktreePath && t.column !== "done") {
return t.id;
}
}
return null;
}
export interface MergerOptions { export interface MergerOptions {
/** Called with agent text output */ /** Called with agent text output */
onAgentText?: (delta: string) => void; onAgentText?: (delta: string) => void;
@@ -190,18 +211,7 @@ export async function aiMergeTask(
session.dispose(); session.dispose();
} }
// 7. Clean up worktree // 7. Delete branch (always per-task, regardless of worktree sharing)
if (existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch { /* non-fatal */ }
}
// 8. Delete branch
try { try {
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" }); execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
result.branchDeleted = true; result.branchDeleted = true;
@@ -212,6 +222,23 @@ export async function aiMergeTask(
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
} }
// 8. Clean up worktree — only if no other non-done task still references it
if (existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
if (otherUser) {
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
result.worktreeRemoved = false;
} else {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch { /* non-fatal */ }
}
}
// 9. Move task to done // 9. Move task to done
await completeTask(store, taskId, result); await completeTask(store, taskId, result);
return result; return result;