[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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user