feat: add paired execution freshness guard
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
_initTestDatabase,
|
||||
_initTestDatabaseFromFile,
|
||||
applyPairedEvent,
|
||||
cancelSupersededPairedExecutions,
|
||||
claimServiceHandoff,
|
||||
completeServiceHandoffAndAdvanceTargetCursor,
|
||||
createPairedApproval,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
listPairedApprovalsForTask,
|
||||
listPairedArtifactsForTask,
|
||||
listPairedEventsForTask,
|
||||
listPairedExecutionsForTask,
|
||||
listPairedWorkspacesForTask,
|
||||
markWorkItemDelivered,
|
||||
markWorkItemDeliveryRetry,
|
||||
@@ -1003,6 +1005,71 @@ describe('paired task state', () => {
|
||||
),
|
||||
).toEqual(['/tmp/reviewer-v2']);
|
||||
});
|
||||
|
||||
it('persists execution checkpoints and cancels only superseded running executions', () => {
|
||||
createPairedTask({
|
||||
id: 'paired-task-3',
|
||||
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: null,
|
||||
status: 'active',
|
||||
created_at: '2026-03-29T01:00:00.000Z',
|
||||
updated_at: '2026-03-29T01:00:00.000Z',
|
||||
});
|
||||
createPairedExecution({
|
||||
id: 'paired-exec-3a',
|
||||
task_id: 'paired-task-3',
|
||||
service_id: 'codex-review',
|
||||
role: 'reviewer',
|
||||
workspace_id: 'paired-task-3:reviewer',
|
||||
checkpoint_fingerprint: 'fingerprint-v1',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-29T01:01:00.000Z',
|
||||
started_at: '2026-03-29T01:01:00.000Z',
|
||||
completed_at: null,
|
||||
});
|
||||
createPairedExecution({
|
||||
id: 'paired-exec-3b',
|
||||
task_id: 'paired-task-3',
|
||||
service_id: 'codex-review',
|
||||
role: 'reviewer',
|
||||
workspace_id: 'paired-task-3:reviewer',
|
||||
checkpoint_fingerprint: 'fingerprint-v2',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-29T01:02:00.000Z',
|
||||
started_at: '2026-03-29T01:02:00.000Z',
|
||||
completed_at: null,
|
||||
});
|
||||
|
||||
const cancelled = cancelSupersededPairedExecutions({
|
||||
taskId: 'paired-task-3',
|
||||
role: 'reviewer',
|
||||
exceptExecutionId: 'paired-exec-3b',
|
||||
note: 'Superseded by a newer review checkpoint.',
|
||||
});
|
||||
|
||||
expect(cancelled).toBe(1);
|
||||
expect(getPairedExecutionById('paired-exec-3a')).toMatchObject({
|
||||
status: 'cancelled',
|
||||
checkpoint_fingerprint: 'fingerprint-v1',
|
||||
});
|
||||
expect(getPairedExecutionById('paired-exec-3b')).toMatchObject({
|
||||
status: 'running',
|
||||
checkpoint_fingerprint: 'fingerprint-v2',
|
||||
});
|
||||
expect(
|
||||
listPairedExecutionsForTask('paired-task-3').map((execution) => execution.id),
|
||||
).toEqual(['paired-exec-3a', 'paired-exec-3b']);
|
||||
});
|
||||
});
|
||||
|
||||
// --- LIMIT behavior ---
|
||||
|
||||
63
src/db.ts
63
src/db.ts
@@ -295,6 +295,7 @@ function createSchema(database: Database.Database): void {
|
||||
service_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
workspace_id TEXT,
|
||||
checkpoint_fingerprint TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
summary TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -478,6 +479,14 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE paired_executions ADD COLUMN checkpoint_fingerprint TEXT`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
UPDATE scheduled_tasks
|
||||
SET agent_type = COALESCE(
|
||||
@@ -2149,13 +2158,14 @@ export function createPairedExecution(execution: PairedExecution): void {
|
||||
service_id,
|
||||
role,
|
||||
workspace_id,
|
||||
checkpoint_fingerprint,
|
||||
status,
|
||||
summary,
|
||||
created_at,
|
||||
started_at,
|
||||
completed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
execution.id,
|
||||
@@ -2163,6 +2173,7 @@ export function createPairedExecution(execution: PairedExecution): void {
|
||||
execution.service_id,
|
||||
execution.role,
|
||||
execution.workspace_id,
|
||||
execution.checkpoint_fingerprint ?? null,
|
||||
execution.status,
|
||||
execution.summary,
|
||||
execution.created_at,
|
||||
@@ -2184,7 +2195,12 @@ export function updatePairedExecution(
|
||||
updates: Partial<
|
||||
Pick<
|
||||
PairedExecution,
|
||||
'workspace_id' | 'status' | 'summary' | 'started_at' | 'completed_at'
|
||||
| 'workspace_id'
|
||||
| 'checkpoint_fingerprint'
|
||||
| 'status'
|
||||
| 'summary'
|
||||
| 'started_at'
|
||||
| 'completed_at'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
@@ -2195,6 +2211,10 @@ export function updatePairedExecution(
|
||||
fields.push('workspace_id = ?');
|
||||
values.push(updates.workspace_id);
|
||||
}
|
||||
if (updates.checkpoint_fingerprint !== undefined) {
|
||||
fields.push('checkpoint_fingerprint = ?');
|
||||
values.push(updates.checkpoint_fingerprint);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
@@ -2275,6 +2295,45 @@ export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
|
||||
.all(taskId) as PairedWorkspace[];
|
||||
}
|
||||
|
||||
export function listPairedExecutionsForTask(taskId: string): PairedExecution[] {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT * FROM paired_executions WHERE task_id = ? ORDER BY created_at, id',
|
||||
)
|
||||
.all(taskId) as PairedExecution[];
|
||||
}
|
||||
|
||||
export function cancelSupersededPairedExecutions(args: {
|
||||
taskId: string;
|
||||
role: PairedExecution['role'];
|
||||
exceptExecutionId?: string;
|
||||
note?: string | null;
|
||||
}): number {
|
||||
const now = new Date().toISOString();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_executions
|
||||
SET status = 'cancelled',
|
||||
summary = COALESCE(summary, ?),
|
||||
completed_at = COALESCE(completed_at, ?)
|
||||
WHERE task_id = ?
|
||||
AND role = ?
|
||||
AND status = 'running'
|
||||
AND (? IS NULL OR id <> ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
args.note ?? null,
|
||||
now,
|
||||
args.taskId,
|
||||
args.role,
|
||||
args.exceptExecutionId ?? null,
|
||||
args.exceptExecutionId ?? null,
|
||||
);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export function createPairedApproval(
|
||||
approval: Omit<PairedApproval, 'id'>,
|
||||
): number {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
applyPairedEvent: vi.fn(),
|
||||
cancelSupersededPairedExecutions: vi.fn(() => 0),
|
||||
createPairedArtifact: vi.fn(),
|
||||
createPairedExecution: vi.fn(),
|
||||
createPairedTask: vi.fn(),
|
||||
@@ -11,6 +12,7 @@ vi.mock('./db.js', () => ({
|
||||
getPairedWorkspace: vi.fn(),
|
||||
listPairedArtifactsForTask: vi.fn(),
|
||||
listPairedEventsForTask: vi.fn(),
|
||||
listPairedExecutionsForTask: vi.fn(),
|
||||
updatePairedExecution: vi.fn(),
|
||||
updatePairedTask: vi.fn(),
|
||||
upsertPairedProject: vi.fn(),
|
||||
@@ -79,6 +81,7 @@ async function importExecutionContextForService(serviceId: string) {
|
||||
|
||||
const dbModule = {
|
||||
applyPairedEvent: vi.fn(),
|
||||
cancelSupersededPairedExecutions: vi.fn(() => 0),
|
||||
createPairedArtifact: vi.fn(),
|
||||
createPairedExecution: vi.fn(),
|
||||
createPairedTask: vi.fn(),
|
||||
@@ -88,6 +91,7 @@ async function importExecutionContextForService(serviceId: string) {
|
||||
getPairedWorkspace: vi.fn(),
|
||||
listPairedArtifactsForTask: vi.fn(),
|
||||
listPairedEventsForTask: vi.fn(),
|
||||
listPairedExecutionsForTask: vi.fn(),
|
||||
updatePairedExecution: vi.fn(),
|
||||
updatePairedTask: vi.fn(),
|
||||
upsertPairedProject: vi.fn(),
|
||||
@@ -103,6 +107,7 @@ async function importExecutionContextForService(serviceId: string) {
|
||||
dbModule.getPairedWorkspace.mockReturnValue(undefined);
|
||||
dbModule.listPairedArtifactsForTask.mockReturnValue([]);
|
||||
dbModule.listPairedEventsForTask.mockReturnValue([]);
|
||||
dbModule.listPairedExecutionsForTask.mockReturnValue([]);
|
||||
|
||||
const pairedWorkspaceManagerModule = {
|
||||
PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE:
|
||||
@@ -175,12 +180,14 @@ describe('paired execution context', () => {
|
||||
event: { id: 1, ...event },
|
||||
result: onApply ? onApply() : null,
|
||||
}));
|
||||
vi.mocked(db.cancelSupersededPairedExecutions).mockReturnValue(0);
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
||||
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
|
||||
vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([]);
|
||||
vi.mocked(db.listPairedEventsForTask).mockReturnValue([]);
|
||||
vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([]);
|
||||
vi.mocked(
|
||||
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
||||
).mockReturnValue({
|
||||
@@ -240,13 +247,63 @@ describe('paired execution context', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-requests review when a low-risk owner execution completes', () => {
|
||||
it('does not self-cancel when the same execution resumes', () => {
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).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: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'active',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:00:00.000Z',
|
||||
});
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
started_at: '2026-03-28T00:00:00.000Z',
|
||||
completed_at: null,
|
||||
});
|
||||
|
||||
preparePairedExecutionContext({
|
||||
group,
|
||||
chatJid: 'dc:test',
|
||||
runId: 'run-1',
|
||||
roomRoleContext: ownerContext,
|
||||
});
|
||||
|
||||
expect(db.cancelSupersededPairedExecutions).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
role: 'owner',
|
||||
exceptExecutionId: 'run-1:codex-main',
|
||||
note: 'Superseded by a newer execution for the same task and role.',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-requests review when a low-risk owner execution completes', () => {
|
||||
vi.mocked(pairedWorkspaceManager.resolvePairedTaskSourceFingerprint).mockReturnValue(
|
||||
'fingerprint-2',
|
||||
);
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -331,8 +388,8 @@ describe('paired execution context', () => {
|
||||
event: expect.objectContaining({
|
||||
task_id: 'task-1',
|
||||
event_type: 'request_review',
|
||||
source_fingerprint: 'fingerprint-1',
|
||||
dedupe_key: 'auto-request-review:fingerprint-1',
|
||||
source_fingerprint: 'fingerprint-2',
|
||||
dedupe_key: 'auto-request-review:fingerprint-2',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -553,6 +610,157 @@ describe('paired execution context', () => {
|
||||
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reopen review from a cancelled stale owner execution', () => {
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
status: 'cancelled',
|
||||
summary: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
started_at: '2026-03-28T00:00:00.000Z',
|
||||
completed_at: null,
|
||||
});
|
||||
|
||||
completePairedExecutionContext({
|
||||
executionId: 'run-1:codex-main',
|
||||
status: 'succeeded',
|
||||
summary: 'stale',
|
||||
});
|
||||
|
||||
expect(db.updatePairedExecution).toHaveBeenCalledWith(
|
||||
'run-1:codex-main',
|
||||
expect.objectContaining({
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
expect(db.applyPairedEvent).not.toHaveBeenCalled();
|
||||
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reopen review from an older owner execution when a newer owner execution already failed', () => {
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
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.listPairedExecutionsForTask).mockReturnValue([
|
||||
{
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
started_at: '2026-03-28T00:00:00.000Z',
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
id: 'run-2:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-2',
|
||||
status: 'failed',
|
||||
summary: 'newer',
|
||||
created_at: '2026-03-28T00:01:00.000Z',
|
||||
started_at: '2026-03-28T00:01:00.000Z',
|
||||
completed_at: '2026-03-28T00:02:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
completePairedExecutionContext({
|
||||
executionId: 'run-1:codex-main',
|
||||
status: 'succeeded',
|
||||
summary: 'stale-after-failed',
|
||||
});
|
||||
|
||||
expect(db.updatePairedExecution).toHaveBeenCalledWith(
|
||||
'run-1:codex-main',
|
||||
expect.objectContaining({
|
||||
status: 'succeeded',
|
||||
summary: 'stale-after-failed',
|
||||
}),
|
||||
);
|
||||
expect(db.applyPairedEvent).not.toHaveBeenCalled();
|
||||
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reopen review from an older owner execution when a newer owner execution already succeeded', () => {
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
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.listPairedExecutionsForTask).mockReturnValue([
|
||||
{
|
||||
id: 'run-1:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-1',
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
started_at: '2026-03-28T00:00:00.000Z',
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
id: 'run-2:codex-main',
|
||||
task_id: 'task-1',
|
||||
service_id: 'codex-main',
|
||||
role: 'owner',
|
||||
workspace_id: 'task-1:owner',
|
||||
checkpoint_fingerprint: 'fingerprint-2',
|
||||
status: 'succeeded',
|
||||
summary: 'newer',
|
||||
created_at: '2026-03-28T00:01:00.000Z',
|
||||
started_at: '2026-03-28T00:01:00.000Z',
|
||||
completed_at: '2026-03-28T00:02:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
completePairedExecutionContext({
|
||||
executionId: 'run-1:codex-main',
|
||||
status: 'succeeded',
|
||||
summary: 'stale-after-succeeded',
|
||||
});
|
||||
|
||||
expect(db.updatePairedExecution).toHaveBeenCalledWith(
|
||||
'run-1:codex-main',
|
||||
expect.objectContaining({
|
||||
status: 'succeeded',
|
||||
summary: 'stale-after-succeeded',
|
||||
}),
|
||||
);
|
||||
expect(db.applyPairedEvent).not.toHaveBeenCalled();
|
||||
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a new checkpoint and returns the task to review_pending when owner changes arrive during in_review', () => {
|
||||
vi.mocked(db.getPairedExecutionById).mockReturnValue({
|
||||
id: 'run-1:codex-main',
|
||||
@@ -621,6 +829,11 @@ describe('paired execution context', () => {
|
||||
review_requested_at: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(db.cancelSupersededPairedExecutions).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
role: 'reviewer',
|
||||
note: 'Superseded by a newer review checkpoint.',
|
||||
});
|
||||
expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
||||
import { SERVICE_ID, normalizeServiceId } from './config.js';
|
||||
import {
|
||||
applyPairedEvent,
|
||||
cancelSupersededPairedExecutions,
|
||||
createPairedArtifact,
|
||||
createPairedExecution,
|
||||
createPairedTask,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
getPairedWorkspace,
|
||||
listPairedArtifactsForTask,
|
||||
listPairedEventsForTask,
|
||||
listPairedExecutionsForTask,
|
||||
updatePairedExecution,
|
||||
updatePairedTask,
|
||||
upsertPairedProject,
|
||||
@@ -119,20 +121,32 @@ function ensureExecutionRecord(args: {
|
||||
roomRoleContext: RoomRoleContext;
|
||||
task: PairedTask;
|
||||
workspace?: PairedWorkspace;
|
||||
checkpointFingerprint?: string | null;
|
||||
}): PairedExecution {
|
||||
const executionId = `${args.runId}:${args.roomRoleContext.serviceId}`;
|
||||
const existing = getPairedExecutionById(executionId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
cancelSupersededPairedExecutions({
|
||||
taskId: args.task.id,
|
||||
role: args.roomRoleContext.role,
|
||||
exceptExecutionId: executionId,
|
||||
note: 'Superseded by a newer execution for the same task and role.',
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
updatePairedExecution(existing.id, {
|
||||
workspace_id: args.workspace?.id ?? existing.workspace_id,
|
||||
checkpoint_fingerprint:
|
||||
args.checkpointFingerprint ?? existing.checkpoint_fingerprint ?? null,
|
||||
status: 'running',
|
||||
started_at: existing.started_at ?? now,
|
||||
});
|
||||
return {
|
||||
...existing,
|
||||
workspace_id: args.workspace?.id ?? existing.workspace_id,
|
||||
checkpoint_fingerprint:
|
||||
args.checkpointFingerprint ?? existing.checkpoint_fingerprint ?? null,
|
||||
status: 'running',
|
||||
started_at: existing.started_at ?? now,
|
||||
};
|
||||
@@ -144,6 +158,7 @@ function ensureExecutionRecord(args: {
|
||||
service_id: args.roomRoleContext.serviceId,
|
||||
role: args.roomRoleContext.role,
|
||||
workspace_id: args.workspace?.id ?? null,
|
||||
checkpoint_fingerprint: args.checkpointFingerprint ?? null,
|
||||
status: 'running',
|
||||
summary: null,
|
||||
created_at: now,
|
||||
@@ -154,6 +169,90 @@ function ensureExecutionRecord(args: {
|
||||
return execution;
|
||||
}
|
||||
|
||||
function getLatestReviewCheckpointFingerprint(taskId: string): string | null {
|
||||
const events = listPairedEventsForTask(taskId);
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (
|
||||
event?.event_type === 'request_review' &&
|
||||
event.source_fingerprint?.trim()
|
||||
) {
|
||||
return event.source_fingerprint.trim();
|
||||
}
|
||||
}
|
||||
return getPairedWorkspace(taskId, 'reviewer')?.snapshot_source_fingerprint ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutionCheckpointFingerprint(args: {
|
||||
taskId: string;
|
||||
role: RoomRoleContext['role'];
|
||||
workspace: PairedWorkspace | null;
|
||||
}): string | null {
|
||||
if (args.role === 'reviewer') {
|
||||
return (
|
||||
args.workspace?.snapshot_source_fingerprint ??
|
||||
getLatestReviewCheckpointFingerprint(args.taskId)
|
||||
);
|
||||
}
|
||||
return resolvePairedTaskSourceFingerprint(args.taskId);
|
||||
}
|
||||
|
||||
function isExecutionFreshForTaskStateWrite(args: {
|
||||
execution: PairedExecution;
|
||||
}): boolean {
|
||||
if (args.execution.status === 'cancelled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args.execution.role === 'owner') {
|
||||
const executionOrderKey = [
|
||||
args.execution.started_at ?? args.execution.created_at,
|
||||
args.execution.created_at,
|
||||
args.execution.id,
|
||||
] as const;
|
||||
const hasNewerOwnerExecution = listPairedExecutionsForTask(
|
||||
args.execution.task_id,
|
||||
).some((candidate) => {
|
||||
if (candidate.role !== 'owner' || candidate.id === args.execution.id) {
|
||||
return false;
|
||||
}
|
||||
const candidateOrderKey = [
|
||||
candidate.started_at ?? candidate.created_at,
|
||||
candidate.created_at,
|
||||
candidate.id,
|
||||
] as const;
|
||||
if (candidateOrderKey[0] > executionOrderKey[0]) {
|
||||
return true;
|
||||
}
|
||||
if (candidateOrderKey[0] < executionOrderKey[0]) {
|
||||
return false;
|
||||
}
|
||||
if (candidateOrderKey[1] > executionOrderKey[1]) {
|
||||
return true;
|
||||
}
|
||||
if (candidateOrderKey[1] < executionOrderKey[1]) {
|
||||
return false;
|
||||
}
|
||||
return candidateOrderKey[2] > executionOrderKey[2];
|
||||
});
|
||||
return !hasNewerOwnerExecution;
|
||||
}
|
||||
|
||||
const executionCheckpoint = args.execution.checkpoint_fingerprint ?? null;
|
||||
if (!executionCheckpoint) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const latestCheckpoint = getLatestReviewCheckpointFingerprint(
|
||||
args.execution.task_id,
|
||||
);
|
||||
if (!latestCheckpoint) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return executionCheckpoint === latestCheckpoint;
|
||||
}
|
||||
|
||||
export interface PreparedPairedExecutionContext {
|
||||
task: PairedTask;
|
||||
execution: PairedExecution;
|
||||
@@ -202,6 +301,11 @@ export function preparePairedExecutionContext(args: {
|
||||
roomRoleContext,
|
||||
task,
|
||||
workspace: workspace ?? undefined,
|
||||
checkpointFingerprint: resolveExecutionCheckpointFingerprint({
|
||||
taskId: task.id,
|
||||
role: roomRoleContext.role,
|
||||
workspace,
|
||||
}),
|
||||
});
|
||||
const envOverrides: Record<string, string> = {
|
||||
EJCLAW_PAIRED_TASK_ID: task.id,
|
||||
@@ -394,19 +498,18 @@ function markTaskReviewPendingWithoutSnapshot(taskId: string): void {
|
||||
}
|
||||
|
||||
function maybeAutoRequestReviewForCompletedExecution(args: {
|
||||
executionId: string;
|
||||
execution: PairedExecution;
|
||||
status: 'succeeded' | 'failed';
|
||||
}): void {
|
||||
if (args.status !== 'succeeded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const execution = getPairedExecutionById(args.executionId);
|
||||
if (!execution || execution.role !== 'owner') {
|
||||
if (args.execution.role !== 'owner') {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = getPairedTaskById(execution.task_id);
|
||||
const task = getPairedTaskById(args.execution.task_id);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
@@ -446,7 +549,7 @@ function maybeAutoRequestReviewForCompletedExecution(args: {
|
||||
|
||||
const roomRoleContext = buildRoomRoleContextFromExecution({
|
||||
task,
|
||||
execution,
|
||||
execution: args.execution,
|
||||
});
|
||||
const reviewIntent = applyRoomIntent({
|
||||
task,
|
||||
@@ -460,10 +563,15 @@ function maybeAutoRequestReviewForCompletedExecution(args: {
|
||||
});
|
||||
|
||||
if (reviewIntent.applied) {
|
||||
cancelSupersededPairedExecutions({
|
||||
taskId: task.id,
|
||||
role: 'reviewer',
|
||||
note: 'Superseded by a newer review checkpoint.',
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
taskId: task.id,
|
||||
executionId: execution.id,
|
||||
executionId: args.execution.id,
|
||||
sourceFingerprint,
|
||||
},
|
||||
'Automatically requested review for low-risk owner checkpoint',
|
||||
@@ -476,13 +584,43 @@ export function completePairedExecutionContext(args: {
|
||||
status: 'succeeded' | 'failed';
|
||||
summary?: string | null;
|
||||
}): void {
|
||||
const execution = getPairedExecutionById(args.executionId);
|
||||
if (!execution) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedAt = new Date().toISOString();
|
||||
const preservedStatus =
|
||||
execution.status === 'cancelled' ? execution.status : args.status;
|
||||
|
||||
updatePairedExecution(args.executionId, {
|
||||
status: args.status,
|
||||
status: preservedStatus,
|
||||
summary: args.summary ?? null,
|
||||
completed_at: new Date().toISOString(),
|
||||
completed_at: completedAt,
|
||||
});
|
||||
|
||||
const completedExecution: PairedExecution = {
|
||||
...execution,
|
||||
status: preservedStatus,
|
||||
summary: args.summary ?? null,
|
||||
completed_at: completedAt,
|
||||
};
|
||||
|
||||
if (!isExecutionFreshForTaskStateWrite({ execution: completedExecution })) {
|
||||
logger.info(
|
||||
{
|
||||
taskId: completedExecution.task_id,
|
||||
executionId: completedExecution.id,
|
||||
role: completedExecution.role,
|
||||
checkpointFingerprint: completedExecution.checkpoint_fingerprint,
|
||||
},
|
||||
'Skipped task state write from stale paired execution',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
maybeAutoRequestReviewForCompletedExecution({
|
||||
executionId: args.executionId,
|
||||
execution: completedExecution,
|
||||
status: args.status,
|
||||
});
|
||||
}
|
||||
@@ -535,6 +673,14 @@ export function markRoomReviewReady(args: {
|
||||
onApply: () => markPairedTaskReviewReady(task.id),
|
||||
});
|
||||
|
||||
if (reviewIntent.applied) {
|
||||
cancelSupersededPairedExecutions({
|
||||
taskId: task.id,
|
||||
role: 'reviewer',
|
||||
note: 'Superseded by a newer review checkpoint.',
|
||||
});
|
||||
}
|
||||
|
||||
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||
const { ownerWorkspace: persistedOwnerWorkspace, reviewerWorkspace } =
|
||||
getPersistedReviewReadyState(
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface PairedExecution {
|
||||
service_id: string;
|
||||
role: PairedWorkspaceRole;
|
||||
workspace_id: string | null;
|
||||
checkpoint_fingerprint?: string | null;
|
||||
status: PairedExecutionStatus;
|
||||
summary: string | null;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user