[codex] Guard git-sensitive adapter workspaces (#7644)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The affected subsystem is the heartbeat execution path that turns issue assignment into adapter-backed work in a selected workspace. > - PAP-10409 and sibling follow-ups failed before useful adapter output because project/workspace identity became incoherent. > - A project-workspace-linked child issue could keep `projectWorkspaceId` / execution workspace state while losing `projectId`, then a git-sensitive local adapter could fall through toward an invalid fallback cwd. > - Paperclip needs to treat coherent workspace identity as part of the live-path contract, not only as post-failure cleanup. > - This pull request documents that rule, repairs issue inheritance, and blocks git-sensitive adapter launch before it can run from the wrong cwd. > - The benefit is a bounded recovery path: affected issues are repaired explicitly, future malformed workspaces fail fast with a clear recovery action, and the UI surfaces that reason. ## Linked Issues or Issue Description Refs #7646 Bug report fields: - Summary: adapter-backed follow-up issues can fail before doing work when issue creation/inheritance preserves workspace ids but drops project identity. - Affected issues: internal Paperclip issues PAP-10408 through PAP-10412, especially PAP-10409. - Steps to reproduce: create a project-scoped parent/follow-up tree where a child issue keeps `projectWorkspaceId` or an inherited execution workspace but has `projectId: null`, then launch a git-sensitive local adapter such as `codex_local`. - Expected behavior: Paperclip derives or preserves coherent project identity during issue creation, and heartbeat refuses malformed git-sensitive workspace launches with one clear recovery action. - Actual behavior before this PR: the run could reach adapter bootstrap with an incoherent workspace context and fail with git errors such as `fatal: not a git repository (or any parent up to mount point /srv)`. - Root cause: child/follow-up issue inheritance preserved workspace execution context without coherent project context. That let heartbeat workspace resolution/adapter launch reach a fallback cwd instead of refusing the malformed workspace state up front. ## What Changed - Documented the adapter workspace-coherence live-path precondition in `doc/execution-semantics.md`. - Updated issue creation/inheritance so workspace-inheriting issues preserve or derive project identity, while existing mismatch validation still rejects incoherent project/workspace combinations. - Added a heartbeat preflight guard for git-sensitive local adapters that validates effective cwd, persisted workspace identity, project workspace identity, and required git metadata before launch. - Added `workspace_validation` recovery actions for this failure class and ensured the source issue gets a visible, idempotent recovery comment. - Surfaced workspace-validation recovery state in issue rows, blocked notices, and recovery action cards, including the manual-repair wake policy label. - Added focused regression coverage for issue inheritance, all heartbeat workspace-validation guard branches, recovery display helpers, and UI recovery components. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-workspace-session.test.ts` - Result: 1 test file passed, 68 tests passed. - `pnpm exec vitest run ui/src/components/IssueRecoveryActionCard.test.tsx` - Result: 1 test file passed, 12 tests passed. - `pnpm exec vitest run ui/src/components/IssueBlockedNotice.test.tsx ui/src/components/IssueRecoveryActionCard.test.tsx` - Result: 2 test files passed, 18 tests passed. - `pnpm --filter @paperclipai/ui typecheck` - Result: passed. - `pnpm exec vitest run server/src/__tests__/heartbeat-plugin-environment.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts ui/src/components/IssueBlockedNotice.test.tsx ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/lib/recovery-display.test.ts` - Result: 7 test files passed, 200 tests passed before the final guard-branch additions; the changed server file was re-run above. - UI coverage: `ui/storybook/stories/source-issue-recovery.stories.tsx` contains rendered scenarios for the generic recovery chip, workspace-validation recovery chip, blocked notice indicator, recovery action card, and issue-row chip. - Screenshot capture attempt: Storybook started successfully on `http://127.0.0.1:6016/`, but screenshots could not be captured in this runner because `agent-browser` launched an unusable Chrome binary and Playwright Chromium failed on missing system library `libatk-1.0.so.0`; the runner is non-root and lacks passwordless sudo for installing browser dependencies. - Hosted CI on final commit `969594e7` is green, including `verify`, `Build`, `Typecheck + Release Registry`, `General tests (server)`, workspace suites, serialized server suites, `Canary Dry Run`, and `e2e`. - Roadmap checked: no duplicate roadmap item; this is a tightly scoped reliability fix for existing heartbeat/workspace behavior. - Duplicate PR search checked: no open PR matched `workspace coherence adapter cwd`. ## Risks - Medium risk: heartbeat launch is stricter for git-sensitive local adapters and can now block malformed workspace states before adapter execution. - Mitigation: the guard is limited to local git-sensitive adapters and records a source-scoped recovery action with structured evidence instead of retrying indefinitely. - Compatibility: valid project/workspace execution paths continue normally; explicit project/workspace mismatches remain rejected. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based `codex_local` coding agent with terminal/tool use. Work was produced through Paperclip issue execution with focused local test runs. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -237,6 +237,22 @@ The valid action-path primitives are:
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery action that names the owner and action needed to restore liveness
|
||||
|
||||
### Adapter-backed workspace coherence
|
||||
|
||||
For adapter-backed execution, an active run or queued wake counts as a live path only when Paperclip can also prove that the selected workspace is coherent for that adapter invocation. A wake that cannot start in the intended workspace is only a failed delivery attempt, not a healthy liveness path.
|
||||
|
||||
A workspace-coherent adapter path means:
|
||||
|
||||
- the selected `executionWorkspaceId`, `projectWorkspaceId`, `projectId`, source issue, and company all refer to the same company-scoped work context
|
||||
- any `projectWorkspaceId` is accompanied by the owning `projectId`, and that project relationship is unambiguous
|
||||
- the adapter will receive the same effective workspace/cwd that Paperclip resolved for the run, including the same workspace ids and `PAPERCLIP_WORKSPACE_*` environment values
|
||||
- the effective cwd exists or is provider-reachable, according to the workspace provider
|
||||
- when the adapter or workspace strategy relies on git state, the cwd is git-valid for the selected workspace: it resolves to the expected repository root, required base refs or branch metadata can be resolved, and runtime-created worktrees are still registered or explicitly recoverable
|
||||
|
||||
The state `projectWorkspaceId` plus `executionWorkspaceId` without `projectId` is invalid for project-scoped execution. Paperclip may treat it as recoverable only when it can derive exactly one owning project from the execution workspace, project workspace, or source issue in the same company and then repair the persisted state before delivery. If the owning project is missing, ambiguous, or cross-company, the queued adapter run must not be counted as a live path.
|
||||
|
||||
Workspace incoherence feeds into the same non-terminal liveness and stranded assigned-work model as a disappeared run. The recovery path should first fail or reject the incoherent wake, then either repair and requeue one bounded continuation for the same assignee or surface an explicit recovery action. It must not leave an agent-owned `in_progress` issue healthy solely because a wake record exists that would invoke the adapter in the wrong cwd, a non-git directory where git is required, an unrelated project workspace, or an unrecoverable missing worktree.
|
||||
|
||||
### Explicit recovery actions
|
||||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
@@ -271,6 +271,7 @@ export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number]
|
||||
export const ISSUE_RECOVERY_ACTION_KINDS = [
|
||||
"missing_disposition",
|
||||
"stranded_assigned_issue",
|
||||
"workspace_validation",
|
||||
"active_run_watchdog",
|
||||
"issue_graph_liveness",
|
||||
] as const;
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
issueTreeHolds,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -383,6 +385,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(documentAnnotationComments);
|
||||
@@ -1308,6 +1312,96 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(comments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks a git-sensitive local adapter before launch when a project-workspace-linked issue is missing its project id", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Paperclip App",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: `/tmp/paperclip-missing-workspace-${randomUUID()}`,
|
||||
isPrimary: true,
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
title: "Launch from linked workspace without project id",
|
||||
identifier: `${issuePrefix}-1`,
|
||||
projectId: null,
|
||||
projectWorkspaceId,
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await waitForRunToSettle(heartbeat, runId, 5_000);
|
||||
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
|
||||
const failedRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(failedRun).toMatchObject({
|
||||
status: "failed",
|
||||
errorCode: "workspace_validation_failed",
|
||||
});
|
||||
expect(failedRun?.error).toContain("linked to a project workspace but has no project id");
|
||||
expect(failedRun?.resultJson).toMatchObject({
|
||||
workspaceValidation: {
|
||||
reason: "missing_project_id",
|
||||
adapterType: "codex_local",
|
||||
issueId,
|
||||
issueProjectId: null,
|
||||
issueProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const issue = await waitForValue(async () =>
|
||||
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
return row?.status === "blocked" ? row : null;
|
||||
}),
|
||||
);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
|
||||
const recoveryAction = await db
|
||||
.select()
|
||||
.from(issueRecoveryActions)
|
||||
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(recoveryAction).toMatchObject({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
status: "active",
|
||||
ownerAgentId: agentId,
|
||||
recoveryIssueId: null,
|
||||
});
|
||||
expect(recoveryAction?.evidence).toMatchObject({
|
||||
sourceIssueId: issueId,
|
||||
latestRunId: runId,
|
||||
latestRunErrorCode: "workspace_validation_failed",
|
||||
recoveryCause: "workspace_validation_failed",
|
||||
});
|
||||
expect(recoveryAction?.nextAction).toContain("Repair the source issue workspace link");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments.some((comment) => comment.body.includes("workspace failed validation"))).toBe(true);
|
||||
});
|
||||
|
||||
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
|
||||
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
|
||||
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-lo
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
applyPersistedExecutionWorkspaceConfig,
|
||||
assertGitSensitiveAdapterWorkspaceValid,
|
||||
buildRealizedExecutionWorkspaceFromPersisted,
|
||||
buildExplicitResumeSessionOverride,
|
||||
deriveTaskKeyWithHeartbeatFallback,
|
||||
@@ -38,6 +39,86 @@ function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceValidationInput = Parameters<typeof assertGitSensitiveAdapterWorkspaceValid>[0];
|
||||
|
||||
function buildWorkspaceValidationInput(
|
||||
overrides: Partial<WorkspaceValidationInput> = {},
|
||||
): WorkspaceValidationInput {
|
||||
return {
|
||||
adapterType: "codex_local",
|
||||
agentId: "agent-1",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
resolvedWorkspace: buildResolvedWorkspace(),
|
||||
executionWorkspace: {
|
||||
baseCwd: "/tmp/project",
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
strategy: "project_primary",
|
||||
cwd: "/tmp/project",
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
baseRefSha: null,
|
||||
},
|
||||
persistedExecutionWorkspace: {
|
||||
id: "execution-workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
sourceIssueId: "issue-1",
|
||||
mode: "project_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Primary workspace",
|
||||
status: "active",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: null,
|
||||
providerType: "local_path",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-06-06T00:00:00.000Z"),
|
||||
openedAt: new Date("2026-06-06T00:00:00.000Z"),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-06T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-06T00:00:00.000Z"),
|
||||
},
|
||||
executionTarget: { kind: "local" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectWorkspaceValidationFailure(
|
||||
input: WorkspaceValidationInput,
|
||||
reason: string,
|
||||
message: string,
|
||||
) {
|
||||
await expect(assertGitSensitiveAdapterWorkspaceValid(input)).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
message: expect.stringContaining(message),
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason,
|
||||
adapterType: input.adapterType,
|
||||
issueId: input.issue?.id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
@@ -125,6 +206,173 @@ function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; paren
|
||||
};
|
||||
}
|
||||
|
||||
describe("assertGitSensitiveAdapterWorkspaceValid", () => {
|
||||
it("rejects a project-workspace-linked issue that is missing its project id before adapter launch", async () => {
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: "workspace-1",
|
||||
},
|
||||
}),
|
||||
"missing_project_id",
|
||||
"linked to a project workspace but has no project id",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a git-sensitive local adapter when effective cwd differs from the persisted workspace cwd", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
cwd: "/tmp/agent-fallback",
|
||||
},
|
||||
}),
|
||||
"persisted_cwd_mismatch",
|
||||
'resolved adapter cwd "/tmp/agent-fallback"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a workspace-linked issue when no execution workspace was persisted", async () => {
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
persistedExecutionWorkspace: null,
|
||||
}),
|
||||
"missing_persisted_execution_workspace",
|
||||
"requires a project execution workspace",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a workspace-linked issue when no effective adapter cwd was resolved", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
cwd: null,
|
||||
},
|
||||
}),
|
||||
"missing_effective_cwd",
|
||||
"no adapter cwd was resolved",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a persisted execution workspace linked to a different project workspace", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
persistedExecutionWorkspace: {
|
||||
...input.persistedExecutionWorkspace!,
|
||||
projectWorkspaceId: "workspace-other",
|
||||
},
|
||||
}),
|
||||
"project_workspace_mismatch",
|
||||
'expected project workspace "workspace-1"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a persisted execution workspace missing its project workspace id", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
persistedExecutionWorkspace: {
|
||||
...input.persistedExecutionWorkspace!,
|
||||
projectWorkspaceId: null,
|
||||
},
|
||||
}),
|
||||
"persisted_workspace_missing_project_workspace_id",
|
||||
"has no project workspace id",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a workspace-linked issue that would launch from the agent fallback cwd", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
cwd: fallbackCwd,
|
||||
},
|
||||
persistedExecutionWorkspace: {
|
||||
...input.persistedExecutionWorkspace!,
|
||||
cwd: fallbackCwd,
|
||||
},
|
||||
}),
|
||||
"fallback_agent_home_cwd",
|
||||
"would launch from agent fallback cwd",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a git worktree persisted workspace when cwd differs from providerRef", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
strategy: "git_worktree",
|
||||
cwd: "/tmp/worktree-current",
|
||||
},
|
||||
persistedExecutionWorkspace: {
|
||||
...input.persistedExecutionWorkspace!,
|
||||
strategyType: "git_worktree",
|
||||
cwd: "/tmp/worktree-current",
|
||||
providerRef: "/tmp/worktree-expected",
|
||||
},
|
||||
}),
|
||||
"git_worktree_provider_ref_mismatch",
|
||||
'expected git worktree "/tmp/worktree-expected"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a workspace-linked issue when adapter cwd has no git metadata", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
const cwd = "/tmp/paperclip-workspace-without-git-metadata";
|
||||
|
||||
await expectWorkspaceValidationFailure(
|
||||
buildWorkspaceValidationInput({
|
||||
resolvedWorkspace: buildResolvedWorkspace({ cwd }),
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
baseCwd: cwd,
|
||||
cwd,
|
||||
},
|
||||
persistedExecutionWorkspace: {
|
||||
...input.persistedExecutionWorkspace!,
|
||||
cwd,
|
||||
},
|
||||
}),
|
||||
"missing_git_metadata",
|
||||
"has no .git metadata",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply the git-sensitive workspace guard to non-local execution targets", async () => {
|
||||
const input = buildWorkspaceValidationInput();
|
||||
|
||||
await expect(
|
||||
assertGitSensitiveAdapterWorkspaceValid(
|
||||
buildWorkspaceValidationInput({
|
||||
executionTarget: { kind: "cloud" },
|
||||
executionWorkspace: {
|
||||
...input.executionWorkspace,
|
||||
cwd: "/tmp/agent-fallback",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => {
|
||||
it("removes only the host-side provision command for sandbox-backed low-trust runs", () => {
|
||||
const config = {
|
||||
|
||||
@@ -3060,6 +3060,74 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the parent project when a generic child create inherits workspace linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Issue worktree",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parentIssueId,
|
||||
title: "Generic child issue",
|
||||
});
|
||||
|
||||
expect(child.parentId).toBe(parentIssueId);
|
||||
expect(child.projectId).toBe(projectId);
|
||||
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
@@ -3216,12 +3284,12 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
|
||||
const followUp = await svc.create(companyId, {
|
||||
projectId,
|
||||
title: "Follow-up issue",
|
||||
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
|
||||
});
|
||||
|
||||
expect(followUp.parentId).toBeNull();
|
||||
expect(followUp.projectId).toBe(projectId);
|
||||
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
|
||||
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
|
||||
@@ -3230,6 +3298,51 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("derives project identity when an update adds workspace linkage to a projectless issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Projectless issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
const updated = await svc.update(issueId, {
|
||||
projectWorkspaceId,
|
||||
});
|
||||
|
||||
expect(updated?.projectId).toBe(projectId);
|
||||
expect(updated?.projectWorkspaceId).toBe(projectWorkspaceId);
|
||||
});
|
||||
|
||||
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -240,6 +240,20 @@ const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0.25;
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure";
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry";
|
||||
const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length;
|
||||
const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
|
||||
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
|
||||
// Keep this in sync with local adapters that require a git workspace before launch.
|
||||
const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"grok_local",
|
||||
"hermes_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
export const MAX_TURN_CONTINUATION_RETRY_REASON = "max_turns_continuation";
|
||||
export const MAX_TURN_CONTINUATION_WAKE_REASON = "max_turns_continuation_retry";
|
||||
const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2;
|
||||
@@ -259,6 +273,17 @@ interface MaxTurnContinuationPolicy {
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export class WorkspaceValidationFailure extends Error {
|
||||
code = WORKSPACE_VALIDATION_FAILURE_CODE;
|
||||
resultJson: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, resultJson: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "WorkspaceValidationFailure";
|
||||
this.resultJson = resultJson;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallbackMode {
|
||||
if (attempt <= 1) return "same_session";
|
||||
if (attempt === 2) return "safer_invocation";
|
||||
@@ -891,6 +916,184 @@ async function ensureManagedProjectWorkspace(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkspaceValidationFailure(error: unknown): error is WorkspaceValidationFailure {
|
||||
return error instanceof WorkspaceValidationFailure;
|
||||
}
|
||||
|
||||
function isWorkspaceValidationFailedRun(
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "errorCode"> | null | undefined,
|
||||
) {
|
||||
return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
|
||||
}
|
||||
|
||||
async function hasGitMetadata(cwd: string | null | undefined) {
|
||||
const normalized = readNonEmptyString(cwd);
|
||||
if (!normalized) return false;
|
||||
return fs
|
||||
.lstat(path.resolve(normalized, ".git"))
|
||||
.then((entry) => entry.isDirectory() || entry.isFile())
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
function sameResolvedPath(left: string | null | undefined, right: string | null | undefined) {
|
||||
const leftPath = readNonEmptyString(left);
|
||||
const rightPath = readNonEmptyString(right);
|
||||
if (!leftPath || !rightPath) return false;
|
||||
return path.resolve(leftPath) === path.resolve(rightPath);
|
||||
}
|
||||
|
||||
export async function assertGitSensitiveAdapterWorkspaceValid(input: {
|
||||
adapterType: string;
|
||||
agentId: string;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
} | null;
|
||||
resolvedWorkspace: ResolvedWorkspaceForRun;
|
||||
executionWorkspace: RealizedExecutionWorkspace;
|
||||
persistedExecutionWorkspace: ExecutionWorkspace | null;
|
||||
executionTarget: unknown;
|
||||
environmentDriver?: string | null;
|
||||
leaseMetadata?: unknown;
|
||||
}) {
|
||||
if (!GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType)) return;
|
||||
|
||||
const executionTargetKind = readNonEmptyString((input.executionTarget as { kind?: unknown } | null)?.kind) ?? "local";
|
||||
if (executionTargetKind !== "local") return;
|
||||
|
||||
const issue = input.issue;
|
||||
if (!issue) return;
|
||||
|
||||
const environmentDriver = readNonEmptyString(input.environmentDriver) ?? "local";
|
||||
const leaseMetadata = parseObject(input.leaseMetadata);
|
||||
const leaseProviderMetadata = parseObject(leaseMetadata.providerMetadata);
|
||||
const leaseRemoteCwd =
|
||||
readNonEmptyString(leaseMetadata.remoteCwd) ??
|
||||
readNonEmptyString(leaseProviderMetadata.remoteCwd);
|
||||
|
||||
const effectiveCwd = readNonEmptyString(input.executionWorkspace.cwd);
|
||||
const persistedCwd = readNonEmptyString(input.persistedExecutionWorkspace?.cwd);
|
||||
const agentFallbackCwd = resolveDefaultAgentWorkspaceDir(input.agentId);
|
||||
const workspaceExpectation =
|
||||
Boolean(issue.projectWorkspaceId) ||
|
||||
Boolean(input.resolvedWorkspace.workspaceId) ||
|
||||
input.executionWorkspace.strategy === "git_worktree";
|
||||
|
||||
const fail = (reason: string, message: string, extra: Record<string, unknown> = {}) => {
|
||||
throw new WorkspaceValidationFailure(message, {
|
||||
workspaceValidation: {
|
||||
reason,
|
||||
adapterType: input.adapterType,
|
||||
issueId: issue.id,
|
||||
issueIdentifier: issue.identifier,
|
||||
issueProjectId: issue.projectId,
|
||||
issueProjectWorkspaceId: issue.projectWorkspaceId,
|
||||
resolvedWorkspaceSource: input.resolvedWorkspace.source,
|
||||
resolvedProjectId: input.resolvedWorkspace.projectId,
|
||||
resolvedProjectWorkspaceId: input.resolvedWorkspace.workspaceId,
|
||||
resolvedWorkspaceCwd: input.resolvedWorkspace.cwd,
|
||||
executionWorkspaceCwd: effectiveCwd,
|
||||
executionWorkspaceStrategy: input.executionWorkspace.strategy,
|
||||
executionWorkspaceProjectId: input.executionWorkspace.projectId,
|
||||
executionWorkspaceProjectWorkspaceId: input.executionWorkspace.workspaceId,
|
||||
persistedExecutionWorkspaceId: input.persistedExecutionWorkspace?.id ?? null,
|
||||
persistedWorkspaceCwd: persistedCwd,
|
||||
persistedWorkspaceStrategy: input.persistedExecutionWorkspace?.strategyType ?? null,
|
||||
persistedProjectId: input.persistedExecutionWorkspace?.projectId ?? null,
|
||||
persistedProjectWorkspaceId: input.persistedExecutionWorkspace?.projectWorkspaceId ?? null,
|
||||
persistedProviderRef: input.persistedExecutionWorkspace?.providerRef ?? null,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (issue.projectWorkspaceId && !issue.projectId) {
|
||||
fail(
|
||||
"missing_project_id",
|
||||
`Issue ${issue.identifier ?? issue.id} is linked to a project workspace but has no project id; refusing to launch ${input.adapterType} from fallback cwd.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!input.executionTarget && environmentDriver !== "local" && leaseRemoteCwd) return;
|
||||
|
||||
if (workspaceExpectation && !input.persistedExecutionWorkspace) {
|
||||
fail(
|
||||
"missing_persisted_execution_workspace",
|
||||
`Issue ${issue.identifier ?? issue.id} requires a project execution workspace, but none was persisted before adapter launch.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspaceExpectation && !effectiveCwd) {
|
||||
fail(
|
||||
"missing_effective_cwd",
|
||||
`Issue ${issue.identifier ?? issue.id} expected a project workspace, but no adapter cwd was resolved before launch.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
input.persistedExecutionWorkspace &&
|
||||
effectiveCwd &&
|
||||
persistedCwd &&
|
||||
!sameResolvedPath(effectiveCwd, persistedCwd)
|
||||
) {
|
||||
fail(
|
||||
"persisted_cwd_mismatch",
|
||||
`Issue ${issue.identifier ?? issue.id} resolved adapter cwd "${effectiveCwd}" but persisted execution workspace cwd is "${persistedCwd}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedProjectWorkspaceId = issue.projectWorkspaceId ?? input.resolvedWorkspace.workspaceId ?? null;
|
||||
if (
|
||||
expectedProjectWorkspaceId &&
|
||||
input.persistedExecutionWorkspace &&
|
||||
!input.persistedExecutionWorkspace.projectWorkspaceId
|
||||
) {
|
||||
fail(
|
||||
"persisted_workspace_missing_project_workspace_id",
|
||||
`Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace has no project workspace id.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
expectedProjectWorkspaceId &&
|
||||
input.persistedExecutionWorkspace?.projectWorkspaceId &&
|
||||
input.persistedExecutionWorkspace.projectWorkspaceId !== expectedProjectWorkspaceId
|
||||
) {
|
||||
fail(
|
||||
"project_workspace_mismatch",
|
||||
`Issue ${issue.identifier ?? issue.id} expected project workspace "${expectedProjectWorkspaceId}" but persisted execution workspace points at "${input.persistedExecutionWorkspace.projectWorkspaceId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspaceExpectation && effectiveCwd && sameResolvedPath(effectiveCwd, agentFallbackCwd)) {
|
||||
fail(
|
||||
"fallback_agent_home_cwd",
|
||||
`Issue ${issue.identifier ?? issue.id} expected a project workspace, but ${input.adapterType} would launch from agent fallback cwd "${effectiveCwd}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
input.persistedExecutionWorkspace?.strategyType === "git_worktree" &&
|
||||
input.persistedExecutionWorkspace.providerRef &&
|
||||
effectiveCwd &&
|
||||
!sameResolvedPath(effectiveCwd, input.persistedExecutionWorkspace.providerRef)
|
||||
) {
|
||||
fail(
|
||||
"git_worktree_provider_ref_mismatch",
|
||||
`Issue ${issue.identifier ?? issue.id} expected git worktree "${input.persistedExecutionWorkspace.providerRef}" but adapter cwd resolved to "${effectiveCwd}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspaceExpectation && effectiveCwd && !await hasGitMetadata(effectiveCwd)) {
|
||||
fail(
|
||||
"missing_git_metadata",
|
||||
`Issue ${issue.identifier ?? issue.id} expected a git workspace for ${input.adapterType}, but "${effectiveCwd}" has no .git metadata.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatRunProcessGroupIdColumn =
|
||||
heartbeatRuns.processGroupId ?? sql<number | null>`NULL`.as("processGroupId");
|
||||
|
||||
@@ -8314,6 +8517,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const logEntry = formatRuntimeWorkspaceWarningLog(warning);
|
||||
await onLog(logEntry.stream, logEntry.chunk);
|
||||
}
|
||||
await assertGitSensitiveAdapterWorkspaceValid({
|
||||
adapterType: agent.adapterType,
|
||||
agentId: agent.id,
|
||||
issue: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
projectId: issueRef.projectId,
|
||||
projectWorkspaceId: issueRef.projectWorkspaceId,
|
||||
}
|
||||
: null,
|
||||
resolvedWorkspace,
|
||||
executionWorkspace,
|
||||
persistedExecutionWorkspace,
|
||||
executionTarget,
|
||||
environmentDriver: selectedEnvironment.driver,
|
||||
leaseMetadata: activeEnvironmentLease.lease.metadata,
|
||||
});
|
||||
const adapterEnv = Object.fromEntries(
|
||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
@@ -8798,6 +9019,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailure(err) ? err : null;
|
||||
const failureErrorCode = workspaceValidationFailure?.code ?? "adapter_failed";
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
|
||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||
@@ -8818,11 +9041,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
const failedRun = await setRunStatus(run.id, "failed", {
|
||||
error: message,
|
||||
errorCode: "adapter_failed",
|
||||
errorCode: failureErrorCode,
|
||||
finishedAt: new Date(),
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "failed", {
|
||||
errorCode: "adapter_failed",
|
||||
errorCode: failureErrorCode,
|
||||
errorMessage: message,
|
||||
resultJson: workspaceValidationFailure?.resultJson ?? null,
|
||||
}),
|
||||
stdoutExcerpt,
|
||||
stderrExcerpt,
|
||||
@@ -8844,7 +9068,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun;
|
||||
await refreshContinuationSummaryForRun(livenessRun, agent);
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
|
||||
await updateRuntimeState(agent, livenessRun, {
|
||||
@@ -8907,7 +9133,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined);
|
||||
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined);
|
||||
if (!isWorkspaceValidationFailedRun(livenessRun)) {
|
||||
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(livenessRun).catch(() => undefined);
|
||||
}
|
||||
@@ -8949,6 +9177,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkspaceValidationRecoveryComment(input: {
|
||||
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined;
|
||||
}) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
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 ?? ""} ` +
|
||||
"Moving it to `blocked` with a source-scoped recovery action so the workspace link, cwd, or git checkout can be repaired before resuming."
|
||||
);
|
||||
}
|
||||
|
||||
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const runContext = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(runContext.issueId);
|
||||
@@ -9001,6 +9240,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.where(eq(issues.id, issue.id));
|
||||
}
|
||||
|
||||
if (
|
||||
isWorkspaceValidationFailedRun(run) &&
|
||||
(issue.status === "todo" || issue.status === "in_progress") &&
|
||||
!issue.assigneeUserId &&
|
||||
issue.assigneeAgentId === run.agentId
|
||||
) {
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment: buildWorkspaceValidationRecoveryComment({ latestRun: run }),
|
||||
recoveryCause: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
|
||||
};
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const deferred = await tx
|
||||
.select()
|
||||
@@ -9247,17 +9501,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const shouldBlockImmediately =
|
||||
!recoveryAgentInvokable ||
|
||||
!recoveryAgent ||
|
||||
isWorkspaceValidationFailedRun(run) ||
|
||||
didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed");
|
||||
if (shouldBlockImmediately) {
|
||||
const comment = buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
const workspaceValidationFailure = isWorkspaceValidationFailedRun(run);
|
||||
const comment = workspaceValidationFailure
|
||||
? buildWorkspaceValidationRecoveryComment({ latestRun: run })
|
||||
: buildImmediateExecutionPathRecoveryComment({
|
||||
status: issue.status as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
});
|
||||
return {
|
||||
kind: "blocked" as const,
|
||||
issue,
|
||||
previousStatus: issue.status,
|
||||
comment,
|
||||
recoveryCause: workspaceValidationFailure ? WORKSPACE_VALIDATION_RECOVERY_CAUSE : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9340,6 +9599,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
previousStatus: promotionResult.previousStatus as "todo" | "in_progress",
|
||||
latestRun: run,
|
||||
comment: promotionResult.comment,
|
||||
recoveryCause:
|
||||
promotionResult.recoveryCause === WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3461,6 +3461,7 @@ export function issueService(db: Db) {
|
||||
if (projectId && workspace.projectId !== projectId) {
|
||||
throw unprocessable("Project workspace must belong to the selected project");
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function assertValidExecutionWorkspace(
|
||||
@@ -3483,6 +3484,7 @@ export function issueService(db: Db) {
|
||||
if (projectId && workspace.projectId !== projectId) {
|
||||
throw unprocessable("Execution workspace must belong to the selected project");
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
|
||||
@@ -4741,7 +4743,6 @@ export function issueService(db: Db) {
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
||||
let executionWorkspaceId = issueData.executionWorkspaceId ?? null;
|
||||
let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null;
|
||||
@@ -4754,6 +4755,9 @@ export function issueService(db: Db) {
|
||||
issueData.executionWorkspaceSettings !== undefined;
|
||||
if (workspaceInheritanceIssueId) {
|
||||
const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId);
|
||||
if (issueData.projectId == null && workspaceSource.projectId) {
|
||||
issueData.projectId = workspaceSource.projectId;
|
||||
}
|
||||
if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) {
|
||||
projectWorkspaceId = workspaceSource.projectWorkspaceId;
|
||||
}
|
||||
@@ -4780,6 +4784,15 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (issueData.projectId == null && projectWorkspaceId) {
|
||||
const workspace = await assertValidProjectWorkspace(companyId, null, projectWorkspaceId, tx);
|
||||
issueData.projectId = workspace.projectId;
|
||||
}
|
||||
if (issueData.projectId == null && executionWorkspaceId) {
|
||||
const workspace = await assertValidExecutionWorkspace(companyId, null, executionWorkspaceId, tx);
|
||||
issueData.projectId = workspace.projectId;
|
||||
}
|
||||
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||
// Cache the project policy lookup for this insert. Both the
|
||||
// default-settings block and the assignee-environment-promotion block
|
||||
// need the same row; without caching they'd issue two round-trips.
|
||||
@@ -5014,7 +5027,7 @@ export function issueService(db: Db) {
|
||||
if (issueData.assigneeUserId) {
|
||||
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
|
||||
}
|
||||
const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
|
||||
let nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
|
||||
const nextProjectWorkspaceId =
|
||||
issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
|
||||
const nextExecutionWorkspaceId =
|
||||
@@ -5027,11 +5040,29 @@ export function issueService(db: Db) {
|
||||
issueData.executionWorkspaceSettings !== undefined
|
||||
? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings)
|
||||
: parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings);
|
||||
let validatedProjectWorkspace: { projectId: string } | null = null;
|
||||
let validatedExecutionWorkspace: { projectId: string } | null = null;
|
||||
if (!nextProjectId && nextProjectWorkspaceId) {
|
||||
const workspace = await assertValidProjectWorkspace(existing.companyId, null, nextProjectWorkspaceId);
|
||||
validatedProjectWorkspace = workspace;
|
||||
nextProjectId = workspace.projectId;
|
||||
patch.projectId = workspace.projectId;
|
||||
}
|
||||
if (!nextProjectId && nextExecutionWorkspaceId) {
|
||||
const workspace = await assertValidExecutionWorkspace(existing.companyId, null, nextExecutionWorkspaceId);
|
||||
validatedExecutionWorkspace = workspace;
|
||||
nextProjectId = workspace.projectId;
|
||||
patch.projectId = workspace.projectId;
|
||||
}
|
||||
if (nextProjectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
|
||||
if (!validatedProjectWorkspace) {
|
||||
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
|
||||
}
|
||||
}
|
||||
if (nextExecutionWorkspaceId) {
|
||||
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
|
||||
if (!validatedExecutionWorkspace) {
|
||||
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
applyStatusSideEffects(issueData.status, patch);
|
||||
|
||||
@@ -102,7 +102,10 @@ type LatestIssueRun = Pick<
|
||||
> | null;
|
||||
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
|
||||
|
||||
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
type StrandedRecoveryCause =
|
||||
| "stranded_assigned_issue"
|
||||
| "workspace_validation_failed"
|
||||
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
sourceRunId: string | null;
|
||||
@@ -2036,6 +2039,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
function strandedRecoveryActionKind(cause: StrandedRecoveryCause) {
|
||||
return cause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "missing_disposition" as const
|
||||
: cause === "workspace_validation_failed"
|
||||
? "workspace_validation" as const
|
||||
: "stranded_assigned_issue" as const;
|
||||
}
|
||||
|
||||
@@ -2109,8 +2114,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}),
|
||||
nextAction: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "Choose and record a valid issue disposition without copying transcript content."
|
||||
: recoveryCause === "workspace_validation_failed"
|
||||
? "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
|
||||
: "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.",
|
||||
wakePolicy: ownerAgentId
|
||||
wakePolicy: recoveryCause === "workspace_validation_failed"
|
||||
? {
|
||||
type: "manual_repair_required",
|
||||
reason: "workspace_validation_failed",
|
||||
ownerAgentId,
|
||||
}
|
||||
: ownerAgentId
|
||||
? {
|
||||
type: "wake_owner",
|
||||
reason: "source_scoped_recovery_action",
|
||||
@@ -2134,6 +2147,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
latestRun: LatestIssueRun;
|
||||
recoveryCause: StrandedRecoveryCause;
|
||||
}) {
|
||||
if (input.recoveryCause === "workspace_validation_failed") return;
|
||||
if (!input.action.ownerAgentId) return;
|
||||
await deps.enqueueWakeup(input.action.ownerAgentId, {
|
||||
source: "assignment",
|
||||
@@ -2337,7 +2351,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
|
||||
].join("\n");
|
||||
|
||||
if (recoveryAction.attemptCount === 1) {
|
||||
const shouldPostEscalationComment =
|
||||
recoveryAction.attemptCount === 1 ||
|
||||
input.recoveryCause === "workspace_validation_failed";
|
||||
if (shouldPostEscalationComment) {
|
||||
const escalationCommentMarker = `Recovery action: \`${recoveryAction.id}\``;
|
||||
|
||||
const hasEscalationComment = await db
|
||||
@@ -2388,6 +2405,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
previousStatus: input.previousStatus,
|
||||
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "recovery.reconcile_successful_run_handoff_missing_state"
|
||||
: input.recoveryCause === "workspace_validation_failed"
|
||||
? "recovery.reconcile_workspace_validation_failed"
|
||||
: "recovery.reconcile_stranded_assigned_issue",
|
||||
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
|
||||
latestRunId: input.latestRun?.id ?? null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -27,6 +27,18 @@ vi.mock("../api/issues", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act<T>(callback: () => T): T {
|
||||
let result: T | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
const maybePromise = result as unknown as PromiseLike<unknown>;
|
||||
if (result && typeof maybePromise.then === "function") {
|
||||
throw new TypeError("This test act shim only supports synchronous callbacks.");
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
@@ -158,9 +170,8 @@ describe("IssueBlockedNotice", () => {
|
||||
expect(button).not.toBeNull();
|
||||
expect(button!.textContent ?? "").toContain("Retry now");
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
button!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -263,6 +274,65 @@ describe("IssueBlockedNotice", () => {
|
||||
);
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
|
||||
expect(indicator?.getAttribute("data-recovery-kind")).toBe("missing_disposition");
|
||||
expect(indicator?.textContent).toContain("Recovery needed");
|
||||
});
|
||||
|
||||
it("labels a workspace_validation blocker recovery distinctly", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-2",
|
||||
identifier: "PAP-409",
|
||||
title: "Workspace cwd lost git context",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
activeRecoveryAction: {
|
||||
id: "rec-2",
|
||||
companyId: "co-1",
|
||||
sourceIssueId: "blocker-2",
|
||||
recoveryIssueId: null,
|
||||
kind: "workspace_validation",
|
||||
status: "active",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: "agent-cto",
|
||||
ownerUserId: null,
|
||||
previousOwnerAgentId: null,
|
||||
returnOwnerAgentId: null,
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-2",
|
||||
evidence: {
|
||||
latestRunErrorCode: "workspace_validation_failed",
|
||||
},
|
||||
nextAction:
|
||||
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
|
||||
wakePolicy: { type: "wake_owner" },
|
||||
monitorPolicy: null,
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
timeoutAt: null,
|
||||
lastAttemptAt: null,
|
||||
outcome: null,
|
||||
resolutionNote: null,
|
||||
resolvedAt: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const indicator = node.querySelector(
|
||||
'[data-testid="issue-blocked-notice-recovery-indicator"]',
|
||||
);
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
|
||||
expect(indicator?.getAttribute("data-recovery-kind")).toBe("workspace_validation");
|
||||
expect(indicator?.textContent).toContain("Workspace recovery needed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
recoveryChipLabel,
|
||||
} from "../lib/recovery-display";
|
||||
|
||||
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
@@ -24,17 +25,19 @@ function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
const label = recoveryChipLabel(state, action.kind);
|
||||
return (
|
||||
<span
|
||||
data-testid="issue-blocked-notice-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
data-recovery-kind={action.kind}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
aria-label={label}
|
||||
title={`${label} — open the source issue to act.`}
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
@@ -16,6 +16,18 @@ vi.mock("@/lib/router", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act<T>(callback: () => T): T {
|
||||
let result: T | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
const maybePromise = result as unknown as PromiseLike<unknown>;
|
||||
if (result && typeof maybePromise.then === "function") {
|
||||
throw new TypeError("This test act shim only supports synchronous callbacks.");
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
@@ -157,6 +169,33 @@ describe("IssueRecoveryActionCard", () => {
|
||||
expect(node.textContent).toContain("OBSERVING ACTIVE RUN");
|
||||
});
|
||||
|
||||
it("renders a workspace-specific label and headline for workspace_validation", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
nextAction:
|
||||
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
|
||||
wakePolicy: { type: "manual_repair_required" },
|
||||
evidence: {
|
||||
recoveryCause: "workspace_validation_failed",
|
||||
latestRunErrorCode: "workspace_validation_failed",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const section = node.querySelector("section[aria-label]");
|
||||
expect(section?.getAttribute("data-recovery-kind")).toBe("workspace_validation");
|
||||
expect(node.textContent).toContain("Workspace Validation");
|
||||
expect(node.textContent).not.toContain("workspace_validation\n");
|
||||
expect(node.textContent).toContain(
|
||||
"Paperclip stopped this run because the issue's git workspace could not be validated.",
|
||||
);
|
||||
expect(node.textContent).toContain("Repair the source issue workspace link");
|
||||
expect(node.textContent).toContain("Manual repair required");
|
||||
});
|
||||
|
||||
it("renders the resolved label and outcome when resolved", () => {
|
||||
const node = render(
|
||||
<IssueRecoveryActionCard action={buildAction({ status: "resolved", outcome: "restored", resolvedAt: "2026-05-09T19:35:00.000Z" })} />,
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface IssueRecoveryActionCardProps {
|
||||
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||||
missing_disposition: "Missing Disposition",
|
||||
stranded_assigned_issue: "Stranded Issue",
|
||||
workspace_validation: "Workspace Validation",
|
||||
active_run_watchdog: "Active Watchdog",
|
||||
issue_graph_liveness: "Graph Liveness",
|
||||
};
|
||||
@@ -54,6 +55,8 @@ const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||||
missing_disposition: "This issue's run finished, but no next step was chosen.",
|
||||
stranded_assigned_issue:
|
||||
"Paperclip retried this issue's last run and it still has no live execution path.",
|
||||
workspace_validation:
|
||||
"Paperclip stopped this run because the issue's git workspace could not be validated.",
|
||||
active_run_watchdog:
|
||||
"The active run has been silent. Recovery is observing without interrupting it.",
|
||||
issue_graph_liveness:
|
||||
@@ -169,6 +172,7 @@ function readWakePolicySummary(action: IssueRecoveryAction): string | null {
|
||||
if (type === "wake_owner") return "Corrective wake queued";
|
||||
if (type === "board_escalation") return "Escalated to board";
|
||||
if (type === "manual") return "Manual";
|
||||
if (type === "manual_repair_required") return "Manual repair required";
|
||||
if (type === "monitor") {
|
||||
const interval = readEvidenceString(policy.intervalLabel);
|
||||
return interval ? `Monitor scheduled · ${interval}` : "Monitor scheduled";
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { deriveActiveRecoveryDisplayState, RECOVERY_CHIP_DEFAULT_TONE } from "../lib/recovery-display";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
RECOVERY_CHIP_DEFAULT_TONE,
|
||||
recoveryChipLabel,
|
||||
} from "../lib/recovery-display";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
@@ -233,21 +237,23 @@ function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): Rea
|
||||
if (!state) return null;
|
||||
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
||||
const Icon = tone.icon;
|
||||
const label = recoveryChipLabel(state, action.kind);
|
||||
return (
|
||||
<span
|
||||
data-testid="issue-row-recovery-indicator"
|
||||
data-recovery-state={state}
|
||||
data-recovery-kind={action.kind}
|
||||
role="status"
|
||||
aria-label={tone.label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||
tone.className,
|
||||
selected ? "!border-muted-foreground !text-muted-foreground" : null,
|
||||
)}
|
||||
title={`${tone.label} — open the source issue to act.`}
|
||||
title={`${label} — open the source issue to act.`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" aria-hidden />
|
||||
{tone.label}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveActiveRecoveryDisplayState,
|
||||
deriveRecoveryDisplayState,
|
||||
recoveryChipLabel,
|
||||
} from "./recovery-display";
|
||||
|
||||
describe("recoveryChipLabel", () => {
|
||||
it("returns the workspace-specific label when kind is workspace_validation and state is needed", () => {
|
||||
expect(recoveryChipLabel("needed", "workspace_validation")).toBe(
|
||||
"Workspace recovery needed",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic label for other needed kinds", () => {
|
||||
expect(recoveryChipLabel("needed", "missing_disposition")).toBe("Recovery needed");
|
||||
expect(recoveryChipLabel("needed", "stranded_assigned_issue")).toBe("Recovery needed");
|
||||
expect(recoveryChipLabel("needed", "issue_graph_liveness")).toBe("Recovery needed");
|
||||
});
|
||||
|
||||
it("does not override the chip label for non-needed states", () => {
|
||||
expect(recoveryChipLabel("in_progress", "workspace_validation")).toBe(
|
||||
"Recovery in progress",
|
||||
);
|
||||
expect(recoveryChipLabel("escalated", "workspace_validation")).toBe(
|
||||
"Recovery escalated",
|
||||
);
|
||||
expect(recoveryChipLabel("observe_only", "workspace_validation")).toBe(
|
||||
"Observing active run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRecoveryDisplayState", () => {
|
||||
const base = {
|
||||
status: "active" as const,
|
||||
kind: "missing_disposition" as const,
|
||||
outcome: null,
|
||||
};
|
||||
|
||||
it("classifies workspace_validation active as needed", () => {
|
||||
expect(deriveRecoveryDisplayState({ ...base, kind: "workspace_validation" })).toBe(
|
||||
"needed",
|
||||
);
|
||||
expect(
|
||||
deriveActiveRecoveryDisplayState({ ...base, kind: "workspace_validation" }),
|
||||
).toBe("needed");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type { IssueRecoveryAction, IssueRecoveryActionKind } from "@paperclipai/shared";
|
||||
import { Eye, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
|
||||
export type RecoveryDisplayState =
|
||||
@@ -55,3 +55,13 @@ export function deriveActiveRecoveryDisplayState(
|
||||
const state = deriveRecoveryDisplayState(action);
|
||||
return state === "resolved" ? null : state;
|
||||
}
|
||||
|
||||
export function recoveryChipLabel(
|
||||
state: ActiveRecoveryDisplayState,
|
||||
kind: IssueRecoveryActionKind,
|
||||
): string {
|
||||
if (kind === "workspace_validation" && state === "needed") {
|
||||
return "Workspace recovery needed";
|
||||
}
|
||||
return RECOVERY_CHIP_DEFAULT_TONE[state].label;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ function AllStatesPanel() {
|
||||
nextAction: "Issue restored to a valid disposition.",
|
||||
})}
|
||||
/>
|
||||
<CardPanel
|
||||
caption="State 6 · Workspace validation (kind: workspace_validation)"
|
||||
action={buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-workspace",
|
||||
evidence: {
|
||||
latestRunErrorCode: "workspace_validation_failed",
|
||||
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
|
||||
},
|
||||
nextAction:
|
||||
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
|
||||
})}
|
||||
canFalsePositive
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,6 +188,20 @@ function BlockerNoticePanel() {
|
||||
identifier: "PAP-9051",
|
||||
title: "Bare blocker without recovery state",
|
||||
}),
|
||||
buildBlocker({
|
||||
id: "blocker-5",
|
||||
identifier: "PAP-10409",
|
||||
title: "Phase 6 follow-up failed: git workspace lost",
|
||||
status: "blocked",
|
||||
activeRecoveryAction: buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-workspace-blocker",
|
||||
evidence: { latestRunErrorCode: "workspace_validation_failed" },
|
||||
nextAction:
|
||||
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -322,6 +351,23 @@ function InboxRowPanel() {
|
||||
activeRecoveryAction: buildAction({ status: "escalated" }),
|
||||
}}
|
||||
/>
|
||||
<IssueRow
|
||||
issue={{
|
||||
...baseIssue,
|
||||
id: "issue-recovery-workspace",
|
||||
identifier: "PAP-10409",
|
||||
title: "Phase 6 follow-up failed: git workspace lost",
|
||||
status: "blocked",
|
||||
activeRecoveryAction: buildAction({
|
||||
kind: "workspace_validation",
|
||||
cause: "workspace_validation_failed",
|
||||
fingerprint: "fp-workspace-row",
|
||||
evidence: { latestRunErrorCode: "workspace_validation_failed" },
|
||||
nextAction:
|
||||
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user