-
Notifications
You must be signed in to change notification settings - Fork 16.5k
Expand file tree
/
Copy pathtasks.ts
More file actions
868 lines (786 loc) · 25.9 KB
/
Copy pathtasks.ts
File metadata and controls
868 lines (786 loc) · 25.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
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.reduce((acc: string[], t) => {
if (t.status !== 'completed') acc.push(t.id);
return acc;
}, [])
)
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.reduce((acc: string[], t) => {
if (t.status !== 'completed') acc.push(t.id);
return acc;
}, [])
)
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'