style: sync paired workspace formatting fallout

This commit is contained in:
Eyejoker
2026-03-28 21:15:16 +09:00
parent e7f49d77da
commit 06b6326a9d
8 changed files with 426 additions and 353 deletions

View File

@@ -689,7 +689,9 @@ describe('paired task state', () => {
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready'); expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running'); expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
expect( expect(
listPairedWorkspacesForTask('paired-task-2').map((workspace) => workspace.workspace_dir), listPairedWorkspacesForTask('paired-task-2').map(
(workspace) => workspace.workspace_dir,
),
).toEqual(['/tmp/reviewer-v2']); ).toEqual(['/tmp/reviewer-v2']);
}); });
}); });

View File

@@ -1808,9 +1808,9 @@ export function createPairedTask(task: PairedTask): void {
} }
export function getPairedTaskById(id: string): PairedTask | undefined { export function getPairedTaskById(id: string): PairedTask | undefined {
return db return db.prepare('SELECT * FROM paired_tasks WHERE id = ?').get(id) as
.prepare('SELECT * FROM paired_tasks WHERE id = ?') | PairedTask
.get(id) as PairedTask | undefined; | undefined;
} }
export function getLatestOpenPairedTaskForChat( export function getLatestOpenPairedTaskForChat(
@@ -1905,9 +1905,9 @@ export function createPairedExecution(execution: PairedExecution): void {
export function getPairedExecutionById( export function getPairedExecutionById(
id: string, id: string,
): PairedExecution | undefined { ): PairedExecution | undefined {
return db return db.prepare('SELECT * FROM paired_executions WHERE id = ?').get(id) as
.prepare('SELECT * FROM paired_executions WHERE id = ?') | PairedExecution
.get(id) as PairedExecution | undefined; | undefined;
} }
export function updatePairedExecution( export function updatePairedExecution(

View File

@@ -310,52 +310,52 @@ describe('runAgentForGroup room memory', () => {
workDir: '/repo/canonical', workDir: '/repo/canonical',
}; };
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue( vi.mocked(
{ pairedExecutionContext.preparePairedExecutionContext,
task: { ).mockReturnValue({
id: 'paired-task-1', task: {
chat_jid: 'group@test', id: 'paired-task-1',
group_folder: 'test-group', chat_jid: 'group@test',
owner_service_id: 'claude', group_folder: 'test-group',
reviewer_service_id: 'codex-main', owner_service_id: 'claude',
title: null, reviewer_service_id: 'codex-main',
source_ref: 'HEAD', title: null,
review_requested_at: null, source_ref: 'HEAD',
status: 'draft', review_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', status: 'draft',
updated_at: '2026-03-28T00:00:00.000Z', 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',
},
}, },
); 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(), { await runAgentForGroup(makeDeps(), {
group, group,
@@ -403,44 +403,44 @@ describe('runAgentForGroup room memory', () => {
reason: null, reason: null,
explicit: false, explicit: false,
}); });
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue( vi.mocked(
{ pairedExecutionContext.preparePairedExecutionContext,
task: { ).mockReturnValue({
id: 'paired-task-1', task: {
chat_jid: 'group@test', id: 'paired-task-1',
group_folder: 'test-group', chat_jid: 'group@test',
owner_service_id: 'codex-main', group_folder: 'test-group',
reviewer_service_id: 'claude', owner_service_id: 'codex-main',
title: null, reviewer_service_id: 'claude',
source_ref: 'HEAD', title: null,
review_requested_at: null, source_ref: 'HEAD',
status: 'draft', review_requested_at: null,
created_at: '2026-03-28T00:00:00.000Z', status: 'draft',
updated_at: '2026-03-28T00:00:00.000Z', 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.',
}, },
); 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(), { const result = await runAgentForGroup(makeDeps(), {
group, group,
@@ -449,7 +449,10 @@ describe('runAgentForGroup room memory', () => {
runId: 'run-blocked-reviewer', runId: 'run-blocked-reviewer',
onOutput: async (output) => { onOutput: async (output) => {
outputs.push({ 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, result: output.result,
}); });
}, },

View File

@@ -281,7 +281,10 @@ export async function runAgentForGroup(
const outputText = getAgentOutputText(output); const outputText = getAgentOutputText(output);
if (typeof outputText === 'string' && outputText.length > 0) { if (typeof outputText === 'string' && outputText.length > 0) {
pairedExecutionSummary = outputText.slice(0, 500); 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); pairedExecutionSummary = output.error.slice(0, 500);
} }
if ( if (
@@ -596,261 +599,269 @@ export async function runAgentForGroup(
try { try {
let primaryAttempt = await runAttempt(provider); let primaryAttempt = await runAttempt(provider);
const isRetryableClaudeSessionFailure = ( const isRetryableClaudeSessionFailure = (
attempt: Awaited<ReturnType<typeof runAttempt>>, attempt: Awaited<ReturnType<typeof runAttempt>>,
): boolean => ): boolean =>
isClaudeCodeAgent && isClaudeCodeAgent &&
provider === 'claude' && provider === 'claude' &&
!attempt.sawOutput && !attempt.sawOutput &&
(attempt.retryableSessionFailureDetected === true || (attempt.retryableSessionFailureDetected === true ||
(attempt.error != null && (attempt.error != null &&
shouldRetryFreshSessionOnAgentFailure({ shouldRetryFreshSessionOnAgentFailure({
result: null, result: null,
error: getErrorMessage(attempt.error), 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');
if (isRetryableClaudeSessionFailure(primaryAttempt)) { if (isRetryableClaudeSessionFailure(primaryAttempt)) {
deps.clearSession(group.folder); deps.clearSession(group.folder);
logger.warn( logger.warn(
{ group: group.name, chatJid, runId }, { 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( 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'; return 'error';
} }
}
if (primaryAttempt.error) { const output = primaryAttempt.output;
if (canRotateToken && provider === 'claude' && !primaryAttempt.sawOutput) { if (!output) {
const errMsg = getErrorMessage(primaryAttempt.error); logger.error(
const trigger = primaryAttempt.streamedTriggerReason {
? { chatJid,
shouldRetry: true, group: group.name,
reason: primaryAttempt.streamedTriggerReason.reason, groupFolder: group.folder,
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, runId,
} provider,
: classifyRotationTrigger(errMsg); },
if (trigger.shouldRetry) { 'Agent produced no output object',
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,
); );
return 'error';
} }
return result;
}
if ( if (!pairedExecutionSummary) {
isClaudeCodeAgent && const finalOutputText = getAgentOutputText(output);
(resetSessionRequested || shouldResetSessionOnAgentFailure(output)) pairedExecutionSummary =
) { (typeof finalOutputText === 'string' && finalOutputText.length > 0
deps.clearSession(group.folder); ? finalOutputText.slice(0, 500)
logger.warn( : null) ??
{ group: group.name, chatJid, runId }, (typeof output.error === 'string' && output.error.length > 0
'Cleared poisoned agent session after unrecoverable error', ? output.error.slice(0, 500)
); : null);
} }
if (output.status === 'error') { if (
if (canRotateToken && provider === 'claude' && !primaryAttempt.sawOutput) { canRotateToken &&
const trigger = primaryAttempt.streamedTriggerReason provider === 'claude' &&
? { !primaryAttempt.sawOutput &&
shouldRetry: true, primaryAttempt.streamedTriggerReason &&
reason: primaryAttempt.streamedTriggerReason.reason, output.status !== 'error'
retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs, ) {
} const result = await retryClaudeWithRotation({
: classifyRotationTrigger(output.error); reason: primaryAttempt.streamedTriggerReason.reason,
if (trigger.shouldRetry) { retryAfterMs: primaryAttempt.streamedTriggerReason.retryAfterMs,
const result = await retryClaudeWithRotation( });
{ if (result === 'error') {
reason: trigger.reason, return maybeHandoffAfterError(
retryAfterMs: trigger.retryAfterMs, primaryAttempt.streamedTriggerReason.reason,
}, primaryAttempt,
output.error ?? undefined,
); );
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 ( if (
isClaudeCodeAgent && isClaudeCodeAgent &&
maybeHandoffToCodex( (resetSessionRequested || shouldResetSessionOnAgentFailure(output))
primaryAttempt.streamedTriggerReason.reason,
primaryAttempt.sawVisibleOutput,
)
) { ) {
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 (output.status === 'error') {
if (primaryAttempt.sawSuccessNullResultWithoutOutput) { if (
logger.error( canRotateToken &&
{ group: group.name, chatJid, runId }, provider === 'claude' &&
'Agent returned success with null result and no visible output', !primaryAttempt.sawOutput
); ) {
return 'error'; 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'; pairedExecutionStatus = 'succeeded';
return 'success'; return 'success';

View File

@@ -236,9 +236,9 @@ describe('paired execution context', () => {
roomRoleContext: ownerContext, roomRoleContext: ownerContext,
}); });
expect(pairedWorkspaceManager.markPairedTaskReviewReady).toHaveBeenCalledWith( expect(
'task-1', pairedWorkspaceManager.markPairedTaskReviewReady,
); ).toHaveBeenCalledWith('task-1');
expect(result?.task.status).toBe('review_ready'); expect(result?.task.status).toBe('review_ready');
}); });
}); });

View File

@@ -100,7 +100,10 @@ describe('paired workspace manager', () => {
manager.markPairedTaskReviewReady('paired-task-1'); manager.markPairedTaskReviewReady('paired-task-1');
expect( 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'); ).toBe('owner modified\n');
expect( expect(
fs.readFileSync( fs.readFileSync(
@@ -109,14 +112,15 @@ describe('paired workspace manager', () => {
), ),
).toBe('review me\n'); ).toBe('review me\n');
expect( 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'); ).toBe('DISABLED_BY_EJCLAW');
expect( expect(
runGit(['status', '--short'], reviewerWorkspace.workspace_dir), runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
).toContain('M README.md'); ).toContain('M README.md');
expect( expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
db.getPairedTaskById('paired-task-1')?.status,
).toBe('review_ready');
expect( expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at, db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
).toBeTruthy(); ).toBeTruthy();
@@ -164,7 +168,10 @@ describe('paired workspace manager', () => {
manager.refreshReviewerSnapshotForPairedTask('paired-task-2'); manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
fs.rmSync(path.join(ownerWorkspace.workspace_dir, 'remove.txt')); 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 = const reviewerWorkspace =
manager.refreshReviewerSnapshotForPairedTask('paired-task-2'); manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
@@ -173,7 +180,10 @@ describe('paired workspace manager', () => {
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'remove.txt')), fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'remove.txt')),
).toBe(false); ).toBe(false);
expect( 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'); ).toBe('updated\n');
expect( expect(
runGit(['status', '--short'], reviewerWorkspace.workspace_dir), runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
@@ -189,10 +199,19 @@ describe('paired workspace manager', () => {
runGit(['init'], canonicalDir); runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir); runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'tracked.ts'), 'export const ok = 1;\n'); fs.writeFileSync(
fs.writeFileSync(path.join(canonicalDir, '.env.production'), 'TRACKED_SECRET=1\n'); 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'); 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); runGit(['commit', '-m', 'initial'], canonicalDir);
const now = '2026-03-28T00:00:00.000Z'; const now = '2026-03-28T00:00:00.000Z';
@@ -223,20 +242,38 @@ describe('paired workspace manager', () => {
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'src'), { fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'src'), {
recursive: true, recursive: true,
}); });
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'), { fs.mkdirSync(
recursive: true, path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'),
}); {
recursive: true,
},
);
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'dist'), { fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'dist'), {
recursive: true, recursive: true,
}); });
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'logs'), { fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'logs'), {
recursive: true, recursive: true,
}); });
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'), 'export const draft = true;\n'); fs.writeFileSync(
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, '.env.local'), 'SECRET=1\n'); path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'),
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), 'cache\n'); 'export const draft = true;\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, '.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 = const reviewerWorkspace =
manager.refreshReviewerSnapshotForPairedTask('paired-task-3'); manager.refreshReviewerSnapshotForPairedTask('paired-task-3');
@@ -248,7 +285,9 @@ describe('paired workspace manager', () => {
), ),
).toBe('export const draft = true;\n'); ).toBe('export const draft = true;\n');
expect( expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.production')), fs.existsSync(
path.join(reviewerWorkspace.workspace_dir, '.env.production'),
),
).toBe(false); ).toBe(false);
expect( expect(
fs.readFileSync( fs.readFileSync(
@@ -261,14 +300,23 @@ describe('paired workspace manager', () => {
).toBe(false); ).toBe(false);
expect( expect(
fs.existsSync( fs.existsSync(
path.join(reviewerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), path.join(
reviewerWorkspace.workspace_dir,
'node_modules',
'.cache',
'x',
),
), ),
).toBe(false); ).toBe(false);
expect( expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js')), fs.existsSync(
path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js'),
),
).toBe(false); ).toBe(false);
expect( expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log')), fs.existsSync(
path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log'),
),
).toBe(false); ).toBe(false);
}); });
}); });

View File

@@ -93,7 +93,10 @@ function runGit(args: string[], cwd?: string): string {
} }
function ensureGitRepository(repoDir: string): void { 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') { if (insideWorkTree !== 'true') {
throw new Error(`Not a git repository: ${repoDir}`); throw new Error(`Not a git repository: ${repoDir}`);
} }
@@ -125,7 +128,9 @@ function listGitPaths(repoDir: string, args: string[]): string[] {
function isReviewerSnapshotDeniedPath(relativePath: string): boolean { function isReviewerSnapshotDeniedPath(relativePath: string): boolean {
const segments = relativePath.split(/[\\/]+/).filter(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; return true;
} }
@@ -237,7 +242,8 @@ function makeWorkspaceRecord(args: {
task_id: args.taskId, task_id: args.taskId,
role: args.role, role: args.role,
workspace_dir: args.workspaceDir, 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', status: 'ready',
snapshot_refreshed_at: snapshot_refreshed_at:
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null, args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
@@ -263,7 +269,10 @@ export function provisionOwnerWorkspaceForPairedTask(
if (!fs.existsSync(path.join(workspaceDir, '.git'))) { if (!fs.existsSync(path.join(workspaceDir, '.git'))) {
ensureCleanDirectory(parentDir); ensureCleanDirectory(parentDir);
runGit(['worktree', 'add', '--detach', workspaceDir, sourceRef], canonicalWorkDir); runGit(
['worktree', 'add', '--detach', workspaceDir, sourceRef],
canonicalWorkDir,
);
logger.info( logger.info(
{ taskId, workspaceDir, sourceRef }, { taskId, workspaceDir, sourceRef },
'Provisioned owner workspace for paired task', 'Provisioned owner workspace for paired task',

View File

@@ -187,9 +187,9 @@ describe('handleSessionCommand', () => {
it('handles authorized /review-ready without invoking the agent', async () => { it('handles authorized /review-ready without invoking the agent', async () => {
const deps = makeDeps({ const deps = makeDeps({
markReviewReady: vi.fn().mockResolvedValue( markReviewReady: vi
'Review snapshot updated.\n- Task: paired-task-1', .fn()
), .mockResolvedValue('Review snapshot updated.\n- Task: paired-task-1'),
}); });
const result = await handleSessionCommand({ const result = await handleSessionCommand({