Files
claude-code-haha/src/tools/TeamPlanTool/context.ts
T
程序员阿江-Relakkes fd42b0ca05 fix(agent-teams): keep long-running teams recoverable (#1426)
Long Agent Teams runs lost members for good: a truncated provider stream
ended a member's turn with nobody to wake it, the desktop Stop button and
every lead restart killed all members and marked the plan interrupted,
mail sent to a stopped member landed in an inbox nothing read, and a lead
kept inside one long turn never saw member reports. Aligned with the
official CLI 2.1.284 and verified with DeepSeek Flash through a
fault-injecting proxy.

Stream recovery
- Re-send a stream that breaks before any tool ran (proxy truncation,
  transport errors), with the existing retry budget and backoff; the
  desktop drops the discarded attempt's tool cards and todo update.

Desktop team runtime (teamPlanRuntime)
- The server supervises members: a stopped member restarts from its own
  transcript when messaged; transient failures continue automatically
  (15s/45s/2m/5m/10m) and only exhausted retries reach the lead; ready
  dependent tasks wake their owner; a crash-loop guard ignores user stops.
- Stop pauses the team instead of ending it; the lead's next user message
  is followed by a notice listing the stopped members and their open
  tasks. Lead restarts (model/permission switch, crash) keep members;
  server restarts re-own the team. Teams end on /clear or session delete.
- Approving a plan no longer races a concurrent plan read into
  "Launch ownership was lost".

Mailbox and messaging
- Atomic inbox writes, identity-based read marking, read history files,
  idle notifications with result/failureReason, and write failures
  reported instead of "Message sent". External builds keep the official
  between-turn delivery to the lead.
- SendMessage resumes non-running in-process teammates from their
  transcript, notes restarting desktop members, queues mail for members
  of a plan awaiting approval, and rejects unknown names.

CLI in-process teammates
- Compaction uses the teammate's own controller and real history and no
  longer kills it on error; failed turns are classified and continued;
  the turn-end mailbox drains as one batch; one durable transcript per
  teammate.

Lead behaviour
- An unmet /goal ends the lead turn while members work, so member reports
  arrive; WaitSessions on own team members returns immediately.

Desktop UI
- Member states for stopped, auto-retrying and failed, with reason,
  countdown and recovery hint in all five locales.

Tests and tooling
- Regression tests for every behaviour above; module mocks in four test
  files are restored after use so the single-process coverage run is not
  polluted; the desktop smoke asserts the new Stop semantics.
2026-10-03 17:07:30 +08:00

143 lines
7.4 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { z } from 'zod/v4'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { snapshotTeamPlanPresetSource } from '../../utils/swarm/teamPlanPresetSource.js'
import type { ToolUseContext } from '../../Tool.js'
import type { TeamPlanMember, TeamPlanRecord, TeamPlanRuntime, TeamPlanTask } from '../../shared/teamPlan.js'
import { getTeamLeaderRuntime } from '../../utils/swarm/teamPlanPolicy.js'
import { listTasks, getCanonicalTeamTaskListId } from '../../utils/tasks.js'
import { resolveTeammateModel } from '../../utils/swarm/resolveTeammateModel.js'
const runtimeSchema = z.object({
providerId: z.string().min(1),
modelId: z.string().min(1),
effortLevel: z.string().optional(),
})
export const proposedTeamPlanSchema = z.object({
members: z.array(z.object({
id: z.string().min(1),
name: z.string().min(1),
agentType: z.string().optional().describe('An available Agent preset; defaults to general-purpose.'),
prompt: z.string().min(1),
runtime: runtimeSchema.optional().describe('Suggested provider and model; the user makes the final choice.'),
reason: z.string().optional(),
difficulty: z.enum(['low', 'medium', 'high']).optional(),
})),
tasks: z.array(z.object({
id: z.string().min(1),
subject: z.string().min(1),
description: z.string().optional(),
ownerId: z.string().optional().describe('Stable member id, not its display name.'),
dependencies: z.array(z.string()).default([]),
})),
})
export type ProposedTeamPlan = z.infer<typeof proposedTeamPlanSchema>
export function snapshotTeamAgents(context: ToolUseContext) {
return Object.fromEntries(context.options.agentDefinitions.activeAgents.map(agent => [agent.agentType, {
agentType: agent.agentType,
source: agent.source,
sourceIdentity: snapshotTeamPlanPresetSource(agent),
description: agent.whenToUse,
systemPrompt: agent.rawSystemPrompt ?? agent.getSystemPrompt({ toolUseContext: context }),
...(agent.tools ? { tools: [...agent.tools] } : {}),
...(agent.disallowedTools ? { disallowedTools: [...agent.disallowedTools] } : {}),
...(agent.model ? { model: agent.model } : {}),
...(agent.effort !== undefined ? { effortLevel: String(agent.effort) } : {}),
...(agent.permissionMode ? { permissionMode: agent.permissionMode } : {}),
...(agent.skills ? { skills: [...agent.skills] } : {}),
...(agent.isolation ? { isolation: agent.isolation } : {}),
...(agent.memory ? { memory: agent.memory } : {}),
...(agent.hooks ? { hooks: structuredClone(agent.hooks) } : {}),
...(agent.mcpServers ? { mcpServers: agent.mcpServers.filter(spec => typeof spec === 'string') } : {}),
...(agent.mcpServers?.some(spec => typeof spec !== 'string') ? { configurationError: 'Inline MCP server configurations are not supported in team plans. Configure the server separately and reference its name in the Agent preset.' } : {}),
...(agent.initialPrompt ? { initialPrompt: agent.initialPrompt } : {}),
...(agent.maxTurns !== undefined ? { maxTurns: agent.maxTurns } : {}),
...(agent.omitClaudeMd !== undefined ? { omitClaudeMd: agent.omitClaudeMd } : {}),
}]))
}
/**
* The model cannot see provider ids, so a suggested runtime is only kept when
* it names the leader's provider or one the user actually configured. A
* guessed id ("anthropic") would otherwise fail approval, and a guessed
* first-party id could silently move members onto a different, billed provider.
*/
function isConfiguredProviderSuggestion(providerId: string, leaderRuntime: TeamPlanRuntime): boolean {
if (providerId === leaderRuntime.providerId) return true
// Only the saved ids matter here; parsing the file directly keeps this tool
// free of the server's provider modules (importing them is an init cycle).
try {
const index = JSON.parse(readFileSync(join(getClaudeConfigHomeDir(), 'cc-haha', 'providers.json'), 'utf8')) as { providers?: unknown }
return Array.isArray(index.providers) && index.providers.some(provider =>
!!provider && typeof provider === 'object' && (provider as { id?: unknown }).id === providerId)
} catch {
return false
}
}
export function resolveProposedTeamPlan(plan: ProposedTeamPlan, context: ToolUseContext) {
const state = context.getAppState()
const leaderRuntime = getTeamLeaderRuntime(context.options.mainLoopModel ?? state.mainLoopModelForSession ?? state.mainLoopModel ?? '')
const agentCatalog = snapshotTeamAgents(context)
const members: TeamPlanMember[] = plan.members.map(member => {
const agentType = member.agentType || 'general-purpose'
const agentSnapshot = agentCatalog[agentType]
if (!agentSnapshot) throw new Error(`Agent preset '${agentType}' is unavailable. Choose a listed Agent preset.`)
if (agentSnapshot.configurationError) throw new Error(agentSnapshot.configurationError)
const suggested = member.runtime && isConfiguredProviderSuggestion(member.runtime.providerId, leaderRuntime)
? member.runtime
: undefined
const runtime: TeamPlanRuntime = suggested ?? {
...leaderRuntime,
modelId: resolveTeammateModel(undefined, leaderRuntime.modelId, agentSnapshot.model, true),
}
if (!suggested) {
if (agentSnapshot.effortLevel !== undefined) runtime.effortLevel = agentSnapshot.effortLevel
else if (runtime.modelId !== leaderRuntime.modelId) delete runtime.effortLevel
}
return { ...member, agentType, runtime, suggestedRuntime: { ...runtime }, agentSnapshot }
})
return { members, tasks: plan.tasks, agentCatalog, leaderRuntime }
}
export async function captureLegacyPlanTasks(plan: TeamPlanRecord): Promise<TeamPlanTask[]> {
if (plan.tasks.length > 0) return plan.tasks
const tasks = await listTasks(getCanonicalTeamTaskListId(plan.teamName))
const proposed = tasks.filter(task => task.status === 'pending' && !task.metadata?.teamPlanId
&& plan.members.some(member => member.name === task.owner))
const ids = new Set(proposed.map(task => task.id))
for (const task of proposed) {
if (task.blockedBy.some(id => !ids.has(id) && tasks.find(dependency => dependency.id === id)?.status !== 'completed')) {
throw new Error(`Task '${task.subject}' depends on work outside this proposal. Submit an explicit complete plan with TeamPlan.`)
}
}
return proposed.map(task => ({
id: task.id,
subject: task.subject,
description: task.description,
ownerId: plan.members.find(member => member.name === task.owner)?.id,
dependencies: task.blockedBy.filter(id => ids.has(id)),
}))
}
/** Do not repeat the full preset prompts in the leader's tool-result history. */
export function teamPlanToolResult(plan: TeamPlanRecord) {
return {
team_name: plan.teamName,
plan_id: plan.planId,
revision: plan.revision,
state: plan.state,
members: plan.members.map(({ agentSnapshot: _snapshot, ...member }) => member),
tasks: plan.tasks,
message: plan.state === 'review_pending'
? 'The proposed members are awaiting human review and have not started. Existing approved members may continue. End this planning turn and wait for the user to confirm in the team panel.'
: plan.state === 'draft'
? 'The proposed members have not started. Finish the roster and tasks, then call TeamPlan with operation="submit" and this revision.'
: `The plan is ${plan.state}. Do not recreate or replay its members. Inspect the team panel for execution status.`,
}
}