fix: preserve handoff roles during failover
This commit is contained in:
@@ -1227,6 +1227,28 @@ describe('service handoff completion', () => {
|
|||||||
'dc:handoff': '5',
|
'dc:handoff': '5',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores the intended handoff role when provided', () => {
|
||||||
|
const handoff = createServiceHandoff({
|
||||||
|
chat_jid: 'dc:handoff-role',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
source_service_id: 'claude',
|
||||||
|
target_service_id: 'codex-review',
|
||||||
|
target_agent_type: 'codex',
|
||||||
|
prompt: 'please review',
|
||||||
|
reason: 'reviewer-claude-429',
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handoff.intended_role).toBe('reviewer');
|
||||||
|
expect(getPendingServiceHandoffs('codex-review')).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: handoff.id,
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'reviewer-claude-429',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('message seq cursors', () => {
|
describe('message seq cursors', () => {
|
||||||
|
|||||||
18
src/db.ts
18
src/db.ts
@@ -159,6 +159,7 @@ export interface ServiceHandoff {
|
|||||||
start_seq: number | null;
|
start_seq: number | null;
|
||||||
end_seq: number | null;
|
end_seq: number | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
intended_role: PairedRoomRole | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
claimed_at: string | null;
|
claimed_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@@ -408,11 +409,13 @@ function createSchema(database: Database): void {
|
|||||||
start_seq INTEGER,
|
start_seq INTEGER,
|
||||||
end_seq INTEGER,
|
end_seq INTEGER,
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
|
intended_role TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
claimed_at TEXT,
|
claimed_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
CHECK (status IN ('pending', 'claimed', 'completed', 'failed'))
|
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
||||||
|
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
||||||
ON service_handoffs(target_service_id, status, created_at);
|
ON service_handoffs(target_service_id, status, created_at);
|
||||||
@@ -568,6 +571,12 @@ function createSchema(database: Database): void {
|
|||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.exec(`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
database.exec(
|
database.exec(
|
||||||
`UPDATE room_settings
|
`UPDATE room_settings
|
||||||
SET mode_source = 'explicit'
|
SET mode_source = 'explicit'
|
||||||
@@ -3518,6 +3527,7 @@ export function createServiceHandoff(input: {
|
|||||||
start_seq?: number | null;
|
start_seq?: number | null;
|
||||||
end_seq?: number | null;
|
end_seq?: number | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
|
intended_role?: PairedRoomRole | null;
|
||||||
}): ServiceHandoff {
|
}): ServiceHandoff {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO service_handoffs (
|
`INSERT INTO service_handoffs (
|
||||||
@@ -3529,8 +3539,9 @@ export function createServiceHandoff(input: {
|
|||||||
prompt,
|
prompt,
|
||||||
start_seq,
|
start_seq,
|
||||||
end_seq,
|
end_seq,
|
||||||
reason
|
reason,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
intended_role
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.chat_jid,
|
input.chat_jid,
|
||||||
input.group_folder,
|
input.group_folder,
|
||||||
@@ -3541,6 +3552,7 @@ export function createServiceHandoff(input: {
|
|||||||
input.start_seq ?? null,
|
input.start_seq ?? null,
|
||||||
input.end_seq ?? null,
|
input.end_seq ?? null,
|
||||||
input.reason ?? null,
|
input.reason ?? null,
|
||||||
|
input.intended_role ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastId = (
|
const lastId = (
|
||||||
|
|||||||
@@ -397,6 +397,61 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honors a forced reviewer role even when the paired task status is active', async () => {
|
||||||
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'paired-task-active',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'please retry review',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-forced-reviewer',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
group,
|
||||||
|
expect.objectContaining({
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'claude',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'claude',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows silent reviewer outputs', async () => {
|
it('allows silent reviewer outputs', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
@@ -1005,6 +1060,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
start_seq: 10,
|
start_seq: 10,
|
||||||
end_seq: 12,
|
end_seq: 12,
|
||||||
reason: 'claude-usage-exhausted',
|
reason: 'claude-usage-exhausted',
|
||||||
|
intended_role: 'owner',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1207,6 +1263,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
target_service_id: 'codex-review',
|
target_service_id: 'codex-review',
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
reason: 'claude-org-access-denied',
|
reason: 'claude-org-access-denied',
|
||||||
|
intended_role: 'owner',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ import {
|
|||||||
} from './codex-token-rotation.js';
|
} from './codex-token-rotation.js';
|
||||||
import type { CodexRotationReason } from './agent-error-detection.js';
|
import type { CodexRotationReason } from './agent-error-detection.js';
|
||||||
import { getTokenCount } from './token-rotation.js';
|
import { getTokenCount } from './token-rotation.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { PairedRoomRole, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// ── Main executor ─────────────────────────────────────────────────
|
// ── Main executor ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,6 +92,7 @@ export async function runAgentForGroup(
|
|||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
},
|
},
|
||||||
): Promise<'success' | 'error'> {
|
): Promise<'success' | 'error'> {
|
||||||
@@ -107,7 +108,13 @@ export async function runAgentForGroup(
|
|||||||
const pairedTask = currentLease.reviewer_service_id
|
const pairedTask = currentLease.reviewer_service_id
|
||||||
? getLatestOpenPairedTaskForChat(chatJid)
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
: null;
|
: null;
|
||||||
const activeRole = resolveActiveRole(pairedTask?.status);
|
const inferredRole = resolveActiveRole(pairedTask?.status);
|
||||||
|
const canHonorForcedRole = Boolean(
|
||||||
|
args.forcedRole === 'owner' ||
|
||||||
|
(args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) ||
|
||||||
|
(args.forcedRole === 'arbiter' && currentLease.arbiter_service_id),
|
||||||
|
);
|
||||||
|
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole;
|
||||||
const effectiveServiceId =
|
const effectiveServiceId =
|
||||||
activeRole === 'arbiter'
|
activeRole === 'arbiter'
|
||||||
? currentLease.arbiter_service_id!
|
? currentLease.arbiter_service_id!
|
||||||
@@ -293,6 +300,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `arbiter-claude-${reason}`,
|
reason: `arbiter-claude-${reason}`,
|
||||||
|
intended_role: 'arbiter',
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
@@ -314,6 +322,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `reviewer-claude-${reason}`,
|
reason: `reviewer-claude-${reason}`,
|
||||||
|
intended_role: 'reviewer',
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
@@ -333,6 +342,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `claude-${reason}`,
|
reason: `claude-${reason}`,
|
||||||
|
intended_role: activeRole,
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ vi.mock('./session-commands.js', () => ({
|
|||||||
import * as agentRunner from './agent-runner.js';
|
import * as agentRunner from './agent-runner.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { createMessageRuntime } from './message-runtime.js';
|
import {
|
||||||
|
createMessageRuntime,
|
||||||
|
resolveHandoffRoleOverride,
|
||||||
|
} from './message-runtime.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.js';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
@@ -223,6 +226,33 @@ describe('createMessageRuntime', () => {
|
|||||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers intended_role over reason prefixes for handoff role resolution', () => {
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'claude-429',
|
||||||
|
}),
|
||||||
|
).toBe('reviewer');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'arbiter-claude-429',
|
||||||
|
}),
|
||||||
|
).toBe('arbiter');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'reviewer-claude-usage-exhausted',
|
||||||
|
}),
|
||||||
|
).toBe('reviewer');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'claude-usage-exhausted',
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores generic failure bot messages in paired rooms', async () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
|
type PairedRoomRole,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
type PairedTask,
|
type PairedTask,
|
||||||
type PairedTurnOutput,
|
type PairedTurnOutput,
|
||||||
@@ -94,6 +95,21 @@ export function isDuplicateOfLastBotFinal(
|
|||||||
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
|
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveHandoffRoleOverride(
|
||||||
|
handoff: Pick<ServiceHandoff, 'intended_role' | 'reason'>,
|
||||||
|
): PairedRoomRole | undefined {
|
||||||
|
if (handoff.intended_role) {
|
||||||
|
return handoff.intended_role;
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('reviewer-')) {
|
||||||
|
return 'reviewer';
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('arbiter-')) {
|
||||||
|
return 'arbiter';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageRuntimeDeps {
|
export interface MessageRuntimeDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
idleTimeout: number;
|
idleTimeout: number;
|
||||||
@@ -415,6 +431,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
},
|
},
|
||||||
): Promise<'success' | 'error'> =>
|
): Promise<'success' | 'error'> =>
|
||||||
runAgentForGroup(
|
runAgentForGroup(
|
||||||
@@ -434,6 +451,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq: options?.startSeq,
|
startSeq: options?.startSeq,
|
||||||
endSeq: options?.endSeq,
|
endSeq: options?.endSeq,
|
||||||
hasHumanMessage: options?.hasHumanMessage,
|
hasHumanMessage: options?.hasHumanMessage,
|
||||||
|
forcedRole: options?.forcedRole,
|
||||||
onOutput,
|
onOutput,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -447,6 +465,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq: number | null;
|
startSeq: number | null;
|
||||||
endSeq: number | null;
|
endSeq: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
outputStatus: 'success' | 'error';
|
outputStatus: 'success' | 'error';
|
||||||
deliverySucceeded: boolean;
|
deliverySucceeded: boolean;
|
||||||
@@ -503,7 +522,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
(result) => turnController.handleOutput(result),
|
(result) => turnController.handleOutput(result),
|
||||||
{ startSeq, endSeq, hasHumanMessage: args.hasHumanMessage },
|
{
|
||||||
|
startSeq,
|
||||||
|
endSeq,
|
||||||
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
forcedRole: args.forcedRole,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { deliverySucceeded, visiblePhase } =
|
const { deliverySucceeded, visiblePhase } =
|
||||||
@@ -574,14 +598,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
|
|
||||||
// Reviewer/arbiter failover handoffs should run via the appropriate
|
// Reviewer/arbiter failover handoffs should run via the appropriate
|
||||||
// channel so they execute in the correct role mode.
|
// channel so they execute in the correct role mode.
|
||||||
const isReviewerHandoff = handoff.reason?.startsWith('reviewer-');
|
const handoffRole = resolveHandoffRoleOverride(handoff);
|
||||||
const isArbiterHandoff = handoff.reason?.startsWith('arbiter-');
|
|
||||||
let handoffChannel = channel;
|
let handoffChannel = channel;
|
||||||
if (isReviewerHandoff) {
|
if (handoffRole === 'reviewer') {
|
||||||
const revChName =
|
const revChName =
|
||||||
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
||||||
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
||||||
} else if (isArbiterHandoff) {
|
} else if (handoffRole === 'arbiter') {
|
||||||
handoffChannel =
|
handoffChannel =
|
||||||
findChannelByName(deps.channels, 'discord-review') || channel;
|
findChannelByName(deps.channels, 'discord-review') || channel;
|
||||||
}
|
}
|
||||||
@@ -596,6 +619,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel: handoffChannel,
|
channel: handoffChannel,
|
||||||
startSeq: handoff.start_seq,
|
startSeq: handoff.start_seq,
|
||||||
endSeq: handoff.end_seq,
|
endSeq: handoff.end_seq,
|
||||||
|
forcedRole: handoffRole,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.deliverySucceeded) {
|
if (!result.deliverySucceeded) {
|
||||||
|
|||||||
@@ -445,6 +445,51 @@ describe('paired execution context', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests arbiter instead of re-reviewing when repeated DONE finalize loops exceed the threshold', () => {
|
||||||
|
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
|
const repoDir = createCanonicalRepoWithCommit('reviewed');
|
||||||
|
const approvedSourceRef = resolveTreeRef(repoDir);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed again\n');
|
||||||
|
execFileSync('git', ['add', 'README.md'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
execFileSync('git', ['commit', '-m', 'code change'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'merge_ready',
|
||||||
|
source_ref: approvedSourceRef,
|
||||||
|
round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
|
||||||
|
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
completePairedExecutionContext({
|
||||||
|
taskId: 'task-1',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'succeeded',
|
||||||
|
summary: 'DONE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_requested_at: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
|
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
|
||||||
'escalates immediately when owner reports %s during finalize without arbiter',
|
'escalates immediately when owner reports %s during finalize without arbiter',
|
||||||
(summary) => {
|
(summary) => {
|
||||||
|
|||||||
@@ -525,6 +525,30 @@ export function completePairedExecutionContext(args: {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (hasNewChanges === true) {
|
if (hasNewChanges === true) {
|
||||||
|
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
||||||
|
if (isArbiterEnabled()) {
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_requested_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, roundTrips: task.round_trip_count, hasNewChanges },
|
||||||
|
'Owner finalize DONE loop detected — requesting arbiter',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'escalated',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, roundTrips: task.round_trip_count, hasNewChanges },
|
||||||
|
'Owner finalize DONE loop detected — escalating to user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Owner made changes after approval → needs re-review
|
// Owner made changes after approval → needs re-review
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user