feat: finalize paired tasks on deploy completion

This commit is contained in:
Eyejoker
2026-03-29 06:42:03 +09:00
parent dad6f50937
commit 0e12a560a4
9 changed files with 849 additions and 3 deletions

View File

@@ -1,3 +1,8 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({
@@ -6,6 +11,7 @@ vi.mock('./db.js', () => ({
createPairedArtifact: vi.fn(),
createPairedExecution: vi.fn(),
createPairedTask: vi.fn(),
getLatestPairedTaskForChat: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedExecutionById: vi.fn(),
getPairedTaskById: vi.fn(),
@@ -41,6 +47,7 @@ import * as db from './db.js';
import {
approveRoomPlan,
completePairedExecutionContext,
finalizeRoomDeployment,
formatRoomReviewReadyMessage,
markRoomReviewReady,
planPairedExecutionRecovery,
@@ -77,6 +84,26 @@ const reviewerContext: RoomRoleContext = {
failoverOwner: false,
};
function createCanonicalRepoWithCommit(commitMessage: string): string {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test User'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: repoDir,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repoDir, 'README.md'), `${commitMessage}\n`);
execFileSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'ignore' });
execFileSync('git', ['commit', '-m', commitMessage], {
cwd: repoDir,
stdio: 'ignore',
});
return repoDir;
}
async function importExecutionContextForService(serviceId: string) {
vi.resetModules();
@@ -86,6 +113,7 @@ async function importExecutionContextForService(serviceId: string) {
createPairedArtifact: vi.fn(),
createPairedExecution: vi.fn(),
createPairedTask: vi.fn(),
getLatestPairedTaskForChat: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedExecutionById: vi.fn(),
getPairedTaskById: vi.fn(),
@@ -102,6 +130,7 @@ async function importExecutionContextForService(serviceId: string) {
event: { id: 1, ...event },
result: onApply ? onApply() : null,
}));
dbModule.getLatestPairedTaskForChat.mockReturnValue(undefined);
dbModule.getLatestOpenPairedTaskForChat.mockReturnValue(undefined);
dbModule.getPairedExecutionById.mockReturnValue(undefined);
dbModule.getPairedTaskById.mockReturnValue(undefined);
@@ -182,6 +211,7 @@ describe('paired execution context', () => {
result: onApply ? onApply() : null,
}));
vi.mocked(db.cancelSupersededPairedExecutions).mockReturnValue(0);
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
@@ -418,6 +448,256 @@ describe('paired execution context', () => {
);
});
it('promotes an approved push gate verdict to merge_ready', () => {
vi.mocked(db.getPairedExecutionById).mockReturnValue({
id: 'run-review:codex-review',
task_id: 'task-1',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: 'task-1:reviewer',
checkpoint_fingerprint: null,
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:01:00.000Z',
gate_turn_kind: 'push',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'in_review',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
});
completePairedExecutionContext({
executionId: 'run-review:codex-review',
status: 'succeeded',
reviewerVerdict: 'done',
reviewerVerdictNote: '**DONE** okay to deploy',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
gate_turn_kind: null,
reviewer_verdict: 'done',
reviewer_verdict_note: '**DONE** okay to deploy',
status: 'merge_ready',
}),
);
});
it('finalizes a merge-ready task with a durable deploy_complete checkpoint', () => {
const canonicalDir = createCanonicalRepoWithCommit('deploy-ready');
const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: canonicalDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const groupWithRepo: RegisteredGroup = {
...group,
workDir: canonicalDir,
};
const task = {
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous' as const,
risk_level: 'low' as const,
plan_status: 'approved' as const,
review_requested_at: '2026-03-28T00:01:00.000Z',
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: 'done' as const,
reviewer_verdict_at: '2026-03-28T00:01:00.000Z',
reviewer_verdict_note: '**DONE** okay to deploy',
status: 'merge_ready' as const,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
};
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(task);
vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any);
vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => {
Object.assign(task, updates);
});
const message = finalizeRoomDeployment({
group: groupWithRepo,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(db.applyPairedEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
task_id: 'task-1',
event_type: 'deploy_complete',
source_fingerprint: deployedCheckpoint,
dedupe_key: `deploy-complete:${deployedCheckpoint}`,
}),
}),
);
expect(task.status).toBe('merged');
expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint);
expect(message).toContain('Deployment finalized.');
expect(message).toContain(deployedCheckpoint);
fs.rmSync(canonicalDir, { recursive: true, force: true });
});
it('keeps deploy completion idempotent for the same checkpoint', () => {
const canonicalDir = createCanonicalRepoWithCommit('deploy-once');
const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: canonicalDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const groupWithRepo: RegisteredGroup = {
...group,
workDir: canonicalDir,
};
const task = {
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous' as const,
risk_level: 'low' as const,
plan_status: 'approved' as const,
review_requested_at: '2026-03-28T00:01:00.000Z',
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: 'done' as const,
reviewer_verdict_at: '2026-03-28T00:01:00.000Z',
reviewer_verdict_note: '**DONE** okay to deploy',
status: 'merge_ready' as const,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
};
let applied = false;
vi.mocked(db.getLatestPairedTaskForChat).mockImplementation(() => task as any);
vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any);
vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => {
Object.assign(task, updates);
});
vi.mocked(db.applyPairedEvent).mockImplementation(({ event, onApply }) => {
if (applied) {
return {
applied: false,
event: { id: 1, ...event },
result: null,
};
}
applied = true;
const result = onApply ? onApply() : null;
return {
applied: true,
event: { id: 1, ...event },
result,
};
});
finalizeRoomDeployment({
group: groupWithRepo,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
const second = finalizeRoomDeployment({
group: groupWithRepo,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(db.applyPairedEvent).toHaveBeenCalledTimes(2);
expect(task.status).toBe('merged');
expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint);
expect(second).toContain(deployedCheckpoint);
fs.rmSync(canonicalDir, { recursive: true, force: true });
});
it('rejects deploy completion for a non-merge-ready task', () => {
const canonicalDir = createCanonicalRepoWithCommit('not-ready');
const groupWithRepo: RegisteredGroup = {
...group,
workDir: canonicalDir,
};
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:01:00.000Z',
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: 'done',
reviewer_verdict_at: '2026-03-28T00:01:00.000Z',
reviewer_verdict_note: '**DONE** okay to deploy',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
});
const message = finalizeRoomDeployment({
group: groupWithRepo,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(message).toContain(
'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.',
);
expect(db.applyPairedEvent).not.toHaveBeenCalled();
fs.rmSync(canonicalDir, { recursive: true, force: true });
});
it('rejects deploy completion when the canonical HEAD is unavailable', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-no-git-'));
const message = finalizeRoomDeployment({
group: {
...group,
workDir: tempDir,
},
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(message).toBe(
'Deploy completion requires a canonical workDir with a readable HEAD.',
);
expect(db.applyPairedEvent).not.toHaveBeenCalled();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('plans reviewer recovery from the latest review checkpoint', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',