mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Honor reuse-existing preference and assignee default environment in issue runs (#5139)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run inside execution workspaces (a per-issue cwd + env), and an issue > can prefer to reuse an existing workspace or get a fresh one each time > - The heartbeat service was reading the existing workspace's config to derive > environment selection regardless of whether the issue actually wanted to reuse > it. So fresh-run issues were inheriting stale config from a workspace that was > about to be discarded > - Separately, when an issue is assigned to an agent, the issue's execution > workspace settings weren't picking up the agent's `defaultEnvironmentId`, > even though the agent's choice is the natural default for that issue > - This PR makes both selection paths honor the obvious source of truth: > workspace config flows only when the issue actually wants `reuse_existing`, > and the assignee agent's default environment is applied at assignment time if > nothing else is set on the issue > - The benefit is that re-running a flaky issue picks up the right environment > instead of inheriting the previous run's config, and assigning an agent to an > issue does the obvious thing without operator intervention ## What Changed - `server/src/services/heartbeat.ts`: introduce `reusableExecutionWorkspaceConfig` that is non-null only when `shouldReuseExisting` is true. Both `resolveExecutionWorkspaceEnvironmentId(...)` and `applyPersistedExecutionWorkspaceConfig(...)` now read from it instead of unconditionally consulting `existingExecutionWorkspace?.config`. Fresh-run issues no longer inherit stale environment config from an in-flight workspace about to be discarded. - `server/src/services/issues.ts`: when an issue update sets a new `assigneeAgentId` and isolated workspaces are enabled, populate `executionWorkspaceSettings.environmentId` from the assignee agent's `defaultEnvironmentId` if the issue doesn't have an explicit `environmentId` set yet. - Tests added in `heartbeat-plugin-environment.test.ts` (~216 lines) and `issues-service.test.ts` (~85 lines) covering both paths. ## Verification - `pnpm --filter @paperclipai/server test -- heartbeat-plugin-environment issues-service` - Manual QA: assign an issue to an agent that has a non-default `defaultEnvironmentId`, confirm the issue's workspace settings now include that environment id without operator intervention. Trigger a rerun on an issue whose existing workspace points at a stale environment, confirm the rerun uses the freshly-resolved environment. ## Risks - Behavioural shift on assignment: previously assigning an agent didn't propagate the agent's default environment to the issue. Now it does. Callers that explicitly want the issue to keep its existing/null environment must set `executionWorkspaceSettings.environmentId` themselves; the new logic only fires when no explicit value is set. - Behavioural shift on rerun: stale workspace config is no longer applied to fresh runs. Operators who relied on this implicit inheritance may see different environment selection on the first rerun after deploy. Mitigation: the explicit isssue settings and project policy are still honored as before. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## 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 (no UI changes) - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
09eceb952a
commit
0e51fa2b0d
4 changed files with 700 additions and 14 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
|
|
@ -1184,6 +1185,351 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("captures the assignee default environment when neither issue nor project specifies one", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const assigneeEnvironmentId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: assigneeEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: assigneeEnvironmentId,
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId,
|
||||
title: "Environment matrix: e2b / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
expect(issue.executionWorkspaceSettings).toEqual({
|
||||
mode: "shared_workspace",
|
||||
environmentId: assigneeEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not promote the assignee default environment when the project policy already specifies one", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const projectEnvironmentId = randomUUID();
|
||||
const assigneeEnvironmentId = 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 instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: projectEnvironmentId,
|
||||
companyId,
|
||||
name: "QA SSH",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: assigneeEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: assigneeEnvironmentId,
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
environmentId: projectEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId,
|
||||
title: "Environment matrix: e2b / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
// Project policy's environmentId must win over the assignee's default;
|
||||
// executionWorkspaceSettings should not bake in an environmentId in this case
|
||||
// so resolveExecutionWorkspaceEnvironmentId can fall through to the project
|
||||
// policy's value at run time.
|
||||
expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" });
|
||||
});
|
||||
|
||||
it("captures the new assignee's default environment on reassignment", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const firstEnvironmentId = randomUUID();
|
||||
const secondEnvironmentId = randomUUID();
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{
|
||||
id: firstEnvironmentId,
|
||||
companyId,
|
||||
name: "QA SSH",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: secondEnvironmentId,
|
||||
companyId,
|
||||
name: "QA E2B",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "e2b" },
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId,
|
||||
companyId,
|
||||
name: "QA SSH Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: firstEnvironmentId,
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId,
|
||||
companyId,
|
||||
name: "QA E2B Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
defaultEnvironmentId: secondEnvironmentId,
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary workspace",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const created = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId: firstAgentId,
|
||||
title: "Environment matrix: ssh / codex_local",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
expect(created.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: firstEnvironmentId,
|
||||
});
|
||||
|
||||
const reassigned = await svc.update(created.id, {
|
||||
assigneeAgentId: secondAgentId,
|
||||
});
|
||||
|
||||
expect(reassigned).not.toBeNull();
|
||||
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: secondEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an operator-set environmentId across reassignment", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const firstEnvironmentId = randomUUID();
|
||||
const secondEnvironmentId = randomUUID();
|
||||
const operatorEnvironmentId = randomUUID();
|
||||
const firstAgentId = randomUUID();
|
||||
const secondAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
await db.insert(environments).values([
|
||||
{ id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} },
|
||||
{ id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } },
|
||||
{ id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} },
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active",
|
||||
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
||||
defaultEnvironmentId: firstEnvironmentId, permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active",
|
||||
adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {},
|
||||
defaultEnvironmentId: secondEnvironmentId, permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId, companyId, name: "Workspace project", status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: projectWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true,
|
||||
});
|
||||
|
||||
const created = await svc.create(companyId, {
|
||||
projectId,
|
||||
assigneeAgentId: firstAgentId,
|
||||
title: "Operator overrides env then reassigns",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
// Operator explicitly overrides the environmentId in a separate update.
|
||||
const overridden = await svc.update(created.id, {
|
||||
executionWorkspaceSettings: {
|
||||
mode: "shared_workspace",
|
||||
environmentId: operatorEnvironmentId,
|
||||
},
|
||||
});
|
||||
expect(overridden!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: operatorEnvironmentId,
|
||||
});
|
||||
|
||||
// A subsequent reassignment-only update must NOT overwrite the operator's
|
||||
// explicit choice with the new assignee's default.
|
||||
const reassigned = await svc.update(created.id, {
|
||||
assigneeAgentId: secondAgentId,
|
||||
});
|
||||
expect(reassigned!.executionWorkspaceSettings).toMatchObject({
|
||||
environmentId: operatorEnvironmentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue