@nanmicoder/dsh-agent-teams 0.1.15-alpha.1 → 0.1.16-rc.1

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/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. */
@@ -626,12 +626,17 @@ function coerceTeamState(value, expectedId) {
626
626
  const tasks = value['tasks'].map((task) => {
627
627
  if (!isRecord(task))
628
628
  return task;
629
- if (task['profileSeedId'] !== undefined && (typeof task['profileSeedId'] !== 'string' || task['profileSeedId'].trim() === '')) {
630
- const next = { ...task };
629
+ // Tolerate legacy dirty records instead of bricking the whole team on
630
+ // reload: blank optional fields written by older builds (or by models that
631
+ // materialize optionals as "") are normalized to omitted, matching the
632
+ // profileSeedId handling below and the tool-input normalization.
633
+ const cleaned = normalizeBlankOptionalTaskFields(task);
634
+ if (cleaned['profileSeedId'] !== undefined && (typeof cleaned['profileSeedId'] !== 'string' || cleaned['profileSeedId'].trim() === '')) {
635
+ const next = { ...cleaned };
631
636
  delete next['profileSeedId'];
632
637
  return next;
633
638
  }
634
- return task;
639
+ return cleaned;
635
640
  });
636
641
  const coerced = { ...value, tasks };
637
642
  return isTeamState(coerced, expectedId) ? coerced : undefined;
package/lib/tools.js CHANGED
@@ -12,11 +12,12 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools';
13
13
  import { join } from 'node:path';
14
14
  import { appendTeamEvent, captainSessionOf } from "./events.js";
15
- import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, } from "./state.js";
16
- import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, validateMemberLlmSelections, } from "./members.js";
15
+ import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
16
+ import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
17
17
  import { TERMINAL_TASK_STATUSES } from "./types.js";
18
18
  import { installTeamScheduler } from "./scheduler.js";
19
19
  import { resolveTeamProfile } from "./profiles.js";
20
+ export { steerCaptainReport } from "./members.js";
20
21
  /** The caller agent, or a loud failure for non-agent callers. */
21
22
  function requireCaptain(exec) {
22
23
  if (!exec.agent) {
@@ -275,19 +276,6 @@ export async function haltTeamWork(input) {
275
276
  alreadyHalted: halted.alreadyHalted,
276
277
  };
277
278
  }
278
- export function steerCaptainReport(captain, from, content) {
279
- try {
280
- captain.steer(createUserMessage({
281
- content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
282
- source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
283
- }));
284
- return true;
285
- }
286
- catch {
287
- // The plugin mailbox was persisted before this best-effort live delivery.
288
- return false;
289
- }
290
- }
291
279
  /** Context queued after the human rejects a staged plan. */
292
280
  export function stagedPlanDiscardContext(teamName) {
293
281
  return [
@@ -313,8 +301,8 @@ export function stagedPlanFeedbackContext(teamName) {
313
301
  */
314
302
  export function registerAgentTeamsTools(ctx, config) {
315
303
  installRetiredMemberGuard(ctx, config.stateDir);
316
- const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
317
304
  const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt });
305
+ const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
318
306
  const updateStagedPlanBatch = async (captain, teamId, mutations, signal) => {
319
307
  if (mutations.length === 0)
320
308
  throw new Error('at least one staged plan operation is required');
@@ -586,10 +574,12 @@ export function registerAgentTeamsTools(ctx, config) {
586
574
  throw new Error('team name must not be empty');
587
575
  const teamId = sanitizeKey(teamName);
588
576
  const staged = args.approval === 'required';
589
- const profileName = args.profile?.trim();
590
- if (args.profile !== undefined && profileName === '') {
591
- throw new Error('AgentTeams profile name must not be empty');
592
- }
577
+ // Some models materialize optional parameters as "" instead of omitting
578
+ // them (issue #99). The profile is optional, so treat a blank value
579
+ // exactly like an omitted one instead of failing every create call.
580
+ const profileName = args.profile !== undefined && args.profile.trim() !== ''
581
+ ? args.profile.trim()
582
+ : undefined;
593
583
  const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
594
584
  const current = await findTeamByParticipant(stateRoot, captain.id);
595
585
  if (current !== undefined) {
@@ -927,6 +917,7 @@ export function registerAgentTeamsTools(ctx, config) {
927
917
  provider: selection.provider,
928
918
  model: selection.model,
929
919
  reasoningEffort: selection.reasoningEffort,
920
+ ...selection.fallback === undefined ? {} : { fallback: selection.fallback },
930
921
  executionPrompt: trimmedOptional(args.executionPrompt),
931
922
  joinedAt: Date.now(),
932
923
  status: 'idle',
@@ -1081,28 +1072,33 @@ export function registerAgentTeamsTools(ctx, config) {
1081
1072
  const workspace = workspaceOf(captain);
1082
1073
  const stateRoot = stateRootOf(workspace, config);
1083
1074
  const team = await requireCaptainTeam(workspace, config, captain);
1075
+ // Some models materialize optional parameters as "" instead of omitting
1076
+ // them (issue #105). Normalize blank optional fields to omitted before
1077
+ // validation so a blank value can neither be rejected spuriously nor be
1078
+ // persisted into team.json, where it would brick the team on reload.
1079
+ const input = normalizeBlankOptionalTaskFields(args);
1084
1080
  const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
1085
1081
  const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
1086
1082
  const gate = validateCreateTask(fresh, {
1087
- subject: args.subject,
1088
- description: args.description,
1089
- dependencies: args.dependencies,
1090
- assignee: args.assignee,
1091
- kind: args.kind,
1092
- round: args.round,
1093
- objective: args.objective,
1094
- inScope: args.inScope,
1095
- outOfScope: args.outOfScope,
1096
- acceptance: args.acceptance,
1097
- verify: args.verify,
1098
- deliverables: args.deliverables,
1099
- nonGoals: args.nonGoals,
1100
- reviewedTaskId: args.reviewedTaskId,
1101
- sourceTaskId: args.sourceTaskId,
1102
- sourceFindingIds: args.sourceFindingIds,
1103
- coverageOf: args.coverageOf,
1104
- resume: args.resume,
1105
- resumeReason: args.resumeReason,
1083
+ subject: input.subject,
1084
+ description: input.description,
1085
+ dependencies: input.dependencies,
1086
+ assignee: input.assignee,
1087
+ kind: input.kind,
1088
+ round: input.round,
1089
+ objective: input.objective,
1090
+ inScope: input.inScope,
1091
+ outOfScope: input.outOfScope,
1092
+ acceptance: input.acceptance,
1093
+ verify: input.verify,
1094
+ deliverables: input.deliverables,
1095
+ nonGoals: input.nonGoals,
1096
+ reviewedTaskId: input.reviewedTaskId,
1097
+ sourceTaskId: input.sourceTaskId,
1098
+ sourceFindingIds: input.sourceFindingIds,
1099
+ coverageOf: input.coverageOf,
1100
+ resume: input.resume,
1101
+ resumeReason: input.resumeReason,
1106
1102
  });
1107
1103
  if (!gate.ok)
1108
1104
  throw new Error(gate.error ?? 'create_task rejected by quality gates');
@@ -1128,11 +1124,11 @@ export function registerAgentTeamsTools(ctx, config) {
1128
1124
  requireMember(fresh, args.assignee);
1129
1125
  const kind = gate.kind ?? 'work';
1130
1126
  const objective = kind === 'review' || kind === 'requirements'
1131
- ? sanitizeReviewObjective(args.objective)
1132
- : args.objective;
1127
+ ? sanitizeReviewObjective(input.objective)
1128
+ : input.objective;
1133
1129
  const acceptance = kind === 'review' || kind === 'requirements'
1134
- ? sanitizeReviewAcceptance(args.acceptance)
1135
- : args.acceptance;
1130
+ ? sanitizeReviewAcceptance(input.acceptance)
1131
+ : input.acceptance;
1136
1132
  const task = {
1137
1133
  id: `t${fresh.taskSeq + 1}`,
1138
1134
  subject: args.subject,
@@ -1146,16 +1142,16 @@ export function registerAgentTeamsTools(ctx, config) {
1146
1142
  kind,
1147
1143
  ...args.round === undefined ? {} : { round: args.round },
1148
1144
  ...objective === undefined ? {} : { objective },
1149
- ...args.inScope === undefined ? {} : { inScope: args.inScope },
1150
- ...args.outOfScope === undefined ? {} : { outOfScope: args.outOfScope },
1145
+ ...input.inScope === undefined ? {} : { inScope: input.inScope },
1146
+ ...input.outOfScope === undefined ? {} : { outOfScope: input.outOfScope },
1151
1147
  ...acceptance === undefined ? {} : { acceptance },
1152
- ...args.verify === undefined ? {} : { verify: args.verify },
1153
- ...args.deliverables === undefined ? {} : { deliverables: args.deliverables },
1154
- ...args.nonGoals === undefined ? {} : { nonGoals: args.nonGoals },
1155
- ...args.reviewedTaskId === undefined ? {} : { reviewedTaskId: args.reviewedTaskId },
1156
- ...args.sourceTaskId === undefined ? {} : { sourceTaskId: args.sourceTaskId },
1157
- ...args.sourceFindingIds === undefined ? {} : { sourceFindingIds: args.sourceFindingIds },
1158
- ...args.coverageOf === undefined ? {} : { coverageOf: args.coverageOf },
1148
+ ...input.verify === undefined ? {} : { verify: input.verify },
1149
+ ...input.deliverables === undefined ? {} : { deliverables: input.deliverables },
1150
+ ...input.nonGoals === undefined ? {} : { nonGoals: input.nonGoals },
1151
+ ...input.reviewedTaskId === undefined ? {} : { reviewedTaskId: input.reviewedTaskId },
1152
+ ...input.sourceTaskId === undefined ? {} : { sourceTaskId: input.sourceTaskId },
1153
+ ...input.sourceFindingIds === undefined ? {} : { sourceFindingIds: input.sourceFindingIds },
1154
+ ...input.coverageOf === undefined ? {} : { coverageOf: input.coverageOf },
1159
1155
  };
1160
1156
  fresh.taskSeq += 1;
1161
1157
  fresh.tasks.push(task);
@@ -1304,10 +1300,10 @@ export function registerAgentTeamsTools(ctx, config) {
1304
1300
  }));
1305
1301
  ctx.tools.register(defineTool({
1306
1302
  name: 'agent_teams_claim_task',
1307
- description: 'Claim one ready task for a member (or yourself). A member cannot own a second unfinished task. The returned attempt_id is required for that member\'s updates and becomes stale after retry/reassignment.',
1303
+ description: 'Members claim their own ready task or read their existing attempt_id. Captains must use reassign_task to assign and wake a member; claim_task does not dispatch work. A member cannot own a second unfinished task. The returned attempt_id is required for updates and becomes stale after retry/reassignment.',
1308
1304
  parameters: {
1309
1305
  task_id: { type: 'string', required: true, description: 'The task id to claim.' },
1310
- assignee: { type: 'string', description: 'Member to claim for (captain only; defaults to the task\'s assignee).' },
1306
+ assignee: { type: 'string', description: 'Deprecated: claim_task only supports a member claiming its own task. Captains must use reassign_task.' },
1311
1307
  },
1312
1308
  output: {
1313
1309
  schema: {
@@ -1339,9 +1335,11 @@ export function registerAgentTeamsTools(ctx, config) {
1339
1335
  }
1340
1336
  let assignee = task.assignee;
1341
1337
  if (identity.kind === 'captain') {
1342
- if (args.assignee !== undefined) {
1343
- requireMember(fresh, args.assignee);
1344
- assignee = args.assignee;
1338
+ // A captain may read the capability of its already-started takeover,
1339
+ // but must never create a member claim without dispatching it (#125).
1340
+ if (args.assignee !== undefined || task.assignee !== CAPTAIN_KEY
1341
+ || (task.status !== 'claimed' && task.status !== 'in_progress')) {
1342
+ throw new Error('claim_task is for members claiming their own task; captains must use agent_teams_reassign_task to assign and wake a member');
1345
1343
  }
1346
1344
  }
1347
1345
  else {
@@ -1518,6 +1516,10 @@ export function registerAgentTeamsTools(ctx, config) {
1518
1516
  ...task.output !== undefined ? { output: task.output } : {},
1519
1517
  };
1520
1518
  }
1519
+ // Blank optional list entries (e.g. changedPaths:[""]) must not be
1520
+ // persisted: hasValidQualityTaskFields rejects them on reload and
1521
+ // would brick the whole team state (issue #105 class).
1522
+ const input = normalizeBlankOptionalTaskFields(args);
1521
1523
  const findings = parseFindings(args.findings);
1522
1524
  const acceptanceResults = parseAcceptanceResults(args.acceptanceResults);
1523
1525
  const commandsRun = parseCommandResults(args.commandsRun);
@@ -1526,7 +1528,7 @@ export function registerAgentTeamsTools(ctx, config) {
1526
1528
  output: args.output,
1527
1529
  verdict: args.verdict,
1528
1530
  findings,
1529
- changedPaths: args.changedPaths,
1531
+ changedPaths: input.changedPaths,
1530
1532
  acceptanceResults,
1531
1533
  commandsRun,
1532
1534
  });
@@ -1544,8 +1546,8 @@ export function registerAgentTeamsTools(ctx, config) {
1544
1546
  task.verdict = args.verdict;
1545
1547
  if (findings !== undefined)
1546
1548
  task.findings = findings;
1547
- if (args.changedPaths !== undefined)
1548
- task.changedPaths = args.changedPaths;
1549
+ if (input.changedPaths !== undefined)
1550
+ task.changedPaths = input.changedPaths;
1549
1551
  if (acceptanceResults !== undefined)
1550
1552
  task.acceptanceResults = acceptanceResults;
1551
1553
  if (commandsRun !== undefined)
@@ -1698,7 +1700,7 @@ export function registerAgentTeamsTools(ctx, config) {
1698
1700
  }));
1699
1701
  ctx.tools.register(defineTool({
1700
1702
  name: 'agent_teams_status',
1701
- description: 'Team snapshot: members with live activity and tasks with status/assignee/dependencies/output. Captains also see every team mailbox; members see only their own inbox. Poll this to watch progress.',
1703
+ description: 'Team snapshot: members with live activity and tasks with status/assignee/dependencies/output. Captains also see every team mailbox; members see only their own inbox. Use after mailbox progress deliveries or for an explicit status request. After dispatch, end your turn while members work; do not repeatedly poll.',
1702
1704
  parameters: {},
1703
1705
  output: {
1704
1706
  schema: { type: 'object', additionalProperties: true, properties: {} },
@@ -2065,7 +2067,9 @@ function parseFindings(value) {
2065
2067
  severity: raw['severity'],
2066
2068
  problem: raw['problem'],
2067
2069
  requiredFix: raw['requiredFix'],
2068
- ...typeof raw['file'] === 'string' ? { file: raw['file'] } : {},
2070
+ // A blank optional file must be omitted, not persisted: durable-state
2071
+ // validation requires non-empty optional strings (issue #105 class).
2072
+ ...typeof raw['file'] === 'string' && raw['file'].trim() !== '' ? { file: raw['file'] } : {},
2069
2073
  ...typeof raw['line'] === 'number' ? { line: raw['line'] } : {},
2070
2074
  ...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
2071
2075
  };
@@ -81,7 +81,6 @@ export declare const zh: {
81
81
  'dependency.hint.pinned': string;
82
82
  'task.runningAria': string;
83
83
  'task.model': string;
84
- 'member.model': string;
85
84
  'task.detail.completed': string;
86
85
  'task.detail.noPrerequisite': string;
87
86
  'task.detail.ready': string;
@@ -275,7 +274,6 @@ export declare const en: {
275
274
  'dependency.hint.pinned': string;
276
275
  'task.runningAria': string;
277
276
  'task.model': string;
278
- 'member.model': string;
279
277
  'task.detail.completed': string;
280
278
  'task.detail.noPrerequisite': string;
281
279
  'task.detail.ready': string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The audited Harness 0.1.2 subagent boundary. Keep version-specific shapes
3
+ * here: API presence alone is not a promise of support for future versions.
4
+ *
5
+ * Alpha.2 owns followup/registerContinuableSetup; Alpha.5 and rc.1 own a
6
+ * host-only FIFO queue and synchronous agent/session-start. Their public
7
+ * sendMessage instead steers a running Agent and must never carry team jobs.
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import type { Agent } from '@deepseek-ai/dsh-agent';
11
+ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm';
12
+ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
13
+ type Setup = (childCtx: Context) => () => void;
14
+ /** Read child-owned history, excluding any descriptor inherited from a parent. */
15
+ export declare function sessionOwnEvents(session: Session): readonly SessionEvent[];
16
+ /** Install before the first request, including cold resume, with HMR cleanup. */
17
+ export declare function installContinuableMemberSetup(ctx: Context, setup: Setup): void;
18
+ /** Queue a distinct host-authored turn; never substitute model-message steer. */
19
+ export declare function queueMemberPrompt(runtime: Context['subagents'], parent: Agent, childId: SessionId, content: ContentBlock[], signal: AbortSignal): Promise<MessageId>;
20
+ /** Guard all resumable delivery paths, preserving the native service receiver. */
21
+ export declare function guardSubagentDelivery(ctx: Context, isRetired: (sender: Agent, targetId: SessionId) => Promise<boolean>): void;
22
+ export {};
@@ -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,7 +13,8 @@
13
13
  */
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import { type Agent } from '@deepseek-ai/dsh-agent';
16
- import { type TeamMember, type TeamState } from './types.ts';
16
+ import type { Session } from '@deepseek-ai/dsh-session';
17
+ import { type TeamMember, type TeamState, type TeamTask } from './types.ts';
17
18
  /** Persona snapshot of a profile protocol; the full text lives on team.json. */
18
19
  export declare const PERSONA_PROTOCOL_MAX_CHARS = 400;
19
20
  /** Runtime knobs for member spawning, resolved from plugin config. */
@@ -88,6 +89,18 @@ export declare function selectFallbackRoute(current: {
88
89
  model: string;
89
90
  };
90
91
  };
92
+ /** Deliver a durable member report to the live captain at its next model step. */
93
+ export declare function steerCaptainReport(captain: Pick<Agent, 'steer'>, from: string, content: string): boolean;
94
+ interface FailedMemberAttempt {
95
+ readonly captainSessionId: string;
96
+ readonly memberId: string;
97
+ readonly task?: Pick<TeamTask, 'id' | 'attempt' | 'attemptId'>;
98
+ }
99
+ /** Record a final turn failure, never an intermediate request retry. */
100
+ export declare function failMemberOpenAttempt(ctx: Context, stateRoot: string, teamId: string, memberName: string, failure: {
101
+ readonly code: string;
102
+ readonly message: string;
103
+ }, fallbackSession: Session, observed: FailedMemberAttempt): Promise<boolean>;
91
104
  /**
92
105
  * Resolve one member's complete model selection. Ordinary members snapshot the
93
106
  * captain's current request route and reasoning effort. When provider or model
@@ -105,7 +118,7 @@ export declare function resolveMemberLlmSelection(ctx: Context, captain: Agent,
105
118
  * record. Legacy members without a complete saved route retain Harness's
106
119
  * descriptor provider/model behavior.
107
120
  */
108
- export declare function installMemberSelectionRuntime(ctx: Context, stateDir: string): MemberSelectionRuntime;
121
+ export declare function installMemberSelectionRuntime(ctx: Context, stateDir: string, onFailureSettled?: (workspace: string, teamId: string, memberName: string) => Promise<void>): MemberSelectionRuntime;
109
122
  /**
110
123
  * The member's system prompt (persona), shadowing the deployment persona for
111
124
  * that child. Self-contained: it replaces the whole persona section.
@@ -168,12 +181,12 @@ export declare function interruptMember(ctx: Context, captain: Agent, childId: s
168
181
  *
169
182
  * Upstream `interrupt()` deliberately preserves continuable sessions and the
170
183
  * upstream seam exposes no targeted forget/retire method. The durable
171
- * AgentTeams index therefore rejects `followup()` before it can cold-resume a
184
+ * AgentTeams index therefore rejects every inbox delivery before it can cold-resume a
172
185
  * retired member. Catalog rows deliberately remain discoverable: Harness rc.8
173
186
  * uses the direct-child catalog to authorize historical transcript reads and
174
187
  * `openSubagent()`, so filtering those rows would make an archived member's
175
188
  * persisted conversation inaccessible. Exact ids keep unrelated subagents
176
- * untouched while the followup boundary still prevents further model turns.
189
+ * untouched while the delivery boundary still prevents further model turns.
177
190
  */
178
191
  export declare function installRetiredMemberGuard(ctx: Context, stateDir: string): void;
179
192
  /**
@@ -189,3 +202,4 @@ export declare function installRetiredMemberGuard(ctx: Context, stateDir: string
189
202
  * @returns child id → live activity.
190
203
  */
191
204
  export declare function memberActivity(ctx: Context, memberIds: readonly string[]): Map<string, 'running' | 'idle' | 'ready'>;
205
+ export {};
@@ -128,6 +128,14 @@ export declare function resumeTeamState(team: TeamState, reason: string): Resume
128
128
  export declare function isReviewFinding(value: unknown): value is ReviewFinding;
129
129
  export declare function isAcceptanceResult(value: unknown): value is AcceptanceResult;
130
130
  export declare function isCommandResult(value: unknown): value is CommandResult;
131
+ /**
132
+ * Normalize blank optional task fields to omitted ("blank means absent").
133
+ * Blank string scalars are deleted; string lists have blank entries filtered
134
+ * out, and a list that only contained blanks is omitted entirely. Non-blank
135
+ * values and every other field are passed through untouched, so durable-state
136
+ * validation stays strict.
137
+ */
138
+ export declare function normalizeBlankOptionalTaskFields<T extends object>(task: T): T;
131
139
  export declare function hasValidQualityTaskFields(value: Record<string, unknown>): boolean;
132
140
  export declare function isTaskKind(value: unknown): value is TaskKind;
133
141
  export declare function isReviewVerdict(value: unknown): value is ReviewVerdict;
@@ -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 type { Context } from '@deepseek-ai/cordis';
@@ -42,6 +42,12 @@ export interface DispatchTicket {
42
42
  readonly attempt: number;
43
43
  readonly attemptId: string;
44
44
  readonly previousAssignee?: string;
45
+ /** True when this ticket rotates an unobserved durable open attempt. */
46
+ readonly recoveredOwned: boolean;
47
+ /** Original task generation, used to restore a failed automatic recovery. */
48
+ readonly previousStatus?: 'claimed' | 'in_progress';
49
+ readonly previousAttempt?: number;
50
+ readonly previousAttemptId?: string;
45
51
  readonly subject: string;
46
52
  readonly description?: string;
47
53
  readonly teamDescription?: string;
@@ -13,7 +13,7 @@
13
13
  * @module dsh-agent-teams/state
14
14
  */
15
15
  import { type TaskStatus, type TeamMessage, type TeamState, type TeamTask } from './types.ts';
16
- export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from './quality-gates.ts';
16
+ export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from './quality-gates.ts';
17
17
  /** Mailbox key of the captain. */
18
18
  export declare const CAPTAIN_KEY = "captain";
19
19
  /**
@@ -11,6 +11,7 @@
11
11
  import type { Context } from '@deepseek-ai/cordis';
12
12
  import type { Agent } from '@deepseek-ai/dsh-agent';
13
13
  import { type TeamState, type TeamTask } from './types.ts';
14
+ export { steerCaptainReport } from './members.ts';
14
15
  /** Resolved plugin config consumed by the tools. */
15
16
  export interface ToolsConfig {
16
17
  /** State directory name under the captain's workspace. */
@@ -87,7 +88,6 @@ export declare function haltTeamWork(input: {
87
88
  cancelledTasks: number;
88
89
  alreadyHalted: boolean;
89
90
  }>;
90
- export declare function steerCaptainReport(captain: Pick<Agent, 'steer'>, from: string, content: string): boolean;
91
91
  /** Context queued after the human rejects a staged plan. */
92
92
  export declare function stagedPlanDiscardContext(teamName: string): string;
93
93
  /** Model-facing continuation that turns the review UI back into a conversation. */
@@ -9,5 +9,11 @@ export interface WebRouteHost {
9
9
  }): () => void;
10
10
  }
11
11
  export type BrowserRequestGate = Pick<HostConnectionService, 'requestRejection'>;
12
+ export declare class RequestBodyError extends Error {
13
+ readonly status: 400 | 413;
14
+ constructor(message: string, status: 400 | 413);
15
+ }
16
+ /** Bound memory while draining oversized requests so the route can return 413. */
17
+ export declare function readJsonRequest(req: IncomingMessage, maxBytes?: number): Promise<Record<string, unknown>>;
12
18
  /** Raw WebServer routes do not inherit Connection's authentication fence. */
13
19
  export declare function authenticatedWebRoutes(server: WebRouteHost, connection: () => BrowserRequestGate | undefined): WebRouteHost;
package/lib/web-routes.js CHANGED
@@ -1,3 +1,63 @@
1
+ export class RequestBodyError extends Error {
2
+ status;
3
+ constructor(message, status) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+ /** Bound memory while draining oversized requests so the route can return 413. */
9
+ export async function readJsonRequest(req, maxBytes = 1_000_000) {
10
+ const raw = await new Promise((resolve, reject) => {
11
+ let size = 0;
12
+ let settled = false;
13
+ const chunks = [];
14
+ const finish = (error) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ req.off('data', onData);
19
+ req.off('end', onEnd);
20
+ req.off('aborted', onAborted);
21
+ req.off('error', onError);
22
+ if (error) {
23
+ chunks.length = 0;
24
+ // IncomingMessage may still receive data; discard it without buffering.
25
+ req.once('error', () => { });
26
+ req.resume();
27
+ reject(error);
28
+ }
29
+ else {
30
+ resolve(Buffer.concat(chunks).toString('utf8'));
31
+ }
32
+ };
33
+ const onData = (chunk) => {
34
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35
+ size += part.length;
36
+ if (size > maxBytes)
37
+ finish(new RequestBodyError('request body is too large', 413));
38
+ else
39
+ chunks.push(part);
40
+ };
41
+ const onEnd = () => finish();
42
+ const onAborted = () => finish(new RequestBodyError('request body was aborted', 400));
43
+ const onError = () => finish(new RequestBodyError('invalid request body', 400));
44
+ req.on('data', onData);
45
+ req.once('end', onEnd);
46
+ req.once('aborted', onAborted);
47
+ req.once('error', onError);
48
+ });
49
+ let value;
50
+ try {
51
+ value = raw.trim() === '' ? {} : JSON.parse(raw);
52
+ }
53
+ catch {
54
+ throw new RequestBodyError('invalid JSON', 400);
55
+ }
56
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
57
+ throw new RequestBodyError('body must be an object', 400);
58
+ }
59
+ return value;
60
+ }
1
61
  /** Raw WebServer routes do not inherit Connection's authentication fence. */
2
62
  export function authenticatedWebRoutes(server, connection) {
3
63
  return {