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_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_INITIAL_GIT_COMMIT,
TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK,
TEAM_DELETE_DRAFT,
@ -37,6 +38,8 @@ import {
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
TEAM_GET_WORKTREE_GIT_STATUS,
TEAM_INITIALIZE_GIT_REPOSITORY,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
@ -130,6 +133,7 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import {
validateFromField,
@ -205,6 +209,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TeamWorktreeGitStatus,
TeamViewSnapshot,
ToolApprovalFileContent,
ToolApprovalSettings,
@ -514,6 +519,7 @@ let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
const teamMetaStore = new TeamMetaStore();
const worktreeGitService = new TeamWorktreeGitService();
const ALLOWED_ATTACHMENT_TYPES = new Set([
'image/png',
@ -525,6 +531,16 @@ const ALLOWED_ATTACHMENT_TYPES = new Set([
]);
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.
* @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_GET_CLAUDE_LOGS, handleGetClaudeLogs);
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_LAUNCH, handleLaunchTeam);
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_GET_CLAUDE_LOGS);
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_LAUNCH);
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[]>> {
setCurrentMainOp('team:list');
const startedAt = Date.now();
@ -4267,10 +4337,9 @@ async function handleAddTaskComment(
if (
typeof a.id !== 'string' ||
typeof a.filename !== 'string' ||
typeof a.mimeType !== 'string' ||
!isValidStoredAttachmentMimeType(a.mimeType) ||
typeof a.base64Data !== 'string' ||
a.base64Data.length === 0 ||
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
a.base64Data.length === 0
) {
throw new Error('Invalid attachment data');
}
@ -4283,7 +4352,7 @@ async function handleAddTaskComment(
vTask.value!,
safeId,
a.filename,
a.mimeType,
a.mimeType.trim(),
a.base64Data
);
savedAttachments.push(meta);
@ -4386,11 +4455,8 @@ async function handleSaveTaskAttachment(
if (typeof filename !== 'string' || filename.trim().length === 0) {
return { success: false, error: 'filename must be a non-empty string' };
}
if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) {
return {
success: false,
error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`,
};
if (!isValidStoredAttachmentMimeType(mimeType)) {
return { success: false, error: 'Invalid mimeType' };
}
if (typeof base64Data !== 'string' || base64Data.length === 0) {
return { success: false, error: 'base64Data must be a non-empty string' };
@ -4407,7 +4473,7 @@ async function handleSaveTaskAttachment(
vTask.value!,
safeAttId,
filename,
mimeType,
mimeType.trim(),
base64Data
);
// Write metadata into the task JSON
@ -4430,7 +4496,7 @@ async function handleGetTaskAttachment(
if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) {
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' };
}
const safeAttId = attachmentId.trim();
@ -4439,7 +4505,7 @@ async function handleGetTaskAttachment(
}
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) {
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' };
}
const safeAttId = attachmentId.trim();
@ -4466,7 +4532,12 @@ async function handleDeleteTaskAttachment(
}
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
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
});

View file

@ -209,9 +209,7 @@ export class ChangeExtractorService {
return ledgerResult;
}
if (!includeDetails) {
this.enqueueOpenCodeLedgerBackfill(resolvedInput);
} else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) {
await this.recordTaskChangePresence(
@ -435,6 +433,28 @@ export class ChangeExtractorService {
}
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);
if (existing) {
return existing;
@ -454,14 +474,6 @@ export class ChangeExtractorService {
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(
input: ResolvedTaskChangeComputeInput,
projectDir: string,

View file

@ -1504,6 +1504,20 @@ function nowIso(): string {
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 {
const updatedAt = nowIso();
return {
@ -5573,9 +5587,10 @@ export class TeamProvisioningService {
};
}
const retryDueBeforeObserve =
attemptDue &&
(ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable');
const retryDueBeforeObserve = isOpenCodePromptDeliveryRetryAttemptDue({
attemptDue,
ledgerRecord,
});
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
const observed = await adapter.observeMessageDelivery({
...(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 === 'tool_error' ||
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 */
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 */
export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus';

View file

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

View file

@ -67,6 +67,7 @@ import type {
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamWorktreeGitStatus,
TeamsAPI,
TeamSummary,
TeamTask,
@ -755,6 +756,22 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<TeamProvisioningPrepareResult> => {
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> => {
throw new Error('Team provisioning is not available in browser mode');
},

View file

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

View file

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

View file

@ -140,6 +140,12 @@ import {
OPENCODE_TEAM_LEAD_DISABLED_REASON,
TeamModelSelector,
} from './TeamModelSelector';
import {
getWorktreeGitBlockingMessage,
getWorktreeGitControlDisabledReason,
useWorktreeGitReadiness,
WorktreeGitReadinessBanner,
} from './WorktreeGitReadinessBanner';
import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -1282,6 +1288,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
? ''
: selectedProjectPath.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(
() =>
buildProviderPrepareRuntimeStatusSignature(
@ -1726,13 +1743,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const validationErrors = useMemo(() => {
const errors: string[] = [];
if (!effectiveCwd) errors.push('Working directory is required');
if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage);
if (isSchedule) {
if (!effectiveTeamName) errors.push('Team is required');
if (!promptDraft.value.trim()) errors.push('Prompt is required');
if (!cronExpression.trim()) errors.push('Cron expression is required');
}
return errors;
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
}, [
effectiveCwd,
worktreeGitBlockingMessage,
isSchedule,
effectiveTeamName,
promptDraft.value,
cronExpression,
]);
const modelValidationError = useMemo(() => {
const leadError = getTeamModelSelectionError(
selectedProviderId,
@ -2422,6 +2447,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
memberWarningById={combinedMemberRuntimeWarningById}
@ -2429,6 +2455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
memberModelIssueById={memberModelIssueById}
softDeleteMembers
disableGeminiOption={isGeminiUiFrozen()}
headerBottom={<WorktreeGitReadinessBanner state={worktreeGitReadiness} />}
/>
<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;
modelIssueText?: string | null;
showWorktreeIsolationControls?: boolean;
worktreeIsolationDisabledReason?: string | null;
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
lockedModelAction?: {
label: string;
@ -124,6 +125,7 @@ export const MemberDraftRow = ({
disableGeminiOption = false,
modelIssueText,
showWorktreeIsolationControls = false,
worktreeIsolationDisabledReason,
onWorktreeIsolationChange,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
@ -218,6 +220,8 @@ export const MemberDraftRow = ({
: lockProviderModel
? (lockedModelAction?.description ?? modelLockReason)
: undefined;
const worktreeIsolationDisabled =
isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree');
const hasModelIssue = Boolean(modelIssueText);
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
@ -349,13 +353,13 @@ export const MemberDraftRow = ({
<div
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)]',
isRemoved && 'cursor-not-allowed opacity-50'
worktreeIsolationDisabled && 'cursor-not-allowed opacity-50'
)}
>
<Checkbox
id={`member-${member.id}-worktree-isolation`}
checked={member.isolation === 'worktree'}
disabled={isRemoved}
disabled={worktreeIsolationDisabled}
onCheckedChange={(checked) =>
onWorktreeIsolationChange?.(member.id, checked === true)
}
@ -364,7 +368,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-worktree-isolation`}
className={cn(
'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" />
@ -373,8 +377,9 @@ export const MemberDraftRow = ({
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
Run this teammate in a separate git worktree. Apply/reject changes targets that
worktree, not the lead workspace.
{worktreeIsolationDisabledReason && member.isolation !== 'worktree'
? worktreeIsolationDisabledReason
: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.'}
</TooltipContent>
</Tooltip>
) : null}

View file

@ -117,6 +117,7 @@ export interface MembersEditorSectionProps {
addMemberLockReason?: string;
showWorktreeIsolationControls?: boolean;
teammateWorktreeDefault?: boolean;
worktreeIsolationDisabledReason?: string | null;
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
}
@ -154,6 +155,7 @@ export const MembersEditorSection = ({
addMemberLockReason,
showWorktreeIsolationControls = false,
teammateWorktreeDefault = false,
worktreeIsolationDisabledReason,
onTeammateWorktreeDefaultChange,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
@ -247,6 +249,9 @@ export const MembersEditorSection = ({
};
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onChange(
members.map((c) =>
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
@ -255,6 +260,9 @@ export const MembersEditorSection = ({
};
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
if (enabled && worktreeIsolationDisabledReason) {
return;
}
onTeammateWorktreeDefaultChange?.(enabled);
onChange(
members.map((member) =>
@ -348,10 +356,14 @@ export const MembersEditorSection = ({
{!hideContent && (
<>
{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
id={worktreeDefaultControlId}
checked={teammateWorktreeDefault}
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
/>
<Label
@ -386,6 +398,7 @@ export const MembersEditorSection = ({
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}

View file

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

View file

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

View file

@ -1233,6 +1233,24 @@ export interface TeamProvisioningMemberInput {
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 {
teamName: string;
displayName?: string;

View file

@ -1,4 +1,6 @@
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 type {
BoardTaskActivityDetailResult,
@ -130,6 +132,7 @@ import {
removeTeamHandlers,
} from '../../../src/main/ipc/teams';
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -408,6 +411,43 @@ describe('ipc teams handlers', () => {
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 () => {
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
items: [

View file

@ -67,6 +67,65 @@ async function writeTaskFile(
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 {
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');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
@ -1063,7 +1123,7 @@ describe('ChangeExtractorService', () => {
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-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
@ -1071,6 +1131,7 @@ describe('ChangeExtractorService', () => {
const projectPath = path.join(tmpDir, 'repo');
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(projectPath, { recursive: true });
await writeOpenCodeDeliveryLedger(tmpDir);
const pendingBackfill = deferred<any>();
const backfillOpenCodeTaskLedger = vi.fn(() => pendingBackfill.promise);
const workerClient = {
@ -1105,10 +1166,13 @@ describe('ChangeExtractorService', () => {
{ 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(() => {
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
expect.objectContaining({
@ -1116,10 +1180,14 @@ describe('ChangeExtractorService', () => {
taskId: TASK_ID,
projectDir,
workspaceRoot: projectPath,
deliveryContextPath: expect.stringContaining('delivery-context.json'),
attributionMode: 'strict-delivery',
})
);
});
expect(settled).toBe(false);
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
pendingBackfill.resolve({
schemaVersion: 1,
providerId: 'opencode',
@ -1137,6 +1205,10 @@ describe('ChangeExtractorService', () => {
notices: [],
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 () => {
@ -1202,51 +1274,17 @@ describe('ChangeExtractorService', () => {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toBeUndefined();
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
const deliveryLedgerPath = path.join(
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 writeOpenCodeDeliveryLedger(tmpDir);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'bob',
status: 'completed',
});
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
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 () => {
const svc = new TeamProvisioningService();
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),
replaceMembers: 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: {
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();
});
});