Skip to content

Commit 3f1f186

Browse files
committed
🤖 fix: offer a legacy plan import only when the migration found the shared plan alone; list only migrated SSH ancestors
1 parent 807023c commit 3f1f186

5 files changed

Lines changed: 48 additions & 22 deletions

File tree

‎src/node/services/turnContextAssembler.test.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,9 +1403,10 @@ describe("buildStreamSystemContext", () => {
14031403
});
14041404
}
14051405

1406-
// #5174: SSH ancestors are listed by their installation-scoped path only, even one from an older
1407-
// build that has not migrated yet: a turn never points the agent at a legacy plan file.
1408-
test("lists only installation-scoped plan paths for SSH ancestors", async () => {
1406+
// #5174: SSH ancestors are listed by their installation-scoped path only. One from an older build
1407+
// that has not migrated yet is left out (its plan is not at that path yet), and a turn never
1408+
// points the agent at a legacy plan file.
1409+
test("lists migrated SSH ancestors by their installation-scoped path, and no legacy path", async () => {
14091410
using tempRoot = new DisposableTempDir("stream-system-context");
14101411
const projectPath = path.join(tempRoot.path, "project");
14111412
const xumHome = path.join(tempRoot.path, "mux-home");
@@ -1464,9 +1465,6 @@ describe("buildStreamSystemContext", () => {
14641465
mcpServers: {},
14651466
});
14661467

1467-
expect(result.ancestorPlanFilePaths).toEqual([
1468-
scoped("child-workspace"),
1469-
scoped("parent-workspace"),
1470-
]);
1468+
expect(result.ancestorPlanFilePaths).toEqual([scoped("child-workspace")]);
14711469
});
14721470
});

‎src/node/services/turnContextAssembler.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,8 @@ interface WorkspaceConfigLookupEntry {
609609
projectName: string;
610610
projectPath: string;
611611
parentWorkspaceId: string | undefined;
612+
/** The row's #5174 migration flag: an SSH row without it may have no plan at its scoped path. */
613+
remotePlanMigrated: boolean;
612614
}
613615

614616
interface AncestorPlanPathEntry {
@@ -639,6 +641,7 @@ function buildWorkspaceConfigLookup(cfg: ProjectsConfig): Map<string, WorkspaceC
639641
: projectName,
640642
projectPath: workspace.projects?.[0]?.projectPath ?? projectPath,
641643
parentWorkspaceId: workspace.parentWorkspaceId,
644+
remotePlanMigrated: workspace.remotePlanMigrated === true,
642645
});
643646
}
644647
}
@@ -719,11 +722,14 @@ function resolveAncestorPlanContext(args: {
719722
}
720723

721724
// Same storage as this workspace's runtime. On SSH that is the installation-scoped tree
722-
// (#5174), which needs this installation's identity: without it the ancestor is skipped.
725+
// (#5174), which needs this installation's identity: without it the ancestor is skipped. So
726+
// is an SSH ancestor from before #5174 that has not migrated yet: its plan is not at its
727+
// scoped path until its own next plan access migrates it, and a turn never points the agent
728+
// at a legacy file.
723729
const xumHome = args.runtime.getXumHome();
724730
const ancestorPlanFilePath = !usesInstallationScopedPlans(args.metadata.runtimeConfig)
725731
? getPlanFilePath(currentWorkspace.workspaceName, currentWorkspace.projectName, xumHome)
726-
: args.installationId === undefined
732+
: args.installationId === undefined || !currentWorkspace.remotePlanMigrated
727733
? undefined
728734
: getInstallationScopedPlanFilePath(
729735
currentWorkspace.workspaceName,

‎src/node/services/workspaceService.remotePlanNamespace.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,24 @@ describe("SSH plans are installation-scoped (#5174)", () => {
236236
});
237237
});
238238

239+
test("a row left unmarked by a failed flag write is not offered the shared plan", async () => {
240+
await withTempMuxRoot(async () => {
241+
await addOlderRow();
242+
await writeIdPlan();
243+
await writeSharedPlan();
244+
spyOn(harness.config, "markRemotePlanMigrated").mockRejectedValue(
245+
new Error("config write failed")
246+
);
247+
248+
expect(await planContent()).toBe(ID_PLAN);
249+
expect(migrated()).toBe(false);
250+
251+
expect(await offer()).toBeNull();
252+
const renamed = await harness.service.rename(id, "renamed");
253+
expect(renamed.success ? "" : renamed.error).toBe("");
254+
});
255+
});
256+
239257
test("an import never replaces a plan already in this installation's path", async () => {
240258
await withTempMuxRoot(async () => {
241259
await addOlderRow();

‎src/node/services/workspaceService.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ import { isWorkspaceTrustedForSharedExecution } from "@/node/services/utils/work
133133
import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets";
134134
import { getLegacyPlanFilePath, sharesPlanDirectory } from "@/common/utils/planStorage";
135135
import {
136-
getImportableLegacyPlanPath,
136+
findImportableLegacyPlanPath,
137137
importLegacyRemotePlan,
138138
markRemotePlanMigratedForDeletion,
139139
resolvePlanFileLocation,
@@ -5605,8 +5605,8 @@ export class WorkspaceService
56055605
/**
56065606
* The shared pre-#5174 plan path the user can import into this SSH workspace (Option B): only
56075607
* for a row from before #5174 whose only legacy plan is that file. Null otherwise, and for
5608-
* non-SSH workspaces. Resolving the location runs the row's automatic migration first, so an
5609-
* own plans/<id>.md plan is migrated rather than offered.
5608+
* non-SSH workspaces. This runs the row's automatic migration first, so an own plans/<id>.md
5609+
* plan is migrated rather than offered.
56105610
*/
56115611
public async getImportableLegacyPlan(workspaceId: string): Promise<Result<string | null>> {
56125612
const metadata = await this.getInfo(workspaceId);
@@ -5615,8 +5615,7 @@ export class WorkspaceService
56155615
try {
56165616
const runtime = createRuntimeForWorkspace(metadata);
56175617
const owner = { ...metadata, id: workspaceId };
5618-
await this.resolvePlanLocation(owner, runtime);
5619-
const legacyPath = getImportableLegacyPlanPath(this.config, runtime, owner);
5618+
const legacyPath = await findImportableLegacyPlanPath(this.config, runtime, owner);
56205619
return Ok(legacyPath === undefined ? null : await runtime.resolvePath(legacyPath));
56215620
} catch (error) {
56225621
return Err(`Failed to check for a plan from an older Xum: ${getErrorMessage(error)}`);
@@ -9964,7 +9963,7 @@ export class WorkspaceService
99649963
// A row whose only legacy plan is the shared basename file stays unmigrated until the
99659964
// user imports it (Option B). That file is named after the workspace, so after a rename
99669965
// the offer would look at another path and the plan would be orphaned: refuse instead.
9967-
const importable = getImportableLegacyPlanPath(this.config, runtimeForPlanFile, {
9966+
const importable = await findImportableLegacyPlanPath(this.config, runtimeForPlanFile, {
99689967
...oldMetadata,
99699968
id: workspaceId,
99709969
});

‎src/node/utils/runtime/planLocation.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,19 +256,24 @@ async function migrateRemotePlan(
256256
}
257257

258258
/**
259-
* The shared pre-#5174 plan path a user can import into an SSH row (Option B): set only while the
260-
* row is unmigrated after its automatic migration, which means its only legacy plan is that file.
261-
* Call after resolvePlanFileLocation. Undefined for other rows.
259+
* The shared pre-#5174 plan path a user can import into an SSH row (Option B), or undefined. Runs
260+
* the row's automatic migration (as resolvePlanFileLocation does) and offers the path only when
261+
* that migration found the shared file to be the row's only legacy plan, so a row left unmarked by
262+
* a failed flag write is never offered a file that is not there.
262263
*/
263-
export function getImportableLegacyPlanPath(
264-
config: Pick<PlanLocationConfig, "isRemotePlanMigrated">,
264+
export async function findImportableLegacyPlanPath(
265+
config: PlanLocationConfig,
265266
runtime: Runtime,
266267
owner: PlanOwner
267-
): string | undefined {
268+
): Promise<string | undefined> {
268269
if (!usesInstallationScopedPlans(owner.runtimeConfig) || config.isRemotePlanMigrated(owner.id)) {
269270
return undefined;
270271
}
271-
return getPlanFilePath(owner.name, owner.projectName, runtime.getXumHome());
272+
const planPath = await resolvePlanFilePath(config, runtime, owner);
273+
const outcome = await migrateRemotePlan(config, runtime, owner, planPath, false);
274+
return outcome === "sharedOnly"
275+
? getPlanFilePath(owner.name, owner.projectName, runtime.getXumHome())
276+
: undefined;
272277
}
273278

274279
/** Result of importLegacyRemotePlan. */

0 commit comments

Comments
 (0)