@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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Event-driven shared task scheduler.
3
+ *
4
+ * Claude Code teammates keep polling the shared task list after a turn. DSH
5
+ * continuable agents instead expose explicit idle/running edges, so this
6
+ * scheduler closes the same loop without keeping a polling turn alive: every
7
+ * idle edge and every task-graph mutation attempts one atomic claim and wakes
8
+ * the selected durable member.
9
+ * @module dsh-agent-teams/scheduler
10
+ */
11
+ import { join } from 'node:path';
12
+ import { deliverToMember } from "./members.js";
13
+ import { acknowledgeMailbox, beginTaskAttempt, claimMailboxDelivery, findTeamByParticipant, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
14
+ function stateRootOf(workspace, config) {
15
+ return join(workspace, config.stateDir);
16
+ }
17
+ function teamLockKey(stateRoot, teamId) {
18
+ return `team:${stateRoot}:${teamId}`;
19
+ }
20
+ function liveCaptain(ctx, captainSessionId, supplied) {
21
+ if (supplied !== undefined && supplied.id === captainSessionId)
22
+ return supplied;
23
+ return ctx.agents.get(captainSessionId);
24
+ }
25
+ function isMemberAvailable(ctx, member) {
26
+ const live = ctx.agents.get(member.id);
27
+ return live === undefined || live.status === 'idle';
28
+ }
29
+ function ownedOpenTask(tasks, memberName) {
30
+ return tasks.find(task => task.assignee === memberName
31
+ && (task.status === 'claimed' || task.status === 'in_progress'));
32
+ }
33
+ function nextReadyTask(tasks, memberName) {
34
+ const ready = tasks.filter(task => task.status === 'pending'
35
+ && task.reassigning !== true
36
+ && unsatisfiedDependencies([...tasks], task.dependencies).length === 0);
37
+ return ready.find(task => task.assignee === memberName)
38
+ ?? ready.find(task => task.assignee === undefined);
39
+ }
40
+ function assignmentPrompt(ticket, stateDir, teamId) {
41
+ const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
42
+ return `AgentTeams automatic task assignment from the shared task list.
43
+
44
+ Task: ${ticket.taskId} — ${ticket.subject}${description}
45
+ Attempt: ${ticket.attempt}
46
+ Attempt id: ${ticket.attemptId}
47
+
48
+ Call agent_teams_claim_task for ${ticket.taskId}; it will return this same attempt_id. Include attempt_id=${ticket.attemptId} in every agent_teams_update_task call. If it is rejected as stale, stop work because the task was reassigned. Work only this task in this turn, report the result to the captain, then become idle so the scheduler can select your next ready task.
49
+
50
+ State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
51
+ }
52
+ function fallbackMailboxPrompt(messages) {
53
+ return [
54
+ 'AgentTeams delivered messages that were persisted while live delivery was unavailable:',
55
+ ...messages.map(message => `\nFrom ${message.from}:\n${message.content}`),
56
+ '\nHandle these messages in this turn. Task assignments still require agent_teams_claim_task and the current attempt_id.',
57
+ ].join('\n');
58
+ }
59
+ /** Install one scheduler and its member activity observer. */
60
+ export function installTeamScheduler(ctx, config) {
61
+ const memberQueues = new Map();
62
+ const serializeMember = async (key, operation) => {
63
+ const previous = memberQueues.get(key) ?? Promise.resolve();
64
+ let release;
65
+ const gate = new Promise((resolve) => { release = resolve; });
66
+ const tail = previous.then(() => gate);
67
+ memberQueues.set(key, tail);
68
+ await previous;
69
+ try {
70
+ return await operation();
71
+ }
72
+ finally {
73
+ release();
74
+ if (memberQueues.get(key) === tail)
75
+ memberQueues.delete(key);
76
+ }
77
+ };
78
+ const runtime = {
79
+ async kickTeam(workspace, teamId, suppliedCaptain) {
80
+ const stateRoot = stateRootOf(workspace, config);
81
+ const team = await readTeam(stateRoot, teamId);
82
+ if (team === undefined)
83
+ return;
84
+ const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
85
+ if (captain === undefined)
86
+ return;
87
+ for (const member of team.members) {
88
+ if (member.status === 'removed')
89
+ continue;
90
+ await runtime.kickMember(workspace, teamId, member.name, captain);
91
+ }
92
+ },
93
+ async kickMember(workspace, teamId, memberName, suppliedCaptain) {
94
+ const stateRoot = stateRootOf(workspace, config);
95
+ const queueKey = `${stateRoot}\u0000${teamId}\u0000${memberName}`;
96
+ await serializeMember(queueKey, async () => {
97
+ let team = await readTeam(stateRoot, teamId);
98
+ if (team === undefined)
99
+ return;
100
+ const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
101
+ if (captain === undefined)
102
+ return;
103
+ let member = team.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
104
+ if (member === undefined || member.id === '' || !isMemberAvailable(ctx, member))
105
+ return;
106
+ // A mailbox-only fallback is real pending work. Deliver it before a
107
+ // fresh task and acknowledge only after Harness accepts the follow-up.
108
+ const unread = await readUnreadMailbox(stateRoot, team.id, member.name);
109
+ if (unread.length > 0) {
110
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (claimMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
111
+ const accepted = await deliverToMember(ctx, captain, member.id, fallbackMailboxPrompt(unread), new AbortController().signal);
112
+ if (accepted) {
113
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, member.name, unread.map(message => message.id))));
114
+ }
115
+ else {
116
+ await withTeamLock(teamLockKey(stateRoot, team.id), () => (releaseMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
117
+ }
118
+ return;
119
+ }
120
+ const ticket = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
121
+ const fresh = await readTeam(stateRoot, team.id);
122
+ if (fresh === undefined)
123
+ return undefined;
124
+ const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
125
+ if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
126
+ return undefined;
127
+ // An idle/ready member that still owns an open task lost the turn
128
+ // that was executing it (model stopped early, interrupt settlement,
129
+ // or process restart). Retry that task with a fresh capability
130
+ // instead of permanently treating the durable claim as "busy".
131
+ const task = ownedOpenTask(fresh.tasks, currentMember.name)
132
+ ?? nextReadyTask(fresh.tasks, currentMember.name);
133
+ if (task === undefined) {
134
+ if (currentMember.status !== 'idle') {
135
+ currentMember.status = 'idle';
136
+ await writeTeam(stateRoot, fresh);
137
+ }
138
+ return undefined;
139
+ }
140
+ const previousAssignee = task.assignee;
141
+ const attemptId = beginTaskAttempt(task, currentMember.name);
142
+ currentMember.status = 'working';
143
+ await writeTeam(stateRoot, fresh);
144
+ return {
145
+ taskId: task.id,
146
+ memberName: currentMember.name,
147
+ memberId: currentMember.id,
148
+ attempt: task.attempt ?? 1,
149
+ attemptId,
150
+ previousAssignee,
151
+ subject: task.subject,
152
+ description: task.description,
153
+ };
154
+ });
155
+ if (ticket === undefined)
156
+ return;
157
+ const accepted = await deliverToMember(ctx, captain, ticket.memberId, assignmentPrompt(ticket, config.stateDir, team.id), new AbortController().signal);
158
+ if (accepted)
159
+ return;
160
+ // Roll back only our exact failed dispatch. A concurrent captain
161
+ // handoff has already changed the capability and wins.
162
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
163
+ const fresh = await readTeam(stateRoot, team.id);
164
+ if (fresh === undefined)
165
+ return;
166
+ const task = fresh.tasks.find(candidate => candidate.id === ticket.taskId);
167
+ if (task?.attemptId !== ticket.attemptId)
168
+ return;
169
+ task.status = 'pending';
170
+ task.assignee = ticket.previousAssignee;
171
+ task.attemptId = undefined;
172
+ task.handoffId = undefined;
173
+ task.reassigning = false;
174
+ task.updatedAt = Date.now();
175
+ const currentMember = fresh.members.find(candidate => candidate.name === ticket.memberName);
176
+ if (currentMember !== undefined && currentMember.status !== 'removed')
177
+ currentMember.status = 'idle';
178
+ await writeTeam(stateRoot, fresh);
179
+ });
180
+ });
181
+ },
182
+ };
183
+ const syncMemberStatus = async (agent, status) => {
184
+ const workspace = agent.session.header.cwd ?? process.cwd();
185
+ const stateRoot = stateRootOf(workspace, config);
186
+ const located = await findTeamByParticipant(stateRoot, agent.id);
187
+ if (located === undefined || located.captainSessionId === agent.id)
188
+ return;
189
+ const member = located.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
190
+ if (member === undefined)
191
+ return;
192
+ await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
193
+ const fresh = await readTeam(stateRoot, located.id);
194
+ const current = fresh?.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
195
+ if (fresh === undefined || current === undefined)
196
+ return;
197
+ const next = status === 'running' ? 'working' : 'idle';
198
+ if (current.status === next)
199
+ return;
200
+ current.status = next;
201
+ await writeTeam(stateRoot, fresh);
202
+ });
203
+ if (status === 'idle')
204
+ await runtime.kickMember(workspace, located.id, member.name);
205
+ };
206
+ ctx.on('agent/status', ({ agent, status }) => {
207
+ void syncMemberStatus(agent, status).catch((error) => {
208
+ ctx.logger.warn(`agent-teams: member status scheduling failed for ${agent.id}: ${String(error)}`);
209
+ });
210
+ });
211
+ return runtime;
212
+ }
package/lib/snapshot.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { readdir } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
- import { CAPTAIN_KEY, listArchivedTeamIds, readArchivedTeam, readMailbox, readTeam, taskDepthsById, taskVisualState, } from "./state.js";
12
+ import { CAPTAIN_KEY, listArchivedTeamIds, readArchivedTeam, readUnreadMailbox, readTeam, taskDepthsById, taskVisualState, } from "./state.js";
13
13
  /** The current task of a member: its first unfinished owned task. */
14
14
  function currentTaskOf(memberName, tasks) {
15
15
  for (const task of tasks) {
@@ -26,41 +26,54 @@ function currentTaskOf(memberName, tasks) {
26
26
  * @param state - the durable team record.
27
27
  * @returns the panel snapshot.
28
28
  */
29
- export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state) {
29
+ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, options = {}) {
30
30
  const tasks = state.tasks;
31
31
  const depths = taskDepthsById(tasks);
32
- const byName = new Map(state.members.filter((m) => m.status !== 'removed').map((m) => [m.name, m]));
32
+ const roster = options.includeRemoved === true
33
+ ? state.members
34
+ : state.members.filter((member) => member.status !== 'removed');
33
35
  const activity = new Map();
34
- try {
35
- const children = await ctx.subagents.listChildren(state.captainSessionId);
36
- for (const entry of children) {
37
- if (entry.kind === 'child')
38
- activity.set(entry.id, entry.activity);
36
+ if (options.historic !== true) {
37
+ try {
38
+ const children = await ctx.subagents.listChildren(state.captainSessionId);
39
+ for (const entry of children) {
40
+ if (entry.kind === 'child') {
41
+ const live = ctx.agents.get(entry.id);
42
+ activity.set(entry.id, live === undefined ? 'ready' : live.status);
43
+ }
44
+ }
45
+ }
46
+ catch (error) {
47
+ ctx.logger.warn(`agent-teams: activity listing failed for ${state.name}: ${String(error)}`);
39
48
  }
40
- }
41
- catch (error) {
42
- ctx.logger.warn(`agent-teams: activity listing failed for ${state.name}: ${String(error)}`);
43
49
  }
44
50
  const unreadByMember = new Map();
45
- for (const member of state.members.filter((candidate) => candidate.status !== 'removed')) {
51
+ for (const member of roster) {
46
52
  try {
47
- unreadByMember.set(member.name, (await readMailbox(stateRoot, state.id, member.name)).length);
53
+ unreadByMember.set(member.name, (await readUnreadMailbox(stateRoot, state.id, member.name)).length);
48
54
  }
49
55
  catch (error) {
50
56
  ctx.logger.warn(`agent-teams: mailbox read failed for ${member.name}: ${String(error)}`);
51
57
  unreadByMember.set(member.name, 0);
52
58
  }
53
59
  }
54
- const members = state.members
55
- .filter((member) => member.status !== 'removed')
56
- .map((member) => {
60
+ const members = roster.map((member) => {
57
61
  const owned = tasks.filter((task) => task.assignee === member.name);
58
62
  const done = owned.filter((task) => task.status === 'completed').length;
59
63
  return {
60
64
  id: member.id,
61
65
  name: member.name,
62
66
  role: member.role ?? '',
63
- activity: member.id !== '' ? (activity.get(member.id) === 'running' ? 'working' : activity.get(member.id) === 'inactive' ? 'idle' : 'unknown') : 'unknown',
67
+ status: member.status,
68
+ activity: options.historic === true
69
+ ? 'idle'
70
+ : member.id !== ''
71
+ ? (activity.get(member.id) === 'running'
72
+ ? 'working'
73
+ : activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
74
+ ? 'idle'
75
+ : 'unknown')
76
+ : 'unknown',
64
77
  progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
65
78
  done,
66
79
  total: owned.length,
@@ -68,7 +81,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state) {
68
81
  unread: unreadByMember.get(member.name) ?? 0,
69
82
  };
70
83
  });
71
- const captainInbox = await readMailbox(stateRoot, state.id, CAPTAIN_KEY);
84
+ const captainInbox = await readUnreadMailbox(stateRoot, state.id, CAPTAIN_KEY);
72
85
  return {
73
86
  workspace,
74
87
  teamId: state.id,
@@ -144,7 +157,7 @@ export async function collectArchivedTeamsActivity(ctx, roots) {
144
157
  const state = await readArchivedTeam(root.stateRoot, teamId);
145
158
  if (state === undefined)
146
159
  continue;
147
- snapshots.push(await assembleTeamSnapshot(ctx, join(root.stateRoot, 'archive'), root.workspace, state));
160
+ snapshots.push(await assembleTeamSnapshot(ctx, join(root.stateRoot, 'archive'), root.workspace, state, { includeRemoved: true, historic: true }));
148
161
  }
149
162
  catch {
150
163
  ctx.logger.warn(`agent-teams: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}"`);
package/lib/state.js CHANGED
@@ -18,6 +18,10 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promise
18
18
  import { join } from 'node:path';
19
19
  /** Mailbox key of the captain. */
20
20
  export const CAPTAIN_KEY = 'captain';
21
+ /** A crashed live-delivery attempt becomes retryable after this interval. */
22
+ const MAILBOX_DELIVERY_LEASE_MS = 60_000;
23
+ /** Durable deny-list for AgentTeams members that must never be resumed. */
24
+ const RETIRED_MEMBERS_FILE = 'retired-members.json';
21
25
  /** In-process per-team mutation queues (promise chains). */
22
26
  const locks = new Map();
23
27
  /**
@@ -112,6 +116,36 @@ export function transitionError(current, next) {
112
116
  }
113
117
  return undefined;
114
118
  }
119
+ /** Activate the task's current generation for one owner and return its capability id. */
120
+ export function activateTaskAttempt(task, assignee) {
121
+ const attemptId = randomUUID();
122
+ task.status = 'claimed';
123
+ task.assignee = assignee;
124
+ task.attemptId = attemptId;
125
+ task.handoffId = undefined;
126
+ task.reassigning = false;
127
+ task.output = undefined;
128
+ task.updatedAt = Date.now();
129
+ return attemptId;
130
+ }
131
+ /** Start a fresh task generation for one owner. */
132
+ export function beginTaskAttempt(task, assignee) {
133
+ task.attempt = (task.attempt ?? 0) + 1;
134
+ return activateTaskAttempt(task, assignee);
135
+ }
136
+ /**
137
+ * Revoke the current worker immediately. Clearing its capability makes old
138
+ * updates stale; a separate handoff generation serializes async quiescence.
139
+ */
140
+ export function invalidateTaskAttempt(task, nextAssignee, reassigning = false) {
141
+ task.attemptId = undefined;
142
+ task.handoffId = randomUUID();
143
+ task.status = 'pending';
144
+ task.assignee = nextAssignee;
145
+ task.reassigning = reassigning;
146
+ task.output = undefined;
147
+ task.updatedAt = Date.now();
148
+ }
115
149
  /**
116
150
  * Create the team directory structure and the initial team record.
117
151
  * @param stateRoot - resolved absolute state root directory.
@@ -176,6 +210,35 @@ export function readTeamSync(stateRoot, teamId) {
176
210
  export async function writeTeam(stateRoot, state) {
177
211
  await atomicWriteText(join(stateRoot, state.id, 'team.json'), JSON.stringify(state, null, 2));
178
212
  }
213
+ /** Read the durable set of member session ids retired by remove/delete. */
214
+ export async function readRetiredMemberIds(stateRoot) {
215
+ try {
216
+ const parsed = JSON.parse(stripLeadingBom(await readFile(join(stateRoot, RETIRED_MEMBERS_FILE), 'utf8')));
217
+ if (!Array.isArray(parsed) || parsed.some(value => typeof value !== 'string' || value === '')) {
218
+ throw new Error('invalid AgentTeams retired member index');
219
+ }
220
+ return new Set(parsed);
221
+ }
222
+ catch (error) {
223
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
224
+ return new Set();
225
+ }
226
+ throw error;
227
+ }
228
+ }
229
+ /** Atomically add session ids to the durable retired-member deny-list. */
230
+ export async function recordRetiredMemberIds(stateRoot, memberIds) {
231
+ const additions = memberIds.filter(id => id !== '');
232
+ if (additions.length === 0)
233
+ return;
234
+ await withTeamLock(`retired-members:${stateRoot}`, async () => {
235
+ const retired = await readRetiredMemberIds(stateRoot);
236
+ for (const id of additions)
237
+ retired.add(id);
238
+ await mkdir(stateRoot, { recursive: true });
239
+ await atomicWriteText(join(stateRoot, RETIRED_MEMBERS_FILE), `${JSON.stringify([...retired].sort(), null, 2)}\n`);
240
+ });
241
+ }
179
242
  /**
180
243
  * Find the team owned by one captain session (at most one per captain).
181
244
  * @param stateRoot - resolved absolute state root directory.
@@ -309,21 +372,159 @@ export async function readMailbox(stateRoot, teamId, agentKey, onMalformedLine)
309
372
  throw error;
310
373
  }
311
374
  }
375
+ /** Read only messages that have not been acknowledged by their recipient. */
376
+ export async function readUnreadMailbox(stateRoot, teamId, agentKey, onMalformedLine) {
377
+ const now = Date.now();
378
+ return (await readMailbox(stateRoot, teamId, agentKey, onMalformedLine))
379
+ .filter(message => message.readAt === undefined
380
+ && (message.deliveryClaimedAt === undefined
381
+ || now - message.deliveryClaimedAt >= MAILBOX_DELIVERY_LEASE_MS));
382
+ }
383
+ async function mutateMailbox(stateRoot, teamId, agentKey, messageIds, mutate) {
384
+ if (messageIds.length === 0)
385
+ return;
386
+ const file = join(stateRoot, teamId, 'inbox', `${sanitizeKey(agentKey)}.jsonl`);
387
+ let raw;
388
+ try {
389
+ raw = await readFile(file, 'utf8');
390
+ }
391
+ catch (error) {
392
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
393
+ return;
394
+ throw error;
395
+ }
396
+ const selected = new Set(messageIds);
397
+ const lines = raw.split('\n').map((rawLine) => {
398
+ const line = stripLeadingBom(rawLine);
399
+ if (line.trim() === '')
400
+ return rawLine;
401
+ try {
402
+ const value = JSON.parse(line);
403
+ if (!isTeamMessage(value) || !selected.has(value.id))
404
+ return rawLine;
405
+ return JSON.stringify(mutate(value));
406
+ }
407
+ catch {
408
+ return rawLine;
409
+ }
410
+ });
411
+ await atomicWriteText(file, lines.join('\n'));
412
+ }
413
+ /** Lease selected fallback messages to one delivery path. */
414
+ export async function claimMailboxDelivery(stateRoot, teamId, agentKey, messageIds) {
415
+ const now = Date.now();
416
+ await mutateMailbox(stateRoot, teamId, agentKey, messageIds, message => ({
417
+ ...message,
418
+ deliveryClaimedAt: now,
419
+ }));
420
+ }
421
+ /** Release a failed delivery lease so the scheduler can retry it later. */
422
+ export async function releaseMailboxDelivery(stateRoot, teamId, agentKey, messageIds) {
423
+ await mutateMailbox(stateRoot, teamId, agentKey, messageIds, (message) => {
424
+ const { deliveryClaimedAt: _claimed, ...released } = message;
425
+ return released;
426
+ });
427
+ }
428
+ /**
429
+ * Mark selected durable mailbox records delivered/read while preserving
430
+ * malformed lines for diagnostics. Callers serialize this with the team lock.
431
+ */
432
+ export async function acknowledgeMailbox(stateRoot, teamId, agentKey, messageIds) {
433
+ const now = Date.now();
434
+ await mutateMailbox(stateRoot, teamId, agentKey, messageIds, (message) => {
435
+ const { deliveryClaimedAt: _claimed, ...rest } = message;
436
+ return {
437
+ ...rest,
438
+ deliveredAt: message.deliveredAt ?? now,
439
+ readAt: message.readAt ?? now,
440
+ };
441
+ });
442
+ }
312
443
  /** Remove the optional UTF-8 BOM some editors prepend to JSON text. */
313
444
  function stripLeadingBom(value) {
314
445
  return value.charCodeAt(0) === 0xFEFF ? value.slice(1) : value;
315
446
  }
316
- /** Atomically replace one UTF-8 state file from a same-directory temp file. */
447
+ /** Rename attempts before falling back to a direct overwrite. */
448
+ const ATOMIC_RENAME_RETRIES = 3;
449
+ /** Pause between rename attempts, giving a briefly-locking owner time to finish. */
450
+ const ATOMIC_RENAME_RETRY_DELAY_MS = 50;
451
+ /**
452
+ * Rename error codes worth retrying before the direct-write fallback. On
453
+ * Windows, replacing an existing file whose target is momentarily held open
454
+ * without FILE_SHARE_DELETE surfaces as EPERM (or EACCES/EBUSY variants);
455
+ * EEXIST/ENOTEMPTY cover other "target busy" edge shapes.
456
+ */
457
+ const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST', 'ENOTEMPTY']);
458
+ function isRetryableRenameError(error) {
459
+ return error instanceof Error
460
+ && 'code' in error
461
+ && RETRYABLE_RENAME_CODES.has(error.code ?? '');
462
+ }
463
+ function sleep(ms) {
464
+ return new Promise((resolve) => setTimeout(resolve, ms));
465
+ }
466
+ /**
467
+ * Replace `file` with `content`, preferring an atomic same-directory rename of
468
+ * an already-written temp file.
469
+ *
470
+ * On Windows, `rename(tmp, file)` over an existing target throws EPERM while
471
+ * any other process keeps the target open without FILE_SHARE_DELETE (editors,
472
+ * indexers, antivirus scans, preview panes). By that point the payload has
473
+ * already been fully written to the temp file, so a direct overwrite of the
474
+ * target is a content-equivalent degraded path: retry the rename a few times
475
+ * (transient locks clear quickly), then write the target in place. Every path
476
+ * removes the temp file; when both the atomic rename and the direct write
477
+ * fail, the combined error surfaces as an {@link AggregateError}.
478
+ *
479
+ * @returns nothing once the file has been replaced by one of the two paths.
480
+ */
481
+ export async function replaceFileAtomicOrDirect(temporary, file, content, primitives, options = {}) {
482
+ const retries = options.retries ?? ATOMIC_RENAME_RETRIES;
483
+ const retryDelayMs = options.retryDelayMs ?? ATOMIC_RENAME_RETRY_DELAY_MS;
484
+ for (let attempt = 0;; attempt += 1) {
485
+ try {
486
+ await primitives.rename(temporary, file);
487
+ return;
488
+ }
489
+ catch (error) {
490
+ if (isRetryableRenameError(error) && attempt < retries) {
491
+ await sleep(retryDelayMs);
492
+ continue;
493
+ }
494
+ let fallbackError;
495
+ try {
496
+ await primitives.writeFile(file, content);
497
+ }
498
+ catch (writeError) {
499
+ fallbackError = writeError;
500
+ }
501
+ await primitives.remove(temporary).catch(() => undefined);
502
+ if (fallbackError !== undefined) {
503
+ throw new AggregateError([error, fallbackError], `failed to replace "${file}" atomically (${String(error)}) or by direct write (${String(fallbackError)})`);
504
+ }
505
+ return;
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * Atomically replace one UTF-8 state file from a same-directory temp file,
511
+ * degrading to a direct overwrite when the atomic rename cannot proceed
512
+ * (see {@link replaceFileAtomicOrDirect} for the Windows EPERM rationale).
513
+ */
317
514
  async function atomicWriteText(file, content) {
318
515
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
319
516
  try {
320
517
  await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
321
- await rename(temporary, file);
322
518
  }
323
519
  catch (error) {
324
520
  await rm(temporary, { force: true }).catch(() => undefined);
325
521
  throw error;
326
522
  }
523
+ await replaceFileAtomicOrDirect(temporary, file, content, {
524
+ rename: (from, to) => rename(from, to),
525
+ writeFile: (target, payload) => writeFile(target, payload, 'utf8'),
526
+ remove: (path) => rm(path, { force: true }),
527
+ });
327
528
  }
328
529
  /** Whether a parsed JSON value is a plain record. */
329
530
  function isRecord(value) {
@@ -368,6 +569,11 @@ function isTeamTask(value) {
368
569
  && Array.isArray(value['dependencies'])
369
570
  && value['dependencies'].every((dependency) => typeof dependency === 'string')
370
571
  && isOptionalString(value['output'])
572
+ && (value['attempt'] === undefined
573
+ || (Number.isSafeInteger(value['attempt']) && value['attempt'] >= 0))
574
+ && isOptionalString(value['attemptId'])
575
+ && isOptionalString(value['handoffId'])
576
+ && (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
371
577
  && isFiniteNumber(value['createdAt'])
372
578
  && isFiniteNumber(value['updatedAt']);
373
579
  }
@@ -417,7 +623,10 @@ function isTeamMessage(value) {
417
623
  && typeof value['from'] === 'string'
418
624
  && typeof value['to'] === 'string'
419
625
  && typeof value['content'] === 'string'
420
- && isFiniteNumber(value['ts']);
626
+ && isFiniteNumber(value['ts'])
627
+ && (value['deliveryClaimedAt'] === undefined || isFiniteNumber(value['deliveryClaimedAt']))
628
+ && (value['deliveredAt'] === undefined || isFiniteNumber(value['deliveredAt']))
629
+ && (value['readAt'] === undefined || isFiniteNumber(value['readAt']));
421
630
  }
422
631
  /**
423
632
  * Remove a team's whole directory (members should be interrupted first).
@@ -427,6 +636,30 @@ function isTeamMessage(value) {
427
636
  export async function removeTeamDir(stateRoot, teamId) {
428
637
  await rm(join(stateRoot, teamId), { recursive: true, force: true });
429
638
  }
639
+ /**
640
+ * `rename` with the same transient retry policy as the state-file atomic
641
+ * write, for paths (like archiving a whole team directory) where there is no
642
+ * content-equivalent direct-write degradation on Windows. A short-lived
643
+ * delete-sharing lock on any file below the renamed path is retried a few
644
+ * times before the error propagates.
645
+ * @param from - source path.
646
+ * @param to - destination path.
647
+ */
648
+ async function renameWithRetry(from, to) {
649
+ for (let attempt = 0;; attempt += 1) {
650
+ try {
651
+ await rename(from, to);
652
+ return;
653
+ }
654
+ catch (error) {
655
+ if (isRetryableRenameError(error) && attempt < ATOMIC_RENAME_RETRIES) {
656
+ await sleep(ATOMIC_RENAME_RETRY_DELAY_MS);
657
+ continue;
658
+ }
659
+ throw error;
660
+ }
661
+ }
662
+ }
430
663
  /**
431
664
  * Archive a team instead of deleting it: the whole directory (team.json with
432
665
  * tasks and dependency graph, plus the mailboxes) moves under
@@ -439,7 +672,42 @@ export async function removeTeamDir(stateRoot, teamId) {
439
672
  export async function archiveTeamDir(stateRoot, teamId) {
440
673
  const archiveRoot = join(stateRoot, 'archive');
441
674
  await mkdir(archiveRoot, { recursive: true });
442
- await rename(join(stateRoot, teamId), join(archiveRoot, teamId));
675
+ const source = join(stateRoot, teamId);
676
+ const target = join(archiveRoot, teamId);
677
+ const previous = join(archiveRoot, `.${teamId}.previous-${randomUUID()}`);
678
+ let displaced = false;
679
+ try {
680
+ // The same Windows EPERM-on-rename applies at the directory boundary: a
681
+ // delete-sharing violation on any file below `target` blocks the move, so
682
+ // retry the transient-lock case before giving up.
683
+ await renameWithRetry(target, previous);
684
+ displaced = true;
685
+ }
686
+ catch (error) {
687
+ // Only ENOENT means there was nothing to displace; any other failure
688
+ // (including a persistent EPERM lock) surfaces to the caller.
689
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
690
+ throw error;
691
+ }
692
+ }
693
+ try {
694
+ await renameWithRetry(source, target);
695
+ }
696
+ catch (error) {
697
+ if (displaced) {
698
+ try {
699
+ await renameWithRetry(previous, target);
700
+ }
701
+ catch (restoreError) {
702
+ throw new AggregateError([error, restoreError], `failed to archive team "${teamId}" and restore its previous archive`);
703
+ }
704
+ }
705
+ throw error;
706
+ }
707
+ // The new generation is authoritative. A failed cleanup only leaves a
708
+ // hidden recovery directory, which archive discovery deliberately ignores.
709
+ if (displaced)
710
+ await rm(previous, { recursive: true, force: true }).catch(() => undefined);
443
711
  }
444
712
  /**
445
713
  * Read one archived team (already moved under `archive/`), or undefined when
@@ -458,7 +726,9 @@ export async function readArchivedTeam(stateRoot, teamId) {
458
726
  export async function listArchivedTeamIds(stateRoot) {
459
727
  try {
460
728
  const entries = await readdir(join(stateRoot, 'archive'), { withFileTypes: true });
461
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
729
+ return entries
730
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
731
+ .map((entry) => entry.name);
462
732
  }
463
733
  catch (error) {
464
734
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {