mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
fix(workspaces): name the owner action when a shared source is restricted (#16545)
A reused task workspace in a shared project checkout keeps its source denial when another task's restricted worktree shares that checkout (#16095 deliberately does not clone over existing workspaces). The run then failed as an opaque `setup_failed` 403 and the task was blocked with a generic "no live execution path" notice. Convert that exact denial into a `configuration_incomplete` failure with reason `workspace_source_restricted`, give the task notice and the recovery action the owner step, and keep this pre-dispatch owner action out of the unexpected Sentry run-failure stream. The reason and predicate live in a dependency-free module. Access checks are unchanged. Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+175
-5
No files matched your search
@@ -51,6 +51,11 @@ New implicit Git workspace defaults record their project-source provenance.
|
||||
An untyped checkout binding from an older task is ambiguous and keeps the denial,
|
||||
including explicit checkout IDs and inherited parent sources. Admission rechecks
|
||||
the current binding rather than inferring a default from the selected checkout ID.
|
||||
When the denial is retained, the run stops before the agent starts with a
|
||||
`configuration_incomplete` failure (reason `workspace_source_restricted`). The task
|
||||
is blocked with a notice that tells the owner to push unpublished work and select a
|
||||
new workspace, or to grant access to the restricted task, then retry. Paperclip
|
||||
does not report this owner action to Sentry as an unexpected run failure.
|
||||
Startup validation of these recorded defaults keeps the task's effective workspace
|
||||
strategy. It does not substitute the agent's shared strategy or grant project writes.
|
||||
Workspace close and reopen use the recorded isolated source. Both operations
|
||||
|
||||
@@ -51,6 +51,9 @@ retain the original denial without cloning or replacing their unpublished commit
|
||||
New implicit Git defaults record project-source provenance. Untyped explicit,
|
||||
inherited and older ambiguous checkout bindings retain the denial; admission reads
|
||||
the current company-scoped binding before considering the fallback.
|
||||
A retained denial blocks the task as `configuration_incomplete`
|
||||
(`workspace_source_restricted`) before dispatch; it grants no access and names the
|
||||
owner action on the task.
|
||||
Local paths, unsupported transports and credential-bearing URLs are not fallback
|
||||
sources. Company/task/remote bindings, real paths, Git storage and the exact origin
|
||||
are revalidated for reuse, cleanup and reopening. A failed save removes only the
|
||||
|
||||
@@ -46,6 +46,7 @@ import { buildEffectiveRunWorkspaceConfigMetadata, heartbeatService } from "../s
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.ts";
|
||||
import {
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
@@ -101,6 +102,13 @@ const adapterExecute = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../sentry.ts", async () => {
|
||||
const actual = await vi.importActual<typeof import("../sentry.ts")>("../sentry.ts");
|
||||
return { ...actual, captureRunFailure: mockCaptureRunFailure };
|
||||
});
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
getServerAdapter: () => ({
|
||||
type: "codex_local",
|
||||
@@ -1067,6 +1075,39 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks a reused shared-checkout workspace with the owner action once a restricted task worktree shares it", async () => {
|
||||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||||
const seeded = await seedRetainedSourceRun("deleted");
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: seeded.companyId, projectId: seeded.projectId,
|
||||
title: "Private work", visibility: "private" }).returning();
|
||||
const privateCwd = path.join(seeded.owner, ".paperclip", "worktrees", "restricted-task");
|
||||
await runGit(seeded.owner, ["worktree", "add", "-b", "restricted-task", privateCwd]);
|
||||
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: seeded.companyId, projectId: seeded.projectId,
|
||||
sourceIssueId: privateIssue.id, name: "Private workspace", mode: "isolated_workspace", strategyType: "git_worktree",
|
||||
providerType: "git_worktree", cwd: privateCwd, providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const run = await waitForRunToFinish(heartbeat, seeded.runId);
|
||||
await heartbeat.waitForRunExecutionDrain(seeded.runId);
|
||||
await waitForPendingRunFailureReports();
|
||||
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", resultJson: {
|
||||
configurationIncomplete: { reason: "workspace_source_restricted", issueId: seeded.issueId },
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
|
||||
expect(adapterExecute).not.toHaveBeenCalled();
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ status: "blocked", executionRunId: null, executionWorkspaceId: seeded.executionWorkspaceId });
|
||||
const comments = await db.select({ body: issueComments.body }).from(issueComments).where(eq(issueComments.issueId, seeded.issueId));
|
||||
expect(comments.map(comment => comment.body).join("\n")).toContain("shared project checkout that also holds another task's workspace");
|
||||
const actions = await db.select({ nextAction: issueRecoveryActions.nextAction }).from(issueRecoveryActions)
|
||||
.where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId));
|
||||
expect(actions.map(action => action.nextAction).join("\n")).toContain("select a new workspace");
|
||||
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
|
||||
});
|
||||
|
||||
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -738,6 +738,40 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function restrictedWorkspaceSourceRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "configuration_incomplete",
|
||||
executionStage: "preparing",
|
||||
resultJson: {
|
||||
configurationIncomplete: { reason: "workspace_source_restricted", issueId: randomUUID() },
|
||||
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a restricted shared workspace source actionable on the task", async () => {
|
||||
await seedCompanyAndAgent();
|
||||
await reportRunFailure(db, restrictedWorkspaceSourceRun(), { phase: "setup" });
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "timed_out" }, { errorCode: "setup_failed" }, { executionStage: "executing" }, { exitCode: 1 }, { signal: "SIGTERM" },
|
||||
{ resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" } } },
|
||||
{ resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
|
||||
])("reports restricted-source markers without an exact pre-dispatch outcome: %j", async (overrides) => {
|
||||
await seedCompanyAndAgent();
|
||||
await reportRunFailure(db, restrictedWorkspaceSourceRun(overrides), { phase: "setup" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, "execute"] as const)("reports restricted-source markers outside the setup report: %s", async (phase) => {
|
||||
await seedCompanyAndAgent();
|
||||
await reportRunFailure(db, restrictedWorkspaceSourceRun(), { phase });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function localPathConfigurationRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "workspace_validation_failed",
|
||||
|
||||
@@ -227,7 +227,8 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
await execFile("git", ["-C", source, "checkout", "main"]);
|
||||
if (scenario === "literal-branch") input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "feature/literal-pin" } };
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Task workspace access is no longer available");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
|
||||
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
@@ -298,6 +299,32 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
expect(await readFile(join(privateCwd, "confidential.txt"), "utf8")).toBe("Private source must not be copied");
|
||||
});
|
||||
|
||||
it("keeps a reused shared-checkout workspace denied with an owner action after a restricted worktree appears", async () => {
|
||||
const { input, source, issue } = await fixture(true);
|
||||
const first = await prepareHeartbeatWorkspace(db, input);
|
||||
const workspace = first.persistedExecutionWorkspace!;
|
||||
expect(first.executionWorkspace.baseCwd).toBe(source);
|
||||
await writeFile(join(workspace.cwd!, "unpublished-work.txt"), "Keep this task's local work\n");
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId, title: "Private work",
|
||||
visibility: "private", projectId: issue.projectId }).returning();
|
||||
const privateCwd = join(source, "private-work");
|
||||
await execFile("git", ["-C", source, "worktree", "add", "-b", "restricted-task", privateCwd, "main"]);
|
||||
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
|
||||
name: "Private workspace", projectId: issue.projectId, sourceIssueId: privateIssue.id, cwd: privateCwd, mode: "isolated_workspace",
|
||||
strategyType: "git_worktree", providerType: "git_worktree", providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
|
||||
await reuse(input, workspace);
|
||||
const clone = vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace");
|
||||
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
|
||||
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
|
||||
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(workspace.id);
|
||||
expect(await readFile(join(workspace.cwd!, "unpublished-work.txt"), "utf8")).toBe("Keep this task's local work\n");
|
||||
await expect(access(isolatedRemoteSourcePath(issue.companyId, issue.id))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.each(["sandbox", "ssh", "local", null] as const)("uses the selected %s placement for controller provisioning", async driver => {
|
||||
const { input, source, issue, envOrchestrator } = await fixture(true);
|
||||
const marker = join(source, "host-provision-ran");
|
||||
|
||||
@@ -31,6 +31,8 @@ import { parseObject } from "../../adapters/utils.js";
|
||||
import { materializeNativeChatTaskRoot } from "../native-runtime/native-chat-workspace.js";
|
||||
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
|
||||
import { canIsolateRemoteWorkspaceSource, materializeIsolatedRemoteWorkspaceSource, withIsolatedRepositorySourceLock } from "../isolated-remote-workspace-source.js";
|
||||
import { isRestrictedWorkspaceSourceDenial, RESTRICTED_WORKSPACE_SOURCE_REASON } from "../workspace-source-restriction.js";
|
||||
import { ConfigurationIncompleteFailure } from "./run-preparation.js";
|
||||
import {
|
||||
assertIsolatedWorkspaceSourceReadAccess,
|
||||
retainIsolatedWorkspaceSourceAccess,
|
||||
@@ -148,6 +150,19 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A restricted task worktree in the shared checkout keeps this task's source denied when
|
||||
* no fresh isolated clone is safe (an existing workspace may hold unpublished work).
|
||||
* Name the owner action instead of failing as an opaque setup error.
|
||||
*/
|
||||
function restrictedWorkspaceSourceFailure(error: unknown, issueId: string): unknown {
|
||||
if (!isRestrictedWorkspaceSourceDenial(error)) return error;
|
||||
return new ConfigurationIncompleteFailure(
|
||||
"This task's workspace source is a shared project checkout that also holds a task workspace this run cannot read, so Paperclip did not use it.",
|
||||
{ configurationIncomplete: { reason: RESTRICTED_WORKSPACE_SOURCE_REASON, issueId } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspacePreparationInput) {
|
||||
const sourceIssueId = input.reuse.reusableExistingExecutionWorkspace?.sourceIssueId
|
||||
?? input.reuse.existingExecutionWorkspace?.sourceIssueId ?? input.task.issueId;
|
||||
@@ -510,12 +525,14 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
selection: issues.workspaceSelection }).from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)));
|
||||
if (!sourceChoice || sourceChoice.executionWorkspaceId
|
||||
|| (sourceChoice.projectWorkspaceId && sourceChoice.projectWorkspaceId !== resolvedWorkspace.workspaceId)) throw error;
|
||||
|| (sourceChoice.projectWorkspaceId && sourceChoice.projectWorkspaceId !== resolvedWorkspace.workspaceId)) {
|
||||
throw restrictedWorkspaceSourceFailure(error, issueId);
|
||||
}
|
||||
if (!canIsolateRemoteWorkspaceSource({ error, repoUrl: executionWorkspaceBase.repoUrl,
|
||||
reuseExisting: requestedShouldReuseExisting, hasExistingWorkspace: Boolean(existingExecutionWorkspace),
|
||||
selectedProjectWorkspaceId: sourceChoice.projectWorkspaceId ?? readNonEmptyString(context.projectWorkspaceId),
|
||||
selection: sourceChoice.selection, strategy: parseObject(hostExecutionWorkspaceConfig.workspaceStrategy),
|
||||
trustPresetKind: trustPreset.kind })) throw error;
|
||||
trustPresetKind: trustPreset.kind })) throw restrictedWorkspaceSourceFailure(error, issueId);
|
||||
const cwd = await timeWorkspace("workspace_source_materialize", () => materializeIsolatedRemoteWorkspaceSource({ companyId: agent.companyId,
|
||||
issueId, repoUrl: executionWorkspaceBase.repoUrl!, resolveGitAuth: workspaceGitAuthProvider }));
|
||||
executionWorkspaceBase.baseCwd = cwd;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { GitRemoteAuthProvider } from "./git-credentials.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
|
||||
import { measureRunPhase } from "./execution-preparation-timing.js";
|
||||
import { isRestrictedWorkspaceSourceDenial } from "./workspace-source-restriction.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -88,11 +89,10 @@ export function canIsolateRemoteWorkspaceSource(input: {
|
||||
strategy: Record<string, unknown>;
|
||||
trustPresetKind: string;
|
||||
}): boolean {
|
||||
const error = input.error as { status?: unknown; message?: unknown } | null;
|
||||
const branchTemplate = input.strategy.branchTemplate;
|
||||
const selectsFixedBranch = typeof branchTemplate === "string" && branchTemplate.length > 0
|
||||
&& !/\{\{\s*(?:issue\.(?:id|identifier|title)|slug)\s*\}\}/.test(branchTemplate);
|
||||
if (input.trustPresetKind !== "standard" || error?.status !== 403 || error.message !== "Task workspace access is no longer available"
|
||||
if (input.trustPresetKind !== "standard" || !isRestrictedWorkspaceSourceDenial(input.error)
|
||||
|| input.reuseExisting || input.hasExistingWorkspace || input.strategy.existingBranch || selectsFixedBranch
|
||||
|| (input.selection && !["project", "task_default"].includes(input.selection.source))
|
||||
|| (input.selectedProjectWorkspaceId && (input.selection?.selection.kind !== "configured_source"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
|
||||
import { onboardingFirstTaskAwaitingUserSql } from "./onboarding-first-task-wait.js";
|
||||
import { isAiAuthenticationBlocked } from "../ai-auth-failure.js";
|
||||
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "../workspace-source-restriction.js";
|
||||
import { hasCommittedNativePlanWait } from "../native-runtime/native-plan-wait.js";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
|
||||
@@ -2595,6 +2596,8 @@ export function recoveryService(
|
||||
? "Check the starting branch and repository access, then repair the task’s branch and retry the original assignee."
|
||||
: readConfigurationIncompletePayload(input.latestRun)?.reason === "ai_connection_unavailable"
|
||||
? "Reconnect the selected AI account or choose an available connection, then continue the task."
|
||||
: readConfigurationIncompletePayload(input.latestRun)?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON
|
||||
? "Push any unpublished work from this task's workspace, then select a new workspace for the task, or have an owner grant access to the restricted task. Then retry the original assignee."
|
||||
: readConfigurationIncompletePayload(input.latestRun)
|
||||
?.reason === SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON
|
||||
? `Board operator: the sandbox provider plugin named in the run failure is not ready; ${sandboxProviderPluginRemedy(
|
||||
|
||||
@@ -76,6 +76,14 @@ describe("stranded recovery notice seeds", () => {
|
||||
expect(seed.nextAction).toContain("stop it if it is stuck");
|
||||
});
|
||||
|
||||
it("explains a shared checkout restricted by another task instead of missing secrets", () => {
|
||||
const seed = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "workspace_source_restricted" });
|
||||
expect(seed.title).toBe("Workspace source restricted");
|
||||
expect(seed.body).toContain("shared project checkout");
|
||||
expect(seed.body).not.toContain("secret/env bindings");
|
||||
expect(seed.nextAction).toContain("select a new workspace");
|
||||
});
|
||||
|
||||
it("keeps the secret-binding copy for other configuration gaps", () => {
|
||||
expect(buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "secret_binding_missing" }).body).toContain(
|
||||
"secret/env bindings",
|
||||
|
||||
@@ -137,6 +137,14 @@ export function buildConfigurationIncompleteRecoveryNoticeSeed(
|
||||
tone: "danger",
|
||||
};
|
||||
}
|
||||
if (readNonEmptyStringField(configurationIncomplete, "reason") === "workspace_source_restricted") {
|
||||
return {
|
||||
title: "Workspace source restricted",
|
||||
body: "Paperclip stopped before the agent started because this task's workspace uses a shared project checkout that also holds another task's workspace, and this run cannot read that task. Paperclip does not copy a shared checkout with restricted files.",
|
||||
nextAction: "Push any unpublished work from this task's workspace, then select a new workspace for the task. Alternatively, have an owner grant access to the restricted task. Then retry the task.",
|
||||
tone: "danger",
|
||||
};
|
||||
}
|
||||
if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_unavailable") {
|
||||
return {
|
||||
title: "AI connection needs attention",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isUnexpectedRunCancellation } from "./run-cancellation.js";
|
||||
import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
|
||||
import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js";
|
||||
import { readRunFailureReportingMode, type RunFailureReportingMode } from "../sentry-run-failure-policy.js";
|
||||
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.js";
|
||||
|
||||
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
|
||||
|
||||
@@ -123,6 +124,19 @@ function isLocalPathWorkspaceConfigurationBlocker(run: HeartbeatRun, options: Ru
|
||||
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
||||
}
|
||||
|
||||
/** A retained shared checkout denied by task privacy before dispatch; the task notice names the owner action. */
|
||||
function isRestrictedWorkspaceSourceBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
||||
if (
|
||||
run.status !== "failed" || run.errorCode !== "configuration_incomplete" ||
|
||||
run.executionStage !== "preparing" || options.phase !== "setup" ||
|
||||
run.exitCode != null || run.signal != null
|
||||
) return false;
|
||||
const configuration = asRecord(run.resultJson?.configurationIncomplete);
|
||||
const recovery = asRecord(run.resultJson?.executionRecovery);
|
||||
return configuration?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON &&
|
||||
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
||||
}
|
||||
|
||||
/** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */
|
||||
function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
||||
const connection = readConnectionFailure(run.resultJson?.connectionFailure);
|
||||
@@ -242,6 +256,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
|
||||
isMissingSecretConfigurationBlocker(run, options) ||
|
||||
isAiConnectionSelectionBlocker(run, options) ||
|
||||
isLocalPathWorkspaceConfigurationBlocker(run, options) ||
|
||||
isRestrictedWorkspaceSourceBlocker(run, options) ||
|
||||
isGitConnectionFailure(run, options) ||
|
||||
isProviderQuotaFailure(run, options) ||
|
||||
isCodexAcpModelCapacityFailure(run, options) ||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Dependency-free so run-failure reporting can classify this outcome without
|
||||
// loading the checkout and credential graph behind isolated source handling.
|
||||
export const RESTRICTED_WORKSPACE_SOURCE_REASON = "workspace_source_restricted";
|
||||
|
||||
/** The shared checkout holds a task workspace that the run's actor cannot read. */
|
||||
export function isRestrictedWorkspaceSourceDenial(error: unknown): boolean {
|
||||
const denial = error as { status?: unknown; message?: unknown } | null;
|
||||
return denial?.status === 403 && denial.message === "Task workspace access is no longer available";
|
||||
}
|
||||
Reference in new issue
Block a user