diff --git a/src/hooks/team-session-events/team-member-error-handler.test.ts b/src/hooks/team-session-events/team-member-error-handler.test.ts index cd9cbcfff..6f198292b 100644 --- a/src/hooks/team-session-events/team-member-error-handler.test.ts +++ b/src/hooks/team-session-events/team-member-error-handler.test.ts @@ -198,6 +198,70 @@ describe("createTeamMemberErrorHandler", () => { 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 () => { // given const baseDir = await createTemporaryBaseDir() diff --git a/src/hooks/team-session-events/team-member-error-handler.ts b/src/hooks/team-session-events/team-member-error-handler.ts index 9fd6f4860..cdc7c2ef9 100644 --- a/src/hooks/team-session-events/team-member-error-handler.ts +++ b/src/hooks/team-session-events/team-member-error-handler.ts @@ -1,11 +1,15 @@ +import { randomUUID } from "node:crypto" + import type { TeamModeConfig } from "../../config/schema/team-mode" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" +import { sendMessage } from "../../features/team-mode/team-mailbox/send" import { releaseDeliveryReservation, reserveMessageForDelivery, } from "../../features/team-mode/team-mailbox/reservation" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import { resolveSessionEventID } from "../../shared/event-session-id" +import { isRecord } from "../../shared/record-type-guard" import { log } from "../../shared/logger" import { DEFAULT_SESSION_IDLE_SETTLE_MS, @@ -29,6 +33,18 @@ function getErroredSessionID(properties: unknown): string | undefined { 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( teamRunId: string, memberName: string, @@ -165,6 +181,35 @@ export function createTeamMemberErrorHandler( )), }), 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", { event: "team-mode-member-errored", teamRunId: runtimeState.teamRunId,