feat(FN-1253): implement task checkout leasing end-to-end

- Add checkout lease types and conflict error exports, plus DB schema v20 migration for checkedOutBy/checkedOutAt
- Persist checkout lease fields in TaskStore and add AgentStore checkout/release/force-release/get-holder operations
- Add dashboard checkout API routes for acquire/release/force-release/status with explicit 409 conflict and 403 holder enforcement
- Enforce checkout ownership in heartbeat execution with graceful checkout_conflict exits when another agent holds the lease
- Expand core and dashboard test coverage for schema, store behavior, API routes, and leasing workflows, and document leasing behavior in AGENTS.md
This commit is contained in:
gsxdsm
2026-04-08 17:01:44 -07:00
parent 2a34ebe6cc
commit f227dba29f
12 changed files with 655 additions and 19 deletions

View File

@@ -2202,6 +2202,230 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
}, 30_000);
});
describe("Task checkout routes", () => {
let tempDir: string;
let fusionDir: string;
let store: TaskStore;
let agentAId: string;
let agentBId: string;
let taskState: TaskDetail;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-task-checkout-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agentA = await agentStore.createAgent({
name: "Checkout Agent A",
role: "executor",
});
const agentB = await agentStore.createAgent({
name: "Checkout Agent B",
role: "executor",
});
agentAId = agentA.id;
agentBId = agentB.id;
taskState = {
...FAKE_TASK_DETAIL,
id: "FN-300",
checkedOutBy: undefined,
checkedOutAt: undefined,
};
store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockImplementation(async (id: string) => {
if (id !== taskState.id) {
return null;
}
return { ...taskState };
}),
updateTask: vi.fn().mockImplementation(async (id: string, updates: { checkedOutBy?: string | null; checkedOutAt?: string | null }) => {
if (id !== taskState.id) {
throw new Error(`Task ${id} not found`);
}
if (updates.checkedOutBy === null) {
taskState.checkedOutBy = undefined;
taskState.checkedOutAt = undefined;
} else if (updates.checkedOutBy !== undefined) {
taskState.checkedOutBy = updates.checkedOutBy;
taskState.checkedOutAt = updates.checkedOutAt ?? new Date().toISOString();
}
return { ...taskState };
}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as any);
}, 30_000);
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("POST /tasks/:id/checkout — returns 200 on success", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.checkedOutBy).toBe(agentAId);
expect(res.body.checkedOutAt).toBeTruthy();
}, 20_000);
it("POST /tasks/:id/checkout — returns 409 on conflict", async () => {
await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentBId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toBe("Task is already checked out");
expect(res.body.currentHolder).toBe(agentAId);
expect(res.body.taskId).toBe(taskState.id);
}, 20_000);
it("POST /tasks/:id/checkout — returns 400 when agentId is missing", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toBe("agentId is required");
}, 20_000);
it("POST /tasks/:id/release — returns 200 on success", async () => {
await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/release`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.checkedOutBy).toBeUndefined();
const statusRes = await GET(buildApp(), `/api/tasks/${taskState.id}/checkout`);
expect(statusRes.status).toBe(200);
expect(statusRes.body.checkedOutBy).toBeNull();
expect(statusRes.body.checkedOutAt).toBeNull();
}, 20_000);
it("POST /tasks/:id/release — returns 403 for wrong holder", async () => {
await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/release`,
JSON.stringify({ agentId: agentBId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(403);
expect(res.body.error).toBe("Not the checkout holder");
}, 20_000);
it("POST /tasks/:id/release — returns 400 when agentId is missing", async () => {
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/release`,
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toBe("agentId is required");
}, 20_000);
it("GET /tasks/:id/checkout — returns checkout status", async () => {
await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
const res = await GET(buildApp(), `/api/tasks/${taskState.id}/checkout`);
expect(res.status).toBe(200);
expect(res.body.checkedOutBy).toBe(agentAId);
expect(res.body.checkedOutAt).toBeTruthy();
}, 20_000);
it("POST /tasks/:id/force-release — clears active checkout", async () => {
await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: agentAId }),
{ "Content-Type": "application/json" },
);
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/force-release`,
);
expect(res.status).toBe(200);
const statusRes = await GET(buildApp(), `/api/tasks/${taskState.id}/checkout`);
expect(statusRes.status).toBe(200);
expect(statusRes.body.checkedOutBy).toBeNull();
expect(statusRes.body.checkedOutAt).toBeNull();
}, 20_000);
});
describe("Attachment routes", () => {
const FAKE_ATTACHMENT: TaskAttachment = {
filename: "1234-screenshot.png",

View File

@@ -2891,6 +2891,120 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Acquire checkout lease for a task
router.post("/tasks/:id/checkout", async (req, res) => {
try {
const { agentId } = req.body ?? {};
if (typeof agentId !== "string" || agentId.trim().length === 0) {
throw badRequest("agentId is required");
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({
rootDir: scopedStore.getFusionDir(),
taskStore: scopedStore,
});
await agentStore.init();
const task = await agentStore.checkoutTask(agentId, req.params.id);
res.json(task);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err?.name === "CheckoutConflictError") {
res.status(409).json({
error: "Task is already checked out",
currentHolder: err.currentHolderId,
taskId: err.taskId,
});
return;
}
if (err?.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
// Release checkout lease for a task
router.post("/tasks/:id/release", async (req, res) => {
try {
const { agentId } = req.body ?? {};
if (typeof agentId !== "string" || agentId.trim().length === 0) {
throw badRequest("agentId is required");
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({
rootDir: scopedStore.getFusionDir(),
taskStore: scopedStore,
});
await agentStore.init();
const task = await agentStore.releaseTask(agentId, req.params.id);
res.json(task);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err?.message?.includes("not the checkout holder")) {
throw new ApiError(403, "Not the checkout holder");
}
if (err?.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
// Force release checkout lease for a task
router.post("/tasks/:id/force-release", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({
rootDir: scopedStore.getFusionDir(),
taskStore: scopedStore,
});
await agentStore.init();
const task = await agentStore.forceReleaseTask(req.params.id);
res.json(task);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err?.message?.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err);
}
});
// Get checkout lease state for a task
router.get("/tasks/:id/checkout", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
throw notFound("Task not found");
}
res.json({
checkedOutBy: task.checkedOutBy ?? null,
checkedOutAt: task.checkedOutAt ?? null,
});
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// Delete task
router.delete("/tasks/:id", async (req, res) => {
try {