@nanmicoder/dsh-agent-teams 0.1.13 → 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 +41 -5
- package/README_ZH.md +18 -5
- 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 +1 -0
- package/lib/client/index.js +2 -2
- package/lib/client/locales.js +224 -2
- package/lib/client.js +1775 -241
- package/lib/client.js.map +1 -1
- package/lib/command.js +116 -99
- package/lib/index.js +285 -13
- package/lib/members.js +137 -16
- package/lib/profiles.js +572 -0
- package/lib/quality-gates.js +777 -0
- package/lib/scheduler.js +167 -8
- 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 +14 -1
- 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 +44 -0
- 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.14.md +68 -0
package/lib/scheduler.js
CHANGED
|
@@ -13,7 +13,81 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { deliverToMember } from "./members.js";
|
|
16
|
-
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
|
+
}
|
|
17
91
|
function stateRootOf(workspace, config) {
|
|
18
92
|
return join(workspace, config.stateDir);
|
|
19
93
|
}
|
|
@@ -43,15 +117,54 @@ function nextReadyTask(tasks, memberName) {
|
|
|
43
117
|
return ready.find(task => task.assignee === memberName)
|
|
44
118
|
?? ready.find(task => task.assignee === undefined);
|
|
45
119
|
}
|
|
46
|
-
function assignmentPrompt(ticket, stateDir, teamId) {
|
|
120
|
+
export function assignmentPrompt(ticket, stateDir, teamId) {
|
|
47
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
|
+
: '';
|
|
48
143
|
return `AgentTeams automatic task assignment from the shared task list.
|
|
49
144
|
|
|
50
|
-
|
|
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}
|
|
51
163
|
Attempt: ${ticket.attempt}
|
|
52
164
|
Attempt id: ${ticket.attemptId}
|
|
53
165
|
|
|
54
|
-
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.
|
|
55
168
|
|
|
56
169
|
State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
|
|
57
170
|
}
|
|
@@ -92,7 +205,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
92
205
|
async kickTeam(workspace, teamId, suppliedCaptain) {
|
|
93
206
|
const stateRoot = stateRootOf(workspace, config);
|
|
94
207
|
const team = await readTeam(stateRoot, teamId);
|
|
95
|
-
if (team === undefined)
|
|
208
|
+
if (team === undefined || team.halted === true || team.phase === 'staged')
|
|
96
209
|
return;
|
|
97
210
|
const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
|
|
98
211
|
if (captain === undefined)
|
|
@@ -108,7 +221,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
108
221
|
const queueKey = memberQueueKey(stateRoot, teamId, memberName);
|
|
109
222
|
await serializeMember(queueKey, async () => {
|
|
110
223
|
let team = await readTeam(stateRoot, teamId);
|
|
111
|
-
if (team === undefined)
|
|
224
|
+
if (team === undefined || team.halted === true || team.phase === 'staged')
|
|
112
225
|
return;
|
|
113
226
|
const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
|
|
114
227
|
if (captain === undefined)
|
|
@@ -132,7 +245,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
132
245
|
}
|
|
133
246
|
const ticket = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
134
247
|
const fresh = await readTeam(stateRoot, team.id);
|
|
135
|
-
if (fresh === undefined)
|
|
248
|
+
if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
|
|
136
249
|
return undefined;
|
|
137
250
|
const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
138
251
|
if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
|
|
@@ -163,6 +276,8 @@ export function installTeamScheduler(ctx, config) {
|
|
|
163
276
|
parkedAttempts.delete(currentMember.id);
|
|
164
277
|
currentMember.status = 'working';
|
|
165
278
|
await writeTeam(stateRoot, fresh);
|
|
279
|
+
const profileSeedId = taskProfileSeedId(task);
|
|
280
|
+
const protocol = teamProfileProtocol(fresh);
|
|
166
281
|
return {
|
|
167
282
|
taskId: task.id,
|
|
168
283
|
memberName: currentMember.name,
|
|
@@ -172,6 +287,21 @@ export function installTeamScheduler(ctx, config) {
|
|
|
172
287
|
previousAssignee,
|
|
173
288
|
subject: task.subject,
|
|
174
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)),
|
|
175
305
|
};
|
|
176
306
|
});
|
|
177
307
|
if (ticket === undefined)
|
|
@@ -210,8 +340,37 @@ export function installTeamScheduler(ctx, config) {
|
|
|
210
340
|
parkedAttempts.delete(agent.id);
|
|
211
341
|
return;
|
|
212
342
|
}
|
|
213
|
-
if (located.captainSessionId === agent.id)
|
|
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);
|
|
214
372
|
return;
|
|
373
|
+
}
|
|
215
374
|
const member = located.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
|
|
216
375
|
if (member === undefined) {
|
|
217
376
|
parkedAttempts.delete(agent.id);
|
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]));
|