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:
parent
88bf71b84e
commit
38cca22b09
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>) } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue