fix(heartbeat): surface the real cause when a git_worktree base cannot be materialized (#10719)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Heartbeat runs prepare an execution workspace for each issue; with
isolated workspaces (or low-trust runs), the `git_worktree` strategy
needs a real git checkout as its base
> - For repo-only project workspaces, the server materializes the
checkout with a managed `git clone`; when that clone fails, the resolver
drops the error and silently falls back to the agent home directory with
`source: "project_primary"`
> - The pre-dispatch guard then reports
`git_worktree_base_not_git_checkout`, which hides the real cause; if the
fallback directory happens to be a git checkout, the run silently builds
worktrees off the wrong repository
> - This pull request records materialization failures on the resolved
workspace, marks the fallback explicitly, and fails the guard with a
truthful `git_worktree_base_materialization_failed` reason that carries
the clone error
> - The benefit is that operators see the real cause (a failed clone) in
the run error, the blocked-issue comment, and the recovery next action,
instead of a misleading symptom
## Linked Issues or Issue Description
**What happened?**
An issue configured for isolated `git_worktree` execution on a project
whose only workspace is repo-only (a `repoUrl` with no local path) fails
every run with `workspace_validation_failed` and reason
`git_worktree_base_not_git_checkout`, pointing at the agent home
directory. The message does not mention that the managed `git clone` of
the project repository failed (for a private repository the clone can
never succeed without credentials). The recovery flow then blocks the
issue with the same misleading explanation. Run warnings claim "Project
workspace has no local cwd configured" even though the workspace is
configured and the clone failed.
**Expected behavior**
The run failure, the blocked-issue comment, and the recovery next action
should state the real cause: the project workspace checkout could not be
prepared, including the clone error, so the operator can repair the
repository URL, clone access, or configured local cwd. A fallback
directory that happens to be a git checkout must not let the run proceed
against the wrong repository.
**Steps to reproduce**
1. Create a project whose primary workspace has a `repoUrl` pointing at
a private GitHub repository and no local path.
2. Enable the Isolated Workspaces experimental setting (or use a
low-trust run, which forces isolation).
3. Run any issue in that project.
4. The run fails with `git_worktree_base_not_git_checkout` on the agent
home directory; the clone failure appears nowhere.
**Paperclip version or commit**
`master` (bd86dbe41b).
## What Changed
- `resolveAnchorWorkspaceForRun` collects every failed project-workspace
materialization attempt (previously the error was dropped unless the row
was the preferred workspace) and returns two new fields on
`ResolvedWorkspaceForRun`: `baseCwdFallback` and
`materializationFailures`. The `source` label is unchanged because
session migration keys off `source === "project_primary"`.
- `assertGitWorktreeBaseWorkspaceReady` accepts the anchor facts and
fails with the new reason `git_worktree_base_materialization_failed` —
checked before the git-checkout probe, so a fallback directory that
happens to be a git repo can no longer host worktrees for the wrong
repository. The message carries the first scrubbed clone error and
remediation, and lands in `run.error`, the persisted
`workspaceValidation` payload, and the blocked-issue comment.
- New `scrubGitCredentialText` masks URL userinfo (a `repoUrl` can
legitimately embed credentials today) before errors reach warnings or
persisted payloads.
- Fallback warning assembly moved into the pure helper
`buildAnchorFallbackWorkspaceNotes`; a clone failure now produces
"Failed to prepare the project workspace checkout: …" instead of the
false "no local cwd configured", with the existing warning texts
preserved byte-for-byte when nothing failed to materialize.
- The workspace-validation recovery comment and the recovery service's
next action explain the new reason specifically.
## Verification
- `cd server && npx vitest run
src/__tests__/heartbeat-workspace-session.test.ts` — new cases: the new
reason takes precedence over the git-checkout probe (fallback cwd is a
real git repo), payload carries the scrubbed failures, anchor-absent
legacy behavior unchanged, scrubber unit tests, and warning-assembly
unit tests that pin the existing texts.
- `cd server && npx vitest run
src/__tests__/issue-recovery-actions.test.ts
src/__tests__/heartbeat-process-recovery.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts` — recovery surfaces
still pass.
- `pnpm --filter @paperclipai/server typecheck` is clean.
## Risks
- Additive persisted-payload fields and a new reason string; recovery
reason handling falls through to generic text for unknown reasons, and
no UI consumes the `git_worktree_base_*` strings.
- Intentional behavior change: a repo-only project whose clone fails and
whose agent-home fallback happened to be a git checkout previously ran
in that unrelated repository; it now fails with the truthful reason. A
test locks this.
- Runs without isolated workspaces (the default) never reach the guard;
their fallback behavior is unchanged.
## Model Used
Claude Fable 5 (`claude-fable-5`, extended thinking, agentic tool use
via Claude Code CLI).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
parent
799973f26a
commit
7a3815eb9a
|
|
@ -43,6 +43,8 @@ import {
|
|||
stripPaperclipSessionMetadataFromSessionParams,
|
||||
normalizeSessionParams,
|
||||
shouldResetTaskSessionForWake,
|
||||
scrubGitCredentialText,
|
||||
buildAnchorFallbackWorkspaceNotes,
|
||||
type ResolvedWorkspaceForRun,
|
||||
} from "../services/heartbeat.ts";
|
||||
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
|
||||
|
|
@ -59,6 +61,8 @@ function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}
|
|||
repoRef: null,
|
||||
workspaceHints: [],
|
||||
warnings: [],
|
||||
baseCwdFallback: false,
|
||||
materializationFailures: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -561,6 +565,157 @@ describe("assertGitWorktreeBaseWorkspaceReady", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("rejects isolated git worktrees when the project workspace could not be materialized, even if the fallback cwd is a git checkout", async () => {
|
||||
// The fallback agent-home dir being a git repo must not let the run proceed: it would be an
|
||||
// unrelated repository, and the not-a-git-checkout probe would mask the real clone failure.
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
try {
|
||||
await expect(assertGitWorktreeBaseWorkspaceReady({
|
||||
requestedExecutionWorkspaceMode: "isolated_workspace",
|
||||
config: { workspaceStrategy: { type: "git_worktree" } },
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
base: {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/example/private-repo.git",
|
||||
repoRef: "origin/master",
|
||||
},
|
||||
anchor: {
|
||||
baseCwdFallback: true,
|
||||
materializationFailures: [{
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/example/private-repo.git",
|
||||
error: 'Failed to prepare managed checkout for "https://github.com/example/private-repo.git": fatal: could not read Username',
|
||||
}],
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
message: expect.stringContaining("could not be prepared: Failed to prepare managed checkout"),
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason: "git_worktree_base_materialization_failed",
|
||||
issueId: "issue-1",
|
||||
baseCwdFallback: true,
|
||||
materializationFailures: [expect.objectContaining({
|
||||
projectWorkspaceId: "workspace-1",
|
||||
error: expect.stringContaining("could not read Username"),
|
||||
})],
|
||||
}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the not-a-git-checkout reason for a fallback with no failed materialization attempt", async () => {
|
||||
// A configured path that is simply unavailable is not a clone failure; the message must
|
||||
// not steer the operator toward repairing clone access.
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-unavailable-path-fallback-"));
|
||||
try {
|
||||
await expect(assertGitWorktreeBaseWorkspaceReady({
|
||||
requestedExecutionWorkspaceMode: "isolated_workspace",
|
||||
config: { workspaceStrategy: { type: "git_worktree" } },
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
base: {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
},
|
||||
anchor: { baseCwdFallback: true },
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
message: expect.stringContaining("is not a git checkout"),
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason: "git_worktree_base_not_git_checkout",
|
||||
}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a git-checkout fallback cwd that is not the project workspace, without claiming a clone failed", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
try {
|
||||
await expect(assertGitWorktreeBaseWorkspaceReady({
|
||||
requestedExecutionWorkspaceMode: "isolated_workspace",
|
||||
config: { workspaceStrategy: { type: "git_worktree" } },
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
base: {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
},
|
||||
anchor: { baseCwdFallback: true, materializationFailures: [] },
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
message: expect.stringContaining("configured project workspace path is not available"),
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason: "git_worktree_base_fallback_not_project_workspace",
|
||||
baseCwdFallback: true,
|
||||
materializationFailures: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows isolated git worktrees when the anchor reports no fallback", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
try {
|
||||
await expect(assertGitWorktreeBaseWorkspaceReady({
|
||||
requestedExecutionWorkspaceMode: "isolated_workspace",
|
||||
config: { workspaceStrategy: { type: "git_worktree" } },
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
base: {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/example/repo.git",
|
||||
repoRef: "origin/master",
|
||||
},
|
||||
anchor: { baseCwdFallback: false, materializationFailures: [] },
|
||||
})).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows isolated git worktrees when the resolved base is a git checkout", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
try {
|
||||
|
|
@ -660,6 +815,119 @@ describe("assertGitWorktreeBaseWorkspaceReady", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("scrubGitCredentialText", () => {
|
||||
it("masks URL userinfo including tokens", () => {
|
||||
expect(scrubGitCredentialText(
|
||||
'fatal: unable to access "https://x-access-token:ghp_abc123@github.com/example/repo.git"',
|
||||
)).toBe('fatal: unable to access "https://***@github.com/example/repo.git"');
|
||||
});
|
||||
|
||||
it("masks bare-username userinfo and multiple occurrences", () => {
|
||||
expect(scrubGitCredentialText(
|
||||
"clone https://alice@github.com/a.git then http://token@internal.example/b.git",
|
||||
)).toBe("clone https://***@github.com/a.git then http://***@internal.example/b.git");
|
||||
});
|
||||
|
||||
it("masks userinfo on non-HTTP schemes, leaving scp-style remotes alone", () => {
|
||||
expect(scrubGitCredentialText(
|
||||
"fatal: cannot clone ssh://deploy:hunter2@internal.example/repo.git or git+ssh://bob@host/x.git",
|
||||
)).toBe("fatal: cannot clone ssh://***@internal.example/repo.git or git+ssh://***@host/x.git");
|
||||
expect(scrubGitCredentialText("fetch from git@github.com:example/repo.git failed")).toBe(
|
||||
"fetch from git@github.com:example/repo.git failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks entire URL query strings regardless of parameter names", () => {
|
||||
expect(scrubGitCredentialText(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git?access_token=ghs_secret&ref=main'",
|
||||
)).toBe("fatal: unable to access 'https://github.com/example/repo.git?***'");
|
||||
expect(scrubGitCredentialText(
|
||||
"clone https://gitlab.example/repo.git?anything=glpat-123 failed",
|
||||
)).toBe("clone https://gitlab.example/repo.git?*** failed");
|
||||
});
|
||||
|
||||
it("leaves credential-free text unchanged", () => {
|
||||
const text = 'Failed to clone "https://github.com/example/repo.git": exit code 128';
|
||||
expect(scrubGitCredentialText(text)).toBe(text);
|
||||
const plain = "git clone failed with exit code=128 at key step ref=main";
|
||||
expect(scrubGitCredentialText(plain)).toBe(plain);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAnchorFallbackWorkspaceNotes", () => {
|
||||
it("reports materialization failures ahead of the generic no-cwd note", () => {
|
||||
expect(buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd: "/paperclip/workspaces/agent-1",
|
||||
preferredWorkspaceWarning: null,
|
||||
materializationFailures: [{
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/example/private.git",
|
||||
error: "fatal: could not read Username",
|
||||
}],
|
||||
missingProjectCwds: [],
|
||||
hasConfiguredProjectCwd: false,
|
||||
})).toEqual([
|
||||
'Failed to prepare the project workspace checkout: fatal: could not read Username. Using fallback workspace "/paperclip/workspaces/agent-1" for this run.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts additional failed candidates", () => {
|
||||
const failure = {
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
error: "clone timed out",
|
||||
};
|
||||
expect(buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd: "/fallback",
|
||||
preferredWorkspaceWarning: null,
|
||||
materializationFailures: [failure, { ...failure, projectWorkspaceId: "workspace-2" }],
|
||||
missingProjectCwds: [],
|
||||
hasConfiguredProjectCwd: false,
|
||||
})).toEqual([
|
||||
'Failed to prepare the project workspace checkout (clone timed out), and 1 other candidate workspace(s) also failed. Using fallback workspace "/fallback" for this run.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the existing missing-path and no-cwd notes when nothing failed to materialize", () => {
|
||||
expect(buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd: "/fallback",
|
||||
preferredWorkspaceWarning: "Selected project workspace \"workspace-9\" is not available on this project.",
|
||||
materializationFailures: [],
|
||||
missingProjectCwds: ["/missing/path"],
|
||||
hasConfiguredProjectCwd: true,
|
||||
})).toEqual([
|
||||
'Selected project workspace "workspace-9" is not available on this project.',
|
||||
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
||||
]);
|
||||
expect(buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd: "/fallback",
|
||||
preferredWorkspaceWarning: null,
|
||||
materializationFailures: [],
|
||||
missingProjectCwds: [],
|
||||
hasConfiguredProjectCwd: false,
|
||||
})).toEqual([
|
||||
'Project workspace has no local cwd configured. Using fallback workspace "/fallback" for this run.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits both failure and missing-path notes when a project has both kinds of candidates", () => {
|
||||
expect(buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd: "/fallback",
|
||||
preferredWorkspaceWarning: null,
|
||||
materializationFailures: [{
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/example/private.git",
|
||||
error: "authentication failed",
|
||||
}],
|
||||
missingProjectCwds: ["/missing/path"],
|
||||
hasConfiguredProjectCwd: true,
|
||||
})).toEqual([
|
||||
'Failed to prepare the project workspace checkout: authentication failed. Using fallback workspace "/fallback" for this run.',
|
||||
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertPushCapabilityCheckoutValid", () => {
|
||||
it("rejects a GitHub PR workflow checkout without a configured push remote", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
|
|
|
|||
|
|
@ -1804,6 +1804,15 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: {
|
|||
executionWorkspacePreference?: string | null;
|
||||
} | null;
|
||||
base: ExecutionWorkspaceInput;
|
||||
/**
|
||||
* Anchor-resolution facts that `base` alone cannot express: whether the base cwd is the
|
||||
* agent-home fallback despite the project having workspaces, and which materialization
|
||||
* attempts failed on the way there. Absent means "not a fallback" (legacy callers).
|
||||
*/
|
||||
anchor?: {
|
||||
baseCwdFallback?: boolean;
|
||||
materializationFailures?: WorkspaceMaterializationFailure[];
|
||||
} | null;
|
||||
}) {
|
||||
if (!input.issue) return;
|
||||
if (
|
||||
|
|
@ -1849,12 +1858,40 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: {
|
|||
);
|
||||
}
|
||||
|
||||
// Checked before isGitCheckout: when materialization failed and the base cwd is the
|
||||
// agent-home fallback, a git checkout at that path would be an unrelated repository —
|
||||
// proceeding would build worktrees off the wrong repo, and failing on the checkout probe
|
||||
// would mask the real cause (for example a clone that could not authenticate). The reason
|
||||
// is reserved for genuine materialization failures; a fallback with no failed attempt
|
||||
// (a configured path that is simply unavailable) keeps its accurate reporting below.
|
||||
const materializationFailures = input.anchor?.materializationFailures ?? [];
|
||||
if (input.anchor?.baseCwdFallback && materializationFailures.length > 0) {
|
||||
const failureDetail = `: ${materializationFailures[0].error.replace(/\s+/g, " ")}`;
|
||||
fail(
|
||||
"git_worktree_base_materialization_failed",
|
||||
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the project workspace checkout could not be prepared${failureDetail}. Repair the project workspace repository URL, clone access, or configured local cwd, then retry.`,
|
||||
{ baseCwdFallback: true, materializationFailures },
|
||||
);
|
||||
}
|
||||
|
||||
if (!await isGitCheckout(input.base.baseCwd)) {
|
||||
fail(
|
||||
"git_worktree_base_not_git_checkout",
|
||||
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but base workspace "${input.base.baseCwd}" is not a git checkout. ${remediation}`,
|
||||
);
|
||||
}
|
||||
|
||||
// A fallback cwd that happens to be a git checkout is still not the configured project
|
||||
// workspace — building worktrees there would target an unrelated repository. No
|
||||
// materialization attempt failed here (that case failed above); the configured path is
|
||||
// simply unavailable, so the message points at the path rather than clone access.
|
||||
if (input.anchor?.baseCwdFallback) {
|
||||
fail(
|
||||
"git_worktree_base_fallback_not_project_workspace",
|
||||
`Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the configured project workspace path is not available and the fallback cwd "${input.base.baseCwd}" is not the project workspace checkout. Make the configured project workspace path available on this host, or repair the project workspace configuration, then retry.`,
|
||||
{ baseCwdFallback: true, materializationFailures },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPushCapabilityCheckoutValid(input: {
|
||||
|
|
@ -2389,6 +2426,33 @@ export type ResolvedAdditionalWorkspace = {
|
|||
repoRef: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One project-workspace materialization attempt that failed during anchor resolution — for
|
||||
* example a managed `git clone` that could not authenticate against a private repository.
|
||||
* Carried on {@link ResolvedWorkspaceForRun} so downstream validation can report the real
|
||||
* cause instead of the fallback cwd's symptoms. `repoUrl` and `error` are scrubbed of URL
|
||||
* userinfo credentials before they are stored.
|
||||
*/
|
||||
export type WorkspaceMaterializationFailure = {
|
||||
projectWorkspaceId: string | null;
|
||||
repoUrl: string | null;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mask credential material embedded in URLs so it never reaches warnings, run errors, or
|
||||
* persisted payloads: userinfo on any scheme (`https://user:token@host`,
|
||||
* `ssh://user:pass@host`) and the entire query string of any URL (`?access_token=…` and
|
||||
* every other parameter — masked wholesale rather than by an inevitably incomplete
|
||||
* parameter-name list). Scp-style remotes (`git@host:path`) carry no password and are left
|
||||
* alone.
|
||||
*/
|
||||
export function scrubGitCredentialText(text: string): string {
|
||||
return text
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@")
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s"'?]*)\?[^\s"']*/gi, "$1?***");
|
||||
}
|
||||
|
||||
export type ResolvedWorkspaceForRun = {
|
||||
cwd: string;
|
||||
source: "project_primary" | "task_session" | "agent_home";
|
||||
|
|
@ -2403,6 +2467,15 @@ export type ResolvedWorkspaceForRun = {
|
|||
repoRef: string | null;
|
||||
}>;
|
||||
warnings: string[];
|
||||
/**
|
||||
* True when project workspaces exist for the run but none could be used, so `cwd` is the
|
||||
* agent-home fallback rather than a configured or materialized project workspace path. The
|
||||
* `source` stays `project_primary` in that case (session migration depends on it), so this
|
||||
* flag is the only reliable fallback signal.
|
||||
*/
|
||||
baseCwdFallback: boolean;
|
||||
/** Failed materialization attempts behind {@link baseCwdFallback}; empty when every candidate resolved or none was attempted. */
|
||||
materializationFailures: WorkspaceMaterializationFailure[];
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -2424,6 +2497,47 @@ type ResolvedAnchorWorkspaceForRun = Omit<
|
|||
"additionalWorkspaces" | "referencedProjectFailures"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Assemble the run warnings for the agent-home fallback when a project has workspaces but none
|
||||
* produced a usable cwd. Materialization failures (for example a failed managed clone) take
|
||||
* priority over the generic "no local cwd configured" note, which previously masked them.
|
||||
*/
|
||||
export function buildAnchorFallbackWorkspaceNotes(input: {
|
||||
fallbackCwd: string;
|
||||
preferredWorkspaceWarning: string | null;
|
||||
materializationFailures: WorkspaceMaterializationFailure[];
|
||||
missingProjectCwds: string[];
|
||||
hasConfiguredProjectCwd: boolean;
|
||||
}): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (input.preferredWorkspaceWarning) {
|
||||
warnings.push(input.preferredWorkspaceWarning);
|
||||
}
|
||||
if (input.materializationFailures.length > 0) {
|
||||
const first = input.materializationFailures[0];
|
||||
const extraFailureCount = input.materializationFailures.length - 1;
|
||||
warnings.push(
|
||||
extraFailureCount > 0
|
||||
? `Failed to prepare the project workspace checkout (${first.error}), and ${extraFailureCount} other candidate workspace(s) also failed. Using fallback workspace "${input.fallbackCwd}" for this run.`
|
||||
: `Failed to prepare the project workspace checkout: ${first.error}. Using fallback workspace "${input.fallbackCwd}" for this run.`,
|
||||
);
|
||||
}
|
||||
if (input.missingProjectCwds.length > 0) {
|
||||
const firstMissing = input.missingProjectCwds[0];
|
||||
const extraMissingCount = Math.max(0, input.missingProjectCwds.length - 1);
|
||||
warnings.push(
|
||||
extraMissingCount > 0
|
||||
? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`
|
||||
: `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`,
|
||||
);
|
||||
} else if (input.materializationFailures.length === 0 && !input.hasConfiguredProjectCwd) {
|
||||
warnings.push(
|
||||
`Project workspace has no local cwd configured. Using fallback workspace "${input.fallbackCwd}" for this run.`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plural workspace list that a run exposes to the agent through the
|
||||
* `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor
|
||||
|
|
@ -8298,6 +8412,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
? projectWorkspaceRows.find((workspace) => workspace.id === preferredProjectWorkspaceId) ?? null
|
||||
: null;
|
||||
const missingProjectCwds: string[] = [];
|
||||
const materializationFailures: WorkspaceMaterializationFailure[] = [];
|
||||
let hasConfiguredProjectCwd = false;
|
||||
let preferredWorkspaceWarning: string | null = null;
|
||||
if (preferredProjectWorkspaceId && !preferredWorkspace) {
|
||||
|
|
@ -8317,8 +8432,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
projectCwd = resolvedCwd.cwd;
|
||||
managedWorkspaceWarning = resolvedCwd.warning;
|
||||
} catch (error) {
|
||||
const scrubbedError = scrubGitCredentialText(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl);
|
||||
materializationFailures.push({
|
||||
projectWorkspaceId: workspace.id,
|
||||
repoUrl: workspaceRepoUrl ? scrubGitCredentialText(workspaceRepoUrl) : null,
|
||||
error: scrubbedError,
|
||||
});
|
||||
if (preferredWorkspace?.id === workspace.id) {
|
||||
preferredWorkspaceWarning = error instanceof Error ? error.message : String(error);
|
||||
preferredWorkspaceWarning = scrubbedError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -8339,6 +8463,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
warnings: [preferredWorkspaceWarning, managedWorkspaceWarning].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
),
|
||||
baseCwdFallback: false,
|
||||
materializationFailures,
|
||||
};
|
||||
}
|
||||
if (preferredWorkspace?.id === workspace.id) {
|
||||
|
|
@ -8350,23 +8476,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
|
||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id);
|
||||
await fs.mkdir(fallbackCwd, { recursive: true });
|
||||
const warnings: string[] = [];
|
||||
if (preferredWorkspaceWarning) {
|
||||
warnings.push(preferredWorkspaceWarning);
|
||||
}
|
||||
if (missingProjectCwds.length > 0) {
|
||||
const firstMissing = missingProjectCwds[0];
|
||||
const extraMissingCount = Math.max(0, missingProjectCwds.length - 1);
|
||||
warnings.push(
|
||||
extraMissingCount > 0
|
||||
? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${fallbackCwd}" for this run.`
|
||||
: `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${fallbackCwd}" for this run.`,
|
||||
);
|
||||
} else if (!hasConfiguredProjectCwd) {
|
||||
warnings.push(
|
||||
`Project workspace has no local cwd configured. Using fallback workspace "${fallbackCwd}" for this run.`,
|
||||
);
|
||||
}
|
||||
const warnings = buildAnchorFallbackWorkspaceNotes({
|
||||
fallbackCwd,
|
||||
preferredWorkspaceWarning,
|
||||
materializationFailures,
|
||||
missingProjectCwds,
|
||||
hasConfiguredProjectCwd,
|
||||
});
|
||||
return {
|
||||
cwd: fallbackCwd,
|
||||
source: "project_primary" as const,
|
||||
|
|
@ -8376,6 +8492,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
repoRef: projectWorkspaceRows[0]?.repoRef ?? null,
|
||||
workspaceHints,
|
||||
warnings,
|
||||
baseCwdFallback: true,
|
||||
materializationFailures,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -8394,6 +8512,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
repoRef: null,
|
||||
workspaceHints,
|
||||
warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [],
|
||||
baseCwdFallback: false,
|
||||
materializationFailures: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -8414,6 +8534,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
repoRef: readNonEmptyString(previousSessionParams?.repoRef),
|
||||
workspaceHints,
|
||||
warnings: [],
|
||||
baseCwdFallback: false,
|
||||
materializationFailures: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -8447,6 +8569,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
repoRef: null,
|
||||
workspaceHints,
|
||||
warnings,
|
||||
baseCwdFallback: false,
|
||||
materializationFailures: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -13839,6 +13963,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
config: hostExecutionWorkspaceConfig,
|
||||
issue: issueRef,
|
||||
base: executionWorkspaceBase,
|
||||
anchor: {
|
||||
baseCwdFallback: resolvedWorkspace.baseCwdFallback,
|
||||
materializationFailures: resolvedWorkspace.materializationFailures,
|
||||
},
|
||||
});
|
||||
const workspaceStrategyForFingerprint = parseObject(hostExecutionWorkspaceConfig.workspaceStrategy);
|
||||
const workspaceStrategyFingerprintValue =
|
||||
|
|
@ -15832,9 +15960,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
|
||||
function buildWorkspaceValidationRecoveryComment(input: {
|
||||
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined;
|
||||
latestRun:
|
||||
| Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode" | "resultJson">
|
||||
| null
|
||||
| undefined;
|
||||
}) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
const validationReason = readNonEmptyString(
|
||||
readWorkspaceValidationPayloadFromRun(input.latestRun).reason,
|
||||
);
|
||||
if (validationReason === "git_worktree_base_materialization_failed") {
|
||||
return (
|
||||
"Paperclip stopped before launching the local adapter because the project workspace checkout could not be prepared " +
|
||||
`(for example the repository clone failed).${failureSummary ?? ""} ` +
|
||||
"Moving it to `blocked` with a source-scoped recovery action so the repository URL, clone access, or configured local cwd can be repaired before resuming."
|
||||
);
|
||||
}
|
||||
return (
|
||||
"Paperclip stopped before launching the local adapter because the issue workspace failed validation. " +
|
||||
`This prevents git-sensitive adapters from running in an unrelated fallback cwd.${failureSummary ?? ""} ` +
|
||||
|
|
|
|||
|
|
@ -2928,7 +2928,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
: recoveryCause === "workspace_validation_failed"
|
||||
? readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_branch_incoherence"
|
||||
? "Repair the source issue git worktree branch incoherence, or choose a new execution workspace, before resuming adapter execution."
|
||||
: "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
|
||||
: readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_base_materialization_failed"
|
||||
? "Repair the project workspace repository URL or clone access, or configure a local checkout cwd, before resuming adapter execution."
|
||||
: "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
|
||||
: recoveryCause === "configuration_incomplete"
|
||||
? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution."
|
||||
: recoveryCause === "execution_review_participant_recovery"
|
||||
|
|
|
|||
Loading…
Reference in New Issue