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:
@@ -40,8 +40,10 @@ import type {
|
||||
AgentRating,
|
||||
AgentRatingSummary,
|
||||
AgentRatingInput,
|
||||
Task,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
import { Database } from "./db.js";
|
||||
|
||||
@@ -76,8 +78,10 @@ type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
|
||||
|
||||
/** Options for AgentStore constructor */
|
||||
export interface AgentStoreOptions {
|
||||
/** Root directory for fn data (default: .fusion) */
|
||||
/** Root directory for kb data (default: .fusion) */
|
||||
rootDir?: string;
|
||||
/** Optional TaskStore for checkout/release operations */
|
||||
taskStore?: TaskStore;
|
||||
}
|
||||
|
||||
/** Agent data as stored on disk */
|
||||
@@ -119,11 +123,13 @@ export class AgentStore extends EventEmitter {
|
||||
private agentsDir: string;
|
||||
private locks: Map<string, AgentLock> = new Map();
|
||||
private _db: Database | null = null;
|
||||
private taskStore?: TaskStore;
|
||||
|
||||
constructor(options: AgentStoreOptions = {}) {
|
||||
super();
|
||||
this.rootDir = options.rootDir ?? ".fusion";
|
||||
this.agentsDir = join(this.rootDir, "agents");
|
||||
this.taskStore = options.taskStore;
|
||||
}
|
||||
|
||||
private get db(): Database {
|
||||
@@ -813,6 +819,93 @@ export class AgentStore extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a checkout lease for a task.
|
||||
* Throws CheckoutConflictError when another agent already holds the lease.
|
||||
*/
|
||||
async checkoutTask(agentId: string, taskId: string): Promise<Task> {
|
||||
if (!this.taskStore) {
|
||||
throw new Error("TaskStore not configured for checkout operations");
|
||||
}
|
||||
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const task = await this.taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
if (task.checkedOutBy && task.checkedOutBy !== agentId) {
|
||||
throw new CheckoutConflictError(taskId, task.checkedOutBy, agentId);
|
||||
}
|
||||
|
||||
if (task.checkedOutBy === agentId) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: agentId });
|
||||
await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a checkout lease for a task.
|
||||
*/
|
||||
async releaseTask(agentId: string, taskId: string): Promise<Task> {
|
||||
if (!this.taskStore) {
|
||||
throw new Error("TaskStore not configured for checkout operations");
|
||||
}
|
||||
|
||||
const task = await this.taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
if (task.checkedOutBy && task.checkedOutBy !== agentId) {
|
||||
throw new Error("Cannot release: not the checkout holder");
|
||||
}
|
||||
|
||||
if (!task.checkedOutBy) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null });
|
||||
await this.taskStore.logEntry(taskId, `Released by agent ${agentId}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force release a task checkout lease regardless of holder.
|
||||
*/
|
||||
async forceReleaseTask(taskId: string): Promise<Task> {
|
||||
if (!this.taskStore) {
|
||||
throw new Error("TaskStore not configured for checkout operations");
|
||||
}
|
||||
|
||||
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null });
|
||||
await this.taskStore.logEntry(taskId, "Checkout force-released");
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current checkout lease holder for a task.
|
||||
*/
|
||||
async getCheckedOutBy(taskId: string): Promise<string | undefined> {
|
||||
if (!this.taskStore) {
|
||||
throw new Error("TaskStore not configured for checkout operations");
|
||||
}
|
||||
|
||||
const task = await this.taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
return task.checkedOutBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset budget token usage counters for an agent.
|
||||
* @param agentId - The agent ID
|
||||
|
||||
Reference in New Issue
Block a user