plandex-ai/plandex · warning

Plan already archived

Error message

Plan already archived

What it means

ArchivePlanHandler rejects archiving a plan that already has a non-nil archived_at timestamp, returning HTTP 400 'Plan already archived'. This is an intentional idempotency/state guard: a plan can only be archived once.

Source

Thrown at app/server/handlers/plans_changes.go:452

	if auth == nil {
		return
	}

	log.Println("Received request for ArchivePlanHandler")

	vars := mux.Vars(r)
	planId := vars["planId"]
	log.Println("planId: ", planId)

	plan := authorizePlanArchive(w, planId, auth)

	if plan == nil {
		return
	}

	if plan.ArchivedAt != nil {
		log.Println("Plan already archived")
		http.Error(w, "Plan already archived", http.StatusBadRequest)
		return
	}

	res, err := db.Conn.Exec("UPDATE plans SET archived_at = NOW() WHERE id = $1", planId)

	if err != nil {
		log.Printf("Error archiving plan: %v\n", err)
		http.Error(w, "Error archiving plan: "+err.Error(), http.StatusInternalServerError)
		return
	}

	rowsAffected, err := res.RowsAffected()
	if err != nil {
		log.Printf("Error getting rows affected: %v\n", err)
		http.Error(w, "Error getting rows affected: "+err.Error(), http.StatusInternalServerError)
		return
	}

View on GitHub (pinned to e2d772072e)

Solutions

  1. Fetch the plan first and skip archiving if archivedAt is already set (treat 400 as success in idempotent flows)
  2. Dedupe UI submissions (disable button while in flight)
  3. Don't retry this request automatically — it's a permanent 400, not transient
  4. If you need the plan active again, call unarchive instead

Example fix

// before
await archivePlan(planId); // throws on second call
// after
const plan = await getPlan(planId);
if (!plan.archivedAt) await archivePlan(planId);
Defensive patterns

Strategy: validation

Validate before calling

const plan = await getPlan(planId);
if (plan.archivedAt) { /* already archived, skip */ return; }
await archivePlan(planId);

Type guard

function isAlreadyArchived(v) { return v != null; } // plan.ArchivedAt (*time.Time) non-nil means archived
if (isAlreadyArchived(plan.archivedAt)) skipArchive();

Try / catch

try { await archivePlan(planId); } catch (e) { if (e.status === 400 && e.message === 'Plan already archived') return; /* treat as success (idempotent) */ throw e; }

Prevention

When it happens

Trigger: POST/PUT to the archive endpoint for a planId whose plans.archived_at is already set — i.e. the plan was archived previously by this or another user/session.

Common situations: Double-clicking an archive button; retrying a request that actually succeeded the first time; two operators archiving the same plan concurrently; automated scripts running archive jobs repeatedly.

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 plandex-ai/plandex@e2d772072e (2026-09-05). Data as JSON: /api/errors/25d2b6a955011364. Report an issue: GitHub.