fix: keep archived worktrees optimistically hidden (#640)

This commit is contained in:
Mohamed Boudra
2026-04-30 21:36:41 +08:00
committed by GitHub
parent 92afb826d9
commit 93c6c3e5f8
32 changed files with 712 additions and 112 deletions
@@ -83,6 +83,7 @@ function workspace(input: {
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -1462,7 +1462,7 @@ function WorkspaceRowWithMenu({
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -31,6 +31,7 @@ function workspaceDescriptor(input: {
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
@@ -18,6 +18,7 @@ function workspace(input: {
workspaceKind: "checkout",
name: "main",
status: "running",
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -41,6 +41,7 @@ import {
normalizeWorkspaceDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -58,6 +59,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -1232,6 +1234,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
if (
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId,
workspace,
isArchivePending: isLocalWorktreeArchivePending,
})
) {
return;
}
mergeWorkspaces(serverId, [workspace]);
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
const baseWorkspace: WorkspaceDescriptor = {
id: "/repo/worktree",
projectId: "/repo",
projectDisplayName: "Repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "done",
archivingAt: "2026-04-30T00:00:00.000Z",
diffStat: null,
scripts: [],
};
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return { ...baseWorkspace, ...input };
}
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
it("suppresses archiving upserts for a locally pending archive", () => {
const isArchivePending = vi.fn(() => true);
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
isArchivePending,
}),
).toBe(true);
expect(isArchivePending).toHaveBeenCalledWith({
serverId: "server-1",
cwd: "/repo/worktree",
});
});
it("allows archiving upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace(),
isArchivePending: () => false,
}),
).toBe(false);
});
it("allows normal upserts while a local archive is pending", () => {
expect(
shouldSuppressWorkspaceUpsertForLocalArchive({
serverId: "server-1",
workspace: workspace({ archivingAt: null }),
isArchivePending: () => true,
}),
).toBe(false);
});
});
@@ -0,0 +1,15 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
serverId: string;
workspace: WorkspaceDescriptor;
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
}): boolean {
return (
input.workspace.archivingAt !== null &&
input.isArchivePending({
serverId: input.serverId,
cwd: input.workspace.workspaceDirectory,
})
);
}
@@ -78,6 +78,7 @@ describe("openProjectDirectly", () => {
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
archivingAt: null,
status: "done" as const,
activityAt: null,
diffStat: null,
@@ -87,6 +87,7 @@ function workspace(input: {
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
@@ -57,6 +57,7 @@ function workspaceDescriptor(id: string): WorkspaceDescriptor {
workspaceKind: "worktree",
name: id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
@@ -28,6 +28,7 @@ export interface SidebarWorkspaceEntry {
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
statusBucket: SidebarStateBucket;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptor["scripts"];
hasRunningScripts: boolean;
@@ -65,6 +66,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKind: "checkout",
name: input.workspaceId,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
@@ -86,6 +88,7 @@ export function createSidebarWorkspaceEntry(input: {
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
@@ -26,6 +26,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
diffStat: null,
scripts: [],
...input,
archivingAt: input.archivingAt ?? null,
};
}
@@ -7,6 +7,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
@@ -39,6 +40,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
@@ -207,6 +209,7 @@ describe("checkout-git-actions-store", () => {
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
]);
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
@@ -233,4 +236,25 @@ describe("checkout-git-actions-store", () => {
listSnapshot,
);
});
it("reports local archive pending only while the archive action is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
});
});
@@ -191,7 +191,6 @@ function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath:
function restoreWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
snapshot: WorktreeArchiveSnapshot;
}): void {
if (input.snapshot.workspace) {
@@ -223,6 +222,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
return `${key}::${actionId}`;
}
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
return (
useCheckoutGitActionsStore.getState().getStatus({
serverId: input.serverId,
cwd: input.cwd,
actionId: "archive-worktree",
}) === "pending"
);
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
@@ -436,7 +445,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
throw new Error(payload.error.message);
}
} catch (error) {
restoreWorktreeArchiveState({ serverId, worktreePath, snapshot });
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
@@ -34,6 +34,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -23,6 +23,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -73,6 +74,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "running",
activityAt: "not-a-date",
diffStat: null,
@@ -105,6 +107,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
@@ -116,6 +119,27 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.scripts).toEqual([]);
});
it("defaults missing archivingAt to null", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
} as unknown as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.archivingAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
@@ -126,6 +150,7 @@ describe("normalizeWorkspaceDescriptor", () => {
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
+2
View File
@@ -117,6 +117,7 @@ export interface WorkspaceDescriptor {
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
status: WorkspaceDescriptorPayload["status"];
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
@@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor(
workspaceKind: payload.workspaceKind,
name: payload.name,
status: payload.status,
archivingAt: payload.archivingAt ?? null,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
gitRuntime: payload.gitRuntime,
+1
View File
@@ -43,6 +43,7 @@ function workspace(input: {
workspaceKind: "local_checkout",
name: input.id,
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
@@ -23,6 +23,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
scripts: [],
hasRunningScripts: false,
...overrides,
archivingAt: overrides.archivingAt ?? null,
};
}
@@ -23,6 +23,7 @@ function workspace(input: {
workspaceKind: "checkout",
name: input.name,
statusBucket: "done",
archivingAt: null,
diffStat: null,
scripts: [],
hasRunningScripts: false,
@@ -32,6 +32,7 @@ function workspace(
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -21,6 +21,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name ?? "main",
status: input.status ?? "running",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -743,7 +743,9 @@ describe("create_agent MCP tool", () => {
getSnapshot: vi.fn(async () => null),
};
const archiveWorkspaceRecord = vi.fn(async () => undefined);
const emitWorkspaceUpdatesForCwds = vi.fn(async () => undefined);
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async () => undefined);
const markWorkspaceArchiving = vi.fn();
const clearWorkspaceArchiving = vi.fn();
const emitSessionMessage = vi.fn();
const server = await createAgentMcpServer({
agentManager,
@@ -755,7 +757,9 @@ describe("create_agent MCP tool", () => {
"getSnapshot" | "listWorktrees"
>,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForCwds,
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving,
clearWorkspaceArchiving,
emitSessionMessage,
github: createGitHubServiceStub(),
logger,
@@ -779,7 +783,14 @@ describe("create_agent MCP tool", () => {
reason: "archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
[created.structuredContent.worktreePath],
expect.any(String),
);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith([
created.structuredContent.worktreePath,
]);
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath,
]);
} finally {
+13 -3
View File
@@ -79,7 +79,9 @@ export interface AgentMcpServerOptions {
github?: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
createPaseoWorktree?: CreatePaseoWorktreeFn;
paseoHome?: string;
@@ -1850,9 +1852,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForCwds) {
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
if (!options.markWorkspaceArchiving) {
throw new Error("Workspace archiving marker is required to archive worktrees");
}
if (!options.clearWorkspaceArchiving) {
throw new Error("Workspace archiving clearer is required to archive worktrees");
}
if (!options.emitSessionMessage) {
throw new Error("Session message emitter is required to archive worktrees");
}
@@ -1870,7 +1878,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentStorage,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emit: options.emitSessionMessage,
emitWorkspaceUpdatesForCwds: options.emitWorkspaceUpdatesForCwds,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
isPathWithinRoot: isSameOrDescendantPath,
killTerminalsUnderPath: (rootPath) =>
killTerminalsUnderPath(
+21 -4
View File
@@ -519,11 +519,26 @@ export async function createPaseoDaemon(
projectRegistry,
});
};
const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable<string>) => {
const cwdList = Array.from(cwds);
const markWorkspaceArchivingForMcpArchive = (
workspaceIds: Iterable<string>,
archivingAt: string,
) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingForMcpArchive = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesForMcpArchive = async (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
await Promise.all(
(wsServer?.listActiveSessions() ?? []).map((session) =>
session.emitWorkspaceUpdatesForExternalCwds(cwdList),
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
),
);
};
@@ -542,7 +557,9 @@ export async function createPaseoDaemon(
github,
workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesForMcpArchive,
markWorkspaceArchiving: markWorkspaceArchivingForMcpArchive,
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
emitSessionMessage: emitMcpArchiveSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
@@ -19,7 +19,9 @@ export interface ArchivePaseoWorktreeDependencies {
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
sessionLogger?: Logger;
@@ -86,87 +88,95 @@ export async function archivePaseoWorktree(
...matchingStoredRecords.map((record) => record.id),
]);
const teardownResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
dependencies.killTerminalsUnderPath(targetPath),
]);
const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds);
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
for (const result of teardownResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, targetPath },
"Worktree teardown step failed during archive; continuing",
);
}
}
try {
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
const storageRemovalResults = await Promise.allSettled(
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
);
storageRemovalResults.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
dependencies.sessionLogger?.warn(
{
err: result.reason,
agentId: agentIdsToRemove[index],
targetPath,
},
"Failed to remove archived worktree agent from storage; continuing",
);
});
const teardownResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
dependencies.killTerminalsUnderPath(targetPath),
]);
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
});
if (options.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: options.repoRoot },
"Failed to force-refresh workspace git snapshot after archiving worktree",
);
}
}
for (const cwd of affectedWorkspaceCwds) {
dependencies.github.invalidate({ cwd });
}
await Promise.all(
Array.from(affectedWorkspaceIds).map(async (workspaceId) => {
try {
await dependencies.archiveWorkspaceRecord(workspaceId);
} catch (error) {
for (const result of teardownResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
{ err: result.reason, targetPath },
"Worktree teardown step failed during archive; continuing",
);
}
}),
);
}
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
const storageRemovalResults = await Promise.allSettled(
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
);
storageRemovalResults.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
dependencies.sessionLogger?.warn(
{
err: result.reason,
agentId: agentIdsToRemove[index],
targetPath,
},
"Failed to remove archived worktree agent from storage; continuing",
);
});
}
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome,
});
if (options.repoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: options.repoRoot },
"Failed to force-refresh workspace git snapshot after archiving worktree",
);
}
}
for (const cwd of affectedWorkspaceCwds) {
dependencies.github.invalidate({ cwd });
}
await Promise.all(
affectedWorkspaceIdList.map(async (workspaceId) => {
try {
await dependencies.archiveWorkspaceRecord(workspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
);
}
}),
);
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
} finally {
dependencies.clearWorkspaceArchiving(affectedWorkspaceIdList);
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIdList);
}
return Array.from(removedAgents);
}
+39 -19
View File
@@ -939,6 +939,7 @@ export class Session {
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly archivingByWorkspaceId = new Map<string, string>();
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
@@ -1061,8 +1062,19 @@ export class Session {
await this.archiveWorkspaceRecord(workspaceId);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await this.emitWorkspaceUpdatesForCwds(cwds);
markWorkspaceArchivingForExternalMutation(
workspaceIds: Iterable<string>,
archivingAt: string,
): void {
this.markWorkspaceArchiving(workspaceIds, archivingAt);
}
clearWorkspaceArchivingForExternalMutation(workspaceIds: Iterable<string>): void {
this.clearWorkspaceArchiving(workspaceIds);
}
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
@@ -5468,14 +5480,13 @@ export class Session {
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
archiveWorkspaceRecord: async (workspaceDirectory) => {
const workspace = await this.findWorkspaceByDirectory(workspaceDirectory);
if (workspace) {
await this.archiveWorkspaceRecord(workspace.workspaceId);
}
},
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
isPathWithinRoot: (rootPath, candidatePath) =>
this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath),
@@ -6171,6 +6182,7 @@ export class Session {
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
workspaceKind: workspace.kind,
name: workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat,
@@ -6258,6 +6270,7 @@ export class Session {
projectKind: "git",
workspaceKind: result.workspace.kind,
name: result.worktree.branchName || result.workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: { additions: 0, deletions: 0 },
@@ -6286,6 +6299,18 @@ export class Session {
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
}
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void {
for (const workspaceId of workspaceIds) {
this.archivingByWorkspaceId.set(workspaceId, archivingAt);
}
}
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void {
for (const workspaceId of workspaceIds) {
this.archivingByWorkspaceId.delete(workspaceId);
}
}
private async buildWorkspaceDescriptorMap(options: {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
@@ -6329,7 +6354,11 @@ export class Session {
),
);
for (let i = 0; i < includedWorkspaces.length; i += 1) {
descriptorsByWorkspaceId.set(includedWorkspaces[i]!.workspaceId, workspaceDescriptors[i]!);
const workspaceId = includedWorkspaces[i]!.workspaceId;
descriptorsByWorkspaceId.set(workspaceId, {
...workspaceDescriptors[i]!,
archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null,
});
}
for (const agent of agents) {
@@ -7019,15 +7048,6 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const uniqueWorkspaceIds = new Set<string>();
for (const cwd of cwds) {
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces));
}
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
}
private async handleFetchAgents(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
): Promise<void> {
@@ -58,7 +58,10 @@ interface SessionTestAccess {
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<unknown>;
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void;
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
emit(message: unknown): void;
onMessage(message: unknown): void;
getAvailableEditorTargets(...args: unknown[]): Promise<unknown>;
@@ -3343,6 +3346,7 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
projectKind: project.kind,
workspaceKind: workspace.kind,
name: "main",
archivingAt: null,
status: "done",
activityAt: null,
diffStat: null,
@@ -3368,6 +3372,105 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
expect(descriptors).toEqual([baselineDescriptor]);
});
test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => {
const session = createSessionForWorkspaceTests();
const archivingAt = "2026-04-30T20:45:00.000Z";
const project = createPersistedProjectRecord({
projectId: "proj-archiving-map",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archiving-map",
projectId: project.projectId,
cwd: "/tmp/repo/worktree",
kind: "worktree",
displayName: "feature",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
const readArchivingAt = async () =>
(
await session.buildWorkspaceDescriptorMap({
includeGitData: false,
})
).get(workspace.workspaceId)?.archivingAt;
await expect(readArchivingAt()).resolves.toBeNull();
session.markWorkspaceArchiving([workspace.workspaceId], archivingAt);
await expect(readArchivingAt()).resolves.toBe(archivingAt);
session.clearWorkspaceArchiving([workspace.workspaceId]);
await expect(readArchivingAt()).resolves.toBeNull();
});
test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests();
const archivingAt = "2026-04-30T20:45:00.000Z";
const project = createPersistedProjectRecord({
projectId: "proj-archiving-emit",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archiving-emit",
projectId: project.projectId,
cwd: "/tmp/repo/worktree",
kind: "worktree",
displayName: "feature",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-archiving",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
session.markWorkspaceArchiving([workspace.workspaceId], archivingAt);
await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], {
skipReconcile: true,
});
await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], {
skipReconcile: true,
});
expect(emitted).toEqual([
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: expect.objectContaining({
id: workspace.workspaceId,
archivingAt,
}),
},
},
]);
});
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
@@ -354,6 +354,14 @@ function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
};
}
function createWorkspaceArchivingDeps() {
return {
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
};
}
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
const repoDir = path.join(tempDir, "repo");
@@ -1548,7 +1556,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath,
sessionLogger: createLogger(),
@@ -1573,6 +1581,235 @@ describe("archivePaseoWorktree", () => {
expect(maxEnd - minStart).toBeLessThan(220);
});
test("emits archiving upserts during worktree archive request until final remove", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-marked-during-close",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-marked-during-close",
runSetup: false,
paseoHome,
});
const affectedIds = [created.worktreePath];
const liveAgent = createManagedAgentForArchive({
id: "agent-1",
cwd: created.worktreePath,
});
const workspaceRecord: PersistedWorkspaceRecord = {
workspaceId: created.worktreePath,
projectId: repoDir,
cwd: created.worktreePath,
kind: "worktree",
displayName: "archive-marked-during-close",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
};
const emitted: SessionOutboundMessage[] = [];
const events: string[] = [];
const archivedWorkspaceIds = new Set<string>();
const archivingByWorkspaceId = new Map<string, string>();
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(workspaceId)) {
emitted.push({
type: "workspace_update",
payload: {
kind: "remove",
id: workspaceId,
},
});
continue;
}
emitted.push({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }),
archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null,
},
},
});
}
events.push(`emit:${Array.from(workspaceIds).join(",")}`);
});
const closeAgent = vi.fn(async () => {
events.push("close:start");
await emitWorkspaceUpdatesForWorkspaceIds(affectedIds);
events.push("close:end");
});
await handlePaseoWorktreeArchiveRequest(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
},
agentManager: {
listAgents: () => [liveAgent],
closeAgent,
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async (workspaceId: string) => {
archivedWorkspaceIds.add(workspaceId);
}),
emit: (message) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
events.push(`mark:${Array.from(workspaceIds).join(",")}`);
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.set(workspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
events.push(`clear:${Array.from(workspaceIds).join(",")}`);
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.delete(workspaceId);
}
},
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
type: "paseo_worktree_archive_request",
requestId: "req-archive-marked-during-close",
worktreePath: created.worktreePath,
repoRoot: repoDir,
},
);
const workspaceUpdates = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_update" }> =>
message.type === "workspace_update",
);
expect(events.slice(0, 3)).toEqual([
`mark:${created.worktreePath}`,
`emit:${created.worktreePath}`,
"close:start",
]);
expect(workspaceUpdates[0]?.payload).toEqual({
kind: "upsert",
workspace: expect.objectContaining({
id: created.worktreePath,
archivingAt: expect.any(String),
}),
});
const archivingAt =
workspaceUpdates[0]?.payload.kind === "upsert"
? workspaceUpdates[0].payload.workspace.archivingAt
: null;
expect(workspaceUpdates[1]?.payload).toEqual({
kind: "upsert",
workspace: expect.objectContaining({
id: created.worktreePath,
archivingAt,
}),
});
expect(workspaceUpdates.at(-1)?.payload).toEqual({
kind: "remove",
id: created.worktreePath,
});
expect(events.slice(-2)).toEqual([
`clear:${created.worktreePath}`,
`emit:${created.worktreePath}`,
]);
expect(
emitted.find((message) => message.type === "paseo_worktree_archive_response"),
).toMatchObject({
payload: {
success: true,
error: null,
},
});
});
test("clears archiving state and leaves workspace records active when worktree delete fails", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createLegacyWorktreeForTest({
branchName: "archive-delete-fails",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-delete-fails",
runSetup: false,
paseoHome,
});
const archivingByWorkspaceId = new Map<string, string>();
const emittedUpdates: Array<{
kind: "upsert";
workspaceId: string;
archivingAt: string | null;
}> = [];
const archiveWorkspaceRecord = vi.fn(async () => {});
await expect(
archivePaseoWorktree(
{
paseoHome,
github: createGitHubServiceStub(),
workspaceGitService: { getSnapshot: vi.fn(async () => null) },
agentManager: {
listAgents: () => [],
closeAgent: vi.fn(async () => {}),
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord,
emit: vi.fn(),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
emittedUpdates.push({
kind: "upsert",
workspaceId,
archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null,
});
}
}),
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.set(workspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.delete(workspaceId);
}
},
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: null,
requestId: "req-archive-delete-fails",
},
),
).rejects.toThrow("cwd or worktreesRoot is required to delete a Paseo worktree");
expect(existsSync(created.worktreePath)).toBe(true);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emittedUpdates[0]).toEqual({
kind: "upsert",
workspaceId: created.worktreePath,
archivingAt: expect.any(String),
});
expect(emittedUpdates.at(-1)).toEqual({
kind: "upsert",
workspaceId: created.worktreePath,
archivingAt: null,
});
});
test("proceeds to FS delete even when terminal teardown rejects", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -1602,7 +1839,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: vi.fn(),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath,
sessionLogger: createLogger(),
@@ -1647,7 +1884,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: vi.fn(),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath: vi.fn(async () => {}),
sessionLogger: createLogger(),
@@ -1698,7 +1935,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath: vi.fn(async () => {}),
sessionLogger: createLogger(),
@@ -364,11 +364,11 @@ export async function handlePaseoWorktreeListRequest(
export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<
ArchivePaseoWorktreeDependencies,
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
+1
View File
@@ -2175,6 +2175,7 @@ export const WorkspaceDescriptorPayloadSchema = z
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
name: z.string(),
archivingAt: z.string().nullable().optional().default(null),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
diffStat: z
@@ -1,6 +1,10 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceDescriptorPayloadSchema,
} from "./messages.js";
describe("workspace message schemas", () => {
test("parses fetch_workspaces_request", () => {
@@ -281,6 +285,31 @@ describe("workspace message schemas", () => {
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
});
test("defaults omitted workspace archiving state and preserves present timestamps", () => {
const baseWorkspace = {
id: "ws-archiving",
projectId: "proj-archiving",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
status: "done",
activityAt: null,
scripts: [],
} as const;
const archivingAt = "2026-04-30T20:45:00.000Z";
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).archivingAt).toBeNull();
expect(
WorkspaceDescriptorPayloadSchema.parse({
...baseWorkspace,
archivingAt,
}).archivingAt,
).toBe(archivingAt);
});
test("parses legacy workspace descriptor enum values", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",