mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
PAPA-430: workspace finalize gates + no-remote-git enforcement (#6969)
## Thinking Path > - Paperclip orchestrates AI agents across isolated execution workspaces; the local cwd is the only persistence boundary between runs. > - Workspace lifecycle (worktree_prepare → execute → workspace_finalize) and the wake/accept flow are what guarantee that dependent issues see a consistent worktree. > - PAPA-380 / PAPA-431 / PAPA-432 / PAPA-440 surfaced three holes in that contract: silent env reuse across assignees, dependent wakes firing before finalize, and `issue.interaction.accept` advancing before finalize landed. > - PAPA-441 / PAPA-442 then needed to document the "no remote git" contract and prevent future adapter/runtime code from quietly reintroducing `git push` as a backdoor sync. > - This pull request lands those server fixes, the static `check-no-git-push` enforcement, the AUTHORING.md cross-link, and the Cody-review follow-ups on the PAPA-430 thread. > - The benefit is that finalize is a real barrier — board accepts, dependent wakes, and operator-set env all respect it — and adapter code can't bypass it via raw `git push`. ## What Changed - **server (PAPA-380, PAPA-431):** `execution-workspace-policy` refuses silent env reuse when the assignee's resolved env disagrees with the workspace it would inherit. The inheritance protection is now scoped to the actual inheritance signal — explicit issue-level `environmentId` is honored even when the agent's default env is `null`. - **server (PAPA-432):** `heartbeat.ts` gates dependent wakes on `listUnfinalizedExecutionWorkspaceIds`, and writes a `workspace_finalize` row on the succeeded path. Write failures now surface instead of being swallowed so dependents aren't silently stranded behind a missing row. - **server (PAPA-440):** `issue-thread-interactions.acceptInteraction` adds a workspace_finalize precondition for `request_confirmation` (not `suggest_tasks`). Accept returns 409 if finalize hasn't succeeded for the latest workspace operation. - **ci (PAPA-442):** new `scripts/check-no-git-push.mjs` static check scans `packages/adapters/`, `packages/adapter-utils/`, `server/src/`, and `cli/src/` for any `git push` invocation (string or args-array). Wired into the `policy` PR job and `test:release-registry`. Operators can opt in per-call with `// paperclip:allow-git-push: <reason>`. Release scripts are out of scope by design. - **docs (PAPA-441):** `AUTHORING.md` documents the no-remote-git contract and cross-links the static check so adapter authors learn the rule and the enforcement together. - **review follow-up (PAPA-430, Cody):** three fixes — env resolver bug, accept-gate scope (request_confirmation only), and finalize record write on the succeeded path. ## Verification - `pnpm exec vitest run server/src/__tests__/execution-workspace-policy.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts` → 33/33 pass - `node scripts/check-no-git-push.test.mjs` → check covers string form, args-array form, comment exclusions, and per-line allow-comment. - Manual: server compiles; the policy job runs the check in <1s before heavier jobs. ## Risks - **Behavioral shift in accept:** boards accepting `request_confirmation` while finalize is in-flight now get 409s. This is intentional — they can retry — but it changes timing on a hot path. `suggest_tasks` is unaffected. - **Workspace policy:** the env-reuse refusal is a new error path. Issues that previously silently reused an env from a different-assignee workspace will now fail-loud; the resolver still honors explicit issue-level `executionWorkspaceSettings.environmentId`. - **CI rule:** any future legitimate `git push` in scoped dirs must be marked with the allow-comment, which is the intended ergonomic. ## Model Used - Claude Opus 4.7 (`claude-opus-4-7`, extended thinking), via Claude Code in the Paperclip executor adapter. ## 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 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 (N/A — server/CI/docs only) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Closes related issues: PAPA-430, PAPA-380, PAPA-431, PAPA-432, PAPA-440, PAPA-441, PAPA-442 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
524e18b060
commit
1f70fd9a22
25 changed files with 21133 additions and 95 deletions
|
|
@ -148,16 +148,117 @@ describe("execution workspace policy helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("prefers persisted environment selection over issue and project defaults", () => {
|
||||
it("reuses persisted workspace environment when it agrees with the assignee's identity", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "agent-env" },
|
||||
issueSettings: { environmentId: "agent-env" },
|
||||
workspaceConfig: { environmentId: "agent-env" },
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "agent-env",
|
||||
source: "workspace",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses silent reuse when the persisted workspace env disagrees with the assignee (PAPA-380: sandbox agent on local workspace)", () => {
|
||||
// Claude E2B was assigned to a child issue whose parent had already
|
||||
// realized a `Local` workspace. The persisted workspace env must not
|
||||
// shadow the agent's intended sandbox env.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
|
||||
workspaceConfig: { environmentId: "local-env" },
|
||||
agentDefaultEnvironmentId: "sandbox-env",
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "sandbox-env",
|
||||
source: "issue",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "local-env",
|
||||
assigneeIntendedEnvironmentId: "sandbox-env",
|
||||
assigneeIntendedSource: "issue",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses silent reuse when a null-default (local) agent inherits a non-local workspace env (PAPA-431: Manual QA on engineer SSH workspace)", () => {
|
||||
// Manual QA agent has defaultEnvironmentId: null. When a sibling issue's
|
||||
// SSH workspace is inherited via inheritExecutionWorkspaceFromIssueId,
|
||||
// the persisted SSH env must NOT shadow the agent's deliberate local
|
||||
// identity. The inherited issueSettings.environmentId is treated as a
|
||||
// promoted artifact, not an explicit operator choice.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "ssh-env", mode: "isolated_workspace" },
|
||||
workspaceConfig: { environmentId: "ssh-env" },
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "local-env",
|
||||
source: "default",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "ssh-env",
|
||||
assigneeIntendedEnvironmentId: "local-env",
|
||||
assigneeIntendedSource: "default",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit issue env override for null-default agents when no workspace is being reused", () => {
|
||||
// Operator explicitly chose an env on this issue via PATCH (see the
|
||||
// issues-service contract at issues-service.test.ts:1924). For null-default
|
||||
// agents, this is a deliberate choice — only inherited issue env (which
|
||||
// matches a reused workspace env) should be discarded.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
issueSettings: { environmentId: "issue-env" },
|
||||
workspaceConfig: { environmentId: "workspace-env" },
|
||||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
workspaceConfig: null,
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toBe("workspace-env");
|
||||
).toEqual({
|
||||
environmentId: "issue-env",
|
||||
source: "issue",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit issue env override for null-default agents even against a disagreeing reused workspace", () => {
|
||||
// Operator picked sandbox-env explicitly while the previously-realized
|
||||
// workspace was on local-env. The mismatch is genuine — surface a conflict
|
||||
// so the heartbeat forces a fresh realization on the operator's chosen env.
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
issueSettings: { environmentId: "sandbox-env", mode: "shared_workspace" },
|
||||
workspaceConfig: { environmentId: "local-env" },
|
||||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "local-env",
|
||||
}),
|
||||
).toEqual({
|
||||
environmentId: "sandbox-env",
|
||||
source: "issue",
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId: "local-env",
|
||||
assigneeIntendedEnvironmentId: "sandbox-env",
|
||||
assigneeIntendedSource: "issue",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the explicit issue environment over project and agent defaults when no workspace is reused", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
|
|
@ -166,7 +267,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("issue-env");
|
||||
).toEqual({
|
||||
environmentId: "issue-env",
|
||||
source: "issue",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: "project-env" },
|
||||
|
|
@ -175,7 +280,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("project-env");
|
||||
).toEqual({
|
||||
environmentId: "project-env",
|
||||
source: "project",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the agent default environment before the company default", () => {
|
||||
|
|
@ -187,7 +296,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("agent-env");
|
||||
).toEqual({
|
||||
environmentId: "agent-env",
|
||||
source: "agent",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
|
|
@ -196,7 +309,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: "agent-env",
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "project",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: null,
|
||||
|
|
@ -205,7 +322,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "default",
|
||||
conflict: null,
|
||||
});
|
||||
expect(
|
||||
resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: { enabled: true, environmentId: null },
|
||||
|
|
@ -214,7 +335,11 @@ describe("execution workspace policy helpers", () => {
|
|||
agentDefaultEnvironmentId: null,
|
||||
defaultEnvironmentId: "default-env",
|
||||
}),
|
||||
).toBe("default-env");
|
||||
).toEqual({
|
||||
environmentId: "default-env",
|
||||
source: "default",
|
||||
conflict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
issueRelations,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -142,6 +144,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(environments);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
issueTreeHolds,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -378,6 +380,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(documentAnnotationComments);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
|
|
@ -15,6 +16,9 @@ import {
|
|||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -48,7 +52,11 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
await db.delete(documents);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
|
|
@ -1135,4 +1143,262 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = 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: false });
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "exec",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accept gate fixture",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Issue with execution workspace",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accept of suggest_tasks even when no successful workspace_finalize has landed", async () => {
|
||||
// suggest_tasks acceptance only creates follow-up issues; it does not
|
||||
// approve code state or move the source workspace forward, so the
|
||||
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
||||
// carve-out the board cannot triage suggested tasks on an issue whose
|
||||
// latest workspace op is still worktree_prepare.
|
||||
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "suggest_tasks",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
tasks: [
|
||||
{
|
||||
clientKey: "follow-up",
|
||||
title: "Created from suggest_tasks accept under prepare-only workspace",
|
||||
},
|
||||
],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
kind: "suggest_tasks",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accept when the issue has no execution workspace attached", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId: null, projectId: null },
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -2283,6 +2284,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
|
|
@ -2452,6 +2454,179 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
|||
]);
|
||||
});
|
||||
|
||||
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = 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 db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
// A run touched the workspace (prepare phase) but has not yet recorded
|
||||
// workspace_finalize — the dependent must NOT wake.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: false,
|
||||
pendingFinalizeBlockerIssueIds: [blockerId],
|
||||
unresolvedBlockerIssueIds: [blockerId],
|
||||
});
|
||||
|
||||
// A failed finalize must keep the gate closed.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
|
||||
|
||||
// Once a workspace_finalize succeeded row lands AFTER the failed one,
|
||||
// the gate opens and the dependent is wakeable.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
assigneeAgentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blockers with no executionWorkspaceId as not subject to the workspace-finalize barrier", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
// Done blocker with no execution workspace ever attached (e.g. closed manually).
|
||||
{ id: blockerId, companyId, title: "Manual done blocker", status: "done", priority: "medium" },
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
// No executionWorkspaceId → no barrier → dependent should be wakeable.
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
assigneeAgentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports dependency readiness for blocked issue chains", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
|
|||
|
|
@ -119,26 +119,126 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
|||
};
|
||||
}
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentSource =
|
||||
| "workspace"
|
||||
| "issue"
|
||||
| "project"
|
||||
| "agent"
|
||||
| "default";
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentConflict = {
|
||||
reason: "reused_workspace_environment_mismatch";
|
||||
workspaceEnvironmentId: string;
|
||||
assigneeIntendedEnvironmentId: string;
|
||||
assigneeIntendedSource: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
};
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentResolution = {
|
||||
environmentId: string;
|
||||
source: ExecutionWorkspaceEnvironmentSource;
|
||||
conflict: ExecutionWorkspaceEnvironmentConflict | null;
|
||||
};
|
||||
|
||||
function resolveAssigneeIntendedExecutionWorkspaceEnvironment(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}): {
|
||||
environmentId: string;
|
||||
source: Exclude<ExecutionWorkspaceEnvironmentSource, "workspace">;
|
||||
} {
|
||||
// Explicit issue-level env override always wins, even for null-default
|
||||
// (local-only) agents. An operator who deliberately set
|
||||
// `executionWorkspaceSettings.environmentId` on this specific issue (see the
|
||||
// issues-service contract preserved in issues.ts:4243) chose that env for
|
||||
// this assignment and should not be silently downgraded to the local default
|
||||
// (PAPA-430 review fix). Inherited issue envs from
|
||||
// `inheritExecutionWorkspaceFromIssueId` are stripped before this point in
|
||||
// `resolveExecutionWorkspaceEnvironmentId`.
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.issueSettings.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "issue",
|
||||
};
|
||||
}
|
||||
// A null defaultEnvironmentId on the agent means it is deliberately scoped to
|
||||
// the local default (e.g. Manual QA today). Project policy must not promote
|
||||
// such an agent off of local — only an explicit issue-level override above
|
||||
// can move the assignee away from the local default.
|
||||
if (input.agentDefaultEnvironmentId === null) {
|
||||
return { environmentId: input.defaultEnvironmentId, source: "default" };
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return {
|
||||
environmentId: input.projectPolicy.environmentId ?? input.defaultEnvironmentId,
|
||||
source: "project",
|
||||
};
|
||||
}
|
||||
return { environmentId: input.agentDefaultEnvironmentId, source: "agent" };
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
workspaceConfig: { environmentId?: string | null } | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
defaultEnvironmentId: string;
|
||||
}) {
|
||||
}): ExecutionWorkspaceEnvironmentResolution {
|
||||
// PAPA-431 companion: when the assignee has no explicit defaultEnvironmentId
|
||||
// (deliberately local-only, e.g. Manual QA) AND the issue settings env exactly
|
||||
// matches the reused workspace env, treat the issue env as a promoted artifact
|
||||
// from `inheritExecutionWorkspaceFromIssueId` rather than a deliberate
|
||||
// operator choice. Strip it so the resolver falls back to the local default
|
||||
// and the workspace-vs-intended conflict check forces a fresh realization.
|
||||
// A genuine operator override (via PATCH on the issue) reaches this code path
|
||||
// either with no reused workspace (workspaceConfig === null) or against a
|
||||
// workspace whose persisted env does not match the new override; both keep
|
||||
// the issue setting in place.
|
||||
const inheritedIssueEnvOnNullDefaultAssignee =
|
||||
input.agentDefaultEnvironmentId === null &&
|
||||
input.workspaceConfig?.environmentId !== undefined &&
|
||||
input.workspaceConfig?.environmentId !== null &&
|
||||
input.issueSettings?.environmentId !== undefined &&
|
||||
input.issueSettings.environmentId === input.workspaceConfig.environmentId;
|
||||
let issueSettingsForResolution = input.issueSettings;
|
||||
if (inheritedIssueEnvOnNullDefaultAssignee && input.issueSettings) {
|
||||
const { environmentId: _droppedInheritedEnv, ...rest } = input.issueSettings;
|
||||
void _droppedInheritedEnv;
|
||||
issueSettingsForResolution = rest as IssueExecutionWorkspaceSettings;
|
||||
}
|
||||
|
||||
const assigneeIntended = resolveAssigneeIntendedExecutionWorkspaceEnvironment({
|
||||
projectPolicy: input.projectPolicy,
|
||||
issueSettings: issueSettingsForResolution,
|
||||
agentDefaultEnvironmentId: input.agentDefaultEnvironmentId,
|
||||
defaultEnvironmentId: input.defaultEnvironmentId,
|
||||
});
|
||||
|
||||
if (input.workspaceConfig?.environmentId !== undefined) {
|
||||
return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
const workspaceEnvironmentId =
|
||||
input.workspaceConfig.environmentId ?? input.defaultEnvironmentId;
|
||||
// PAPA-380 / PAPA-431: a reused workspace's persisted environmentId must
|
||||
// never silently shadow the current assignee's environment identity.
|
||||
// When they disagree, refuse the silent reuse: return the assignee's
|
||||
// intended env and surface a conflict signal so the caller forces a fresh
|
||||
// workspace realization (or otherwise alerts the operator) instead of
|
||||
// running the agent on someone else's environment.
|
||||
if (workspaceEnvironmentId !== assigneeIntended.environmentId) {
|
||||
return {
|
||||
environmentId: assigneeIntended.environmentId,
|
||||
source: assigneeIntended.source,
|
||||
conflict: {
|
||||
reason: "reused_workspace_environment_mismatch",
|
||||
workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId: assigneeIntended.environmentId,
|
||||
assigneeIntendedSource: assigneeIntended.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { environmentId: workspaceEnvironmentId, source: "workspace", conflict: null };
|
||||
}
|
||||
if (input.issueSettings?.environmentId !== undefined) {
|
||||
return input.issueSettings.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.projectPolicy?.environmentId !== undefined) {
|
||||
return input.projectPolicy.environmentId ?? input.defaultEnvironmentId;
|
||||
}
|
||||
if (input.agentDefaultEnvironmentId !== null) {
|
||||
return input.agentDefaultEnvironmentId;
|
||||
}
|
||||
return input.defaultEnvironmentId;
|
||||
return { environmentId: assigneeIntended.environmentId, source: assigneeIntended.source, conflict: null };
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
|
|
|
|||
|
|
@ -7276,13 +7276,47 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const shouldReuseExisting =
|
||||
const requestedShouldReuseExisting =
|
||||
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
||||
existingExecutionWorkspace !== null &&
|
||||
existingExecutionWorkspace.status !== "archived";
|
||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
||||
const requestedReusableExecutionWorkspaceConfig = requestedShouldReuseExisting
|
||||
? existingExecutionWorkspace?.config ?? null
|
||||
: null;
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: requestedReusableExecutionWorkspaceConfig,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
// PAPA-380 / PAPA-431: when the resolver refuses silent reuse of the
|
||||
// persisted workspace environment, also force a fresh workspace
|
||||
// realization on the assignee's intended env. Reusing the on-disk
|
||||
// workspace while swapping the env underneath it would mismatch the cwd's
|
||||
// runtime expectations (e.g. an SSH-targeted worktree running on the
|
||||
// local default driver).
|
||||
if (environmentResolution.conflict) {
|
||||
logger.warn(
|
||||
{
|
||||
runId: run.id,
|
||||
issueId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
existingExecutionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
workspaceEnvironmentId: environmentResolution.conflict.workspaceEnvironmentId,
|
||||
assigneeIntendedEnvironmentId:
|
||||
environmentResolution.conflict.assigneeIntendedEnvironmentId,
|
||||
assigneeIntendedSource: environmentResolution.conflict.assigneeIntendedSource,
|
||||
},
|
||||
"Refusing silent reuse of execution workspace whose environment does not match the assignee's intended environment; forcing fresh realization",
|
||||
);
|
||||
}
|
||||
const shouldReuseExisting = requestedShouldReuseExisting && !environmentResolution.conflict;
|
||||
const reusableExecutionWorkspaceConfig = shouldReuseExisting
|
||||
? requestedReusableExecutionWorkspaceConfig
|
||||
: null;
|
||||
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
|
||||
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
|
||||
: null;
|
||||
|
|
@ -7292,14 +7326,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
persistedExecutionWorkspaceMode === "agent_default"
|
||||
? persistedExecutionWorkspaceMode
|
||||
: requestedExecutionWorkspaceMode;
|
||||
const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId);
|
||||
const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
workspaceConfig: reusableExecutionWorkspaceConfig,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
const selectedEnvironmentId = environmentResolution.environmentId;
|
||||
const workspaceManagedConfig = shouldReuseExisting
|
||||
? { ...config }
|
||||
: buildExecutionWorkspaceAdapterConfig({
|
||||
|
|
@ -7980,31 +8007,80 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
|
||||
);
|
||||
}
|
||||
const adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
let adapterFinalizeOutcome: "succeeded" | "failed" | null = null;
|
||||
const recordWorkspaceFinalize = async (
|
||||
status: "succeeded" | "failed",
|
||||
metadata?: Record<string, unknown>,
|
||||
) => {
|
||||
if (adapterFinalizeOutcome) return;
|
||||
await workspaceOperationRecorder.recordOperation({
|
||||
phase: "workspace_finalize",
|
||||
cwd: executionWorkspace.cwd,
|
||||
metadata: {
|
||||
adapterType: agent.adapterType,
|
||||
executionTargetKind: executionTarget?.kind ?? "local",
|
||||
...metadata,
|
||||
},
|
||||
run: async () => ({ status }),
|
||||
});
|
||||
// Only mark the outcome after the row landed, so a transient write
|
||||
// failure on the succeeded path can still be recovered by recording
|
||||
// finalize=failed from the catch path below.
|
||||
adapterFinalizeOutcome = status;
|
||||
};
|
||||
|
||||
let adapterResult: Awaited<ReturnType<typeof adapter.execute>>;
|
||||
try {
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
// Adapter returned cleanly, which means its workspace-restore finally
|
||||
// block also ran without throwing. Record the workspace_finalize
|
||||
// barrier so dependents that share this executionWorkspace can wake.
|
||||
// If recording the barrier itself fails, propagate as a run failure
|
||||
// rather than silently leaving dependents stranded behind a missing
|
||||
// finalize row.
|
||||
await recordWorkspaceFinalize("succeeded");
|
||||
} catch (adapterErr) {
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
// state. Best-effort record finalize=failed so the dependent readiness
|
||||
// check keeps the gate closed instead of waking on stale local state,
|
||||
// and surface the original error to the caller.
|
||||
try {
|
||||
await recordWorkspaceFinalize("failed", {
|
||||
errorMessage: adapterErr instanceof Error ? adapterErr.message : String(adapterErr),
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
} catch (recordErr) {
|
||||
logger.warn(
|
||||
{ err: recordErr, runId: run.id, executionWorkspaceId: persistedExecutionWorkspace?.id ?? null },
|
||||
"failed to record workspace_finalize=failed operation; dependents may remain gated",
|
||||
);
|
||||
}
|
||||
throw adapterErr;
|
||||
}
|
||||
const adapterManagedRuntimeServices = adapterResult.runtimeServices
|
||||
? await persistAdapterManagedRuntimeServices({
|
||||
db,
|
||||
|
|
@ -8250,6 +8326,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
: livenessRun,
|
||||
agent,
|
||||
);
|
||||
|
||||
// Workspace-finalize wake re-fire: if this run's issue was marked done
|
||||
// mid-run (so the original `issue_blockers_resolved` wake was gated by
|
||||
// the readiness check waiting for workspace_finalize), the finalize
|
||||
// row we just recorded now lets dependents proceed. Fire wakes here.
|
||||
if (issueId && adapterFinalizeOutcome === "succeeded") {
|
||||
try {
|
||||
const blockerIssueStatus = await db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0]?.status ?? null);
|
||||
if (blockerIssueStatus === "done") {
|
||||
const dependents = await issuesSvc.listWakeableBlockedDependents(issueId);
|
||||
for (const dependent of dependents) {
|
||||
await enqueueWakeup(dependent.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId: dependent.id,
|
||||
resolvedBlockerIssueId: issueId,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
deferredFor: "workspace_finalize",
|
||||
},
|
||||
contextSnapshot: {
|
||||
issueId: dependent.id,
|
||||
taskId: dependent.id,
|
||||
wakeReason: "issue_blockers_resolved",
|
||||
source: "workspace.finalize",
|
||||
resolvedBlockerIssueId: issueId,
|
||||
blockerIssueIds: dependent.blockerIssueIds,
|
||||
},
|
||||
}).catch((wakeErr) => {
|
||||
logger.warn(
|
||||
{ err: wakeErr, issueId, dependentIssueId: dependent.id, agentId: dependent.assigneeAgentId },
|
||||
"failed to fire deferred dependent wake after workspace_finalize",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (finalizeWakeErr) {
|
||||
logger.warn(
|
||||
{ err: finalizeWakeErr, runId: run.id, issueId },
|
||||
"failed to evaluate dependent wakes after workspace_finalize",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalizedRun) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import {
|
|||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
|
|
@ -457,6 +457,32 @@ export function issueThreadInteractionService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
}) {
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, args.issue.id))
|
||||
.then((rows: Array<{ executionWorkspaceId: string | null }>) => rows[0]?.executionWorkspaceId ?? null);
|
||||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
);
|
||||
}
|
||||
|
||||
async function getPendingInteractionForResolution(args: {
|
||||
issue: { id: string; companyId: string };
|
||||
interactionId: string;
|
||||
|
|
@ -747,8 +773,12 @@ export function issueThreadInteractionService(db: Db) {
|
|||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
// Accepting suggest_tasks only creates follow-up issues; it does not
|
||||
// approve code state or move the source workspace forward, so the
|
||||
// workspace_finalize gate (PAPA-440) does not apply here.
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
labels,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AcceptedPlanDecomposition,
|
||||
|
|
@ -351,6 +352,8 @@ export type IssueDependencyReadiness = {
|
|||
blockerIssueIds: string[];
|
||||
unresolvedBlockerIssueIds: string[];
|
||||
unresolvedBlockerCount: number;
|
||||
/** Blockers whose status is `done` but whose execution workspace has not yet finalized. */
|
||||
pendingFinalizeBlockerIssueIds: string[];
|
||||
allBlockersDone: boolean;
|
||||
isDependencyReady: boolean;
|
||||
};
|
||||
|
|
@ -582,11 +585,70 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
|
|||
blockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
unresolvedBlockerCount: 0,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
allBlockersDone: true,
|
||||
isDependencyReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of execution-workspace ids whose most recent workspace operation
|
||||
* is NOT a successful `workspace_finalize`. These workspaces have either an in-flight
|
||||
* run, a failed finalize, or never reached the finalize barrier — dependents that
|
||||
* read this workspace must wait until finalize succeeds.
|
||||
*
|
||||
* Workspaces with no recorded operations are considered finalized (nothing has
|
||||
* touched them since they were realized).
|
||||
*/
|
||||
export async function listUnfinalizedExecutionWorkspaceIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const unfinalized = new Set<string>();
|
||||
if (executionWorkspaceIds.length === 0) return unfinalized;
|
||||
|
||||
// Pull every workspace op for the candidate workspaces and pick the latest per
|
||||
// workspace in memory. Per-workspace LATERAL queries would be tighter, but the
|
||||
// candidate set is tiny in practice (one workspace per blocker per readiness call).
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
|
||||
),
|
||||
);
|
||||
|
||||
const latestByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
|
||||
for (const row of rows) {
|
||||
if (!row.executionWorkspaceId) continue;
|
||||
const current = latestByWorkspace.get(row.executionWorkspaceId);
|
||||
if (!current || row.startedAt > current.startedAt) {
|
||||
latestByWorkspace.set(row.executionWorkspaceId, {
|
||||
phase: row.phase,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceId of executionWorkspaceIds) {
|
||||
const latest = latestByWorkspace.get(workspaceId);
|
||||
if (!latest) continue; // no ops recorded → treat as finalized
|
||||
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
|
||||
unfinalized.add(workspaceId);
|
||||
}
|
||||
|
||||
return unfinalized;
|
||||
}
|
||||
|
||||
async function listIssueDependencyReadinessMap(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
|
|
@ -604,6 +666,7 @@ async function listIssueDependencyReadinessMap(
|
|||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issueRelations.issueId,
|
||||
blockerStatus: issues.status,
|
||||
blockerExecutionWorkspaceId: issues.executionWorkspaceId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
|
|
@ -615,6 +678,21 @@ async function listIssueDependencyReadinessMap(
|
|||
),
|
||||
);
|
||||
|
||||
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
|
||||
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
||||
// mark the dependent as not-ready and don't need a finalize check.
|
||||
const doneBlockerWorkspaceIds = new Set<string>();
|
||||
for (const row of blockerRows) {
|
||||
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
||||
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
|
||||
}
|
||||
}
|
||||
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
|
||||
dbOrTx,
|
||||
companyId,
|
||||
[...doneBlockerWorkspaceIds],
|
||||
);
|
||||
|
||||
for (const row of blockerRows) {
|
||||
const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId);
|
||||
current.blockerIssueIds.push(row.blockerIssueId);
|
||||
|
|
@ -625,6 +703,21 @@ async function listIssueDependencyReadinessMap(
|
|||
current.unresolvedBlockerCount += 1;
|
||||
current.allBlockersDone = false;
|
||||
current.isDependencyReady = false;
|
||||
} else if (
|
||||
row.blockerExecutionWorkspaceId &&
|
||||
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
|
||||
) {
|
||||
// Workspace-finalize barrier: the blocker's most recent run on its
|
||||
// execution workspace hasn't recorded a successful workspace_finalize.
|
||||
// Treat the dependent as not-ready until sync-back lands (or the run
|
||||
// finalizes); a subsequent finalize wake will re-evaluate readiness.
|
||||
// `allBlockersDone` is cleared too so that callers using it as a
|
||||
// proxy for "this dependent can proceed" still see the gate.
|
||||
current.unresolvedBlockerIssueIds.push(row.blockerIssueId);
|
||||
current.unresolvedBlockerCount += 1;
|
||||
current.pendingFinalizeBlockerIssueIds.push(row.blockerIssueId);
|
||||
current.allBlockersDone = false;
|
||||
current.isDependencyReady = false;
|
||||
}
|
||||
readinessMap.set(row.issueId, current);
|
||||
}
|
||||
|
|
@ -4091,45 +4184,33 @@ export function issueService(db: Db) {
|
|||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const candidateIds = candidates.map((candidate) => candidate.id);
|
||||
const blockerRows = await db
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issueRelations.issueId,
|
||||
blockerStatus: issues.status,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, blockerIssue.companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, candidateIds),
|
||||
),
|
||||
);
|
||||
const wakeableCandidates = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status),
|
||||
);
|
||||
if (wakeableCandidates.length === 0) return [];
|
||||
|
||||
const blockersByIssueId = new Map<string, Array<{ blockerIssueId: string; blockerStatus: string }>>();
|
||||
for (const row of blockerRows) {
|
||||
const list = blockersByIssueId.get(row.issueId) ?? [];
|
||||
list.push({ blockerIssueId: row.blockerIssueId, blockerStatus: row.blockerStatus });
|
||||
blockersByIssueId.set(row.issueId, list);
|
||||
}
|
||||
// Defer to the unified readiness check so that a dependent only fires when
|
||||
// (a) every blocker is done AND (b) every done blocker's workspace has
|
||||
// recorded a successful workspace_finalize. The finalize hook also calls
|
||||
// this function on completion, so a wake initially gated by an in-flight
|
||||
// sync-back will re-fire once the restore lands locally.
|
||||
const readinessMap = await listIssueDependencyReadinessMap(
|
||||
db,
|
||||
blockerIssue.companyId,
|
||||
wakeableCandidates.map((candidate) => candidate.id),
|
||||
);
|
||||
|
||||
return candidates
|
||||
.filter((candidate) => candidate.assigneeAgentId && !["backlog", "done", "cancelled"].includes(candidate.status))
|
||||
return wakeableCandidates
|
||||
.map((candidate) => {
|
||||
const blockers = blockersByIssueId.get(candidate.id) ?? [];
|
||||
return {
|
||||
...candidate,
|
||||
blockerIssueIds: blockers.map((blocker) => blocker.blockerIssueId),
|
||||
allBlockersDone: blockers.length > 0 && blockers.every((blocker) => blocker.blockerStatus === "done"),
|
||||
};
|
||||
const readiness = readinessMap.get(candidate.id) ?? createIssueDependencyReadiness(candidate.id);
|
||||
return { candidate, readiness };
|
||||
})
|
||||
.filter((candidate) => candidate.allBlockersDone)
|
||||
.map((candidate) => ({
|
||||
.filter(({ readiness }) => readiness.isDependencyReady && readiness.blockerIssueIds.length > 0)
|
||||
.map(({ candidate, readiness }) => ({
|
||||
id: candidate.id,
|
||||
assigneeAgentId: candidate.assigneeAgentId!,
|
||||
blockerIssueIds: candidate.blockerIssueIds,
|
||||
blockerIssueIds: readiness.blockerIssueIds,
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue