Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,54 @@ describe("ThreadPromptContextBanner", () => {
},
);

it("renders an enabled Restore action once the environment is destroyed", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroyed",
onRestore: noop,
restorePending: false,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Restore environment");
expect(markup).toContain("<button");
expect(markup).not.toContain('disabled=""');
});

it("shows a disabled cleaning-up Restore action while the environment is destroying", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
gitSection={null}
gitSectionPending={false}
archivedSection={null}
environmentGoneSection={{
status: "destroying",
onRestore: noop,
restorePending: false,
}}
parentThreadSection={null}
childThreadsSection={null}
pullRequestSection={null}
expandedSection={null}
onToggleSection={noop}
/>,
);

expect(markup).toContain("Cleaning up...");
expect(markup).toContain('disabled=""');
expect(markup).not.toContain("Restore environment");
});

it("labels a standalone pull request without non-actionable attention text", () => {
const markup = renderToStaticMarkup(
<ThreadPromptContextBanner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export interface ThreadPromptArchivedSection {
*/
export interface ThreadPromptEnvironmentGoneSection {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
/**
* Restores the thread's environment by reprovisioning a fresh workspace on the
* thread's branch (recovering committed work). Enabled once the old workspace
* is fully gone (`destroyed`); while `destroying` the action shows a disabled
* "Cleaning up…" state. Omitted when restore isn't available (e.g. unmanaged
* environments).
*/
onRestore?: () => void;
restorePending?: boolean;
}

/**
Expand Down Expand Up @@ -526,6 +535,33 @@ function PullRequestReadyTextAction({
);
}

function EnvironmentRestoreTextAction({
status,
isPending,
onRestore,
}: {
status: Extract<EnvironmentStatus, "destroying" | "destroyed">;
isPending?: boolean;
onRestore: () => void;
}) {
const cleaningUp = status === "destroying";
const label = cleaningUp
? "Cleaning up..."
: isPending
? "Restoring..."
: "Restore environment";
return (
<button
type="button"
onClick={onRestore}
disabled={cleaningUp || Boolean(isPending)}
className="rounded px-1 py-0.5 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
>
{label}
</button>
);
}

const PULL_REQUEST_MERGE_ACTIONS: readonly {
method: PullRequestMergeMethod;
label: string;
Expand Down Expand Up @@ -882,6 +918,12 @@ export function ThreadPromptContextBanner({
isPending={archivedSection.unarchivePending}
onUnarchive={archivedSection.onUnarchive}
/>
) : environmentGoneSection?.onRestore ? (
<EnvironmentRestoreTextAction
status={environmentGoneSection.status}
isPending={environmentGoneSection.restorePending}
onRestore={environmentGoneSection.onRestore}
/>
) : null
}
parentThreadSection={parentThreadSection}
Expand Down
46 changes: 46 additions & 0 deletions apps/app/src/hooks/mutations/thread-state-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
UpdateThreadRequest,
} from "@bb/server-contract";
import { sdk } from "@/lib/sdk";
import { appToast } from "@/components/ui/app-toast";
import type { LifecycleErrorOperation } from "@/lib/lifecycle-errors";
import {
applyReorderPinnedThreadResult,
Expand Down Expand Up @@ -38,6 +39,14 @@ interface ThreadMutationRequest {
id: string;
}

/**
* How long the "Thread archived — Undo" toast stays up. Matches the server's
* archive grace window (`MANAGED_ENVIRONMENT_RETIRE_GRACE_MS`), within which an
* Undo revives the environment losslessly (its worktree has not been destroyed
* yet). After it elapses the durable Unarchive in the read-only banner remains.
*/
const ARCHIVE_UNDO_TOAST_DURATION_MS = 10_000;

type UpdateThreadMutationRequest = ThreadMutationRequest & UpdateThreadRequest;
type ReorderPinnedThreadMutationRequest = ThreadMutationRequest &
ReorderPinnedThreadRequest;
Expand Down Expand Up @@ -263,6 +272,29 @@ export function useArchiveThreadAndChildren() {
onError: (_error, _variables, context) => {
rollbackArchiveThreadsTransaction({ queryClient, transaction: context });
},
onSuccess: (data) => {
// Offer a quick, lossless Undo while the environment is still inside its
// grace window: un-archiving revives a retiring environment in place
// (retire.cancelled), so the worktree and uncommitted work are preserved.
const archivedThreadIds = data.archivedThreadIds;
appToast.message("Thread archived", {
action: {
label: "Undo",
onClick: () => {
void Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A partial Undo failure leaves restored threads absent from the sidebar.

Promise.all rejects when one unarchive request fails. The .then block then settles no cache entry, even when other requests succeeded. The successful path also repeats broad list invalidation once per thread. Use Promise.allSettled, report failures, settle each detail entry, and invalidate the list keys once.

archivedThreadIds.map((threadId) =>
sdk.threads.unarchive({ threadId }),
),
).then(() => {
for (const threadId of archivedThreadIds) {
settleThreadListMembershipMutation({ queryClient, threadId });
}
});
},
},
duration: ARCHIVE_UNDO_TOAST_DURATION_MS,
});
},
onSettled: (data, _error, _variables, context) => {
settleArchiveThreadsTransaction({
queryClient,
Expand Down Expand Up @@ -301,6 +333,20 @@ export function useUnarchiveThread() {
});
}

export function useRestoreThreadEnvironment() {
return useMutation({
meta: {
errorMessage: "Failed to restore environment.",
},
mutationFn: async ({ id }: ThreadMutationRequest) => {
await sdk.threads.restoreEnvironment({ threadId: id });
},
// The server reprovisions a fresh environment and re-seeds the thread; the
// resulting thread/environment changes arrive over the realtime channel, so
// no optimistic cache mutation is needed here.
});
}

export function useDeleteThread() {
const queryClient = useQueryClient();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,11 @@ vi.mock("@/hooks/mutations/thread-state-mutations", () => ({
mutate: mocks.unarchiveThreadMutate,
variables: null,
}),
useRestoreThreadEnvironment: () => ({
isPending: false,
mutate: vi.fn(),
variables: null,
}),
}));

vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
Expand Down
20 changes: 18 additions & 2 deletions apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ import {
useClearThreadGoal,
useStopThread,
} from "@/hooks/mutations/thread-runtime-mutations";
import { useUnarchiveThread } from "@/hooks/mutations/thread-state-mutations";
import {
useRestoreThreadEnvironment,
useUnarchiveThread,
} from "@/hooks/mutations/thread-state-mutations";
import {
getLatestPendingInteraction,
useThreadQueuedMessages,
Expand Down Expand Up @@ -287,6 +290,7 @@ export function ThreadDetailPromptArea({
const cancelThreadPlan = useCancelThreadPlan();
const clearThreadGoal = useClearThreadGoal();
const unarchiveThread = useUnarchiveThread();
const restoreEnvironment = useRestoreThreadEnvironment();
// The personal project isn't a meaningful label in the footer, so skip it.
const projectName = useProjectDisplayName(
thread.projectId === PERSONAL_PROJECT_ID ? undefined : thread.projectId,
Expand Down Expand Up @@ -813,6 +817,12 @@ export function ThreadDetailPromptArea({
const handleUnarchiveCurrentThread = useCallback(() => {
unarchiveThread.mutate({ id: thread.id });
}, [thread.id, unarchiveThread]);
const isRestoreEnvironmentPending =
restoreEnvironment.isPending &&
restoreEnvironment.variables?.id === thread.id;
const handleRestoreEnvironment = useCallback(() => {
restoreEnvironment.mutate({ id: thread.id });
}, [thread.id, restoreEnvironment]);
const sourceThreadDisplayTitle = getThreadDisplayTitle({
id: thread.id,
title: thread.title,
Expand Down Expand Up @@ -1212,7 +1222,11 @@ export function ThreadDetailPromptArea({
environmentGoneSection={
environmentGoneStatus === null
? null
: { status: environmentGoneStatus }
: {
status: environmentGoneStatus,
onRestore: handleRestoreEnvironment,
restorePending: isRestoreEnvironmentPending,
}
}
parentThreadSection={parentThreadSection}
childThreadsSection={childThreadsSection}
Expand Down Expand Up @@ -1276,9 +1290,11 @@ export function ThreadDetailPromptArea({
handleSetQueuedMessageGroupBoundary,
handleToggleBannerSection,
handleUnarchiveCurrentThread,
handleRestoreEnvironment,
environmentGoneStatus,
isFollowUpSubmitting,
isUnarchiveCurrentThreadPending,
isRestoreEnvironmentPending,
isQueueMutationPending,
inlineEditor,
activeGoalCard,
Expand Down
32 changes: 32 additions & 0 deletions apps/cli/src/__tests__/command-output/thread-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ describe("bb thread action command output", () => {
);
});

it("bb thread restore-environment sends the thread id from args", async () => {
const restorePost = vi.fn(async () => ({ ok: true }));
stubServerApi({ "v1.threads.:id.restore-environment.$post": restorePost });

await runCommand(
["thread", "restore-environment", "thread-restore-1"],
register,
);

expect(restorePost).toHaveBeenCalledWith({
param: { id: "thread-restore-1" },
});
expect(collectLogLines(vi.mocked(console.log))).toContain(
"Environment restore started for thread thread-restore-1",
);
});

it("bb thread restore-environment prefixes failures with thread context", async () => {
const restorePost = vi.fn(async () => {
throw new Error("HTTP 409: environment is still being torn down");
});
stubServerApi({ "v1.threads.:id.restore-environment.$post": restorePost });

await expect(
runCommand(["thread", "restore-environment", "thread-restore-1"], register),
).rejects.toThrow("process.exit:1");

expect(collectLogLines(vi.mocked(console.error))).toContain(
"Error: Failed to restore environment for thread thread-restore-1: HTTP 409: environment is still being torn down",
);
});

it("bb thread pin sends the thread id from args", async () => {
const pinnedThread = fixtures.makeThread({
id: "thread-pin-1",
Expand Down
34 changes: 34 additions & 0 deletions apps/cli/src/commands/thread/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ interface ThreadUnarchiveCommandOptions {
json?: boolean;
}

interface ThreadRestoreEnvironmentCommandOptions {
self?: boolean;
json?: boolean;
}

interface ThreadPinCommandOptions {
self?: boolean;
json?: boolean;
Expand Down Expand Up @@ -287,6 +292,35 @@ export function registerActionsCommands(
),
);

parent
.command("restore-environment [id]")
.description(
"Reprovision a fresh environment for a thread whose environment is gone",
)
.option("--self", "Target the current thread (from BB_THREAD_ID)")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
id: string | undefined,
opts: ThreadRestoreEnvironmentCommandOptions,
) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
try {
await sdk.threads.restoreEnvironment({ threadId });
} catch (err: unknown) {
throw prependErrorContext(
`Failed to restore environment for thread ${threadId}`,
err,
);
}
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Environment restore started for thread ${threadId}`);
},
),
);

parent
.command("pin [id]")
.description("Pin a thread")
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ export const HEARTBEAT_INTERVAL_MS = 5_000;
export const LEASE_TIMEOUT_MS = 30_000;
export const DAEMON_DISCONNECT_GRACE_MS = 5_000;
export const DAEMON_ACTIVE_WORK_DISCONNECT_GRACE_MS = LEASE_TIMEOUT_MS;
/**
* Grace window after the last live thread in a managed environment is archived
* before its worktree is destroyed. The environment stays `retiring` (revivable
* via unarchive → `retire.cancelled`, worktree intact) for this long so an
* accidental archive can be undone losslessly. Surfaced as the archive toast's
* "Undo" duration. The destroy is gated on the environment's `updatedAt` (the
* retire-requested time), so the window is durable across restart.
*/
export const MANAGED_ENVIRONMENT_RETIRE_GRACE_MS = 10_000;
export const WORKSPACE_DIFF_MAX_DIFF_BYTES = 2 * 1024 * 1024;
export const WORKSPACE_DIFF_MAX_FILE_LIST_BYTES = 256 * 1024;

Expand Down
Loading
Loading