feat(server): thread plural referenced-project workspaces through run prep (#10448)

## Thinking Path

> - Paperclip coordinates work for autonomous companies.
> - A run needs a workspace view before execution starts.
> - That view now needs to cover one anchor project and more referenced
projects.
> - Those extra workspaces must stay separate and must not change the
anchor path when the feature stays off.
> - This PR threads plural workspace data through run prep behind a
default-off kill switch.
> - It also keeps extra project workspaces isolated and makes the
realization contract round-trip the new shape.
> - The benefit is safer run prep for referenced projects without
changing the current default path.

## Linked Issues or Issue Description

This PR does not link a public GitHub issue.
It follows the internal run-prep task for plural referenced-project
workspaces.

Problem:
- Run prep resolves the anchor project today, but it does not yet carry
each referenced project into the run workspace view.
- That gap blocks runs that need a second repo or sibling project during
preparation.

Proposed solution:
- Thread a plural workspace result through run prep.
- Keep the anchor path unchanged when the kill switch is off.
- Resolve each referenced project into its own managed checkout
directory when the flag is on.

Alternatives considered:
- Keep one shared workspace and layer the extra repos into it. Rejected
because it would blur isolation and make failures harder to bound.
- Upload the extra workspaces immediately. Rejected because this PR only
prepares the data path.

Roadmap alignment:
- This change sits in the workspace and sandbox path.
- It matches the roadmap work on workspace strategy and cloud or sandbox
agents.

## What Changed

- Added `additionalWorkspaces[]` to the run workspace result.
- Split workspace resolution into an anchor path and an optional
referenced-project path behind `PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC`.
- Kept per-project failure isolation so one bad clone does not stop the
run.
- Keyed managed workspace directories by `projectId` so sibling
workspaces stay separate.
- Added `additionalSources[]` to the workspace realization request and
kept read and write paths backward compatible.
- Added tests for the anchor-only path, the new workspace shape, and the
per-project directory rule.

## Verification

- `pnpm --filter @paperclipai/server run typecheck`
- `pnpm --filter @paperclipai/shared run typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-project-env.test.ts` 21/21
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/workspace-runtime.test.ts` 98/98

## Risks

Low risk. The new path stays behind a default-off kill switch, so the
anchor flow does not change when the flag is off.
The main risk is a bad referenced project clone. That case now drops
only the affected project and keeps the run alive.
The shared type change also needs every consumer to use the new array
field where extra workspaces matter.

## Model Used

OpenAI Codex (GPT-5; exact internal model ID not exposed in this
environment; tool use enabled)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-29 14:27:05 -07:00 committed by GitHub
parent d51f42ed64
commit 15ce70dc18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1050 additions and 20 deletions

View File

@ -330,6 +330,19 @@ export interface WorkspaceRealizationRequest {
branchName: string | null;
worktreePath: string | null;
};
/**
* Read-only referenced (mentioned) project sources for this run, one per authorized additional
* project. Additive and backward-compatible: it defaults to an empty array for legacy payloads
* and for the anchor-only path. Additional sources are plain trees; they never get git-worktree
* realization (that stays the anchor-only path).
*/
additionalSources?: Array<{
localPath: string;
projectId: string | null;
projectWorkspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
}>;
runtimeOverlay: {
provisionCommand: string | null;
teardownCommand: string | null;
@ -360,6 +373,20 @@ export interface WorkspaceRealizationRecord {
branchName: string | null;
worktreePath: string | null;
};
/**
* Realized read-only referenced (mentioned) project workspaces for this run, one per authorized
* additional source in the request. This field carries each resolved path to the execution
* target so the target can expose the referenced trees to the agent. It is additive and
* backward-compatible: it defaults to an empty array for legacy records and for the anchor-only
* path.
*/
additional?: Array<{
path: string;
projectId: string | null;
projectWorkspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
}>;
remote: {
path: string | null;
host?: string | null;

View File

@ -6,9 +6,18 @@ import { buildSkillMentionHref } from "@paperclipai/shared";
import {
LOW_TRUST_REVIEW_PRESET,
applyRunScopedMentionedSkillKeys,
buildRunWorkspaceHints,
extractMentionedSkillIdsFromSources,
resolveAdditionalProjectWorkspace,
resolveAdditionalRunWorkspaces,
resolveExecutionRunAdapterConfig,
type ResolveAdditionalProjectWorkspaceDeps,
type ResolveAdditionalRunWorkspacesOptions,
type ResolvedAdditionalWorkspace,
type RunReferencedProject,
} from "../services/heartbeat.ts";
import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => {
@ -720,3 +729,394 @@ describe("applyRunScopedMentionedSkillKeys", () => {
});
});
});
describe("resolveAdditionalRunWorkspaces", () => {
const companyId = "company-1";
const issueId = "issue-1";
const allowDecision: AuthorizationDecision = {
allowed: true,
action: "project:read",
reason: "allow_company_agent",
explanation: "test allow",
};
// Build injectable dependencies. Every mentioned project is available and authorized by default.
function buildOptions(
overrides: Partial<ResolveAdditionalRunWorkspacesOptions> & { mentionedIds?: string[] },
): ResolveAdditionalRunWorkspacesOptions {
const mentionedIds = overrides.mentionedIds ?? [];
const actor: AuthorizationActor = {
type: "agent",
agentId: "agent-1",
companyId,
source: "agent_key",
};
const issues: ResolveAdditionalRunWorkspacesOptions["issues"] = {
findMentionedProjectIds: async () => mentionedIds,
};
const projects: ResolveAdditionalRunWorkspacesOptions["projects"] = {
listByIds: async (_companyId, ids) =>
ids.map((id) => ({ id })) as unknown as Awaited<
ReturnType<ResolveAdditionalRunWorkspacesOptions["projects"]["listByIds"]>
>,
};
const access: ResolveAdditionalRunWorkspacesOptions["access"] = {
decide: async () => allowDecision,
};
const resolveProjectWorkspace = async (
project: RunReferencedProject,
): Promise<ResolvedAdditionalWorkspace> => ({
cwd: `/managed/${project.projectId}`,
projectId: project.projectId,
workspaceId: `${project.projectId}-ws`,
repoUrl: null,
repoRef: null,
});
return {
enabled: true,
companyId,
actor,
issues,
projects,
access,
resolveProjectWorkspace,
...overrides,
};
}
it("resolves additionalWorkspaces for each mentioned project when the sync flag is ON", async () => {
const options = buildOptions({ mentionedIds: ["project-a", "project-b"] });
const result = await resolveAdditionalRunWorkspaces(issueId, null, options);
expect(result.additionalWorkspaces).toEqual([
{
cwd: "/managed/project-a",
projectId: "project-a",
workspaceId: "project-a-ws",
repoUrl: null,
repoRef: null,
},
{
cwd: "/managed/project-b",
projectId: "project-b",
workspaceId: "project-b-ws",
repoUrl: null,
repoRef: null,
},
]);
expect(result.warnings).toEqual([]);
});
it("returns empty additionalWorkspaces when the sync flag is OFF (anchor-only, unchanged)", async () => {
let mentionLookups = 0;
let workspaceResolves = 0;
const options = buildOptions({
enabled: false,
mentionedIds: ["project-a", "project-b"],
issues: {
findMentionedProjectIds: async () => {
mentionLookups += 1;
return ["project-a", "project-b"];
},
},
resolveProjectWorkspace: async (project) => {
workspaceResolves += 1;
return {
cwd: `/managed/${project.projectId}`,
projectId: project.projectId,
workspaceId: null,
repoUrl: null,
repoRef: null,
};
},
});
const result = await resolveAdditionalRunWorkspaces(issueId, null, options);
expect(result).toEqual({ additionalWorkspaces: [], warnings: [] });
// The flag-off path must be inert: no mention lookup and no workspace resolution run.
expect(mentionLookups).toBe(0);
expect(workspaceResolves).toBe(0);
});
it("isolates a failing mentioned-project clone with a warning, run still resolves", async () => {
const options = buildOptions({
mentionedIds: ["project-a", "project-b"],
resolveProjectWorkspace: async (project) => {
if (project.projectId === "project-a") {
throw new Error("clone exploded");
}
return {
cwd: `/managed/${project.projectId}`,
projectId: project.projectId,
workspaceId: null,
repoUrl: null,
repoRef: null,
};
},
});
const result = await resolveAdditionalRunWorkspaces(issueId, null, options);
expect(result.additionalWorkspaces).toEqual([
{
cwd: "/managed/project-b",
projectId: "project-b",
workspaceId: null,
repoUrl: null,
repoRef: null,
},
]);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain("project-a");
expect(result.warnings[0]).toContain("clone exploded");
});
it("skips referenced-project work on a remote target and warns when the issue mentions a project", async () => {
let mentionLookups = 0;
let workspaceResolves = 0;
const options = buildOptions({
executionTargetIsRemote: true,
mentionedIds: ["project-a", "project-b"],
issues: {
findMentionedProjectIds: async () => {
mentionLookups += 1;
return ["project-a", "project-b"];
},
},
resolveProjectWorkspace: async (project) => {
workspaceResolves += 1;
return {
cwd: `/managed/${project.projectId}`,
projectId: project.projectId,
workspaceId: null,
repoUrl: null,
repoRef: null,
};
},
});
const result = await resolveAdditionalRunWorkspaces(issueId, null, options);
expect(result.additionalWorkspaces).toEqual([]);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain("local execution target");
// The remote path must skip the per-project clone work whose result the target cannot receive.
expect(mentionLookups).toBe(1);
expect(workspaceResolves).toBe(0);
});
it("stays silent on a remote target when the issue mentions no referenced project", async () => {
const options = buildOptions({
executionTargetIsRemote: true,
mentionedIds: [],
});
const result = await resolveAdditionalRunWorkspaces(issueId, null, options);
expect(result).toEqual({ additionalWorkspaces: [], warnings: [] });
});
});
describe("buildRunWorkspaceHints", () => {
const anchorHints = [
{ workspaceId: "anchor-ws", cwd: "/anchor", repoUrl: "https://example.test/anchor.git", repoRef: "main" },
];
it("returns only the anchor hints when no referenced project resolves (flag OFF, inert)", () => {
const hints = buildRunWorkspaceHints({ workspaceHints: anchorHints, additionalWorkspaces: [] });
expect(hints).toEqual(anchorHints);
});
it("appends each referenced project workspace so the agent sees its path", () => {
const hints = buildRunWorkspaceHints({
workspaceHints: anchorHints,
additionalWorkspaces: [
{
cwd: "/managed/project-b",
projectId: "project-b",
workspaceId: "project-b-ws",
repoUrl: "https://example.test/b.git",
repoRef: "main",
},
// The hint builder passes each field through unchanged, including a null workspaceId.
{ cwd: "/managed/project-c", projectId: "project-c", workspaceId: null, repoUrl: null, repoRef: null },
],
});
expect(hints).toEqual([
{ workspaceId: "anchor-ws", cwd: "/anchor", repoUrl: "https://example.test/anchor.git", repoRef: "main" },
{
workspaceId: "project-b-ws",
cwd: "/managed/project-b",
repoUrl: "https://example.test/b.git",
repoRef: "main",
projectId: "project-b",
},
{ workspaceId: null, cwd: "/managed/project-c", repoUrl: null, repoRef: null, projectId: "project-c" },
]);
});
});
describe("resolveAdditionalProjectWorkspace", () => {
const companyId = "company-1";
function referencedProject(projectId: string): RunReferencedProject {
return { projectId, project: { id: projectId } as RunReferencedProject["project"] };
}
type WorkspaceRow = Awaited<ReturnType<ResolveAdditionalProjectWorkspaceDeps["loadProjectWorkspaceRows"]>>[number];
function workspaceRow(overrides: Partial<WorkspaceRow>): WorkspaceRow {
return { id: "workspace-x", cwd: null, repoUrl: null, repoRef: null, ...overrides } as WorkspaceRow;
}
// Build deps whose managed and configured resolvers are pure, so no database or filesystem runs.
function buildDeps(
overrides: Partial<ResolveAdditionalProjectWorkspaceDeps>,
): ResolveAdditionalProjectWorkspaceDeps {
return {
loadProjectWorkspaceRows: async () => [],
resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }),
ensureManagedProjectWorkspace: async (input) => ({
cwd: `/managed/${input.projectId}`,
warning: null,
}),
directoryHasContents: async () => true,
...overrides,
};
}
it("throws instead of creating an empty managed directory when the project has no workspace rows", async () => {
let ensureCalls = 0;
const deps = buildDeps({
loadProjectWorkspaceRows: async () => [],
ensureManagedProjectWorkspace: async (input) => {
ensureCalls += 1;
return { cwd: `/managed/${input.projectId}`, warning: null };
},
});
await expect(
resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-b") }, deps),
).rejects.toThrow(/project-b/);
// The fallback must not fabricate an empty managed directory for a project with no real source.
expect(ensureCalls).toBe(0);
});
it("throws when a workspace row supplies neither a checkout directory nor a repository URL", async () => {
let ensureCalls = 0;
const deps = buildDeps({
loadProjectWorkspaceRows: async () => [workspaceRow({ id: "ws-empty", cwd: null, repoUrl: null })],
resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }),
// directoryHasContents returns true for any path; the row must still be skipped before this runs.
directoryHasContents: async () => true,
ensureManagedProjectWorkspace: async (input) => {
ensureCalls += 1;
return { cwd: `/managed/${input.projectId}`, warning: null };
},
});
await expect(
resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-c") }, deps),
).rejects.toThrow(/project-c/);
// The row without a real source neither resolves a checkout nor triggers a managed fallback.
expect(ensureCalls).toBe(0);
});
it("throws when a configured checkout directory exists but has no content", async () => {
let ensureCalls = 0;
const deps = buildDeps({
loadProjectWorkspaceRows: async () => [
workspaceRow({ id: "ws-empty-dir", cwd: "/checkout/empty", repoUrl: null }),
],
resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }),
// The configured directory exists but holds no content, so it is not a realized workspace.
directoryHasContents: async () => false,
ensureManagedProjectWorkspace: async (input) => {
ensureCalls += 1;
return { cwd: `/managed/${input.projectId}`, warning: null };
},
});
await expect(
resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-d") }, deps),
).rejects.toThrow(/project-d/);
// An empty configured directory must not mask a missing workspace, and the row has no
// repository URL, so the managed fallback never runs.
expect(ensureCalls).toBe(0);
});
it("returns the first workspace row whose directory has content", async () => {
const deps = buildDeps({
loadProjectWorkspaceRows: async () => [
workspaceRow({ id: "ws-1", cwd: "/checkout/a", repoUrl: "https://example.test/a.git", repoRef: "main" }),
],
resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }),
directoryHasContents: async (cwd) => cwd === "/checkout/a",
});
const result = await resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-a") }, deps);
expect(result).toEqual({
cwd: "/checkout/a",
projectId: "project-a",
workspaceId: "ws-1",
repoUrl: "https://example.test/a.git",
repoRef: "main",
});
});
it("clones into the managed directory when configured rows point at missing paths", async () => {
let ensuredRepoUrl: string | null | undefined;
const deps = buildDeps({
loadProjectWorkspaceRows: async () => [
workspaceRow({ id: "ws-1", cwd: "/checkout/missing", repoUrl: "https://example.test/a.git", repoRef: "release" }),
],
resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }),
directoryHasContents: async () => false,
ensureManagedProjectWorkspace: async (input) => {
ensuredRepoUrl = input.repoUrl;
return { cwd: `/managed/${input.projectId}`, warning: null };
},
});
const result = await resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-a") }, deps);
expect(result).toEqual({
cwd: "/managed/project-a",
projectId: "project-a",
workspaceId: "ws-1",
repoUrl: "https://example.test/a.git",
repoRef: "release",
});
// The fallback clone reuses the repository URL from the first configured workspace row.
expect(ensuredRepoUrl).toBe("https://example.test/a.git");
});
});
describe("resolveManagedProjectWorkspaceDir isolation", () => {
it("resolves distinct, non-nested managed dirs for two projects", () => {
const companyId = "company-1";
const dirA = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-a", repoName: null });
const dirB = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-b", repoName: null });
expect(dirA).not.toBe(dirB);
// Neither directory is a path prefix of the other: append a separator so a shared leading
// string (for example "project-a" vs "project-ab") never reads as nesting.
expect(`${dirB}${path.sep}`.startsWith(`${dirA}${path.sep}`)).toBe(false);
expect(`${dirA}${path.sep}`.startsWith(`${dirB}${path.sep}`)).toBe(false);
});
it("keeps a project id that prefixes another project id in a sibling, non-nested dir", () => {
const companyId = "company-1";
const dir = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project", repoName: null });
const dirLonger = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-extra", repoName: null });
expect(`${dirLonger}${path.sep}`.startsWith(`${dir}${path.sep}`)).toBe(false);
expect(`${dir}${path.sep}`.startsWith(`${dirLonger}${path.sep}`)).toBe(false);
});
});

View File

@ -19,6 +19,7 @@ import { issueService } from "../services/issues.ts";
import { projectService } from "../services/projects.ts";
import {
isMultiProjectWorkspaceSyncEnabled,
isRemoteExecutionEnvironmentDriver,
MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS,
MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS,
MULTI_PROJECT_WORKSPACE_SYNC_ENV,
@ -41,6 +42,15 @@ describe("multi-project workspace sync kill-switch", () => {
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "1" })).toBe(true);
expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "on" })).toBe(true);
});
it("classifies ssh, sandbox, and plugin drivers as remote and local/unknown as local", () => {
expect(isRemoteExecutionEnvironmentDriver("ssh")).toBe(true);
expect(isRemoteExecutionEnvironmentDriver("sandbox")).toBe(true);
expect(isRemoteExecutionEnvironmentDriver("plugin")).toBe(true);
expect(isRemoteExecutionEnvironmentDriver("local")).toBe(false);
expect(isRemoteExecutionEnvironmentDriver(null)).toBe(false);
expect(isRemoteExecutionEnvironmentDriver(undefined)).toBe(false);
});
});
describeEmbeddedPostgres("resolveRunReferencedProjects", () => {

View File

@ -49,6 +49,12 @@ import {
readLocalServicePortOwner,
writeLocalServiceRegistryRecord,
} from "../services/local-service-supervisor.ts";
import {
buildWorkspaceRealizationRecord,
buildWorkspaceRealizationRequest,
readWorkspaceRealizationRequest,
} from "../services/workspace-realization.ts";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
@ -6191,3 +6197,180 @@ describe("normalizeAdapterManagedRuntimeServices", () => {
});
});
});
describe("workspace realization request additionalSources", () => {
function buildRealizedWorkspace(
overrides: Partial<RealizedExecutionWorkspace> = {},
): RealizedExecutionWorkspace {
return {
baseCwd: "/anchor",
source: "project_primary",
projectId: "project-anchor",
workspaceId: "workspace-anchor",
repoUrl: "https://example.test/anchor.git",
repoRef: "main",
strategy: "project_primary",
cwd: "/anchor",
branchName: null,
worktreePath: null,
warnings: [],
created: false,
...overrides,
};
}
it("round-trips additionalSources through build/read realization request", () => {
const workspace = buildRealizedWorkspace({
additionalWorkspaces: [
{
cwd: "/managed/project-b",
projectId: "project-b",
workspaceId: "workspace-b",
repoUrl: "https://example.test/b.git",
repoRef: "release",
},
],
});
const request = buildWorkspaceRealizationRequest({
adapterType: "codex",
companyId: "company-1",
environmentId: "environment-1",
executionWorkspaceId: "execution-workspace-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
requestedMode: "shared_workspace",
workspace,
workspaceConfig: null,
});
expect(request.additionalSources).toEqual([
{
localPath: "/managed/project-b",
projectId: "project-b",
projectWorkspaceId: "workspace-b",
repoUrl: "https://example.test/b.git",
repoRef: "release",
},
]);
// The anchor source stays scalar and unchanged alongside the new plural field.
expect(request.source.localPath).toBe("/anchor");
// A serialize/deserialize round-trip preserves additionalSources.
const roundTripped = readWorkspaceRealizationRequest(
JSON.parse(JSON.stringify(request)),
);
expect(roundTripped?.additionalSources).toEqual(request.additionalSources);
});
it("exposes additionalSources on the realization record so targets receive the paths", () => {
const workspace = buildRealizedWorkspace({
additionalWorkspaces: [
{
cwd: "/managed/project-b",
projectId: "project-b",
workspaceId: "workspace-b",
repoUrl: "https://example.test/b.git",
repoRef: "release",
},
],
});
const request = buildWorkspaceRealizationRequest({
adapterType: "codex",
companyId: "company-1",
environmentId: "environment-1",
executionWorkspaceId: "execution-workspace-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
requestedMode: "shared_workspace",
workspace,
workspaceConfig: null,
});
const now = new Date(0);
const environment: Environment = {
id: "environment-1",
name: "local",
description: null,
driver: "local",
status: "active",
config: {},
envVars: {},
metadata: null,
createdAt: now,
updatedAt: now,
};
const lease: EnvironmentLease = {
id: "lease-1",
companyId: "company-1",
environmentId: "environment-1",
executionWorkspaceId: "execution-workspace-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
status: "active",
leasePolicy: "ephemeral",
provider: "local",
providerLeaseId: null,
acquiredAt: now,
lastUsedAt: now,
expiresAt: null,
releasedAt: null,
failureReason: null,
cleanupStatus: null,
metadata: null,
createdAt: now,
updatedAt: now,
};
const record = buildWorkspaceRealizationRecord({ environment, lease, request });
// The record carries the resolved referenced-project path so the execution target can expose it.
expect(record.additional).toEqual([
{
path: "/managed/project-b",
projectId: "project-b",
projectWorkspaceId: "workspace-b",
repoUrl: "https://example.test/b.git",
repoRef: "release",
},
]);
// The anchor stays scalar and unchanged alongside the new plural field.
expect(record.local.path).toBe("/anchor");
});
it("reads a legacy request without additionalSources as an empty array", () => {
const legacyRequest = {
version: 1,
adapterType: "codex",
companyId: "company-1",
environmentId: "environment-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/anchor",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
workspaceRuntime: null,
},
};
const parsed = readWorkspaceRealizationRequest(legacyRequest);
expect(parsed).not.toBeNull();
expect(parsed?.additionalSources).toEqual([]);
});
});

View File

@ -64,6 +64,16 @@ function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback
return sanitized || fallback;
}
/**
* Resolve the managed checkout directory for one project:
* `<instanceRoot>/projects/<companyId>/<projectId>/<repoName|_default>`.
*
* Per-project directory isolation invariant: the `projectId` is a distinct path segment, so two
* different projects always resolve to sibling directories under `<companyId>/`. One project's
* directory can never nest inside, or be a path prefix of, another project's directory. A run that
* materializes several referenced projects can therefore place each in its own directory without
* collision. See the "distinct, non-nested managed dirs" test in `heartbeat-project-env.test.ts`.
*/
export function resolveManagedProjectWorkspaceDir(input: {
companyId: string;
projectId: string;

View File

@ -1402,6 +1402,142 @@ async function ensureManagedProjectWorkspace(input: {
}
}
/**
* Resolve one project workspace row to a usable cwd. The anchor path and each additional
* referenced project share this step: use the configured cwd when present, otherwise clone or
* create the managed checkout directory for the project. It throws only when the managed
* checkout cannot be prepared (for example, a clone failure).
*/
async function resolveConfiguredOrManagedProjectCwd(input: {
companyId: string;
projectId: string;
cwd: string | null;
repoUrl: string | null;
}): Promise<{ cwd: string; warning: string | null }> {
const configuredCwd = readNonEmptyString(input.cwd);
if (configuredCwd && configuredCwd !== REPO_ONLY_CWD_SENTINEL) {
return { cwd: configuredCwd, warning: null };
}
return ensureManagedProjectWorkspace({
companyId: input.companyId,
projectId: input.projectId,
repoUrl: readNonEmptyString(input.repoUrl),
});
}
/**
* Side-effecting dependencies for {@link resolveAdditionalProjectWorkspace}. The caller injects
* the real database, filesystem, and managed-checkout helpers. A test injects fakes to exercise
* the resolution logic without a database or filesystem.
*/
export interface ResolveAdditionalProjectWorkspaceDeps {
loadProjectWorkspaceRows: (
companyId: string,
projectId: string,
) => Promise<Array<typeof projectWorkspaces.$inferSelect>>;
resolveConfiguredOrManagedProjectCwd: typeof resolveConfiguredOrManagedProjectCwd;
ensureManagedProjectWorkspace: typeof ensureManagedProjectWorkspace;
directoryHasContents: (cwd: string) => Promise<boolean>;
}
/** Build the real dependencies for {@link resolveAdditionalProjectWorkspace}. */
function defaultAdditionalProjectWorkspaceDeps(db: Db): ResolveAdditionalProjectWorkspaceDeps {
return {
loadProjectWorkspaceRows: (companyId, projectId) =>
db
.select()
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.companyId, companyId), eq(projectWorkspaces.projectId, projectId)))
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)),
resolveConfiguredOrManagedProjectCwd,
ensureManagedProjectWorkspace,
// A realized workspace must hold real content. An empty directory gives the agent an empty
// referenced workspace, so treat an empty directory the same as a missing one.
directoryHasContents: async (cwd) => {
const stats = await fs.stat(cwd).catch(() => null);
if (!stats || !stats.isDirectory()) {
return false;
}
const entries = await fs.readdir(cwd).catch(() => [] as string[]);
return entries.length > 0;
},
};
}
/**
* Resolve one authorized referenced project to its own workspace cwd. Each additional project
* lands in its own managed checkout directory, never nested inside the anchor's worktree (the
* directory isolation invariant lives in {@link resolveManagedProjectWorkspaceDir}).
*
* A referenced project must resolve to a directory with real content. The function uses a
* configured checkout directory that exists, or clones a managed checkout from a workspace row
* that supplies a repository URL. When no row offers either, the function throws instead of
* creating an empty managed directory. An empty directory gives the agent an empty referenced
* workspace and hides the real cause. The caller catches the error and drops only that project.
* The function also throws when the managed checkout cannot be prepared (for example, a clone
* failure), so the caller can drop only that project.
*/
export async function resolveAdditionalProjectWorkspace(
input: {
companyId: string;
project: RunReferencedProject;
},
deps: ResolveAdditionalProjectWorkspaceDeps,
): Promise<ResolvedAdditionalWorkspace> {
const { companyId } = input;
const projectId = input.project.projectId;
const workspaceRows = await deps.loadProjectWorkspaceRows(companyId, projectId);
for (const workspace of workspaceRows) {
// A row realizes real content only through a configured checkout directory or a repository URL
// to clone. A row with neither can produce only an empty managed directory, so skip it here.
const configuredCwd = readNonEmptyString(workspace.cwd);
const hasConfiguredCwd = Boolean(configuredCwd) && configuredCwd !== REPO_ONLY_CWD_SENTINEL;
if (!hasConfiguredCwd && !readNonEmptyString(workspace.repoUrl)) {
continue;
}
const { cwd } = await deps.resolveConfiguredOrManagedProjectCwd({
companyId,
projectId,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
});
// A directory that exists but holds no content is not a realized workspace. Accept the row only
// when the resolved directory has real content, so an empty directory never masks a missing one.
if (await deps.directoryHasContents(cwd)) {
return {
cwd,
projectId,
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
};
}
}
// No configured checkout resolved to a directory with content. Clone a managed checkout only from a
// real source: the first workspace row that supplies a repository URL. Without a real source, do
// not fabricate an empty managed directory and report success. Throw instead, so the caller drops
// only this referenced project and adds a clear warning.
const fallbackRow = workspaceRows.find((row) => readNonEmptyString(row.repoUrl)) ?? null;
const fallbackRepoUrl = fallbackRow ? readNonEmptyString(fallbackRow.repoUrl) : null;
if (!fallbackRow || !fallbackRepoUrl) {
throw new Error(
`Referenced project ${projectId} has no workspace checkout or repository URL to realize.`,
);
}
const managed = await deps.ensureManagedProjectWorkspace({
companyId,
projectId,
repoUrl: fallbackRepoUrl,
});
return {
cwd: managed.cwd,
projectId,
workspaceId: fallbackRow.id,
repoUrl: fallbackRow.repoUrl,
repoRef: fallbackRow.repoRef,
};
}
type WorkspaceValidationFailureLike = WorkspaceValidationFailure | {
code: typeof WORKSPACE_VALIDATION_FAILURE_CODE;
resultJson: Record<string, unknown>;
@ -2102,6 +2238,19 @@ export interface ModelProfileApplication {
adapterConfig: Record<string, unknown> | null;
}
/**
* A single read-only referenced (mentioned) project workspace resolved for a run.
* The run materializes one entry per authorized additional project, each in its own
* managed checkout directory. See {@link resolveAdditionalRunWorkspaces}.
*/
export type ResolvedAdditionalWorkspace = {
cwd: string;
projectId: string;
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
};
export type ResolvedWorkspaceForRun = {
cwd: string;
source: "project_primary" | "task_session" | "agent_home";
@ -2116,8 +2265,45 @@ export type ResolvedWorkspaceForRun = {
repoRef: string | null;
}>;
warnings: string[];
/**
* Read-only referenced (mentioned) project workspaces for this run, one per authorized
* additional project. The array is empty unless the multi-project workspace-sync flag is on
* ({@link isMultiProjectWorkspaceSyncEnabled}); with the flag off the run resolves the anchor
* workspace only, exactly as before.
*/
additionalWorkspaces: ResolvedAdditionalWorkspace[];
};
/** The anchor workspace shape, before the additional referenced workspaces are attached. */
type ResolvedAnchorWorkspaceForRun = Omit<ResolvedWorkspaceForRun, "additionalWorkspaces">;
/**
* Build the plural workspace list that a run exposes to the agent through the
* `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor
* project's alternative workspace rows with the read-only referenced (mentioned)
* project workspaces, so every execution target receives the referenced project
* paths through the same channel the run already uses for the anchor project.
*
* Each referenced entry carries its `projectId` so the agent can tell which
* mentioned project a path belongs to. The referenced set is empty unless the
* multi-project workspace-sync flag is on, so the exposed list is byte-for-byte
* unchanged in the production default.
*/
export function buildRunWorkspaceHints(
resolved: Pick<ResolvedWorkspaceForRun, "workspaceHints" | "additionalWorkspaces">,
): Array<Record<string, unknown>> {
return [
...resolved.workspaceHints,
...resolved.additionalWorkspaces.map((additional) => ({
workspaceId: additional.workspaceId,
cwd: additional.cwd,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
projectId: additional.projectId,
})),
];
}
type ProjectWorkspaceCandidate = {
id: string;
};
@ -2146,6 +2332,17 @@ export function isMultiProjectWorkspaceSyncEnabled(
return isTruthyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_ENV]);
}
/**
* True when an environment driver runs the workspace on a non-local target. The `ssh`, `sandbox`,
* and `plugin` drivers each realize the workspace off the host, so a host-local directory path is
* not present on the target. This mirrors the remote-transport classification in
* {@link buildWorkspaceRealizationRecord}. The `local` driver (and an unknown/absent driver) is
* treated as local.
*/
export function isRemoteExecutionEnvironmentDriver(driver: string | null | undefined): boolean {
return driver === "ssh" || driver === "sandbox" || driver === "plugin";
}
/**
* Upper bound on how many additional (mentioned) projects a single run may materialize
* beyond the anchor. Bounds the fan-out of per-project authorization and workspace prep.
@ -2366,6 +2563,95 @@ export async function resolveRunReferencedProjects(
return { anchor, additional, warnings };
}
export interface ResolveAdditionalRunWorkspacesOptions {
/** Gate that mirrors {@link isMultiProjectWorkspaceSyncEnabled}. When false, the result is empty. */
enabled: boolean;
companyId: string;
/** The run actor; every additional project is authorized against this actor. */
actor: AuthorizationActor;
issues: Pick<ReturnType<typeof issueService>, "findMentionedProjectIds">;
projects: Pick<ReturnType<typeof projectService>, "listByIds">;
access: Pick<ReturnType<typeof authorizationService>, "decide">;
/** Resolve one authorized referenced project to its own workspace cwd (injectable for tests). */
resolveProjectWorkspace: (project: RunReferencedProject) => Promise<ResolvedAdditionalWorkspace>;
maxAdditionalProjects?: number;
maxCandidateEvaluations?: number;
/**
* True when the run executes on a non-local target (ssh, sandbox, or plugin). A referenced
* project realizes as a local directory only, and a remote target has no path yet to receive
* that tree, so a resolved cwd would not exist on the target. When true, the function skips
* referenced-project authorization and workspace work and returns no additional workspaces.
*/
executionTargetIsRemote?: boolean;
}
/**
* Resolve the read-only referenced (mentioned) project workspaces for a run.
*
* The function is inert until the multi-project workspace-sync flag is on: when `enabled` is
* false (the production default) or there is no issue, it returns an empty result and performs
* no authorization or workspace work. When enabled, it authorizes the referenced set through
* {@link resolveRunReferencedProjects} and resolves each admitted project to its own cwd. Each
* project resolves in isolation: a per-project failure drops only that project and appends a
* warning, so one bad clone never aborts the run.
*/
export async function resolveAdditionalRunWorkspaces(
issueId: string | null,
anchorProjectId: string | null,
opts: ResolveAdditionalRunWorkspacesOptions,
): Promise<{ additionalWorkspaces: ResolvedAdditionalWorkspace[]; warnings: string[] }> {
if (!opts.enabled || !issueId) {
return { additionalWorkspaces: [], warnings: [] };
}
// A referenced project realizes as a local directory only. A remote execution target (ssh,
// sandbox, or plugin) has no path yet to receive the referenced tree, so a resolved cwd would
// not exist on the target and the anchor-only remote sync never carries it across. Skip the
// referenced-project authorization and clone work on a remote target, so the run neither does
// work it must discard nor exposes an inaccessible referenced path to the agent. Warn only when
// the issue actually mentions a project, so a remote run without any referenced mention stays
// silent.
if (opts.executionTargetIsRemote) {
const mentionedIds = await opts.issues.findMentionedProjectIds(issueId, {
includeCommentBodies: true,
});
const hasReferencedMention = mentionedIds.some((projectId) => projectId !== anchorProjectId);
return {
additionalWorkspaces: [],
warnings: hasReferencedMention
? [
"Referenced-project workspaces are available only on a local execution target. This run uses a remote execution target, so no referenced-project workspace was attached.",
]
: [],
};
}
const referenced = await resolveRunReferencedProjects(issueId, anchorProjectId, {
companyId: opts.companyId,
actor: opts.actor,
issues: opts.issues,
projects: opts.projects,
access: opts.access,
maxAdditionalProjects: opts.maxAdditionalProjects,
maxCandidateEvaluations: opts.maxCandidateEvaluations,
});
const additionalWorkspaces: ResolvedAdditionalWorkspace[] = [];
const warnings = [...referenced.warnings];
for (const project of referenced.additional) {
try {
additionalWorkspaces.push(await opts.resolveProjectWorkspace(project));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
warnings.push(
`Referenced project ${project.projectId} was skipped because its workspace could not be prepared: ${reason}`,
);
}
}
return { additionalWorkspaces, warnings };
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
@ -7574,12 +7860,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
};
}
async function resolveWorkspaceForRun(
async function resolveAnchorWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null },
): Promise<ResolvedWorkspaceForRun> {
): Promise<ResolvedAnchorWorkspaceForRun> {
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const contextProjectId = readNonEmptyString(context.projectId);
const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId);
@ -7636,23 +7922,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
`Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`;
}
for (const workspace of projectWorkspaceRows) {
let projectCwd = readNonEmptyString(workspace.cwd);
let projectCwd: string;
let managedWorkspaceWarning: string | null = null;
if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) {
try {
const managedWorkspace = await ensureManagedProjectWorkspace({
companyId: agent.companyId,
projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId,
repoUrl: readNonEmptyString(workspace.repoUrl),
});
projectCwd = managedWorkspace.cwd;
managedWorkspaceWarning = managedWorkspace.warning;
} catch (error) {
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning = error instanceof Error ? error.message : String(error);
}
continue;
try {
const resolvedCwd = await resolveConfiguredOrManagedProjectCwd({
companyId: agent.companyId,
projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
});
projectCwd = resolvedCwd.cwd;
managedWorkspaceWarning = resolvedCwd.warning;
} catch (error) {
if (preferredWorkspace?.id === workspace.id) {
preferredWorkspaceWarning = error instanceof Error ? error.message : String(error);
}
continue;
}
hasConfiguredProjectCwd = true;
const projectCwdExists = await fs
@ -7782,6 +8067,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
};
}
/**
* Resolve the run workspace: the anchor workspace plus, when the multi-project workspace-sync
* flag is on, the read-only referenced (mentioned) project workspaces. With the flag off (the
* production default) the anchor path is unchanged and `additionalWorkspaces` is empty.
*/
async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
opts?: { useProjectWorkspace?: boolean | null; executionTargetIsRemote?: boolean },
): Promise<ResolvedWorkspaceForRun> {
const anchor = await resolveAnchorWorkspaceForRun(agent, context, previousSessionParams, opts);
if (!isMultiProjectWorkspaceSyncEnabled()) {
return { ...anchor, additionalWorkspaces: [] };
}
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const { additionalWorkspaces, warnings } = await resolveAdditionalRunWorkspaces(
issueId,
anchor.projectId,
{
enabled: true,
executionTargetIsRemote: opts?.executionTargetIsRemote ?? false,
companyId: agent.companyId,
actor: {
type: "agent",
agentId: agent.id,
companyId: agent.companyId,
source: "agent_key",
},
issues: issueService(db),
projects: projectService(db),
access: authorizationService(db),
resolveProjectWorkspace: (project) =>
resolveAdditionalProjectWorkspace(
{ companyId: agent.companyId, project },
defaultAdditionalProjectWorkspaceDeps(db),
),
},
);
return {
...anchor,
additionalWorkspaces,
warnings: warnings.length > 0 ? [...anchor.warnings, ...warnings] : anchor.warnings,
};
}
async function upsertTaskSession(input: {
companyId: string;
agentId: string;
@ -12807,7 +13140,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
agent,
context,
previousSessionParams,
{ useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
{
useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default",
// Referenced-project workspaces attach on a local execution target only. Gate their
// resolution on the selected environment driver so a remote run never resolves a
// referenced path it cannot reach. This never changes the anchor workspace.
executionTargetIsRemote: isRemoteExecutionEnvironmentDriver(
selectedEnvironmentForConfig?.driver,
),
},
),
});
const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({
@ -12822,6 +13163,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
additionalWorkspaces: resolvedWorkspace.additionalWorkspaces,
} satisfies ExecutionWorkspaceInput;
await assertGitWorktreeBaseWorkspaceReady({
requestedExecutionWorkspaceMode,
@ -13314,7 +13656,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return home;
})(),
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
context.paperclipWorkspaces = buildRunWorkspaceHints(resolvedWorkspace);
// The wake payload is built before the execution workspace is resolved, so
// attach the branch pin here; the shared wake-prompt renderer surfaces it as
// a one-time "stay on this branch" hint on non-resumed sessions.

View File

@ -37,7 +37,29 @@ function readPathAliases(value: unknown): Array<{ path: string; target: string }
});
}
function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null {
// Read the additional referenced (mentioned) project sources. Legacy payloads omit the field, so
// this defaults to an empty array. Each source needs a localPath; entries without one are dropped.
function readAdditionalSources(
value: unknown,
): NonNullable<WorkspaceRealizationRequest["additionalSources"]> {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const parsed = parseObject(entry);
const localPath = readString(parsed.localPath);
if (!localPath) return [];
return [
{
localPath,
projectId: readString(parsed.projectId),
projectWorkspaceId: readString(parsed.projectWorkspaceId),
repoUrl: readString(parsed.repoUrl),
repoRef: readString(parsed.repoRef),
},
];
});
}
export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null {
const parsed = parseObject(value);
if (parsed.version !== 1) return null;
const source = parseObject(parsed.source);
@ -72,6 +94,7 @@ function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRe
branchName: readString(source.branchName),
worktreePath: readString(source.worktreePath),
},
additionalSources: readAdditionalSources(parsed.additionalSources),
runtimeOverlay: {
provisionCommand: readString(runtimeOverlay.provisionCommand),
teardownCommand: readString(runtimeOverlay.teardownCommand),
@ -114,6 +137,19 @@ export function buildWorkspaceRealizationRequest(input: {
branchName: input.workspace.branchName,
worktreePath: input.workspace.worktreePath,
},
// The additional (referenced) sources carry the read-only referenced-project workspaces. Run
// preparation resolves them for a local execution target only and exposes each local path to
// the agent through the workspace-hints channel (`PAPERCLIP_WORKSPACES_JSON`). A remote target
// never receives a referenced source: run preparation skips referenced-project resolution on a
// remote target, so this array is empty there. The `sync` block below therefore realizes only
// the anchor source; a remote-transport sync of the referenced trees is not implemented yet.
additionalSources: (input.workspace.additionalWorkspaces ?? []).map((additional) => ({
localPath: additional.cwd,
projectId: additional.projectId,
projectWorkspaceId: additional.workspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
runtimeOverlay: {
provisionCommand: input.workspaceConfig?.provisionCommand ?? null,
teardownCommand: input.workspaceConfig?.teardownCommand ?? null,
@ -224,6 +260,13 @@ export function buildWorkspaceRealizationRecord(input: {
branchName: input.request.source.branchName,
worktreePath: input.request.source.worktreePath,
},
additional: (input.request.additionalSources ?? []).map((additional) => ({
path: additional.localPath,
projectId: additional.projectId,
projectWorkspaceId: additional.projectWorkspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
remote: {
path: remotePath,
...(host ? { host } : {}),

View File

@ -45,6 +45,19 @@ export function resolveShell(): string {
return shell;
}
/**
* A read-only referenced (mentioned) project workspace carried alongside the anchor. Additive and
* backward-compatible: it defaults to an empty array. Additional workspaces never get git-worktree
* realization; the anchor keeps the single scalar realization path.
*/
export interface ExecutionWorkspaceAdditionalInput {
cwd: string;
projectId: string;
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
}
export interface ExecutionWorkspaceInput {
baseCwd: string;
source: "project_primary" | "task_session" | "agent_home";
@ -52,6 +65,7 @@ export interface ExecutionWorkspaceInput {
workspaceId: string | null;
repoUrl: string | null;
repoRef: string | null;
additionalWorkspaces?: ExecutionWorkspaceAdditionalInput[];
}
export interface ExecutionWorkspaceIssueRef {
@ -2932,6 +2946,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
repoRef: input.workspace.baseRef ?? input.base.repoRef,
additionalWorkspaces: input.base.additionalWorkspaces ?? [],
strategy,
cwd,
branchName: input.workspace.branchName ?? null,