fix(team): match suffixed inbox senders during launch

This commit is contained in:
777genius 2026-04-23 04:17:00 +03:00
parent b29adf1008
commit 9766a2b7fc
2 changed files with 128 additions and 5 deletions

View file

@ -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<string, LeadInboxMemberSpawnMessage[]>();
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)

View file

@ -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<string>());
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);