@nanmicoder/dsh-agent-teams 0.1.15 → 0.1.16-rc.3
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 +24 -44
- package/README_ZH.md +24 -44
- package/compatibility.json +10 -0
- package/lib/capabilities.js +106 -0
- package/lib/client/ActivityPanel.js +2 -2
- package/lib/client/locales.js +0 -2
- package/lib/client.js +30 -32
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -3
- package/lib/harness-compat.js +159 -0
- package/lib/index.js +47 -75
- package/lib/members.js +185 -73
- package/lib/profiles.js +1 -1
- package/lib/quality-gates.js +45 -0
- package/lib/scheduler.js +37 -12
- package/lib/state.js +44 -11
- package/lib/tool-names.js +11 -0
- package/lib/tools.js +81 -64
- package/lib/types/capabilities.d.ts +14 -0
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/harness-compat.d.ts +22 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/members.d.ts +21 -5
- package/lib/types/quality-gates.d.ts +8 -0
- package/lib/types/scheduler.d.ts +7 -1
- package/lib/types/state.d.ts +8 -2
- package/lib/types/tool-names.d.ts +4 -0
- package/lib/types/tools.d.ts +4 -1
- package/lib/types/web-routes.d.ts +6 -0
- package/lib/web-routes.js +60 -0
- package/package.json +69 -58
- package/release-notes/v0.1.11.md +1 -1
- package/release-notes/v0.1.16-rc.1.md +33 -0
- package/release-notes/v0.1.16-rc.2.md +33 -0
- package/release-notes/v0.1.16-rc.3.md +33 -0
- package/scripts/compatibility.mjs +90 -0
- package/scripts/doctor.mjs +126 -0
package/lib/members.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Members are durable continuable subagents of the captain, so a member keeps
|
|
6
6
|
* its conversation across turns and across harness restarts: the captain
|
|
7
|
-
*
|
|
7
|
+
* queues its next turn through {@link deliverToMember}, it works through its turn
|
|
8
8
|
* (updating team state through the `agent_teams_*` tools), and becomes idle
|
|
9
9
|
* again. Its final assistant message is not readable programmatically, so the
|
|
10
10
|
* member persists its report into the captain's mailbox and the task records,
|
|
@@ -13,23 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
15
15
|
// Declaration merge only: makes ctx.subagents visible.
|
|
16
|
-
import { foldSubagentDescriptor
|
|
17
|
-
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
16
|
+
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent';
|
|
17
|
+
import { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
|
|
20
|
+
import { acknowledgeMailbox, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
|
|
21
|
+
import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
20
22
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
23
|
+
import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
|
|
21
24
|
/** Persona snapshot of a profile protocol; the full text lives on team.json. */
|
|
22
25
|
export const PERSONA_PROTOCOL_MAX_CHARS = 400;
|
|
23
26
|
/** Captain-only AgentTeams tools hidden from newly spawned members. */
|
|
24
|
-
const MEMBER_DENIED_TOOLS =
|
|
25
|
-
'agent_teams_create',
|
|
26
|
-
'agent_teams_add_member',
|
|
27
|
-
'agent_teams_remove_member',
|
|
28
|
-
'agent_teams_reassign_task',
|
|
29
|
-
'agent_teams_create_task',
|
|
30
|
-
'agent_teams_resume',
|
|
31
|
-
'agent_teams_delete',
|
|
32
|
-
];
|
|
27
|
+
const MEMBER_DENIED_TOOLS = CAPTAIN_TOOL_NAMES;
|
|
33
28
|
/**
|
|
34
29
|
* Restore the SessionId brand on a value that round-tripped through the
|
|
35
30
|
* durable team file. The brand is erased by JSON serialization; the value
|
|
@@ -74,6 +69,81 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
|
|
|
74
69
|
}
|
|
75
70
|
return { retry: true, switched: true, selection: fallback };
|
|
76
71
|
}
|
|
72
|
+
/** Deliver a durable member report to the live captain at its next model step. */
|
|
73
|
+
export function steerCaptainReport(captain, from, content) {
|
|
74
|
+
try {
|
|
75
|
+
captain.steer(createUserMessage({
|
|
76
|
+
content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
|
|
77
|
+
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
78
|
+
}));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Record a final turn failure, never an intermediate request retry. */
|
|
86
|
+
export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, fallbackSession, observed) {
|
|
87
|
+
const summary = `${failure.message} (code ${failure.code})`;
|
|
88
|
+
const lockKey = `team:${stateRoot}:${teamId}`;
|
|
89
|
+
const prepared = await withTeamLock(lockKey, async () => {
|
|
90
|
+
const team = await readTeam(stateRoot, teamId);
|
|
91
|
+
if (team === undefined || team.halted === true || team.captainSessionId !== observed.captainSessionId)
|
|
92
|
+
return;
|
|
93
|
+
const member = team.members.find(candidate => candidate.name === memberName
|
|
94
|
+
&& candidate.id === observed.memberId && candidate.status !== 'removed');
|
|
95
|
+
if (member === undefined)
|
|
96
|
+
return;
|
|
97
|
+
const task = team.tasks.find(candidate => candidate.assignee === memberName
|
|
98
|
+
&& (candidate.status === 'claimed' || candidate.status === 'in_progress'));
|
|
99
|
+
// A reassign, completion, or recreated member may win the lock while the
|
|
100
|
+
// final error is queued. Only the capability observed at that event may fail.
|
|
101
|
+
if (task?.id !== observed.task?.id || task?.attemptId !== observed.task?.attemptId
|
|
102
|
+
|| task?.attempt !== observed.task?.attempt)
|
|
103
|
+
return;
|
|
104
|
+
if (task === undefined && member.status !== 'working')
|
|
105
|
+
return;
|
|
106
|
+
if (task !== undefined) {
|
|
107
|
+
task.status = 'failed';
|
|
108
|
+
task.output = summary;
|
|
109
|
+
task.updatedAt = Date.now();
|
|
110
|
+
}
|
|
111
|
+
if (ctx.agents.get(brandedSessionId(member.id))?.status !== 'running')
|
|
112
|
+
member.status = 'idle';
|
|
113
|
+
const message = {
|
|
114
|
+
...createMessage(memberName, CAPTAIN_KEY, task === undefined
|
|
115
|
+
? `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. No open attempt was owned.`
|
|
116
|
+
: `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. Task ${task.id} ("${task.subject}") was marked failed; reassign it or retry when ready.`),
|
|
117
|
+
deliveryClaimedAt: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
await writeTeam(stateRoot, team);
|
|
120
|
+
await appendMailbox(stateRoot, team.id, CAPTAIN_KEY, message);
|
|
121
|
+
if (task !== undefined) {
|
|
122
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 'agent-teams/task-updated', {
|
|
123
|
+
teamId, taskId: task.id, status: task.status, assignee: memberName, output: task.output,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 'agent-teams/message-sent', {
|
|
127
|
+
teamId: team.id,
|
|
128
|
+
messageId: message.id,
|
|
129
|
+
from: memberName,
|
|
130
|
+
to: CAPTAIN_KEY,
|
|
131
|
+
content: message.content,
|
|
132
|
+
ts: message.ts,
|
|
133
|
+
});
|
|
134
|
+
return { captainSessionId: team.captainSessionId, message };
|
|
135
|
+
});
|
|
136
|
+
if (prepared === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
// Use the same lease/acknowledgment contract as send_message, outside the
|
|
139
|
+
// team lock: steering can synchronously start another agent turn.
|
|
140
|
+
const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
|
|
141
|
+
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
|
|
142
|
+
await withTeamLock(lockKey, () => delivered
|
|
143
|
+
? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
|
|
144
|
+
: releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
77
147
|
async function updateFallbackState(stateRoot, teamId, memberName, fallback, ctx) {
|
|
78
148
|
await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
|
|
79
149
|
const team = await readTeam(stateRoot, teamId);
|
|
@@ -99,12 +169,14 @@ function selectionFromMember(member) {
|
|
|
99
169
|
const model = (member.activeModel ?? member.model).trim();
|
|
100
170
|
if (provider === '' || model === '')
|
|
101
171
|
return undefined;
|
|
102
|
-
|
|
172
|
+
// Effort ids belong to the original model. After a fallback, its own
|
|
173
|
+
// provider default remains authoritative, including after cold recovery.
|
|
174
|
+
const reasoningEffort = member.fallbackActive === true ? undefined : member.reasoningEffort?.trim();
|
|
103
175
|
return {
|
|
104
176
|
provider,
|
|
105
177
|
model,
|
|
106
178
|
...reasoningEffort === undefined || reasoningEffort === '' ? {} : { reasoningEffort },
|
|
107
|
-
...member.fallback === undefined ? {} : { fallback: member.fallback },
|
|
179
|
+
...member.fallback === undefined || member.fallbackActive === true ? {} : { fallback: member.fallback },
|
|
108
180
|
};
|
|
109
181
|
}
|
|
110
182
|
function modelSelection(selection) {
|
|
@@ -189,76 +261,128 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
|
|
|
189
261
|
* record. Legacy members without a complete saved route retain Harness's
|
|
190
262
|
* descriptor provider/model behavior.
|
|
191
263
|
*/
|
|
192
|
-
export function installMemberSelectionRuntime(ctx, stateDir) {
|
|
264
|
+
export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
|
|
193
265
|
const pending = new Map();
|
|
194
|
-
ctx
|
|
266
|
+
installContinuableMemberSetup(ctx, (childCtx) => {
|
|
195
267
|
const child = childCtx.agent;
|
|
196
268
|
if (child === undefined)
|
|
197
269
|
return () => undefined;
|
|
198
|
-
const
|
|
199
|
-
const descriptor = foldSubagentDescriptor(suffix);
|
|
270
|
+
const descriptor = foldSubagentDescriptor(sessionOwnEvents(child.session));
|
|
200
271
|
if (descriptor?.mode !== 'continuable' || !descriptor.label.startsWith(MEMBER_LABEL_PREFIX)) {
|
|
201
272
|
return () => undefined;
|
|
202
273
|
}
|
|
203
274
|
const parentSessionId = child.session.header.parentSession;
|
|
204
275
|
if (parentSessionId === undefined)
|
|
205
276
|
return () => undefined;
|
|
277
|
+
const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
|
|
278
|
+
const separator = identity.indexOf(':');
|
|
279
|
+
if (separator < 1 || separator === identity.length - 1)
|
|
280
|
+
return () => undefined;
|
|
281
|
+
const teamId = identity.slice(0, separator);
|
|
282
|
+
const memberName = identity.slice(separator + 1);
|
|
283
|
+
const workspace = child.session.header.cwd ?? process.cwd();
|
|
284
|
+
const stateRoot = join(workspace, stateDir);
|
|
206
285
|
const key = pendingSelectionKey(parentSessionId, descriptor.label);
|
|
207
286
|
let selection = pending.get(key);
|
|
208
287
|
if (selection === undefined) {
|
|
209
|
-
const
|
|
210
|
-
const separator = identity.indexOf(':');
|
|
211
|
-
if (separator < 1 || separator === identity.length - 1)
|
|
212
|
-
return () => undefined;
|
|
213
|
-
const teamId = identity.slice(0, separator);
|
|
214
|
-
const memberName = identity.slice(separator + 1);
|
|
215
|
-
const workspace = child.session.header.cwd ?? process.cwd();
|
|
216
|
-
const team = readTeamSync(join(workspace, stateDir), teamId);
|
|
288
|
+
const team = readTeamSync(stateRoot, teamId);
|
|
217
289
|
if (team?.captainSessionId !== parentSessionId)
|
|
218
290
|
return () => undefined;
|
|
219
291
|
const durableMember = team.members.find(member => member.name === memberName);
|
|
220
292
|
selection = selectionFromMember(durableMember);
|
|
221
|
-
|
|
222
|
-
// Harness descriptor still restores provider/model, so leave it alone.
|
|
223
|
-
if (selection === undefined)
|
|
224
|
-
return () => undefined;
|
|
225
|
-
if (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model) {
|
|
293
|
+
if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
|
|
226
294
|
throw new Error(`agent-teams: saved model route for member "${memberName}" does not match its subagent descriptor`);
|
|
227
295
|
}
|
|
228
296
|
}
|
|
297
|
+
let lastFailedTurn;
|
|
298
|
+
// Harness emits agent/error only after request recovery is exhausted. In
|
|
299
|
+
// particular, llm-retry's "always" policy calls downstream request-error
|
|
300
|
+
// listeners before it retries; that waterfall cannot declare a turn dead.
|
|
301
|
+
const disposeFailure = childCtx.on('agent/error', async (payload) => {
|
|
302
|
+
if (payload.agent.id !== child.id || payload.turn === lastFailedTurn)
|
|
303
|
+
return;
|
|
304
|
+
lastFailedTurn = payload.turn;
|
|
305
|
+
try {
|
|
306
|
+
// Capture the attempt synchronously at the event, before any lock wait
|
|
307
|
+
// can let a captain reassign it or replace the member/team generation.
|
|
308
|
+
const snapshot = readTeamSync(stateRoot, teamId);
|
|
309
|
+
if (snapshot?.captainSessionId !== parentSessionId)
|
|
310
|
+
return;
|
|
311
|
+
const member = snapshot.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
|
|
312
|
+
if (member === undefined)
|
|
313
|
+
return;
|
|
314
|
+
const task = snapshot.tasks.find(item => item.assignee === memberName
|
|
315
|
+
&& (item.status === 'claimed' || item.status === 'in_progress'));
|
|
316
|
+
const failure = payload.error instanceof LlmError ? payload.error.failure : {
|
|
317
|
+
code: 'UNKNOWN',
|
|
318
|
+
message: payload.error instanceof Error ? payload.error.message : String(payload.error),
|
|
319
|
+
};
|
|
320
|
+
const recorded = await failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, child.session, {
|
|
321
|
+
captainSessionId: parentSessionId, memberId: child.id, task,
|
|
322
|
+
});
|
|
323
|
+
if (!recorded)
|
|
324
|
+
return;
|
|
325
|
+
// The final-error event precedes driver quiescence. Observe the real
|
|
326
|
+
// lifecycle and kick explicitly even if its idle event was missed.
|
|
327
|
+
// Never force an active Agent's status to idle or retry the failed task.
|
|
328
|
+
await child.whenIdle();
|
|
329
|
+
await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
|
|
330
|
+
const team = await readTeam(stateRoot, teamId);
|
|
331
|
+
const current = team?.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
|
|
332
|
+
if (team?.captainSessionId !== parentSessionId || current === undefined || child.status !== 'idle')
|
|
333
|
+
return;
|
|
334
|
+
if (current.status !== 'idle') {
|
|
335
|
+
current.status = 'idle';
|
|
336
|
+
await writeTeam(stateRoot, team);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
await onFailureSettled?.(workspace, teamId, memberName);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
ctx.logger.warn(`agent-teams: failed to record member turn failure: ${String(error)}`);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
// Legacy teams still need failure reporting even without a saved route.
|
|
346
|
+
if (selection === undefined)
|
|
347
|
+
return disposeFailure;
|
|
229
348
|
const selectionRef = { current: modelSelection(selection), assembled: undefined };
|
|
230
349
|
const disposeSelection = installModelSelection(childCtx, selectionRef);
|
|
231
350
|
const fallback = selection.fallback;
|
|
232
|
-
if (fallback === undefined)
|
|
233
|
-
return disposeSelection;
|
|
234
351
|
let switched = false;
|
|
235
|
-
const disposeFallback = childCtx.on('agent/request-error', async (payload) => {
|
|
236
|
-
if (payload.agent.id !== child.id)
|
|
237
|
-
return
|
|
352
|
+
const disposeFallback = childCtx.on('agent/request-error', async (payload, next) => {
|
|
353
|
+
if (payload.agent.id !== child.id || payload.signal.aborted)
|
|
354
|
+
return next();
|
|
238
355
|
const transition = selectFallbackRoute(selectionRef.current ?? { provider: selection.provider, model: selection.model }, fallback, payload.failure.code, switched);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
void updateFallbackState(join(workspace, stateDir), teamId, memberName, fallback, ctx).catch((error) => {
|
|
356
|
+
// selectFallbackRoute only retries when a fallback route exists; the
|
|
357
|
+
// explicit guard narrows that fact for the compiler.
|
|
358
|
+
if (fallback !== undefined && transition.retry) {
|
|
359
|
+
switched = transition.switched;
|
|
360
|
+
selectionRef.current = transition.selection;
|
|
361
|
+
// Request recovery repeats buildRequest inside the current step; it
|
|
362
|
+
// does not re-run prompt assembly. Override that captured route too,
|
|
363
|
+
// otherwise the authorized retry would hit the failed primary again.
|
|
364
|
+
selectionRef.assembled = transition.selection;
|
|
365
|
+
await updateFallbackState(stateRoot, teamId, memberName, fallback, ctx).catch((error) => {
|
|
250
366
|
ctx.logger.warn(`agent-teams: failed to persist fallback route: ${String(error)}`);
|
|
251
367
|
});
|
|
368
|
+
ctx.logger.warn(`agent-teams: member ${child.id} switching to fallback ${fallback.provider}/${fallback.model} after ${payload.failure.code}`);
|
|
369
|
+
return { kind: 'retry' };
|
|
252
370
|
}
|
|
253
|
-
|
|
254
|
-
return { kind: 'retry' };
|
|
371
|
+
return next();
|
|
255
372
|
});
|
|
256
373
|
return () => {
|
|
257
374
|
disposeFallback();
|
|
258
375
|
disposeSelection();
|
|
376
|
+
disposeFailure();
|
|
259
377
|
};
|
|
260
378
|
});
|
|
261
379
|
return {
|
|
380
|
+
isPendingMember(agent) {
|
|
381
|
+
const parent = agent.session.header.parentSession;
|
|
382
|
+
const descriptor = foldSubagentDescriptor(sessionOwnEvents(agent.session));
|
|
383
|
+
return parent !== undefined && descriptor?.mode === 'continuable'
|
|
384
|
+
&& pending.has(pendingSelectionKey(parent, descriptor.label));
|
|
385
|
+
},
|
|
262
386
|
async withPending(parentSessionId, label, selection, operation) {
|
|
263
387
|
const key = pendingSelectionKey(parentSessionId, label);
|
|
264
388
|
if (pending.has(key)) {
|
|
@@ -387,6 +511,9 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
387
511
|
agentOptions: {
|
|
388
512
|
provider: llmSelection.provider,
|
|
389
513
|
model: llmSelection.model,
|
|
514
|
+
...llmSelection.reasoningEffort === undefined
|
|
515
|
+
? {}
|
|
516
|
+
: { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
|
|
390
517
|
},
|
|
391
518
|
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
|
392
519
|
},
|
|
@@ -413,14 +540,11 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
413
540
|
*/
|
|
414
541
|
export async function deliverToMember(ctx, captain, childId, text, signal) {
|
|
415
542
|
try {
|
|
416
|
-
await ctx.subagents
|
|
417
|
-
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
418
|
-
signal,
|
|
419
|
-
});
|
|
543
|
+
await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), [{ type: 'text', text }], signal);
|
|
420
544
|
return true;
|
|
421
545
|
}
|
|
422
546
|
catch (error) {
|
|
423
|
-
ctx.logger.warn(`agent-teams:
|
|
547
|
+
ctx.logger.warn(`agent-teams: prompt delivery to member ${childId} failed: ${String(error)}`);
|
|
424
548
|
return false;
|
|
425
549
|
}
|
|
426
550
|
}
|
|
@@ -444,30 +568,18 @@ export function interruptMember(ctx, captain, childId) {
|
|
|
444
568
|
*
|
|
445
569
|
* Upstream `interrupt()` deliberately preserves continuable sessions and the
|
|
446
570
|
* upstream seam exposes no targeted forget/retire method. The durable
|
|
447
|
-
* AgentTeams index therefore rejects
|
|
571
|
+
* AgentTeams index therefore rejects every inbox delivery before it can cold-resume a
|
|
448
572
|
* retired member. Catalog rows deliberately remain discoverable: Harness rc.8
|
|
449
573
|
* uses the direct-child catalog to authorize historical transcript reads and
|
|
450
574
|
* `openSubagent()`, so filtering those rows would make an archived member's
|
|
451
575
|
* persisted conversation inaccessible. Exact ids keep unrelated subagents
|
|
452
|
-
* untouched while the
|
|
576
|
+
* untouched while the delivery boundary still prevents further model turns.
|
|
453
577
|
*/
|
|
454
578
|
export function installRetiredMemberGuard(ctx, stateDir) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const retired = await readRetiredMemberIds(join(parent.session.header.cwd ?? process.cwd(), stateDir));
|
|
460
|
-
if (retired.has(childId)) {
|
|
461
|
-
throw new SubagentError(`AgentTeams member "${childId}" was retired and cannot be resumed`, 'NOT_RESUMABLE');
|
|
462
|
-
}
|
|
463
|
-
return followup.call(runtime, parent, childId, content, options);
|
|
464
|
-
};
|
|
465
|
-
runtime.followup = guardedFollowup;
|
|
466
|
-
return () => {
|
|
467
|
-
if (runtime.followup === guardedFollowup)
|
|
468
|
-
runtime.followup = followup;
|
|
469
|
-
};
|
|
470
|
-
}, 'agent-teams: retired member guard');
|
|
579
|
+
guardSubagentDelivery(ctx, async (parent, childId) => {
|
|
580
|
+
const retired = await readRetiredMemberIds(join(parent.session.header.cwd ?? process.cwd(), stateDir));
|
|
581
|
+
return retired.has(childId);
|
|
582
|
+
});
|
|
471
583
|
}
|
|
472
584
|
/**
|
|
473
585
|
* Snapshot the real driver activity for durable member ids.
|
package/lib/profiles.js
CHANGED
|
@@ -116,7 +116,7 @@ function formatProfileListingLine(entry) {
|
|
|
116
116
|
? 'captain planning'
|
|
117
117
|
: countLabel(Array.isArray(entry.config.tasks) ? entry.config.tasks.length : 0, 'task');
|
|
118
118
|
const counts = `(${countLabel(memberCount, 'member')}, ${graph})`;
|
|
119
|
-
const summary = protocolSummary(entry.config.protocol);
|
|
119
|
+
const summary = protocolSummary(entry.config.protocol) ?? protocolSummary(entry.config.description);
|
|
120
120
|
return summary === undefined
|
|
121
121
|
? `- ${entry.name} ${counts}`
|
|
122
122
|
: `- ${entry.name} ${counts}: ${summary}`;
|
package/lib/quality-gates.js
CHANGED
|
@@ -600,6 +600,51 @@ export function isCommandResult(value) {
|
|
|
600
600
|
&& (value['exitCode'] === undefined || (Number.isSafeInteger(value['exitCode'])))
|
|
601
601
|
&& (value['evidence'] === undefined || typeof value['evidence'] === 'string');
|
|
602
602
|
}
|
|
603
|
+
// Optional fields whose persisted values must be non-empty when present
|
|
604
|
+
// (mirrors the checks in hasValidQualityTaskFields). Some models materialize
|
|
605
|
+
// optional tool parameters as "" instead of omitting them (e.g. GPT-5.6
|
|
606
|
+
// sending reviewedTaskId:"" or profile:""), which would otherwise be written
|
|
607
|
+
// to team.json and then brick the whole team state on reload.
|
|
608
|
+
const BLANK_SENSITIVE_STRING_FIELDS = ['objective', 'reviewedTaskId', 'sourceTaskId'];
|
|
609
|
+
const BLANK_SENSITIVE_STRING_LIST_FIELDS = [
|
|
610
|
+
'inScope',
|
|
611
|
+
'outOfScope',
|
|
612
|
+
'acceptance',
|
|
613
|
+
'verify',
|
|
614
|
+
'deliverables',
|
|
615
|
+
'nonGoals',
|
|
616
|
+
'changedPaths',
|
|
617
|
+
'sourceFindingIds',
|
|
618
|
+
'coverageOf',
|
|
619
|
+
];
|
|
620
|
+
/**
|
|
621
|
+
* Normalize blank optional task fields to omitted ("blank means absent").
|
|
622
|
+
* Blank string scalars are deleted; string lists have blank entries filtered
|
|
623
|
+
* out, and a list that only contained blanks is omitted entirely. Non-blank
|
|
624
|
+
* values and every other field are passed through untouched, so durable-state
|
|
625
|
+
* validation stays strict.
|
|
626
|
+
*/
|
|
627
|
+
export function normalizeBlankOptionalTaskFields(task) {
|
|
628
|
+
const next = { ...task };
|
|
629
|
+
for (const key of BLANK_SENSITIVE_STRING_FIELDS) {
|
|
630
|
+
const value = next[key];
|
|
631
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
632
|
+
delete next[key];
|
|
633
|
+
}
|
|
634
|
+
for (const key of BLANK_SENSITIVE_STRING_LIST_FIELDS) {
|
|
635
|
+
const value = next[key];
|
|
636
|
+
if (!Array.isArray(value))
|
|
637
|
+
continue;
|
|
638
|
+
const kept = value.filter((item) => !(typeof item === 'string' && item.trim() === ''));
|
|
639
|
+
if (kept.length === value.length)
|
|
640
|
+
continue;
|
|
641
|
+
if (kept.length === 0)
|
|
642
|
+
delete next[key];
|
|
643
|
+
else
|
|
644
|
+
next[key] = kept;
|
|
645
|
+
}
|
|
646
|
+
return next;
|
|
647
|
+
}
|
|
603
648
|
export function hasValidQualityTaskFields(value) {
|
|
604
649
|
if (value['kind'] !== undefined && !TASK_KINDS.includes(value['kind']))
|
|
605
650
|
return false;
|
package/lib/scheduler.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the selected durable member. A resident member that becomes idle while it
|
|
9
9
|
* still owns an open attempt is parked: only an explicit captain reassignment
|
|
10
10
|
* may rotate that capability. Automatic retry is reserved for cold recovery,
|
|
11
|
-
* when
|
|
11
|
+
* when this process has not observed the durable owner settle its open attempt.
|
|
12
12
|
* @module dsh-agent-teams/scheduler
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'node:path';
|
|
@@ -251,13 +251,11 @@ export function installTeamScheduler(ctx, config) {
|
|
|
251
251
|
if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
|
|
252
252
|
return undefined;
|
|
253
253
|
const owned = ownedOpenTask(fresh.tasks, currentMember.name);
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// durable capability (cold process recovery) or a legacy open task
|
|
260
|
-
// with no capability is retried.
|
|
254
|
+
// An idle edge observed by this scheduler parks the exact open
|
|
255
|
+
// capability. Harness may dispose its AgentHandle after settlement,
|
|
256
|
+
// so registry absence is not evidence that the owner was lost. Keep
|
|
257
|
+
// the marker sticky across later kicks; only a durable attempt that
|
|
258
|
+
// this process has not observed is eligible for one cold recovery.
|
|
261
259
|
const parkedAttemptId = parkedAttempts.get(currentMember.id);
|
|
262
260
|
const recoverOwned = owned !== undefined
|
|
263
261
|
&& (owned.attemptId === undefined || owned.attemptId !== parkedAttemptId);
|
|
@@ -272,8 +270,18 @@ export function installTeamScheduler(ctx, config) {
|
|
|
272
270
|
return undefined;
|
|
273
271
|
}
|
|
274
272
|
const previousAssignee = task.assignee;
|
|
273
|
+
const previousStatus = recoverOwned ? task.status : undefined;
|
|
274
|
+
const previousAttempt = recoverOwned ? task.attempt : undefined;
|
|
275
|
+
const previousAttemptId = recoverOwned ? task.attemptId : undefined;
|
|
275
276
|
const attemptId = beginTaskAttempt(task, currentMember.name);
|
|
276
|
-
|
|
277
|
+
// A recovered generation is parked before delivery. This makes each
|
|
278
|
+
// (member, attempt) recovery idempotent even if every status poll
|
|
279
|
+
// sees a disposed handle. Fresh pending work remains unparked so a
|
|
280
|
+
// genuinely lost first delivery can be recovered once.
|
|
281
|
+
if (recoverOwned)
|
|
282
|
+
parkedAttempts.set(currentMember.id, attemptId);
|
|
283
|
+
else
|
|
284
|
+
parkedAttempts.delete(currentMember.id);
|
|
277
285
|
currentMember.status = 'working';
|
|
278
286
|
await writeTeam(stateRoot, fresh);
|
|
279
287
|
const profileSeedId = taskProfileSeedId(task);
|
|
@@ -285,6 +293,10 @@ export function installTeamScheduler(ctx, config) {
|
|
|
285
293
|
attempt: task.attempt ?? 1,
|
|
286
294
|
attemptId,
|
|
287
295
|
previousAssignee,
|
|
296
|
+
recoveredOwned: recoverOwned,
|
|
297
|
+
...previousStatus === undefined ? {} : { previousStatus },
|
|
298
|
+
...previousAttempt === undefined ? {} : { previousAttempt },
|
|
299
|
+
...previousAttemptId === undefined ? {} : { previousAttemptId },
|
|
288
300
|
subject: task.subject,
|
|
289
301
|
description: task.description,
|
|
290
302
|
teamDescription: fresh.description,
|
|
@@ -318,9 +330,22 @@ export function installTeamScheduler(ctx, config) {
|
|
|
318
330
|
const task = fresh.tasks.find(candidate => candidate.id === ticket.taskId);
|
|
319
331
|
if (task?.attemptId !== ticket.attemptId)
|
|
320
332
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
if (ticket.recoveredOwned && ticket.previousStatus !== undefined && ticket.previousAttemptId !== undefined) {
|
|
334
|
+
// Recovery delivery failed. Restore the durable generation instead
|
|
335
|
+
// of returning it to pending, then keep it parked so later status
|
|
336
|
+
// kicks cannot spend an unbounded sequence of fresh attempts.
|
|
337
|
+
task.status = ticket.previousStatus;
|
|
338
|
+
task.assignee = ticket.previousAssignee;
|
|
339
|
+
task.attempt = ticket.previousAttempt;
|
|
340
|
+
task.attemptId = ticket.previousAttemptId;
|
|
341
|
+
parkedAttempts.set(ticket.memberId, ticket.previousAttemptId);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
task.status = 'pending';
|
|
345
|
+
task.assignee = ticket.previousAssignee;
|
|
346
|
+
task.attemptId = undefined;
|
|
347
|
+
parkedAttempts.delete(ticket.memberId);
|
|
348
|
+
}
|
|
324
349
|
task.handoffId = undefined;
|
|
325
350
|
task.reassigning = false;
|
|
326
351
|
task.updatedAt = Date.now();
|
package/lib/state.js
CHANGED
|
@@ -17,8 +17,8 @@ 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
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";
|
|
20
|
+
import { hasValidQualityTaskFields, isReviewPolicy, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
|
|
21
|
+
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
22
22
|
/** Mailbox key of the captain. */
|
|
23
23
|
export const CAPTAIN_KEY = 'captain';
|
|
24
24
|
/** A crashed live-delivery attempt becomes retryable after this interval. */
|
|
@@ -37,15 +37,29 @@ export async function withTeamLock(key, fn) {
|
|
|
37
37
|
const previous = locks.get(key) ?? Promise.resolve();
|
|
38
38
|
let release;
|
|
39
39
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
40
|
-
|
|
40
|
+
const tail = previous.then(() => gate);
|
|
41
|
+
locks.set(key, tail);
|
|
41
42
|
await previous;
|
|
42
43
|
try {
|
|
43
44
|
return await fn();
|
|
44
45
|
}
|
|
45
46
|
finally {
|
|
46
47
|
release();
|
|
48
|
+
// Drop this key's queue entry once we are still its tail, so settled
|
|
49
|
+
// teams do not leave one resolved promise chained forever (the same
|
|
50
|
+
// cleanup discipline as the scheduler's serializeMember). A successor
|
|
51
|
+
// that already appended itself owns the map slot; keep its entry.
|
|
52
|
+
if (locks.get(key) === tail)
|
|
53
|
+
locks.delete(key);
|
|
47
54
|
}
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Keys with an in-process lock queue (held or waiting), snapshot for
|
|
58
|
+
* diagnostics and leak checks. The queue promises themselves stay private.
|
|
59
|
+
*/
|
|
60
|
+
export function teamLockQueueKeys() {
|
|
61
|
+
return [...locks.keys()];
|
|
62
|
+
}
|
|
49
63
|
/** Longest key emitted before truncating and appending a digest. */
|
|
50
64
|
const MAX_KEY_LENGTH = 48;
|
|
51
65
|
/** Short stable digest, used to keep otherwise-colliding keys distinct. */
|
|
@@ -228,13 +242,27 @@ export async function writeTeam(stateRoot, state) {
|
|
|
228
242
|
await atomicWriteText(join(stateRoot, state.id, 'team.json'), JSON.stringify(state, null, 2));
|
|
229
243
|
}
|
|
230
244
|
/** Read the durable set of member session ids retired by remove/delete. */
|
|
245
|
+
function parseRetiredMemberIds(raw) {
|
|
246
|
+
const parsed = JSON.parse(stripLeadingBom(raw));
|
|
247
|
+
if (!Array.isArray(parsed) || parsed.some(value => typeof value !== 'string' || value === '')) {
|
|
248
|
+
throw new Error('invalid AgentTeams retired member index');
|
|
249
|
+
}
|
|
250
|
+
return new Set(parsed);
|
|
251
|
+
}
|
|
252
|
+
/** Synchronous role hydration before the host's first prompt assembly. */
|
|
253
|
+
export function readRetiredMemberIdsSync(stateRoot) {
|
|
254
|
+
try {
|
|
255
|
+
return parseRetiredMemberIds(readFileSync(join(stateRoot, RETIRED_MEMBERS_FILE), 'utf8'));
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code === 'ENOENT')
|
|
259
|
+
return new Set();
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
231
263
|
export async function readRetiredMemberIds(stateRoot) {
|
|
232
264
|
try {
|
|
233
|
-
|
|
234
|
-
if (!Array.isArray(parsed) || parsed.some(value => typeof value !== 'string' || value === '')) {
|
|
235
|
-
throw new Error('invalid AgentTeams retired member index');
|
|
236
|
-
}
|
|
237
|
-
return new Set(parsed);
|
|
265
|
+
return parseRetiredMemberIds(await readFile(join(stateRoot, RETIRED_MEMBERS_FILE), 'utf8'));
|
|
238
266
|
}
|
|
239
267
|
catch (error) {
|
|
240
268
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
@@ -626,12 +654,17 @@ function coerceTeamState(value, expectedId) {
|
|
|
626
654
|
const tasks = value['tasks'].map((task) => {
|
|
627
655
|
if (!isRecord(task))
|
|
628
656
|
return task;
|
|
629
|
-
|
|
630
|
-
|
|
657
|
+
// Tolerate legacy dirty records instead of bricking the whole team on
|
|
658
|
+
// reload: blank optional fields written by older builds (or by models that
|
|
659
|
+
// materialize optionals as "") are normalized to omitted, matching the
|
|
660
|
+
// profileSeedId handling below and the tool-input normalization.
|
|
661
|
+
const cleaned = normalizeBlankOptionalTaskFields(task);
|
|
662
|
+
if (cleaned['profileSeedId'] !== undefined && (typeof cleaned['profileSeedId'] !== 'string' || cleaned['profileSeedId'].trim() === '')) {
|
|
663
|
+
const next = { ...cleaned };
|
|
631
664
|
delete next['profileSeedId'];
|
|
632
665
|
return next;
|
|
633
666
|
}
|
|
634
|
-
return
|
|
667
|
+
return cleaned;
|
|
635
668
|
});
|
|
636
669
|
const coerced = { ...value, tasks };
|
|
637
670
|
return isTeamState(coerced, expectedId) ? coerced : undefined;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Stable business API names; exposure changes never rename these operations. */
|
|
2
|
+
export const TEAM_TOOL_NAMES = [
|
|
3
|
+
'agent_teams_create', 'agent_teams_approve', 'agent_teams_edit_plan',
|
|
4
|
+
'agent_teams_add_member', 'agent_teams_remove_member', 'agent_teams_create_task',
|
|
5
|
+
'agent_teams_reassign_task', 'agent_teams_claim_task', 'agent_teams_update_task',
|
|
6
|
+
'agent_teams_send_message', 'agent_teams_status', 'agent_teams_resume', 'agent_teams_delete',
|
|
7
|
+
];
|
|
8
|
+
export const MEMBER_TOOL_NAMES = [
|
|
9
|
+
'agent_teams_claim_task', 'agent_teams_update_task', 'agent_teams_send_message', 'agent_teams_status',
|
|
10
|
+
];
|
|
11
|
+
export const CAPTAIN_TOOL_NAMES = TEAM_TOOL_NAMES.filter(name => !MEMBER_TOOL_NAMES.includes(name));
|