feat(team): add worktree readiness checks

This commit is contained in:
777genius 2026-04-27 13:46:11 +03:00
parent ea25e6ba58
commit 9fe9f81046
25 changed files with 1258 additions and 92 deletions

View file

@ -11,6 +11,7 @@ import {
TEAM_ALIVE_LIST, TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING, TEAM_CANCEL_PROVISIONING,
TEAM_CREATE, TEAM_CREATE,
TEAM_CREATE_INITIAL_GIT_COMMIT,
TEAM_CREATE_CONFIG, TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_DRAFT, TEAM_DELETE_DRAFT,
@ -37,6 +38,8 @@ import {
TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_GET_TASK_LOG_STREAM_SUMMARY,
TEAM_GET_WORKTREE_GIT_STATUS,
TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -130,6 +133,7 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import { import {
validateFromField, validateFromField,
@ -205,6 +209,7 @@ import type {
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
TeamUpdateConfigRequest, TeamUpdateConfigRequest,
TeamWorktreeGitStatus,
TeamViewSnapshot, TeamViewSnapshot,
ToolApprovalFileContent, ToolApprovalFileContent,
ToolApprovalSettings, ToolApprovalSettings,
@ -514,6 +519,7 @@ let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null
const attachmentStore = new TeamAttachmentStore(); const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore();
const teamMetaStore = new TeamMetaStore(); const teamMetaStore = new TeamMetaStore();
const worktreeGitService = new TeamWorktreeGitService();
const ALLOWED_ATTACHMENT_TYPES = new Set([ const ALLOWED_ATTACHMENT_TYPES = new Set([
'image/png', 'image/png',
@ -525,6 +531,16 @@ const ALLOWED_ATTACHMENT_TYPES = new Set([
]); ]);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
function isValidStoredAttachmentMimeType(value: unknown): value is string {
if (typeof value !== 'string') return false;
const v = value.trim();
if (!v) return false;
if (v.length > 200) return false;
if (v.includes('\0') || /[\r\n]/.test(v)) return false;
const slash = v.indexOf('/');
return slash > 0 && slash < v.length - 1;
}
/** /**
* Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification. * Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification.
* @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html
@ -574,6 +590,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_GET_WORKTREE_GIT_STATUS, handleGetWorktreeGitStatus);
ipcMain.handle(TEAM_INITIALIZE_GIT_REPOSITORY, handleInitializeGitRepository);
ipcMain.handle(TEAM_CREATE_INITIAL_GIT_COMMIT, handleCreateInitialGitCommit);
ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
@ -652,6 +671,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_GET_WORKTREE_GIT_STATUS);
ipcMain.removeHandler(TEAM_INITIALIZE_GIT_REPOSITORY);
ipcMain.removeHandler(TEAM_CREATE_INITIAL_GIT_COMMIT);
ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH); ipcMain.removeHandler(TEAM_LAUNCH);
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
@ -820,6 +842,54 @@ async function handleGetProjectBranch(
} }
} }
function validateProjectPathInput(
projectPath: unknown
): { valid: true; value: string } | { valid: false; error: string } {
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
return { valid: false, error: 'projectPath must be a non-empty string' };
}
return { valid: true, value: path.normalize(projectPath.trim()) };
}
async function handleGetWorktreeGitStatus(
_event: IpcMainInvokeEvent,
projectPath: unknown
): Promise<IpcResult<TeamWorktreeGitStatus>> {
const validated = validateProjectPathInput(projectPath);
if (!validated.valid) {
return { success: false, error: validated.error };
}
return wrapTeamHandler('getWorktreeGitStatus', () =>
worktreeGitService.getStatus(validated.value)
);
}
async function handleInitializeGitRepository(
_event: IpcMainInvokeEvent,
projectPath: unknown
): Promise<IpcResult<TeamWorktreeGitStatus>> {
const validated = validateProjectPathInput(projectPath);
if (!validated.valid) {
return { success: false, error: validated.error };
}
return wrapTeamHandler('initializeGitRepository', () =>
worktreeGitService.initializeRepository(validated.value)
);
}
async function handleCreateInitialGitCommit(
_event: IpcMainInvokeEvent,
projectPath: unknown
): Promise<IpcResult<TeamWorktreeGitStatus>> {
const validated = validateProjectPathInput(projectPath);
if (!validated.valid) {
return { success: false, error: validated.error };
}
return wrapTeamHandler('createInitialGitCommit', () =>
worktreeGitService.createInitialCommit(validated.value)
);
}
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> { async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
setCurrentMainOp('team:list'); setCurrentMainOp('team:list');
const startedAt = Date.now(); const startedAt = Date.now();
@ -4267,10 +4337,9 @@ async function handleAddTaskComment(
if ( if (
typeof a.id !== 'string' || typeof a.id !== 'string' ||
typeof a.filename !== 'string' || typeof a.filename !== 'string' ||
typeof a.mimeType !== 'string' || !isValidStoredAttachmentMimeType(a.mimeType) ||
typeof a.base64Data !== 'string' || typeof a.base64Data !== 'string' ||
a.base64Data.length === 0 || a.base64Data.length === 0
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
) { ) {
throw new Error('Invalid attachment data'); throw new Error('Invalid attachment data');
} }
@ -4283,7 +4352,7 @@ async function handleAddTaskComment(
vTask.value!, vTask.value!,
safeId, safeId,
a.filename, a.filename,
a.mimeType, a.mimeType.trim(),
a.base64Data a.base64Data
); );
savedAttachments.push(meta); savedAttachments.push(meta);
@ -4386,11 +4455,8 @@ async function handleSaveTaskAttachment(
if (typeof filename !== 'string' || filename.trim().length === 0) { if (typeof filename !== 'string' || filename.trim().length === 0) {
return { success: false, error: 'filename must be a non-empty string' }; return { success: false, error: 'filename must be a non-empty string' };
} }
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { if (!isValidStoredAttachmentMimeType(mimeType)) {
return { return { success: false, error: 'Invalid mimeType' };
success: false,
error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`,
};
} }
if (typeof base64Data !== 'string' || base64Data.length === 0) { if (typeof base64Data !== 'string' || base64Data.length === 0) {
return { success: false, error: 'base64Data must be a non-empty string' }; return { success: false, error: 'base64Data must be a non-empty string' };
@ -4407,7 +4473,7 @@ async function handleSaveTaskAttachment(
vTask.value!, vTask.value!,
safeAttId, safeAttId,
filename, filename,
mimeType, mimeType.trim(),
base64Data base64Data
); );
// Write metadata into the task JSON // Write metadata into the task JSON
@ -4430,7 +4496,7 @@ async function handleGetTaskAttachment(
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
return { success: false, error: 'attachmentId must be a non-empty string' }; return { success: false, error: 'attachmentId must be a non-empty string' };
} }
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { if (!isValidStoredAttachmentMimeType(mimeType)) {
return { success: false, error: 'Invalid mimeType' }; return { success: false, error: 'Invalid mimeType' };
} }
const safeAttId = attachmentId.trim(); const safeAttId = attachmentId.trim();
@ -4439,7 +4505,7 @@ async function handleGetTaskAttachment(
} }
return wrapTeamHandler('getTaskAttachment', () => return wrapTeamHandler('getTaskAttachment', () =>
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType) taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType.trim())
); );
} }
@ -4457,7 +4523,7 @@ async function handleDeleteTaskAttachment(
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
return { success: false, error: 'attachmentId must be a non-empty string' }; return { success: false, error: 'attachmentId must be a non-empty string' };
} }
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { if (!isValidStoredAttachmentMimeType(mimeType)) {
return { success: false, error: 'Invalid mimeType' }; return { success: false, error: 'Invalid mimeType' };
} }
const safeAttId = attachmentId.trim(); const safeAttId = attachmentId.trim();
@ -4466,7 +4532,12 @@ async function handleDeleteTaskAttachment(
} }
return wrapTeamHandler('deleteTaskAttachment', async () => { return wrapTeamHandler('deleteTaskAttachment', async () => {
await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType); await taskAttachmentStore.deleteAttachment(
vTeam.value!,
vTask.value!,
safeAttId,
mimeType.trim()
);
// Remove metadata from task JSON // Remove metadata from task JSON
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
}); });

View file

@ -209,9 +209,7 @@ export class ChangeExtractorService {
return ledgerResult; return ledgerResult;
} }
if (!includeDetails) { if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
this.enqueueOpenCodeLedgerBackfill(resolvedInput);
} else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput); const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) { if (backfilledLedgerResult) {
await this.recordTaskChangePresence( await this.recordTaskChangePresence(
@ -435,6 +433,28 @@ export class ChangeExtractorService {
} }
this.openCodeBackfillCache.delete(cacheKey); this.openCodeBackfillCache.delete(cacheKey);
if (deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
void appendOpenCodeTaskChangeDiag({
event: 'backfill_skipped',
reason: 'no-delivery-context',
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: 0,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
}).catch(() => undefined);
return false;
}
const existing = this.openCodeBackfillInFlight.get(cacheKey); const existing = this.openCodeBackfillInFlight.get(cacheKey);
if (existing) { if (existing) {
return existing; return existing;
@ -454,14 +474,6 @@ export class ChangeExtractorService {
return promise; return promise;
} }
private enqueueOpenCodeLedgerBackfill(input: ResolvedTaskChangeComputeInput): void {
void this.tryBackfillOpenCodeLedger(input).catch((error) => {
logger.debug(
`Background OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
});
}
private async runOpenCodeBackfill( private async runOpenCodeBackfill(
input: ResolvedTaskChangeComputeInput, input: ResolvedTaskChangeComputeInput,
projectDir: string, projectDir: string,

View file

@ -1504,6 +1504,20 @@ function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
export function isOpenCodePromptDeliveryRetryAttemptDue(input: {
attemptDue: boolean;
ledgerRecord: Pick<OpenCodePromptDeliveryLedgerRecord, 'status' | 'responseState'>;
}): boolean {
if (!input.attemptDue) {
return false;
}
return (
input.ledgerRecord.status === 'retry_scheduled' ||
input.ledgerRecord.status === 'failed_retryable' ||
isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)
);
}
function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
const updatedAt = nowIso(); const updatedAt = nowIso();
return { return {
@ -5573,9 +5587,10 @@ export class TeamProvisioningService {
}; };
} }
const retryDueBeforeObserve = const retryDueBeforeObserve = isOpenCodePromptDeliveryRetryAttemptDue({
attemptDue && attemptDue,
(ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable'); ledgerRecord,
});
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) { if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
const observed = await adapter.observeMessageDelivery({ const observed = await adapter.observeMessageDelivery({
...(runtimeRunId ? { runId: runtimeRunId } : {}), ...(runtimeRunId ? { runId: runtimeRunId } : {}),

View file

@ -0,0 +1,158 @@
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import type { TeamWorktreeGitStatus } from '@shared/types';
const GIT_TIMEOUT_MS = 20_000;
function execGit(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
'git',
args,
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
const message = String(stderr || error.message || 'git command failed').trim();
reject(new Error(message));
return;
}
resolve(String(stdout).trim());
}
);
});
}
function normalizeGitError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isGitUnavailable(error: unknown): boolean {
const message = normalizeGitError(error).toLowerCase();
return message.includes('enoent') || message.includes('git: command not found');
}
async function assertUsableDirectory(projectPath: string): Promise<string> {
const trimmed = projectPath.trim();
if (!trimmed || !path.isAbsolute(trimmed)) {
throw new Error('Project path must be an absolute directory path.');
}
const stat = await fs.promises.stat(trimmed).catch(() => null);
if (!stat?.isDirectory()) {
throw new Error(`Project path is not a directory: ${trimmed}`);
}
return await fs.promises.realpath(trimmed).catch(() => trimmed);
}
function blockedStatus(
projectPath: string,
reason: NonNullable<TeamWorktreeGitStatus['reason']>,
message: string,
overrides: Partial<TeamWorktreeGitStatus> = {}
): TeamWorktreeGitStatus {
return {
projectPath,
isGitRepo: false,
hasHead: false,
canUseWorktrees: false,
reason,
message,
...overrides,
};
}
export class TeamWorktreeGitService {
async getStatus(projectPath: string): Promise<TeamWorktreeGitStatus> {
let cwd: string;
try {
cwd = await assertUsableDirectory(projectPath);
} catch (error) {
return blockedStatus(projectPath.trim(), 'invalid_project_path', normalizeGitError(error));
}
let rootPath: string;
try {
const rootRaw = await execGit(['rev-parse', '--show-toplevel'], cwd);
rootPath = await fs.promises.realpath(rootRaw).catch(() => rootRaw);
} catch (error) {
if (isGitUnavailable(error)) {
return blockedStatus(cwd, 'git_unavailable', 'Git is not available on this machine.');
}
return blockedStatus(
cwd,
'not_git_repo',
'Worktree isolation requires a Git repository. This project is not a Git repo yet.'
);
}
const hasHead = await execGit(['rev-parse', '--verify', 'HEAD'], cwd)
.then(() => true)
.catch(() => false);
const branch = await execGit(['branch', '--show-current'], cwd)
.then((value) => value || undefined)
.catch(() => undefined);
if (!hasHead) {
return blockedStatus(
cwd,
'missing_head',
'Create an initial commit before using worktrees. We will not commit files automatically.',
{
isGitRepo: true,
rootPath,
branch,
}
);
}
return {
projectPath: cwd,
isGitRepo: true,
hasHead: true,
canUseWorktrees: true,
rootPath,
branch,
};
}
async initializeRepository(projectPath: string): Promise<TeamWorktreeGitStatus> {
const current = await this.getStatus(projectPath);
if (current.isGitRepo) {
return current;
}
if (current.reason !== 'not_git_repo') {
return current;
}
const cwd = await assertUsableDirectory(projectPath);
await execGit(['init'], cwd);
return this.getStatus(cwd);
}
async createInitialCommit(projectPath: string): Promise<TeamWorktreeGitStatus> {
const current = await this.getStatus(projectPath);
if (!current.isGitRepo || !current.rootPath) {
return current;
}
if (current.hasHead) {
return current;
}
await execGit(['add', '-A'], current.rootPath);
await execGit(
[
'-c',
'user.name=Agent Teams',
'-c',
'user.email=agent-teams@local',
'commit',
'--allow-empty',
'-m',
'chore: initial commit',
],
current.rootPath
);
return this.getStatus(current.rootPath);
}
}

View file

@ -91,7 +91,8 @@ export function isOpenCodePromptDeliveryRetryableResponseState(
state === 'empty_assistant_turn' || state === 'empty_assistant_turn' ||
state === 'tool_error' || state === 'tool_error' ||
state === 'reconcile_failed' || state === 'reconcile_failed' ||
state === 'not_observed' state === 'not_observed' ||
state === 'session_stale'
); );
} }

View file

@ -255,6 +255,15 @@ export const TEAM_LAUNCH = 'team:launch';
/** Warm up provisioning runtime before create */ /** Warm up provisioning runtime before create */
export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning'; export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning';
/** Inspect whether a project can support git worktree isolation */
export const TEAM_GET_WORKTREE_GIT_STATUS = 'team:getWorktreeGitStatus';
/** Initialize a git repository for worktree isolation */
export const TEAM_INITIALIZE_GIT_REPOSITORY = 'team:initializeGitRepository';
/** Create the first commit required by git worktrees */
export const TEAM_CREATE_INITIAL_GIT_COMMIT = 'team:createInitialGitCommit';
/** Get provisioning status by runId */ /** Get provisioning status by runId */
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'; export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';

View file

@ -118,6 +118,7 @@ import {
TEAM_CANCEL_PROVISIONING, TEAM_CANCEL_PROVISIONING,
TEAM_CHANGE, TEAM_CHANGE,
TEAM_CREATE, TEAM_CREATE,
TEAM_CREATE_INITIAL_GIT_COMMIT,
TEAM_CREATE_CONFIG, TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_DRAFT, TEAM_DELETE_DRAFT,
@ -144,6 +145,8 @@ import {
TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_LOG_STREAM_SUMMARY, TEAM_GET_TASK_LOG_STREAM_SUMMARY,
TEAM_GET_WORKTREE_GIT_STATUS,
TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -315,6 +318,7 @@ import type {
TeamTaskStatus, TeamTaskStatus,
TeamUpdateConfigRequest, TeamUpdateConfigRequest,
TeamViewSnapshot, TeamViewSnapshot,
TeamWorktreeGitStatus,
ToolApprovalEvent, ToolApprovalEvent,
ToolApprovalFileContent, ToolApprovalFileContent,
ToolApprovalSettings, ToolApprovalSettings,
@ -892,6 +896,21 @@ const electronAPI: ElectronAPI = {
modelVerificationMode modelVerificationMode
); );
}, },
getWorktreeGitStatus: async (projectPath: string) => {
return invokeIpcWithResult<TeamWorktreeGitStatus>(TEAM_GET_WORKTREE_GIT_STATUS, projectPath);
},
initializeGitRepository: async (projectPath: string) => {
return invokeIpcWithResult<TeamWorktreeGitStatus>(
TEAM_INITIALIZE_GIT_REPOSITORY,
projectPath
);
},
createInitialGitCommit: async (projectPath: string) => {
return invokeIpcWithResult<TeamWorktreeGitStatus>(
TEAM_CREATE_INITIAL_GIT_COMMIT,
projectPath
);
},
createTeam: async (request: TeamCreateRequest) => { createTeam: async (request: TeamCreateRequest) => {
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request); return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
}, },

View file

@ -67,6 +67,7 @@ import type {
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamWorktreeGitStatus,
TeamsAPI, TeamsAPI,
TeamSummary, TeamSummary,
TeamTask, TeamTask,
@ -755,6 +756,22 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<TeamProvisioningPrepareResult> => { ): Promise<TeamProvisioningPrepareResult> => {
throw new Error('Team provisioning is not available in browser mode'); throw new Error('Team provisioning is not available in browser mode');
}, },
getWorktreeGitStatus: async (projectPath: string): Promise<TeamWorktreeGitStatus> => {
return {
projectPath,
isGitRepo: false,
hasHead: false,
canUseWorktrees: false,
reason: 'git_error',
message: 'Worktree Git setup is not available in browser mode',
};
},
initializeGitRepository: async (_projectPath: string): Promise<TeamWorktreeGitStatus> => {
throw new Error('Git repository initialization is not available in browser mode');
},
createInitialGitCommit: async (_projectPath: string): Promise<TeamWorktreeGitStatus> => {
throw new Error('Initial Git commit is not available in browser mode');
},
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => { createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
throw new Error('Team provisioning is not available in browser mode'); throw new Error('Team provisioning is not available in browser mode');
}, },

View file

@ -324,6 +324,10 @@ function hastToText(node: HastNode): string {
return ''; return '';
} }
function extractLanguageFromClassName(className?: string): string {
return /(?:^|\s)language-([^\s]+)/.exec(className ?? '')?.[1] ?? '';
}
// ============================================================================= // =============================================================================
// Component factories // Component factories
// ============================================================================= // =============================================================================
@ -583,8 +587,8 @@ function createViewerMarkdownComponents(
const isBlock = (hasLanguage ?? false) || isMultiLine; const isBlock = (hasLanguage ?? false) || isMultiLine;
if (isBlock) { if (isBlock) {
const lang = codeClassName?.replace('language-', '') ?? ''; const lang = extractLanguageFromClassName(codeClassName);
const raw = typeof children === 'string' ? children : ''; const raw = typeof children === 'string' ? children : extractTextFromReactNode(children);
const text = raw.replace(/\n$/, ''); const text = raw.replace(/\n$/, '');
const lines = text.split('\n'); const lines = text.split('\n');
return ( return (

View file

@ -131,6 +131,12 @@ import {
import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice'; import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice';
import { computeEffectiveTeamModel } from './TeamModelSelector'; import { computeEffectiveTeamModel } from './TeamModelSelector';
import { getNextSuggestedTeamName } from './teamNameSets'; import { getNextSuggestedTeamName } from './teamNameSets';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
useWorktreeGitReadiness,
WorktreeGitReadinessBanner,
} from './WorktreeGitReadinessBanner';
import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection'; import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection';
@ -549,6 +555,20 @@ export const CreateTeamDialog = ({
() => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members), () => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members),
[members, syncModelsWithLead] [members, syncModelsWithLead]
); );
const hasSelectedWorktreeIsolation =
!soloTeam &&
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
const worktreeGitReadiness = useWorktreeGitReadiness(
effectiveCwd || null,
open && canCreate && !soloTeam
);
const worktreeIsolationDisabledReason =
!soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null;
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
worktreeGitReadiness,
hasSelectedWorktreeIsolation
);
const worktreeGitBlocksSubmission = Boolean(worktreeGitBlockingMessage);
const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate); const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate);
const selectedMemberProviders = useMemo<TeamProviderId[]>(() => { const selectedMemberProviders = useMemo<TeamProviderId[]>(() => {
@ -1425,7 +1445,8 @@ export const CreateTeamDialog = ({
isNameProvisioning || isNameProvisioning ||
!requestValidation.valid || !requestValidation.valid ||
!!modelValidationError || !!modelValidationError ||
teammateRuntimeCompatibility.blocksSubmission; teammateRuntimeCompatibility.blocksSubmission ||
worktreeGitBlocksSubmission;
const internalArgs = useMemo(() => { const internalArgs = useMemo(() => {
const args: string[] = []; const args: string[] = [];
@ -1569,6 +1590,10 @@ export const CreateTeamDialog = ({
setLocalError(teammateRuntimeCompatibility.message); setLocalError(teammateRuntimeCompatibility.message);
return; return;
} }
if (worktreeGitBlockingMessage) {
setLocalError(worktreeGitBlockingMessage);
return;
}
setFieldErrors({}); setFieldErrors({});
setLocalError(null); setLocalError(null);
setIsSubmitting(true); setIsSubmitting(true);
@ -1781,6 +1806,7 @@ export const CreateTeamDialog = ({
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
showWorktreeIsolationControls={!soloTeam} showWorktreeIsolationControls={!soloTeam}
teammateWorktreeDefault={teammateWorktreeDefault} teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()} disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText} leadModelIssueText={leadModelIssueText}
@ -1802,17 +1828,22 @@ export const CreateTeamDialog = ({
</div> </div>
} }
headerBottom={ headerBottom={
soloTeam ? ( <div className="space-y-2">
{soloTeam ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300"> <p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; no teammates will be Only the team lead (main process) will be started &mdash; no teammates will
spawned. Works like a regular Claude session but with access to the task board be spawned. Works like a regular Claude session but with access to the task
for planning. Saves tokens by avoiding teammate coordination overhead. You can board for planning. Saves tokens by avoiding teammate coordination overhead.
add members later from the team settings. You can add members later from the team settings.
</p> </p>
</div> </div>
) : null ) : null}
{!soloTeam && canCreate ? (
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
) : null}
</div>
} }
/> />
</div> </div>

View file

@ -140,6 +140,12 @@ import {
OPENCODE_TEAM_LEAD_DISABLED_REASON, OPENCODE_TEAM_LEAD_DISABLED_REASON,
TeamModelSelector, TeamModelSelector,
} from './TeamModelSelector'; } from './TeamModelSelector';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
useWorktreeGitReadiness,
WorktreeGitReadinessBanner,
} from './WorktreeGitReadinessBanner';
import type { ActiveTeamRef } from './CreateTeamDialog'; import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -1282,6 +1288,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
? '' ? ''
: selectedProjectPath.trim(); : selectedProjectPath.trim();
const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim();
const hasSelectedWorktreeIsolation =
isLaunchMode &&
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
const worktreeGitReadiness = useWorktreeGitReadiness(effectiveCwd || null, open && isLaunchMode);
const worktreeIsolationDisabledReason = isLaunchMode
? getWorktreeGitControlDisabledReason(worktreeGitReadiness)
: null;
const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage(
worktreeGitReadiness,
hasSelectedWorktreeIsolation
);
const prepareRuntimeStatusSignature = useMemo( const prepareRuntimeStatusSignature = useMemo(
() => () =>
buildProviderPrepareRuntimeStatusSignature( buildProviderPrepareRuntimeStatusSignature(
@ -1726,13 +1743,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const validationErrors = useMemo(() => { const validationErrors = useMemo(() => {
const errors: string[] = []; const errors: string[] = [];
if (!effectiveCwd) errors.push('Working directory is required'); if (!effectiveCwd) errors.push('Working directory is required');
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
if (isSchedule) { if (isSchedule) {
if (!effectiveTeamName) errors.push('Team is required'); if (!effectiveTeamName) errors.push('Team is required');
if (!promptDraft.value.trim()) errors.push('Prompt is required'); if (!promptDraft.value.trim()) errors.push('Prompt is required');
if (!cronExpression.trim()) errors.push('Cron expression is required'); if (!cronExpression.trim()) errors.push('Cron expression is required');
} }
return errors; return errors;
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); }, [
effectiveCwd,
worktreeGitBlockingMessage,
isSchedule,
effectiveTeamName,
promptDraft.value,
cronExpression,
]);
const modelValidationError = useMemo(() => { const modelValidationError = useMemo(() => {
const leadError = getTeamModelSelectionError( const leadError = getTeamModelSelectionError(
selectedProviderId, selectedProviderId,
@ -2422,6 +2447,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onSyncModelsWithTeammatesChange={setSyncModelsWithLead} onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
showWorktreeIsolationControls showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault} teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText} leadWarningText={leadRuntimeWarningText}
memberWarningById={combinedMemberRuntimeWarningById} memberWarningById={combinedMemberRuntimeWarningById}
@ -2429,6 +2455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
softDeleteMembers softDeleteMembers
disableGeminiOption={isGeminiUiFrozen()} disableGeminiOption={isGeminiUiFrozen()}
headerBottom={<WorktreeGitReadinessBanner state={worktreeGitReadiness} />}
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">

View file

@ -0,0 +1,243 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from 'lucide-react';
import type { TeamWorktreeGitStatus } from '@shared/types';
interface WorktreeGitReadinessState {
status: TeamWorktreeGitStatus | null;
loading: boolean;
actionLoading: 'init' | 'commit' | null;
error: string | null;
refresh: () => Promise<void>;
initializeRepository: () => Promise<void>;
createInitialCommit: () => Promise<void>;
}
export function useWorktreeGitReadiness(
projectPath: string | null,
enabled: boolean
): WorktreeGitReadinessState {
const [status, setStatus] = useState<TeamWorktreeGitStatus | null>(null);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<'init' | 'commit' | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled || !projectPath?.trim()) {
setStatus(null);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setStatus(await api.teams.getWorktreeGitStatus(projectPath));
} catch (err) {
setStatus(null);
setError(err instanceof Error ? err.message : 'Failed to inspect Git repository');
} finally {
setLoading(false);
}
}, [enabled, projectPath]);
useEffect(() => {
let cancelled = false;
if (!enabled || !projectPath?.trim()) {
setStatus(null);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
void api.teams
.getWorktreeGitStatus(projectPath)
.then((nextStatus) => {
if (!cancelled) setStatus(nextStatus);
})
.catch((err) => {
if (!cancelled) {
setStatus(null);
setError(err instanceof Error ? err.message : 'Failed to inspect Git repository');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled, projectPath]);
const initializeRepository = useCallback(async () => {
if (!projectPath?.trim()) return;
setActionLoading('init');
setError(null);
try {
setStatus(await api.teams.initializeGitRepository(projectPath));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize Git repository');
} finally {
setActionLoading(null);
}
}, [projectPath]);
const createInitialCommit = useCallback(async () => {
if (!projectPath?.trim()) return;
setActionLoading('commit');
setError(null);
try {
setStatus(await api.teams.createInitialGitCommit(projectPath));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create initial Git commit');
} finally {
setActionLoading(null);
}
}, [projectPath]);
return useMemo(
() => ({
status,
loading,
actionLoading,
error,
refresh,
initializeRepository,
createInitialCommit,
}),
[actionLoading, createInitialCommit, error, initializeRepository, loading, refresh, status]
);
}
export function getWorktreeGitBlockingMessage(
state: Pick<WorktreeGitReadinessState, 'status' | 'loading' | 'error'>,
hasSelectedWorktreeIsolation: boolean
): string | null {
if (!hasSelectedWorktreeIsolation) {
return null;
}
if (state.loading) {
return 'Checking Git repository status before enabling worktree isolation.';
}
if (state.error) {
return state.error;
}
if (!state.status) {
return 'Worktree isolation requires a Git repository with an initial commit.';
}
return state.status.canUseWorktrees ? null : (state.status.message ?? null);
}
export function getWorktreeGitControlDisabledReason(
state: Pick<WorktreeGitReadinessState, 'status' | 'loading' | 'error'>
): string | null {
if (state.loading) {
return 'Checking Git repository status...';
}
if (state.error) {
return state.error;
}
if (!state.status) {
return null;
}
return state.status.canUseWorktrees ? null : (state.status.message ?? null);
}
export function WorktreeGitReadinessBanner({
state,
showReady = false,
}: {
state: WorktreeGitReadinessState;
showReady?: boolean;
}): React.JSX.Element | null {
const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } =
state;
if (loading) {
return (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2 text-[11px] leading-relaxed text-sky-300">
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin" />
<p>Checking Git repository status for teammate worktrees...</p>
</div>
);
}
if (error) {
return (
<div className="bg-red-500/8 flex items-start gap-2 rounded-md border border-red-500/25 px-3 py-2 text-[11px] leading-relaxed text-red-200">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>{error}</p>
</div>
);
}
if (!status) {
return null;
}
if (status.canUseWorktrees) {
if (!showReady) return null;
return (
<div className="flex items-start gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-[11px] leading-relaxed text-emerald-300">
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
<p>
Git worktrees are ready
{status.branch ? ` on branch ${status.branch}` : ''}.
</p>
</div>
);
}
return (
<div className="bg-amber-500/8 space-y-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-100">
<div className="flex items-start gap-2">
<GitBranch className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<div className="min-w-0 flex-1">
<p className="font-medium text-amber-100">Worktree isolation needs Git setup</p>
<p className="mt-0.5 text-amber-100/85">
{status.message ??
'Worktree isolation requires a Git repository with an initial commit.'}
</p>
{status.reason === 'missing_head' ? (
<p className="mt-1 text-amber-100/70">
The initial commit action stages and commits all current files with message{' '}
<span className="font-mono">chore: initial commit</span>.
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2 pl-5">
{status.reason === 'not_git_repo' ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[11px]"
disabled={actionLoading !== null}
onClick={initializeRepository}
>
{actionLoading === 'init' ? <Loader2 className="mr-1.5 size-3 animate-spin" /> : null}
Initialize Git repository
</Button>
) : null}
{status.reason === 'missing_head' ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 border-amber-400/50 text-[11px] text-amber-100 hover:bg-amber-500/15"
disabled={actionLoading !== null}
onClick={createInitialCommit}
>
{actionLoading === 'commit' ? <Loader2 className="mr-1.5 size-3 animate-spin" /> : null}
Create initial commit
</Button>
) : null}
</div>
</div>
);
}

View file

@ -76,6 +76,7 @@ interface MemberDraftRowProps {
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
modelIssueText?: string | null; modelIssueText?: string | null;
showWorktreeIsolationControls?: boolean; showWorktreeIsolationControls?: boolean;
worktreeIsolationDisabledReason?: string | null;
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void; onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
lockedModelAction?: { lockedModelAction?: {
label: string; label: string;
@ -124,6 +125,7 @@ export const MemberDraftRow = ({
disableGeminiOption = false, disableGeminiOption = false,
modelIssueText, modelIssueText,
showWorktreeIsolationControls = false, showWorktreeIsolationControls = false,
worktreeIsolationDisabledReason,
onWorktreeIsolationChange, onWorktreeIsolationChange,
lockedModelAction, lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
@ -218,6 +220,8 @@ export const MemberDraftRow = ({
: lockProviderModel : lockProviderModel
? (lockedModelAction?.description ?? modelLockReason) ? (lockedModelAction?.description ?? modelLockReason)
: undefined; : undefined;
const worktreeIsolationDisabled =
isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree');
const hasModelIssue = Boolean(modelIssueText); const hasModelIssue = Boolean(modelIssueText);
const runtimeSummary = formatTeamModelSummary( const runtimeSummary = formatTeamModelSummary(
effectiveProviderId, effectiveProviderId,
@ -349,13 +353,13 @@ export const MemberDraftRow = ({
<div <div
className={cn( className={cn(
'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]', 'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
isRemoved && 'cursor-not-allowed opacity-50' worktreeIsolationDisabled && 'cursor-not-allowed opacity-50'
)} )}
> >
<Checkbox <Checkbox
id={`member-${member.id}-worktree-isolation`} id={`member-${member.id}-worktree-isolation`}
checked={member.isolation === 'worktree'} checked={member.isolation === 'worktree'}
disabled={isRemoved} disabled={worktreeIsolationDisabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onWorktreeIsolationChange?.(member.id, checked === true) onWorktreeIsolationChange?.(member.id, checked === true)
} }
@ -364,7 +368,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-worktree-isolation`} htmlFor={`member-${member.id}-worktree-isolation`}
className={cn( className={cn(
'flex cursor-pointer items-center gap-1.5 text-xs font-normal', 'flex cursor-pointer items-center gap-1.5 text-xs font-normal',
isRemoved && 'cursor-not-allowed' worktreeIsolationDisabled && 'cursor-not-allowed'
)} )}
> >
<GitBranch className="size-3.5 shrink-0" /> <GitBranch className="size-3.5 shrink-0" />
@ -373,8 +377,9 @@ export const MemberDraftRow = ({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed"> <TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
Run this teammate in a separate git worktree. Apply/reject changes targets that {worktreeIsolationDisabledReason && member.isolation !== 'worktree'
worktree, not the lead workspace. ? worktreeIsolationDisabledReason
: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.'}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}

View file

@ -117,6 +117,7 @@ export interface MembersEditorSectionProps {
addMemberLockReason?: string; addMemberLockReason?: string;
showWorktreeIsolationControls?: boolean; showWorktreeIsolationControls?: boolean;
teammateWorktreeDefault?: boolean; teammateWorktreeDefault?: boolean;
worktreeIsolationDisabledReason?: string | null;
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void; onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
} }
@ -154,6 +155,7 @@ export const MembersEditorSection = ({
addMemberLockReason, addMemberLockReason,
showWorktreeIsolationControls = false, showWorktreeIsolationControls = false,
teammateWorktreeDefault = false, teammateWorktreeDefault = false,
worktreeIsolationDisabledReason,
onTeammateWorktreeDefaultChange, onTeammateWorktreeDefaultChange,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
@ -247,6 +249,9 @@ export const MembersEditorSection = ({
}; };
const updateMemberIsolation = (memberId: string, enabled: boolean): void => { const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onChange( onChange(
members.map((c) => members.map((c) =>
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
@ -255,6 +260,9 @@ export const MembersEditorSection = ({
}; };
const updateTeammateWorktreeDefault = (enabled: boolean): void => { const updateTeammateWorktreeDefault = (enabled: boolean): void => {
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onTeammateWorktreeDefaultChange?.(enabled); onTeammateWorktreeDefaultChange?.(enabled);
onChange( onChange(
members.map((member) => members.map((member) =>
@ -348,10 +356,14 @@ export const MembersEditorSection = ({
{!hideContent && ( {!hideContent && (
<> <>
{showWorktreeIsolationControls ? ( {showWorktreeIsolationControls ? (
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2"> <div
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2"
title={worktreeIsolationDisabledReason ?? undefined}
>
<Checkbox <Checkbox
id={worktreeDefaultControlId} id={worktreeDefaultControlId}
checked={teammateWorktreeDefault} checked={teammateWorktreeDefault}
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)} onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
/> />
<Label <Label
@ -386,6 +398,7 @@ export const MembersEditorSection = ({
onModelChange={updateMemberModel} onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort} onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls} showWorktreeIsolationControls={showWorktreeIsolationControls}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onWorktreeIsolationChange={updateMemberIsolation} onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId} inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel} inheritedModel={inheritedModel}

View file

@ -48,6 +48,7 @@ interface TeamRosterEditorSectionProps {
memberModelIssueById?: Record<string, string | null | undefined>; memberModelIssueById?: Record<string, string | null | undefined>;
showWorktreeIsolationControls?: boolean; showWorktreeIsolationControls?: boolean;
teammateWorktreeDefault?: boolean; teammateWorktreeDefault?: boolean;
worktreeIsolationDisabledReason?: string | null;
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void; onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
} }
@ -92,6 +93,7 @@ export const TeamRosterEditorSection = ({
memberModelIssueById, memberModelIssueById,
showWorktreeIsolationControls = false, showWorktreeIsolationControls = false,
teammateWorktreeDefault = false, teammateWorktreeDefault = false,
worktreeIsolationDisabledReason,
onTeammateWorktreeDefaultChange, onTeammateWorktreeDefaultChange,
}: TeamRosterEditorSectionProps): React.JSX.Element => { }: TeamRosterEditorSectionProps): React.JSX.Element => {
return ( return (
@ -122,6 +124,7 @@ export const TeamRosterEditorSection = ({
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
showWorktreeIsolationControls={showWorktreeIsolationControls} showWorktreeIsolationControls={showWorktreeIsolationControls}
teammateWorktreeDefault={teammateWorktreeDefault} teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange} onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
headerExtra={ headerExtra={
<div className="space-y-3"> <div className="space-y-3">

View file

@ -85,6 +85,7 @@ import type {
TeamTaskStatus, TeamTaskStatus,
TeamUpdateConfigRequest, TeamUpdateConfigRequest,
TeamViewSnapshot, TeamViewSnapshot,
TeamWorktreeGitStatus,
ToolApprovalEvent, ToolApprovalEvent,
ToolApprovalFileContent, ToolApprovalFileContent,
ToolApprovalSettings, ToolApprovalSettings,
@ -450,6 +451,9 @@ export interface TeamsAPI {
limitContext?: boolean, limitContext?: boolean,
modelVerificationMode?: TeamProvisioningModelVerificationMode modelVerificationMode?: TeamProvisioningModelVerificationMode
) => Promise<TeamProvisioningPrepareResult>; ) => Promise<TeamProvisioningPrepareResult>;
getWorktreeGitStatus: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
initializeGitRepository: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
createInitialGitCommit: (projectPath: string) => Promise<TeamWorktreeGitStatus>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>; createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>; getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
cancelProvisioning: (runId: string) => Promise<void>; cancelProvisioning: (runId: string) => Promise<void>;

View file

@ -1233,6 +1233,24 @@ export interface TeamProvisioningMemberInput {
fastMode?: TeamFastMode; fastMode?: TeamFastMode;
} }
export type TeamWorktreeGitBlockReason =
| 'invalid_project_path'
| 'not_git_repo'
| 'missing_head'
| 'git_unavailable'
| 'git_error';
export interface TeamWorktreeGitStatus {
projectPath: string;
isGitRepo: boolean;
hasHead: boolean;
canUseWorktrees: boolean;
rootPath?: string;
branch?: string;
reason?: TeamWorktreeGitBlockReason;
message?: string;
}
export interface TeamCreateRequest { export interface TeamCreateRequest {
teamName: string; teamName: string;
displayName?: string; displayName?: string;

View file

@ -1,4 +1,6 @@
import * as os from 'os'; import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { import type {
BoardTaskActivityDetailResult, BoardTaskActivityDetailResult,
@ -130,6 +132,7 @@ import {
removeTeamHandlers, removeTeamHandlers,
} from '../../../src/main/ipc/teams'; } from '../../../src/main/ipc/teams';
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager'; import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
describe('ipc teams handlers', () => { describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>(); const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -408,6 +411,43 @@ describe('ipc teams handlers', () => {
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team'); expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
}); });
it('returns stored task attachments with source-code MIME types', async () => {
const handler = handlers.get(TEAM_GET_TASK_ATTACHMENT);
expect(handler).toBeDefined();
const taskId = 'task-js';
const attachmentId = 'att-js';
const attachmentDir = path.join(
getAppDataPath(),
'task-attachments',
'my-team',
taskId
);
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
await fs.promises.mkdir(attachmentDir, { recursive: true });
await fs.promises.writeFile(
path.join(attachmentDir, `${attachmentId}--script.js`),
'const calculator = 1;\n'
);
try {
const result = (await handler!(
{} as never,
'my-team',
taskId,
attachmentId,
'text/javascript'
)) as { success: boolean; data?: string; error?: string };
expect(result.success).toBe(true);
expect(Buffer.from(result.data ?? '', 'base64').toString('utf8')).toBe(
'const calculator = 1;\n'
);
} finally {
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
}
});
it('returns explicit exact task-log summaries for a task', async () => { it('returns explicit exact task-log summaries for a task', async () => {
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({ boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
items: [ items: [

View file

@ -67,6 +67,65 @@ async function writeTaskFile(
return taskPath; return taskPath;
} }
async function writeOpenCodeDeliveryLedger(
baseDir: string,
overrides?: Partial<{
memberName: string;
laneId: string;
runtimeSessionId: string;
inboxMessageId: string;
deliveredUserMessageId: string;
observedAssistantMessageId: string | null;
taskId: string;
displayId: string;
teamName: string;
}>
): Promise<string> {
const memberName = overrides?.memberName ?? 'bob';
const laneId = overrides?.laneId ?? `secondary:opencode:${memberName}`;
const filePath = path.join(
baseDir,
'teams',
overrides?.teamName ?? TEAM_NAME,
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId),
'opencode-prompt-delivery-ledger.json'
);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify(
{
data: [
{
teamName: overrides?.teamName ?? TEAM_NAME,
memberName,
laneId,
runtimeSessionId: overrides?.runtimeSessionId ?? 'session-1',
inboxMessageId: overrides?.inboxMessageId ?? 'user-1',
deliveredUserMessageId: overrides?.deliveredUserMessageId ?? 'user-1',
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [
{
taskId: overrides?.taskId ?? TASK_ID,
displayId: overrides?.displayId ?? 'abc12345',
teamName: overrides?.teamName ?? TEAM_NAME,
},
],
},
],
},
null,
2
),
'utf8'
);
return filePath;
}
function persistedEntryPath(baseDir: string): string { function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
} }
@ -873,6 +932,7 @@ describe('ChangeExtractorService', () => {
const projectPath = path.join(tmpDir, 'repo'); const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true }); await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles'); const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
@ -1063,7 +1123,7 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled(); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
}); });
it('queues OpenCode backfill for summary-only requests without blocking board rendering', async () => { it('awaits OpenCode backfill for summary-only requests with delivery context before falling back', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' }); await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
@ -1071,6 +1131,7 @@ describe('ChangeExtractorService', () => {
const projectPath = path.join(tmpDir, 'repo'); const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true }); await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const pendingBackfill = deferred<any>(); const pendingBackfill = deferred<any>();
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise); const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
const workerClient = { const workerClient = {
@ -1105,10 +1166,13 @@ describe('ChangeExtractorService', () => {
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any { getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
); );
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); let settled = false;
const request = service
.getTaskChanges(TEAM_NAME, TASK_ID, { ...SUMMARY_OPTIONS, owner: 'bob' })
.finally(() => {
settled = true;
});
expect(result.files).toHaveLength(0);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith( expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -1116,10 +1180,14 @@ describe('ChangeExtractorService', () => {
taskId: TASK_ID, taskId: TASK_ID,
projectDir, projectDir,
workspaceRoot: projectPath, workspaceRoot: projectPath,
deliveryContextPath: expect.stringContaining('delivery-context.json'),
attributionMode: 'strict-delivery', attributionMode: 'strict-delivery',
}) })
); );
}); });
expect(settled).toBe(false);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
pendingBackfill.resolve({ pendingBackfill.resolve({
schemaVersion: 1, schemaVersion: 1,
providerId: 'opencode', providerId: 'opencode',
@ -1137,6 +1205,10 @@ describe('ChangeExtractorService', () => {
notices: [], notices: [],
diagnostics: [], diagnostics: [],
}); });
const result = await request;
expect(result.files).toHaveLength(0);
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
}); });
it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', async () => { it('does not reuse a negative OpenCode backfill cache entry after delivery context appears', async () => {
@ -1202,51 +1274,17 @@ describe('ChangeExtractorService', () => {
owner: 'bob', owner: 'bob',
status: 'completed', status: 'completed',
}); });
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1); expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toBeUndefined();
const deliveryLedgerPath = path.join( await writeOpenCodeDeliveryLedger(tmpDir);
tmpDir,
'teams',
TEAM_NAME,
'.opencode-runtime',
'lanes',
encodeURIComponent('secondary:opencode:bob'),
'opencode-prompt-delivery-ledger.json'
);
await fs.mkdir(path.dirname(deliveryLedgerPath), { recursive: true });
await fs.writeFile(
deliveryLedgerPath,
JSON.stringify(
{
data: [
{
teamName: TEAM_NAME,
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runtimeSessionId: 'session-1',
inboxMessageId: 'user-1',
deliveredUserMessageId: 'user-1',
observedAssistantMessageId: null,
prePromptCursor: null,
postPromptCursor: null,
taskRefs: [{ taskId: TASK_ID, displayId: 'abc12345', teamName: TEAM_NAME }],
},
],
},
null,
2
),
'utf8'
);
await service.getTaskChanges(TEAM_NAME, TASK_ID, { await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob', owner: 'bob',
status: 'completed', status: 'completed',
}); });
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2); expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual( expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json') expect.stringContaining('delivery-context.json')
); );
}); });

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
isOpenCodePromptDeliveryObserveLaterResponseState,
isOpenCodePromptDeliveryRetryableResponseState,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog';
import { isOpenCodePromptDeliveryRetryAttemptDue } from '@main/services/team/TeamProvisioningService';
describe('OpenCodePromptDeliveryWatchdog retry policy', () => {
it('treats stale OpenCode sessions as retryable after observation', () => {
expect(isOpenCodePromptDeliveryObserveLaterResponseState('session_stale')).toBe(true);
expect(isOpenCodePromptDeliveryRetryableResponseState('session_stale')).toBe(true);
});
it('does not retry prompt indexing states before OpenCode has had a chance to answer', () => {
expect(isOpenCodePromptDeliveryObserveLaterResponseState('prompt_not_indexed')).toBe(true);
expect(isOpenCodePromptDeliveryRetryableResponseState('prompt_not_indexed')).toBe(false);
});
it('lets due accepted stale-session records proceed to a fresh send attempt', () => {
expect(
isOpenCodePromptDeliveryRetryAttemptDue({
attemptDue: true,
ledgerRecord: {
status: 'accepted',
responseState: 'session_stale',
},
})
).toBe(true);
});
it('keeps non-due stale-session records in observation mode', () => {
expect(
isOpenCodePromptDeliveryRetryAttemptDue({
attemptDue: false,
ledgerRecord: {
status: 'accepted',
responseState: 'session_stale',
},
})
).toBe(false);
});
});

View file

@ -3277,6 +3277,155 @@ describe('TeamProvisioningService', () => {
); );
}); });
it('retries due stale OpenCode sessions instead of observing forever', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: `cursor-${sendMessageToMember.mock.calls.length}`,
responseObservation: {
state: 'pending',
deliveredUserMessageId: `oc-user-${sendMessageToMember.mock.calls.length}`,
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'assistant_response_pending',
},
diagnostics: [],
}));
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
responseObservation: {
state: 'session_stale',
deliveredUserMessageId: null,
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'resolved_behavior_changed:old->new',
},
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
observeMessageDelivery,
} as any,
])
);
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-stale-session',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
responsePending: true,
responseState: 'pending',
});
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: tempTeamsBase,
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
fileName: 'opencode-prompt-delivery-ledger.json',
});
const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
data: Array<{
status: string;
responseState: string;
nextAttemptAt: string | null;
lastReason: string | null;
}>;
};
Object.assign(ledgerEnvelope.data[0], {
status: 'accepted',
responseState: 'session_stale',
nextAttemptAt: '2000-01-01T00:00:00.000Z',
lastReason: 'resolved_behavior_changed:old->new',
});
await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8');
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-stale-session',
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
responsePending: true,
});
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({
runId: 'opencode-run-bob',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
messageId: 'msg-stale-session',
});
});
it('keeps OpenCode ack-only plain-text responses pending instead of committing read', async () => { it('keeps OpenCode ack-only plain-text responses pending instead of committing read', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({ const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({

View file

@ -0,0 +1,70 @@
import { execFile } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { TeamWorktreeGitService } from '../../../../src/main/services/team/TeamWorktreeGitService';
function execGit(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(new Error(String(stderr || error.message).trim()));
return;
}
resolve(String(stdout).trim());
});
});
}
describe('TeamWorktreeGitService', () => {
let tempRoot = '';
let projectPath = '';
let service: TeamWorktreeGitService;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-worktree-git-'));
projectPath = path.join(tempRoot, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(path.join(projectPath, 'README.md'), 'hello\n', 'utf8');
service = new TeamWorktreeGitService();
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('reports non-git projects as blocked but initializable', async () => {
await expect(service.getStatus(projectPath)).resolves.toMatchObject({
isGitRepo: false,
hasHead: false,
canUseWorktrees: false,
reason: 'not_git_repo',
});
});
it('initializes git without silently creating a commit', async () => {
await expect(service.initializeRepository(projectPath)).resolves.toMatchObject({
isGitRepo: true,
hasHead: false,
canUseWorktrees: false,
reason: 'missing_head',
});
});
it('creates an explicit initial commit for all current files', async () => {
await service.initializeRepository(projectPath);
await expect(service.createInitialCommit(projectPath)).resolves.toMatchObject({
isGitRepo: true,
hasHead: true,
canUseWorktrees: true,
});
await expect(execGit(['log', '--format=%s', '-1'], projectPath)).resolves.toBe(
'chore: initial commit'
);
await expect(execGit(['ls-files'], projectPath)).resolves.toContain('README.md');
});
});

View file

@ -0,0 +1,91 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
},
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
teams: [],
openTeamTab: vi.fn(),
searchMatchItemIds: new Set<string>(),
searchQuery: '',
searchMatches: [],
currentSearchIndex: -1,
}),
}));
vi.mock('@renderer/components/team/TaskTooltip', () => ({
TaskTooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/team/members/MemberHoverCard', () => ({
MemberHoverCard: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
FileLink: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
isRelativeUrl: () => false,
}));
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
describe('MarkdownViewer code blocks', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('renders highlighted fenced code content instead of an empty copy-only block', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<MarkdownViewer
content={[
'Содержимое файла `472/script.js`:',
'',
'```javascript',
'const calculator = {',
" displayValue: '0',",
'};',
'',
'function updateDisplay() {',
" const display = document.querySelector('.calculator-screen');",
' display.value = calculator.displayValue;',
'}',
'```',
].join('\n')}
maxHeight="max-h-none"
bare
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Содержимое файла');
expect(host.textContent).toContain('const calculator');
expect(host.textContent).toContain('function updateDisplay');
expect(host.textContent).toContain('calculator.displayValue');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -43,6 +43,25 @@ vi.mock('@renderer/api', () => ({
getSavedRequest: vi.fn(async () => null), getSavedRequest: vi.fn(async () => null),
replaceMembers: vi.fn(async () => {}), replaceMembers: vi.fn(async () => {}),
prepareProvisioning: vi.fn(async () => ({})), prepareProvisioning: vi.fn(async () => ({})),
getWorktreeGitStatus: vi.fn(async (projectPath: string) => ({
projectPath,
isGitRepo: true,
hasHead: true,
canUseWorktrees: true,
})),
initializeGitRepository: vi.fn(async (projectPath: string) => ({
projectPath,
isGitRepo: true,
hasHead: false,
canUseWorktrees: false,
reason: 'missing_head',
})),
createInitialGitCommit: vi.fn(async (projectPath: string) => ({
projectPath,
isGitRepo: true,
hasHead: true,
canUseWorktrees: true,
})),
}, },
tmux: { tmux: {
getStatus: vi.fn(() => getStatus: vi.fn(() =>

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
} from '@renderer/components/team/dialogs/WorktreeGitReadinessBanner';
describe('WorktreeGitReadinessBanner helpers', () => {
it('does not block submit when no teammate selected worktree isolation', () => {
expect(
getWorktreeGitBlockingMessage(
{
loading: false,
error: null,
status: {
projectPath: '/project',
isGitRepo: false,
hasHead: false,
canUseWorktrees: false,
reason: 'not_git_repo',
message: 'not ready',
},
},
false
)
).toBeNull();
});
it('blocks selected worktree isolation until git has a HEAD commit', () => {
const state = {
loading: false,
error: null,
status: {
projectPath: '/project',
isGitRepo: true,
hasHead: false,
canUseWorktrees: false,
reason: 'missing_head' as const,
message: 'Create an initial commit before using worktrees.',
},
};
expect(getWorktreeGitBlockingMessage(state, true)).toBe(
'Create an initial commit before using worktrees.'
);
expect(getWorktreeGitControlDisabledReason(state)).toBe(
'Create an initial commit before using worktrees.'
);
});
it('allows worktree controls when git worktrees are ready', () => {
const state = {
loading: false,
error: null,
status: {
projectPath: '/project',
isGitRepo: true,
hasHead: true,
canUseWorktrees: true,
},
};
expect(getWorktreeGitBlockingMessage(state, true)).toBeNull();
expect(getWorktreeGitControlDisabledReason(state)).toBeNull();
});
});