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(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']);
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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<ReturnType<typeof runAttempt>>,
|
||||
): 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<ReturnType<typeof runAttempt>>,
|
||||
): 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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user