fix(server): avoid accepted-plan workspace branch freeze before child realization (#9233)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents tackle complex tasks via *plan* flows: a planner decomposes
work into child issues, which are accepted by the board and then
executed
> - When a plan is accepted, `createChild` in `issues.ts` inserts child
issues pre-bound to the parent's already-realized execution workspace —
carrying over its concrete branch ref
> - If the repository's base ref advances between plan acceptance and a
child's first heartbeat, the child inherits a stale branch that no
longer matches the current base
> - At first heartbeat the workspace validator detects the mismatch and
freezes the child ("branch freeze"), blocking it from starting any work
> - The real fix is to strip the concrete workspace binding when
creating accepted-plan children: they should receive only the unresolved
*intent* (mode, baseRef, branchTemplate) and realize a fresh workspace
from the current base on their own first heartbeat
> - This PR implements that strip, adds a regression test that proves a
post-base-advance child realizes cleanly, and also fixes
`parseIssueExecutionWorkspaceSettings` so `environmentId` is not
silently dropped on update round-trips (a latent bug that was masking
the original fix)

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. Bug description follows the
bug-report template:

**What happened:** Accepted-plan decomposition pre-binds child issues to
the parent's realized execution workspace branch (`executionWorkspaceId`
+ `executionWorkspaceBranch`). When `origin/master` advances between
plan acceptance and the child's first heartbeat, the workspace branch
interlock fires and the child is permanently frozen before it can start.

**Expected behavior:** Accepted-plan children should receive only
unresolved workspace intent (mode, git strategy fields) and realize a
fresh isolated worktree from the current base on first heartbeat. A
base-ref advance between acceptance and first-run should be transparent.

**Steps to reproduce:**
1. Accept a plan that decomposes into one or more child issues
(isolated_workspace + git_worktree mode).
2. Allow `origin/master` to advance (new merge).
3. Observe the first child heartbeat: workspace validation fails with a
branch-freeze error.

**Paperclip version:** current `master` (pre-fix).

**Deployment mode:** any (affects all modes that use isolated workspace
+ git worktree strategy).

Supersedes #9227 (earlier attempt, now closed — the fix was incomplete
because `environmentId` was silently dropped during
`parseIssueExecutionWorkspaceSettings` update round-trips, causing the
child workspace to lose its environment binding; this PR includes that
fix).

## What Changed

- **`server/src/issues.ts` — `createChild` / accepted-plan decomposition
path:** strip resolved workspace fields (`executionWorkspaceId`,
concrete branch) when creating accepted-plan children; preserve only
unresolved intent fields (`mode`, `baseRef`, `branchTemplate`,
`environmentId`, runtime/provisioning settings).
- **`server/src/execution-workspace-policy.ts` —
`parseIssueExecutionWorkspaceSettings`:** preserve `environmentId`
through update round-trips (was silently dropped, causing environment to
detach on any workspace settings update).
-
**`server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts`:**
new regression test — accepted-plan child created after `origin/master`
moves realizes a fresh isolated worktree from the moved base and passes
workspace execution.
- **`server/src/__tests__/issues-service.test.ts`:** extended
workspace-linkage and `createChild` tests covering the accepted-plan
strip and the unchanged direct-child path.

## Verification

```sh
pnpm exec vitest run server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "workspace linkage|accepted plan decomposition|createChild applies"
pnpm --filter @paperclipai/server typecheck
```

All three pass on this branch.

## Risks

**Low.** The change is scoped to the accepted-plan `createChild` code
path. The direct child / follow-up issue creation path (normal non-plan
decomposition) is unchanged and covered by existing tests. The
`parseIssueExecutionWorkspaceSettings` fix is additive — it now
preserves a field that was previously silently dropped, so no consumer
loses data.

## Model Used

Claude Sonnet 4.6 (`claude-sonnet-4-6`), Anthropic, 200K context window,
extended tool use + code generation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (supersedes #9227)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-08 11:07:36 -07:00 committed by GitHub
parent 88bf71b84e
commit 38cca22b09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 473 additions and 11 deletions

View File

@ -274,10 +274,23 @@ describe("execution workspace policy helpers", () => {
expect(
parseIssueExecutionWorkspaceSettings({
mode: "project_primary",
environmentId: "11111111-1111-4111-8111-111111111111",
}),
).toEqual({
mode: "shared_workspace",
});
expect(
parseIssueExecutionWorkspaceSettings(
{
mode: "project_primary",
environmentId: "11111111-1111-4111-8111-111111111111",
},
{ includeEnvironmentId: true },
),
).toEqual({
mode: "shared_workspace",
environmentId: "11111111-1111-4111-8111-111111111111",
});
});
it("prefers the agent default environment", () => {

View File

@ -23,6 +23,7 @@ import {
issueComments,
issueDocuments,
issuePlanDecompositions,
issueThreadInteractions,
issues,
projects,
projectWorkspaces,
@ -34,6 +35,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
const execFileAsync = promisify(execFile);
@ -72,17 +74,33 @@ if (!embeddedPostgresSupport.supported) {
);
}
async function runGit(cwd: string, args: string[]) {
const result = await execFileAsync("git", args, { cwd });
return result.stdout.trim();
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-"));
await execFileAsync("git", ["init"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot });
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["checkout", "-B", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip-test@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n");
await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot });
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "initial"]);
return repoRoot;
}
async function createGitRepoWithOrigin() {
const repoRoot = await createGitRepo();
const originRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-origin-"));
await runGit(originRoot, ["init", "--bare"]);
await runGit(repoRoot, ["remote", "add", "origin", originRoot]);
await runGit(repoRoot, ["push", "-u", "origin", "master"]);
await runGit(repoRoot, ["fetch", "origin", "master"]);
return { repoRoot, originRoot };
}
describeEmbeddedPostgres("accepted plan workspace refresh", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@ -114,6 +132,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(issuePlanDecompositions);
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
@ -198,6 +217,73 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
});
}
async function seedAcceptedPlanAcceptance(args: {
companyId: string;
issueId: string;
ownerAgentId: string;
}) {
const documentId = randomUUID();
const revisionId = randomUUID();
const interactionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId: args.companyId,
title: "Plan",
format: "markdown",
latestBody: "Plan body",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: args.ownerAgentId,
updatedByAgentId: args.ownerAgentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId: args.companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "Plan body",
createdByAgentId: args.ownerAgentId,
});
await db.insert(issueDocuments).values({
companyId: args.companyId,
issueId: args.issueId,
documentId,
key: "plan",
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId: args.companyId,
issueId: args.issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: args.issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
result: {
version: 1,
outcome: "accepted",
},
resolvedAt: new Date(),
createdByUserId: "local-board",
resolvedByUserId: "local-board",
});
return revisionId;
}
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@ -373,6 +459,232 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
}, 20_000);
it("keeps accepted-plan children strategy-only until first realization after the base ref moves", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sourceIssueId = randomUUID();
const agentId = randomUUID();
const { repoRoot, originRoot } = await createGitRepoWithOrigin();
tempRoots.push(repoRoot, originRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Branch Freshness",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Planning source issue",
status: "in_progress",
workMode: "planning",
priority: "medium",
responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-1584",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeat = heartbeatService(db);
adapterExecute.mockImplementationOnce(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "planning-source-session" },
sessionDisplayId: "planning-source-session",
summary: "Realized the planning source workspace.",
provider: "test",
model: "test-model",
}));
const sourceRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
contextSnapshot: {
issueId: sourceIssueId,
taskId: sourceIssueId,
wakeReason: "issue_commented",
skipIssueComment: true,
},
});
expect(sourceRun).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(sourceRun!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
const sourceWorkspace = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.sourceIssueId, sourceIssueId))
.then((rows) => rows[0] ?? null);
expect(sourceWorkspace).toMatchObject({
mode: "isolated_workspace",
strategyType: "git_worktree",
baseRef: "origin/master",
});
expect(sourceWorkspace?.branchName).toBeTruthy();
await writeFile(path.join(repoRoot, "base-moved.txt"), "base moved after planning\n");
await runGit(repoRoot, ["add", "base-moved.txt"]);
await runGit(repoRoot, ["commit", "-m", "Move base after planning"]);
const movedBaseSha = await runGit(repoRoot, ["rev-parse", "HEAD"]);
await runGit(repoRoot, ["push", "origin", "HEAD:master"]);
await runGit(repoRoot, ["fetch", "origin", "master"]);
const acceptedPlanRevisionId = await seedAcceptedPlanAcceptance({
companyId,
issueId: sourceIssueId,
ownerAgentId: agentId,
});
const decomposition = await issueService(db).decomposeAcceptedPlan(sourceIssueId, {
acceptedPlanRevisionId,
children: [
{
title: "Implement approved child after base move",
status: "todo",
workMode: "standard",
priority: "medium",
assigneeAgentId: agentId,
},
],
actorAgentId: agentId,
});
const childIssueId = decomposition.childIssueIds[0];
expect(childIssueId).toBeTruthy();
const childBeforeRun = await db
.select({
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, childIssueId!))
.then((rows) => rows[0] ?? null);
expect(childBeforeRun?.executionWorkspaceId).toBeNull();
expect(childBeforeRun?.executionWorkspacePreference).toBeNull();
expect(childBeforeRun?.executionWorkspaceSettings).toMatchObject({
mode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
});
let childRunWorkspace:
| { cwd: string; branchName: string; executionWorkspaceId: string }
| null = null;
adapterExecute.mockImplementationOnce(async (input) => {
const context = (input as { context?: Record<string, unknown> }).context ?? {};
const workspace = context.paperclipWorkspace as Record<string, unknown> | undefined;
const cwd = typeof workspace?.cwd === "string" ? workspace.cwd : null;
const branchName = typeof workspace?.branchName === "string" ? workspace.branchName : null;
const executionWorkspaceId =
typeof context.executionWorkspaceId === "string" ? context.executionWorkspaceId : null;
if (!cwd || !branchName || !executionWorkspaceId) {
throw new Error("Accepted-plan child run did not receive a realized workspace");
}
childRunWorkspace = { cwd, branchName, executionWorkspaceId };
expect(branchName).not.toBe(sourceWorkspace?.branchName);
await expect(runGit(cwd, ["rev-parse", "HEAD"])).resolves.toBe(movedBaseSha);
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, childIssueId!));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "child-session" },
sessionDisplayId: "child-session",
summary: "Child realized from the moved base.",
provider: "test",
model: "test-model",
};
});
const childRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_assigned",
contextSnapshot: {
issueId: childIssueId,
taskId: childIssueId,
wakeReason: "issue_assigned",
skipIssueComment: true,
},
});
expect(childRun).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(childRun!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(childRunWorkspace).not.toBeNull();
expect(childRunWorkspace?.executionWorkspaceId).not.toBe(sourceWorkspace?.id);
const childAfterRun = await db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
.where(eq(issues.id, childIssueId!))
.then((rows) => rows[0] ?? null);
expect(childAfterRun?.executionWorkspaceId).toBe(childRunWorkspace?.executionWorkspaceId);
}, 20_000);
it("forces a fresh session and suppresses accepted-plan continuation when another issue owns the in-flight claim", async () => {
const companyId = randomUUID();
const projectId = randomUUID();

View File

@ -3087,6 +3087,78 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
]);
});
it("createChild preserves strategy-only workspace intent without realizing the parent workspace", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Accepted plan parent",
status: "in_progress",
priority: "medium",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId,
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
});
const { issue: child } = await svc.createChild(parentIssueId, {
title: "Accepted plan child",
status: "todo",
priority: "medium",
executionWorkspaceInheritanceMode: "strategy_only",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(projectId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBeNull();
expect(child.executionWorkspacePreference).toBeNull();
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
environmentId,
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/master",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
});
});
it("clamps helper-created child requestDepth to the safe maximum", async () => {
const companyId = randomUUID();
const projectId = randomUUID();

View File

@ -571,7 +571,7 @@ function summarizeIssueWorkspaceForActivity(
issue: IssueWorkspaceAuditInput,
names: WorkspaceNameMaps,
) {
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings, { includeEnvironmentId: true });
const mode = settings?.mode ?? issue.executionWorkspacePreference ?? null;
const executionWorkspaceId = issue.executionWorkspaceId ?? null;
const projectWorkspaceId = issue.projectWorkspaceId ?? null;

View File

@ -155,7 +155,14 @@ export function gateProjectExecutionWorkspacePolicy(
return projectPolicy;
}
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
type ParseIssueExecutionWorkspaceSettingsOptions = {
includeEnvironmentId?: boolean;
};
export function parseIssueExecutionWorkspaceSettings(
raw: unknown,
options: ParseIssueExecutionWorkspaceSettingsOptions = {},
): IssueExecutionWorkspaceSettings | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
@ -179,6 +186,9 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
...(normalizedMode
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
: {}),
...(options.includeEnvironmentId && (typeof parsed.environmentId === "string" || parsed.environmentId === null)
? { environmentId: parsed.environmentId }
: {}),
...(workspaceStrategy ? { workspaceStrategy } : {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }

View File

@ -286,6 +286,32 @@ function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
};
}
// Accepted-plan children are not realized yet, so carry only unresolved
// workspace intent and let the first child run render/persist its own branch.
function buildPreRealizationExecutionWorkspaceSettings(raw: unknown): Record<string, unknown> | null {
const settings = parseIssueExecutionWorkspaceSettings(raw, { includeEnvironmentId: true });
if (!settings) return null;
const mode =
settings.mode && settings.mode !== "inherit" && settings.mode !== "reuse_existing"
? settings.mode
: null;
const next: Record<string, unknown> = {};
if (mode) next.mode = mode;
if (settings.environmentId !== undefined) next.environmentId = settings.environmentId;
if (settings.workspaceRuntime) next.workspaceRuntime = settings.workspaceRuntime;
if (settings.workspaceStrategy) {
next.workspaceStrategy = {
type: settings.workspaceStrategy.type,
...(settings.workspaceStrategy.baseRef ? { baseRef: settings.workspaceStrategy.baseRef } : {}),
...(settings.workspaceStrategy.branchTemplate ? { branchTemplate: settings.workspaceStrategy.branchTemplate } : {}),
...(settings.workspaceStrategy.worktreeParentDir ? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir } : {}),
...(settings.workspaceStrategy.provisionCommand ? { provisionCommand: settings.workspaceStrategy.provisionCommand } : {}),
...(settings.workspaceStrategy.teardownCommand ? { teardownCommand: settings.workspaceStrategy.teardownCommand } : {}),
};
}
return Object.keys(next).length > 0 ? next : null;
}
function toTimestampMs(value: Date | string | null | undefined) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
@ -542,6 +568,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
skipExecutionWorkspaceInheritance?: boolean;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
actorRunId?: string | null;
@ -551,6 +578,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
blockParentUntilDone?: boolean;
executionWorkspaceInheritanceMode?: "linkage" | "strategy_only";
actorAgentId?: string | null;
actorUserId?: string | null;
};
@ -741,6 +769,8 @@ const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([
"updatedByUserId",
"actorAgentId",
"actorUserId",
"executionWorkspaceInheritanceMode",
"skipExecutionWorkspaceInheritance",
]);
function normalizeAcceptedPlanDecompositionFingerprintChild(child: IssueChildCreateInput) {
@ -5606,14 +5636,25 @@ export function issueService(db: Db) {
const {
acceptanceCriteria,
blockParentUntilDone,
executionWorkspaceInheritanceMode = "linkage",
actorAgentId,
actorUserId,
...issueData
} = data;
const inheritStrategyOnly = executionWorkspaceInheritanceMode === "strategy_only";
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
issueData.executionWorkspaceSettings !== undefined;
const inheritedPreRealizationWorkspaceSettings =
inheritStrategyOnly && !hasExplicitExecutionWorkspaceOverride
? buildPreRealizationExecutionWorkspaceSettings(parent.executionWorkspaceSettings)
: null;
let child = await issueService(db).create(parent.companyId, {
...issueData,
parentId: parent.id,
projectId: issueData.projectId ?? parent.projectId,
projectWorkspaceId: issueData.projectWorkspaceId ?? (inheritStrategyOnly ? parent.projectWorkspaceId : undefined),
goalId: issueData.goalId ?? parent.goalId,
actorResponsibleUserId: issueData.actorResponsibleUserId ?? null,
trustExplicitResponsibleUserId: issueData.trustExplicitResponsibleUserId === true,
@ -5621,7 +5662,12 @@ export function issueService(db: Db) {
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
),
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
inheritExecutionWorkspaceFromIssueId: parent.id,
...(inheritedPreRealizationWorkspaceSettings
? { executionWorkspaceSettings: inheritedPreRealizationWorkspaceSettings }
: {}),
...(inheritStrategyOnly
? { skipExecutionWorkspaceInheritance: true }
: { inheritExecutionWorkspaceFromIssueId: parent.id }),
});
if (blockParentUntilDone) {
@ -5796,7 +5842,10 @@ export function issueService(db: Db) {
throw new Error("Accepted-plan decomposition child cursor moved past the requested children");
}
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, nextChildInput);
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, {
...nextChildInput,
executionWorkspaceInheritanceMode: "strategy_only",
});
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
const nextStatus = nextIds.length === data.children.length ? "completed" : "in_flight";
@ -5924,6 +5973,7 @@ export function issueService(db: Db) {
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
skipExecutionWorkspaceInheritance,
watchdog,
watchdogActorRunId,
actorRunId,
@ -5956,7 +6006,9 @@ export function issueService(db: Db) {
let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null;
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
? null
: inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
@ -6423,7 +6475,10 @@ export function issueService(db: Db) {
let cleared = 0;
for (const row of rows) {
const settings = parseIssueExecutionWorkspaceSettings(row.executionWorkspaceSettings);
const settings = parseIssueExecutionWorkspaceSettings(
row.executionWorkspaceSettings,
{ includeEnvironmentId: true },
);
if (settings?.environmentId !== environmentId) continue;
await db