@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/README.md +8 -7
- package/README_ZH.md +8 -7
- package/lib/client/ActivityPanel.js +93 -38
- package/lib/client/activity-model.js +63 -0
- package/lib/client.js +478 -367
- package/lib/client.js.map +1 -1
- package/lib/index.js +7 -5
- package/lib/members.js +101 -18
- package/lib/scheduler.js +212 -0
- package/lib/snapshot.js +32 -19
- package/lib/state.js +275 -5
- package/lib/tools.js +303 -31
- package/lib/types/client/ActivityPanel.d.ts +1 -0
- package/lib/types/client/activity-model.d.ts +34 -0
- package/lib/types/event-types.d.ts +2 -0
- package/lib/types/members.d.ts +25 -8
- package/lib/types/scheduler.d.ts +23 -0
- package/lib/types/snapshot.d.ts +10 -2
- package/lib/types/state.d.ts +53 -0
- package/lib/types/types.d.ts +16 -2
- package/package.json +2 -2
package/lib/scheduler.js
ADDED
|
@@ -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,
|
|
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
|
|
32
|
+
const roster = options.includeRemoved === true
|
|
33
|
+
? state.members
|
|
34
|
+
: state.members.filter((member) => member.status !== 'removed');
|
|
33
35
|
const activity = new Map();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
51
|
+
for (const member of roster) {
|
|
46
52
|
try {
|
|
47
|
-
unreadByMember.set(member.name, (await
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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') {
|