mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10: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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue