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:
Devin Foley 2026-05-29 08:25:29 -07:00 committed by GitHub
parent 524e18b060
commit 1f70fd9a22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 21133 additions and 95 deletions

View file

@ -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", () => {

View file

@ -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);
});

View file

@ -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);

View file

@ -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",
});
});
});
});

View file

@ -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({