Multiworkspace tasks could not complete due to two independent bugs:
1. task.workspaceWorktrees had no SQLite column / rowToTask mapping, so
fn_acquire_repo_worktree's updateTask write was dropped on every persist
(applyTaskPatch writes the DB-round-tripped task back to task.json). Every
later getTask returned undefined, so fn_task_done's scope verifier read {}
and blocked with "acquired no sub-repo worktrees", and isWorkspaceTask()
consumers misfired. Persist it mirroring mergeDetails (schema column + v129
migration + db-migrate + defineTaskColumn + TaskRow + rowToTask).
2. In workspace mode every task ran rooted at the shared browse-only root, and
setActiveSession registered that path keyed only by path — so a second
concurrent workspace task was rejected by the foreign-task guard
("active-session path ... is held by ..."). Give each task a task-scoped
synthetic session key (sessionRegistryPath), applied at all register and
unregister sites; the in-memory worktree Set still holds the real root.
Regression tests assert the persistence invariant across getTask/listTasks/
store-reopen and concurrent session registration across all three session
surfaces; both verified to fail without the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
4.9 KiB
TypeScript
93 lines
4.9 KiB
TypeScript
/*
|
|
FNXC:Workspace 2026-06-24-15:45 (concurrent workspace tasks — shared browse-root collision regression):
|
|
In workspace mode every task runs its agent session rooted at the SHARED browse-only workspace root
|
|
(`this.rootDir`); per-sub-repo worktrees are acquired on demand. The session registrations
|
|
(executor / step-session / workflow-step) are keyed in the GLOBAL path-keyed activeSessionRegistry,
|
|
whose foreign-task guard rejects a second task registering a path already held by a different task.
|
|
With the bare root as the key, the SECOND concurrent workspace task failed with
|
|
"active-session path <root> is held by task <other>; task <self> may not overwrite it" — so only ONE
|
|
task per workspace could ever run (the reported MULT-001 vs MULT-002 failure).
|
|
|
|
Invariant under test (across ALL session-registration surfaces): two different workspace tasks sharing
|
|
the browse-root register concurrently WITHOUT collision, each remains discoverable by liveness
|
|
(pathsForTask returns a task-scoped key), and cleanup leaves no leaked entry. Negative control: a
|
|
NON-workspace executor (unique worktree path) still rejects a foreign-task overwrite, so the
|
|
cross-phase-clobber guard is preserved.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import type { TaskStore } from "@fusion/core";
|
|
import { TaskExecutor } from "../executor.js";
|
|
import { activeSessionRegistry, ActiveSessionPathHeldByForeignTaskError } from "../active-session-registry.js";
|
|
|
|
const WORKSPACE_ROOT = "/tmp/fusion-test-workspace-root";
|
|
|
|
function createStore(): TaskStore & EventEmitter {
|
|
const emitter = new EventEmitter();
|
|
return Object.assign(emitter, {
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
getRunContextFor: vi.fn(),
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
}) as unknown as TaskStore & EventEmitter;
|
|
}
|
|
|
|
function makeWorkspaceExecutor(): TaskExecutor {
|
|
const executor = new TaskExecutor(createStore(), WORKSPACE_ROOT);
|
|
(executor as any).workspaceConfig = { repos: ["swarmclaw", "OpenVide"] };
|
|
return executor;
|
|
}
|
|
|
|
describe("workspace concurrent session registration", () => {
|
|
beforeEach(() => activeSessionRegistry.clear());
|
|
afterEach(() => activeSessionRegistry.clear());
|
|
|
|
it("lets two workspace tasks register executor sessions on the shared browse-root without collision", () => {
|
|
const executor = makeWorkspaceExecutor();
|
|
|
|
// Both tasks pass the SAME shared workspace root as worktreePath — the pre-fix collision point.
|
|
expect(() => (executor as any).setActiveSession("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
expect(() => (executor as any).setActiveSession("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
|
|
// Each task stays discoverable by liveness via a DISTINCT task-scoped registry key.
|
|
const a = activeSessionRegistry.pathsForTask("MULT-001");
|
|
const b = activeSessionRegistry.pathsForTask("MULT-002");
|
|
expect(a).toHaveLength(1);
|
|
expect(b).toHaveLength(1);
|
|
expect(a[0]).not.toEqual(b[0]);
|
|
expect(a[0]).toContain("MULT-001");
|
|
expect(b[0]).toContain("MULT-002");
|
|
});
|
|
|
|
it("cleans up the task-scoped session key on deleteActiveSession (no leak)", () => {
|
|
const executor = makeWorkspaceExecutor();
|
|
// The in-memory activeWorktrees Set holds the REAL root; deleteActiveSession must still map it
|
|
// back to the synthetic key it registered.
|
|
(executor as any).addActiveWorktree("MULT-001", WORKSPACE_ROOT);
|
|
(executor as any).setActiveSession("MULT-001", {}, WORKSPACE_ROOT);
|
|
expect(activeSessionRegistry.pathsForTask("MULT-001")).toHaveLength(1);
|
|
|
|
(executor as any).deleteActiveSession("MULT-001");
|
|
expect(activeSessionRegistry.pathsForTask("MULT-001")).toHaveLength(0);
|
|
});
|
|
|
|
it("does not collide across the step-session and workflow-step surfaces either", () => {
|
|
const executor = makeWorkspaceExecutor();
|
|
expect(() => (executor as any).setActiveStepExecutor("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
expect(() => (executor as any).setActiveStepExecutor("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
expect(() => (executor as any).setActiveWorkflowStepSession("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
expect(() => (executor as any).setActiveWorkflowStepSession("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
|
|
});
|
|
|
|
it("still rejects a foreign-task overwrite for NON-workspace tasks (clobber guard preserved)", () => {
|
|
const sharedWorktree = "/tmp/fusion-test-single-repo-worktree";
|
|
const executor = new TaskExecutor(createStore(), sharedWorktree); // no workspaceConfig → singular path
|
|
|
|
(executor as any).setActiveSession("FN-A", {}, sharedWorktree);
|
|
// A second, different task on the identical real worktree path must still be rejected — this is the
|
|
// cross-phase-clobber protection the workspace fix must not weaken.
|
|
expect(() => (executor as any).setActiveSession("FN-B", {}, sharedWorktree)).toThrow(
|
|
ActiveSessionPathHeldByForeignTaskError,
|
|
);
|
|
});
|
|
});
|