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

@@ -24,6 +24,7 @@ import {
getAllRegisteredGroups,
getDueTasks,
getLatestMessageSeqAtOrBefore,
getLatestPairedTaskForChat,
getMessagesSinceSeq,
getNewMessagesBySeq,
getOpenWorkItem,
@@ -638,6 +639,9 @@ describe('paired task state', () => {
expect(getPairedTaskById('paired-task-1')?.gate_turn_kind ?? null).toBe(
null,
);
expect(
getPairedTaskById('paired-task-1')?.last_finalized_checkpoint ?? null,
).toBe(null);
expect(getPairedTaskById('paired-task-1')?.reviewer_verdict ?? null).toBe(
null,
);
@@ -725,6 +729,85 @@ describe('paired task state', () => {
);
});
it('dedupes deploy_complete and atomically finalizes the checkpoint once', () => {
createPairedTask({
id: 'paired-task-events-deploy',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
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-29T00:00:00.000Z',
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: 'done',
reviewer_verdict_at: '2026-03-29T00:00:00.000Z',
reviewer_verdict_note: '**DONE** ready',
status: 'merge_ready',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
const first = applyPairedEvent({
event: {
task_id: 'paired-task-events-deploy',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-deploy', {
status: 'merged',
last_finalized_checkpoint: 'canonical-v1',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
const second = applyPairedEvent({
event: {
task_id: 'paired-task-events-deploy',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:02:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-deploy', {
status: 'failed',
last_finalized_checkpoint: 'should-not-change',
updated_at: '2026-03-29T00:02:00.000Z',
});
},
});
expect(first.applied).toBe(true);
expect(second.applied).toBe(false);
expect(
listPairedEventsForTask('paired-task-events-deploy').filter(
(event) => event.event_type === 'deploy_complete',
),
).toHaveLength(1);
expect(
getPairedTaskById('paired-task-events-deploy')?.last_finalized_checkpoint,
).toBe('canonical-v1');
expect(getLatestPairedTaskForChat('dc:paired')?.status).toBe('merged');
expect(getPairedTaskById('paired-task-events-deploy')?.status).toBe(
'merged',
);
});
it('rolls back paired event insert when the coupled state update fails', () => {
createPairedTask({
id: 'paired-task-events-2',
@@ -932,6 +1015,160 @@ describe('paired task state', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('migrates paired task finalization fields and deploy_complete events', () => {
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-finalize-'));
const dbPath = path.join(tempRoot, 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE paired_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_role TEXT NOT NULL,
source_service_id TEXT NOT NULL,
source_fingerprint TEXT,
dedupe_key TEXT NOT NULL,
payload_json TEXT,
created_at TEXT NOT NULL
);
`);
legacyDb
.prepare(
`
INSERT INTO paired_tasks (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'paired-finalized',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'autonomous',
'low',
'approved',
'2026-03-29T00:00:00.000Z',
null,
'done',
'2026-03-29T00:00:00.000Z',
'**DONE**',
'merge_ready',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
legacyDb
.prepare(
`
INSERT INTO paired_events (
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'paired-finalized',
'request_review',
'owner',
'codex-main',
'fingerprint-v1',
'msg-review-legacy',
null,
'2026-03-29T00:00:00.000Z',
);
legacyDb.close();
_initTestDatabaseFromFile(dbPath);
expect(
getPairedTaskById('paired-finalized')?.last_finalized_checkpoint ?? null,
).toBe(null);
applyPairedEvent({
event: {
task_id: 'paired-finalized',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-finalized', {
status: 'merged',
last_finalized_checkpoint: 'canonical-v1',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
expect(getPairedTaskById('paired-finalized')?.status).toBe('merged');
expect(
getPairedTaskById('paired-finalized')?.last_finalized_checkpoint,
).toBe('canonical-v1');
expect(
getPairedEventByDedupeKey({
taskId: 'paired-finalized',
eventType: 'deploy_complete',
dedupeKey: 'deploy-complete:canonical-v1',
})?.source_fingerprint,
).toBe('canonical-v1');
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('updates task and execution state and keeps one workspace per role', () => {
createPairedTask({
id: 'paired-task-2',

104
src/db.ts
View File

@@ -258,6 +258,7 @@ function createSchema(database: Database.Database): void {
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
@@ -386,7 +387,8 @@ function createSchema(database: Database.Database): void {
'submit_plan',
'approve_plan',
'request_plan_changes',
'request_review'
'request_review',
'deploy_complete'
)
),
CHECK (actor_role IN ('owner', 'reviewer', 'system'))
@@ -703,6 +705,7 @@ function createSchema(database: Database.Database): void {
!pairedTasksSql.includes('task_policy TEXT') ||
!pairedTasksSql.includes('risk_level TEXT') ||
!pairedTasksSql.includes('plan_status TEXT') ||
!pairedTasksSql.includes('last_finalized_checkpoint TEXT') ||
!pairedTasksSql.includes('gate_turn_kind TEXT') ||
!pairedTasksSql.includes('reviewer_verdict TEXT'));
if (pairedTasksNeedsMigration) {
@@ -719,6 +722,7 @@ function createSchema(database: Database.Database): void {
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
@@ -774,6 +778,7 @@ function createSchema(database: Database.Database): void {
risk_level,
plan_status,
review_requested_at,
last_finalized_checkpoint,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
@@ -808,6 +813,7 @@ function createSchema(database: Database.Database): void {
NULL,
NULL,
NULL,
NULL,
status,
created_at,
updated_at
@@ -823,6 +829,77 @@ function createSchema(database: Database.Database): void {
`);
}
const pairedEventsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_events'`,
)
.get() as { sql?: string } | undefined;
const pairedEventsSql = pairedEventsSqlRow?.sql || '';
const pairedEventsNeedsMigration =
pairedEventsSql && !pairedEventsSql.includes("'deploy_complete'");
if (pairedEventsNeedsMigration) {
database.exec(`
CREATE TABLE paired_events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_role TEXT NOT NULL,
source_service_id TEXT NOT NULL,
source_fingerprint TEXT,
dedupe_key TEXT NOT NULL,
payload_json TEXT,
created_at TEXT NOT NULL,
CHECK (
event_type IN (
'set_risk',
'submit_plan',
'approve_plan',
'request_plan_changes',
'request_review',
'deploy_complete'
)
),
CHECK (actor_role IN ('owner', 'reviewer', 'system'))
);
`);
database.exec(`
INSERT INTO paired_events_new (
id,
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
)
SELECT
id,
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
FROM paired_events;
`);
database.exec(`
DROP TABLE paired_events;
ALTER TABLE paired_events_new RENAME TO paired_events;
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_events_task
ON paired_events(task_id, created_at, id);
`);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe
ON paired_events(task_id, event_type, dedupe_key);
`);
}
const pairedArtifactsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`,
@@ -2076,6 +2153,7 @@ export function createPairedTask(task: PairedTask): void {
risk_level,
plan_status,
review_requested_at,
last_finalized_checkpoint,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
@@ -2084,7 +2162,7 @@ export function createPairedTask(task: PairedTask): void {
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
@@ -2098,6 +2176,7 @@ export function createPairedTask(task: PairedTask): void {
task.risk_level,
task.plan_status,
task.review_requested_at,
task.last_finalized_checkpoint ?? null,
task.gate_turn_kind ?? null,
task.reviewer_verdict ?? null,
task.reviewer_verdict_at ?? null,
@@ -2114,6 +2193,22 @@ export function getPairedTaskById(id: string): PairedTask | undefined {
| undefined;
}
export function getLatestPairedTaskForChat(
chatJid: string,
): PairedTask | undefined {
return db
.prepare(
`
SELECT *
FROM paired_tasks
WHERE chat_jid = ?
ORDER BY updated_at DESC
LIMIT 1
`,
)
.get(chatJid) as PairedTask | undefined;
}
export function getLatestOpenPairedTaskForChat(
chatJid: string,
): PairedTask | undefined {
@@ -2142,6 +2237,7 @@ export function updatePairedTask(
| 'risk_level'
| 'plan_status'
| 'review_requested_at'
| 'last_finalized_checkpoint'
| 'gate_turn_kind'
| 'reviewer_verdict'
| 'reviewer_verdict_at'
@@ -2178,6 +2274,10 @@ export function updatePairedTask(
fields.push('review_requested_at = ?');
values.push(updates.review_requested_at);
}
if (updates.last_finalized_checkpoint !== undefined) {
fields.push('last_finalized_checkpoint = ?');
values.push(updates.last_finalized_checkpoint);
}
if (updates.gate_turn_kind !== undefined) {
fields.push('gate_turn_kind = ?');
values.push(updates.gate_turn_kind);

View File

@@ -39,6 +39,7 @@ vi.mock('./paired-execution-context.js', () => ({
markRoomReviewReady: vi.fn(() => null),
formatRoomReviewReadyMessage: vi.fn(() => null),
planPairedExecutionRecovery: vi.fn(() => null),
finalizeRoomDeployment: vi.fn(() => null),
setRoomTaskRiskLevel: vi.fn(() => null),
recordRoomPlan: vi.fn(() => null),
approveRoomPlan: vi.fn(() => null),
@@ -409,6 +410,77 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage);
});
it('surfaces the deploy-complete message through the message-runtime path', async () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
const deployMessage = [
'Deployment finalized.',
'- Task: paired-task-1',
'- Status: merged',
'- Checkpoint: abc123',
].join('\n');
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-deploy',
chat_jid: chatJid,
sender: 'me@test',
sender_name: 'Me',
content: '/deploy-complete',
timestamp: '2026-03-29T00:00:00.000Z',
is_from_me: true,
},
]);
const actualSessionCommands = await vi.importActual<
typeof import('./session-commands.js')
>('./session-commands.js');
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.finalizeRoomDeployment).mockReturnValue(
deployMessage,
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-deploy-complete',
reason: 'messages',
});
expect(result).toBe(true);
expect(sessionCommands.handleSessionCommand).toHaveBeenCalled();
expect(pairedExecutionContext.finalizeRoomDeployment).toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, deployMessage);
});
it('routes /approve-plan through reviewer context in the message-runtime path', async () => {
const chatJid = 'group@test';
const group = {

View File

@@ -43,6 +43,7 @@ import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js';
import {
approveRoomPlan,
finalizeRoomDeployment,
formatRoomReviewReadyMessage,
markRoomReviewReady,
planPairedExecutionRecovery,
@@ -587,6 +588,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
});
return formatRoomReviewReadyMessage(result);
},
finalizeDeployment: async () => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.owner_service_id,
);
return finalizeRoomDeployment({
group,
chatJid,
roomRoleContext,
});
},
setTaskRiskLevel: async (riskLevel, dedupeKey) => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(

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',

View File

@@ -8,6 +8,7 @@ import {
createPairedArtifact,
createPairedExecution,
createPairedTask,
getLatestPairedTaskForChat,
getLatestOpenPairedTaskForChat,
getPairedExecutionById,
getPairedTaskById,
@@ -51,6 +52,12 @@ const VISIBLE_REVIEWER_GATE_VERDICTS = new Set<PairedReviewerVerdict>([
]);
const REVIEWER_GATE_REQUIRED_MESSAGE =
'A visible reviewer verdict is required before the owner can proceed with this gate.';
const DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE =
'Deployment finalization must be handled by the owner service.';
const DEPLOY_COMPLETE_PRECONDITION_MESSAGE =
'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.';
const DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE =
'Deploy completion requires a canonical workDir with a readable HEAD.';
function getGateTurnKind(task: PairedTask): PairedGateTurnKind | null {
return task.gate_turn_kind ?? null;
@@ -90,6 +97,19 @@ function resolveCanonicalSourceRef(workDir: string): string {
}
}
function resolveCanonicalHead(workDir: string): string | null {
try {
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: workDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
return head || null;
} catch {
return null;
}
}
function ensurePairedProject(
group: RegisteredGroup,
chatJid: string,
@@ -138,6 +158,7 @@ function ensureActiveTask(
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
@@ -835,10 +856,15 @@ export function completePairedExecutionContext(args: {
task.gate_turn_kind &&
args.reviewerVerdict
) {
const shouldPromoteToMergeReady =
task.gate_turn_kind === 'push' &&
APPROVED_REVIEWER_GATE_VERDICTS.has(args.reviewerVerdict);
updatePairedTask(task.id, {
gate_turn_kind: shouldPromoteToMergeReady ? null : task.gate_turn_kind,
reviewer_verdict: args.reviewerVerdict,
reviewer_verdict_at: completedAt,
reviewer_verdict_note: args.reviewerVerdictNote ?? null,
status: shouldPromoteToMergeReady ? 'merge_ready' : task.status,
updated_at: completedAt,
});
}
@@ -1201,3 +1227,72 @@ export function requestRoomPlanChanges(args: {
`- Status: ${latestTask.status}`,
].join('\n');
}
export function finalizeRoomDeployment(args: {
group: RegisteredGroup;
chatJid: string;
roomRoleContext?: RoomRoleContext;
}): string | null {
const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext) {
return null;
}
if (roomRoleContext.role !== 'owner') {
return DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE;
}
if (!group.workDir) {
return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE;
}
const deployedCheckpoint = resolveCanonicalHead(group.workDir);
if (!deployedCheckpoint) {
return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE;
}
const task = getLatestPairedTaskForChat(chatJid);
if (!task) {
return null;
}
const alreadyFinalized =
task.status === 'merged' &&
task.last_finalized_checkpoint === deployedCheckpoint;
if (task.status !== 'merge_ready' && !alreadyFinalized) {
return [
DEPLOY_COMPLETE_PRECONDITION_MESSAGE,
`- Task: ${task.id}`,
`- Status: ${task.status}`,
`- Checkpoint: ${deployedCheckpoint}`,
].join('\n');
}
const finalizedAt = new Date().toISOString();
applyPairedEvent({
event: {
task_id: task.id,
event_type: 'deploy_complete',
actor_role: roomRoleContext.role,
source_service_id: roomRoleContext.serviceId,
source_fingerprint: deployedCheckpoint,
dedupe_key: `deploy-complete:${deployedCheckpoint}`,
payload_json: serializeIntentPayload({ checkpoint: deployedCheckpoint }),
created_at: finalizedAt,
},
onApply: () => {
updatePairedTask(task.id, {
status: 'merged',
last_finalized_checkpoint: deployedCheckpoint,
gate_turn_kind: null,
updated_at: finalizedAt,
});
},
});
const latestTask = getPairedTaskById(task.id) ?? task;
return [
'Deployment finalized.',
`- Task: ${latestTask.id}`,
`- Status: ${latestTask.status}`,
`- Checkpoint: ${deployedCheckpoint}`,
].join('\n');
}

View File

@@ -27,6 +27,12 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/review', trigger)).toBe('/review');
});
it('detects bare /deploy-complete', () => {
expect(extractSessionCommand('/deploy-complete', trigger)).toBe(
'/deploy-complete',
);
});
it('normalizes /review-ready to /review', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
});
@@ -146,6 +152,14 @@ describe('isSessionCommandControlMessage', () => {
).toBe(true);
});
it('matches deployment finalized output', () => {
expect(
isSessionCommandControlMessage(
'Deployment finalized.\n- Task: task-1\n- Checkpoint: abc123',
),
).toBe(true);
});
it('does not match regular bot conversation', () => {
expect(
isSessionCommandControlMessage(
@@ -184,6 +198,7 @@ function makeDeps(
isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
finalizeDeployment: vi.fn().mockResolvedValue('Deployment finalized.'),
setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'),
recordPlan: vi.fn().mockResolvedValue('Plan recorded.'),
approvePlan: vi.fn().mockResolvedValue('Plan approved.'),
@@ -246,6 +261,23 @@ describe('handleSessionCommand', () => {
);
});
it('handles authorized /deploy-complete without invoking the agent', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/deploy-complete')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.finalizeDeployment).toHaveBeenCalledTimes(1);
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
expect(deps.sendMessage).toHaveBeenCalledWith('Deployment finalized.');
});
it('handles authorized /review without invoking the agent', async () => {
const deps = makeDeps({
markReviewReady: vi

View File

@@ -22,6 +22,10 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Plan approval must be handled by the reviewer service\.$/,
/^Plan review commands are only required for high-risk tasks\.$/,
/^Plan artifacts are incomplete\.(?:\n|$)/,
/^Deployment finalized\.(?:\n|$)/,
/^Deployment finalization must be handled by the owner service\.$/,
/^Deploy completion requires a merge-ready task or the same already-finalized checkpoint\.(?:\n|$)/,
/^Deploy completion requires a canonical workDir with a readable HEAD\.$/,
/^Review is unavailable for this room\./,
];
@@ -44,6 +48,7 @@ export function extractSessionCommand(
if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear';
if (text === '/review' || text === '/review-ready') return '/review';
if (text === '/deploy-complete') return '/deploy-complete';
if (/^\/risk(?:\s|$)/.test(text)) return '/risk';
if (/^\/plan(?:\s|$)/.test(text)) return '/plan';
if (text === '/approve-plan') return '/approve-plan';
@@ -109,6 +114,7 @@ export interface SessionCommandDeps {
note: string | undefined,
dedupeKey: string,
) => Promise<string | null>;
finalizeDeployment: () => Promise<string | null>;
}
function resultToText(result: string | object | null | undefined): string {
@@ -203,6 +209,15 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true };
}
if (command === '/deploy-complete') {
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
(await deps.finalizeDeployment()) ??
'Deploy finalization is unavailable for this room.',
);
return { handled: true, success: true };
}
if (command === '/risk') {
const riskArg = normalizedCommandText
.slice('/risk'.length)

View File

@@ -91,7 +91,8 @@ export type PairedEventType =
| 'submit_plan'
| 'approve_plan'
| 'request_plan_changes'
| 'request_review';
| 'request_review'
| 'deploy_complete';
export interface RoomRoleContext {
serviceId: string;
@@ -122,6 +123,7 @@ export interface PairedTask {
risk_level: PairedRiskLevel;
plan_status: PairedPlanStatus;
review_requested_at: string | null;
last_finalized_checkpoint?: string | null;
gate_turn_kind?: PairedGateTurnKind | null;
reviewer_verdict?: PairedReviewerVerdict | null;
reviewer_verdict_at?: string | null;