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:
parent
7c6837eacd
commit
729a756f29
5 changed files with 43 additions and 24 deletions
|
|
@ -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.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue