@nanmicoder/dsh-agent-teams 0.1.5 → 0.1.6
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 +6 -5
- package/README_ZH.md +6 -5
- 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 +6 -4
- package/lib/members.js +73 -10
- package/lib/scheduler.js +212 -0
- package/lib/snapshot.js +32 -19
- package/lib/state.js +174 -3
- package/lib/tools.js +287 -29
- 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 +17 -4
- package/lib/types/scheduler.d.ts +23 -0
- package/lib/types/snapshot.d.ts +10 -2
- package/lib/types/state.d.ts +24 -0
- package/lib/types/types.d.ts +15 -1
- 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,6 +372,74 @@ 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;
|
|
@@ -368,6 +499,11 @@ function isTeamTask(value) {
|
|
|
368
499
|
&& Array.isArray(value['dependencies'])
|
|
369
500
|
&& value['dependencies'].every((dependency) => typeof dependency === 'string')
|
|
370
501
|
&& isOptionalString(value['output'])
|
|
502
|
+
&& (value['attempt'] === undefined
|
|
503
|
+
|| (Number.isSafeInteger(value['attempt']) && value['attempt'] >= 0))
|
|
504
|
+
&& isOptionalString(value['attemptId'])
|
|
505
|
+
&& isOptionalString(value['handoffId'])
|
|
506
|
+
&& (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
|
|
371
507
|
&& isFiniteNumber(value['createdAt'])
|
|
372
508
|
&& isFiniteNumber(value['updatedAt']);
|
|
373
509
|
}
|
|
@@ -417,7 +553,10 @@ function isTeamMessage(value) {
|
|
|
417
553
|
&& typeof value['from'] === 'string'
|
|
418
554
|
&& typeof value['to'] === 'string'
|
|
419
555
|
&& typeof value['content'] === 'string'
|
|
420
|
-
&& isFiniteNumber(value['ts'])
|
|
556
|
+
&& isFiniteNumber(value['ts'])
|
|
557
|
+
&& (value['deliveryClaimedAt'] === undefined || isFiniteNumber(value['deliveryClaimedAt']))
|
|
558
|
+
&& (value['deliveredAt'] === undefined || isFiniteNumber(value['deliveredAt']))
|
|
559
|
+
&& (value['readAt'] === undefined || isFiniteNumber(value['readAt']));
|
|
421
560
|
}
|
|
422
561
|
/**
|
|
423
562
|
* Remove a team's whole directory (members should be interrupted first).
|
|
@@ -439,7 +578,37 @@ export async function removeTeamDir(stateRoot, teamId) {
|
|
|
439
578
|
export async function archiveTeamDir(stateRoot, teamId) {
|
|
440
579
|
const archiveRoot = join(stateRoot, 'archive');
|
|
441
580
|
await mkdir(archiveRoot, { recursive: true });
|
|
442
|
-
|
|
581
|
+
const source = join(stateRoot, teamId);
|
|
582
|
+
const target = join(archiveRoot, teamId);
|
|
583
|
+
const previous = join(archiveRoot, `.${teamId}.previous-${randomUUID()}`);
|
|
584
|
+
let displaced = false;
|
|
585
|
+
try {
|
|
586
|
+
await rename(target, previous);
|
|
587
|
+
displaced = true;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
await rename(source, target);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (displaced) {
|
|
599
|
+
try {
|
|
600
|
+
await rename(previous, target);
|
|
601
|
+
}
|
|
602
|
+
catch (restoreError) {
|
|
603
|
+
throw new AggregateError([error, restoreError], `failed to archive team "${teamId}" and restore its previous archive`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
// The new generation is authoritative. A failed cleanup only leaves a
|
|
609
|
+
// hidden recovery directory, which archive discovery deliberately ignores.
|
|
610
|
+
if (displaced)
|
|
611
|
+
await rm(previous, { recursive: true, force: true }).catch(() => undefined);
|
|
443
612
|
}
|
|
444
613
|
/**
|
|
445
614
|
* Read one archived team (already moved under `archive/`), or undefined when
|
|
@@ -458,7 +627,9 @@ export async function readArchivedTeam(stateRoot, teamId) {
|
|
|
458
627
|
export async function listArchivedTeamIds(stateRoot) {
|
|
459
628
|
try {
|
|
460
629
|
const entries = await readdir(join(stateRoot, 'archive'), { withFileTypes: true });
|
|
461
|
-
return entries
|
|
630
|
+
return entries
|
|
631
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
632
|
+
.map((entry) => entry.name);
|
|
462
633
|
}
|
|
463
634
|
catch (error) {
|
|
464
635
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|