mastra-ai/mastra · error · Error

Turn already ended

Error message

Turn already ended

What it means

ObservationTurn enforces a linear lifecycle: start() -> step() -> end(). This error is thrown by step() when the turn has already been finalized via end(), so no further steps can be created. It guards against reusing a consumed turn object.

Source

Thrown at packages/memory/src/processors/observational-memory/observation-turn/turn.ts:197

  setRecord(record: ObservationalMemoryRecord): void {
    this._record = record;
    if (this._context) {
      this._context.record = record;
    }
  }

  /** Patch the cached turn record with merged fields. */
  patchRecord(patch: Partial<ObservationalMemoryRecord>): void {
    this.setRecord({ ...this.record, ...patch });
  }

  /**
   * Create a step handle. If a previous step exists, it is finalized
   * (its output messages will be saved at the start of the new step's prepare()).
   */
  step(stepNumber: number): ObservationStep {
    if (!this._started) throw new Error('Turn not started — call start() first');
    if (this._ended) throw new Error('Turn already ended');

    this._currentStep = new ObservationStep(this, stepNumber);
    return this._currentStep;
  }

  /**
   * Finalize the turn: save any remaining messages and return the current cached record.
   *
   * When async observation buffering is enabled and there are unobserved messages,
   * a background buffer operation is kicked off so that observations are computed
   * proactively while the agent is idle, rather than waiting for the next turn.
   * The returned record does not wait for that background buffering pass to finish.
   */
  async end(): Promise<TurnResult> {
    if (this._ended) throw new Error('Turn already ended');
    this._ended = true;

    // Save any unsaved messages from the last step

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Create a fresh ObservationTurn (call start()) for each new turn instead of reusing the old object
  2. Restructure the loop so step()/end() are called exactly once per turn lifecycle
  3. Guard with a check of the turn's ended state (or track it locally) before calling step()

Example fix

// before
if (!turn) turn = new ObservationTurn(...);
turn.step(stepNumber);
// after
if (!turn || turnEnded) {
  turn = new ObservationTurn(...);
  turn.start();
  turnEnded = false;
}
const step = turn.step(stepNumber);
// ... at end of loop:
await turn.end();
turnEnded = true;
Defensive patterns

Strategy: validation

Validate before calling

if (!turn || turnIsEnded) {
  turn = createNewTurn();
  turn.start();
}
const step = turn.step(stepNumber);

Type guard

function turnIsActive(turn: ObservationTurn | null | undefined): turn is ObservationTurn {
  return !!turn && !turn.isEnded();
}

Try / catch

try {
  const step = turn.step(stepNumber);
} catch (err) {
  if (err instanceof Error && err.message === 'Turn already ended') {
    turn = createNewTurn();
    turn.start();
  } else throw err;
}

Prevention

When it happens

Trigger: Calling turn.step(n) after turn.end() has been called, or after a prior step sequence completed; typically from reusing a cached/stale ObservationTurn reference.

Common situations: Agent loops that keep a reference to the previous turn across iterations; double-processing the same messageList (e.g. retry logic re-invokes step on an already-ended turn); storing the turn in memory and calling step again on a later request.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/7460a106f5f510d9. Report an issue: GitHub.