Skip to content

Archiving a thread fails with 409 once its environment row has been pruned #1924

Description

@brsbl

Symptom

POST /api/v1/threads/:id/archive returns 409 thread_environment_unavailable for any thread whose environment_id is NULL. The thread can never be archived and stays permanently visible in the sidebar. Both /archive and /archive-all fail, so there is no UI or CLI workaround — the only way to get rid of the thread is delete, which destroys its transcript.

$ bb thread archive thr_xxx
Error: Failed to archive thread thr_xxx: HTTP 409: Thread environment is unavailable

$ curl -XPOST .../api/v1/threads/thr_xxx/archive
{"code":"thread_environment_unavailable","message":"Thread environment is unavailable",
 "details":{"reason":"never_attached","environmentStatus":null}}

The reported reason never_attached is itself misleading: these threads did run in an environment, they just outlived its row.

How a thread ends up with a NULL environment

  1. The thread runs in a managed environment. The environment is later destroyed.
  2. pruneDestroyedEnvironments hard-deletes destroyed environments older than DESTROYED_ENVIRONMENT_TTL_MS (7 days) — packages/db/src/data/sweeps.ts.
  3. threads.environment_id is declared ON DELETE SET NULL, so that hard delete silently strips the pointer off threads that are still unarchived.
  4. Archive then reads NULL as "never attached" and rejects.

The sweep filters only on environment status and age. Its sibling sweepManagedEnvironments does guard on live threads (threads.archived_at IS NULL AND threads.deleted_at IS NULL); pruneDestroyedEnvironments has no equivalent guard, so it will keep re-manufacturing this state.

Repro

  1. Create a thread in a managed environment and let it go idle.
  2. Destroy the environment; leave the thread unarchived.
  3. Wait out the 7-day TTL (or set environments.updated_at back 8 days and let the sweep run).
  4. Try to archive the thread — 409.

Why archive diverges from delete

The delete route already handles exactly this state, skipping the environment-scoped work:

// apps/server/src/routes/threads/base.ts
if (thread.environmentId === null) {
  finalizeStoppedThread(deps, { threadId: thread.id });
  return context.json({ ok: true });
}

Archive has no equivalent branch. It calls requireThreadHostCommandEnvironment, which throws for a NULL environment (apps/server/src/services/threads/thread-command-environment.ts). The environment is only needed to stop active runtime work, and a thread with no environment has no runtime left to stop — so the requirement is not load-bearing. This reads as an oversight in archive rather than an intentional difference.

Affected call sites:

  • apps/server/src/routes/threads/actions.ts — the routes.archive handler
  • apps/server/src/services/threads/thread-archive.tsarchiveThreadAndHiddenSourceForks and archiveThreadAndChildren

Proposed fix

  1. Mirror delete's NULL-environment handling in archive: resolve the environment as nullable and skip requestActiveRuntimeThreadStopIfNeeded when it is absent. Every other archive effect (terminal close, event pruning, plugin event, child release) still applies.
  2. Add the live-thread guard to pruneDestroyedEnvironments, mirroring sweepManagedEnvironments, so the sweep stops creating the state in the first place.

Tradeoff

This needs a PR and a new build before archiving works for affected threads. There is no runtime workaround in the meantime.

Impact

Affected threads archive normally and stay recoverable through unarchive, instead of forcing a permanent delete that destroys the transcript. And nobody hits this again.

Affected threads observed

Two threads on one install hit this — both idle, both codex, both with environments pruned after the TTL:

  • thr_vyaz3wdwjv ("Match Sidebar Workflow Icon", created 2026-06-25)
  • thr_ijwvxv9hy6 ("Disk And CPU Cleanup", created 2026-07-18)

Both had to be deleted rather than archived, permanently destroying their transcripts. Any thread will hit this roughly 7 days after its environment is destroyed while it remains unarchived.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

confirmed-reproBug reproduced again from a clean trusted checkout; see linked reportthreadsTurns, timeline, messaging, forksworkspacesWorktrees, environments, git, shells

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions