feat(FN-4813): complete Step 2 — add distributed claim mutex tests

Fusion-Task-Id: FN-4813
Fusion-Task-Lineage: 846893a5-2afa-4817-8f64-8c444d2fd713
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 16:37:21 -07:00
committed by gsxdsm
parent 86237c9a50
commit 735c8f413b
5 changed files with 272 additions and 12 deletions

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
@@ -3800,6 +3800,60 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return rows.map((row) => this.rowToTask(row));
}
async tryClaimCheckout(
taskId: string,
claim: {
agentId: string;
nodeId: string;
runId: string | null;
leaseEpoch: number;
renewedAt: string;
},
precondition: CheckoutClaimPrecondition,
): Promise<{ ok: true; task: Task } | { ok: false; reason: "row_not_found" | "precondition_failed"; current: Task | null }> {
const current = await this.getTask(taskId);
if (!current) {
return { ok: false, reason: "row_not_found", current: null };
}
const updateResult = this.db.prepare(`
UPDATE tasks
SET
checkedOutBy = ?,
checkedOutAt = COALESCE(checkedOutAt, ?),
checkoutNodeId = ?,
checkoutRunId = ?,
checkoutLeaseRenewedAt = ?,
checkoutLeaseEpoch = ?
WHERE id = ?
AND COALESCE(checkedOutBy, '') = COALESCE(?, '')
AND COALESCE(checkoutNodeId, '') = COALESCE(?, '')
AND COALESCE(checkoutLeaseEpoch, 0) = COALESCE(?, 0)
`).run(
claim.agentId,
new Date().toISOString(),
claim.nodeId,
claim.runId,
claim.renewedAt,
claim.leaseEpoch,
taskId,
precondition.expectedCheckedOutBy ?? null,
precondition.expectedNodeId ?? null,
precondition.expectedLeaseEpoch ?? 0,
) as { changes: number };
const post = await this.getTask(taskId);
if (updateResult.changes === 0) {
return { ok: false, reason: "precondition_failed", current: post };
}
if (!post) {
return { ok: false, reason: "row_not_found", current: null };
}
return { ok: true, task: post };
}
async selectNextTaskForAgent(
agentId: string,
agent?: Pick<Agent, "id" | "role">,