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, { controller.tasks.addTaskComment(task.id, {
from: 'alice', from: 'bob',
text: 'Need your decision here.', text: 'Need your decision here.',
}); });
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json'); const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1); 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.'); expect(rows[0].text).toContain('Need your decision here.');
}); });

View file

@ -3,7 +3,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const { createController } = require('../src/index.js'); 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', () => { describe('crossTeam module', () => {
function makeClaudeDir(teams = {}) { function makeClaudeDir(teams = {}) {
@ -60,9 +60,9 @@ describe('crossTeam module', () => {
expect(inbox).toHaveLength(1); expect(inbox).toHaveLength(1);
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
expect(inbox[0].from).toBe('team-a.lead'); 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].conversationId).toBeTruthy();
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`); expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
}); });
it('records outbox entry', () => { it('records outbox entry', () => {
@ -121,8 +121,8 @@ describe('crossTeam module', () => {
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(inbox[0].conversationId).toBe('conv-123'); expect(inbox[0].conversationId).toBe('conv-123');
expect(inbox[0].replyToConversationId).toBe('conv-123'); expect(inbox[0].replyToConversationId).toBe('conv-123');
expect(inbox[0].text).toContain('conversation:conv-123'); expect(inbox[0].text).toContain('conversationId="conv-123"');
expect(inbox[0].text).toContain('replyTo:conv-123'); expect(inbox[0].text).toContain('replyToConversationId="conv-123"');
}); });
it('deduplicates the same recent cross-team request', () => { 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 fs from 'fs';
import * as path from 'path'; 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. * 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> { export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
const dir = path.dirname(targetPath); const dir = path.dirname(targetPath);
@ -24,16 +52,7 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis
await fd?.close(); await fd?.close();
} }
try { await renameWithRetry(tmpPath, targetPath);
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;
}
}
} catch (error) { } catch (error) {
await fs.promises.unlink(tmpPath).catch(() => undefined); await fs.promises.unlink(tmpPath).catch(() => undefined);
throw error; throw error;

View file

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

View file

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