thedotmack/claude-mem · error

cannot transition observation generation job from terminal s

Error message

cannot transition observation generation job from terminal status ${current.status}

What it means

Thrown by assertValidJobStatusTransition (reached from transitionStatus after the UPDATE matches zero rows). The job's current status is in TERMINAL_JOB_STATUSES (completed, failed, cancelled), which all have empty ALLOWED_JOB_TRANSITIONS. Terminal jobs are immutable, so any further transition is rejected.

Source

Thrown at src/storage/postgres/generation-jobs.ts:403

  return { agentEventId: null, serverSessionId: input.serverSessionId ?? null };
}

const TERMINAL_JOB_STATUSES = new Set<ObservationGenerationJobStatus>(['completed', 'failed', 'cancelled']);

const ALLOWED_JOB_TRANSITIONS: Record<ObservationGenerationJobStatus, readonly ObservationGenerationJobStatus[]> = {
  queued: ['processing', 'failed', 'cancelled'],
  processing: ['queued', 'completed', 'failed', 'cancelled'],
  completed: [],
  failed: [],
  cancelled: []
};

function assertValidJobStatusTransition(
  current: PostgresObservationGenerationJob,
  nextStatus: ObservationGenerationJobStatus
): void {
  if (TERMINAL_JOB_STATUSES.has(current.status)) {
    throw new Error(`cannot transition observation generation job from terminal status ${current.status}`);
  }

  if (!ALLOWED_JOB_TRANSITIONS[current.status].includes(nextStatus)) {
    throw new Error(`illegal observation generation job transition from ${current.status} to ${nextStatus}`);
  }

  if (nextStatus === 'processing' && current.attempts >= current.maxAttempts) {
    throw new Error('cannot process observation generation job after max_attempts is reached');
  }

  if (nextStatus === 'queued' && current.attempts >= current.maxAttempts) {
    throw new Error('cannot retry observation generation job after max_attempts is reached');
  }
}

function mapJobRow(row: JobRow): PostgresObservationGenerationJob {
  return {
    id: row.id,

View on GitHub (pinned to d768ba3643)

Solutions

  1. Before calling transitionStatus, load the job and return early if its status is in {completed, failed, cancelled}.
  2. Make the caller idempotent: treat a terminal status matching the desired outcome as success.
  3. If you genuinely need to redo the work, create a new job (new source/idempotency key) instead of reopening a terminal one.

Example fix

// before
await jobs.transitionStatus({ id, projectId, teamId, status:'completed' });
// after
const current = await jobs.getByIdForScope({ id, projectId, teamId });
if (current && ['completed','failed','cancelled'].includes(current.status)) {
  return current; // already terminal, nothing to do
}
await jobs.transitionStatus({ id, projectId, teamId, status:'completed' });
Defensive patterns

Strategy: validation

Validate before calling

const TERMINAL = new Set(['completed','failed','cancelled']);
const job = await jobs.getByIdForScope({ id, projectId, teamId });
if (job && TERMINAL.has(job.status)) return job;

Type guard

function isTerminalJob(job) { return new Set(['completed','failed','cancelled']).has(job?.status); }

Try / catch

try { await jobs.transitionStatus({...}); } catch (e) { if (e.message.startsWith('cannot transition observation generation job from terminal status')) { return; } throw e; }

Prevention

When it happens

Trigger: Calling transitionStatus on a job whose status is already 'completed', 'failed', or 'cancelled'. The UPDATE WHERE clause already excludes terminal statuses, so the function re-reads the row and this guard explains why nothing was applied.

Common situations: Duplicate worker acknowledgements racing after a job finished; a retry worker that does not check terminal state; UI replaying a transition button; idempotency logic not short-circuiting on terminal jobs.

Related errors


AI-assisted analysis of thedotmack/claude-mem@d768ba3643 (2026-08-12). Data as JSON: /api/errors/8c762e383ab1e89f. Report an issue: GitHub.