fix(team): match suffixed inbox senders during launch
This commit is contained in:
parent
b29adf1008
commit
9766a2b7fc
2 changed files with 128 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue