diff --git a/src/db.test.ts b/src/db.test.ts index 466fb07..025315b 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -689,7 +689,9 @@ describe('paired task state', () => { expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready'); expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running'); expect( - listPairedWorkspacesForTask('paired-task-2').map((workspace) => workspace.workspace_dir), + listPairedWorkspacesForTask('paired-task-2').map( + (workspace) => workspace.workspace_dir, + ), ).toEqual(['/tmp/reviewer-v2']); }); }); diff --git a/src/db.ts b/src/db.ts index e88769a..e67a658 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1808,9 +1808,9 @@ export function createPairedTask(task: PairedTask): void { } export function getPairedTaskById(id: string): PairedTask | undefined { - return db - .prepare('SELECT * FROM paired_tasks WHERE id = ?') - .get(id) as PairedTask | undefined; + return db.prepare('SELECT * FROM paired_tasks WHERE id = ?').get(id) as + | PairedTask + | undefined; } export function getLatestOpenPairedTaskForChat( @@ -1905,9 +1905,9 @@ export function createPairedExecution(execution: PairedExecution): void { export function getPairedExecutionById( id: string, ): PairedExecution | undefined { - return db - .prepare('SELECT * FROM paired_executions WHERE id = ?') - .get(id) as PairedExecution | undefined; + return db.prepare('SELECT * FROM paired_executions WHERE id = ?').get(id) as + | PairedExecution + | undefined; } export function updatePairedExecution( diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 7970dfb..19b0584 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -310,52 +310,52 @@ describe('runAgentForGroup room memory', () => { workDir: '/repo/canonical', }; - vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue( - { - task: { - id: 'paired-task-1', - chat_jid: 'group@test', - group_folder: 'test-group', - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - review_requested_at: null, - status: 'draft', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }, - execution: { - id: 'run-room-role:claude', - task_id: 'paired-task-1', - service_id: 'claude', - role: 'owner', - workspace_id: 'paired-task-1:owner', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }, - workspace: { - id: 'paired-task-1:owner', - task_id: 'paired-task-1', - role: 'owner', - workspace_dir: '/tmp/paired/owner', - snapshot_source_dir: null, - status: 'ready', - snapshot_refreshed_at: null, - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }, - envOverrides: { - EJCLAW_WORK_DIR: '/tmp/paired/owner', - EJCLAW_PAIRED_TASK_ID: 'paired-task-1', - EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude', - EJCLAW_PAIRED_ROLE: 'owner', - }, + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-1', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + review_requested_at: null, + status: 'draft', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', }, - ); + execution: { + id: 'run-room-role:claude', + task_id: 'paired-task-1', + service_id: 'claude', + role: 'owner', + workspace_id: 'paired-task-1:owner', + status: 'running', + summary: null, + created_at: '2026-03-28T00:00:00.000Z', + started_at: '2026-03-28T00:00:00.000Z', + completed_at: null, + }, + workspace: { + id: 'paired-task-1:owner', + task_id: 'paired-task-1', + role: 'owner', + workspace_dir: '/tmp/paired/owner', + snapshot_source_dir: null, + status: 'ready', + snapshot_refreshed_at: null, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }, + envOverrides: { + EJCLAW_WORK_DIR: '/tmp/paired/owner', + EJCLAW_PAIRED_TASK_ID: 'paired-task-1', + EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude', + EJCLAW_PAIRED_ROLE: 'owner', + }, + }); await runAgentForGroup(makeDeps(), { group, @@ -403,44 +403,44 @@ describe('runAgentForGroup room memory', () => { reason: null, explicit: false, }); - vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue( - { - task: { - id: 'paired-task-1', - chat_jid: 'group@test', - group_folder: 'test-group', - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - review_requested_at: null, - status: 'draft', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }, - execution: { - id: 'run-blocked-reviewer:claude', - task_id: 'paired-task-1', - service_id: 'claude', - role: 'reviewer', - workspace_id: null, - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }, - workspace: null, - envOverrides: { - EJCLAW_PAIRED_TASK_ID: 'paired-task-1', - EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude', - EJCLAW_PAIRED_ROLE: 'reviewer', - EJCLAW_REVIEWER_RUNTIME: '1', - }, - blockMessage: - 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-1', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + review_requested_at: null, + status: 'draft', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', }, - ); + execution: { + id: 'run-blocked-reviewer:claude', + task_id: 'paired-task-1', + service_id: 'claude', + role: 'reviewer', + workspace_id: null, + status: 'running', + summary: null, + created_at: '2026-03-28T00:00:00.000Z', + started_at: '2026-03-28T00:00:00.000Z', + completed_at: null, + }, + workspace: null, + envOverrides: { + EJCLAW_PAIRED_TASK_ID: 'paired-task-1', + EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude', + EJCLAW_PAIRED_ROLE: 'reviewer', + EJCLAW_REVIEWER_RUNTIME: '1', + }, + blockMessage: + 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', + }); const result = await runAgentForGroup(makeDeps(), { group, @@ -449,7 +449,10 @@ describe('runAgentForGroup room memory', () => { runId: 'run-blocked-reviewer', onOutput: async (output) => { outputs.push({ - text: output.output && 'text' in output.output ? output.output.text : undefined, + text: + output.output && 'text' in output.output + ? output.output.text + : undefined, result: output.result, }); }, diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 6635deb..db8b605 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -281,7 +281,10 @@ export async function runAgentForGroup( const outputText = getAgentOutputText(output); if (typeof outputText === 'string' && outputText.length > 0) { pairedExecutionSummary = outputText.slice(0, 500); - } else if (typeof output.error === 'string' && output.error.length > 0) { + } else if ( + typeof output.error === 'string' && + output.error.length > 0 + ) { pairedExecutionSummary = output.error.slice(0, 500); } if ( @@ -596,261 +599,269 @@ export async function runAgentForGroup( try { let primaryAttempt = await runAttempt(provider); - const isRetryableClaudeSessionFailure = ( - attempt: Awaited>, - ): boolean => - isClaudeCodeAgent && - provider === 'claude' && - !attempt.sawOutput && - (attempt.retryableSessionFailureDetected === true || - (attempt.error != null && - shouldRetryFreshSessionOnAgentFailure({ - result: null, - error: getErrorMessage(attempt.error), - }))); - - if (isRetryableClaudeSessionFailure(primaryAttempt)) { - deps.clearSession(group.folder); - logger.warn( - { group: group.name, chatJid, runId }, - 'Cleared poisoned Claude session before visible output, retrying fresh session', - ); - - primaryAttempt = await runAttempt('claude'); + const isRetryableClaudeSessionFailure = ( + attempt: Awaited>, + ): boolean => + isClaudeCodeAgent && + provider === 'claude' && + !attempt.sawOutput && + (attempt.retryableSessionFailureDetected === true || + (attempt.error != null && + shouldRetryFreshSessionOnAgentFailure({ + result: null, + error: getErrorMessage(attempt.error), + }))); if (isRetryableClaudeSessionFailure(primaryAttempt)) { deps.clearSession(group.folder); logger.warn( { group: group.name, chatJid, runId }, - 'Fresh Claude retry also hit a retryable session failure', + 'Cleared poisoned Claude session before visible output, retrying fresh session', ); + primaryAttempt = await runAttempt('claude'); + + if (isRetryableClaudeSessionFailure(primaryAttempt)) { + deps.clearSession(group.folder); + logger.warn( + { group: group.name, chatJid, runId }, + 'Fresh Claude retry also hit a retryable session failure', + ); + + logger.error( + { group: group.name, chatJid, runId }, + 'Retryable Claude session failure persisted after fresh retry', + ); + return 'error'; + } + } + + if (primaryAttempt.error) { + if ( + canRotateToken && + provider === 'claude' && + !primaryAttempt.sawOutput + ) { + const errMsg = getErrorMessage(primaryAttempt.error); + const trigger = primaryAttempt.streamedTriggerReason + ? { + shouldRetry: true, + reason: primaryAttempt.streamedTriggerReason.reason, + retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, + } + : classifyRotationTrigger(errMsg); + if (trigger.shouldRetry) { + const result = await retryClaudeWithRotation( + { + reason: trigger.reason, + retryAfterMs: trigger.retryAfterMs, + }, + errMsg, + ); + if (result === 'error') { + return maybeHandoffAfterError(trigger.reason, primaryAttempt); + } + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + } + } + + if (!isClaudeCodeAgent) { + const errMsg = getErrorMessage(primaryAttempt.error); + const trigger = detectCodexRotationTrigger(errMsg); + if (trigger.shouldRotate && getCodexAccountCount() > 1) { + const result = await retryCodexWithRotation( + { reason: trigger.reason }, + errMsg, + ); + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + } + } + logger.error( - { group: group.name, chatJid, runId }, - 'Retryable Claude session failure persisted after fresh retry', + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + provider, + err: primaryAttempt.error, + }, + 'Agent error', ); return 'error'; } - } - if (primaryAttempt.error) { - if (canRotateToken && provider === 'claude' && !primaryAttempt.sawOutput) { - const errMsg = getErrorMessage(primaryAttempt.error); - const trigger = primaryAttempt.streamedTriggerReason - ? { - shouldRetry: true, - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - } - : classifyRotationTrigger(errMsg); - if (trigger.shouldRetry) { - const result = await retryClaudeWithRotation( - { - reason: trigger.reason, - retryAfterMs: trigger.retryAfterMs, - }, - errMsg, - ); - if (result === 'error') { - return maybeHandoffAfterError(trigger.reason, primaryAttempt); - } - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } - } - - if (!isClaudeCodeAgent) { - const errMsg = getErrorMessage(primaryAttempt.error); - const trigger = detectCodexRotationTrigger(errMsg); - if (trigger.shouldRotate && getCodexAccountCount() > 1) { - const result = await retryCodexWithRotation( - { reason: trigger.reason }, - errMsg, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } - } - - logger.error( - { - chatJid, - group: group.name, - groupFolder: group.folder, - runId, - provider, - err: primaryAttempt.error, - }, - 'Agent error', - ); - return 'error'; - } - - const output = primaryAttempt.output; - if (!output) { - logger.error( - { - chatJid, - group: group.name, - groupFolder: group.folder, - runId, - provider, - }, - 'Agent produced no output object', - ); - return 'error'; - } - - if (!pairedExecutionSummary) { - const finalOutputText = getAgentOutputText(output); - pairedExecutionSummary = - (typeof finalOutputText === 'string' && finalOutputText.length > 0 - ? finalOutputText.slice(0, 500) - : null) ?? - (typeof output.error === 'string' && output.error.length > 0 - ? output.error.slice(0, 500) - : null); - } - - if ( - canRotateToken && - provider === 'claude' && - !primaryAttempt.sawOutput && - primaryAttempt.streamedTriggerReason && - output.status !== 'error' - ) { - const result = await retryClaudeWithRotation({ - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - }); - if (result === 'error') { - return maybeHandoffAfterError( - primaryAttempt.streamedTriggerReason.reason, - primaryAttempt, + const output = primaryAttempt.output; + if (!output) { + logger.error( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + provider, + }, + 'Agent produced no output object', ); + return 'error'; } - return result; - } - if ( - isClaudeCodeAgent && - (resetSessionRequested || shouldResetSessionOnAgentFailure(output)) - ) { - deps.clearSession(group.folder); - logger.warn( - { group: group.name, chatJid, runId }, - 'Cleared poisoned agent session after unrecoverable error', - ); - } + if (!pairedExecutionSummary) { + const finalOutputText = getAgentOutputText(output); + pairedExecutionSummary = + (typeof finalOutputText === 'string' && finalOutputText.length > 0 + ? finalOutputText.slice(0, 500) + : null) ?? + (typeof output.error === 'string' && output.error.length > 0 + ? output.error.slice(0, 500) + : null); + } - if (output.status === 'error') { - if (canRotateToken && provider === 'claude' && !primaryAttempt.sawOutput) { - const trigger = primaryAttempt.streamedTriggerReason - ? { - shouldRetry: true, - reason: primaryAttempt.streamedTriggerReason.reason, - retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, - } - : classifyRotationTrigger(output.error); - if (trigger.shouldRetry) { - const result = await retryClaudeWithRotation( - { - reason: trigger.reason, - retryAfterMs: trigger.retryAfterMs, - }, - output.error ?? undefined, + if ( + canRotateToken && + provider === 'claude' && + !primaryAttempt.sawOutput && + primaryAttempt.streamedTriggerReason && + output.status !== 'error' + ) { + const result = await retryClaudeWithRotation({ + reason: primaryAttempt.streamedTriggerReason.reason, + retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, + }); + if (result === 'error') { + return maybeHandoffAfterError( + primaryAttempt.streamedTriggerReason.reason, + primaryAttempt, ); - if (result === 'error') { - return maybeHandoffAfterError(trigger.reason, primaryAttempt); - } - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; } + return result; } - if (!isClaudeCodeAgent && getCodexAccountCount() > 1) { - const trigger = detectCodexRotationTrigger(output.error); - if (trigger.shouldRotate) { - const result = await retryCodexWithRotation( - { reason: trigger.reason }, - output.error ?? undefined, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } - } - - logger.error( - { - group: group.name, - chatJid, - runId, - provider, - error: output.error, - }, - 'Agent process error', - ); - return 'error'; - } - - if ( - !isClaudeCodeAgent && - primaryAttempt.streamedTriggerReason && - getCodexAccountCount() > 1 - ) { - const result = await retryCodexWithRotation( - { - reason: primaryAttempt.streamedTriggerReason - .reason as CodexRotationReason, - }, - output.error ?? output.result ?? undefined, - ); - if (result === 'success') { - pairedExecutionStatus = 'succeeded'; - } - return result; - } - - // Unresolved streamed trigger — rotation was unavailable or output was - // already forwarded. Surfaces as an error since there is no alternative provider. - if (primaryAttempt.streamedTriggerReason) { if ( isClaudeCodeAgent && - maybeHandoffToCodex( - primaryAttempt.streamedTriggerReason.reason, - primaryAttempt.sawVisibleOutput, - ) + (resetSessionRequested || shouldResetSessionOnAgentFailure(output)) ) { - return 'success'; + deps.clearSession(group.folder); + logger.warn( + { group: group.name, chatJid, runId }, + 'Cleared poisoned agent session after unrecoverable error', + ); } - logger.error( - { - group: group.name, - chatJid, - runId, - reason: primaryAttempt.streamedTriggerReason.reason, - }, - 'Agent trigger detected but could not be resolved', - ); - return 'error'; - } - // success-null-result with no visible output — agent returned nothing useful - if (primaryAttempt.sawSuccessNullResultWithoutOutput) { - logger.error( - { group: group.name, chatJid, runId }, - 'Agent returned success with null result and no visible output', - ); - return 'error'; - } + if (output.status === 'error') { + if ( + canRotateToken && + provider === 'claude' && + !primaryAttempt.sawOutput + ) { + const trigger = primaryAttempt.streamedTriggerReason + ? { + shouldRetry: true, + reason: primaryAttempt.streamedTriggerReason.reason, + retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, + } + : classifyRotationTrigger(output.error); + if (trigger.shouldRetry) { + const result = await retryClaudeWithRotation( + { + reason: trigger.reason, + retryAfterMs: trigger.retryAfterMs, + }, + output.error ?? undefined, + ); + if (result === 'error') { + return maybeHandoffAfterError(trigger.reason, primaryAttempt); + } + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + } + } + + if (!isClaudeCodeAgent && getCodexAccountCount() > 1) { + const trigger = detectCodexRotationTrigger(output.error); + if (trigger.shouldRotate) { + const result = await retryCodexWithRotation( + { reason: trigger.reason }, + output.error ?? undefined, + ); + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + } + } + + logger.error( + { + group: group.name, + chatJid, + runId, + provider, + error: output.error, + }, + 'Agent process error', + ); + return 'error'; + } + + if ( + !isClaudeCodeAgent && + primaryAttempt.streamedTriggerReason && + getCodexAccountCount() > 1 + ) { + const result = await retryCodexWithRotation( + { + reason: primaryAttempt.streamedTriggerReason + .reason as CodexRotationReason, + }, + output.error ?? output.result ?? undefined, + ); + if (result === 'success') { + pairedExecutionStatus = 'succeeded'; + } + return result; + } + + // Unresolved streamed trigger — rotation was unavailable or output was + // already forwarded. Surfaces as an error since there is no alternative provider. + if (primaryAttempt.streamedTriggerReason) { + if ( + isClaudeCodeAgent && + maybeHandoffToCodex( + primaryAttempt.streamedTriggerReason.reason, + primaryAttempt.sawVisibleOutput, + ) + ) { + return 'success'; + } + logger.error( + { + group: group.name, + chatJid, + runId, + reason: primaryAttempt.streamedTriggerReason.reason, + }, + 'Agent trigger detected but could not be resolved', + ); + return 'error'; + } + + // success-null-result with no visible output — agent returned nothing useful + if (primaryAttempt.sawSuccessNullResultWithoutOutput) { + logger.error( + { group: group.name, chatJid, runId }, + 'Agent returned success with null result and no visible output', + ); + return 'error'; + } pairedExecutionStatus = 'succeeded'; return 'success'; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index fbdaf6c..8edc7e4 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -236,9 +236,9 @@ describe('paired execution context', () => { roomRoleContext: ownerContext, }); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).toHaveBeenCalledWith( - 'task-1', - ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).toHaveBeenCalledWith('task-1'); expect(result?.task.status).toBe('review_ready'); }); }); diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index 3fbb155..c8a0210 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -100,7 +100,10 @@ describe('paired workspace manager', () => { manager.markPairedTaskReviewReady('paired-task-1'); expect( - fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'README.md'), 'utf-8'), + fs.readFileSync( + path.join(reviewerWorkspace.workspace_dir, 'README.md'), + 'utf-8', + ), ).toBe('owner modified\n'); expect( fs.readFileSync( @@ -109,14 +112,15 @@ describe('paired workspace manager', () => { ), ).toBe('review me\n'); expect( - runGit(['config', '--local', '--get', 'remote.origin.pushurl'], reviewerWorkspace.workspace_dir), + runGit( + ['config', '--local', '--get', 'remote.origin.pushurl'], + reviewerWorkspace.workspace_dir, + ), ).toBe('DISABLED_BY_EJCLAW'); expect( runGit(['status', '--short'], reviewerWorkspace.workspace_dir), ).toContain('M README.md'); - expect( - db.getPairedTaskById('paired-task-1')?.status, - ).toBe('review_ready'); + expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready'); expect( db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at, ).toBeTruthy(); @@ -164,7 +168,10 @@ describe('paired workspace manager', () => { manager.refreshReviewerSnapshotForPairedTask('paired-task-2'); fs.rmSync(path.join(ownerWorkspace.workspace_dir, 'remove.txt')); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'keep.txt'), 'updated\n'); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, 'keep.txt'), + 'updated\n', + ); const reviewerWorkspace = manager.refreshReviewerSnapshotForPairedTask('paired-task-2'); @@ -173,7 +180,10 @@ describe('paired workspace manager', () => { fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'remove.txt')), ).toBe(false); expect( - fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'keep.txt'), 'utf-8'), + fs.readFileSync( + path.join(reviewerWorkspace.workspace_dir, 'keep.txt'), + 'utf-8', + ), ).toBe('updated\n'); expect( runGit(['status', '--short'], reviewerWorkspace.workspace_dir), @@ -189,10 +199,19 @@ describe('paired workspace manager', () => { runGit(['init'], canonicalDir); runGit(['config', 'user.email', 'test@example.com'], canonicalDir); runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); - fs.writeFileSync(path.join(canonicalDir, 'tracked.ts'), 'export const ok = 1;\n'); - fs.writeFileSync(path.join(canonicalDir, '.env.production'), 'TRACKED_SECRET=1\n'); + fs.writeFileSync( + path.join(canonicalDir, 'tracked.ts'), + 'export const ok = 1;\n', + ); + fs.writeFileSync( + path.join(canonicalDir, '.env.production'), + 'TRACKED_SECRET=1\n', + ); fs.writeFileSync(path.join(canonicalDir, '.env.example'), 'EXAMPLE=1\n'); - runGit(['add', 'tracked.ts', '.env.production', '.env.example'], canonicalDir); + runGit( + ['add', 'tracked.ts', '.env.production', '.env.example'], + canonicalDir, + ); runGit(['commit', '-m', 'initial'], canonicalDir); const now = '2026-03-28T00:00:00.000Z'; @@ -223,20 +242,38 @@ describe('paired workspace manager', () => { fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'src'), { recursive: true, }); - fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'), { - recursive: true, - }); + fs.mkdirSync( + path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'), + { + recursive: true, + }, + ); fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'dist'), { recursive: true, }); fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'logs'), { recursive: true, }); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'), 'export const draft = true;\n'); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, '.env.local'), 'SECRET=1\n'); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), 'cache\n'); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'dist', 'bundle.js'), 'dist\n'); - fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'logs', 'debug.log'), 'log\n'); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'), + 'export const draft = true;\n', + ); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, '.env.local'), + 'SECRET=1\n', + ); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), + 'cache\n', + ); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, 'dist', 'bundle.js'), + 'dist\n', + ); + fs.writeFileSync( + path.join(ownerWorkspace.workspace_dir, 'logs', 'debug.log'), + 'log\n', + ); const reviewerWorkspace = manager.refreshReviewerSnapshotForPairedTask('paired-task-3'); @@ -248,7 +285,9 @@ describe('paired workspace manager', () => { ), ).toBe('export const draft = true;\n'); expect( - fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.production')), + fs.existsSync( + path.join(reviewerWorkspace.workspace_dir, '.env.production'), + ), ).toBe(false); expect( fs.readFileSync( @@ -261,14 +300,23 @@ describe('paired workspace manager', () => { ).toBe(false); expect( fs.existsSync( - path.join(reviewerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), + path.join( + reviewerWorkspace.workspace_dir, + 'node_modules', + '.cache', + 'x', + ), ), ).toBe(false); expect( - fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js')), + fs.existsSync( + path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js'), + ), ).toBe(false); expect( - fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log')), + fs.existsSync( + path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log'), + ), ).toBe(false); }); }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index fd34287..c6025ad 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -93,7 +93,10 @@ function runGit(args: string[], cwd?: string): string { } function ensureGitRepository(repoDir: string): void { - const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree'], repoDir); + const insideWorkTree = runGit( + ['rev-parse', '--is-inside-work-tree'], + repoDir, + ); if (insideWorkTree !== 'true') { throw new Error(`Not a git repository: ${repoDir}`); } @@ -125,7 +128,9 @@ function listGitPaths(repoDir: string, args: string[]): string[] { function isReviewerSnapshotDeniedPath(relativePath: string): boolean { const segments = relativePath.split(/[\\/]+/).filter(Boolean); - if (segments.some((segment) => REVIEWER_SNAPSHOT_DENY_SEGMENTS.has(segment))) { + if ( + segments.some((segment) => REVIEWER_SNAPSHOT_DENY_SEGMENTS.has(segment)) + ) { return true; } @@ -237,7 +242,8 @@ function makeWorkspaceRecord(args: { task_id: args.taskId, role: args.role, workspace_dir: args.workspaceDir, - snapshot_source_dir: args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, + snapshot_source_dir: + args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, status: 'ready', snapshot_refreshed_at: args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null, @@ -263,7 +269,10 @@ export function provisionOwnerWorkspaceForPairedTask( if (!fs.existsSync(path.join(workspaceDir, '.git'))) { ensureCleanDirectory(parentDir); - runGit(['worktree', 'add', '--detach', workspaceDir, sourceRef], canonicalWorkDir); + runGit( + ['worktree', 'add', '--detach', workspaceDir, sourceRef], + canonicalWorkDir, + ); logger.info( { taskId, workspaceDir, sourceRef }, 'Provisioned owner workspace for paired task', diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 046de1c..211f1d0 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -187,9 +187,9 @@ describe('handleSessionCommand', () => { it('handles authorized /review-ready without invoking the agent', async () => { const deps = makeDeps({ - markReviewReady: vi.fn().mockResolvedValue( - 'Review snapshot updated.\n- Task: paired-task-1', - ), + markReviewReady: vi + .fn() + .mockResolvedValue('Review snapshot updated.\n- Task: paired-task-1'), }); const result = await handleSessionCommand({