← reports

#1710 · Archived threads cannot resume in the original chat

Bug Medium Effort: not set threads open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none

TL;DR

Plain-language framing. A bb thread that runs in its own git worktree owns a "managed environment" (a checkout on disk plus a branch). When you archive the last live thread of such an environment, bb schedules the worktree for deletion. Since PR #1016 (v0.37.0, 2026-08-12) there is a 5-minute grace window during which Unarchive (or the 10-second toast Undo) restores everything losslessly. When the window elapses the host daemon runs git worktree remove --force, the environment row becomes destroyed (a terminal state; the branch itself survives in the main repo, uncommitted changes do not), and the thread page replaces the composer with the banner "Environment archived".

I reproduced the reporter's state end to end on a real instance: archive a worktree thread, wait 5 min + one 10 s sweep tick, and the thread shows Environment archived with no button at all. What is actually wrong is that from that state every path back into the conversation is closed: the in-thread banner deliberately hides Unarchive once the environment is gone; the "..." menu / archived-threads settings still offer Unarchive, and it returns 200, but it only clears archivedAt and the thread becomes a live-but-dead thread (composer hidden, send409 thread_environment_unavailable, CLI bb thread tellHTTP 409); fork is rejected (Source thread must have a ready environment to fork); the "Handoff to new thread" action lives inside the hidden composer; and no route/CLI command can attach a fresh workspace to an existing thread. PR #1016 explicitly deferred a "Continue in new thread" action for destroyed environments and never shipped it. Root cause is therefore a design gap rather than a crash: destroyed is terminal for the thread as well as for the environment, and no re-provisioning path exists although all the ingredients (surviving branch name on the environment row, updateThread({environmentId}), and resolveForkEnvironment which already builds a new worktree from a source branch) are in the codebase.

Claims vs findings

ClaimStatusEvidence
Accidentally archived thread then shows Environment archivedVerifiedReproduced 5 min 6 s after archiving a worktree thread (screenshot 3, poll log). Before v0.37.0 the destroy was immediate (PR #1016 description); after it, only after the grace window.
User cannot continue the original chatVerifiedComposer hidden (shouldHideComposer), POST /threads/:id/send → 409 thread_environment_unavailable, bb thread tellHTTP 409, fork → 400. Transcript 1710-unarchive-after-destroy.out.
"No direct recovery path in that conversation"VerifiedBanner hides the Unarchive action when environmentGoneSection is set (screenshot 3); the "..." menu Unarchive succeeds but produces a thread that is unarchived and still unwritable (screenshots 4-5).
Expected: unarchive/recover and continue without losing contextNot implementedThe route comment says the user "can hand its context and surviving branch off to a new thread instead", but the handoff action is inside the hidden composer and fork is blocked; PR #1016 deferred the destroyed-environment "Continue in new thread" action.
Substantial work is lostPartiallyCommitted work survives on the branch (bb/1710-accidental-archive-thr_3wbj638q94 still at 716a568 work after destroy); uncommitted changes are deleted by git worktree remove --force. Conversation context (events, provider session) is retained in bb's data dir, but is unreachable for continuation.
Recovery inside the grace window worksVerified (control)Archive → unarchive a few seconds later → environment retiringready, follow-up answered (control transcript). The UI gives no hint that a 5-minute deadline exists.

Environment

Minimal reproduction

  1. Build and start your dev instance: pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build && scripts/bb-dev-app current. export BB_REPO=/abs/path/to/bb/worktree.
  2. Create a scratch repo and a project on it:
    mkdir /tmp/1710-qa && cd /tmp/1710-qa && git init -q -b main && echo "# qa" > README.md && git add . && git commit -qm init
    eval "$(scripts/bb-dev-app env)"
    bb machine list                                   # note the host id
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa-1710","source":{"type":"local_path","path":"/tmp/1710-qa","hostId":"<host id>"}}'   # → proj_…
  3. Spawn a thread in a new worktree and let it do some work:
    bb thread spawn --project proj_kwhvshvxma --new-environment worktree --provider codex --permission-mode accept-edits \
      --title "1710 accidental archive" --prompt "Create a file named WORK.md containing the single line 'substantial work' and commit it with message 'work'. Then reply only with ok." --json
    bb thread wait thr_3wbj638q94 --timeout 180        # Thread thr_3wbj638q94 reached status idle.
    bb thread show thr_3wbj638q94 --json | jq '.environment | {id,status,path,branchName}'
    # "env_r4czunz9nt", "ready", ".../worktrees/env_r4czunz9nt/1710-qa", "bb/1710-accidental-archive-thr_3wbj638q94"
    thread before archive
    1. Before: the worktree thread is idle, the composer is available, the banner shows the committed change (Committed · 1 file, +1 -0) and branch bb/1710-accidental-archive-thr_3wbj638q94.
  4. Archive it (this is the "accident"; the "..." menu → Archive in the app does the same thing) and observe the environment enter the grace window:
    $ bb thread archive thr_3wbj638q94
    Thread thr_3wbj638q94 archived
    $ sqlite3 <data dir>/bb.db "select status,retire_requested_at,path from environments where id='env_r4czunz9nt';"
    retiring|1787037567625|/home/sawyer/.bb-dev/.../worktrees/env_r4czunz9nt/1710-qa
    archived thread inside grace window
    2. Inside the 5-minute grace window the banner says Thread is archived and offers Unarchive (lossless, see control below). Nothing tells the user that this offer expires in 5 minutes.
  5. Do nothing for 5 minutes (a poller: 1710-env-status-poll.log). The periodic sweep (every 10 s) destroys the worktree once now - retireRequestedAt ≥ 5 min:
    07:20:15 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:20:30 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:20:45 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:21:00 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:21:15 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:21:30 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:21:45 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:22:01 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:22:16 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:22:31 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:22:46 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:23:01 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:23:16 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:23:31 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:23:46 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:24:01 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:24:16 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:24:31 retiring|/home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-10-fe38788052a6/worktrees/env_r4czunz9nt/1710-qa
    07:24:46 destroyed|NULL
    
    Environment archived banner
    3. The reported state. The banner now says Environment archived, there is no Unarchive button, no composer, no "continue" action. Info panel: "Workspace no longer exists."
  6. Try every recovery path the product exposes (script 1710-unarchive-after-destroy.sh, output .out):
    $ bb thread show thr_3wbj638q94 --json | jq '{archivedAt,status,env,path}'
    {
      "archivedAt": 1787037567622,
      "status": "idle",
      "env": "destroyed",
      "path": null
    }
    $ bb thread unarchive thr_3wbj638q94
    Thread thr_3wbj638q94 unarchived
    exit=0
    $ bb thread show thr_3wbj638q94 --json | jq '{archivedAt,status,env,path}'
    {
      "archivedAt": null,
      "status": "idle",
      "env": "destroyed",
      "path": null
    }
    $ bb thread tell thr_3wbj638q94 'Reply only with ok.'
    Error: HTTP 409: Thread environment is unavailable
    exit=1
    $ curl -X POST $BB_SERVER_URL/api/v1/threads/thr_3wbj638q94/send   (raw server response)
    {"code":"thread_environment_unavailable","message":"Thread environment is unavailable","details":{"reason":"destroyed","environmentStatus":"destroyed"}}
    

    Expected (issue): the user can unarchive/recover the thread and continue the conversation. Actual: unarchive returns success and clears archivedAt, but the thread stays read-only forever; every send returns 409 thread_environment_unavailable. Fork is rejected too:

    $ bb thread fork thr_3wbj638q94 --workspace isolated --prompt "Reply only with ok." --json
    Error: Failed to fork thread thr_3wbj638q94: HTTP 400: Source thread must have a ready environment to fork
    
    actions menu offering Unarchive
    4. The thread's "..." menu (and Settings → Archived threads) still offers Unarchive for the archived thread…
    unarchived thread still shows Environment archived
    5. …and after clicking it the thread is back in the sidebar as a live thread but still shows Environment archived with no composer: a zombie thread. The environment row is terminal (status: destroyed, path: null) while its branchName is still recorded and the branch still exists in /tmp/1710-qa (716a568 work).

Control: recovery inside the grace window is lossless

Script 1710-control-grace-unarchive.sh, output .out. This is what the reporter would have gotten had they noticed within 5 minutes:

thread=thr_754fzrrkut
Thread thr_754fzrrkut reached status idle.
$ bb thread archive thr_754fzrrkut
Thread thr_754fzrrkut archived
env status after archive: retiring
$ bb thread unarchive thr_754fzrrkut   (a few seconds later, inside the grace window)
Thread thr_754fzrrkut unarchived
env status after unarchive: ready
$ bb thread tell thr_754fzrrkut 'Reply only with ok again.'
Thread thr_754fzrrkut steered
exit=0
Thread thr_754fzrrkut reached status idle.

── User ────────────────────────────────────────────────────
Reply only with ok again.

── Assistant ───────────────────────────────────────────────
ok

Unit-level repro (server harness, in-memory SQLite)

File: 1710/repro/issue-1710-archived-thread-dead-end.test.ts (copy to apps/server/test/public/; run cd apps/server && pnpm exec vitest run test/public/issue-1710-archived-thread-dead-end.test.ts). It passes on main: it documents the dead end, walking the exact production code path (archive route → retiring → grace-gated sweep → destroy.started → daemon success → destroyed → unarchive 200 → send 409). If a fix lands, the last assertion (send → 409) is the one that should flip.

import { eq } from "drizzle-orm";
import {
  archiveThread,
  createEnvironment,
  createThread,
  environments,
  getEnvironment,
  getThread,
} from "@bb/db";
import { describe, expect, it } from "vitest";
import { MANAGED_ENVIRONMENT_RETIRE_GRACE_MS } from "../../src/constants.js";
import { settleEnvironmentDestroyCommandResult } from "../../src/services/environments/environment-cleanup-internal.js";
import { runManagedEnvironmentArchiveCleanupRecoverySweep } from "../../src/services/system/periodic-sweeps.js";
import { listQueuedEnvironmentCommands } from "../helpers/commands.js";
import { readJson } from "../helpers/json.js";
import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";

const SWEEP_START_MS = 4_000_000_000_000;

/**
 * Issue #1710: a thread archived by accident becomes unrecoverable once its
 * managed worktree's 5-minute grace window elapses. This test PASSES on main:
 * it documents the dead end, not a crash. After the destroy settles the
 * environment is `destroyed` (terminal), `unarchive` returns 200 but the
 * thread stays read-only (send -> 409 thread_environment_unavailable) and
 * nothing in the API can re-provision a workspace for the thread.
 */
describe("issue #1710: archived thread cannot resume after grace expiry", () => {
  it("unarchive succeeds but the thread is permanently unwritable once its environment was destroyed", async () => {
    await withTestHarness(async (harness) => {
      const { host } = seedHostSession(harness.deps);
      const { project } = seedProjectWithSource(harness.deps, {
        hostId: host.id,
      });
      const environment = createEnvironment(harness.db, harness.hub, {
        hostId: host.id,
        isGitRepo: true,
        managed: true,
        path: "/tmp/issue-1710-worktree",
        projectId: project.id,
        status: "ready",
        workspaceProvisionType: "managed-worktree",
      });
      const thread = createThread(harness.db, harness.hub, {
        projectId: project.id,
        environmentId: environment.id,
        providerId: "codex",
        status: "idle",
      });

      // 1. Archive the only live thread of the managed worktree -> the
      //    environment enters `retiring` (5-minute grace window).
      const archiveResponse = await harness.app.request(
        `/api/v1/threads/${thread.id}/archive`,
        { method: "POST" },
      );
      expect(archiveResponse.status).toBe(200);
      expect(getThread(harness.db, thread.id)?.archivedAt).not.toBeNull();
      expect(getEnvironment(harness.db, environment.id)?.status).toBe(
        "retiring",
      );

      // 2. Inside the grace window unarchive is lossless: retire.cancelled.
      //    (Control: this is what the user gets if they notice within 5 min.)
      await runManagedEnvironmentArchiveCleanupRecoverySweep(
        harness.deps,
        SWEEP_START_MS,
      );
      expect(getEnvironment(harness.db, environment.id)?.status).toBe(
        "retiring",
      );

      // 3. The user does not notice for > 5 minutes. Simulate the elapsed
      //    grace window exactly the way the periodic sweep sees it.
      archiveThread(harness.db, harness.hub, thread.id);
      harness.db
        .update(environments)
        .set({
          retireRequestedAt:
            Date.now() - MANAGED_ENVIRONMENT_RETIRE_GRACE_MS - 1,
        })
        .where(eq(environments.id, environment.id))
        .run();
      await runManagedEnvironmentArchiveCleanupRecoverySweep(
        harness.deps,
        SWEEP_START_MS + 1,
      );
      expect(getEnvironment(harness.db, environment.id)?.status).toBe(
        "destroying",
      );
      const destroyCommands = listQueuedEnvironmentCommands(
        harness,
        "environment.destroy",
        environment.id,
      );
      expect(destroyCommands).toHaveLength(1);

      // 4. The host daemon reports the worktree removed -> `destroyed`
      //    (terminal state, path cleared).
      const destroyAttemptId = getEnvironment(harness.db, environment.id)
        ?.destroyAttemptId;
      expect(destroyAttemptId).toBeTruthy();
      harness.db.transaction((tx) => {
        settleEnvironmentDestroyCommandResult({
          command: {
            type: "environment.destroy",
            environmentId: environment.id,
            workspaceContext: {
              workspacePath: "/tmp/issue-1710-worktree",
              workspaceProvisionType: "managed-worktree",
            },
          },
          deps: { ...harness.deps, db: tx, hub: harness.hub },
          execution: {
            createdAt: Date.now(),
            hostId: host.id,
            id: destroyAttemptId!,
          },
          report: { ok: true, result: {} },
        });
      });
      expect(getEnvironment(harness.db, environment.id)).toMatchObject({
        status: "destroyed",
        path: null,
      });

      // 5. The user finds the thread and clicks Unarchive (sidebar menu /
      //    settings > archived threads; the in-thread banner hides the button).
      const unarchiveResponse = await harness.app.request(
        `/api/v1/threads/${thread.id}/unarchive`,
        { method: "POST" },
      );
      expect(unarchiveResponse.status).toBe(200);
      expect(getThread(harness.db, thread.id)?.archivedAt).toBeNull();
      // The environment is not revived: `retire.cancelled` is illegal from
      // `destroyed`, and nothing re-provisions.
      expect(getEnvironment(harness.db, environment.id)?.status).toBe(
        "destroyed",
      );

      // 6. The thread is now live-but-dead: every attempt to continue the
      //    conversation is rejected, and there is no route that would attach a
      //    fresh workspace. This is the "cannot continue the original chat"
      //    state from the issue.
      const sendResponse = await harness.app.request(
        `/api/v1/threads/${thread.id}/send`,
        {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            mode: "auto",
            input: [{ type: "text", text: "Reply only with ok." }],
          }),
        },
      );
      expect(sendResponse.status).toBe(409);
      expect(await readJson(sendResponse)).toMatchObject({
        code: "thread_environment_unavailable",
        details: { reason: "destroyed", environmentStatus: "destroyed" },
      });
    });
  });
});
$ cd apps/server && pnpm exec vitest run test/public/issue-1710-archived-thread-dead-end.test.ts
 Test Files  1 passed (1)
      Tests  1 passed (1)

Root cause

1. Archiving the last live thread of a managed worktree retires the environment; after the grace window it is destroyed. The archive route computes wouldCleanupEnvironment (managed and zero other live threads, environment-cleanup-internal.ts#L286-L303) and, if true, applies retire.requested (actions.ts#L672-L698). The 10-second periodic sweep (periodic-sweeps.ts#L376-L398) calls advanceEnvironmentCleanup, which waits only while now - retireRequestedAt < managedEnvironmentRetireGraceMs (MANAGED_ENVIRONMENT_RETIRE_GRACE_MS = 5 * 60_000, constants.ts#L15) and a revivable archived thread exists (environment-cleanup-internal.ts#L402-L424), then dispatches environment.destroy. The daemon runs git worktree remove <path> --force (provisioning.ts#L713-L753): the checkout and any uncommitted changes are deleted, the branch survives. On success the row goes to destroyed with path = null; the branch name stays on the row (see env JSON).

{"id":"env_r4czunz9nt","name":null,"projectId":"proj_kwhvshvxma","hostId":"host_58mkech586","path":null,"managed":true,"isGitRepo":true,"isWorktree":true,"branchName":"bb/1710-accidental-archive-thr_3wbj638q94","baseBranch":null,"defaultBranch":"main","mergeBaseBranch":null,"destroyAttemptId":null,"retireRequestedAt":null,"workspaceProvisionType":"managed-worktree","status":"destroyed","createdAt":1787037393448,"updatedAt":1787037873054}

2. destroyed is terminal for the thread too ("Decision B*"). requireThreadCommandEnvironment rejects any work request when the environment is destroying/destroyed with 409 thread_environment_unavailable and never re-provisions (thread-command-environment.ts#L43-L62, lifecycle-api-errors.ts#L74-L98). The unarchive route is a pure record op: it emits retire.cancelled only if the environment is still retiring; from destroyed it does nothing and its own comment says the thread "remains read-only" (actions.ts#L717-L745). Fork requires a ready environment with a path (thread-fork.ts#L48-L63). No route or CLI command can attach a new environment to an existing thread (updateThreadRequestSchema has no environmentId; the bb environment command set has no restore/reprovision).

3. The UI removes every affordance in that state. ThreadDetailView derives threadEnvironmentGoneStatus from the environment status (ThreadDetailView.tsx#L2489-L2495); ThreadDetailPromptArea hides the composer (and with it the footer action "Handoff to new thread", ThreadDetailPromptArea.tsx#L628-L629, ThreadDetailPromptArea.tsx#L1078-L1081); and the banner renders the read-only row with the Unarchive action only when the environment is not gone (ThreadPromptContextBanner.tsx#L907-L931):

statusAction={
  archivedSection?.onUnarchive && !environmentGone ? (
    <ThreadUnarchiveTextAction … />
  ) : null
}

Hiding the button is deliberate (unarchive would only make a zombie), but nothing replaces it. PR #1016 states: "There is no destroyed-environment Continue in new thread action in this PR; that workflow will be handled separately." It was not handled: nothing on main or origin/main adds it. Meanwhile the "..." menu and Settings → Archived threads still expose Unarchive, which yields the live-but-dead thread in screenshot 5.

Why the symptom follows. The reporter archived, did not notice within 5 minutes (or was on ≤ v0.36.0 where destroy was immediate), the worktree was removed, and every remaining control path is either hidden or rejects with 409/400. Conversation history and the provider session are still stored, and the branch still exists, so the context is recoverable in principle; the product just has no operation that does it.

Deeper issues. (a) The 5-minute deadline is invisible: the "Thread is archived" banner and the archived-threads list do not show that the worktree will be deleted, and the archive toast disappears after 10 s. (b) The wording Environment archived is misleading — nothing was archived, the workspace was deleted. (c) Unarchive on a destroyed-environment thread should either be refused with a clear message or perform a recovery; today it silently produces an unusable live thread. (d) The Handoff to new thread location state carries reuseEnvironmentId even for a destroyed environment (thread-handoff-request.ts#L20-L30); if that action were simply re-exposed it would try to reuse a destroyed environment.

Proposed fix (first principles)

Two layers; both are needed for "continue in the original chat".

  1. Server: a re-provision path for a thread whose managed environment is destroyed. Add POST /threads/:id/restore-environment (and call it from the unarchive route when the environment is destroyed, or keep unarchive pure and let the UI call it explicitly). Preconditions: thread not deleted, environment managed + workspaceProvisionType = managed-worktree + status = destroyed. Behavior: create a new managed-worktree environment on the same host with baseBranch = {kind:"named", name: oldEnvironment.branchName} when the branch still exists (else default base) — exactly what resolveForkEnvironment already computes (thread-fork.ts#L66-L94) — then updateThread(tx, hub, threadId, {environmentId: newEnvironment.id}) (supported by the DB layer, emits environment-changed) and let the normal thread provisioning create the worktree. Leave the old row destroyed. Provider resume: the stored provider session/rollout is cwd-independent for codex; verify dispatchThreadUnarchiveCommand/session resume tolerate a changed workspace path, and fall back to a fresh session seeded from the thread's history (as fork does) if resume fails. Bump HOST_DAEMON_PROTOCOL_VERSION only if any daemon command shape changes (none should).
  2. App/CLI/SDK: in the environment-gone banner replace the empty right slot with Restore workspace from branch <branch> (calls the new route) and Continue in new thread (existing handoff, but with reuseEnvironmentId omitted when the environment is gone). Show the same in the "..." menu instead of a bare Unarchive for destroyed-environment threads. Add bb thread restore-environment <id> / sdk.threads.restoreEnvironment. Also show the deadline in the archived banner ("worktree is deleted in 4:32") and rename Environment archived to Workspace deleted.
  3. Cheap intermediate step if the full restore is too big: relax requireSourceEnvironment so fork is allowed from a destroyed environment when branchName is set (isolated mode only; reuse must stay rejected), and expose "Continue in new thread (fork)" in the gone banner. That already gives context + branch continuity in a new thread.

What could go wrong: the branch may have been deleted or merged (fall back to default base and say so); the project's source repo may be gone (fail with the existing provisioning error); uncommitted work is unrecoverable no matter what — the restore UI must say "committed work on branch X is restored; uncommitted changes were lost"; plugins that keyed on the old environmentId see a new id (the environment-changed notification exists for this); the destroyed row is pruned after 7 days, so branchName should be captured before pruning or the thread should store it.

Related issues

Appendix

Timing

retire_requested_at = 1787037567625 (07:19:27.625Z) → destroy.completed at updated_at = 1787037873054 (07:24:33.054Z): 305.4 s = 5 min grace + one 10 s sweep tick. Server/daemon logs contain no line about the retire/destroy (note).

Artifacts

Commands run (chronological)

git checkout 16ceb3a54 && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
scripts/bb-dev-app current
mkdir /tmp/1710-qa; git init -b main; commit README.md
bb machine list                                            # host_58mkech586
curl -X POST $BB_SERVER_URL/api/v1/projects … /tmp/1710-qa # proj_kwhvshvxma
bb thread spawn --project proj_kwhvshvxma --new-environment worktree --provider codex --permission-mode accept-edits --title "1710 accidental archive" --prompt "…commit WORK.md…" --json   # thr_3wbj638q94 / env_r4czunz9nt
bb thread wait thr_3wbj638q94 --timeout 180
dev-browser (headless) screenshot → assets/1710-before.png
bb thread archive thr_3wbj638q94                          # 07:19:27Z
sqlite3 bb.db "select status,retire_requested_at,path from environments where id='env_r4czunz9nt'"   # retiring
dev-browser screenshot → assets/1710-archived-grace.png
poll sqlite every 15 s → 1710-env-status-poll.log         # destroyed at 07:24:33Z
dev-browser screenshot → assets/1710-env-archived-no-recovery.png
git -C /tmp/1710-qa branch -a; git log bb/1710-accidental-archive-thr_3wbj638q94   # branch survives, 716a568 work
1710-unarchive-after-destroy.sh thr_3wbj638q94            # unarchive 200, tell 409, send 409
bb thread fork thr_3wbj638q94 --workspace isolated …      # 400
bb thread archive thr_3wbj638q94; dev-browser screenshot of "..." menu → assets/1710-actions-menu-unarchive.png
dev-browser screenshot after unarchive → assets/1710-unarchived-still-dead.png
1710-control-grace-unarchive.sh proj_kwhvshvxma           # thr_754fzrrkut, lossless
cd apps/server && pnpm exec vitest run test/public/issue-1710-archived-thread-dead-end.test.ts
pnpm dev:stop