@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/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
- * wakes it with {@link ctx.subagents.followup}, it works through its turn
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, SubagentError } from '@deepseek-ai/dsh-subagent';
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 { readRetiredMemberIds, readTeamSync, readTeam, withTeamLock, writeTeam } from "./state.js";
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
- const reasoningEffort = member.reasoningEffort?.trim();
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.subagents.registerContinuableSetup((childCtx) => {
266
+ installContinuableMemberSetup(ctx, (childCtx) => {
195
267
  const child = childCtx.agent;
196
268
  if (child === undefined)
197
269
  return () => undefined;
198
- const suffix = child.session.events.slice(child.session.header.seedLength ?? 0);
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 identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
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
- // An old team record has no provider/reasoning snapshot. Its durable
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 undefined;
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
- if (!transition.retry)
240
- return undefined;
241
- switched = transition.switched;
242
- selectionRef.current = transition.selection;
243
- const workspace = child.session.header.cwd ?? process.cwd();
244
- const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
245
- const separator = identity.indexOf(':');
246
- if (separator > 0) {
247
- const teamId = identity.slice(0, separator);
248
- const memberName = identity.slice(separator + 1);
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
- ctx.logger.warn(`agent-teams: member ${child.id} switching to fallback ${fallback.provider}/${fallback.model} after ${payload.failure.code}`);
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.followup(captain, brandedSessionId(childId), [{ type: 'text', text }], {
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: followup to member ${childId} failed: ${String(error)}`);
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 `followup()` before it can cold-resume a
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 followup boundary still prevents further model turns.
576
+ * untouched while the delivery boundary still prevents further model turns.
453
577
  */
454
578
  export function installRetiredMemberGuard(ctx, stateDir) {
455
- const runtime = ctx.subagents;
456
- ctx.effect(() => {
457
- const followup = runtime.followup;
458
- const guardedFollowup = async (parent, childId, content, options) => {
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}`;
@@ -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 the durable owner is no longer resident in the live Agent registry.
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
- // 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.
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
- parkedAttempts.delete(currentMember.id);
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
- task.status = 'pending';
322
- task.assignee = ticket.previousAssignee;
323
- task.attemptId = undefined;
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
- locks.set(key, previous.then(() => gate));
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
- const parsed = JSON.parse(stripLeadingBom(await readFile(join(stateRoot, RETIRED_MEMBERS_FILE), 'utf8')));
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
- if (task['profileSeedId'] !== undefined && (typeof task['profileSeedId'] !== 'string' || task['profileSeedId'].trim() === '')) {
630
- const next = { ...task };
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 task;
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));