fix(team-mode): surface member error to main agent (#3923)
When a team member task errored, the failure stayed in the member's internal state and the main/coordinator agent's wait/status loop kept polling indefinitely — the run stalled with no visible error. Emit a structured `member_error` peer_message into the team mailbox on member error transition, naming the member and including the underlying error text so the main agent's next team_status (or pending-message read) returns a terminal failure instead of an empty in-progress poll. Regression test asserts the failure is visible in the main agent's view after a member task errors mid-execution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,70 @@ describe("createTeamMemberErrorHandler", () => {
|
|||||||
expect(wrongRuntimeState.members[0]?.status).toBe("running")
|
expect(wrongRuntimeState.members[0]?.status).toBe("running")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("injects a member_error announcement into the lead inbox when a non-lead member errors", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await createTemporaryBaseDir()
|
||||||
|
const config = createConfig(baseDir)
|
||||||
|
const teamRunId = randomUUID()
|
||||||
|
const runtimeStateWithLeader: RuntimeState = {
|
||||||
|
version: 1,
|
||||||
|
teamRunId,
|
||||||
|
teamName: "team-alpha",
|
||||||
|
specSource: "project",
|
||||||
|
createdAt: 1,
|
||||||
|
status: "active",
|
||||||
|
leadSessionId: "lead-session",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: "lead",
|
||||||
|
sessionId: "lead-session",
|
||||||
|
agentType: "leader",
|
||||||
|
status: "running",
|
||||||
|
pendingInjectedMessageIds: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "worker",
|
||||||
|
sessionId: "member-session",
|
||||||
|
agentType: "general-purpose",
|
||||||
|
status: "running",
|
||||||
|
pendingInjectedMessageIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shutdownRequests: [],
|
||||||
|
bounds: {
|
||||||
|
maxMembers: 8,
|
||||||
|
maxParallelMembers: 4,
|
||||||
|
maxMessagesPerRun: 10000,
|
||||||
|
maxWallClockMinutes: 120,
|
||||||
|
maxMemberTurns: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await seedRuntimeState(runtimeStateWithLeader, config)
|
||||||
|
const handler = createTeamMemberErrorHandler(config)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({
|
||||||
|
event: {
|
||||||
|
type: "session.error",
|
||||||
|
properties: { sessionID: "member-session", error: new Error("task exploded") },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// then — lead inbox must contain an announcement about the failed member
|
||||||
|
const leadInboxDir = getInboxDir(resolveBaseDir(config), teamRunId, "lead")
|
||||||
|
const leadInboxEntries = await readdir(leadInboxDir)
|
||||||
|
expect(leadInboxEntries.some((entry) => entry.endsWith(".json"))).toBe(true)
|
||||||
|
|
||||||
|
const { listUnreadMessages } = await import("../../features/team-mode/team-mailbox/inbox")
|
||||||
|
const unread = await listUnreadMessages(teamRunId, "lead", config)
|
||||||
|
expect(unread).toHaveLength(1)
|
||||||
|
expect(unread[0]?.kind).toBe("announcement")
|
||||||
|
expect(unread[0]?.from).toBe("system")
|
||||||
|
expect(unread[0]?.to).toBe("lead")
|
||||||
|
expect(unread[0]?.body).toContain("worker")
|
||||||
|
expect(unread[0]?.body).toContain("task exploded")
|
||||||
|
})
|
||||||
|
|
||||||
test("requeues pending live-delivery messages when the recipient session errors before idle ack", async () => {
|
test("requeues pending live-delivery messages when the recipient session errors before idle ack", async () => {
|
||||||
// given
|
// given
|
||||||
const baseDir = await createTemporaryBaseDir()
|
const baseDir = await createTemporaryBaseDir()
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
|
||||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||||
|
import { sendMessage } from "../../features/team-mode/team-mailbox/send"
|
||||||
import {
|
import {
|
||||||
releaseDeliveryReservation,
|
releaseDeliveryReservation,
|
||||||
reserveMessageForDelivery,
|
reserveMessageForDelivery,
|
||||||
} from "../../features/team-mode/team-mailbox/reservation"
|
} from "../../features/team-mode/team-mailbox/reservation"
|
||||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
import { isRecord } from "../../shared/record-type-guard"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type HookInput = { event: { type: string; properties?: unknown } }
|
type HookInput = { event: { type: string; properties?: unknown } }
|
||||||
@@ -15,6 +19,18 @@ function getErroredSessionID(properties: unknown): string | undefined {
|
|||||||
return resolveSessionEventID(properties)
|
return resolveSessionEventID(properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractErrorText(properties: unknown): string {
|
||||||
|
const props = isRecord(properties) ? properties : undefined
|
||||||
|
const errorValue = props?.["error"]
|
||||||
|
if (errorValue instanceof Error) {
|
||||||
|
return errorValue.message
|
||||||
|
}
|
||||||
|
if (typeof errorValue === "string" && errorValue.length > 0) {
|
||||||
|
return errorValue
|
||||||
|
}
|
||||||
|
return "unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
async function requeuePendingLiveDeliveries(
|
async function requeuePendingLiveDeliveries(
|
||||||
teamRunId: string,
|
teamRunId: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
@@ -62,6 +78,35 @@ export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
|||||||
)),
|
)),
|
||||||
}), config)
|
}), config)
|
||||||
|
|
||||||
|
const leaderMember = runtimeState.members.find((member) => member.agentType === "leader")
|
||||||
|
if (leaderMember !== undefined && leaderMember.name !== runtimeMember.memberName) {
|
||||||
|
const errorText = extractErrorText(event.properties)
|
||||||
|
const errorBody = `Team member "${runtimeMember.memberName}" has entered an error state and will not complete its task.\nError: ${errorText}`
|
||||||
|
try {
|
||||||
|
await sendMessage(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
messageId: randomUUID(),
|
||||||
|
from: "system",
|
||||||
|
to: leaderMember.name,
|
||||||
|
kind: "announcement",
|
||||||
|
body: errorBody,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
runtimeState.teamRunId,
|
||||||
|
config,
|
||||||
|
{ isLead: true, activeMembers: runtimeState.members.map((m) => m.name) },
|
||||||
|
)
|
||||||
|
} catch (sendError) {
|
||||||
|
log("team member error handler: failed to notify lead of member error", {
|
||||||
|
event: "team-mode-member-error-notify-failed",
|
||||||
|
teamRunId: runtimeState.teamRunId,
|
||||||
|
memberName: runtimeMember.memberName,
|
||||||
|
error: sendError instanceof Error ? sendError.message : String(sendError),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log("team member session errored", {
|
log("team member session errored", {
|
||||||
event: "team-mode-member-errored",
|
event: "team-mode-member-errored",
|
||||||
teamRunId: runtimeState.teamRunId,
|
teamRunId: runtimeState.teamRunId,
|
||||||
|
|||||||
Reference in New Issue
Block a user