863 lines
26 KiB
TypeScript
863 lines
26 KiB
TypeScript
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
|
||
import { join } from 'path'
|
||
import { z } from 'zod/v4'
|
||
import { getIsNonInteractiveSession, getSessionId } from '../bootstrap/state.js'
|
||
import { uniq } from './array.js'
|
||
import { logForDebugging } from './debug.js'
|
||
import { getClaudeConfigHomeDir, getTeamsDir, isEnvTruthy } from './envUtils.js'
|
||
import { errorMessage, getErrnoCode } from './errors.js'
|
||
import { lazySchema } from './lazySchema.js'
|
||
import * as lockfile from './lockfile.js'
|
||
import { logError } from './log.js'
|
||
import { createSignal } from './signal.js'
|
||
import { jsonParse, jsonStringify } from './slowOperations.js'
|
||
import { getTeamName } from './teammate.js'
|
||
import { getTeammateContext } from './teammateContext.js'
|
||
|
||
// Listeners for task list updates (used for immediate UI refresh in same process)
|
||
const tasksUpdated = createSignal()
|
||
|
||
/**
|
||
* Team name set by the leader when creating a team.
|
||
* Used by getTaskListId() so the leader's tasks are stored under the team name
|
||
* (matching where tmux/iTerm2 teammates look), not under the session ID.
|
||
*/
|
||
let leaderTeamName: string | undefined
|
||
|
||
/**
|
||
* Sets the leader's team name for task list resolution.
|
||
* Called by TeamCreateTool when a team is created.
|
||
*/
|
||
export function setLeaderTeamName(teamName: string): void {
|
||
if (leaderTeamName === teamName) return
|
||
leaderTeamName = teamName
|
||
// Changing the task list ID is a "tasks updated" event for subscribers —
|
||
// they're now looking at a different directory.
|
||
notifyTasksUpdated()
|
||
}
|
||
|
||
/**
|
||
* Clears the leader's team name.
|
||
* Called when a team is deleted.
|
||
*/
|
||
export function clearLeaderTeamName(): void {
|
||
if (leaderTeamName === undefined) return
|
||
leaderTeamName = undefined
|
||
notifyTasksUpdated()
|
||
}
|
||
|
||
/**
|
||
* Register a listener to be called when tasks are updated in this process.
|
||
* Returns an unsubscribe function.
|
||
*/
|
||
export const onTasksUpdated = tasksUpdated.subscribe
|
||
|
||
/**
|
||
* Notify listeners that tasks have been updated.
|
||
* Called internally after createTask, updateTask, etc.
|
||
* Wraps emit in try/catch so listener failures never propagate to callers
|
||
* (task mutations must succeed from the caller's perspective).
|
||
*/
|
||
export function notifyTasksUpdated(): void {
|
||
try {
|
||
tasksUpdated.emit()
|
||
} catch {
|
||
// Ignore listener errors — task mutations must not fail due to notification issues
|
||
}
|
||
}
|
||
|
||
export const TASK_STATUSES = ['pending', 'in_progress', 'completed'] as const
|
||
|
||
export const TaskStatusSchema = lazySchema(() =>
|
||
z.enum(['pending', 'in_progress', 'completed']),
|
||
)
|
||
export type TaskStatus = z.infer<ReturnType<typeof TaskStatusSchema>>
|
||
|
||
export const TaskSchema = lazySchema(() =>
|
||
z.object({
|
||
id: z.string(),
|
||
subject: z.string(),
|
||
description: z.string(),
|
||
activeForm: z.string().optional(), // present continuous form for spinner (e.g., "Running tests")
|
||
owner: z.string().optional(), // agent ID
|
||
status: TaskStatusSchema(),
|
||
blocks: z.array(z.string()), // task IDs this task blocks
|
||
blockedBy: z.array(z.string()), // task IDs that block this task
|
||
metadata: z.record(z.string(), z.unknown()).optional(), // arbitrary metadata
|
||
}),
|
||
)
|
||
export type Task = z.infer<ReturnType<typeof TaskSchema>>
|
||
|
||
// High water mark file name - stores the maximum task ID ever assigned
|
||
const HIGH_WATER_MARK_FILE = '.highwatermark'
|
||
|
||
// Lock options: retry with backoff so concurrent callers (multiple Claudes
|
||
// in a swarm) wait for the lock instead of failing immediately. The sync
|
||
// lockSync API blocked the event loop; the async API needs explicit retries
|
||
// to achieve the same serialization semantics.
|
||
//
|
||
// Budget sized for ~10+ concurrent swarm agents: each critical section does
|
||
// readdir + N×readFile + writeFile (~50-100ms on slow disks), so the last
|
||
// caller in a 10-way race needs ~900ms. retries=30 gives ~2.6s total wait.
|
||
const LOCK_OPTIONS = {
|
||
retries: {
|
||
retries: 30,
|
||
minTimeout: 5,
|
||
maxTimeout: 100,
|
||
},
|
||
}
|
||
|
||
function getHighWaterMarkPath(taskListId: string): string {
|
||
return join(getTasksDir(taskListId), HIGH_WATER_MARK_FILE)
|
||
}
|
||
|
||
async function readHighWaterMark(taskListId: string): Promise<number> {
|
||
const path = getHighWaterMarkPath(taskListId)
|
||
try {
|
||
const content = (await readFile(path, 'utf-8')).trim()
|
||
const value = parseInt(content, 10)
|
||
return isNaN(value) ? 0 : value
|
||
} catch {
|
||
return 0
|
||
}
|
||
}
|
||
|
||
async function writeHighWaterMark(
|
||
taskListId: string,
|
||
value: number,
|
||
): Promise<void> {
|
||
const path = getHighWaterMarkPath(taskListId)
|
||
await writeFile(path, String(value))
|
||
}
|
||
|
||
export function isTodoV2Enabled(): boolean {
|
||
// Force-enable tasks in non-interactive mode (e.g. SDK users who want Task tools over TodoWrite)
|
||
if (isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TASKS)) {
|
||
return true
|
||
}
|
||
return !getIsNonInteractiveSession()
|
||
}
|
||
|
||
/**
|
||
* Resets the task list for a new swarm - clears any existing tasks.
|
||
* Writes a high water mark file to prevent ID reuse after reset.
|
||
* Should be called when a new swarm is created to ensure task numbering starts at 1.
|
||
* Uses file locking to prevent race conditions when multiple Claudes run in parallel.
|
||
*/
|
||
export async function resetTaskList(taskListId: string): Promise<void> {
|
||
const dir = getTasksDir(taskListId)
|
||
const lockPath = await ensureTaskListLockFile(taskListId)
|
||
|
||
let release: (() => Promise<void>) | undefined
|
||
try {
|
||
// Acquire exclusive lock on the task list
|
||
release = await lockfile.lock(lockPath, LOCK_OPTIONS)
|
||
|
||
// Find the current highest ID and save it to the high water mark file
|
||
const currentHighest = await findHighestTaskIdFromFiles(taskListId)
|
||
if (currentHighest > 0) {
|
||
const existingMark = await readHighWaterMark(taskListId)
|
||
if (currentHighest > existingMark) {
|
||
await writeHighWaterMark(taskListId, currentHighest)
|
||
}
|
||
}
|
||
|
||
// Delete all task files
|
||
let files: string[]
|
||
try {
|
||
files = await readdir(dir)
|
||
} catch {
|
||
files = []
|
||
}
|
||
for (const file of files) {
|
||
if (file.endsWith('.json') && !file.startsWith('.')) {
|
||
const filePath = join(dir, file)
|
||
try {
|
||
await unlink(filePath)
|
||
} catch {
|
||
// Ignore errors, file may already be deleted
|
||
}
|
||
}
|
||
}
|
||
notifyTasksUpdated()
|
||
} finally {
|
||
if (release) {
|
||
await release()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gets the task list ID based on the current context.
|
||
* Priority:
|
||
* 1. CLAUDE_CODE_TASK_LIST_ID - explicit task list ID
|
||
* 2. In-process teammate: leader's team name (so teammates share the leader's task list)
|
||
* 3. CLAUDE_CODE_TEAM_NAME - set when running as a process-based teammate
|
||
* 4. Leader team name - set when the leader creates a team via TeamCreate
|
||
* 5. Session ID - fallback for standalone sessions
|
||
*/
|
||
export function getTaskListId(): string {
|
||
if (process.env.CLAUDE_CODE_TASK_LIST_ID) {
|
||
return process.env.CLAUDE_CODE_TASK_LIST_ID
|
||
}
|
||
// In-process teammates use the leader's team name so they share the same
|
||
// task list that tmux/iTerm2 teammates also resolve to.
|
||
const teammateCtx = getTeammateContext()
|
||
if (teammateCtx) {
|
||
return teammateCtx.teamName
|
||
}
|
||
return getTeamName() || leaderTeamName || getSessionId()
|
||
}
|
||
|
||
/**
|
||
* Sanitizes a string for safe use in file paths.
|
||
* Removes path traversal characters and other potentially dangerous characters.
|
||
* Only allows alphanumeric characters, hyphens, and underscores.
|
||
*/
|
||
export function sanitizePathComponent(input: string): string {
|
||
return input.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||
}
|
||
|
||
export function getTasksDir(taskListId: string): string {
|
||
return join(
|
||
getClaudeConfigHomeDir(),
|
||
'tasks',
|
||
sanitizePathComponent(taskListId),
|
||
)
|
||
}
|
||
|
||
export function getTaskPath(taskListId: string, taskId: string): string {
|
||
return join(getTasksDir(taskListId), `${sanitizePathComponent(taskId)}.json`)
|
||
}
|
||
|
||
export async function ensureTasksDir(taskListId: string): Promise<void> {
|
||
const dir = getTasksDir(taskListId)
|
||
try {
|
||
await mkdir(dir, { recursive: true })
|
||
} catch {
|
||
// Directory already exists or creation failed; callers will surface
|
||
// errors from subsequent operations.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Finds the highest task ID from existing task files (not including high water mark).
|
||
*/
|
||
async function findHighestTaskIdFromFiles(taskListId: string): Promise<number> {
|
||
const dir = getTasksDir(taskListId)
|
||
let files: string[]
|
||
try {
|
||
files = await readdir(dir)
|
||
} catch {
|
||
return 0
|
||
}
|
||
let highest = 0
|
||
for (const file of files) {
|
||
if (!file.endsWith('.json')) {
|
||
continue
|
||
}
|
||
const taskId = parseInt(file.replace('.json', ''), 10)
|
||
if (!isNaN(taskId) && taskId > highest) {
|
||
highest = taskId
|
||
}
|
||
}
|
||
return highest
|
||
}
|
||
|
||
/**
|
||
* Finds the highest task ID ever assigned, considering both existing files
|
||
* and the high water mark (for deleted/reset tasks).
|
||
*/
|
||
async function findHighestTaskId(taskListId: string): Promise<number> {
|
||
const [fromFiles, fromMark] = await Promise.all([
|
||
findHighestTaskIdFromFiles(taskListId),
|
||
readHighWaterMark(taskListId),
|
||
])
|
||
return Math.max(fromFiles, fromMark)
|
||
}
|
||
|
||
/**
|
||
* Creates a new task with a unique ID.
|
||
* Uses file locking to prevent race conditions when multiple processes
|
||
* create tasks concurrently.
|
||
*/
|
||
export async function createTask(
|
||
taskListId: string,
|
||
taskData: Omit<Task, 'id'>,
|
||
): Promise<string> {
|
||
const lockPath = await ensureTaskListLockFile(taskListId)
|
||
|
||
let release: (() => Promise<void>) | undefined
|
||
try {
|
||
// Acquire exclusive lock on the task list
|
||
release = await lockfile.lock(lockPath, LOCK_OPTIONS)
|
||
|
||
// Read highest ID from disk while holding the lock
|
||
const highestId = await findHighestTaskId(taskListId)
|
||
const id = String(highestId + 1)
|
||
const task: Task = { id, ...taskData }
|
||
const path = getTaskPath(taskListId, id)
|
||
await writeFile(path, jsonStringify(task, null, 2))
|
||
notifyTasksUpdated()
|
||
return id
|
||
} finally {
|
||
if (release) {
|
||
await release()
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function getTask(
|
||
taskListId: string,
|
||
taskId: string,
|
||
): Promise<Task | null> {
|
||
const path = getTaskPath(taskListId, taskId)
|
||
try {
|
||
const content = await readFile(path, 'utf-8')
|
||
const data = jsonParse(content) as { status?: string }
|
||
|
||
// TEMPORARY: Migrate old status names for existing sessions (ant-only)
|
||
if (process.env.USER_TYPE === 'ant') {
|
||
if (data.status === 'open') data.status = 'pending'
|
||
else if (data.status === 'resolved') data.status = 'completed'
|
||
// Migrate development task statuses to in_progress
|
||
else if (
|
||
data.status &&
|
||
['planning', 'implementing', 'reviewing', 'verifying'].includes(
|
||
data.status,
|
||
)
|
||
) {
|
||
data.status = 'in_progress'
|
||
}
|
||
}
|
||
const parsed = TaskSchema().safeParse(data)
|
||
if (!parsed.success) {
|
||
logForDebugging(
|
||
`[Tasks] Task ${taskId} failed schema validation: ${parsed.error.message}`,
|
||
)
|
||
return null
|
||
}
|
||
return parsed.data
|
||
} catch (e) {
|
||
const code = getErrnoCode(e)
|
||
if (code === 'ENOENT') {
|
||
return null
|
||
}
|
||
logForDebugging(`[Tasks] Failed to read task ${taskId}: ${errorMessage(e)}`)
|
||
logError(e)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Internal: no lock. Callers already holding a lock on taskPath must use this
|
||
// to avoid deadlock (claimTask, deleteTask cascade, etc.).
|
||
async function updateTaskUnsafe(
|
||
taskListId: string,
|
||
taskId: string,
|
||
updates: Partial<Omit<Task, 'id'>>,
|
||
): Promise<Task | null> {
|
||
const existing = await getTask(taskListId, taskId)
|
||
if (!existing) {
|
||
return null
|
||
}
|
||
const updated: Task = { ...existing, ...updates, id: taskId }
|
||
const path = getTaskPath(taskListId, taskId)
|
||
await writeFile(path, jsonStringify(updated, null, 2))
|
||
notifyTasksUpdated()
|
||
return updated
|
||
}
|
||
|
||
export async function updateTask(
|
||
taskListId: string,
|
||
taskId: string,
|
||
updates: Partial<Omit<Task, 'id'>>,
|
||
): Promise<Task | null> {
|
||
const path = getTaskPath(taskListId, taskId)
|
||
|
||
// Check existence before locking — proper-lockfile throws if the
|
||
// target file doesn't exist, and we want a clean null result.
|
||
const taskBeforeLock = await getTask(taskListId, taskId)
|
||
if (!taskBeforeLock) {
|
||
return null
|
||
}
|
||
|
||
let release: (() => Promise<void>) | undefined
|
||
try {
|
||
release = await lockfile.lock(path, LOCK_OPTIONS)
|
||
return await updateTaskUnsafe(taskListId, taskId, updates)
|
||
} finally {
|
||
await release?.()
|
||
}
|
||
}
|
||
|
||
export async function deleteTask(
|
||
taskListId: string,
|
||
taskId: string,
|
||
): Promise<boolean> {
|
||
const path = getTaskPath(taskListId, taskId)
|
||
|
||
try {
|
||
// Update high water mark before deleting to prevent ID reuse
|
||
const numericId = parseInt(taskId, 10)
|
||
if (!isNaN(numericId)) {
|
||
const currentMark = await readHighWaterMark(taskListId)
|
||
if (numericId > currentMark) {
|
||
await writeHighWaterMark(taskListId, numericId)
|
||
}
|
||
}
|
||
|
||
// Delete the task file
|
||
try {
|
||
await unlink(path)
|
||
} catch (e) {
|
||
const code = getErrnoCode(e)
|
||
if (code === 'ENOENT') {
|
||
return false
|
||
}
|
||
throw e
|
||
}
|
||
|
||
// Remove references to this task from other tasks
|
||
const allTasks = await listTasks(taskListId)
|
||
for (const task of allTasks) {
|
||
const newBlocks = task.blocks.filter(id => id !== taskId)
|
||
const newBlockedBy = task.blockedBy.filter(id => id !== taskId)
|
||
if (
|
||
newBlocks.length !== task.blocks.length ||
|
||
newBlockedBy.length !== task.blockedBy.length
|
||
) {
|
||
await updateTask(taskListId, task.id, {
|
||
blocks: newBlocks,
|
||
blockedBy: newBlockedBy,
|
||
})
|
||
}
|
||
}
|
||
|
||
notifyTasksUpdated()
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
export async function listTasks(taskListId: string): Promise<Task[]> {
|
||
const dir = getTasksDir(taskListId)
|
||
let files: string[]
|
||
try {
|
||
files = await readdir(dir)
|
||
} catch {
|
||
return []
|
||
}
|
||
const taskIds = files
|
||
.filter(f => f.endsWith('.json'))
|
||
.map(f => f.replace('.json', ''))
|
||
const results = await Promise.all(taskIds.map(id => getTask(taskListId, id)))
|
||
return results.filter((t): t is Task => t !== null)
|
||
}
|
||
|
||
export async function blockTask(
|
||
taskListId: string,
|
||
fromTaskId: string,
|
||
toTaskId: string,
|
||
): Promise<boolean> {
|
||
const [fromTask, toTask] = await Promise.all([
|
||
getTask(taskListId, fromTaskId),
|
||
getTask(taskListId, toTaskId),
|
||
])
|
||
if (!fromTask || !toTask) {
|
||
return false
|
||
}
|
||
|
||
// Update source task: A blocks B
|
||
if (!fromTask.blocks.includes(toTaskId)) {
|
||
await updateTask(taskListId, fromTaskId, {
|
||
blocks: [...fromTask.blocks, toTaskId],
|
||
})
|
||
}
|
||
|
||
// Update target task: B is blockedBy A
|
||
if (!toTask.blockedBy.includes(fromTaskId)) {
|
||
await updateTask(taskListId, toTaskId, {
|
||
blockedBy: [...toTask.blockedBy, fromTaskId],
|
||
})
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
export type ClaimTaskResult = {
|
||
success: boolean
|
||
reason?:
|
||
| 'task_not_found'
|
||
| 'already_claimed'
|
||
| 'already_resolved'
|
||
| 'blocked'
|
||
| 'agent_busy'
|
||
task?: Task
|
||
busyWithTasks?: string[] // task IDs the agent is busy with (when reason is 'agent_busy')
|
||
blockedByTasks?: string[] // task IDs blocking this task (when reason is 'blocked')
|
||
}
|
||
|
||
/**
|
||
* Gets the lock file path for a task list (used for list-level locking)
|
||
*/
|
||
function getTaskListLockPath(taskListId: string): string {
|
||
return join(getTasksDir(taskListId), '.lock')
|
||
}
|
||
|
||
/**
|
||
* Ensures the lock file exists for a task list
|
||
*/
|
||
async function ensureTaskListLockFile(taskListId: string): Promise<string> {
|
||
await ensureTasksDir(taskListId)
|
||
const lockPath = getTaskListLockPath(taskListId)
|
||
// proper-lockfile requires the target file to exist. Create it with the
|
||
// 'wx' flag (write-exclusive) so concurrent callers don't both create it,
|
||
// and the first one to create wins silently.
|
||
try {
|
||
await writeFile(lockPath, '', { flag: 'wx' })
|
||
} catch {
|
||
// EEXIST or other — file already exists, which is fine.
|
||
}
|
||
return lockPath
|
||
}
|
||
|
||
export type ClaimTaskOptions = {
|
||
/**
|
||
* If true, checks whether the agent is already busy (owns other open tasks)
|
||
* before allowing the claim. This check is performed atomically with the claim
|
||
* using a task-list-level lock to prevent TOCTOU race conditions.
|
||
*/
|
||
checkAgentBusy?: boolean
|
||
}
|
||
|
||
/**
|
||
* Attempts to claim a task for an agent with file locking to prevent race conditions.
|
||
* Returns success if the task was claimed, or a reason if it wasn't.
|
||
*
|
||
* When checkAgentBusy is true, uses a task-list-level lock to atomically check
|
||
* if the agent owns any other open tasks before claiming.
|
||
*/
|
||
export async function claimTask(
|
||
taskListId: string,
|
||
taskId: string,
|
||
claimantAgentId: string,
|
||
options: ClaimTaskOptions = {},
|
||
): Promise<ClaimTaskResult> {
|
||
const taskPath = getTaskPath(taskListId, taskId)
|
||
|
||
// Check existence before locking — proper-lockfile.lock throws if the
|
||
// target file doesn't exist, and we want a clean task_not_found result.
|
||
const taskBeforeLock = await getTask(taskListId, taskId)
|
||
if (!taskBeforeLock) {
|
||
return { success: false, reason: 'task_not_found' }
|
||
}
|
||
|
||
// If we need to check agent busy status, use task-list-level lock
|
||
// to prevent TOCTOU race conditions
|
||
if (options.checkAgentBusy) {
|
||
return claimTaskWithBusyCheck(taskListId, taskId, claimantAgentId)
|
||
}
|
||
|
||
// Otherwise, use task-level lock (original behavior)
|
||
let release: (() => Promise<void>) | undefined
|
||
try {
|
||
// Acquire exclusive lock on the task file
|
||
release = await lockfile.lock(taskPath, LOCK_OPTIONS)
|
||
|
||
// Read current task state
|
||
const task = await getTask(taskListId, taskId)
|
||
if (!task) {
|
||
return { success: false, reason: 'task_not_found' }
|
||
}
|
||
|
||
// Check if already claimed by another agent
|
||
if (task.owner && task.owner !== claimantAgentId) {
|
||
return { success: false, reason: 'already_claimed', task }
|
||
}
|
||
|
||
// Check if already resolved
|
||
if (task.status === 'completed') {
|
||
return { success: false, reason: 'already_resolved', task }
|
||
}
|
||
|
||
// Check for unresolved blockers (open or in_progress tasks block)
|
||
const allTasks = await listTasks(taskListId)
|
||
const unresolvedTaskIds = new Set(
|
||
allTasks.filter(t => t.status !== 'completed').map(t => t.id),
|
||
)
|
||
const blockedByTasks = task.blockedBy.filter(id =>
|
||
unresolvedTaskIds.has(id),
|
||
)
|
||
if (blockedByTasks.length > 0) {
|
||
return { success: false, reason: 'blocked', task, blockedByTasks }
|
||
}
|
||
|
||
// Claim the task (already holding taskPath lock — use unsafe variant)
|
||
const updated = await updateTaskUnsafe(taskListId, taskId, {
|
||
owner: claimantAgentId,
|
||
})
|
||
return { success: true, task: updated! }
|
||
} catch (error) {
|
||
logForDebugging(
|
||
`[Tasks] Failed to claim task ${taskId}: ${errorMessage(error)}`,
|
||
)
|
||
logError(error)
|
||
return { success: false, reason: 'task_not_found' }
|
||
} finally {
|
||
if (release) {
|
||
await release()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Claims a task with an atomic check for agent busy status.
|
||
* Uses a task-list-level lock to ensure the busy check and claim are atomic.
|
||
*/
|
||
async function claimTaskWithBusyCheck(
|
||
taskListId: string,
|
||
taskId: string,
|
||
claimantAgentId: string,
|
||
): Promise<ClaimTaskResult> {
|
||
const lockPath = await ensureTaskListLockFile(taskListId)
|
||
|
||
let release: (() => Promise<void>) | undefined
|
||
try {
|
||
// Acquire exclusive lock on the task list
|
||
release = await lockfile.lock(lockPath, LOCK_OPTIONS)
|
||
|
||
// Read all tasks to check agent status and task state atomically
|
||
const allTasks = await listTasks(taskListId)
|
||
|
||
// Find the task we want to claim
|
||
const task = allTasks.find(t => t.id === taskId)
|
||
if (!task) {
|
||
return { success: false, reason: 'task_not_found' }
|
||
}
|
||
|
||
// Check if already claimed by another agent
|
||
if (task.owner && task.owner !== claimantAgentId) {
|
||
return { success: false, reason: 'already_claimed', task }
|
||
}
|
||
|
||
// Check if already resolved
|
||
if (task.status === 'completed') {
|
||
return { success: false, reason: 'already_resolved', task }
|
||
}
|
||
|
||
// Check for unresolved blockers (open or in_progress tasks block)
|
||
const unresolvedTaskIds = new Set(
|
||
allTasks.filter(t => t.status !== 'completed').map(t => t.id),
|
||
)
|
||
const blockedByTasks = task.blockedBy.filter(id =>
|
||
unresolvedTaskIds.has(id),
|
||
)
|
||
if (blockedByTasks.length > 0) {
|
||
return { success: false, reason: 'blocked', task, blockedByTasks }
|
||
}
|
||
|
||
// Check if agent is busy with other unresolved tasks
|
||
const agentOpenTasks = allTasks.filter(
|
||
t =>
|
||
t.status !== 'completed' &&
|
||
t.owner === claimantAgentId &&
|
||
t.id !== taskId,
|
||
)
|
||
if (agentOpenTasks.length > 0) {
|
||
return {
|
||
success: false,
|
||
reason: 'agent_busy',
|
||
task,
|
||
busyWithTasks: agentOpenTasks.map(t => t.id),
|
||
}
|
||
}
|
||
|
||
// Claim the task
|
||
const updated = await updateTask(taskListId, taskId, {
|
||
owner: claimantAgentId,
|
||
})
|
||
return { success: true, task: updated! }
|
||
} catch (error) {
|
||
logForDebugging(
|
||
`[Tasks] Failed to claim task ${taskId} with busy check: ${errorMessage(error)}`,
|
||
)
|
||
logError(error)
|
||
return { success: false, reason: 'task_not_found' }
|
||
} finally {
|
||
if (release) {
|
||
await release()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Team member info (subset of TeamFile member structure)
|
||
*/
|
||
export type TeamMember = {
|
||
agentId: string
|
||
name: string
|
||
agentType?: string
|
||
}
|
||
|
||
/**
|
||
* Agent status based on task ownership
|
||
*/
|
||
export type AgentStatus = {
|
||
agentId: string
|
||
name: string
|
||
agentType?: string
|
||
status: 'idle' | 'busy'
|
||
currentTasks: string[] // task IDs the agent owns
|
||
}
|
||
|
||
/**
|
||
* Sanitizes a name for use in file paths
|
||
*/
|
||
function sanitizeName(name: string): string {
|
||
return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* Reads team members from the team file
|
||
*/
|
||
async function readTeamMembers(
|
||
teamName: string,
|
||
): Promise<{ leadAgentId: string; members: TeamMember[] } | null> {
|
||
const teamsDir = getTeamsDir()
|
||
const teamFilePath = join(teamsDir, sanitizeName(teamName), 'config.json')
|
||
try {
|
||
const content = await readFile(teamFilePath, 'utf-8')
|
||
const teamFile = jsonParse(content) as {
|
||
leadAgentId: string
|
||
members: TeamMember[]
|
||
}
|
||
return {
|
||
leadAgentId: teamFile.leadAgentId,
|
||
members: teamFile.members.map(m => ({
|
||
agentId: m.agentId,
|
||
name: m.name,
|
||
agentType: m.agentType,
|
||
})),
|
||
}
|
||
} catch (e) {
|
||
const code = getErrnoCode(e)
|
||
if (code === 'ENOENT') {
|
||
return null
|
||
}
|
||
logForDebugging(
|
||
`[Tasks] Failed to read team file for ${teamName}: ${errorMessage(e)}`,
|
||
)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gets the status of all agents in a team based on task ownership.
|
||
* An agent is considered "idle" if they don't own any open tasks.
|
||
* An agent is considered "busy" if they own at least one open task.
|
||
*
|
||
* @param teamName - The name of the team (also used as taskListId)
|
||
* @returns Array of agent statuses, or null if team not found
|
||
*/
|
||
export async function getAgentStatuses(
|
||
teamName: string,
|
||
): Promise<AgentStatus[] | null> {
|
||
const teamData = await readTeamMembers(teamName)
|
||
if (!teamData) {
|
||
return null
|
||
}
|
||
|
||
const taskListId = sanitizeName(teamName)
|
||
const allTasks = await listTasks(taskListId)
|
||
|
||
// Get unresolved tasks grouped by owner (open or in_progress)
|
||
const unresolvedTasksByOwner = new Map<string, string[]>()
|
||
for (const task of allTasks) {
|
||
if (task.status !== 'completed' && task.owner) {
|
||
const existing = unresolvedTasksByOwner.get(task.owner) || []
|
||
existing.push(task.id)
|
||
unresolvedTasksByOwner.set(task.owner, existing)
|
||
}
|
||
}
|
||
|
||
// Build status for each agent (leader is already in members)
|
||
return teamData.members.map(member => {
|
||
// Check both name (new) and agentId (legacy) for backwards compatibility
|
||
const tasksByName = unresolvedTasksByOwner.get(member.name) || []
|
||
const tasksById = unresolvedTasksByOwner.get(member.agentId) || []
|
||
const currentTasks = uniq([...tasksByName, ...tasksById])
|
||
return {
|
||
agentId: member.agentId,
|
||
name: member.name,
|
||
agentType: member.agentType,
|
||
status: currentTasks.length === 0 ? 'idle' : 'busy',
|
||
currentTasks,
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Result of unassigning tasks from a teammate
|
||
*/
|
||
export type UnassignTasksResult = {
|
||
unassignedTasks: Array<{ id: string; subject: string }>
|
||
notificationMessage: string
|
||
}
|
||
|
||
/**
|
||
* Unassigns all open tasks from a teammate and builds a notification message.
|
||
* Used when a teammate is killed or gracefully shuts down.
|
||
*
|
||
* @param teamName - The team/task list name
|
||
* @param teammateId - The teammate's agent ID
|
||
* @param teammateName - The teammate's display name
|
||
* @param reason - How the teammate exited ('terminated' | 'shutdown')
|
||
* @returns The unassigned tasks and a formatted notification message
|
||
*/
|
||
export async function unassignTeammateTasks(
|
||
teamName: string,
|
||
teammateId: string,
|
||
teammateName: string,
|
||
reason: 'terminated' | 'shutdown',
|
||
): Promise<UnassignTasksResult> {
|
||
const tasks = await listTasks(teamName)
|
||
const unresolvedAssignedTasks = tasks.filter(
|
||
t =>
|
||
t.status !== 'completed' &&
|
||
(t.owner === teammateId || t.owner === teammateName),
|
||
)
|
||
|
||
// Unassign each task and reset status to open
|
||
for (const task of unresolvedAssignedTasks) {
|
||
await updateTask(teamName, task.id, { owner: undefined, status: 'pending' })
|
||
}
|
||
|
||
if (unresolvedAssignedTasks.length > 0) {
|
||
logForDebugging(
|
||
`[Tasks] Unassigned ${unresolvedAssignedTasks.length} task(s) from ${teammateName}`,
|
||
)
|
||
}
|
||
|
||
// Build notification message
|
||
const actionVerb =
|
||
reason === 'terminated' ? 'was terminated' : 'has shut down'
|
||
let notificationMessage = `${teammateName} ${actionVerb}.`
|
||
if (unresolvedAssignedTasks.length > 0) {
|
||
const taskList = unresolvedAssignedTasks
|
||
.map(t => `#${t.id} "${t.subject}"`)
|
||
.join(', ')
|
||
notificationMessage += ` ${unresolvedAssignedTasks.length} task(s) were unassigned: ${taskList}. Use TaskList to check availability and TaskUpdate with owner to reassign them to idle teammates.`
|
||
}
|
||
|
||
return {
|
||
unassignedTasks: unresolvedAssignedTasks.map(t => ({
|
||
id: t.id,
|
||
subject: t.subject,
|
||
})),
|
||
notificationMessage,
|
||
}
|
||
}
|
||
|
||
export const DEFAULT_TASKS_MODE_TASK_LIST_ID = 'tasklist'
|