fix(workspaces): preserve portable runtime readiness

This commit is contained in:
Mohamed Boudra
2026-08-14 20:13:04 +02:00
parent b500809229
commit e5f3b3dc02
9 changed files with 342 additions and 247 deletions
@@ -136,6 +136,7 @@ test.describe("New workspace entry points", () => {
sourceDirectory: "/tmp/missing-project",
});
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceControlsEnabled(page);
});
+8 -3
View File
@@ -856,6 +856,8 @@ interface ComposerProps {
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** Disables submission without locking draft editing or attachments. */
isSubmitDisabled?: boolean;
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
waitForGithubAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
@@ -1067,6 +1069,7 @@ export function Composer({
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
isSubmitDisabled = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
@@ -2144,8 +2147,10 @@ export function Composer({
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitDisabled =
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
const isSubmitActionDisabled =
isSubmitDisabled ||
isSubmitLoadingVisible ||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
@@ -2204,7 +2209,7 @@ export function Composer({
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitDisabled={isSubmitActionDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
@@ -2487,7 +2487,8 @@ export function NewWorkspaceScreen({
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
submitButtonTestID="new-workspace-launch-submit"
isSubmitLoading={isCreationBlocked}
isSubmitLoading={isPending}
isSubmitDisabled={isCreationBlocked}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={terminalComposerValue}
@@ -2511,7 +2512,8 @@ export function NewWorkspaceScreen({
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isCreationBlocked}
isSubmitLoading={isPending}
isSubmitDisabled={isCreationBlocked}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
@@ -265,7 +265,7 @@ test("failed Hub creates release their lifecycle subscriptions", async () => {
expect(await hub.durableOwnedAgentIds()).toEqual([]);
expect(await hub.listedWorktrees()).toHaveLength(1);
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
}, 10_000);
}, 30_000);
test("failed Hub create cleans durable state when provider close rejects", async () => {
const hub = await launchRelationship();
File diff suppressed because it is too large Load Diff
@@ -2494,21 +2494,26 @@ test("non-git workspace uses deterministic directory name and no unknown branch
test("workspace placements preserve checkout facts independently from the project", async () => {
const session = createSessionForWorkspaceTests();
const mainRepoRoot = path.resolve("/tmp/main-repo");
const manualWorktreeCwd = path.resolve("/tmp/manual-worktree");
const explicitDirectoryCwd = path.resolve("/tmp/plain-directory");
const paseoWorktreeRoot = path.resolve("/tmp/paseo-worktree");
const paseoSubdirectoryCwd = path.join(paseoWorktreeRoot, "packages", "app");
const manualWorktree = createPersistedWorkspaceRecord({
workspaceId: "ws-manual-worktree",
projectId: "proj-manual-worktree",
cwd: "/tmp/manual-worktree",
cwd: manualWorktreeCwd,
kind: "worktree",
displayName: "manual",
isPaseoOwnedWorktree: false,
mainRepoRoot: "/tmp/main-repo",
mainRepoRoot,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const explicitDirectory = createPersistedWorkspaceRecord({
workspaceId: "ws-explicit-directory",
projectId: "proj-manual-worktree",
cwd: "/tmp/plain-directory",
cwd: explicitDirectoryCwd,
kind: "directory",
displayName: "plain",
createdAt: "2026-03-01T12:00:00.000Z",
@@ -2517,17 +2522,17 @@ test("workspace placements preserve checkout facts independently from the projec
const paseoSubdirectory = createPersistedWorkspaceRecord({
workspaceId: "ws-paseo-subdirectory",
projectId: "proj-manual-worktree",
cwd: "/tmp/paseo-worktree/packages/app",
cwd: paseoSubdirectoryCwd,
kind: "worktree",
displayName: "app",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/tmp/main-repo",
mainRepoRoot,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-manual-worktree",
rootPath: "/tmp/main-repo",
rootPath: mainRepoRoot,
kind: "git",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
@@ -2541,7 +2546,7 @@ test("workspace placements preserve checkout facts independently from the projec
session.workspaceGitService.peekSnapshot = (cwd: string) =>
cwd === paseoSubdirectory.cwd
? createWorkspaceRuntimeSnapshot(cwd, {
git: { repoRoot: "/tmp/paseo-worktree" },
git: { repoRoot: paseoWorktreeRoot },
})
: null;
@@ -2552,7 +2557,7 @@ test("workspace placements preserve checkout facts independently from the projec
checkout: expect.objectContaining({
isGit: true,
isPaseoOwnedWorktree: false,
mainRepoRoot: "/tmp/main-repo",
mainRepoRoot,
}),
}),
);
@@ -2573,7 +2578,7 @@ test("workspace placements preserve checkout facts independently from the projec
expect.objectContaining({
checkout: expect.objectContaining({
cwd: paseoSubdirectory.cwd,
worktreeRoot: "/tmp/paseo-worktree",
worktreeRoot: paseoWorktreeRoot,
}),
}),
);
@@ -24,6 +24,14 @@ function eventWithin(event: Promise<void>, label: string): Promise<void> {
]);
}
function createDeferred(): { promise: Promise<void>; resolve(): void } {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
afterEach(async () => {
await Promise.all(
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
@@ -105,6 +113,69 @@ test.each(["local", "worktree"] as const)(
20_000,
);
test("workspace Git disposal waits for runtime observation teardown", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-dispose-"));
cleanupRoots.push(root);
const source = await createRepository(path.join(root, "source"));
const workspaceId = "runtime-git-dispose";
const runtimeIds = new Map<string, string>();
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: path.join(root, "paseo-home"),
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
persistRuntimeId: async (id, runtimeId) => {
runtimeIds.set(id, runtimeId);
},
beginWorkspaceDeletion: async () => {},
removeWorkspaceRecord: async (id) => {
runtimeIds.delete(id);
},
});
await workspaceRuntime.create({
workspaceId,
runtimeId: "local",
project: { id: "dispose-project", source: { kind: "host-directory", path: source } },
placement: { kind: "existing" },
});
const boundRuntime = await workspaceRuntime.bind(workspaceId);
const originalSubscribe = boundRuntime.files.subscribe.bind(boundRuntime.files);
const unsubscribeStarted = createDeferred();
const unsubscribeReleased = createDeferred();
boundRuntime.files.subscribe = async (...args) => {
const subscription = await originalSubscribe(...args);
return {
unsubscribe: async () => {
unsubscribeStarted.resolve();
await unsubscribeReleased.promise;
await subscription.unsubscribe();
},
};
};
const workspaceGit = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: path.join(root, "paseo-home"),
workspaceRuntime,
});
const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: source });
await selectedGit.observe(() => undefined);
const disposal = workspaceGit.dispose();
try {
await eventWithin(unsubscribeStarted.promise, "runtime observation teardown");
expect(
await Promise.race([
disposal.then(() => "disposed" as const),
Promise.resolve("pending" as const),
]),
).toBe("pending");
} finally {
unsubscribeReleased.resolve();
}
await disposal;
await workspaceRuntime.destroy(workspaceId);
});
test.each([
["local", "pause", "resume"],
["worktree", "archive", "restore"],
@@ -1425,9 +1425,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.selectedWorkspaces.clear();
this.legacyWorkspaces.clear();
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
}
const workspaceTargetDisposals = [...this.workspaceTargets.values()].map((target) =>
this.closeWorkspaceTarget(target),
);
this.workspaceTargets.clear();
for (const target of this.repoTargets.values()) {
@@ -1448,6 +1448,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.disposePromise = Promise.all([
this.fileObserver.close(),
...selectedWorkspaceDisposals,
...workspaceTargetDisposals,
]).then(() => undefined);
return this.disposePromise;
}
@@ -3750,7 +3751,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
}
this.closeWorkspaceTarget(target);
void this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(target.cwd);
}
@@ -3796,9 +3797,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchTargets.delete(target.cwd);
}
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
private closeWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
target.closed = true;
void target.runtimeObservation?.unsubscribe();
const runtimeObservationDisposal =
target.runtimeObservation?.unsubscribe() ?? Promise.resolve();
target.runtimeObservation = null;
if (target.workingTreeWatchTarget) {
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
@@ -3814,6 +3816,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
this.stopForgePrStatusPollForTarget(target);
target.listeners.clear();
return runtimeObservationDisposal;
}
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import {
createFileObserver,
@@ -13,72 +13,74 @@ import {
} from "../../file-observer/index.js";
import { createHostGitObservationOwner } from "./host-git-observation.js";
test("sibling host worktrees share one physical common-Git observation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-"));
try {
const source = await repository(path.join(root, "source"));
const sibling = path.join(root, "sibling");
git(source, ["branch", "sibling"]);
git(source, ["worktree", "add", sibling, "sibling"]);
const unrelated = await repository(path.join(root, "unrelated"));
const owner = createHostGitObservationOwner();
describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () => {
test("sibling host worktrees share one physical common-Git observation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-"));
try {
const source = await repository(path.join(root, "source"));
const sibling = path.join(root, "sibling");
git(source, ["branch", "sibling"]);
git(source, ["worktree", "add", sibling, "sibling"]);
const unrelated = await repository(path.join(root, "unrelated"));
const owner = createHostGitObservationOwner();
const [sourceSubscription, siblingSubscription] = await Promise.all([
owner.observe(source, () => undefined),
owner.observe(sibling, () => undefined),
]);
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
const [sourceSubscription, siblingSubscription] = await Promise.all([
owner.observe(source, () => undefined),
owner.observe(sibling, () => undefined),
]);
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
const unrelatedSubscription = await owner.observe(unrelated, () => undefined);
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
const unrelatedSubscription = await owner.observe(unrelated, () => undefined);
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
await sourceSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
await siblingSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
await unrelatedSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await sourceSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
await siblingSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
await unrelatedSubscription.unsubscribe();
expect(owner.getDiagnostics().activeObservationCount).toBe(0);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("a failed physical common watcher rebinds once without changing logical ownership", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-"));
try {
const source = await repository(path.join(root, "source"));
const sibling = path.join(root, "sibling");
git(source, ["branch", "sibling"]);
git(source, ["worktree", "add", sibling, "sibling"]);
const observer = new InstrumentedFileObserver();
const owner = createHostGitObservationOwner(observer);
let notifications = 0;
const subscriptions = await Promise.all([
owner.observe(source, () => {
notifications += 1;
}),
owner.observe(sibling, () => {
notifications += 1;
}),
]);
expect(observer.subscribeCount).toBe(1);
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
test("a failed physical common watcher rebinds once without changing logical ownership", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-"));
try {
const source = await repository(path.join(root, "source"));
const sibling = path.join(root, "sibling");
git(source, ["branch", "sibling"]);
git(source, ["worktree", "add", sibling, "sibling"]);
const observer = new InstrumentedFileObserver();
const owner = createHostGitObservationOwner(observer);
let notifications = 0;
const subscriptions = await Promise.all([
owner.observe(source, () => {
notifications += 1;
}),
owner.observe(sibling, () => {
notifications += 1;
}),
]);
expect(observer.subscribeCount).toBe(1);
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
observer.failActive(new Error("physical watcher failed"));
const barrier = await owner.observe(sibling, () => undefined);
expect(notifications).toBe(2);
expect(observer.subscribeCount).toBe(2);
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
observer.failActive(new Error("physical watcher failed"));
const barrier = await owner.observe(sibling, () => undefined);
expect(notifications).toBe(2);
expect(observer.subscribeCount).toBe(2);
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
await Promise.all([
...subscriptions.map((subscription) => subscription.unsubscribe()),
barrier.unsubscribe(),
]);
expect(observer.getDiagnostics().activeObservationCount).toBe(0);
await observer.close();
} finally {
await rm(root, { recursive: true, force: true });
}
await Promise.all([
...subscriptions.map((subscription) => subscription.unsubscribe()),
barrier.unsubscribe(),
]);
expect(observer.getDiagnostics().activeObservationCount).toBe(0);
await observer.close();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
class InstrumentedFileObserver implements FileObserver {