mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix: retain the original repository for reused task workspaces (#15528)
Apply the reviewed change for fix: retain the original repository for reused task workspaces. Validation: required local typecheck and tests, passing CI, Greptile 5/5, and independent review of the exact source head. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e4b39da6f6
commit
5717523b9e
9 files changed
+788
-12
No files matched your search
@@ -868,6 +868,8 @@ Agent, project, environment, secret, skill, and workspace config edits are sampl
|
||||
|
||||
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
|
||||
|
||||
An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback.
|
||||
|
||||
## Workspace Git Scan Protection
|
||||
|
||||
Paperclip applies one process-wide scheduler to expensive host-side workspace Git enumeration, including changed-file browsing, runtime/finalization cleanliness guards, and adapter sandbox-sync snapshots. The scheduler defaults to two active scans and a bounded queue of 32. Identical buffered scans of the same canonical worktree share one subprocess, while successful changed-file listings are cached for 10 seconds. Streaming snapshot scans have caller-owned sinks, so they use separate jobs in the same queue and are never cached or coalesced. Correctness-sensitive runtime guards bypass the result cache.
|
||||
|
||||
@@ -584,6 +584,17 @@ from 1 through 255. No paths, repository or branch names, IDs, command output, o
|
||||
arbitrary messages enter these fields. Validation still blocks reuse; it does
|
||||
not repair Git metadata, change the selected repository, or retry the task.
|
||||
|
||||
For explicit reuse of a retained Git workspace, `persisted_workspace_source_conflict`
|
||||
uses the same two fields with the fixed reason codes `source_scope_mismatch`,
|
||||
`explicit_project_workspace_conflict`, `source_path_unproven`,
|
||||
`source_registration_unproven`, `source_repository_mismatch`, or
|
||||
`source_repository_unavailable`. These distinguish an unverified original source
|
||||
from a missing Git registration. Paths, repository URLs, and workspace IDs stay
|
||||
out of the diagnostic fields. The task remains blocked until its original source
|
||||
is available or the owner intentionally selects a different workspace. A failed
|
||||
Git registration probe retains `git_inspection_failed` and the same bounded
|
||||
inspection fields, including when it occurs during original-source selection.
|
||||
|
||||
When available, the saved `workspaceRestoreDiagnostic` adds the bounded fields
|
||||
`workspaceRestorePhase`, `workspaceRestoreStep`, `workspaceRestoreErrorCode`,
|
||||
`workspaceRestoreHttpStatus`, and `workspaceRestoreExitCode` to `run_execution`.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
closeRegisteredClients,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environmentLeases,
|
||||
@@ -37,7 +39,8 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { buildEffectiveRunWorkspaceConfigMetadata, heartbeatService } from "../services/heartbeat.ts";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import {
|
||||
@@ -85,7 +88,7 @@ function fingerprintWorkspaceBranchIncoherenceForTest(input: {
|
||||
}
|
||||
|
||||
const adapterExecute = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
vi.fn(async (_input?: unknown) => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
@@ -109,7 +112,8 @@ vi.mock("../adapters/index.js", () => ({
|
||||
runningProcesses: new Map(),
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
|
||||
const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
@@ -871,8 +875,11 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl);
|
||||
else {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -921,13 +928,136 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
await db.delete(environments);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl);
|
||||
await db.$client.end();
|
||||
await tempDb?.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
async function seedRetainedSourceRun(kind: "deleted" | "explicit_conflict" | "foreign_project" | "foreign_company" | "unproven") {
|
||||
const home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-retained-source-home-")));
|
||||
tempRoots.push(home);
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
const original = await createGitRepo();
|
||||
const replacement = await createGitRepo();
|
||||
tempRoots.push(original, replacement);
|
||||
const companyId = randomUUID(), projectId = randomUUID(), oldWorkspaceId = randomUUID(), newWorkspaceId = randomUUID();
|
||||
const agentId = randomUUID(), issueId = randomUUID(), executionWorkspaceId = randomUUID(), runId = randomUUID(), wakeupId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const repoUrl = pathToFileURL(original).href;
|
||||
const replacementUrl = pathToFileURL(replacement).href;
|
||||
const owner = kind === "unproven" ? original : resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: path.basename(original) });
|
||||
if (owner !== original) {
|
||||
await mkdir(path.dirname(owner), { recursive: true });
|
||||
await runGit(original, ["clone", repoUrl, owner]);
|
||||
}
|
||||
const branch = "retained-task";
|
||||
const worktree = path.join(owner, ".paperclip", "worktrees", branch);
|
||||
await runGit(owner, ["worktree", "add", "-b", branch, worktree]);
|
||||
await writeFile(path.join(worktree, "retained.txt"), "unexported original task edits\n");
|
||||
await writeFile(path.join(replacement, "replacement.txt"), "replacement source must stay untouched\n");
|
||||
const snapshot = { provisionCommand: null, runtimeProvisionCommand: null };
|
||||
const fingerprint = buildEffectiveRunWorkspaceConfigMetadata({
|
||||
mode: "isolated_workspace", projectId, projectWorkspaceId: oldWorkspaceId, strategyType: "git_worktree",
|
||||
workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" }, repoUrl, repoRef: "HEAD", configSnapshot: snapshot,
|
||||
environment: null, realization: null,
|
||||
});
|
||||
const metadata = {
|
||||
createdByRuntime: false, config: snapshot,
|
||||
configFingerprint: { version: fingerprint.version, workspaceHash: fingerprint.fingerprint, categories: fingerprint.categories,
|
||||
categoryFingerprints: fingerprint.categoryFingerprints, lastEvaluatedAt: fingerprint.evaluatedAt },
|
||||
};
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix, status: "active", defaultResponsibleUserId: "responsible-user" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Repository replacement", status: "active",
|
||||
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" } } });
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{ id: oldWorkspaceId, companyId, projectId, name: "Original", cwd: owner, repoUrl: kind === "unproven" ? null : repoUrl, isPrimary: true },
|
||||
{ id: newWorkspaceId, companyId, projectId, name: "Replacement", cwd: replacement, repoUrl: replacementUrl, isPrimary: false },
|
||||
]);
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Source tester", role: "engineer", status: "idle", adapterType: "codex_local",
|
||||
adapterConfig: {}, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {} });
|
||||
await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, projectWorkspaceId: oldWorkspaceId,
|
||||
mode: "isolated_workspace", strategyType: "git_worktree", name: branch, status: "active", cwd: worktree, providerRef: worktree,
|
||||
providerType: "git_worktree", branchName: branch, repoUrl: kind === "unproven" ? null : repoUrl, baseRef: "HEAD", metadata });
|
||||
await db.insert(agentWakeupRequests).values({ id: wakeupId, companyId, agentId, source: "assignment", triggerDetail: "system",
|
||||
reason: "issue_assigned", payload: { issueId }, status: "queued", runId });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "assignment", triggerDetail: "system", status: "queued",
|
||||
wakeupRequestId: wakeupId, responsibleUserId: "responsible-user", contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_assigned" } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId: oldWorkspaceId, title: "Continue retained work",
|
||||
status: "in_progress", workMode: "standard", assigneeAgentId: agentId, responsibleUserId: "responsible-user", issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
checkoutRunId: runId, executionRunId: runId, executionAgentNameKey: "source-tester", executionLockedAt: new Date() });
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, executionWorkspaceId));
|
||||
// Exercise the real FK deletion semantics, rather than manufacturing nulls.
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, oldWorkspaceId));
|
||||
await db.update(projectWorkspaces).set({ isPrimary: true }).where(eq(projectWorkspaces.id, newWorkspaceId));
|
||||
if (kind === "explicit_conflict") await db.update(issues).set({ projectWorkspaceId: newWorkspaceId }).where(eq(issues.id, issueId));
|
||||
if (kind === "foreign_project" || kind === "foreign_company") {
|
||||
const foreignCompanyId = kind === "foreign_company" ? randomUUID() : companyId;
|
||||
if (foreignCompanyId !== companyId) await db.insert(companies).values({ id: foreignCompanyId, name: "Other company", issuePrefix: `F${foreignCompanyId.slice(0, 7)}` });
|
||||
const foreignProjectId = randomUUID();
|
||||
await db.insert(projects).values({ id: foreignProjectId, companyId: foreignCompanyId, name: "Other project", status: "active" });
|
||||
await db.update(executionWorkspaces).set({ companyId: foreignCompanyId, projectId: foreignProjectId }).where(eq(executionWorkspaces.id, executionWorkspaceId));
|
||||
}
|
||||
return { companyId, projectId, newWorkspaceId, issueId, agentId, runId, executionWorkspaceId, worktree, branch, owner, replacement, repoUrl, metadata };
|
||||
}
|
||||
|
||||
it("preserves a retained task source after its project repository is replaced and the original FK is deleted", async () => {
|
||||
const seeded = await seedRetainedSourceRun("deleted");
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const run = await waitForRunToFinish(heartbeat, seeded.runId);
|
||||
await heartbeat.waitForRunExecutionDrain(seeded.runId);
|
||||
expect(run).toMatchObject({ status: "succeeded" });
|
||||
expect(adapterExecute).toHaveBeenCalledTimes(1);
|
||||
expect(readAdapterWorkspace(adapterExecute.mock.calls[0]?.[0])).toEqual({ cwd: seeded.worktree, branchName: seeded.branch, executionWorkspaceId: seeded.executionWorkspaceId });
|
||||
expect(adapterExecute.mock.calls[0]?.[0]).toMatchObject({ context: { paperclipWorkspace: {
|
||||
projectId: seeded.projectId, workspaceId: null, repoUrl: seeded.repoUrl, repoRef: "HEAD", source: "task_session",
|
||||
} } });
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(workspace).toMatchObject({ companyId: seeded.companyId, projectId: seeded.projectId, projectWorkspaceId: null,
|
||||
cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, repoUrl: seeded.repoUrl, metadata: seeded.metadata });
|
||||
expect(issue).toMatchObject({ projectWorkspaceId: null, executionWorkspaceId: seeded.executionWorkspaceId, executionWorkspacePreference: "reuse_existing" });
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, seeded.companyId))).toHaveLength(1);
|
||||
expect(run?.resultJson).toMatchObject({ configFreshness: { workspace: { action: "replace", reuseRequested: true, workspaceReused: true, configSnapshotRefreshed: false,
|
||||
changedCategories: expect.arrayContaining(["projectWorkspace", "repo"]) } } });
|
||||
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
|
||||
expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n");
|
||||
expect(await readGit(seeded.worktree, ["branch", "--show-current"])).toBe(seeded.branch);
|
||||
expect(await readGit(seeded.owner, ["worktree", "list", "--porcelain"])).toContain(`worktree ${seeded.worktree}`);
|
||||
});
|
||||
|
||||
it.each(["explicit_conflict", "foreign_project", "foreign_company", "unproven"] as const)(
|
||||
"does not dispatch or rebind a retained source with %s authority", async (kind) => {
|
||||
const seeded = await seedRetainedSourceRun(kind);
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const run = await waitForRunToFinish(heartbeat, seeded.runId);
|
||||
await heartbeat.waitForRunExecutionDrain(seeded.runId);
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" });
|
||||
expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict",
|
||||
reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict"
|
||||
: kind === "unproven" ? "source_registration_unproven" : "source_scope_mismatch" } });
|
||||
expect(adapterExecute).not.toHaveBeenCalled();
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
|
||||
expect(workspace).toMatchObject({ projectWorkspaceId: null, cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, metadata: seeded.metadata });
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId,
|
||||
projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null });
|
||||
// Intentionally malformed cross-company/project FKs also prevent the
|
||||
// existing issue-update service from changing status. The source gate
|
||||
// still must fail before dispatch and may never silently repair that FK.
|
||||
if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked");
|
||||
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
|
||||
expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n");
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePersistedGitWorkspaceSource } from "../services/persisted-workspace-source.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "persisted-source-")));
|
||||
roots.push(root);
|
||||
const original = path.join(root, "original");
|
||||
const replacement = path.join(root, "replacement");
|
||||
const worktree = path.join(root, "task");
|
||||
for (const repo of [original, replacement]) {
|
||||
await exec("git", ["init", repo]);
|
||||
await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
|
||||
await exec("git", ["-C", repo, "remote", "add", "origin", `https://example.invalid/${path.basename(repo)}.git`]);
|
||||
}
|
||||
await exec("git", ["-C", original, "worktree", "add", "-b", "task", worktree]);
|
||||
await fs.writeFile(path.join(worktree, "retained.txt"), "keep this work\n");
|
||||
const workspace = {
|
||||
id: "execution-workspace", companyId: "company", projectId: "project", projectWorkspaceId: null,
|
||||
repoUrl: "https://example.invalid/original.git", baseRef: "HEAD", cwd: worktree, providerRef: worktree,
|
||||
};
|
||||
return { root, original, replacement, worktree, workspace, input: {
|
||||
companyId: "company", projectId: "project", explicitProjectWorkspaceId: null,
|
||||
workspace, boundProjectWorkspace: null, candidateBaseCwds: [original], managedSourceRoot: root,
|
||||
} };
|
||||
}
|
||||
|
||||
describe("persisted Git workspace source", () => {
|
||||
it("keeps the original registered source after its project binding is deleted", async () => {
|
||||
const { original, replacement, worktree, input } = await fixture();
|
||||
const source = await resolvePersistedGitWorkspaceSource(input);
|
||||
expect(source).toBe(original);
|
||||
expect(source).not.toBe(replacement);
|
||||
expect(input.workspace.projectWorkspaceId).toBeNull();
|
||||
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
|
||||
});
|
||||
|
||||
it.each(["companyId", "projectId"] as const)("rejects a foreign %s before source inspection or materialization", async field => {
|
||||
const { input } = await fixture();
|
||||
const materializeOriginalRepository = vi.fn();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, [field]: "foreign" },
|
||||
materializeOriginalRepository })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
|
||||
expect(materializeOriginalRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an explicit new project selection without rebinding the old task source", async () => {
|
||||
const { input } = await fixture();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, explicitProjectWorkspaceId: "replacement" })).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed", resultJson: { workspaceValidation: { reasonCode: "explicit_project_workspace_conflict" } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, { id: "bound", companyId: "foreign", projectId: "project", cwd: null },
|
||||
{ id: "bound", companyId: "company", projectId: "foreign", cwd: null }])("rejects an unverified non-null project binding (%j)", async binding => {
|
||||
const { input } = await fixture();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, projectWorkspaceId: "bound" },
|
||||
boundProjectWorkspace: binding })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
|
||||
});
|
||||
|
||||
it("rejects a supplied source binding when the saved association is null", async () => {
|
||||
const { original, input } = await fixture();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
boundProjectWorkspace: { id: "foreign-binding", companyId: "company", projectId: "project", cwd: original },
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_scope_mismatch" } } });
|
||||
});
|
||||
|
||||
it("does not trust another Git owner just because its origin matches", async () => {
|
||||
const { original, replacement, input } = await fixture();
|
||||
await exec("git", ["-C", replacement, "remote", "set-url", "origin", input.workspace.repoUrl]);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
|
||||
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
|
||||
});
|
||||
expect(original).not.toBe(replacement);
|
||||
});
|
||||
|
||||
it("uses the stored origin identity without transport-only URL rewrites", async () => {
|
||||
const { original, input } = await fixture();
|
||||
await exec("git", ["-C", original, "config", "url.ssh://git@example.invalid/.insteadOf", "https://example.invalid/"]);
|
||||
expect((await exec("git", ["-C", original, "remote", "get-url", "origin"])).stdout.trim()).not.toBe(input.workspace.repoUrl);
|
||||
expect(await resolvePersistedGitWorkspaceSource(input)).toBe(original);
|
||||
});
|
||||
|
||||
it("rejects a symlink that redirects a managed source into another repository", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const alias = path.join(root, "scoped", "original");
|
||||
await fs.mkdir(path.dirname(alias));
|
||||
await fs.symlink(original, alias);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [alias] })).rejects.toMatchObject({
|
||||
resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multiple origin URLs even if the last one matches the saved repository", async () => {
|
||||
const { original, input } = await fixture();
|
||||
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
|
||||
await exec("git", ["-C", original, "config", "--add", "remote.origin.url", input.workspace.repoUrl]);
|
||||
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
|
||||
reasonCode: "source_repository_mismatch",
|
||||
} } });
|
||||
});
|
||||
|
||||
it("rejects changed origin identity even at the recorded managed path", async () => {
|
||||
const { original, input } = await fixture();
|
||||
await exec("git", ["-C", original, "remote", "set-url", "origin", "https://example.invalid/other.git"]);
|
||||
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: { workspaceValidation: {
|
||||
reasonCode: "source_repository_mismatch",
|
||||
} } });
|
||||
});
|
||||
|
||||
it("refuses an independent clone masquerading as the selected linked worktree", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const clone = path.join(root, "clone");
|
||||
await exec("git", ["clone", original, clone]);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
|
||||
candidateBaseCwds: [clone] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
|
||||
});
|
||||
|
||||
it("refuses a primary checkout whose separate Git directory impersonates a managed base", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const fakeOwner = path.join(root, "fake-owner");
|
||||
const clone = path.join(root, "separate-checkout");
|
||||
await fs.mkdir(fakeOwner);
|
||||
await exec("git", ["clone", "--separate-git-dir", path.join(fakeOwner, ".git"), original, clone]);
|
||||
await exec("git", ["-C", clone, "remote", "set-url", "origin", input.workspace.repoUrl]);
|
||||
// This shape can appear in a complete worktree list, but has no per-worktree
|
||||
// storage under the owner's .git/worktrees directory.
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, cwd: clone, providerRef: clone },
|
||||
candidateBaseCwds: [fakeOwner] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
|
||||
});
|
||||
|
||||
it("preserves registered local sources whose configured cwd is a repository subfolder", async () => {
|
||||
const { original, input } = await fixture();
|
||||
const cwd = path.join(original, "subfolder");
|
||||
await fs.mkdir(cwd);
|
||||
expect(await resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
|
||||
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd },
|
||||
})).toBe(original);
|
||||
});
|
||||
|
||||
it("supports a bound local repository with separate Git storage", async () => {
|
||||
const { root, input } = await fixture();
|
||||
const owner = path.join(root, "separate-owner");
|
||||
const storage = path.join(root, "git-storage");
|
||||
const task = path.join(root, "separate-task");
|
||||
await exec("git", ["init", "--separate-git-dir", storage, owner]);
|
||||
await exec("git", ["-C", owner, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "initial"]);
|
||||
await exec("git", ["-C", owner, "worktree", "add", "-b", "retained", task]);
|
||||
expect(await resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, repoUrl: null, cwd: task, providerRef: task, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
|
||||
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: owner },
|
||||
})).toBe(owner);
|
||||
});
|
||||
|
||||
it("does not reconstruct a missing local worktree from its binding's replacement cwd", async () => {
|
||||
const { original, replacement, worktree, input } = await fixture();
|
||||
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
|
||||
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
|
||||
});
|
||||
|
||||
it("fails closed when a legacy local source lost its only project association", async () => {
|
||||
const { input } = await fixture();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, workspace: { ...input.workspace, repoUrl: null },
|
||||
candidateBaseCwds: [] })).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
|
||||
});
|
||||
|
||||
it("rejects a changed bound local cwd instead of adopting its new repository", async () => {
|
||||
const { replacement, input } = await fixture();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, repoUrl: null, projectWorkspaceId: "bound" }, candidateBaseCwds: [],
|
||||
boundProjectWorkspace: { id: "bound", companyId: "company", projectId: "project", cwd: replacement },
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_registration_unproven" } } });
|
||||
});
|
||||
|
||||
it("materializes only the recorded original source when both managed checkout and worktree are absent", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const restored = path.join(root, "restored-source");
|
||||
const missingWorktree = path.join(root, "missing-worktree");
|
||||
const materializeOriginalRepository = vi.fn(async () => {
|
||||
await exec("git", ["clone", original, restored]);
|
||||
await exec("git", ["-C", restored, "remote", "set-url", "origin", input.workspace.repoUrl]);
|
||||
return restored;
|
||||
});
|
||||
expect(await resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
|
||||
candidateBaseCwds: [restored], materializeOriginalRepository,
|
||||
})).toBe(restored);
|
||||
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a materializer result outside the retained source candidates", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const missingWorktree = path.join(root, "missing-worktree");
|
||||
const materializeOriginalRepository = vi.fn(async () => original);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
|
||||
candidateBaseCwds: [path.join(root, "expected-clone")], materializeOriginalRepository,
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
|
||||
expect(materializeOriginalRepository).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not invoke materialization through a managed project symlink", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const scoped = path.join(root, "scoped");
|
||||
await fs.symlink(original, scoped);
|
||||
const missingWorktree = path.join(root, "missing-worktree");
|
||||
const materializeOriginalRepository = vi.fn(async () => original);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, cwd: missingWorktree, providerRef: missingWorktree },
|
||||
candidateBaseCwds: [path.join(scoped, "new-clone")], materializeOriginalRepository,
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } } });
|
||||
expect(materializeOriginalRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps bounded Git inspection evidence before restoring a retained source (missing=%s)", async missing => {
|
||||
const { root, original, worktree, input } = await fixture();
|
||||
if (missing) await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
|
||||
const realGit = (await exec("/bin/sh", ["-c", "command -v git"])).stdout.trim();
|
||||
const bin = path.join(root, "bin");
|
||||
await fs.mkdir(bin);
|
||||
await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\nconst args=process.argv.slice(2);\nif(args[0]==="worktree"&&args[1]==="list"){process.stderr.write("private-provider-output /private/path");process.exitCode=128;}else{const r=require("node:child_process").spawnSync(${JSON.stringify(realGit)},args,{stdio:"inherit"});process.exitCode=r.status??1;}\n`, { mode: 0o755 });
|
||||
vi.stubEnv("PATH", `${bin}${path.delimiter}${process.env.PATH ?? ""}`);
|
||||
const materializeOriginalRepository = vi.fn();
|
||||
const failure = await resolvePersistedGitWorkspaceSource({ ...input, materializeOriginalRepository }).catch(error => error);
|
||||
expect(failure).toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: {
|
||||
reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed",
|
||||
inspectionDiagnostic: { command: "worktree_list", failure: "nonzero_exit", exitCode: 128 },
|
||||
} } });
|
||||
expect(JSON.stringify(failure.resultJson)).not.toContain("private");
|
||||
expect(materializeOriginalRepository).not.toHaveBeenCalled();
|
||||
if (!missing) expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
|
||||
});
|
||||
|
||||
it("rebuilds a missing worktree only from the retained source, not a new primary", async () => {
|
||||
const { original, replacement, worktree, input } = await fixture();
|
||||
await exec("git", ["-C", original, "worktree", "remove", "--force", worktree]);
|
||||
expect(await resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement, original] })).toBe(original);
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input, candidateBaseCwds: [replacement] })).rejects.toMatchObject({
|
||||
resultJson: { workspaceValidation: { reasonCode: "source_repository_unavailable" } },
|
||||
});
|
||||
});
|
||||
it("accepts a symlink for the configured instance root only", async () => {
|
||||
const { root, original, input } = await fixture();
|
||||
const alias = `${root}-alias`;
|
||||
roots.push(alias);
|
||||
await fs.symlink(root, alias);
|
||||
expect(await resolvePersistedGitWorkspaceSource({ ...input, managedSourceRoot: alias,
|
||||
candidateBaseCwds: [path.join(alias, "original")] })).toBe(original);
|
||||
});
|
||||
|
||||
it("mismatched saved paths never materialize another source", async () => {
|
||||
const { replacement, input } = await fixture();
|
||||
const materializeOriginalRepository = vi.fn();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, providerRef: replacement }, materializeOriginalRepository,
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
|
||||
expect(materializeOriginalRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a dangling saved-worktree symlink is not replaced", async () => {
|
||||
const { root, input } = await fixture();
|
||||
const dangling = path.join(root, "dangling-task");
|
||||
await fs.symlink(path.join(root, "absent-target"), dangling);
|
||||
const materializeOriginalRepository = vi.fn();
|
||||
await expect(resolvePersistedGitWorkspaceSource({ ...input,
|
||||
workspace: { ...input.workspace, cwd: dangling, providerRef: dangling }, materializeOriginalRepository,
|
||||
})).rejects.toMatchObject({ resultJson: { workspaceValidation: { reasonCode: "source_path_unproven" } } });
|
||||
expect(materializeOriginalRepository).not.toHaveBeenCalled();
|
||||
expect((await fs.lstat(dangling)).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("a substituted worktree backlink cannot authorize reuse", async () => {
|
||||
const { original, worktree, input } = await fixture();
|
||||
const storage = (await exec("git", ["-C", worktree, "rev-parse", "--absolute-git-dir"])).stdout.trim();
|
||||
await fs.writeFile(path.join(storage, "gitdir"), path.join(original, ".git") + "\n");
|
||||
await expect(resolvePersistedGitWorkspaceSource(input)).rejects.toMatchObject({ resultJson: {
|
||||
workspaceValidation: { reasonCode: "source_registration_unproven" },
|
||||
} });
|
||||
expect(await fs.readFile(path.join(worktree, "retained.txt"), "utf8")).toBe("keep this work\n");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -49,6 +49,30 @@ describe("run failure diagnostics", () => {
|
||||
expect(inspect({ ...base, inspectionDiagnostic: { command: "worktree_list", failure: "spawn_failed", errorCode: "EACCES", exitCode: 128 } })).toMatchObject({ workspaceValidationInspectionErrorCode: "EACCES" });
|
||||
});
|
||||
|
||||
it.each(["source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven",
|
||||
"source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable"])(
|
||||
"exports only the fixed retained-source reason %s", reasonCode => {
|
||||
const workspaceValidation = { reason: "persisted_workspace_source_conflict", reasonCode,
|
||||
executionWorkspaceId: "private-id", repoUrl: "private-url", cwd: "/private-path", message: "private-message" };
|
||||
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution)
|
||||
.toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict", workspaceValidationReasonCode: reasonCode });
|
||||
expect(collectRunFailureDiagnostics(run({ errorCode: "adapter_failed", resultJson: { workspaceValidation } }), {}).execution).toEqual({});
|
||||
},
|
||||
);
|
||||
|
||||
it("omits arbitrary retained-source reason strings and hostile getters", () => {
|
||||
for (const reasonCode of ["private-url", Object.defineProperty({}, "value", { get() { throw new Error("private"); } })]) {
|
||||
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation: {
|
||||
reason: "persisted_workspace_source_conflict", reasonCode,
|
||||
} } }), {}).execution).toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" });
|
||||
}
|
||||
const workspaceValidation = Object.defineProperty({ reason: "persisted_workspace_source_conflict" }, "reasonCode", {
|
||||
get() { throw new Error("private"); },
|
||||
});
|
||||
expect(collectRunFailureDiagnostics(run({ errorCode: "workspace_validation_failed", resultJson: { workspaceValidation } }), {}).execution)
|
||||
.toEqual({ workspaceValidationReason: "persisted_workspace_source_conflict" });
|
||||
});
|
||||
|
||||
it("exports bounded orphan evidence only for a process-loss failure", () => {
|
||||
const processLossDiagnostic = { pidRecorded: false, groupRecorded: false, localCheck: "no_identifiers",
|
||||
retryEligible: false, runPredatesObserver: true, observerUptimeMs: 30_000, lastOutputAgeMs: 120_000,
|
||||
|
||||
@@ -434,6 +434,7 @@ import {
|
||||
type UnresolvedWorkspaceBaseRefError,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { resolvePersistedGitWorkspaceSource } from "./persisted-workspace-source.js";
|
||||
import {
|
||||
readManagedWorktreeInstanceOwnership,
|
||||
WORKTREE_INSTANCE_ROOT_METADATA_KEY,
|
||||
@@ -3947,6 +3948,8 @@ export type ResolvedWorkspaceForRun = {
|
||||
materializationFailures: WorkspaceMaterializationFailure[];
|
||||
/** True only for an explicitly configured local project path without a repository URL. */
|
||||
localPathOnlyWorkspace?: boolean;
|
||||
/** Current configuration for drift reporting; never an alternative restore source. */
|
||||
freshnessSource?: { projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null };
|
||||
/**
|
||||
* 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
|
||||
@@ -12601,6 +12604,85 @@ export function heartbeatService(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReusedGitWorkspaceAnchor(input: {
|
||||
agent: typeof agents.$inferSelect;
|
||||
workspace: ExecutionWorkspace;
|
||||
projectId: string | null;
|
||||
explicitProjectWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
runId: string;
|
||||
responsibleUserId: string | null;
|
||||
immutableNativeBinding: boolean;
|
||||
}): Promise<ResolvedAnchorWorkspaceForRun> {
|
||||
const { workspace, agent } = input;
|
||||
// Configuration preparation may have awaited credentials and skills since
|
||||
// issueRef was read. Match the ordinary anchor resolver's fresh selection
|
||||
// check; only an already-admitted native input owns immutable source scope.
|
||||
const currentIssueSource = input.issueId && !input.immutableNativeBinding
|
||||
? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues)
|
||||
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null)
|
||||
: null;
|
||||
const sourceProjectId = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId;
|
||||
const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId;
|
||||
const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and(
|
||||
eq(projectWorkspaces.companyId, agent.companyId),
|
||||
eq(projectWorkspaces.projectId, workspace.projectId),
|
||||
)).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null;
|
||||
const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0];
|
||||
const managedBase = workspace.repoUrl ? resolveManagedProjectWorkspaceDir({
|
||||
companyId: agent.companyId,
|
||||
projectId: workspace.projectId,
|
||||
repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl),
|
||||
}) : null;
|
||||
const candidateBaseCwds = [
|
||||
managedBase,
|
||||
managedBase && workspace.repoUrl
|
||||
? `${managedBase}-${createHash("sha256").update(workspace.repoUrl).digest("hex").slice(0, 12)}` : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const cwd = await resolvePersistedGitWorkspaceSource({
|
||||
companyId: agent.companyId,
|
||||
projectId: sourceProjectId,
|
||||
explicitProjectWorkspaceId,
|
||||
workspace,
|
||||
boundProjectWorkspace,
|
||||
candidateBaseCwds,
|
||||
managedSourceRoot: resolvePaperclipInstanceRoot(),
|
||||
materializeOriginalRepository: workspace.repoUrl ? async () => {
|
||||
const original = await ensureManagedProjectWorkspace({
|
||||
companyId: agent.companyId,
|
||||
projectId: workspace.projectId,
|
||||
repoUrl: workspace.repoUrl,
|
||||
resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, {
|
||||
issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id,
|
||||
responsibleUserId: input.responsibleUserId,
|
||||
}),
|
||||
});
|
||||
return original.cwd;
|
||||
} : undefined,
|
||||
});
|
||||
return {
|
||||
cwd,
|
||||
source: "task_session",
|
||||
projectId: workspace.projectId,
|
||||
workspaceId: workspace.projectWorkspaceId,
|
||||
repoUrl: workspace.repoUrl,
|
||||
repoRef: workspace.baseRef,
|
||||
// Freshness must still expose drift in current project configuration.
|
||||
// This snapshot authorizes no filesystem lookup or repository fallback.
|
||||
freshnessSource: {
|
||||
projectId: workspace.projectId,
|
||||
workspaceId: configuredSource?.id ?? null,
|
||||
repoUrl: configuredSource?.repoUrl ?? null,
|
||||
repoRef: configuredSource?.repoRef ?? null,
|
||||
},
|
||||
workspaceHints: [],
|
||||
warnings: [],
|
||||
baseCwdFallback: false,
|
||||
materializationFailures: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAnchorWorkspaceForRun(
|
||||
agent: typeof agents.$inferSelect,
|
||||
context: Record<string, unknown>,
|
||||
@@ -22288,6 +22370,22 @@ export function heartbeatService(
|
||||
{
|
||||
useProjectWorkspace:
|
||||
requestedExecutionWorkspaceMode !== "agent_default",
|
||||
anchorWorkspace: requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
|
||||
? await resolveReusedGitWorkspaceAnchor({
|
||||
agent,
|
||||
workspace: reusableExistingExecutionWorkspace,
|
||||
responsibleUserId,
|
||||
immutableNativeBinding: Boolean(nativeRecoveryExecutionWorkspaceId),
|
||||
projectId: nativeRecoveryExecutionWorkspaceId
|
||||
? reusableExistingExecutionWorkspace.projectId
|
||||
: issueRef?.projectId ?? readNonEmptyString(context.projectId),
|
||||
explicitProjectWorkspaceId: nativeRecoveryExecutionWorkspaceId
|
||||
? reusableExistingExecutionWorkspace.projectWorkspaceId
|
||||
: readNonEmptyString(context.projectWorkspaceId),
|
||||
issueId,
|
||||
runId: run.id,
|
||||
})
|
||||
: undefined,
|
||||
// Thread the selected environment driver so run-workspace resolution can tell a local
|
||||
// target from a remote one, and a confined sandbox target from an unconfined remote
|
||||
// target. A remote run resolves referenced projects only for the confined sandbox
|
||||
@@ -22371,17 +22469,18 @@ export function heartbeatService(
|
||||
trustPreset: trustPreset.kind,
|
||||
lowTrustSandboxDriver: lowTrustPreflightEnvironmentDriver,
|
||||
};
|
||||
const workspaceFreshnessSource = resolvedWorkspace.freshnessSource ?? executionWorkspaceBase;
|
||||
const latestWorkspaceConfigMetadata =
|
||||
buildEffectiveRunWorkspaceConfigMetadata({
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
projectId: executionWorkspaceBase.projectId,
|
||||
projectWorkspaceId: executionWorkspaceBase.workspaceId,
|
||||
projectId: workspaceFreshnessSource.projectId,
|
||||
projectWorkspaceId: workspaceFreshnessSource.workspaceId,
|
||||
strategyType: latestWorkspaceStrategyType,
|
||||
workspaceStrategy: workspaceStrategyFingerprintValue,
|
||||
repoUrl: executionWorkspaceBase.repoUrl,
|
||||
repoUrl: workspaceFreshnessSource.repoUrl,
|
||||
repoRef:
|
||||
readNonEmptyString(workspaceStrategyForFingerprint.baseRef) ??
|
||||
executionWorkspaceBase.repoRef,
|
||||
workspaceFreshnessSource.repoRef,
|
||||
configSnapshot,
|
||||
environment: workspaceEnvironmentFingerprint,
|
||||
realization: workspaceRealizationFingerprint,
|
||||
@@ -22560,7 +22659,9 @@ export function heartbeatService(
|
||||
executionProjectId ??
|
||||
null;
|
||||
const resolvedProjectWorkspaceId =
|
||||
issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
|
||||
? reusableExistingExecutionWorkspace.projectWorkspaceId
|
||||
: issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
|
||||
let issueExecutionWorkspaceIdForRun =
|
||||
issueRef?.executionWorkspaceId ?? null;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { inspectManagedGitWorktreeBranch, WorkspaceRuntimeValidationFailure } from "./workspace-runtime.js";
|
||||
import type { ManagedGitInspectionDiagnostic, PersistedWorkspaceSourceReasonCode } from "./workspace-validation-diagnostics.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
type SourceWorkspace = Pick<ExecutionWorkspace,
|
||||
"id" | "companyId" | "projectId" | "projectWorkspaceId" | "repoUrl" | "baseRef" | "cwd" | "providerRef"
|
||||
>;
|
||||
|
||||
function fail(workspace: SourceWorkspace, reasonCode: PersistedWorkspaceSourceReasonCode): never {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
"The selected execution workspace's original repository could not be verified. "
|
||||
+ "Restore its original project source. Before intentionally clearing the task's existing-workspace binding to choose another repository, review and preserve its retained work.",
|
||||
{ workspaceValidation: { reason: "persisted_workspace_source_conflict", reasonCode, executionWorkspaceId: workspace.id } },
|
||||
);
|
||||
}
|
||||
|
||||
function failInspection(workspace: SourceWorkspace, diagnostic: ManagedGitInspectionDiagnostic): never {
|
||||
throw new WorkspaceRuntimeValidationFailure(
|
||||
"The original repository's complete Git worktree registration could not be inspected. Inspect the source before retrying; its workspace binding was preserved.",
|
||||
{ workspaceValidation: { reason: "git_worktree_not_reusable", reasonCode: "git_inspection_failed",
|
||||
executionWorkspaceId: workspace.id, inspectionDiagnostic: diagnostic } },
|
||||
);
|
||||
}
|
||||
|
||||
async function canonicalDirectory(value: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await fs.realpath(value);
|
||||
return (await fs.stat(resolved)).isDirectory() ? resolved : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the stored identity, not `remote get-url` which expands transport-only
|
||||
// insteadOf settings (including the managed SSH-to-HTTPS credential rewrite).
|
||||
async function readOrigin(cwd: string): Promise<string | null> {
|
||||
return exec("git", ["config", "--local", "--null", "--get-all", "remote.origin.url"], {
|
||||
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
|
||||
}).then(result => {
|
||||
const urls = result.stdout.split("\0");
|
||||
if (urls.at(-1) === "") urls.pop();
|
||||
// Fetch uses the first URL, while a scalar config read can select the last.
|
||||
// Ambiguous origins are not proof that this is the recorded repository.
|
||||
return urls.length === 1 ? urls[0]!.trim() || null : null;
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async function gitDirectory(cwd: string, flag: "--absolute-git-dir" | "--git-common-dir"): Promise<string | null> {
|
||||
const directory = await exec("git", ["rev-parse", "--path-format=absolute", flag], {
|
||||
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
|
||||
}).then(result => result.stdout.trim() || null).catch(() => null);
|
||||
return directory ? canonicalDirectory(directory) : null;
|
||||
}
|
||||
|
||||
async function hasLinkedWorktreeStorage(cwd: string, owner: string): Promise<boolean> {
|
||||
const [storage, common, ownerCommon] = await Promise.all([
|
||||
gitDirectory(cwd, "--absolute-git-dir"), gitDirectory(cwd, "--git-common-dir"), gitDirectory(owner, "--git-common-dir"),
|
||||
]);
|
||||
if (!storage || !common || common !== ownerCommon) return false;
|
||||
const worktrees = await canonicalDirectory(path.join(common, "worktrees"));
|
||||
if (!worktrees || path.dirname(storage) !== worktrees) return false;
|
||||
try {
|
||||
const backlinkPath = path.join(storage, "gitdir");
|
||||
if ((await fs.stat(backlinkPath)).size > 16 * 1024) return false;
|
||||
const backlink = (await fs.readFile(backlinkPath, "utf8")).trim();
|
||||
return await fs.realpath(backlink) === await fs.realpath(path.join(cwd, ".git"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBoundLocalOwner(cwd: string): Promise<string | null> {
|
||||
const root = await exec("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, maxBuffer: 16 * 1024,
|
||||
}).then(result => result.stdout.trim() || null).catch(() => null);
|
||||
return root ? canonicalDirectory(root) : null;
|
||||
}
|
||||
|
||||
async function canMaterializeManagedCandidate(root: string, candidate: string): Promise<boolean> {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
|
||||
if (!await canonicalDirectory(root)) return false;
|
||||
let current = root;
|
||||
for (const part of relative.split(path.sep)) {
|
||||
current = path.join(current, part);
|
||||
try {
|
||||
const entry = await fs.lstat(current);
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) return false;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Source candidates come from scoped project state and the instance's managed path generator, never Git output. */
|
||||
export async function resolvePersistedGitWorkspaceSource(input: {
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
explicitProjectWorkspaceId: string | null;
|
||||
workspace: SourceWorkspace;
|
||||
boundProjectWorkspace: { id: string; companyId: string; projectId: string; cwd: string | null } | null;
|
||||
candidateBaseCwds: string[];
|
||||
managedSourceRoot: string;
|
||||
materializeOriginalRepository?: () => Promise<string>;
|
||||
}): Promise<string> {
|
||||
const workspace = input.workspace;
|
||||
if (workspace.companyId !== input.companyId || workspace.projectId !== input.projectId) {
|
||||
fail(workspace, "source_scope_mismatch");
|
||||
}
|
||||
const binding = input.boundProjectWorkspace;
|
||||
if ((workspace.projectWorkspaceId && !binding) || (binding && (binding.id !== workspace.projectWorkspaceId
|
||||
|| binding.companyId !== input.companyId || binding.projectId !== workspace.projectId))) {
|
||||
fail(workspace, "source_scope_mismatch");
|
||||
}
|
||||
if (input.explicitProjectWorkspaceId && input.explicitProjectWorkspaceId !== workspace.projectWorkspaceId) {
|
||||
fail(workspace, "explicit_project_workspace_conflict");
|
||||
}
|
||||
const cwd = workspace.providerRef ?? workspace.cwd;
|
||||
if (!cwd || (workspace.cwd && workspace.providerRef && path.resolve(workspace.cwd) !== path.resolve(workspace.providerRef))) {
|
||||
fail(workspace, "source_path_unproven");
|
||||
}
|
||||
const roots = async () => {
|
||||
const canonicalRoot = await canonicalDirectory(input.managedSourceRoot);
|
||||
if (!canonicalRoot) return new Set<string>();
|
||||
const candidates = await Promise.all(input.candidateBaseCwds.map(async candidate => {
|
||||
const relative = path.relative(input.managedSourceRoot, candidate);
|
||||
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
||||
const resolved = await canonicalDirectory(candidate);
|
||||
// The configured instance root may be a symlink. A project or repository
|
||||
// below it may not redirect the scoped managed identity elsewhere.
|
||||
return resolved === path.join(canonicalRoot, relative) ? resolved : null;
|
||||
}));
|
||||
return new Set(candidates.filter((value): value is string => value !== null));
|
||||
};
|
||||
const knownRoots = await roots();
|
||||
if (binding?.cwd) {
|
||||
// A configured local path may be a repository subfolder or another linked
|
||||
// checkout. Resolve its Git owner as the old restore path already did.
|
||||
const localOwner = await resolveBoundLocalOwner(binding.cwd);
|
||||
if (localOwner) knownRoots.add(localOwner);
|
||||
}
|
||||
const existing = await canonicalDirectory(cwd);
|
||||
const sourceEntry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
return fail(workspace, "source_path_unproven");
|
||||
});
|
||||
if (sourceEntry && !existing) fail(workspace, "source_path_unproven");
|
||||
if (existing) {
|
||||
// An independent clone (including a stand-alone checkout with separate Git
|
||||
// storage) is not a linked worktree authorized by the persisted binding.
|
||||
const dotGit = await fs.lstat(path.join(existing, ".git")).catch(() => null);
|
||||
if (!dotGit?.isFile()) fail(workspace, "source_registration_unproven");
|
||||
let repositoryMismatch = false;
|
||||
let failedInspection: ManagedGitInspectionDiagnostic | null = null;
|
||||
for (const owner of knownRoots) {
|
||||
if (owner === existing) continue;
|
||||
const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: existing, repoRoot: owner, expectedBranchName: null });
|
||||
if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic;
|
||||
if (!inspection.valid || !await hasLinkedWorktreeStorage(existing, owner)) continue;
|
||||
if (workspace.repoUrl && await readOrigin(owner) !== workspace.repoUrl.trim()) {
|
||||
repositoryMismatch = true;
|
||||
continue;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
if (failedInspection) failInspection(workspace, failedInspection);
|
||||
fail(workspace, repositoryMismatch ? "source_repository_mismatch" : "source_registration_unproven");
|
||||
}
|
||||
|
||||
// A mutable local project cwd alone cannot prove the repository that owned a
|
||||
// now-missing worktree. Do not reconstruct historical work from its new cwd.
|
||||
if (!workspace.repoUrl) fail(workspace, "source_repository_unavailable");
|
||||
|
||||
// Reconstruct only from a source authorized by the old binding. A new project
|
||||
// primary is never a fallback for a missing historical worktree.
|
||||
let failedInspection: ManagedGitInspectionDiagnostic | null = null;
|
||||
for (const candidate of knownRoots) {
|
||||
if (workspace.repoUrl && await readOrigin(candidate) !== workspace.repoUrl.trim()) continue;
|
||||
const inspection = await inspectManagedGitWorktreeBranch({ worktreePath: candidate, repoRoot: candidate, expectedBranchName: null });
|
||||
if (inspection.valid) return candidate;
|
||||
if (inspection.inspectionDiagnostic) failedInspection ??= inspection.inspectionDiagnostic;
|
||||
}
|
||||
if (failedInspection) failInspection(workspace, failedInspection);
|
||||
if (workspace.repoUrl && input.materializeOriginalRepository) {
|
||||
if (input.candidateBaseCwds.length === 0 || !(await Promise.all(input.candidateBaseCwds.map(
|
||||
candidate => canMaterializeManagedCandidate(input.managedSourceRoot, candidate),
|
||||
))).every(Boolean)) fail(workspace, "source_repository_unavailable");
|
||||
const materialized = await input.materializeOriginalRepository();
|
||||
const owner = await canonicalDirectory(materialized);
|
||||
const expectedRoots = await roots();
|
||||
if (owner && expectedRoots.has(owner) && await readOrigin(owner) === workspace.repoUrl.trim()) return owner;
|
||||
}
|
||||
fail(workspace, "source_repository_unavailable");
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/w
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js";
|
||||
import { MANAGED_GIT_WORKTREE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
|
||||
import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
type Context = Record<string, string | number | boolean>;
|
||||
@@ -162,6 +162,11 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
|
||||
if (diagnostic.exitCode !== undefined) execution.workspaceValidationInspectionExitCode = diagnostic.exitCode;
|
||||
}
|
||||
}
|
||||
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "persisted_workspace_source_conflict") {
|
||||
execution.workspaceValidationReason = "persisted_workspace_source_conflict";
|
||||
const reasonCode = PERSISTED_WORKSPACE_SOURCE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
|
||||
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
|
||||
}
|
||||
if (run.errorCode === "process_lost") {
|
||||
const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic"));
|
||||
for (const [field, value] of Object.entries(diagnostic)) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export const PERSISTED_WORKSPACE_SOURCE_REASON_CODES = [
|
||||
"source_scope_mismatch", "explicit_project_workspace_conflict", "source_path_unproven",
|
||||
"source_registration_unproven", "source_repository_mismatch", "source_repository_unavailable",
|
||||
] as const;
|
||||
|
||||
export type PersistedWorkspaceSourceReasonCode = typeof PERSISTED_WORKSPACE_SOURCE_REASON_CODES[number];
|
||||
|
||||
export const MANAGED_GIT_WORKTREE_REASON_CODES = [
|
||||
"missing_worktree", "not_a_git_checkout", "not_registered",
|
||||
"wrong_repository_root", "branch_mismatch", "git_inspection_failed",
|
||||
|
||||
Reference in new issue
Block a user