@nanmicoder/dsh-agent-teams 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js CHANGED
@@ -12,8 +12,10 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { join } from 'node:path';
14
14
  import { appendTeamEvent, captainSessionOf } from "./events.js";
15
- import { appendMailbox, archiveTeamDir, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, readMailbox, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
- import { deliverToMember, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
15
+ import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
+ import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
17
+ import { TERMINAL_TASK_STATUSES } from "./types.js";
18
+ import { installTeamScheduler } from "./scheduler.js";
17
19
  /** The caller agent, or a loud failure for non-agent callers. */
18
20
  function requireCaptain(exec) {
19
21
  if (!exec.agent) {
@@ -99,6 +101,31 @@ function requireTask(team, taskId) {
99
101
  }
100
102
  return task;
101
103
  }
104
+ function memberOpenTask(team, memberName, exceptTaskId) {
105
+ return team.tasks.find(task => task.id !== exceptTaskId
106
+ && task.assignee === memberName
107
+ && (task.status === 'claimed' || task.status === 'in_progress'));
108
+ }
109
+ async function waitForMemberIdle(ctx, member, signal) {
110
+ if (member.id === '')
111
+ return;
112
+ const live = ctx.agents.get(member.id);
113
+ if (live === undefined)
114
+ return;
115
+ if (signal.aborted)
116
+ throw signal.reason;
117
+ let onAbort;
118
+ const aborted = new Promise((_resolve, reject) => {
119
+ onAbort = () => reject(signal.reason ?? new Error('task reassignment was cancelled'));
120
+ signal.addEventListener('abort', onAbort, { once: true });
121
+ });
122
+ try {
123
+ await Promise.race([live.whenIdle(), aborted]);
124
+ }
125
+ finally {
126
+ signal.removeEventListener('abort', onAbort);
127
+ }
128
+ }
102
129
  /**
103
130
  * Deliver a durable member report at the captain's nearest model boundary.
104
131
  *
@@ -126,7 +153,9 @@ export function steerCaptainReport(captain, from, content) {
126
153
  * @param config - resolved tool config.
127
154
  */
128
155
  export function registerAgentTeamsTools(ctx, config) {
156
+ installRetiredMemberGuard(ctx, config.stateDir);
129
157
  const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
158
+ const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir });
130
159
  ctx.tools.register(defineTool({
131
160
  name: 'agent_teams_create',
132
161
  description: 'Create a new AgentTeams team: you (the calling agent) become the captain. A captain leads one team at a time; create tasks and members afterwards with agent_teams_add_member and agent_teams_create_task.',
@@ -192,12 +221,13 @@ export function registerAgentTeamsTools(ctx, config) {
192
221
  }));
193
222
  ctx.tools.register(defineTool({
194
223
  name: 'agent_teams_add_member',
195
- description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM provider, model, and reasoning effort with no user prompt. Supply provider/model only for an explicitly requested role-specific route. The member waits for messages, works on assigned tasks, and can message the team.',
224
+ description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM route and effort. Supply provider/model only for an explicitly requested role-specific route; a changed provider or model automatically uses the target model\'s default effort. Set reasoning_effort only to request one of the target model\'s supported ids explicitly (or "default" to force its default). The member waits for messages, works on assigned tasks, and can message the team.',
196
225
  parameters: {
197
226
  name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
198
227
  role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
199
228
  provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
200
229
  model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
230
+ reasoning_effort: { type: 'string', description: 'Optional reasoning effort override: one of the target model\'s supported effort ids, or "default" to force its default. When omitted, the captain\'s effort is inherited only for the same provider/model; a changed route uses the target default.' },
201
231
  },
202
232
  output: {
203
233
  schema: {
@@ -222,7 +252,7 @@ export function registerAgentTeamsTools(ctx, config) {
222
252
  const workspace = workspaceOf(captain);
223
253
  const stateRoot = stateRootOf(workspace, config);
224
254
  const team = await requireCaptainTeam(workspace, config, captain);
225
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
255
+ const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
226
256
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
227
257
  const memberName = args.name.trim();
228
258
  if (memberName === '')
@@ -241,6 +271,7 @@ export function registerAgentTeamsTools(ctx, config) {
241
271
  provider: args.provider,
242
272
  model: args.model,
243
273
  defaultModel: config.memberModel,
274
+ reasoningEffort: args.reasoning_effort,
244
275
  }, exec.signal);
245
276
  const member = {
246
277
  id: '',
@@ -254,7 +285,19 @@ export function registerAgentTeamsTools(ctx, config) {
254
285
  };
255
286
  await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
256
287
  fresh.members.push(member);
257
- await writeTeam(stateRoot, fresh);
288
+ try {
289
+ await writeTeam(stateRoot, fresh);
290
+ }
291
+ catch (error) {
292
+ // The continuable child is already live, but the durable team record
293
+ // never saw it. Retire the orphan so it disappears from subagent
294
+ // listings and cannot be resumed, then surface the write failure.
295
+ if (member.id !== '') {
296
+ await recordRetiredMemberIds(stateRoot, [member.id]).catch(() => undefined);
297
+ interruptMember(ctx, captain, member.id);
298
+ }
299
+ throw error;
300
+ }
258
301
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
259
302
  teamId: fresh.id,
260
303
  memberId: member.id,
@@ -272,11 +315,13 @@ export function registerAgentTeamsTools(ctx, config) {
272
315
  status: member.status,
273
316
  };
274
317
  });
318
+ await scheduler.kickMember(workspace, team.id, created.member_name, captain);
319
+ return created;
275
320
  },
276
321
  }));
277
322
  ctx.tools.register(defineTool({
278
323
  name: 'agent_teams_remove_member',
279
- description: 'Remove a member from your team: interrupts its live turn (best effort) and marks it removed. Its mailbox and past task outputs stay on disk.',
324
+ description: 'Remove a member safely: revoke its current attempts, return all unfinished owned tasks to the shared pending pool, interrupt its live turn, and mark it removed.',
280
325
  parameters: {
281
326
  name: { type: 'string', required: true, description: 'Name of the member to remove.' },
282
327
  },
@@ -287,11 +332,12 @@ export function registerAgentTeamsTools(ctx, config) {
287
332
  properties: {
288
333
  member_name: { type: 'string', required: true },
289
334
  status: { type: 'string', required: true },
335
+ requeued_tasks: { type: 'array', items: { type: 'string' }, required: true },
290
336
  },
291
337
  },
292
338
  render: (args, value) => [{
293
339
  type: 'text',
294
- text: `Member "${value.member_name}" removed (status ${value.status}).`,
340
+ text: `Member "${value.member_name}" removed (status ${value.status}); requeued tasks: ${value.requeued_tasks.join(', ') || 'none'}.`,
295
341
  }],
296
342
  },
297
343
  async execute(args, exec) {
@@ -299,19 +345,36 @@ export function registerAgentTeamsTools(ctx, config) {
299
345
  const workspace = workspaceOf(captain);
300
346
  const stateRoot = stateRootOf(workspace, config);
301
347
  const team = await requireCaptainTeam(workspace, config, captain);
302
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
348
+ const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
303
349
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
304
350
  const member = requireMember(fresh, args.name);
305
- if (member.id !== '')
306
- interruptMember(ctx, captain, member.id);
351
+ const requeued = [];
352
+ for (const task of fresh.tasks) {
353
+ if (task.assignee !== member.name || task.status === 'completed')
354
+ continue;
355
+ invalidateTaskAttempt(task);
356
+ task.reassigning = false;
357
+ requeued.push(task.id);
358
+ }
307
359
  member.status = 'removed';
308
360
  await writeTeam(stateRoot, fresh);
309
361
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
310
362
  teamId: fresh.id,
311
363
  memberId: member.id,
312
364
  });
313
- return { member_name: member.name, status: member.status };
365
+ return { member: { ...member }, requeued };
314
366
  });
367
+ if (revoked.member.id !== '') {
368
+ await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
369
+ interruptMember(ctx, captain, revoked.member.id);
370
+ await waitForMemberIdle(ctx, revoked.member, exec.signal);
371
+ }
372
+ await scheduler.kickTeam(workspace, team.id, captain);
373
+ return {
374
+ member_name: revoked.member.name,
375
+ status: revoked.member.status,
376
+ requeued_tasks: revoked.requeued,
377
+ };
315
378
  },
316
379
  }));
317
380
  ctx.tools.register(defineTool({
@@ -348,7 +411,7 @@ export function registerAgentTeamsTools(ctx, config) {
348
411
  const workspace = workspaceOf(captain);
349
412
  const stateRoot = stateRootOf(workspace, config);
350
413
  const team = await requireCaptainTeam(workspace, config, captain);
351
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
414
+ const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
352
415
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
353
416
  const dependencies = args.dependencies ?? [];
354
417
  for (const dependency of dependencies) {
@@ -365,6 +428,7 @@ export function registerAgentTeamsTools(ctx, config) {
365
428
  status: 'pending',
366
429
  assignee: args.assignee,
367
430
  dependencies,
431
+ attempt: 0,
368
432
  createdAt: Date.now(),
369
433
  updatedAt: Date.now(),
370
434
  };
@@ -385,11 +449,120 @@ export function registerAgentTeamsTools(ctx, config) {
385
449
  ...task.assignee !== undefined ? { assignee: task.assignee } : {},
386
450
  };
387
451
  });
452
+ await scheduler.kickTeam(workspace, team.id, captain);
453
+ return created;
454
+ },
455
+ }));
456
+ ctx.tools.register(defineTool({
457
+ name: 'agent_teams_reassign_task',
458
+ description: 'Atomically retry, reassign, or let the captain take over any unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" for captain takeover.',
459
+ parameters: {
460
+ task_id: { type: 'string', required: true, description: 'Task to retry/reassign.' },
461
+ assignee: { type: 'string', required: true, description: 'Active member name, or "captain" for captain takeover.' },
462
+ reason: { type: 'string', description: 'Why the task is being retried or reassigned.' },
463
+ },
464
+ output: {
465
+ schema: {
466
+ type: 'object',
467
+ additionalProperties: false,
468
+ properties: {
469
+ task_id: { type: 'string', required: true },
470
+ previous_assignee: { type: 'string', required: true },
471
+ assignee: { type: 'string', required: true },
472
+ status: { type: 'string', required: true },
473
+ attempt: { type: 'number', required: true },
474
+ attempt_id: { type: 'string' },
475
+ },
476
+ },
477
+ render: (_args, value) => [{
478
+ type: 'text',
479
+ text: `Task ${value.task_id} reassigned ${value.previous_assignee || 'unassigned'} → ${value.assignee} (attempt ${value.attempt}, status ${value.status}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}).`,
480
+ }],
481
+ },
482
+ async execute(args, exec) {
483
+ const captain = requireCaptain(exec);
484
+ const workspace = workspaceOf(captain);
485
+ const stateRoot = stateRootOf(workspace, config);
486
+ const team = await requireCaptainTeam(workspace, config, captain);
487
+ const target = args.assignee.trim();
488
+ if (target === '')
489
+ throw new Error('reassignment assignee must not be empty');
490
+ const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
491
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
492
+ const task = requireTask(fresh, args.task_id);
493
+ if (task.status === 'completed')
494
+ throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
495
+ if (task.reassigning === true)
496
+ throw new Error(`task ${task.id} is already being reassigned`);
497
+ const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
498
+ if (targetMember !== undefined) {
499
+ const busy = memberOpenTask(fresh, targetMember.name, task.id);
500
+ if (busy !== undefined) {
501
+ throw new Error(`member "${targetMember.name}" is busy with ${busy.id}; finish or reassign it first`);
502
+ }
503
+ }
504
+ const previousAssignee = task.assignee ?? '';
505
+ const previousMember = (task.status !== 'claimed' && task.status !== 'in_progress')
506
+ || task.assignee === undefined || task.assignee === CAPTAIN_KEY
507
+ ? undefined
508
+ : fresh.members.find(member => member.name === task.assignee && member.status !== 'removed');
509
+ invalidateTaskAttempt(task, target, true);
510
+ await writeTeam(stateRoot, fresh);
511
+ return {
512
+ previousAssignee,
513
+ previousMember: previousMember === undefined ? undefined : { ...previousMember },
514
+ handoffId: task.handoffId,
515
+ };
516
+ });
517
+ let quiescenceError;
518
+ if (revoked.previousMember !== undefined) {
519
+ interruptMember(ctx, captain, revoked.previousMember.id);
520
+ try {
521
+ await waitForMemberIdle(ctx, revoked.previousMember, exec.signal);
522
+ }
523
+ catch (error) {
524
+ quiescenceError = error;
525
+ }
526
+ }
527
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
528
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
529
+ const task = requireTask(fresh, args.task_id);
530
+ if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
531
+ throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
532
+ }
533
+ task.reassigning = false;
534
+ if (quiescenceError === undefined && target === CAPTAIN_KEY)
535
+ beginTaskAttempt(task, CAPTAIN_KEY);
536
+ await writeTeam(stateRoot, fresh);
537
+ appendTeamEvent(ctx, captain.session, 'agent-teams/task-updated', {
538
+ teamId: fresh.id,
539
+ taskId: task.id,
540
+ status: task.status,
541
+ assignee: task.assignee,
542
+ ...args.reason === undefined ? {} : { output: `Reassigned: ${args.reason}` },
543
+ });
544
+ });
545
+ if (quiescenceError !== undefined)
546
+ throw quiescenceError;
547
+ if (target !== CAPTAIN_KEY)
548
+ await scheduler.kickMember(workspace, team.id, target, captain);
549
+ const current = await readTeam(stateRoot, team.id);
550
+ const task = current === undefined ? undefined : requireTask(current, args.task_id);
551
+ if (task === undefined)
552
+ throw new Error(`team "${team.name}" ended during reassignment`);
553
+ return {
554
+ task_id: task.id,
555
+ previous_assignee: revoked.previousAssignee,
556
+ assignee: task.assignee ?? '',
557
+ status: task.status,
558
+ attempt: task.attempt ?? 0,
559
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
560
+ };
388
561
  },
389
562
  }));
390
563
  ctx.tools.register(defineTool({
391
564
  name: 'agent_teams_claim_task',
392
- description: 'Claim a task for a member (or for yourself when you are the member). Blocked while any dependency is unfinished — the error lists the pending dependencies. The captain may claim on behalf of an assignee; a member may only claim tasks assigned to it (or unassigned).',
565
+ description: 'Claim one ready task for a member (or yourself). A member cannot own a second unfinished task. The returned attempt_id is required for that member\'s updates and becomes stale after retry/reassignment.',
393
566
  parameters: {
394
567
  task_id: { type: 'string', required: true, description: 'The task id to claim.' },
395
568
  assignee: { type: 'string', description: 'Member to claim for (captain only; defaults to the task\'s assignee).' },
@@ -402,11 +575,13 @@ export function registerAgentTeamsTools(ctx, config) {
402
575
  task_id: { type: 'string', required: true },
403
576
  status: { type: 'string', required: true },
404
577
  assignee: { type: 'string', required: true },
578
+ attempt: { type: 'number', required: true },
579
+ attempt_id: { type: 'string' },
405
580
  },
406
581
  },
407
582
  render: (args, value) => [{
408
583
  type: 'text',
409
- text: `Task ${value.task_id} claimed by ${value.assignee} (status ${value.status}).`,
584
+ text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).`,
410
585
  }],
411
586
  },
412
587
  async execute(args, exec) {
@@ -417,6 +592,9 @@ export function registerAgentTeamsTools(ctx, config) {
417
592
  return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
418
593
  const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
419
594
  const task = requireTask(fresh, args.task_id);
595
+ if (task.reassigning === true) {
596
+ throw new Error(`task ${task.id} is being reassigned; wait for the handoff to finish`);
597
+ }
420
598
  let assignee = task.assignee;
421
599
  if (identity.kind === 'captain') {
422
600
  if (args.assignee !== undefined) {
@@ -439,7 +617,13 @@ export function registerAgentTeamsTools(ctx, config) {
439
617
  if (assignee === undefined || task.assignee !== assignee) {
440
618
  throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
441
619
  }
442
- return { task_id: task.id, status: task.status, assignee };
620
+ return {
621
+ task_id: task.id,
622
+ status: task.status,
623
+ assignee,
624
+ attempt: task.attempt ?? 0,
625
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
626
+ };
443
627
  }
444
628
  const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
445
629
  if (pending.length > 0) {
@@ -451,9 +635,11 @@ export function registerAgentTeamsTools(ctx, config) {
451
635
  if (assignee === undefined) {
452
636
  throw new Error('claiming an unassigned task needs an assignee (claim on behalf of a member)');
453
637
  }
454
- task.status = 'claimed';
455
- task.assignee = assignee;
456
- task.updatedAt = Date.now();
638
+ const busy = memberOpenTask(fresh, assignee, task.id);
639
+ if (busy !== undefined) {
640
+ throw new Error(`member "${assignee}" is busy with ${busy.id}; finish or reassign it first`);
641
+ }
642
+ const attemptId = beginTaskAttempt(task, assignee);
457
643
  await writeTeam(stateRoot, fresh);
458
644
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
459
645
  teamId: fresh.id,
@@ -461,13 +647,19 @@ export function registerAgentTeamsTools(ctx, config) {
461
647
  status: task.status,
462
648
  assignee: task.assignee,
463
649
  });
464
- return { task_id: task.id, status: task.status, assignee: task.assignee ?? '' };
650
+ return {
651
+ task_id: task.id,
652
+ status: task.status,
653
+ assignee: task.assignee ?? '',
654
+ attempt: task.attempt ?? 0,
655
+ attempt_id: attemptId,
656
+ };
465
657
  });
466
658
  },
467
659
  }));
468
660
  ctx.tools.register(defineTool({
469
661
  name: 'agent_teams_update_task',
470
- description: 'Update a task\'s status and/or write its output. Transitions: claimed in_progress completed|failed|cancelled (pending may also be cancelled). The captain may update any task; a member may only update tasks assigned to it. Set output when completing or failing a task.',
662
+ description: 'Update a task status/output. Members must supply the current attempt_id returned by claim_task; stale attempts are rejected after takeover/reassignment. Terminal results are immutable. A captain must use reassign_task(assignee="captain") before updating member-owned work.',
471
663
  parameters: {
472
664
  task_id: { type: 'string', required: true, description: 'The task id to update.' },
473
665
  status: {
@@ -476,6 +668,7 @@ export function registerAgentTeamsTools(ctx, config) {
476
668
  description: 'New status (in_progress, completed, failed, cancelled).',
477
669
  },
478
670
  output: { type: 'string', description: 'Result summary; set when completing or failing.' },
671
+ attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
479
672
  },
480
673
  output: {
481
674
  schema: {
@@ -485,11 +678,13 @@ export function registerAgentTeamsTools(ctx, config) {
485
678
  task_id: { type: 'string', required: true },
486
679
  status: { type: 'string', required: true },
487
680
  output: { type: 'string' },
681
+ attempt: { type: 'number', required: true },
682
+ attempt_id: { type: 'string' },
488
683
  },
489
684
  },
490
685
  render: (args, value) => [{
491
686
  type: 'text',
492
- text: `Task ${value.task_id} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
687
+ text: `Task ${value.task_id} attempt ${value.attempt} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
493
688
  }],
494
689
  },
495
690
  async execute(args, exec) {
@@ -497,13 +692,35 @@ export function registerAgentTeamsTools(ctx, config) {
497
692
  const workspace = workspaceOf(caller);
498
693
  const stateRoot = stateRootOf(workspace, config);
499
694
  const team = await requireParticipantTeam(workspace, config, caller);
500
- return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
695
+ const updated = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
501
696
  const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
502
697
  const task = requireTask(fresh, args.task_id);
698
+ if (identity.kind === 'captain'
699
+ && task.assignee !== undefined
700
+ && task.assignee !== CAPTAIN_KEY) {
701
+ throw new Error(`task ${task.id} is owned by member "${task.assignee}"; call agent_teams_reassign_task with assignee="captain" before takeover`);
702
+ }
503
703
  if (identity.kind === 'member') {
504
704
  if (task.assignee !== identity.name) {
505
705
  throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
506
706
  }
707
+ if (task.attemptId !== undefined && args.attempt_id !== task.attemptId) {
708
+ throw new Error(`stale attempt for task ${task.id}: expected the current attempt_id; stop work and request fresh assignment`);
709
+ }
710
+ }
711
+ if (TERMINAL_TASK_STATUSES.includes(task.status)) {
712
+ const sameStatus = args.status === undefined || args.status === task.status;
713
+ const sameOutput = args.output === undefined || args.output === task.output;
714
+ if (!sameStatus || !sameOutput) {
715
+ throw new Error(`terminal task ${task.id} is immutable; use agent_teams_reassign_task to retry failed/cancelled work`);
716
+ }
717
+ return {
718
+ task_id: task.id,
719
+ status: task.status,
720
+ attempt: task.attempt ?? 0,
721
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
722
+ ...task.output !== undefined ? { output: task.output } : {},
723
+ };
507
724
  }
508
725
  if (args.status !== undefined) {
509
726
  const transition = transitionError(task.status, args.status);
@@ -525,9 +742,13 @@ export function registerAgentTeamsTools(ctx, config) {
525
742
  return {
526
743
  task_id: task.id,
527
744
  status: task.status,
745
+ attempt: task.attempt ?? 0,
746
+ ...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
528
747
  ...task.output !== undefined ? { output: task.output } : {},
529
748
  };
530
749
  });
750
+ await scheduler.kickTeam(workspace, team.id, team.captainSessionId === caller.id ? caller : undefined);
751
+ return updated;
531
752
  },
532
753
  }));
533
754
  ctx.tools.register(defineTool({
@@ -569,7 +790,7 @@ export function registerAgentTeamsTools(ctx, config) {
569
790
  throw new Error(`agent_teams_send_message: "from" must be your own identity ("${from}"), not "${args.from}"`);
570
791
  }
571
792
  if (to === CAPTAIN_KEY) {
572
- const message = createMessage(from, CAPTAIN_KEY, args.content);
793
+ const message = { ...createMessage(from, CAPTAIN_KEY, args.content), deliveryClaimedAt: Date.now() };
573
794
  await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, message);
574
795
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
575
796
  teamId: fresh.id,
@@ -582,7 +803,7 @@ export function registerAgentTeamsTools(ctx, config) {
582
803
  return { kind: 'captain', fresh, identity, message, from };
583
804
  }
584
805
  const recipient = requireMember(fresh, to);
585
- const message = createMessage(from, recipient.name, args.content);
806
+ const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
586
807
  await appendMailbox(stateRoot, fresh.id, recipient.name, message);
587
808
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
588
809
  teamId: fresh.id,
@@ -602,6 +823,12 @@ export function registerAgentTeamsTools(ctx, config) {
602
823
  if (captain !== undefined && prepared.identity.kind === 'member') {
603
824
  delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
604
825
  }
826
+ if (delivered === 'live') {
827
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
828
+ }
829
+ else {
830
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
831
+ }
605
832
  return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
606
833
  }
607
834
  let delivered = 'mailbox';
@@ -612,6 +839,12 @@ export function registerAgentTeamsTools(ctx, config) {
612
839
  const text = `AgentTeams state policy: inspect ${config.stateDir}/${prepared.fresh.id}/ read-only; never edit team.json or inbox files directly. Use agent_teams_* tools for team state.\n\n${senderText}`;
613
840
  const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
614
841
  delivered = accepted ? 'wake' : 'mailbox';
842
+ if (accepted) {
843
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
844
+ }
845
+ }
846
+ if (delivered === 'mailbox') {
847
+ await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
615
848
  }
616
849
  return {
617
850
  message_id: prepared.message.id,
@@ -634,6 +867,9 @@ export function registerAgentTeamsTools(ctx, config) {
634
867
  const workspace = workspaceOf(caller);
635
868
  const stateRoot = stateRootOf(workspace, config);
636
869
  const located = await requireParticipantTeam(workspace, config, caller);
870
+ if (located.captainSessionId === caller.id) {
871
+ await scheduler.kickTeam(workspace, located.id, caller);
872
+ }
637
873
  const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
638
874
  const activity = await memberActivity(ctx, team.captainSessionId);
639
875
  const members = team.members
@@ -653,6 +889,9 @@ export function registerAgentTeamsTools(ctx, config) {
653
889
  status: task.status,
654
890
  assignee: task.assignee ?? '',
655
891
  dependencies: task.dependencies,
892
+ attempt: task.attempt ?? 0,
893
+ attempt_id: task.attemptId ?? '',
894
+ reassigning: task.reassigning === true,
656
895
  ...task.output !== undefined ? { output: task.output } : {},
657
896
  }));
658
897
  const mailboxWarnings = [];
@@ -664,14 +903,14 @@ export function registerAgentTeamsTools(ctx, config) {
664
903
  }
665
904
  };
666
905
  const captainInbox = identity.kind === 'captain'
667
- ? await readMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
906
+ ? await readUnreadMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
668
907
  : [];
669
908
  const memberInboxes = {};
670
909
  const visibleMembers = identity.kind === 'captain'
671
910
  ? members
672
911
  : members.filter((member) => member.name === identity.name);
673
912
  for (const member of visibleMembers) {
674
- const messages = await readMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
913
+ const messages = await readUnreadMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
675
914
  if (messages.length > 0) {
676
915
  memberInboxes[member.name] = {
677
916
  count: messages.length,
@@ -679,7 +918,7 @@ export function registerAgentTeamsTools(ctx, config) {
679
918
  };
680
919
  }
681
920
  }
682
- return {
921
+ const result = {
683
922
  team_id: team.id,
684
923
  team_name: team.name,
685
924
  description: team.description ?? '',
@@ -695,6 +934,13 @@ export function registerAgentTeamsTools(ctx, config) {
695
934
  mailbox_warnings: mailboxWarnings,
696
935
  mailbox_warning_count: mailboxWarningCount,
697
936
  };
937
+ const acknowledged = identity.kind === 'captain'
938
+ ? captainInbox.map(message => message.id)
939
+ : await readUnreadMailbox(stateRoot, team.id, identity.name).then(messages => messages.map(message => message.id));
940
+ if (acknowledged.length > 0) {
941
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, identity.kind === 'captain' ? CAPTAIN_KEY : identity.name, acknowledged)));
942
+ }
943
+ return result;
698
944
  },
699
945
  }));
700
946
  ctx.tools.register(defineTool({
@@ -720,12 +966,37 @@ export function registerAgentTeamsTools(ctx, config) {
720
966
  const workspace = workspaceOf(captain);
721
967
  const stateRoot = stateRootOf(workspace, config);
722
968
  const team = await requireCaptainTeam(workspace, config, captain);
723
- await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
969
+ const members = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
724
970
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
971
+ // Include previously removed members so deleting a pre-fix team also
972
+ // retires durable catalog entries left behind by remove_member.
973
+ const roster = fresh.members.map(member => ({ ...member }));
725
974
  for (const member of fresh.members) {
726
- if (member.status !== 'removed' && member.id !== '')
727
- interruptMember(ctx, captain, member.id);
975
+ if (member.status === 'removed')
976
+ continue;
977
+ member.status = 'removed';
978
+ for (const task of fresh.tasks) {
979
+ if (task.assignee === member.name && task.status !== 'completed')
980
+ invalidateTaskAttempt(task);
981
+ }
982
+ }
983
+ await writeTeam(stateRoot, fresh);
984
+ return roster;
985
+ });
986
+ await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
987
+ for (const member of members) {
988
+ if (member.id === '')
989
+ continue;
990
+ interruptMember(ctx, captain, member.id);
991
+ }
992
+ const quiescence = await Promise.allSettled(members.map(member => waitForMemberIdle(ctx, member, exec.signal)));
993
+ for (const result of quiescence) {
994
+ if (result.status === 'rejected') {
995
+ ctx.logger.warn(`agent-teams: member did not quiesce cleanly before team archive: ${String(result.reason)}`);
728
996
  }
997
+ }
998
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
999
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
729
1000
  appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
730
1001
  teamId: fresh.id,
731
1002
  });
@@ -760,7 +1031,8 @@ function renderStatus(value) {
760
1031
  ...team.tasks.map((task) => {
761
1032
  const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
762
1033
  const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
763
- return ` - ${task.id} [${task.status}] ${task.subject} ${task.assignee || 'unassigned'}${deps}${output}`;
1034
+ const handoff = task.reassigning ? ' (reassigning)' : '';
1035
+ return ` - ${task.id} [${task.status}] attempt ${task.attempt}${handoff} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
764
1036
  }),
765
1037
  `Captain inbox (${team.captain_inbox.length}):`,
766
1038
  ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
@@ -21,6 +21,7 @@ export interface ActivityMember {
21
21
  readonly id: string;
22
22
  readonly name: string;
23
23
  readonly role: string;
24
+ readonly status?: 'idle' | 'working' | 'removed';
24
25
  readonly activity: 'working' | 'idle' | 'unknown';
25
26
  readonly progress: number;
26
27
  readonly done: number;