diff --git a/doc/project-repositories.md b/doc/project-repositories.md index 7fbdbd1ee99..18aef98676c 100644 --- a/doc/project-repositories.md +++ b/doc/project-repositories.md @@ -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 diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 354ea195f1d..6d050c32fd7 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -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 diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index e81f9d3c5ef..e37a2af9b81 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -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("../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(); diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index d6269880635..e8fab6b1fe3 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -738,6 +738,40 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); }); + function restrictedWorkspaceSourceRun(overrides: Partial = {}) { + 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 = {}) { return buildRun({ errorCode: "workspace_validation_failed", diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index c704a7d75f1..c10e21af12a 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -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"); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 991398f1d4c..97396951483 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -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; diff --git a/server/src/services/isolated-remote-workspace-source.ts b/server/src/services/isolated-remote-workspace-source.ts index 02357b3ba35..c0bf8250db1 100644 --- a/server/src/services/isolated-remote-workspace-source.ts +++ b/server/src/services/isolated-remote-workspace-source.ts @@ -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; 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" diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 9d2d0696eeb..e0264f92ee5 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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( diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts index 2ad1e7f45d5..594bff88519 100644 --- a/server/src/services/recovery/stranded-notice.test.ts +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -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", diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts index dfe2a71b80f..d4a1014cd44 100644 --- a/server/src/services/recovery/stranded-notice.ts +++ b/server/src/services/recovery/stranded-notice.ts @@ -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", diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index aedc4046a23..ad756c8c33b 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -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) || diff --git a/server/src/services/workspace-source-restriction.ts b/server/src/services/workspace-source-restriction.ts new file mode 100644 index 00000000000..724c5a909f6 --- /dev/null +++ b/server/src/services/workspace-source-restriction.ts @@ -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"; +}