fix: resolve CI failures across all platforms

- Fix TS error: use kanbanTaskState?.reviewer instead of task.reviewer
  (reviewer exists on KanbanTaskState, not TeamTask)
- Fix crossTeam tests: update assertions to match XML tag protocol format
  (CROSS_TEAM_PREFIX_TAG → CROSS_TEAM_TAG_NAME with XML attributes)
- Fix controller test: use 'bob' instead of 'alice' as commenter
  (alice is the lead = same as task owner, so no notification fires)
- Fix TeamMcpConfigBuilder test: use path.join() for cross-platform paths
  (forward slashes fail on Windows where path.join uses backslashes)
- Fix atomicWrite EPERM on Windows: add retry logic for rename operations
  (Windows CI antivirus can briefly hold files, causing EPERM on rename)
This commit is contained in:
iliya 2026-03-16 19:56:06 +02:00
parent 7c6837eacd
commit 729a756f29
5 changed files with 43 additions and 24 deletions

View file

@ -661,14 +661,14 @@ describe('agent-teams-controller API', () => {
});
controller.tasks.addTaskComment(task.id, {
from: 'alice',
from: 'bob',
text: 'Need your decision here.',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('alice');
expect(rows[0].from).toBe('bob');
expect(rows[0].text).toContain('Need your decision here.');
});

View file

@ -3,7 +3,7 @@ const os = require('os');
const path = require('path');
const { createController } = require('../src/index.js');
const { CROSS_TEAM_SOURCE, CROSS_TEAM_PREFIX_TAG } = require('../src/internal/crossTeamProtocol.js');
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
describe('crossTeam module', () => {
function makeClaudeDir(teams = {}) {
@ -60,9 +60,9 @@ describe('crossTeam module', () => {
expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
expect(inbox[0].from).toBe('team-a.lead');
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
expect(inbox[0].conversationId).toBeTruthy();
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
});
it('records outbox entry', () => {
@ -121,8 +121,8 @@ describe('crossTeam module', () => {
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox[0].conversationId).toBe('conv-123');
expect(inbox[0].replyToConversationId).toBe('conv-123');
expect(inbox[0].text).toContain('conversation:conv-123');
expect(inbox[0].text).toContain('replyTo:conv-123');
expect(inbox[0].text).toContain('conversationId="conv-123"');
expect(inbox[0].text).toContain('replyToConversationId="conv-123"');
});
it('deduplicates the same recent cross-team request', () => {

View file

@ -2,9 +2,37 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const EPERM_MAX_RETRIES = 3;
const EPERM_RETRY_DELAY_MS = 50;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function renameWithRetry(src: string, dest: string): Promise<void> {
for (let attempt = 0; attempt <= EPERM_MAX_RETRIES; attempt++) {
try {
await fs.promises.rename(src, dest);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EXDEV') {
await fs.promises.copyFile(src, dest);
await fs.promises.unlink(src).catch(() => undefined);
return;
}
if (code === 'EPERM' && attempt < EPERM_MAX_RETRIES) {
await sleep(EPERM_RETRY_DELAY_MS * (attempt + 1));
continue;
}
throw error;
}
}
}
/**
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV fallback for safety.
* Uses best-effort fsync and EXDEV/EPERM fallback for safety.
*/
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
const dir = path.dirname(targetPath);
@ -24,16 +52,7 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis
await fd?.close();
}
try {
await fs.promises.rename(tmpPath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await fs.promises.copyFile(tmpPath, targetPath);
await fs.promises.unlink(tmpPath).catch(() => undefined);
} else {
throw error;
}
}
await renameWithRetry(tmpPath, targetPath);
} catch (error) {
await fs.promises.unlink(tmpPath).catch(() => undefined);
throw error;

View file

@ -216,7 +216,7 @@ export const KanbanTaskCard = ({
task,
teamName,
columnId,
kanbanTaskState: _kanbanTaskState,
kanbanTaskState,
hasReviewers,
compact,
taskMap,
@ -266,7 +266,7 @@ export const KanbanTaskCard = ({
taskChangeRequestOptions,
]);
const isReviewManual = columnId === 'review' && !hasReviewers && !task.reviewer;
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
const metaActions = (
<>
{canDisplay && taskHasChanges === true ? (

View file

@ -52,10 +52,10 @@ describe('TeamMcpConfigBuilder', () => {
expect(server?.command).toBe('pnpm');
expect(server?.args).toEqual([
'--dir',
`${process.cwd()}/mcp-server`,
path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
`${process.cwd()}/mcp-server/src/index.ts`,
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
]);
});
@ -178,10 +178,10 @@ describe('TeamMcpConfigBuilder', () => {
command: 'pnpm',
args: [
'--dir',
`${process.cwd()}/mcp-server`,
path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
`${process.cwd()}/mcp-server/src/index.ts`,
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
],
});
});