paired: carry forward latest owner final across superseded tasks

This commit is contained in:
ejclaw
2026-04-20 08:19:31 +09:00
parent a59813dee6
commit b6a24ad9fb
7 changed files with 154 additions and 15 deletions

View File

@@ -1246,6 +1246,40 @@ describe('paired task state', () => {
expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2);
});
it('preserves explicit created_at when inserting a paired turn output', () => {
createPairedTask({
id: 'paired-task-turn-output-created-at',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
insertPairedTurnOutput(
'paired-task-turn-output-created-at',
0,
'owner',
'carried forward owner final',
'2026-03-28T00:01:23.000Z',
);
const outputs = getPairedTurnOutputs('paired-task-turn-output-created-at');
expect(outputs).toHaveLength(1);
expect(outputs[0].created_at).toBe('2026-03-28T00:01:23.000Z');
});
it('fails init when paired task agent and service metadata conflict', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-shadow-');
const dbPath = path.join(tempDir, 'messages.db');
@@ -2406,10 +2440,9 @@ describe('room assignment writes', () => {
folder: 'arbiter-only-capability',
});
expect(getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort()).toEqual([
'claude-code',
'codex',
]);
expect(
getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort(),
).toEqual(['claude-code', 'codex']);
expect(
getAllRoomBindings('codex')['dc:arbiter-only-capability'],
).toMatchObject({

View File

@@ -11,6 +11,7 @@ export function insertPairedTurnOutputInDatabase(
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
): void {
if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
logger.warn(
@@ -36,7 +37,7 @@ export function insertPairedTurnOutputInDatabase(
turnNumber,
role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
new Date().toISOString(),
createdAt ?? new Date().toISOString(),
);
}

View File

@@ -300,6 +300,7 @@ export function insertPairedTurnOutput(
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
): void {
insertPairedTurnOutputInDatabase(
requireDatabase(),
@@ -307,6 +308,7 @@ export function insertPairedTurnOutput(
turnNumber,
role,
outputText,
createdAt,
);
}

View File

@@ -18,7 +18,9 @@ vi.mock('./db.js', () => {
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedTaskById: vi.fn(),
getPairedTurnById: vi.fn(),
getPairedTurnOutputs: vi.fn(() => []),
getPairedWorkspace: vi.fn(),
insertPairedTurnOutput: vi.fn(),
updatePairedTask,
updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => {
updatePairedTask(id, updates);
@@ -51,6 +53,7 @@ import * as config from './config.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,
resolveOwnerTaskForHumanMessage,
} from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type {
@@ -250,6 +253,50 @@ describe('paired execution context', () => {
});
});
it('carries forward the latest owner final when a merge_ready task is superseded by new human input', () => {
const supersededTask = buildPairedTask({
id: 'task-superseded',
status: 'merge_ready',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(supersededTask);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-superseded',
turn_number: 1,
role: 'owner',
output_text: 'DONE_WITH_CONCERNS\n이전 task owner final',
created_at: '2026-03-28T00:01:00.000Z',
},
{
id: 2,
task_id: 'task-superseded',
turn_number: 2,
role: 'reviewer',
output_text: 'DONE\nreview approved',
created_at: '2026-03-28T00:02:00.000Z',
},
]);
const result = resolveOwnerTaskForHumanMessage({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
existingTask: supersededTask,
});
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(result.supersededTask).toEqual(supersededTask);
expect(db.insertPairedTurnOutput).toHaveBeenCalledWith(
expect.any(String),
0,
'owner',
'[Carried forward context from the previous task: latest owner final]\nDONE_WITH_CONCERNS\n이전 task owner final',
'2026-03-28T00:01:00.000Z',
);
});
it('uses room role context agent overrides when creating a paired task', () => {
preparePairedExecutionContext({
group,
@@ -626,7 +673,9 @@ describe('paired execution context', () => {
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
});
expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBeUndefined();
expect(
result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY,
).toBeUndefined();
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});

View File

@@ -24,8 +24,10 @@ import {
getLatestOpenPairedTaskForChat,
getPairedTaskById,
getPairedTurnById,
getPairedTurnOutputs,
getPairedWorkspace,
hasActiveCiWatcherForChat,
insertPairedTurnOutput,
releasePairedTaskExecutionLease,
upsertPairedProject,
} from './db.js';
@@ -62,6 +64,7 @@ import type {
AgentType,
PairedRoomRole,
PairedTask,
PairedTurnOutput,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
@@ -115,8 +118,7 @@ function createActiveTaskForRoom(args: {
groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType,
configuredReviewer:
roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
configuredArbiter:
roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
configuredArbiter: roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
});
const ownerServiceShadow = resolvePairedTaskServiceShadow(
'owner',
@@ -190,6 +192,43 @@ function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void {
);
}
function getLatestTurnOutputByRole(
taskId: string,
role: PairedRoomRole,
): PairedTurnOutput | null {
return (
[...getPairedTurnOutputs(taskId)]
.reverse()
.find((output) => output.role === role) ?? null
);
}
function carryForwardLatestOwnerFinal(args: {
sourceTask: PairedTask;
targetTask: PairedTask;
}): void {
const latestOwnerFinal = getLatestTurnOutputByRole(args.sourceTask.id, 'owner');
if (!latestOwnerFinal) {
return;
}
insertPairedTurnOutput(
args.targetTask.id,
0,
'owner',
`[Carried forward context from the previous task: latest owner final]\n${latestOwnerFinal.output_text}`,
latestOwnerFinal.created_at,
);
logger.info(
{
sourceTaskId: args.sourceTask.id,
targetTaskId: args.targetTask.id,
carriedChars: latestOwnerFinal.output_text.length,
},
'Carried forward latest owner final into superseding paired task',
);
}
export interface ResolvedOwnerHumanTask {
task: PairedTask | null;
supersededTask: PairedTask | null;
@@ -247,13 +286,19 @@ export function resolveOwnerTaskForHumanMessage(args: {
cancelOutstandingFinalizeOwnerTurn(existing);
const newTask = createActiveTaskForRoom({
group: args.group,
chatJid: args.chatJid,
canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
});
carryForwardLatestOwnerFinal({
sourceTask: existing,
targetTask: newTask,
});
return {
task: createActiveTaskForRoom({
group: args.group,
chatJid: args.chatJid,
canonicalWorkDir,
roomRoleContext: args.roomRoleContext,
}),
task: newTask,
supersededTask: existing,
};
}

View File

@@ -77,6 +77,12 @@ describe('platform-prompts', () => {
expect(codexPairedPrompt).toContain(
'canonical verification root for this turn',
);
expect(codexPairedPrompt).toContain(
'suggest 1-2 better alternatives with the reason and tradeoff for each',
);
expect(codexPairedPrompt).toContain(
'Separate correctness issues from improvement ideas',
);
expect(codexPairedPrompt).not.toContain('owner-side paired agent');
const failoverPlatformPrompt = fs.readFileSync(