windmill-labs/windmill · error · PlanSlotTakenError

PlanSlotTakenError

Error message

PlanSlotTakenError

What it means

PlanSlotTakenError is thrown by artifactsState.create when a plan-role create targets a session that already has a plan artifact. A session holds exactly one plan: the id is deterministic (planArtifactId(sessionId)), so a second create collides with the existing document inside mutateArtifact, and the error carries that existing plan because the useful next step is to revise it.

Source

Thrown at frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts:177

			kind: input.kind ?? 'md',
			role: input.role,
			approvedVersion: input.approvedVersion,
			name: input.name,
			content: input.content,
			createdAt: now,
			updatedAt: now,
			version: 1
		})
		// A plan's id is the session's, so a second one cannot be minted; the slot check happens
		// on the row this write is about to replace, inside the transaction that replaces it.
		const id = input.role === 'plan' ? planArtifactId(sessionId) : randomUUID()
		const { outcome, artifact } = await mutateArtifact(id, (existing) => {
			// Before the slot check: a posture that may not mint a plan at all is the more useful
			// thing to say, and it holds whether or not the session already has one.
			if (input.role === 'plan' && opts?.canWritePlan?.() === false) {
				throw new PlanWriteRefusedError()
			}
			if (existing) throw new PlanSlotTakenError(existing)
			const created = draft(id)
			return { artifact: created, snapshots: [snapshotOf(created, 1)] }
		})
		// An ordinary artifact degrades unpersisted; a plan cannot. Returning one the database
		// refused would let the user approve a plan that disappears on reload.
		if (input.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()
		const written = artifact ?? draft(id)
		this.#reflect(written)
		return written
	}

	/**
	 * Merge changes into an existing artifact. Returns undefined if `id` is unknown, or if
	 * `opts.sessionId` is given and the artifact belongs to a different session.
	 */
	async update(
		id: string,
		input: UpdateArtifactInput,

View on GitHub (pinned to e474e8803c)

Solutions

  1. Catch PlanSlotTakenError and use error.plan (or planArtifactId(sessionId)) to update/revise the existing plan instead of creating one
  2. Read the existing plan first (getVersion/getArtifactVersion) and only create when none exists
  3. Clear/replace the session's plan through the update path so approval state carries over correctly

Example fix

// before
await artifacts.create({ role: 'plan', sessionId, content }) // throws when slot taken
// after
try { await artifacts.create({ role: 'plan', sessionId, content }) }
catch (e) {
  if (e instanceof PlanSlotTakenError) await artifacts.update(e.plan.id, { content })
  else throw e
}
Defensive patterns

Strategy: try-catch

Validate before calling

const existingPlan = await getArtifactVersion(planArtifactId(sessionId), 1)
if (existingPlan) throw new Error('Plan already exists; revise it via update')

Type guard

function isPlanSlotTaken(e: unknown): e is PlanSlotTakenError {
  return e instanceof PlanSlotTakenError
}

Try / catch

try {
  await artifacts.create({ role: 'plan', sessionId, content })
} catch (e) {
  if (e instanceof PlanSlotTakenError) {
    await artifacts.update(e.plan.id, { content })
    return
  }
  throw e
}

Prevention

When it happens

Trigger: Calling create({ role: 'plan', sessionId, ... }) when a persisted plan artifact already exists for that session — duplicate plan creation by the model, or a race where two plan writes run concurrently.

Common situations: Model re-issuing a plan-create tool call after a retry that actually succeeded; a workflow stage resetting without clearing the old plan; the user re-running the planning step on a session that already stored a plan.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/9b5de2cfb7c9a6d8. Report an issue: GitHub.