paired: carry forward latest owner final across superseded tasks
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user