@nanmicoder/dsh-agent-teams 0.1.12 → 0.1.14
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 +43 -7
- package/README_ZH.md +20 -7
- package/lib/client/ActivityPanel.js +219 -50
- package/lib/client/StagingPlanEditor.js +493 -0
- package/lib/client/activity-model.js +71 -0
- package/lib/client/activity-monitor.js +39 -13
- package/lib/client/index.js +2 -2
- package/lib/client/locales.js +224 -2
- package/lib/client.js +1808 -250
- package/lib/client.js.map +1 -1
- package/lib/command.js +116 -99
- package/lib/index.js +286 -14
- package/lib/members.js +137 -16
- package/lib/profiles.js +572 -0
- package/lib/quality-gates.js +777 -0
- package/lib/scheduler.js +215 -18
- package/lib/snapshot.js +25 -1
- package/lib/state.js +116 -10
- package/lib/tools.js +1230 -38
- package/lib/types/client/ActivityPanel.d.ts +3 -1
- package/lib/types/client/StagingPlanEditor.d.ts +17 -0
- package/lib/types/client/activity-model.d.ts +67 -0
- package/lib/types/client/activity-monitor.d.ts +32 -7
- package/lib/types/client/locales.d.ts +222 -0
- package/lib/types/command.d.ts +11 -56
- package/lib/types/event-types.d.ts +35 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/members.d.ts +48 -3
- package/lib/types/profiles.d.ts +124 -0
- package/lib/types/quality-gates.d.ts +148 -0
- package/lib/types/scheduler.d.ts +48 -1
- package/lib/types/snapshot.d.ts +18 -1
- package/lib/types/state.d.ts +8 -3
- package/lib/types/tools.d.ts +73 -9
- package/lib/types/types.d.ts +118 -0
- package/lib/types.js +11 -0
- package/package.json +10 -4
- package/release-notes/v0.1.13.md +60 -0
- package/release-notes/v0.1.14.md +68 -0
package/lib/scheduler.js
CHANGED
|
@@ -5,12 +5,89 @@
|
|
|
5
5
|
* continuable agents instead expose explicit idle/running edges, so this
|
|
6
6
|
* scheduler closes the same loop without keeping a polling turn alive: every
|
|
7
7
|
* idle edge and every task-graph mutation attempts one atomic claim and wakes
|
|
8
|
-
* the selected durable member.
|
|
8
|
+
* the selected durable member. A resident member that becomes idle while it
|
|
9
|
+
* still owns an open attempt is parked: only an explicit captain reassignment
|
|
10
|
+
* may rotate that capability. Automatic retry is reserved for cold recovery,
|
|
11
|
+
* when the durable owner is no longer resident in the live Agent registry.
|
|
9
12
|
* @module dsh-agent-teams/scheduler
|
|
10
13
|
*/
|
|
11
14
|
import { join } from 'node:path';
|
|
12
15
|
import { deliverToMember } from "./members.js";
|
|
13
|
-
import { acknowledgeMailbox, beginTaskAttempt, claimMailboxDelivery, findTeamByParticipant, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
16
|
+
import { acknowledgeMailbox, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
17
|
+
/** Per-dependency output cap in the assignment prompt. */
|
|
18
|
+
export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
|
|
19
|
+
/** Combined dependency-output budget in the assignment prompt. */
|
|
20
|
+
export const DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS = 12_000;
|
|
21
|
+
function taskProfileSeedId(task) {
|
|
22
|
+
const seed = task.profileSeedId?.trim();
|
|
23
|
+
return seed === undefined || seed === '' ? undefined : seed;
|
|
24
|
+
}
|
|
25
|
+
function teamProfileProtocol(team) {
|
|
26
|
+
return team.profile?.protocol;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Recursively collect `status=completed` ancestors of `taskId` in topological
|
|
30
|
+
* order (dependencies before dependents). Cycles stop that branch only.
|
|
31
|
+
*/
|
|
32
|
+
export function collectCompletedDependencyOutputs(tasks, taskId, warn) {
|
|
33
|
+
const byId = new Map(tasks.map(task => [task.id, task]));
|
|
34
|
+
const visiting = new Set();
|
|
35
|
+
const visited = new Set();
|
|
36
|
+
const ordered = [];
|
|
37
|
+
const walk = (id) => {
|
|
38
|
+
if (visiting.has(id)) {
|
|
39
|
+
warn?.(`agent-teams: dependency cycle involving "${id}" while collecting outputs; stopping this branch`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (visited.has(id))
|
|
43
|
+
return;
|
|
44
|
+
visiting.add(id);
|
|
45
|
+
const task = byId.get(id);
|
|
46
|
+
if (task !== undefined) {
|
|
47
|
+
for (const dependency of task.dependencies)
|
|
48
|
+
walk(dependency);
|
|
49
|
+
if (id !== taskId)
|
|
50
|
+
ordered.push(task);
|
|
51
|
+
}
|
|
52
|
+
visiting.delete(id);
|
|
53
|
+
visited.add(id);
|
|
54
|
+
};
|
|
55
|
+
walk(taskId);
|
|
56
|
+
return ordered
|
|
57
|
+
.filter(task => task.status === 'completed')
|
|
58
|
+
.map((task) => {
|
|
59
|
+
const profileSeedId = taskProfileSeedId(task);
|
|
60
|
+
return {
|
|
61
|
+
id: task.id,
|
|
62
|
+
subject: task.subject,
|
|
63
|
+
...profileSeedId === undefined ? {} : { profileSeedId },
|
|
64
|
+
...task.output === undefined ? {} : { output: task.output },
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Format completed-dependency outputs with per-item and total truncation. */
|
|
69
|
+
export function formatDependencyOutputs(items) {
|
|
70
|
+
if (items.length === 0)
|
|
71
|
+
return '(none)';
|
|
72
|
+
const formatted = items.map((item) => {
|
|
73
|
+
const seed = item.profileSeedId === undefined ? '' : ` [${item.profileSeedId}]`;
|
|
74
|
+
const raw = item.output === undefined || item.output === ''
|
|
75
|
+
? '(no output recorded)'
|
|
76
|
+
: item.output;
|
|
77
|
+
const truncated = raw.length > DEPENDENCY_OUTPUT_MAX_CHARS;
|
|
78
|
+
const body = truncated ? `${raw.slice(0, DEPENDENCY_OUTPUT_MAX_CHARS)} [truncated]` : raw;
|
|
79
|
+
return `- ${item.id}${seed} ${item.subject}:\n ${body}`;
|
|
80
|
+
});
|
|
81
|
+
let selected = formatted;
|
|
82
|
+
while (selected.length > 1 && selected.join('\n').length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
|
|
83
|
+
selected = selected.slice(1);
|
|
84
|
+
}
|
|
85
|
+
const last = selected[0];
|
|
86
|
+
if (selected.length === 1 && last !== undefined && last.length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
|
|
87
|
+
selected = [`${last.slice(0, DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS)} [truncated]`];
|
|
88
|
+
}
|
|
89
|
+
return selected.join('\n');
|
|
90
|
+
}
|
|
14
91
|
function stateRootOf(workspace, config) {
|
|
15
92
|
return join(workspace, config.stateDir);
|
|
16
93
|
}
|
|
@@ -22,8 +99,11 @@ function liveCaptain(ctx, captainSessionId, supplied) {
|
|
|
22
99
|
return supplied;
|
|
23
100
|
return ctx.agents.get(captainSessionId);
|
|
24
101
|
}
|
|
102
|
+
function liveMember(ctx, member) {
|
|
103
|
+
return ctx.agents.get(member.id);
|
|
104
|
+
}
|
|
25
105
|
function isMemberAvailable(ctx, member) {
|
|
26
|
-
const live = ctx
|
|
106
|
+
const live = liveMember(ctx, member);
|
|
27
107
|
return live === undefined || live.status === 'idle';
|
|
28
108
|
}
|
|
29
109
|
function ownedOpenTask(tasks, memberName) {
|
|
@@ -37,15 +117,54 @@ function nextReadyTask(tasks, memberName) {
|
|
|
37
117
|
return ready.find(task => task.assignee === memberName)
|
|
38
118
|
?? ready.find(task => task.assignee === undefined);
|
|
39
119
|
}
|
|
40
|
-
function assignmentPrompt(ticket, stateDir, teamId) {
|
|
120
|
+
export function assignmentPrompt(ticket, stateDir, teamId) {
|
|
41
121
|
const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
|
|
122
|
+
const seed = ticket.profileSeedId === undefined ? '' : ` [${ticket.profileSeedId}]`;
|
|
123
|
+
const goal = ticket.teamDescription?.trim() || '(not provided)';
|
|
124
|
+
const protocol = ticket.profileProtocol?.trim() || '(none)';
|
|
125
|
+
const executionPrompt = ticket.executionPrompt?.trim();
|
|
126
|
+
const kind = ticket.kind?.trim() || 'work';
|
|
127
|
+
const contract = [
|
|
128
|
+
`Kind: ${kind}${ticket.round === undefined ? '' : ` (round ${ticket.round})`}`,
|
|
129
|
+
ticket.objective === undefined || ticket.objective === '' ? '' : `Objective: ${ticket.objective}`,
|
|
130
|
+
ticket.inScope === undefined || ticket.inScope.length === 0 ? '' : `In scope: ${ticket.inScope.join(', ')}`,
|
|
131
|
+
ticket.outOfScope === undefined || ticket.outOfScope.length === 0 ? '' : `Out of scope: ${ticket.outOfScope.join(', ')}`,
|
|
132
|
+
ticket.acceptance === undefined || ticket.acceptance.length === 0 ? '' : `Acceptance: ${ticket.acceptance.join('; ')}`,
|
|
133
|
+
ticket.verify === undefined || ticket.verify.length === 0 ? '' : `Verify: ${ticket.verify.join('; ')}`,
|
|
134
|
+
ticket.reviewedTaskId === undefined ? '' : `Reviewed task: ${ticket.reviewedTaskId}`,
|
|
135
|
+
].filter((line) => line !== '').join('\n');
|
|
136
|
+
const structuredCompletion = ['implementation', 'repair', 'verification', 'integration'].includes(kind)
|
|
137
|
+
? `
|
|
138
|
+
Structured completion payload (keep these arrays in contract order):
|
|
139
|
+
acceptanceResults: ${JSON.stringify((ticket.acceptance ?? []).map((criterion) => ({ criterion, status: 'passed', evidence: '<what proved it>' })))}
|
|
140
|
+
commandsRun: ${JSON.stringify((ticket.verify ?? []).map((command) => ({ command, status: 'passed', exitCode: 0, evidence: '<observed result>' })))}
|
|
141
|
+
${kind === 'implementation' || kind === 'repair' ? 'changedPaths: list the actual workspace-relative POSIX paths you changed.\n' : ''}`
|
|
142
|
+
: '';
|
|
42
143
|
return `AgentTeams automatic task assignment from the shared task list.
|
|
43
144
|
|
|
44
|
-
|
|
145
|
+
You are executing as configured member "${ticket.memberName}".
|
|
146
|
+
Do not start a teammate's assigned task.
|
|
147
|
+
|
|
148
|
+
Team goal:
|
|
149
|
+
${goal}
|
|
150
|
+
|
|
151
|
+
Profile protocol:
|
|
152
|
+
${protocol}
|
|
153
|
+
${executionPrompt === undefined || executionPrompt === '' ? '' : `
|
|
154
|
+
Execution guidance:
|
|
155
|
+
${executionPrompt}
|
|
156
|
+
`}
|
|
157
|
+
Completed dependency results:
|
|
158
|
+
${formatDependencyOutputs(ticket.dependencyOutputs)}
|
|
159
|
+
|
|
160
|
+
Task: ${ticket.taskId}${seed} — ${ticket.subject}${description}
|
|
161
|
+
${contract === '' ? '' : `\nContract:\n${contract}\n`}
|
|
162
|
+
${structuredCompletion}
|
|
45
163
|
Attempt: ${ticket.attempt}
|
|
46
164
|
Attempt id: ${ticket.attemptId}
|
|
47
165
|
|
|
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.
|
|
166
|
+
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. claimed cannot jump to completed. Mark in_progress first, then completed or failed. Include attempt_id on every update. Then send_message to captain and become idle.
|
|
167
|
+
When finishing: use status=completed only when the task's success criteria are satisfied; use status=failed when blocking findings or validation failures mean downstream work must not proceed; include a concise output in either case. Quality kinds must submit structured fields: review/requirements need verdict=pass to complete (needs_revision/reject must fail with findings); implementation/repair/verification/integration need acceptanceResults and commandsRun, while implementation/repair also need in-scope changedPaths. Use status values "passed" or "failed" inside those arrays. After the work and verification finish, call agent_teams_update_task immediately; do not wait for captain confirmation and do not continue exploring. Do not approve your own implementation. Mail is not a formal next review. Treat the dependency results above as source material. Do not ignore them. Work only this task and only its in-scope paths in this turn.
|
|
49
168
|
|
|
50
169
|
State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
|
|
51
170
|
}
|
|
@@ -59,6 +178,13 @@ function fallbackMailboxPrompt(messages) {
|
|
|
59
178
|
/** Install one scheduler and its member activity observer. */
|
|
60
179
|
export function installTeamScheduler(ctx, config) {
|
|
61
180
|
const memberQueues = new Map();
|
|
181
|
+
// An idle edge in this process proves that the resident member ended its
|
|
182
|
+
// turn while the current attempt was still open. Remember that capability
|
|
183
|
+
// even after Harness disposes the continuable AgentHandle: later status or
|
|
184
|
+
// graph kicks must keep it parked. A cold process starts with an empty map,
|
|
185
|
+
// so durable open attempts are still recovered after restart.
|
|
186
|
+
const parkedAttempts = new Map();
|
|
187
|
+
const memberQueueKey = (stateRoot, teamId, memberName) => (`${stateRoot}\u0000${teamId}\u0000${memberName}`);
|
|
62
188
|
const serializeMember = async (key, operation) => {
|
|
63
189
|
const previous = memberQueues.get(key) ?? Promise.resolve();
|
|
64
190
|
let release;
|
|
@@ -79,7 +205,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
79
205
|
async kickTeam(workspace, teamId, suppliedCaptain) {
|
|
80
206
|
const stateRoot = stateRootOf(workspace, config);
|
|
81
207
|
const team = await readTeam(stateRoot, teamId);
|
|
82
|
-
if (team === undefined)
|
|
208
|
+
if (team === undefined || team.halted === true || team.phase === 'staged')
|
|
83
209
|
return;
|
|
84
210
|
const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
|
|
85
211
|
if (captain === undefined)
|
|
@@ -92,10 +218,10 @@ export function installTeamScheduler(ctx, config) {
|
|
|
92
218
|
},
|
|
93
219
|
async kickMember(workspace, teamId, memberName, suppliedCaptain) {
|
|
94
220
|
const stateRoot = stateRootOf(workspace, config);
|
|
95
|
-
const queueKey =
|
|
221
|
+
const queueKey = memberQueueKey(stateRoot, teamId, memberName);
|
|
96
222
|
await serializeMember(queueKey, async () => {
|
|
97
223
|
let team = await readTeam(stateRoot, teamId);
|
|
98
|
-
if (team === undefined)
|
|
224
|
+
if (team === undefined || team.halted === true || team.phase === 'staged')
|
|
99
225
|
return;
|
|
100
226
|
const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
|
|
101
227
|
if (captain === undefined)
|
|
@@ -119,17 +245,25 @@ export function installTeamScheduler(ctx, config) {
|
|
|
119
245
|
}
|
|
120
246
|
const ticket = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
121
247
|
const fresh = await readTeam(stateRoot, team.id);
|
|
122
|
-
if (fresh === undefined)
|
|
248
|
+
if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
|
|
123
249
|
return undefined;
|
|
124
250
|
const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
125
251
|
if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
|
|
126
252
|
return undefined;
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
253
|
+
const owned = ownedOpenTask(fresh.tasks, currentMember.name);
|
|
254
|
+
// A resident idle member can intentionally leave an attempt open
|
|
255
|
+
// while waiting for guidance, or because the user paused its turn.
|
|
256
|
+
// Re-dispatching here would revoke still-valid work on every idle
|
|
257
|
+
// edge and every status kick. The idle observer remembers that exact
|
|
258
|
+
// capability across normal continuable disposal; only an unobserved
|
|
259
|
+
// durable capability (cold process recovery) or a legacy open task
|
|
260
|
+
// with no capability is retried.
|
|
261
|
+
const parkedAttemptId = parkedAttempts.get(currentMember.id);
|
|
262
|
+
const recoverOwned = owned !== undefined
|
|
263
|
+
&& (owned.attemptId === undefined || owned.attemptId !== parkedAttemptId);
|
|
264
|
+
const task = recoverOwned ? owned : owned === undefined
|
|
265
|
+
? nextReadyTask(fresh.tasks, currentMember.name)
|
|
266
|
+
: undefined;
|
|
133
267
|
if (task === undefined) {
|
|
134
268
|
if (currentMember.status !== 'idle') {
|
|
135
269
|
currentMember.status = 'idle';
|
|
@@ -139,8 +273,11 @@ export function installTeamScheduler(ctx, config) {
|
|
|
139
273
|
}
|
|
140
274
|
const previousAssignee = task.assignee;
|
|
141
275
|
const attemptId = beginTaskAttempt(task, currentMember.name);
|
|
276
|
+
parkedAttempts.delete(currentMember.id);
|
|
142
277
|
currentMember.status = 'working';
|
|
143
278
|
await writeTeam(stateRoot, fresh);
|
|
279
|
+
const profileSeedId = taskProfileSeedId(task);
|
|
280
|
+
const protocol = teamProfileProtocol(fresh);
|
|
144
281
|
return {
|
|
145
282
|
taskId: task.id,
|
|
146
283
|
memberName: currentMember.name,
|
|
@@ -150,6 +287,21 @@ export function installTeamScheduler(ctx, config) {
|
|
|
150
287
|
previousAssignee,
|
|
151
288
|
subject: task.subject,
|
|
152
289
|
description: task.description,
|
|
290
|
+
teamDescription: fresh.description,
|
|
291
|
+
...protocol === undefined ? {} : { profileProtocol: protocol },
|
|
292
|
+
...profileSeedId === undefined ? {} : { profileSeedId },
|
|
293
|
+
...fresh.profile?.executionPrompt === undefined && config.executionPrompt === undefined
|
|
294
|
+
? {}
|
|
295
|
+
: { executionPrompt: fresh.profile?.executionPrompt ?? config.executionPrompt },
|
|
296
|
+
kind: task.kind ?? 'work',
|
|
297
|
+
...task.round === undefined ? {} : { round: task.round },
|
|
298
|
+
...task.objective === undefined ? {} : { objective: task.objective },
|
|
299
|
+
...task.inScope === undefined ? {} : { inScope: task.inScope },
|
|
300
|
+
...task.outOfScope === undefined ? {} : { outOfScope: task.outOfScope },
|
|
301
|
+
...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
|
|
302
|
+
...task.verify === undefined ? {} : { verify: task.verify },
|
|
303
|
+
...task.reviewedTaskId === undefined ? {} : { reviewedTaskId: task.reviewedTaskId },
|
|
304
|
+
dependencyOutputs: collectCompletedDependencyOutputs(fresh.tasks, task.id, (message) => ctx.logger.warn(message)),
|
|
153
305
|
};
|
|
154
306
|
});
|
|
155
307
|
if (ticket === undefined)
|
|
@@ -184,17 +336,62 @@ export function installTeamScheduler(ctx, config) {
|
|
|
184
336
|
const workspace = agent.session.header.cwd ?? process.cwd();
|
|
185
337
|
const stateRoot = stateRootOf(workspace, config);
|
|
186
338
|
const located = await findTeamByParticipant(stateRoot, agent.id);
|
|
187
|
-
if (located === undefined
|
|
339
|
+
if (located === undefined) {
|
|
340
|
+
parkedAttempts.delete(agent.id);
|
|
188
341
|
return;
|
|
342
|
+
}
|
|
343
|
+
if (located.captainSessionId === agent.id) {
|
|
344
|
+
// Captain takeover is scoped to the captain's current turn. Unlike a
|
|
345
|
+
// durable member, the captain has no scheduler lane that can resume an
|
|
346
|
+
// abandoned attempt later. Returning unfinished captain-owned work to
|
|
347
|
+
// the shared pool on the idle edge prevents it from becoming a
|
|
348
|
+
// permanently parked `claimed` task after the captain answers, is
|
|
349
|
+
// interrupted, or the user switches conversations.
|
|
350
|
+
if (status === 'running')
|
|
351
|
+
return;
|
|
352
|
+
let requeued = false;
|
|
353
|
+
await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
|
|
354
|
+
const fresh = await readTeam(stateRoot, located.id);
|
|
355
|
+
if (fresh === undefined || fresh.captainSessionId !== agent.id)
|
|
356
|
+
return;
|
|
357
|
+
for (const task of fresh.tasks) {
|
|
358
|
+
if (task.assignee !== CAPTAIN_KEY
|
|
359
|
+
|| task.status === 'completed'
|
|
360
|
+
|| task.status === 'failed'
|
|
361
|
+
|| task.status === 'cancelled')
|
|
362
|
+
continue;
|
|
363
|
+
invalidateTaskAttempt(task);
|
|
364
|
+
task.reassigning = false;
|
|
365
|
+
requeued = true;
|
|
366
|
+
}
|
|
367
|
+
if (requeued)
|
|
368
|
+
await writeTeam(stateRoot, fresh);
|
|
369
|
+
});
|
|
370
|
+
if (requeued)
|
|
371
|
+
await runtime.kickTeam(workspace, located.id, agent);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
189
374
|
const member = located.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
|
|
190
|
-
if (member === undefined)
|
|
375
|
+
if (member === undefined) {
|
|
376
|
+
parkedAttempts.delete(agent.id);
|
|
191
377
|
return;
|
|
378
|
+
}
|
|
192
379
|
await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
|
|
193
380
|
const fresh = await readTeam(stateRoot, located.id);
|
|
194
381
|
const current = fresh?.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
|
|
195
382
|
if (fresh === undefined || current === undefined)
|
|
196
383
|
return;
|
|
197
384
|
const next = status === 'running' ? 'working' : 'idle';
|
|
385
|
+
if (next === 'idle') {
|
|
386
|
+
const owned = ownedOpenTask(fresh.tasks, current.name);
|
|
387
|
+
if (owned?.attemptId === undefined)
|
|
388
|
+
parkedAttempts.delete(agent.id);
|
|
389
|
+
else
|
|
390
|
+
parkedAttempts.set(agent.id, owned.attemptId);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
parkedAttempts.delete(agent.id);
|
|
394
|
+
}
|
|
198
395
|
if (current.status === next)
|
|
199
396
|
return;
|
|
200
397
|
current.status = next;
|
package/lib/snapshot.js
CHANGED
|
@@ -19,6 +19,16 @@ function currentTaskOf(memberName, tasks) {
|
|
|
19
19
|
}
|
|
20
20
|
return '';
|
|
21
21
|
}
|
|
22
|
+
/** Compact `provider/model` route for the activity panel, or just the model. */
|
|
23
|
+
export function memberModelRoute(member) {
|
|
24
|
+
if (member === undefined)
|
|
25
|
+
return '';
|
|
26
|
+
const provider = member.provider?.trim() ?? '';
|
|
27
|
+
const model = member.model?.trim() ?? '';
|
|
28
|
+
if (provider !== '' && model !== '')
|
|
29
|
+
return `${provider}/${model}`;
|
|
30
|
+
return model;
|
|
31
|
+
}
|
|
22
32
|
/**
|
|
23
33
|
* Assemble one team snapshot from its durable files plus live activity.
|
|
24
34
|
* @param ctx - the plugin context (injects `subagents`, used for activity).
|
|
@@ -53,6 +63,10 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
53
63
|
id: member.id,
|
|
54
64
|
name: member.name,
|
|
55
65
|
role: member.role ?? '',
|
|
66
|
+
provider: member.provider?.trim() ?? '',
|
|
67
|
+
model: member.model?.trim() ?? '',
|
|
68
|
+
reasoningEffort: member.reasoningEffort?.trim() ?? '',
|
|
69
|
+
executionPrompt: member.executionPrompt ?? '',
|
|
56
70
|
status: member.status,
|
|
57
71
|
activity: options.historic === true
|
|
58
72
|
? 'idle'
|
|
@@ -62,7 +76,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
62
76
|
: activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
|
|
63
77
|
? 'idle'
|
|
64
78
|
: 'unknown')
|
|
65
|
-
: 'unknown',
|
|
79
|
+
: state.phase === 'staged' ? 'idle' : 'unknown',
|
|
66
80
|
progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
|
|
67
81
|
done,
|
|
68
82
|
total: owned.length,
|
|
@@ -77,15 +91,25 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
77
91
|
name: state.name,
|
|
78
92
|
...state.description !== undefined ? { description: state.description } : {},
|
|
79
93
|
captainSessionId: state.captainSessionId,
|
|
94
|
+
phase: state.phase ?? 'running',
|
|
95
|
+
...state.phase === 'staged'
|
|
96
|
+
? { planReviewState: state.planReviewState ?? 'awaiting_review' }
|
|
97
|
+
: {},
|
|
98
|
+
...state.halted === true ? { halted: true } : {},
|
|
80
99
|
members,
|
|
81
100
|
tasks: tasks.map((task) => ({
|
|
82
101
|
id: task.id,
|
|
83
102
|
subject: task.subject,
|
|
103
|
+
description: task.description ?? '',
|
|
84
104
|
status: task.status,
|
|
85
105
|
state: taskVisualState(task.status, task.dependencies, tasks),
|
|
86
106
|
assignee: task.assignee ?? '',
|
|
107
|
+
model: memberModelRoute(roster.find((member) => member.name === task.assignee)),
|
|
87
108
|
dependencies: task.dependencies,
|
|
88
109
|
depth: depths.get(task.id) ?? 0,
|
|
110
|
+
...task.kind === undefined ? {} : { kind: task.kind },
|
|
111
|
+
...task.round === undefined ? {} : { round: task.round },
|
|
112
|
+
...task.verdict === undefined ? {} : { verdict: task.verdict },
|
|
89
113
|
})),
|
|
90
114
|
messageCount: captainInbox.length
|
|
91
115
|
+ members.reduce((count, member) => count + member.unread, 0),
|
package/lib/state.js
CHANGED
|
@@ -16,6 +16,9 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
+
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
20
|
+
import { hasValidQualityTaskFields, isReviewPolicy } from "./quality-gates.js";
|
|
21
|
+
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
19
22
|
/** Mailbox key of the captain. */
|
|
20
23
|
export const CAPTAIN_KEY = 'captain';
|
|
21
24
|
/** A crashed live-delivery attempt becomes retryable after this interval. */
|
|
@@ -137,6 +140,18 @@ export function beginTaskAttempt(task, assignee) {
|
|
|
137
140
|
* Revoke the current worker immediately. Clearing its capability makes old
|
|
138
141
|
* updates stale; a separate handoff generation serializes async quiescence.
|
|
139
142
|
*/
|
|
143
|
+
/** Cancel one unfinished task without returning it to the ready pool. */
|
|
144
|
+
export function cancelUnfinishedTask(task, output) {
|
|
145
|
+
if (TERMINAL_TASK_STATUSES.includes(task.status))
|
|
146
|
+
return;
|
|
147
|
+
task.status = 'cancelled';
|
|
148
|
+
task.attemptId = undefined;
|
|
149
|
+
task.handoffId = undefined;
|
|
150
|
+
task.reassigning = false;
|
|
151
|
+
if (output !== undefined)
|
|
152
|
+
task.output = output;
|
|
153
|
+
task.updatedAt = Date.now();
|
|
154
|
+
}
|
|
140
155
|
export function invalidateTaskAttempt(task, nextAssignee, reassigning = false) {
|
|
141
156
|
task.attemptId = undefined;
|
|
142
157
|
task.handoffId = randomUUID();
|
|
@@ -165,10 +180,11 @@ export async function readTeam(stateRoot, teamId) {
|
|
|
165
180
|
try {
|
|
166
181
|
const raw = await readFile(join(stateRoot, teamId, 'team.json'), 'utf8');
|
|
167
182
|
const value = JSON.parse(stripLeadingBom(raw));
|
|
168
|
-
|
|
183
|
+
const team = coerceTeamState(value, teamId);
|
|
184
|
+
if (team === undefined) {
|
|
169
185
|
throw new Error(`invalid AgentTeams state in team "${teamId}"`);
|
|
170
186
|
}
|
|
171
|
-
return
|
|
187
|
+
return team;
|
|
172
188
|
}
|
|
173
189
|
catch (error) {
|
|
174
190
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
@@ -190,10 +206,11 @@ export function readTeamSync(stateRoot, teamId) {
|
|
|
190
206
|
try {
|
|
191
207
|
const raw = readFileSync(join(stateRoot, teamId, 'team.json'), 'utf8');
|
|
192
208
|
const value = JSON.parse(stripLeadingBom(raw));
|
|
193
|
-
|
|
209
|
+
const team = coerceTeamState(value, teamId);
|
|
210
|
+
if (team === undefined) {
|
|
194
211
|
throw new Error(`invalid AgentTeams state in team "${teamId}"`);
|
|
195
212
|
}
|
|
196
|
-
return
|
|
213
|
+
return team;
|
|
197
214
|
}
|
|
198
215
|
catch (error) {
|
|
199
216
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
@@ -549,14 +566,82 @@ function isTeamMember(value) {
|
|
|
549
566
|
&& isOptionalString(value['provider'])
|
|
550
567
|
&& isOptionalString(value['model'])
|
|
551
568
|
&& isOptionalString(value['reasoningEffort'])
|
|
569
|
+
&& isOptionalString(value['activeProvider'])
|
|
570
|
+
&& isOptionalString(value['activeModel'])
|
|
571
|
+
&& (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
|
|
572
|
+
&& (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
|
|
573
|
+
&& (value['fallbackActive'] === undefined || typeof value['fallbackActive'] === 'boolean')
|
|
552
574
|
&& isFiniteNumber(value['joinedAt'])
|
|
553
575
|
&& (value['status'] === 'idle' || value['status'] === 'working' || value['status'] === 'removed');
|
|
554
576
|
}
|
|
555
577
|
/** Validate one task record at the durable JSON boundary. */
|
|
556
|
-
function
|
|
578
|
+
function isTeamProfileSnapshot(value) {
|
|
579
|
+
return isRecord(value)
|
|
580
|
+
&& typeof value['name'] === 'string'
|
|
581
|
+
&& value['name'].trim() !== ''
|
|
582
|
+
&& isOptionalString(value['description'])
|
|
583
|
+
&& isOptionalString(value['protocol'])
|
|
584
|
+
&& (value['executionPrompt'] === undefined || typeof value['executionPrompt'] === 'string')
|
|
585
|
+
&& (value['fallback'] === undefined || (isRecord(value['fallback']) && typeof value['fallback']['provider'] === 'string' && typeof value['fallback']['model'] === 'string'))
|
|
586
|
+
&& (value['taskPlanning'] === undefined || value['taskPlanning'] === 'captain' || value['taskPlanning'] === 'seed')
|
|
587
|
+
&& (value['reviewPolicy'] === undefined || isReviewPolicy(value['reviewPolicy']));
|
|
588
|
+
}
|
|
589
|
+
function coerceProfileSnapshot(value) {
|
|
590
|
+
if (typeof value === 'string') {
|
|
591
|
+
const name = value.trim();
|
|
592
|
+
return name === '' ? undefined : { name };
|
|
593
|
+
}
|
|
594
|
+
if (!isRecord(value))
|
|
595
|
+
return undefined;
|
|
596
|
+
if (!isTeamProfileSnapshot(value))
|
|
597
|
+
return undefined;
|
|
598
|
+
return {
|
|
599
|
+
name: value.name.trim(),
|
|
600
|
+
...value.description === undefined ? {} : { description: value.description },
|
|
601
|
+
...value.protocol === undefined ? {} : { protocol: value.protocol },
|
|
602
|
+
...value.taskPlanning === undefined ? {} : { taskPlanning: value.taskPlanning },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function coerceTeamState(value, expectedId) {
|
|
606
|
+
if (!isRecord(value))
|
|
607
|
+
return undefined;
|
|
608
|
+
if (value['profile'] !== undefined && !isTeamProfileSnapshot(value['profile']) && typeof value['profile'] !== 'string') {
|
|
609
|
+
const next = { ...value };
|
|
610
|
+
delete next['profile'];
|
|
611
|
+
value = next;
|
|
612
|
+
}
|
|
613
|
+
else if (typeof value['profile'] === 'string') {
|
|
614
|
+
const upgraded = coerceProfileSnapshot(value['profile']);
|
|
615
|
+
value = upgraded === undefined
|
|
616
|
+
? (() => {
|
|
617
|
+
const next = { ...value };
|
|
618
|
+
delete next['profile'];
|
|
619
|
+
return next;
|
|
620
|
+
})()
|
|
621
|
+
: { ...value, profile: upgraded };
|
|
622
|
+
}
|
|
623
|
+
if (!isRecord(value) || !Array.isArray(value['tasks'])) {
|
|
624
|
+
return isTeamState(value, expectedId) ? value : undefined;
|
|
625
|
+
}
|
|
626
|
+
const tasks = value['tasks'].map((task) => {
|
|
627
|
+
if (!isRecord(task))
|
|
628
|
+
return task;
|
|
629
|
+
if (task['profileSeedId'] !== undefined && (typeof task['profileSeedId'] !== 'string' || task['profileSeedId'].trim() === '')) {
|
|
630
|
+
const next = { ...task };
|
|
631
|
+
delete next['profileSeedId'];
|
|
632
|
+
return next;
|
|
633
|
+
}
|
|
634
|
+
return task;
|
|
635
|
+
});
|
|
636
|
+
const coerced = { ...value, tasks };
|
|
637
|
+
return isTeamState(coerced, expectedId) ? coerced : undefined;
|
|
638
|
+
}
|
|
639
|
+
export function isTeamTask(value) {
|
|
557
640
|
if (!isRecord(value))
|
|
558
641
|
return false;
|
|
559
642
|
return typeof value['id'] === 'string'
|
|
643
|
+
&& isOptionalString(value['profileSeedId'])
|
|
644
|
+
&& (value['profileSeedId'] === undefined || value['profileSeedId'].trim() !== '')
|
|
560
645
|
&& typeof value['subject'] === 'string'
|
|
561
646
|
&& isOptionalString(value['description'])
|
|
562
647
|
&& (value['status'] === 'pending'
|
|
@@ -575,7 +660,8 @@ function isTeamTask(value) {
|
|
|
575
660
|
&& isOptionalString(value['handoffId'])
|
|
576
661
|
&& (value['reassigning'] === undefined || typeof value['reassigning'] === 'boolean')
|
|
577
662
|
&& isFiniteNumber(value['createdAt'])
|
|
578
|
-
&& isFiniteNumber(value['updatedAt'])
|
|
663
|
+
&& isFiniteNumber(value['updatedAt'])
|
|
664
|
+
&& hasValidQualityTaskFields(value);
|
|
579
665
|
}
|
|
580
666
|
/** Validate the full team record before it can participate in authorization. */
|
|
581
667
|
function isTeamState(value, expectedId) {
|
|
@@ -585,6 +671,7 @@ function isTeamState(value, expectedId) {
|
|
|
585
671
|
&& typeof value['name'] === 'string'
|
|
586
672
|
&& value['name'].trim() !== ''
|
|
587
673
|
&& isOptionalString(value['description'])
|
|
674
|
+
&& (value['profile'] === undefined || isTeamProfileSnapshot(value['profile']))
|
|
588
675
|
&& typeof value['captainSessionId'] === 'string'
|
|
589
676
|
&& value['captainSessionId'] !== ''
|
|
590
677
|
&& isFiniteNumber(value['createdAt'])
|
|
@@ -593,18 +680,32 @@ function isTeamState(value, expectedId) {
|
|
|
593
680
|
&& Array.isArray(value['tasks'])
|
|
594
681
|
&& value['tasks'].every(isTeamTask)
|
|
595
682
|
&& Number.isSafeInteger(value['taskSeq'])
|
|
596
|
-
&& value['taskSeq'] >= 0
|
|
683
|
+
&& value['taskSeq'] >= 0
|
|
684
|
+
&& (value['phase'] === undefined || value['phase'] === 'staged' || value['phase'] === 'running')
|
|
685
|
+
&& (value['planReviewState'] === undefined
|
|
686
|
+
|| value['planReviewState'] === 'awaiting_review'
|
|
687
|
+
|| value['planReviewState'] === 'awaiting_feedback')
|
|
688
|
+
&& (value['approvedAt'] === undefined || isFiniteNumber(value['approvedAt']))
|
|
689
|
+
&& (value['halted'] === undefined || typeof value['halted'] === 'boolean')
|
|
690
|
+
&& (value['haltedAt'] === undefined || isFiniteNumber(value['haltedAt']))
|
|
691
|
+
&& (value['reviewPolicy'] === undefined || isReviewPolicy(value['reviewPolicy']))
|
|
692
|
+
&& (value['escalated'] === undefined || typeof value['escalated'] === 'boolean');
|
|
597
693
|
if (!validShape)
|
|
598
694
|
return false;
|
|
599
695
|
const members = value['members'];
|
|
600
696
|
const tasks = value['tasks'];
|
|
601
697
|
const memberIds = new Set();
|
|
602
698
|
const memberKeys = new Set();
|
|
699
|
+
const staged = value['phase'] === 'staged';
|
|
603
700
|
for (const member of members) {
|
|
604
701
|
const key = sanitizeKey(member.name);
|
|
605
|
-
if (member.id === '' || key === CAPTAIN_KEY ||
|
|
702
|
+
if ((!staged && member.id === '') || key === CAPTAIN_KEY || memberKeys.has(key))
|
|
606
703
|
return false;
|
|
607
|
-
|
|
704
|
+
if (member.id !== '') {
|
|
705
|
+
if (memberIds.has(member.id))
|
|
706
|
+
return false;
|
|
707
|
+
memberIds.add(member.id);
|
|
708
|
+
}
|
|
608
709
|
memberKeys.add(key);
|
|
609
710
|
}
|
|
610
711
|
const taskIds = new Set();
|
|
@@ -739,11 +840,16 @@ export async function listArchivedTeamIds(stateRoot) {
|
|
|
739
840
|
}
|
|
740
841
|
/**
|
|
741
842
|
* The visual state of one task: `running` while in_progress, `completed`
|
|
742
|
-
* when done, `
|
|
843
|
+
* when done, `failed`/`cancelled` when terminal without success, `blocked`
|
|
844
|
+
* while any dependency is unfinished, else `open`.
|
|
743
845
|
*/
|
|
744
846
|
export function taskVisualState(status, dependencies, tasks) {
|
|
745
847
|
if (status === 'completed')
|
|
746
848
|
return 'completed';
|
|
849
|
+
if (status === 'failed')
|
|
850
|
+
return 'failed';
|
|
851
|
+
if (status === 'cancelled')
|
|
852
|
+
return 'cancelled';
|
|
747
853
|
if (status === 'in_progress')
|
|
748
854
|
return 'running';
|
|
749
855
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|