From 9766a2b7fccd1167ef12104a8c49ee8bd28a5e22 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 23 Apr 2026 04:17:00 +0300 Subject: [PATCH] fix(team): match suffixed inbox senders during launch --- .../services/team/TeamProvisioningService.ts | 38 +++++++- .../team/TeamProvisioningService.test.ts | 95 ++++++++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 90c3e39f..59e3ae85 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4682,12 +4682,12 @@ export class TeamProvisioningService { } const runStartedAtMs = Date.parse(run.startedAt); - const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []); + const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : []; const teammateMessages = leadInboxMessages .filter((message): message is LeadInboxMemberSpawnMessage => { const from = typeof message.from === 'string' ? message.from.trim() : ''; if (!from || from === leadName || from === 'user' || from === 'system') return false; - if (!expectedMembers.has(from)) return false; + if (!this.resolveExpectedLaunchMemberName(expectedMembers, from)) return false; if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) { return false; } @@ -4710,7 +4710,10 @@ export class TeamProvisioningService { const messagesByMember = new Map(); for (const message of teammateMessages) { - const memberName = message.from.trim(); + const memberName = this.resolveExpectedLaunchMemberName(expectedMembers, message.from); + if (!memberName) { + continue; + } const bucket = messagesByMember.get(memberName) ?? []; bucket.push(message); messagesByMember.set(memberName, bucket); @@ -4764,6 +4767,28 @@ export class TeamProvisioningService { ); } + private resolveExpectedLaunchMemberName( + expectedMembers: readonly string[] | undefined, + candidateName: string + ): string | null { + const trimmedCandidate = candidateName.trim(); + if (!trimmedCandidate || !Array.isArray(expectedMembers) || expectedMembers.length === 0) { + return null; + } + + const exact = expectedMembers.find((memberName) => + matchesExactTeamMemberName(memberName, trimmedCandidate) + ); + if (exact) { + return exact; + } + + const matches = expectedMembers.filter((memberName) => + matchesTeamMemberIdentity(memberName, trimmedCandidate) + ); + return matches.length === 1 ? (matches[0] ?? null) : null; + } + private persistSentMessage(teamName: string, message: InboxMessage): void { try { createController({ @@ -12673,7 +12698,12 @@ export class TeamProvisioningService { matchesTeamMemberIdentity(name, expected) ); const heartbeatMessage = leadInboxMessages.find((message) => { - if (typeof message.from !== 'string' || message.from.trim() !== expected) return false; + if ( + typeof message.from !== 'string' || + this.resolveExpectedLaunchMemberName(persistedMemberNames, message.from) !== expected + ) { + return false; + } if ( typeof message.text !== 'string' || !isMeaningfulBootstrapCheckInMessage(message.text) diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 4a7bb356..bf81dac6 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -5030,6 +5030,37 @@ describe('TeamProvisioningService', () => { }); }); + it('maps suffixed teammate heartbeats back onto the expected member during live refresh', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + startedAt: '2026-04-16T09:00:00.000Z', + expectedMembers: ['alice'], + }); + + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice-2', + text: '{"type":"heartbeat","timestamp":"2026-04-16T10:00:00.000Z"}', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-suffixed', + read: false, + }, + ]); + + await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run); + + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-16T10:00:00.000Z', + }); + expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({ + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-suffixed', + }); + }); + it('ignores teammate lead inbox signals that predate the current run', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -5749,7 +5780,7 @@ describe('TeamProvisioningService', () => { teamName, leadSessionId: 'lead-session', launchPhase: 'active', - expectedMembers: ['alice'], + expectedMembers: ['alice', 'bob'], members: { alice: { name: 'alice', @@ -5830,6 +5861,68 @@ describe('TeamProvisioningService', () => { }); }); + it('treats suffixed persisted heartbeat senders as the expected member during reconcile', async () => { + const teamName = 'suffixed-heartbeat-reconcile-team'; + const svc = new TeamProvisioningService(); + (svc as any).launchStateStore = { + read: vi.fn(async () => + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'active', + expectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: '2026-04-16T09:55:00.000Z', + lastEvaluatedAt: '2026-04-16T09:55:00.000Z', + }, + bob: { + name: 'bob', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-16T09:55:00.000Z', + }, + }, + updatedAt: '2026-04-16T09:55:00.000Z', + }) + ), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([ + { + from: 'alice-2', + text: 'heartbeat', + timestamp: '2026-04-16T10:00:00.000Z', + messageId: 'msg-suffixed-reconcile', + read: false, + }, + ]); + (svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set()); + + const result = await (svc as any).reconcilePersistedLaunchState(teamName); + + expect(result.snapshot.members.alice).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + lastHeartbeatAt: '2026-04-16T10:00:00.000Z', + }); + expect(result.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + }); + it('returns persisted expectedMembers as the union of expected and materialized launch members', async () => { const teamName = 'persisted-union-member-spawn-team'; const teamDir = path.join(tempTeamsBase, teamName);