@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.
Files changed (39) hide show
  1. package/README.md +43 -7
  2. package/README_ZH.md +20 -7
  3. package/lib/client/ActivityPanel.js +219 -50
  4. package/lib/client/StagingPlanEditor.js +493 -0
  5. package/lib/client/activity-model.js +71 -0
  6. package/lib/client/activity-monitor.js +39 -13
  7. package/lib/client/index.js +2 -2
  8. package/lib/client/locales.js +224 -2
  9. package/lib/client.js +1808 -250
  10. package/lib/client.js.map +1 -1
  11. package/lib/command.js +116 -99
  12. package/lib/index.js +286 -14
  13. package/lib/members.js +137 -16
  14. package/lib/profiles.js +572 -0
  15. package/lib/quality-gates.js +777 -0
  16. package/lib/scheduler.js +215 -18
  17. package/lib/snapshot.js +25 -1
  18. package/lib/state.js +116 -10
  19. package/lib/tools.js +1230 -38
  20. package/lib/types/client/ActivityPanel.d.ts +3 -1
  21. package/lib/types/client/StagingPlanEditor.d.ts +17 -0
  22. package/lib/types/client/activity-model.d.ts +67 -0
  23. package/lib/types/client/activity-monitor.d.ts +32 -7
  24. package/lib/types/client/locales.d.ts +222 -0
  25. package/lib/types/command.d.ts +11 -56
  26. package/lib/types/event-types.d.ts +35 -1
  27. package/lib/types/index.d.ts +9 -0
  28. package/lib/types/members.d.ts +48 -3
  29. package/lib/types/profiles.d.ts +124 -0
  30. package/lib/types/quality-gates.d.ts +148 -0
  31. package/lib/types/scheduler.d.ts +48 -1
  32. package/lib/types/snapshot.d.ts +18 -1
  33. package/lib/types/state.d.ts +8 -3
  34. package/lib/types/tools.d.ts +73 -9
  35. package/lib/types/types.d.ts +118 -0
  36. package/lib/types.js +11 -0
  37. package/package.json +10 -4
  38. package/release-notes/v0.1.13.md +60 -0
  39. 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.agents.get(member.id);
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
- Task: ${ticket.taskId} ${ticket.subject}${description}
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. 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.
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 = `${stateRoot}\u0000${teamId}\u0000${memberName}`;
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
- // 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);
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 || located.captainSessionId === agent.id)
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
- if (!isTeamState(value, teamId)) {
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 value;
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
- if (!isTeamState(value, teamId)) {
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 value;
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 isTeamTask(value) {
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 || memberIds.has(member.id) || memberKeys.has(key))
702
+ if ((!staged && member.id === '') || key === CAPTAIN_KEY || memberKeys.has(key))
606
703
  return false;
607
- memberIds.add(member.id);
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, `blocked` while any dependency is unfinished, else `open`.
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]));