style: sync paired workspace formatting fallout
This commit is contained in:
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/db.ts
12
src/db.ts
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user