feat(team-mode): add team tasklist claim with tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:46:08 +09:00
parent 043348442d
commit 4270af3ac9
2 changed files with 197 additions and 0 deletions
@@ -0,0 +1,99 @@
/// <reference types="bun-types" />
import { expect, test } from "bun:test"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { getTasksDir, resolveBaseDir } from "../team-registry"
import { claimTask, AlreadyClaimedError, BlockedByError } from "./claim"
import { createTask } from "./store"
import { createTaskInput, createTasklistFixture } from "./test-support"
import { updateTaskStatus } from "./update"
test("claimTask allows exactly one concurrent claimant", async () => {
// given
const fixture = await createTasklistFixture()
try {
const task = await createTask(fixture.teamRunId, createTaskInput(), fixture.config)
// when
const claimResults = await Promise.allSettled([
claimTask(fixture.teamRunId, task.id, "member-a", fixture.config),
claimTask(fixture.teamRunId, task.id, "member-b", fixture.config),
])
const successfulClaims = claimResults.filter((result) => result.status === "fulfilled")
const failedClaims = claimResults.filter((result) => result.status === "rejected")
// then
expect(successfulClaims).toHaveLength(1)
expect(failedClaims).toHaveLength(1)
expect(failedClaims[0]?.status).toBe("rejected")
if (failedClaims[0]?.status === "rejected") {
expect(failedClaims[0].reason).toBeInstanceOf(AlreadyClaimedError)
}
} finally {
await fixture.cleanup()
}
})
test("claimTask rejects blocked tasks until blockers complete", async () => {
// given
const fixture = await createTasklistFixture()
try {
const blockerTask = await createTask(fixture.teamRunId, createTaskInput({ subject: "blocker" }), fixture.config)
const blockedTask = await createTask(
fixture.teamRunId,
createTaskInput({ subject: "blocked", blockedBy: [blockerTask.id] }),
fixture.config,
)
// when
let blockedError: unknown = null
try {
await claimTask(fixture.teamRunId, blockedTask.id, "member-a", fixture.config)
} catch (error) {
blockedError = error
}
// then
expect(blockedError).toBeInstanceOf(BlockedByError)
// given
await claimTask(fixture.teamRunId, blockerTask.id, "member-b", fixture.config)
await updateTaskStatus(fixture.teamRunId, blockerTask.id, "in_progress", "member-b", fixture.config)
await updateTaskStatus(fixture.teamRunId, blockerTask.id, "completed", "member-b", fixture.config)
// when
const claimedTask = await claimTask(fixture.teamRunId, blockedTask.id, "member-a", fixture.config)
// then
expect(claimedTask.status).toBe("claimed")
expect(claimedTask.owner).toBe("member-a")
} finally {
await fixture.cleanup()
}
})
test("claimTask reaps a stale claim lock before claiming", async () => {
// given
const fixture = await createTasklistFixture()
try {
const task = await createTask(fixture.teamRunId, createTaskInput(), fixture.config)
const tasksDirectory = getTasksDir(resolveBaseDir(fixture.config), fixture.teamRunId)
const staleLockPath = path.join(tasksDirectory, "claims", `${task.id}.lock`)
await writeFile(staleLockPath, `member-z\n999999\n${Date.now() - 600_000}\n`)
// when
const claimedTask = await claimTask(fixture.teamRunId, task.id, "member-a", fixture.config)
// then
expect(claimedTask.status).toBe("claimed")
expect(claimedTask.owner).toBe("member-a")
} finally {
await fixture.cleanup()
}
})
@@ -0,0 +1,98 @@
import { access, mkdir } from "node:fs/promises"
import path from "node:path"
import type { TeamModeConfig } from "../../../config/schema/team-mode"
import { getTasksDir, resolveBaseDir } from "../team-registry"
import { atomicWrite, detectStaleLock, reapStaleLock, withLock } from "../team-state-store/locks"
import { TaskSchema } from "../types"
import type { Task } from "../types"
import { canClaim } from "./dependencies"
import { getTask } from "./get"
import { listTasks } from "./list"
const CLAIM_STALE_AFTER_MS = 300_000
async function lockExists(lockPath: string): Promise<boolean> {
try {
await access(lockPath)
return true
} catch {
return false
}
}
function getBlockingTaskIds(task: Task, allTasks: Task[]): string[] {
return task.blockedBy.filter((blockerId) => {
const blockerTask = allTasks.find((candidateTask) => candidateTask.id === blockerId)
return blockerTask !== undefined && blockerTask.status !== "completed"
})
}
export class AlreadyClaimedError extends Error {
constructor(message = "already_claimed") {
super(message)
this.name = "AlreadyClaimedError"
}
}
export class BlockedByError extends Error {
constructor(public readonly blockers: string[]) {
super(`blocked by ${blockers.join(",")}`)
this.name = "BlockedByError"
}
}
export async function claimTask(
teamRunId: string,
taskId: string,
memberName: string,
config: TeamModeConfig,
): Promise<Task> {
const baseDirectory = resolveBaseDir(config)
const tasksDirectory = getTasksDir(baseDirectory, teamRunId)
const claimsDirectory = path.join(tasksDirectory, "claims")
const taskPath = path.join(tasksDirectory, `${taskId}.json`)
const claimLockPath = path.join(claimsDirectory, `${taskId}.lock`)
await mkdir(claimsDirectory, { recursive: true, mode: 0o700 })
const task = await getTask(teamRunId, taskId, config)
if (task.status !== "pending") {
throw new AlreadyClaimedError()
}
const allTasks = await listTasks(teamRunId, config)
if (!canClaim(task, allTasks)) {
throw new BlockedByError(getBlockingTaskIds(task, allTasks))
}
if (await detectStaleLock(claimLockPath, CLAIM_STALE_AFTER_MS)) {
await reapStaleLock(claimLockPath)
} else if (await lockExists(claimLockPath)) {
throw new AlreadyClaimedError()
}
return withLock(claimLockPath, async () => {
const refreshedTask = await getTask(teamRunId, taskId, config)
if (refreshedTask.status !== "pending") {
throw new AlreadyClaimedError()
}
const refreshedTasks = await listTasks(teamRunId, config)
if (!canClaim(refreshedTask, refreshedTasks)) {
throw new BlockedByError(getBlockingTaskIds(refreshedTask, refreshedTasks))
}
const now = Date.now()
const updatedTask = TaskSchema.parse({
...refreshedTask,
status: "claimed",
owner: memberName,
claimedAt: now,
updatedAt: now,
})
await atomicWrite(taskPath, `${JSON.stringify(updatedTask, null, 2)}\n`)
return updatedTask
}, { ownerTag: memberName, staleAfterMs: CLAIM_STALE_AFTER_MS })
}