380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
normalizeLaunchFailureReasonText,
|
||
normalizePersistedLaunchSnapshot,
|
||
snapshotToMemberSpawnStatuses,
|
||
summarizePersistedLaunchMembers,
|
||
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||
|
||
describe('TeamLaunchStateEvaluator', () => {
|
||
it('normalizes message_send tool result JSON in persisted hard failure reasons', () => {
|
||
const reason = normalizeLaunchFailureReasonText(
|
||
JSON.stringify({
|
||
success: true,
|
||
message: "Message sent to team-lead's inbox",
|
||
routing: {
|
||
sender: 'tom',
|
||
target: '@team-lead',
|
||
summary: 'Bootstrap failed - no member_briefing tool',
|
||
content: 'Не могу выполнить member_briefing: tool not found.',
|
||
},
|
||
})
|
||
);
|
||
|
||
expect(reason).toBe(
|
||
'Bootstrap failed - no member_briefing tool: Не могу выполнить member_briefing: tool not found.'
|
||
);
|
||
});
|
||
|
||
it('normalizes truncated message_send tool result JSON in persisted hard failure reasons', () => {
|
||
const reason = normalizeLaunchFailureReasonText(
|
||
`{"success":true,"message":"Message sent to team-lead's inbox","routing":{"sender":"tom","summary":"Bootstrap failed - no member_briefing tool","content":"Не могу выполнить member_briefing`
|
||
);
|
||
|
||
expect(reason).toBe('Bootstrap failed - no member_briefing tool: Не могу выполнить member_briefing');
|
||
});
|
||
|
||
it('keeps member spawn statuses for persisted members even when expectedMembers is stale', () => {
|
||
const statuses = snapshotToMemberSpawnStatuses({
|
||
version: 1,
|
||
teamName: 'my-team',
|
||
runId: 'run-1',
|
||
leadSessionId: 'lead-session',
|
||
expectedMembers: ['alice'],
|
||
bootstrapExpectedMembers: ['alice'],
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'active',
|
||
teamLaunchState: 'partial_pending',
|
||
summary: {
|
||
confirmedCount: 0,
|
||
pendingCount: 2,
|
||
failedCount: 0,
|
||
runtimeAlivePendingCount: 0,
|
||
},
|
||
members: {
|
||
alice: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
diagnostics: ['waiting for teammate check-in'],
|
||
agentToolAccepted: true,
|
||
runtimeAlive: false,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
sources: {},
|
||
},
|
||
bob: {
|
||
launchState: 'runtime_pending_permission',
|
||
diagnostics: ['waiting for permission approval'],
|
||
agentToolAccepted: true,
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
pendingPermissionRequestIds: ['req-1'],
|
||
lastEvaluatedAt: '2026-04-23T00:00:01.000Z',
|
||
sources: {},
|
||
},
|
||
},
|
||
} as any);
|
||
|
||
expect(statuses.alice).toMatchObject({
|
||
launchState: 'runtime_pending_bootstrap',
|
||
status: 'waiting',
|
||
});
|
||
expect(statuses.bob).toMatchObject({
|
||
launchState: 'runtime_pending_permission',
|
||
status: 'waiting',
|
||
runtimeAlive: false,
|
||
pendingPermissionRequestIds: ['req-1'],
|
||
});
|
||
});
|
||
|
||
it('does not count weak persisted runtimeAlive without strong liveness evidence', () => {
|
||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||
alice: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: false,
|
||
},
|
||
bob: {
|
||
launchState: 'runtime_pending_permission',
|
||
runtimeAlive: true,
|
||
},
|
||
} as any);
|
||
|
||
expect(summary).toEqual({
|
||
confirmedCount: 0,
|
||
pendingCount: 2,
|
||
failedCount: 0,
|
||
skippedCount: 0,
|
||
runtimeAlivePendingCount: 0,
|
||
shellOnlyPendingCount: 0,
|
||
runtimeProcessPendingCount: 0,
|
||
runtimeCandidatePendingCount: 0,
|
||
noRuntimePendingCount: 0,
|
||
permissionPendingCount: 1,
|
||
});
|
||
});
|
||
|
||
it('keeps skipped members terminal and out of pending counts', () => {
|
||
const summary = summarizePersistedLaunchMembers(['alice', 'bob'], {
|
||
alice: {
|
||
launchState: 'skipped_for_launch',
|
||
skippedForLaunch: true,
|
||
runtimeAlive: false,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
},
|
||
bob: {
|
||
launchState: 'confirmed_alive',
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: true,
|
||
hardFailure: false,
|
||
},
|
||
} as any);
|
||
|
||
expect(summary).toMatchObject({
|
||
confirmedCount: 1,
|
||
pendingCount: 0,
|
||
failedCount: 0,
|
||
skippedCount: 1,
|
||
});
|
||
});
|
||
|
||
it('does not preserve runtimeAlive for skipped persisted members', () => {
|
||
const snapshot = normalizePersistedLaunchSnapshot('demo', {
|
||
version: 2,
|
||
teamName: 'demo',
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'finished',
|
||
expectedMembers: ['alice'],
|
||
members: {
|
||
alice: {
|
||
name: 'alice',
|
||
launchState: 'skipped_for_launch',
|
||
skippedForLaunch: true,
|
||
agentToolAccepted: true,
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: true,
|
||
hardFailure: false,
|
||
livenessKind: 'runtime_process',
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(snapshot?.members.alice).toMatchObject({
|
||
launchState: 'skipped_for_launch',
|
||
runtimeAlive: false,
|
||
bootstrapConfirmed: false,
|
||
agentToolAccepted: false,
|
||
skippedForLaunch: true,
|
||
});
|
||
|
||
const statuses = snapshotToMemberSpawnStatuses(snapshot!);
|
||
expect(statuses.alice).toMatchObject({
|
||
status: 'skipped',
|
||
launchState: 'skipped_for_launch',
|
||
runtimeAlive: false,
|
||
bootstrapConfirmed: false,
|
||
agentToolAccepted: false,
|
||
skippedForLaunch: true,
|
||
});
|
||
});
|
||
|
||
it('counts registered-only persisted liveness as no-runtime pending', () => {
|
||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||
alice: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: false,
|
||
livenessKind: 'registered_only',
|
||
},
|
||
} as any);
|
||
|
||
expect(summary).toMatchObject({
|
||
pendingCount: 1,
|
||
runtimeAlivePendingCount: 0,
|
||
noRuntimePendingCount: 1,
|
||
});
|
||
});
|
||
|
||
it('preserves persisted runtimeAlive only with strong liveness evidence', () => {
|
||
const summary = summarizePersistedLaunchMembers(['alice', 'bob', 'cara'], {
|
||
alice: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: true,
|
||
livenessKind: 'runtime_process',
|
||
},
|
||
bob: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: true,
|
||
},
|
||
cara: {
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: true,
|
||
livenessKind: 'runtime_process_candidate',
|
||
},
|
||
} as any);
|
||
|
||
expect(summary).toMatchObject({
|
||
pendingCount: 3,
|
||
runtimeAlivePendingCount: 2,
|
||
runtimeCandidatePendingCount: 1,
|
||
});
|
||
});
|
||
|
||
it('keeps bootstrap-stalled runtime processes pending instead of online', () => {
|
||
const snapshot = normalizePersistedLaunchSnapshot('my-team', {
|
||
version: 2,
|
||
teamName: 'my-team',
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'active',
|
||
expectedMembers: ['alice'],
|
||
members: {
|
||
alice: {
|
||
name: 'alice',
|
||
providerId: 'opencode',
|
||
laneKind: 'secondary',
|
||
laneOwnerProviderId: 'opencode',
|
||
laneId: 'secondary:opencode:alice',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
agentToolAccepted: true,
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
livenessKind: 'runtime_process',
|
||
bootstrapStalled: true,
|
||
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
|
||
runtimeDiagnosticSeverity: 'warning',
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(snapshot?.members.alice.bootstrapStalled).toBe(true);
|
||
expect(snapshot?.teamLaunchState).toBe('partial_pending');
|
||
|
||
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
||
expect(statuses.alice).toMatchObject({
|
||
status: 'waiting',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: true,
|
||
livenessSource: undefined,
|
||
livenessKind: 'runtime_process',
|
||
bootstrapStalled: true,
|
||
});
|
||
});
|
||
|
||
it('keeps bootstrap-stalled OpenCode registered sessions pending even without strong runtime liveness', () => {
|
||
const snapshot = normalizePersistedLaunchSnapshot('my-team', {
|
||
version: 2,
|
||
teamName: 'my-team',
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'active',
|
||
expectedMembers: ['alice'],
|
||
members: {
|
||
alice: {
|
||
name: 'alice',
|
||
providerId: 'opencode',
|
||
laneKind: 'secondary',
|
||
laneOwnerProviderId: 'opencode',
|
||
laneId: 'secondary:opencode:alice',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
agentToolAccepted: true,
|
||
runtimeAlive: false,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
livenessKind: 'registered_only',
|
||
runtimeSessionId: 'ses_alice_partial_bootstrap',
|
||
bootstrapStalled: true,
|
||
runtimeDiagnostic:
|
||
'OpenCode member_briefing completed, but runtime_bootstrap_checkin did not complete after 5 min.',
|
||
runtimeDiagnosticSeverity: 'warning',
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(snapshot?.members.alice.bootstrapStalled).toBe(true);
|
||
|
||
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
||
expect(statuses.alice).toMatchObject({
|
||
status: 'waiting',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: false,
|
||
livenessSource: undefined,
|
||
livenessKind: 'registered_only',
|
||
bootstrapStalled: true,
|
||
});
|
||
});
|
||
|
||
it('keeps OpenCode secondary runtime processes pending before bootstrap stalls', () => {
|
||
const snapshot = normalizePersistedLaunchSnapshot('my-team', {
|
||
version: 2,
|
||
teamName: 'my-team',
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'active',
|
||
expectedMembers: ['alice'],
|
||
members: {
|
||
alice: {
|
||
name: 'alice',
|
||
providerId: 'opencode',
|
||
laneKind: 'secondary',
|
||
laneOwnerProviderId: 'opencode',
|
||
laneId: 'secondary:opencode:alice',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
agentToolAccepted: true,
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
livenessKind: 'runtime_process',
|
||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||
runtimeDiagnosticSeverity: 'info',
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
},
|
||
},
|
||
});
|
||
|
||
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
||
expect(statuses.alice).toMatchObject({
|
||
status: 'waiting',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: true,
|
||
livenessSource: undefined,
|
||
livenessKind: 'runtime_process',
|
||
bootstrapStalled: false,
|
||
});
|
||
});
|
||
|
||
it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => {
|
||
const snapshot = normalizePersistedLaunchSnapshot('demo', {
|
||
version: 2,
|
||
teamName: 'demo',
|
||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||
launchPhase: 'active',
|
||
expectedMembers: ['alice'],
|
||
members: {
|
||
alice: {
|
||
name: 'alice',
|
||
launchState: 'runtime_pending_bootstrap',
|
||
agentToolAccepted: true,
|
||
runtimeAlive: true,
|
||
bootstrapConfirmed: false,
|
||
hardFailure: false,
|
||
livenessKind: 'runtime_process_candidate',
|
||
sources: {
|
||
processAlive: true,
|
||
},
|
||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(snapshot?.members.alice).toMatchObject({
|
||
launchState: 'runtime_pending_bootstrap',
|
||
runtimeAlive: false,
|
||
livenessKind: 'runtime_process_candidate',
|
||
});
|
||
expect(snapshot?.members.alice.sources?.processAlive).toBeUndefined();
|
||
});
|
||
});
|