refactor: centralize attempt completion lifecycle

This commit is contained in:
Brad Groux 2026-08-24 00:06:24 -05:00
parent c9db917422
commit 03d4e94eab
4 changed files with 636 additions and 331 deletions

View file

@ -681,6 +681,14 @@ Callback and remote-session terminal sources are accepted only for OpenClaw.
CLI process and SDK stream providers reject callback transport even when an
attempt ID and manifest digest are known.
Terminal task mutation crosses one `AttemptLifecycleCoordinator` seam. The
coordinator validates the persisted runtime, envelope, and optional launch
manifest bindings; enforces active-attempt ownership; retries bounded task
revision conflicts; updates current and historical attempt state together;
and treats only the exact persisted idempotency key as a safe duplicate.
Provider adapters and restart recovery prepare evidence but cannot implement a
parallel terminal persistence path.
Provider summaries, evidence, artifacts, and verification claims are bounded,
redacted, and stored as unverified provider evidence. Veritas independently
captures Git HEAD, post-launch files and commits, task verification state,

View file

@ -0,0 +1,299 @@
import { describe, expect, it } from 'vitest';
import type { Task, TaskAttempt, TaskEnvelope, UpdateTaskInput } from '@veritas-kanban/shared';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
import {
AttemptLifecycleCoordinator,
type AttemptLifecycleStore,
} from '../services/attempt-lifecycle-coordinator.js';
import { ProviderCompletionService } from '../services/provider-completion-service.js';
import { TaskEnvelopeService } from '../services/task-envelope-service.js';
const completedAt = '2026-08-24T05:00:00.000Z';
function baseTask(): Task {
return {
id: 'task_lifecycle_completion',
title: 'Persist completion through one lifecycle owner',
description: 'Keep terminal attempt mutation behind the lifecycle coordinator.',
type: 'code',
status: 'in-progress',
priority: 'high',
project: 'veritas-kanban',
created: '2026-08-24T04:00:00.000Z',
updated: '2026-08-24T04:00:00.000Z',
revision: 4,
executionPolicy: { commitPolicy: 'allowed' },
};
}
async function taskEnvelope(task: Task): Promise<TaskEnvelope> {
return new TaskEnvelopeService({
captureLaunchBaseline: async (_worktreePath, capturedAt) => ({
capturedAt,
headSha: 'a'.repeat(40),
dirty: false,
files: [],
}),
captureCompletionEvidence: async () => ({
capturedAt: completedAt,
headSha: 'b'.repeat(40),
changedFiles: [],
commits: [],
artifacts: [],
verification: [],
sideEffects: [],
}),
}).build({
task,
attemptId: 'attempt_lifecycle_completion',
createdAt: '2026-08-24T04:30:00.000Z',
worktreePath: '/tmp/veritas-attempt-lifecycle',
providerRuntimeManifest,
commitPolicy: 'allowed',
});
}
class MemoryAttemptLifecycleStore implements AttemptLifecycleStore {
constructor(private task: Task) {}
async getTask(taskId: string): Promise<Task | null> {
return taskId === this.task.id ? structuredClone(this.task) : null;
}
async updateTask(taskId: string, input: UpdateTaskInput): Promise<Task | null> {
if (taskId !== this.task.id) return null;
if (input.expectedRevision !== this.task.revision) {
throw new Error('Task revision conflict');
}
const { expectedRevision: _expectedRevision, ...patch } = input;
this.task = {
...this.task,
...patch,
revision: (this.task.revision ?? 0) + 1,
updated: completedAt,
};
return structuredClone(this.task);
}
}
async function completionFixture(summary = 'Lifecycle work completed.') {
const task = baseTask();
const providerRuntimeManifest = providerRuntimeManifestFixture();
const envelope = await taskEnvelope(task);
const attempt: TaskAttempt = {
id: envelope.attempt.id,
agent: 'codex',
provider: envelope.launchManifest.provider,
status: 'running',
started: envelope.createdAt,
providerRuntimeManifest,
taskEnvelope: envelope,
};
const activeTask: Task = { ...task, attempt, attempts: [attempt] };
const completionResult = await new ProviderCompletionService(
{
captureCompletionEvidence: async () => ({
capturedAt: completedAt,
headSha: 'b'.repeat(40),
changedFiles: [],
commits: [],
artifacts: [],
verification: [],
sideEffects: [],
}),
},
() => completedAt
).complete({
task: activeTask,
taskEnvelope: envelope,
claim: {
terminalSource: 'process',
status: 'success',
summary,
},
});
return { task: activeTask, attempt, completionResult };
}
describe('AttemptLifecycleCoordinator', () => {
it('persists terminal completion through the lifecycle seam', async () => {
const { task, attempt, completionResult } = await completionFixture();
const store = new MemoryAttemptLifecycleStore(task);
const coordinator = new AttemptLifecycleCoordinator(store);
const outcome = await coordinator.persistCompletion({
task,
attempt,
completionResult,
});
expect(outcome.duplicate).toBe(false);
expect(outcome.task).toMatchObject({
status: 'done',
revision: 5,
attempt: {
id: attempt.id,
status: 'complete',
ended: completedAt,
completionResult: { idempotencyKey: completionResult.idempotencyKey },
},
});
expect(outcome.task.attempts).toEqual([
expect.objectContaining({ id: attempt.id, status: 'complete' }),
]);
await expect(store.getTask(task.id)).resolves.toEqual(outcome.task);
});
it('retries a revision conflict against the same immutable attempt', async () => {
const fixture = await completionFixture();
const historicalAttempt: TaskAttempt = {
id: 'attempt_historical',
agent: 'hermes',
provider: 'hermes-cli',
status: 'complete',
ended: '2026-08-24T03:00:00.000Z',
};
const currentTask: Task = {
...fixture.task,
revision: 5,
attempts: [historicalAttempt, fixture.attempt],
};
const coordinator = new AttemptLifecycleCoordinator(
new MemoryAttemptLifecycleStore(currentTask)
);
const outcome = await coordinator.persistCompletion(fixture);
expect(outcome.task.revision).toBe(6);
expect(outcome.task.attempts?.map((attempt) => attempt.id)).toEqual([
historicalAttempt.id,
fixture.attempt.id,
]);
});
it('treats the same persisted terminal result as an idempotent duplicate', async () => {
const fixture = await completionFixture();
const completedAttempt: TaskAttempt = {
...fixture.attempt,
status: 'complete',
ended: completedAt,
completionResult: fixture.completionResult,
};
const persistedTask: Task = {
...fixture.task,
status: 'done',
revision: 5,
attempt: completedAttempt,
attempts: [completedAttempt],
};
const coordinator = new AttemptLifecycleCoordinator(
new MemoryAttemptLifecycleStore(persistedTask)
);
const outcome = await coordinator.persistCompletion(fixture);
expect(outcome.duplicate).toBe(true);
expect(outcome.task).toEqual(persistedTask);
});
it('fails closed when another attempt owns the task during retry', async () => {
const fixture = await completionFixture();
const competingAttempt: TaskAttempt = {
id: 'attempt_competing',
agent: 'hermes',
provider: 'hermes-cli',
status: 'running',
};
const coordinator = new AttemptLifecycleCoordinator(
new MemoryAttemptLifecycleStore({
...fixture.task,
revision: 5,
attempt: competingAttempt,
attempts: [fixture.attempt, competingAttempt],
})
);
await expect(coordinator.persistCompletion(fixture)).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
activeAttemptId: competingAttempt.id,
finalizationAttemptId: fixture.attempt.id,
}),
});
});
it('rejects stale completion input before the first persistence attempt', async () => {
const fixture = await completionFixture();
const competingAttempt: TaskAttempt = {
id: 'attempt_competing',
agent: 'hermes',
provider: 'hermes-cli',
status: 'running',
};
const staleTask: Task = {
...fixture.task,
attempt: competingAttempt,
attempts: [fixture.attempt, competingAttempt],
};
const coordinator = new AttemptLifecycleCoordinator(new MemoryAttemptLifecycleStore(staleTask));
await expect(
coordinator.persistCompletion({ ...fixture, task: staleTask })
).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
activeAttemptId: competingAttempt.id,
finalizationAttemptId: fixture.attempt.id,
}),
});
});
it('rejects a different persisted terminal result for the same attempt', async () => {
const fixture = await completionFixture();
const competingFixture = await completionFixture('A different terminal claim completed.');
const competingResult = competingFixture.completionResult;
const completedAttempt: TaskAttempt = {
...fixture.attempt,
status: 'complete',
ended: completedAt,
completionResult: competingResult,
};
const persistedTask: Task = {
...fixture.task,
status: 'done',
revision: 5,
attempt: completedAttempt,
attempts: [completedAttempt],
};
const coordinator = new AttemptLifecycleCoordinator(
new MemoryAttemptLifecycleStore(persistedTask)
);
await expect(coordinator.persistCompletion(fixture)).rejects.toMatchObject({
statusCode: 409,
code: 'CONFLICT',
details: expect.objectContaining({
persistedIdempotencyKey: competingResult.idempotencyKey,
completionIdempotencyKey: fixture.completionResult.idempotencyKey,
}),
});
});
it('preserves a non-active task status during startup reconciliation', async () => {
const fixture = await completionFixture();
const blockedTask: Task = { ...fixture.task, status: 'blocked' };
const coordinator = new AttemptLifecycleCoordinator(
new MemoryAttemptLifecycleStore(blockedTask)
);
const outcome = await coordinator.persistCompletion({
...fixture,
task: blockedTask,
preserveNonActiveTaskStatus: true,
});
expect(outcome.task.status).toBe('blocked');
});
});

View file

@ -0,0 +1,289 @@
import type {
CompletionResult,
Task,
TaskAttempt,
TaskCompletionStatus,
TaskEnvelope,
UpdateTaskInput,
} from '@veritas-kanban/shared';
import { ConflictError } from '../middleware/error-handler.js';
import {
parseCompletionResultForEnvelope,
parseTaskEnvelope,
} from '../schemas/task-envelope-schemas.js';
import { parseRunLaunchManifest } from '../schemas/run-launch-manifest-schemas.js';
import { assertProviderRuntimeManifestSnapshot } from './provider-runtime-control-service.js';
const COMPLETION_PERSISTENCE_ATTEMPTS = 3;
export interface AttemptLifecycleStore {
getTask(taskId: string): Promise<Task | null>;
updateTask(taskId: string, input: UpdateTaskInput): Promise<Task | null>;
}
export interface PersistAttemptCompletionInput {
task: Task;
attempt: TaskAttempt;
completionResult: CompletionResult;
preserveNonActiveTaskStatus?: boolean;
clearRunRecovery?: boolean;
}
export interface PersistAttemptCompletionOutcome {
task: Task;
attempt: TaskAttempt;
completionResult: CompletionResult;
duplicate: boolean;
}
export class CompletionOwnershipError extends ConflictError {}
/**
* Owns terminal attempt mutation and its immutable persistence invariants.
* Provider orchestration prepares completion evidence, then crosses this seam
* exactly once to claim the terminal task state.
*/
export class AttemptLifecycleCoordinator {
constructor(private readonly store: AttemptLifecycleStore) {}
parsePersistedCompletion(attempt: TaskAttempt): CompletionResult {
if (!attempt.taskEnvelope || !attempt.completionResult) {
throw new CompletionOwnershipError(
'Persisted provider completion is missing its task envelope',
{ attemptId: attempt.id }
);
}
try {
return parseCompletionResultForEnvelope(attempt.completionResult, attempt.taskEnvelope);
} catch {
throw new CompletionOwnershipError(
'Persisted provider completion failed integrity validation',
{
attemptId: attempt.id,
remediation:
'Repair or remove the corrupted completion record before accepting another terminal claim.',
}
);
}
}
assertCompletionBinding(taskId: string, attempt: TaskAttempt): TaskEnvelope {
const providerRuntimeManifest = attempt.providerRuntimeManifest;
const taskEnvelope = attempt.taskEnvelope;
if (!providerRuntimeManifest || !taskEnvelope) {
throw new CompletionOwnershipError(
'Persisted attempt is missing immutable completion bindings',
{ taskId, attemptId: attempt.id }
);
}
let parsedEnvelope: TaskEnvelope;
let parsedRunLaunchManifest: ReturnType<typeof parseRunLaunchManifest> | undefined;
try {
assertProviderRuntimeManifestSnapshot(providerRuntimeManifest);
parsedEnvelope = parseTaskEnvelope(taskEnvelope);
parsedRunLaunchManifest = attempt.runLaunchManifest
? parseRunLaunchManifest(attempt.runLaunchManifest)
: undefined;
} catch {
throw new CompletionOwnershipError('Persisted attempt binding failed integrity validation', {
taskId,
attemptId: attempt.id,
});
}
const mismatches = [
attempt.provider !== providerRuntimeManifest.provider && 'attempt runtime provider',
parsedEnvelope.subject.id !== taskId && 'task ID',
parsedEnvelope.attempt.id !== attempt.id && 'attempt ID',
parsedEnvelope.launchManifest.digest !== providerRuntimeManifest.digest &&
'envelope runtime digest',
parsedEnvelope.launchManifest.provider !== providerRuntimeManifest.provider &&
'envelope runtime provider',
parsedEnvelope.launchManifest.adapter !== providerRuntimeManifest.adapter &&
'envelope runtime adapter',
parsedEnvelope.launchManifest.protocolVersion !== providerRuntimeManifest.protocolVersion &&
'envelope runtime protocol',
parsedRunLaunchManifest?.taskId !== undefined &&
parsedRunLaunchManifest.taskId !== taskId &&
'run launch task ID',
parsedRunLaunchManifest?.attemptId !== undefined &&
parsedRunLaunchManifest.attemptId !== attempt.id &&
'run launch attempt ID',
parsedRunLaunchManifest?.taskEnvelope.digest !== undefined &&
parsedRunLaunchManifest.taskEnvelope.digest !== parsedEnvelope.digest &&
'run launch task envelope digest',
parsedRunLaunchManifest?.providerRuntime.digest !== undefined &&
parsedRunLaunchManifest.providerRuntime.digest !== providerRuntimeManifest.digest &&
'run launch runtime digest',
parsedRunLaunchManifest?.providerRuntime.provider !== undefined &&
parsedRunLaunchManifest.providerRuntime.provider !== providerRuntimeManifest.provider &&
'run launch runtime provider',
parsedRunLaunchManifest?.providerRuntime.adapter !== undefined &&
parsedRunLaunchManifest.providerRuntime.adapter !== providerRuntimeManifest.adapter &&
'run launch runtime adapter',
].filter((field): field is string => typeof field === 'string');
if (mismatches.length > 0) {
throw new CompletionOwnershipError('Persisted attempt completion bindings do not agree', {
taskId,
attemptId: attempt.id,
mismatches,
remediation: 'Repair the persisted attempt binding before accepting a terminal completion.',
});
}
return parsedEnvelope;
}
async persistCompletion(
input: PersistAttemptCompletionInput
): Promise<PersistAttemptCompletionOutcome> {
const taskId = input.task.id;
if (input.task.attempt?.id !== input.attempt.id) {
throw new CompletionOwnershipError(
'Provider finalization does not match the active attempt',
{
taskId,
activeAttemptId: input.task.attempt?.id,
finalizationAttemptId: input.attempt.id,
}
);
}
const envelope = this.assertCompletionBinding(taskId, input.attempt);
const completionResult = parseCompletionResultForEnvelope(input.completionResult, envelope);
const completedAttempt: TaskAttempt = {
...input.attempt,
status: completionResult.status === 'success' ? 'complete' : 'failed',
ended: completionResult.completedAt,
completionResult,
...(input.clearRunRecovery ? { runRecovery: undefined } : {}),
};
let taskSnapshot = input.task;
let lastError: unknown;
for (
let persistenceAttempt = 1;
persistenceAttempt <= COMPLETION_PERSISTENCE_ATTEMPTS;
persistenceAttempt++
) {
try {
const statusUpdate =
input.preserveNonActiveTaskStatus && taskSnapshot.status !== 'in-progress'
? {}
: { status: taskStatusForCompletion(completionResult.status) };
const updatedTask = await this.store.updateTask(taskId, {
expectedRevision: normalizedTaskRevision(taskSnapshot),
...statusUpdate,
attempt: completedAttempt,
attempts: upsertAttemptHistory(taskSnapshot.attempts, completedAttempt),
});
if (!updatedTask) {
throw new CompletionOwnershipError(
'Task was archived or deleted before completion could be persisted',
{ taskId, attemptId: input.attempt.id }
);
}
return {
task: updatedTask,
attempt: completedAttempt,
completionResult,
duplicate: false,
};
} catch (error) {
if (error instanceof CompletionOwnershipError) throw error;
lastError = error;
let latestTask: Task | null;
try {
latestTask = await this.store.getTask(taskId);
} catch {
continue;
}
if (!latestTask) continue;
if (latestTask.attempt?.id !== input.attempt.id) {
throw new CompletionOwnershipError(
'Provider finalization no longer matches the active attempt',
{
taskId,
activeAttemptId: latestTask.attempt?.id,
finalizationAttemptId: input.attempt.id,
}
);
}
this.assertRetryBinding(taskId, completedAttempt, latestTask.attempt);
if (latestTask.attempt.completionResult) {
const persisted = this.parsePersistedCompletion(latestTask.attempt);
if (persisted.idempotencyKey === completionResult.idempotencyKey) {
return {
task: latestTask,
attempt: latestTask.attempt,
completionResult: persisted,
duplicate: true,
};
}
throw new CompletionOwnershipError(
'A different terminal result already owns this attempt',
{
taskId,
attemptId: input.attempt.id,
persistedIdempotencyKey: persisted.idempotencyKey,
completionIdempotencyKey: completionResult.idempotencyKey,
}
);
}
taskSnapshot = latestTask;
}
}
throw lastError instanceof Error
? lastError
: new Error('Provider completion persistence retry budget was exhausted');
}
private assertRetryBinding(
taskId: string,
expectedAttempt: TaskAttempt,
latestAttempt: TaskAttempt
): void {
this.assertCompletionBinding(taskId, latestAttempt);
const mismatches = [
expectedAttempt.provider !== latestAttempt.provider && 'provider',
expectedAttempt.providerRuntimeManifest?.digest !==
latestAttempt.providerRuntimeManifest?.digest && 'provider runtime manifest',
expectedAttempt.taskEnvelope?.digest !== latestAttempt.taskEnvelope?.digest &&
'task envelope',
expectedAttempt.runLaunchManifest?.digest !== latestAttempt.runLaunchManifest?.digest &&
'run launch manifest',
].filter((field): field is string => typeof field === 'string');
if (mismatches.length > 0) {
throw new CompletionOwnershipError(
'Persisted attempt binding changed during completion retry',
{
attemptId: latestAttempt.id,
mismatches,
remediation:
'Discard the stale local finalizer and reconcile the currently persisted attempt.',
}
);
}
}
}
function normalizedTaskRevision(task: Pick<Task, 'revision'>): number {
return typeof task.revision === 'number' && Number.isInteger(task.revision) && task.revision >= 0
? task.revision
: 1;
}
function taskStatusForCompletion(status: TaskCompletionStatus): 'done' | 'blocked' | 'in-progress' {
if (status === 'success') return 'done';
if (status === 'blocked') return 'blocked';
return 'in-progress';
}
function upsertAttemptHistory(
history: TaskAttempt[] | undefined,
attempt: TaskAttempt
): TaskAttempt[] {
return [...(history ?? []).filter((candidate) => candidate.id !== attempt.id), attempt];
}

View file

@ -168,9 +168,7 @@ import {
} from '../middleware/error-handler.js';
import type { AgentBudgetThresholdEvent } from '@veritas-kanban/shared';
import { getAgentProfilePackageService } from './agent-profile-package-service.js';
import {
ProviderRuntimeManifestService,
} from './provider-runtime-manifest-service.js';
import { ProviderRuntimeManifestService } from './provider-runtime-manifest-service.js';
import type { WorkspaceFileRepository } from '../storage/interfaces.js';
import { LocalWorkspaceFileRepository } from '../storage/workspace-file-repository.js';
import {
@ -197,11 +195,6 @@ import {
} from './harness-support-profile-registry.js';
import { RunLaunchManifestService, diffRunLaunchManifests } from './run-launch-manifest-service.js';
import { RunLaunchCompiler } from './run-launch-compiler.js';
import {
parseCompletionResultForEnvelope,
parseTaskEnvelope,
} from '../schemas/task-envelope-schemas.js';
import { parseRunLaunchManifest } from '../schemas/run-launch-manifest-schemas.js';
import { RunTerminalExecuteRequestSchema } from '../schemas/run-terminal-schemas.js';
import {
ProviderCompletionService,
@ -209,6 +202,10 @@ import {
type ProviderCompletionEvidenceClaim,
type ProviderTerminalClaim,
} from './provider-completion-service.js';
import {
AttemptLifecycleCoordinator,
CompletionOwnershipError,
} from './attempt-lifecycle-coordinator.js';
import { getCredentialBrokerService } from './credential-broker-service.js';
import { WorktreeService } from './worktree-service.js';
import {
@ -609,7 +606,6 @@ const scheduledRecoveries = new Map<
string,
{ attemptId: string; timer: ReturnType<typeof setTimeout> }
>();
const COMPLETION_PERSISTENCE_ATTEMPTS = 3;
const NOOP_CREDENTIAL_LEASE_LIFECYCLE: CredentialLeaseLifecycle = {
async revokeRun() {
return 0;
@ -627,8 +623,6 @@ class CompletionPersistenceError extends Error {
}
}
class CompletionOwnershipError extends ConflictError {}
function normalizedTaskRevision(task: Pick<Task, 'revision'>): number {
return typeof task.revision === 'number' && Number.isInteger(task.revision) && task.revision >= 0
? task.revision
@ -650,6 +644,7 @@ export class ClawdbotAgentService {
private runLaunchManifests: RunLaunchManifestService;
private runLaunchCompiler: RunLaunchCompiler;
private providerCompletions: ProviderCompletionService;
private attemptLifecycle: AttemptLifecycleCoordinator;
private credentialLeases: CredentialLeaseLifecycle;
private workspaceFiles: WorkspaceFileRepository;
private worktrees: Pick<WorktreeService, 'claimOwnership' | 'releaseOwnership'>;
@ -750,6 +745,7 @@ export class ClawdbotAgentService {
this.taskEnvelopes = taskEnvelopes;
this.runLaunchManifests = new RunLaunchManifestService();
this.providerCompletions = providerCompletions;
this.attemptLifecycle = new AttemptLifecycleCoordinator(this.taskService);
this.credentialLeases = credentialLeases;
this.workspaceFiles = workspaceFiles;
this.worktrees =
@ -1064,16 +1060,9 @@ export class ClawdbotAgentService {
'Legacy running attempt has no durable supervisor bindings and cannot be recovered safely.',
};
if (attempt.taskEnvelope && attempt.providerRuntimeManifest) {
await this.persistRestartedProviderCompletion(
task,
attempt,
claim,
this.providerCompletions.idempotencyKey({
taskEnvelope: attempt.taskEnvelope,
claim,
}),
{ preserveNonActiveTaskStatus: true }
);
await this.persistRestartedProviderCompletion(task, attempt, claim, {
preserveNonActiveTaskStatus: true,
});
} else {
const failedAttempt: TaskAttempt = {
...attempt,
@ -1090,7 +1079,7 @@ export class ClawdbotAgentService {
continue;
}
this.assertPersistedAttemptCompletionBinding(task.id, attempt);
this.attemptLifecycle.assertCompletionBinding(task.id, attempt);
const provider = executableProvider(attempt.provider);
if (provider === 'system') {
throw new CompletionOwnershipError('Persisted attempt has no executable provider.', {
@ -3492,7 +3481,7 @@ export class ClawdbotAgentService {
provenance.providerRuntimeManifestDigest &&
persistedAttempt.taskEnvelope
) {
this.assertPersistedAttemptCompletionBinding(taskId, persistedAttempt);
this.attemptLifecycle.assertCompletionBinding(taskId, persistedAttempt);
this.assertTerminalTransport(persistedAttempt.provider, terminalSource);
const claim = this.normalizeTerminalClaim(result, terminalSource);
const idempotencyKey = this.providerCompletions.idempotencyKey({
@ -3500,7 +3489,8 @@ export class ClawdbotAgentService {
claim,
});
if (persistedAttempt.completionResult) {
const persistedCompletion = this.parsePersistedCompletion(persistedAttempt);
const persistedCompletion =
this.attemptLifecycle.parsePersistedCompletion(persistedAttempt);
if (persistedCompletion.idempotencyKey === idempotencyKey) {
await this.admission.releaseByAttempt(
persistedAttempt.taskEnvelope.workspace.workspaceId,
@ -3537,12 +3527,7 @@ export class ClawdbotAgentService {
(terminalSource === 'callback' || terminalSource === 'remote-session') &&
(persistedAttempt.status === 'running' || persistedAttempt.status === 'failed')
) {
await this.persistRestartedProviderCompletion(
task,
persistedAttempt,
claim,
idempotencyKey
);
await this.persistRestartedProviderCompletion(task, persistedAttempt, claim);
return;
}
}
@ -3606,121 +3591,6 @@ export class ClawdbotAgentService {
}
}
private parsePersistedCompletion(attempt: TaskAttempt): CompletionResult {
if (!attempt.taskEnvelope || !attempt.completionResult) {
throw new CompletionOwnershipError(
'Persisted provider completion is missing its task envelope',
{
attemptId: attempt.id,
}
);
}
try {
return parseCompletionResultForEnvelope(attempt.completionResult, attempt.taskEnvelope);
} catch {
throw new CompletionOwnershipError(
'Persisted provider completion failed integrity validation',
{
attemptId: attempt.id,
remediation:
'Repair or remove the corrupted completion record before accepting another terminal claim.',
}
);
}
}
private assertCompletionRetryBinding(
taskId: string,
expectedAttempt: TaskAttempt,
latestAttempt: TaskAttempt
): void {
this.assertPersistedAttemptCompletionBinding(taskId, latestAttempt);
const mismatches = [
expectedAttempt.provider !== latestAttempt.provider && 'provider',
expectedAttempt.providerRuntimeManifest?.digest !==
latestAttempt.providerRuntimeManifest?.digest && 'provider runtime manifest',
expectedAttempt.taskEnvelope?.digest !== latestAttempt.taskEnvelope?.digest &&
'task envelope',
expectedAttempt.runLaunchManifest?.digest !== latestAttempt.runLaunchManifest?.digest &&
'run launch manifest',
].filter((field): field is string => typeof field === 'string');
if (mismatches.length > 0) {
throw new CompletionOwnershipError(
'Persisted attempt binding changed during completion retry',
{
attemptId: latestAttempt.id,
mismatches,
remediation:
'Discard the stale local finalizer and reconcile the currently persisted attempt.',
}
);
}
}
private assertPersistedAttemptCompletionBinding(taskId: string, attempt: TaskAttempt): void {
const providerRuntimeManifest = attempt.providerRuntimeManifest;
const taskEnvelope = attempt.taskEnvelope;
if (!providerRuntimeManifest || !taskEnvelope) {
throw new CompletionOwnershipError(
'Persisted attempt is missing immutable completion bindings',
{ taskId, attemptId: attempt.id }
);
}
let parsedEnvelope: TaskEnvelope;
let parsedRunLaunchManifest: RunLaunchManifest | undefined;
try {
assertProviderRuntimeManifestSnapshot(providerRuntimeManifest);
parsedEnvelope = parseTaskEnvelope(taskEnvelope);
parsedRunLaunchManifest = attempt.runLaunchManifest
? parseRunLaunchManifest(attempt.runLaunchManifest)
: undefined;
} catch {
throw new CompletionOwnershipError('Persisted attempt binding failed integrity validation', {
taskId,
attemptId: attempt.id,
});
}
const mismatches = [
attempt.provider !== providerRuntimeManifest.provider && 'attempt runtime provider',
parsedEnvelope.subject.id !== taskId && 'task ID',
parsedEnvelope.attempt.id !== attempt.id && 'attempt ID',
parsedEnvelope.launchManifest.digest !== providerRuntimeManifest.digest &&
'envelope runtime digest',
parsedEnvelope.launchManifest.provider !== providerRuntimeManifest.provider &&
'envelope runtime provider',
parsedEnvelope.launchManifest.adapter !== providerRuntimeManifest.adapter &&
'envelope runtime adapter',
parsedEnvelope.launchManifest.protocolVersion !== providerRuntimeManifest.protocolVersion &&
'envelope runtime protocol',
parsedRunLaunchManifest?.taskId !== undefined &&
parsedRunLaunchManifest.taskId !== taskId &&
'run launch task ID',
parsedRunLaunchManifest?.attemptId !== undefined &&
parsedRunLaunchManifest.attemptId !== attempt.id &&
'run launch attempt ID',
parsedRunLaunchManifest?.taskEnvelope.digest !== undefined &&
parsedRunLaunchManifest.taskEnvelope.digest !== parsedEnvelope.digest &&
'run launch task envelope digest',
parsedRunLaunchManifest?.providerRuntime.digest !== undefined &&
parsedRunLaunchManifest.providerRuntime.digest !== providerRuntimeManifest.digest &&
'run launch runtime digest',
parsedRunLaunchManifest?.providerRuntime.provider !== undefined &&
parsedRunLaunchManifest.providerRuntime.provider !== providerRuntimeManifest.provider &&
'run launch runtime provider',
parsedRunLaunchManifest?.providerRuntime.adapter !== undefined &&
parsedRunLaunchManifest.providerRuntime.adapter !== providerRuntimeManifest.adapter &&
'run launch runtime adapter',
].filter((field): field is string => typeof field === 'string');
if (mismatches.length > 0) {
throw new CompletionOwnershipError('Persisted attempt completion bindings do not agree', {
taskId,
attemptId: attempt.id,
mismatches,
remediation: 'Repair the persisted attempt binding before accepting a terminal completion.',
});
}
}
private async persistSupervisorCompletion(
task: Task,
attempt: TaskAttempt,
@ -3732,63 +3602,13 @@ export class ClawdbotAgentService {
attemptId: attempt.id,
});
}
this.assertPersistedAttemptCompletionBinding(task.id, attempt);
const completionResult = parseCompletionResultForEnvelope(value, attempt.taskEnvelope);
const completedAttempt: TaskAttempt = {
...attempt,
status: completionResult.status === 'success' ? 'complete' : 'failed',
ended: completionResult.completedAt,
completionResult,
runRecovery: undefined,
};
let taskSnapshot = task;
let lastError: unknown;
for (
let persistenceAttempt = 1;
persistenceAttempt <= COMPLETION_PERSISTENCE_ATTEMPTS;
persistenceAttempt++
) {
try {
const updatedTask = await this.taskService.updateTask(task.id, {
expectedRevision: normalizedTaskRevision(taskSnapshot),
status: taskStatusForCompletion(completionResult.status),
attempt: completedAttempt,
attempts: upsertAttemptHistory(taskSnapshot.attempts, completedAttempt),
});
if (!updatedTask) {
throw new CompletionOwnershipError(
'Task was archived or deleted before supervisor completion could be persisted.',
{ taskId: task.id, attemptId: attempt.id }
);
}
lastError = undefined;
break;
} catch (error) {
if (error instanceof CompletionOwnershipError) throw error;
lastError = error;
const latestTask = await this.taskService.getTask(task.id);
if (latestTask?.attempt?.id !== attempt.id) throw error;
this.assertCompletionRetryBinding(task.id, completedAttempt, latestTask.attempt);
if (latestTask.attempt.completionResult) {
const persisted = this.parsePersistedCompletion(latestTask.attempt);
if (persisted.idempotencyKey === completionResult.idempotencyKey) {
lastError = undefined;
break;
}
throw new CompletionOwnershipError(
'A different terminal result already owns this attempt.',
{
taskId: task.id,
attemptId: attempt.id,
persistedIdempotencyKey: persisted.idempotencyKey,
completionIdempotencyKey: completionResult.idempotencyKey,
}
);
}
taskSnapshot = latestTask;
}
}
if (lastError) throw lastError;
const persisted = await this.attemptLifecycle.persistCompletion({
task,
attempt,
completionResult: value,
clearRunRecovery: true,
});
const { completionResult, attempt: completedAttempt } = persisted;
this.scheduleReflectionExtraction(attempt.taskEnvelope.workspace.workspaceId, completionResult);
await this.admission.releaseByAttempt(
attempt.taskEnvelope.workspace.workspaceId,
@ -3824,7 +3644,6 @@ export class ClawdbotAgentService {
task: Task,
attempt: TaskAttempt,
claim: ProviderTerminalClaim,
idempotencyKey: string,
options: { preserveNonActiveTaskStatus?: boolean } = {}
): Promise<void> {
if (!attempt.taskEnvelope) {
@ -3833,7 +3652,7 @@ export class ClawdbotAgentService {
attemptId: attempt.id,
});
}
this.assertPersistedAttemptCompletionBinding(task.id, attempt);
this.attemptLifecycle.assertCompletionBinding(task.id, attempt);
const dependencyCircuits = attempt.runLaunchManifest
? await this.captureDependencyCircuits(
attempt.runLaunchManifest.providerRuntime.provider,
@ -3871,13 +3690,6 @@ export class ClawdbotAgentService {
task.id,
attempt.id
);
const completedAttempt: TaskAttempt = {
...attempt,
status: completionResult.status === 'success' ? 'complete' : 'failed',
ended: completionResult.completedAt,
completionResult,
};
const completionStatus = taskStatusForCompletion(completionResult.status);
if (attempt.provider === 'openclaw' && completionResult.summary) {
await this.appendMappedProviderEvent(
task,
@ -3952,58 +3764,12 @@ export class ClawdbotAgentService {
completionResult
);
}
let taskSnapshot = task;
let lastError: unknown;
for (
let persistenceAttempt = 1;
persistenceAttempt <= COMPLETION_PERSISTENCE_ATTEMPTS;
persistenceAttempt++
) {
const taskStatusUpdate =
options.preserveNonActiveTaskStatus && taskSnapshot.status !== 'in-progress'
? {}
: { status: completionStatus };
try {
const updatedTask = await this.taskService.updateTask(task.id, {
expectedRevision: normalizedTaskRevision(taskSnapshot),
...taskStatusUpdate,
attempt: completedAttempt,
attempts: upsertAttemptHistory(taskSnapshot.attempts, completedAttempt),
});
if (!updatedTask) {
throw new CompletionOwnershipError(
'Task was archived or deleted before completion could be persisted',
{ taskId: task.id, attemptId: attempt.id }
);
}
lastError = undefined;
break;
} catch (error) {
if (error instanceof CompletionOwnershipError) throw error;
lastError = error;
const latestTask = await this.taskService.getTask(task.id);
if (latestTask?.attempt?.id !== attempt.id) throw error;
this.assertCompletionRetryBinding(task.id, completedAttempt, latestTask.attempt);
if (latestTask.attempt.completionResult) {
const latestCompletion = this.parsePersistedCompletion(latestTask.attempt);
if (latestCompletion.idempotencyKey === idempotencyKey) {
lastError = undefined;
break;
}
throw new CompletionOwnershipError(
'A different terminal result already owns this attempt',
{
taskId: task.id,
attemptId: attempt.id,
persistedIdempotencyKey: latestCompletion.idempotencyKey,
completionIdempotencyKey: idempotencyKey,
}
);
}
taskSnapshot = latestTask;
}
}
if (lastError) throw lastError;
await this.attemptLifecycle.persistCompletion({
task,
attempt,
completionResult,
preserveNonActiveTaskStatus: options.preserveNonActiveTaskStatus,
});
this.scheduleReflectionExtraction(
attempt.taskEnvelope.workspace.workspaceId,
completionResult,
@ -4557,69 +4323,18 @@ export class ClawdbotAgentService {
pending: PendingAgent,
prepared: NonNullable<PendingAgent['preparedCompletion']>
): Promise<boolean> {
let taskSnapshot = prepared.taskBeforeCompletion;
let lastError: unknown;
for (let attempt = 1; attempt <= COMPLETION_PERSISTENCE_ATTEMPTS; attempt++) {
if (!taskSnapshot) {
throw new ConflictError('Task disappeared before completion could be persisted', {
taskId,
attemptId: pending.attemptId,
});
}
try {
const updatedTask = await this.taskService.updateTask(taskId, {
expectedRevision: normalizedTaskRevision(taskSnapshot),
status: taskStatusForCompletion(prepared.completionResult.status),
attempt: prepared.completedAttempt,
attempts: upsertAttemptHistory(taskSnapshot.attempts, prepared.completedAttempt),
});
if (!updatedTask) {
throw new CompletionOwnershipError(
'Task was archived or deleted before completion could be persisted',
{ taskId, attemptId: pending.attemptId }
);
}
return true;
} catch (error) {
if (error instanceof CompletionOwnershipError) throw error;
lastError = error;
let latestTask: Task | null;
try {
latestTask = await this.taskService.getTask(taskId);
} catch {
latestTask = null;
}
if (!latestTask) continue;
if (latestTask.attempt?.id !== pending.attemptId) {
throw new CompletionOwnershipError(
'Provider finalization no longer matches the active attempt',
{
taskId,
activeAttemptId: latestTask.attempt?.id,
finalizationAttemptId: pending.attemptId,
}
);
}
this.assertCompletionRetryBinding(taskId, prepared.completedAttempt, latestTask.attempt);
if (latestTask.attempt.completionResult) {
const persisted = this.parsePersistedCompletion(latestTask.attempt);
if (persisted.idempotencyKey === prepared.completionResult.idempotencyKey) return true;
throw new CompletionOwnershipError(
'A different terminal result already owns this attempt',
{
taskId,
attemptId: pending.attemptId,
persistedIdempotencyKey: persisted.idempotencyKey,
completionIdempotencyKey: prepared.completionResult.idempotencyKey,
}
);
}
taskSnapshot = latestTask;
}
if (!prepared.taskBeforeCompletion) {
throw new ConflictError('Task disappeared before completion could be persisted', {
taskId,
attemptId: pending.attemptId,
});
}
throw lastError instanceof Error
? lastError
: new Error('Provider completion persistence retry budget was exhausted');
await this.attemptLifecycle.persistCompletion({
task: prepared.taskBeforeCompletion,
attempt: prepared.completedAttempt,
completionResult: prepared.completionResult,
});
return true;
}
/**
@ -10560,12 +10275,6 @@ ${message}
`;
}
function taskStatusForCompletion(status: TaskCompletionStatus): 'done' | 'blocked' | 'in-progress' {
if (status === 'success') return 'done';
if (status === 'blocked') return 'blocked';
return 'in-progress';
}
function admissionReleaseReason(
status: TaskCompletionStatus | undefined,
success?: boolean