From b5b9491f95782672fca17daa6a08ffdecdc9a314 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 27 Oct 2026 15:21:12 -0700 Subject: [PATCH] test(heartbeat): cover first resume with default workspace settings (#17948) ## Thinking Path > - Paperclip manages AI agents and their tasks. > - Task continuations reuse provider sessions when effective configuration is unchanged. > - Repository projects can inherit isolated workspace defaults from a storage plugin. > - Initial workspace binding must not change those defaults. > - PR #17924 fixes policy resolution after binding. > - Its regression supplied an explicit task strategy. > - This pull request also tests unset task settings and an explicit isolated mode. ## Linked Issues or Issue Description Refs #17924 and #17447. **What happened?** On Core revision `e71a23c01c8af0e0b4cc5ac983d86886ba324239`, the first continuation of a task with an inherited repository workspace reset its provider session for `workspaceConfig`. The workspace was reused and its configuration fingerprint did not change. Session preparation runs before workspace binding. The first run resolved the storage plugin's effective project policy. After binding, `!boundSourceWorkspace` skipped that resolver and parsed the raw policy instead. This dropped inherited provider and strategy fields from the session fingerprint. The persisted change from unset task settings to `reuse_existing` and `mode: isolated_workspace` exposed this path; redundant task mode values are already normalized. **Expected behavior** Initial binding and two unchanged continuations retain the session and workspace. Real effective policy changes still reset the session. Explicit task settings and source-free tasks retain their behavior. **Steps to reproduce** 1. Configure a default storage plugin with isolated Git worktrees. 2. Create a repository project with no explicit project policy. 3. Start a task with unset workspace preference and settings. 4. Continue the task twice. **Paperclip version or commit** The runtime fix is already merged in #17924 at `0b65926822aacde48729f4b82a5c333f4cadf375`. This PR adds regression coverage on that fix. It does not duplicate or relax runtime freshness checks. ## What Changed - Run the existing repository heartbeat regression with unset settings, an explicit isolated mode, and an explicit strategy. - Assert that binding persists the expected workspace preference and mode. Each case checks two unchanged continuations and a real policy-change reset. - Make synthetic storage inspection return the branch prepared for that workspace, so generated default branch names pass the existing branch validation. - Update the session verification recipe. ## Verification - All 240 tests pass across `heartbeat-session-preparation.test.ts`, `heartbeat-workspace-session.test.ts`, `project-workspace-provider.integration.test.ts`, `plugin-workspaces.integration.test.ts`, and `plugin-fresh-workspaces.integration.test.ts`. Run them from `server/` with `node ../node_modules/vitest/vitest.mjs run src/__tests__/`. - Restoring the benchmark revision's `!boundSourceWorkspace` condition makes the unset-settings case fail on its first continuation: the dispatched provider session is null. Restoring #17924 makes the complete eight-case session suite pass. - `node scripts/check-maps.mjs` and `git diff --check` pass. - Full `pnpm -r typecheck` and `pnpm build` were attempted. Both stop at Runner Rust checks because this host has no `cargo`. Full `pnpm test:run` was started, then interrupted before its first group completed. It is not counted as a pass; complete suite coverage is left to CI. A separate server typecheck also stops at the missing Runner Rust toolchain. - Initial local attempts found stale cross-checkout dependency links and sandbox restrictions on PostgreSQL/IPC. Isolated workspace links and permitted local PostgreSQL resolved those prerequisites. The first new fixture attempt failed branch validation because inspection returned a hardcoded branch; the fixture now reports its prepared branch. - Greptile scores current head `5185e5e6443e0432b205a8ef63c5c878b9b74bdf` 5/5. Its check concluded success and found no actionable issues. CI is still running. - Tests use real PostgreSQL and heartbeat persistence with synthetic provider execution. No live deployment or TTFT result is claimed. ## Risks Low risk: this PR changes tests and the verification recipe only. Runtime behavior, authorization, and configuration freshness remain owned by the existing implementation. The local full checks are incomplete as stated above. The benchmark deployment needs a revision containing #17924 to receive the runtime fix. ## Model Used OpenAI GPT-6 through Codex, with repository inspection, code editing, and local test execution. The exact serving model ID and context window are not exposed in this session. ## 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 - [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 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 - [x] 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 --- maps/feature-map/runs-adapters.md | 4 +++- .../__tests__/heartbeat-session-preparation.test.ts | 10 ++++++++-- server/src/__tests__/helpers/plugin-workspaces.ts | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 9432c9a792b..25744903964 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -75,7 +75,9 @@ mutations. Qualify a useful model task separately from saving the account. ### `session` For a repository project that inherits storage-plugin defaults, run a task and -send two follow-ups. Binding the workspace must preserve the effective policy, +send two follow-ups. Start with unset task workspace settings, then repeat with +an explicit isolated mode or strategy. Binding must save `reuse_existing` and +the resolved mode without changing the effective policy, session fingerprint, and workspace identity. An actual project-policy change must still reset the session and preserve the stored workspace owner. The [session admission regression](../../server/src/__tests__/heartbeat-session-preparation.test.ts) diff --git a/server/src/__tests__/heartbeat-session-preparation.test.ts b/server/src/__tests__/heartbeat-session-preparation.test.ts index 13d0544016b..2d117a6855c 100644 --- a/server/src/__tests__/heartbeat-session-preparation.test.ts +++ b/server/src/__tests__/heartbeat-session-preparation.test.ts @@ -112,7 +112,11 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => } finally { await db.delete(plugins).where(eq(plugins.id, storage.pluginId)); } }); - it("keeps inherited repository policy stable through binding and retains the provider on policy changes", async () => { + it.each([ + null, + { mode: "isolated_workspace" }, + { workspaceStrategy: { type: "git_worktree", branchTemplate: "work" } }, + ])("keeps inherited repository policy stable through binding with settings %j and retains the provider on policy changes", async (settings) => { const f = await seed(); const storage = await createPluginWorkspaceFixture(db, f.companyId); const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId)); @@ -121,7 +125,7 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => } } }).where(eq(plugins.id, storage.pluginId)); await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, storage.input.projectId)); await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId, - executionWorkspaceSettings: { workspaceStrategy: { type: "git_worktree", branchTemplate: "work" } }, + executionWorkspaceSettings: settings, }).where(eq(issues.id, f.issueId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true }); const cwd = await mkdtemp(join(tmpdir(), "session-provider-fixture-")); @@ -140,6 +144,8 @@ describePostgres("heartbeat session preparation through adapter dispatch", () => const first = await execute(f); const [bound] = await db.select().from(issues).where(eq(issues.id, f.issueId)); expect(bound.executionWorkspaceId).toEqual(expect.any(String)); + expect(bound.executionWorkspacePreference).toBe("reuse_existing"); + expect(bound.executionWorkspaceSettings).toEqual({ ...settings, mode: "isolated_workspace" }); const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!)); expect([...captured.values()].filter(input => input.context.issueId === f.issueId)).toHaveLength(1); expect(workspace).toMatchObject({ providerType: "plugin", strategyType: "git_worktree" }); diff --git a/server/src/__tests__/helpers/plugin-workspaces.ts b/server/src/__tests__/helpers/plugin-workspaces.ts index 31cc69c1546..0ebb4af7a03 100644 --- a/server/src/__tests__/helpers/plugin-workspaces.ts +++ b/server/src/__tests__/helpers/plugin-workspaces.ts @@ -100,6 +100,7 @@ export async function createPluginWorkspaceFixture( status: "running", }); const calls: any[] = []; + const preparedBranches = new Map(); let loseResponse = false, dirty = false, removed = false, @@ -120,6 +121,7 @@ export async function createPluginWorkspaceFixture( } if (params.operation.kind === "prepare") { removed = false; + preparedBranches.set(params.executionWorkspaceId, params.operation.branchName ?? null); return { kind: "prepared", binding: { @@ -141,7 +143,7 @@ export async function createPluginWorkspaceFixture( return { kind: "inspection", headSha: "a".repeat(40), - branchName: "work", + branchName: preparedBranches.get(params.executionWorkspaceId) ?? "work", hasDirtyTrackedFiles: dirty, hasUntrackedFiles: false, hasLocalOnlyData: false,