fix: prevent DONE↔DONE infinite loop and add AGENT_LANGUAGE setting

- Fix source_ref mismatch: update to workspace HEAD on first owner run
  so change detection compares against the correct repo
- Treat hasNewChanges === null as "no changes" at finalize to prevent
  infinite re-review when source_ref is unresolvable
- Add AGENT_LANGUAGE env var: when set, appends language instruction to
  all paired room prompts (owner, reviewer, arbiter)
This commit is contained in:
Eyejoker
2026-03-31 13:55:41 +09:00
parent 47d532e869
commit 757e33c58e
3 changed files with 39 additions and 14 deletions

View File

@@ -115,6 +115,9 @@ export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID ? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID
: null; : null;
/** Language for agent responses. When set, a language instruction is appended to all paired room prompts. */
export const AGENT_LANGUAGE = getEnv('AGENT_LANGUAGE') || '';
/** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */ /** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */
export const ARBITER_DEADLOCK_THRESHOLD = parseInt( export const ARBITER_DEADLOCK_THRESHOLD = parseInt(
getEnv('ARBITER_DEADLOCK_THRESHOLD') || '3', getEnv('ARBITER_DEADLOCK_THRESHOLD') || '3',

View File

@@ -288,6 +288,18 @@ export function preparePairedExecutionContext(args: {
// Use a stable per-channel worktree (not per-task) so the Claude SDK // Use a stable per-channel worktree (not per-task) so the Claude SDK
// session persists across tasks. Different channels still get isolation. // session persists across tasks. Different channels still get isolation.
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id); workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
// Update source_ref from workspace HEAD so change detection compares
// against the correct repo. At task creation, source_ref is from the
// canonical workDir which may differ from the workspace clone.
if (workspace?.workspace_dir && latestTask.status === 'active') {
const wsRef = resolveCanonicalSourceRef(workspace.workspace_dir);
if (wsRef !== latestTask.source_ref) {
updatePairedTask(latestTask.id, {
source_ref: wsRef,
updated_at: now,
});
}
}
} else if (roomRoleContext.role === 'reviewer') { } else if (roomRoleContext.role === 'reviewer') {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
workspace = reviewerWorkspace.workspace; workspace = reviewerWorkspace.workspace;
@@ -512,28 +524,32 @@ export function completePairedExecutionContext(args: {
? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref)
: null; : null;
if (hasNewChanges === false) { if (hasNewChanges === true) {
// No code changes since reviewer approvedfinalize complete // Owner made changes after approvalneeds re-review
logger.info(
{
taskId,
sourceRef: task.source_ref,
hasNewChanges,
},
'Owner made changes after reviewer approval — re-triggering review',
);
} else {
// No code changes (false) or unable to determine (null) →
// finalize complete. Treating null as "no changes" prevents
// infinite DONE↔DONE loops when source_ref is from a different
// repo or the workspace has no matching ref.
updatePairedTask(taskId, { updatePairedTask(taskId, {
status: 'completed', status: 'completed',
completion_reason: 'done', completion_reason: 'done',
updated_at: now, updated_at: now,
}); });
logger.info( logger.info(
{ taskId, summary: args.summary?.slice(0, 100) }, { taskId, hasNewChanges, summary: args.summary?.slice(0, 100) },
'Owner finalized after reviewer approval — task completed', 'Owner finalized after reviewer approval — task completed',
); );
return; return;
} }
// Owner made changes after approval → needs re-review
logger.info(
{
taskId,
sourceRef: task.source_ref,
hasNewChanges,
},
'Owner made changes after reviewer approval — re-triggering review',
);
} }
} }

View File

@@ -1,8 +1,14 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { AGENT_LANGUAGE } from './config.js';
import type { AgentType } from './types.js'; import type { AgentType } from './types.js';
function appendLanguageInstruction(prompt: string): string {
if (!AGENT_LANGUAGE) return prompt;
return `${prompt}\n\n## Language\n\nAlways respond in ${AGENT_LANGUAGE}.`;
}
const PLATFORM_PROMPT_FILES: Record<AgentType, string> = { const PLATFORM_PROMPT_FILES: Record<AgentType, string> = {
'claude-code': 'claude-platform.md', 'claude-code': 'claude-platform.md',
codex: 'codex-platform.md', codex: 'codex-platform.md',
@@ -60,7 +66,7 @@ export function readPairedRoomPrompt(
if (!fs.existsSync(promptPath)) return undefined; if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined; return prompt ? appendLanguageInstruction(prompt) : undefined;
} }
export function getArbiterPromptPath(projectRoot = process.cwd()): string { export function getArbiterPromptPath(projectRoot = process.cwd()): string {
@@ -74,5 +80,5 @@ export function readArbiterPrompt(
if (!fs.existsSync(promptPath)) return undefined; if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined; return prompt ? appendLanguageInstruction(prompt) : undefined;
} }