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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user