From a8f0ebaa8015b15b594e3adc24a2f4e0733d679d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 30 Jun 2026 14:50:25 -0700 Subject: [PATCH] Refresh run config before reusing workspaces (#8797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs are assembled by the heartbeat service from agent config, project workspaces, environment config, secret bindings, skills, and runtime session state. > - The heartbeat service intentionally reuses adapter sessions, execution workspaces, and sandbox leases when that preserves useful state. > - Reuse becomes incorrect when the effective next-run config changes after a saved session, workspace, or lease was created. > - Stale reuse can make a later run appear pinned to old agent, environment, secret, instruction, or workspace settings. > - This pull request records non-sensitive fingerprints for the effective session, workspace, and lease config at run boundaries. > - When those fingerprints drift, Paperclip refreshes persisted runtime config or starts fresh execution instead of reusing stale state. > - The benefit is predictable next-run config freshness without storing raw secret values, full env maps, provider credentials, or private path details. ## Linked Issues or Issue Description - Refs #8058 - Related PRs checked during dedup search: #4968, #4155, #84, #8480. These cover nearby workspace/session routing or model-config freshness areas, but do not duplicate this effective run config fingerprinting path. ## What Changed - Added effective run config fingerprinting for session, workspace, and lease reuse decisions, with canonicalization that ignores generated runtime noise and redacts sensitive values. - Updated heartbeat reuse logic to compare stored and next-run fingerprints, reset stale saved sessions, refresh persisted workspace config snapshots, replace stale reused workspaces when required, and avoid stale sandbox lease reuse. - Included plain environment value drift via value hashes, without storing the raw env values. - Root-bound instruction content hashing so legacy direct absolute instruction paths are represented but not read for config fingerprints. - Batched secret/version metadata lookups for environment lease fingerprinting. - Added workspace operation/run result freshness metadata so operators can inspect non-sensitive decision categories. - Surfaced config freshness labels and next-run copy in the UI and docs. - Added focused coverage for fingerprint redaction, session reset decisions, workspace refresh/replace behavior, environment lease drift, and persisted workspace restoration. ## Verification - `git diff --check` - Sensitive-data scan before push: - `git diff --unified=0 origin/master...HEAD | rg -n --pcre2 "(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|AKIA[0-9A-Z]{16})"` - `git diff --unified=0 origin/master...HEAD | rg -n --pcre2 "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"` - `pnpm exec vitest run server/src/__tests__/effective-run-config-fingerprints.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts server/src/__tests__/environment-runtime.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm -r typecheck` - `pnpm --filter @paperclipai/db clean` - `pnpm test:run` - `pnpm build` - UI screenshots from Cutter: - https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-01.png - https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-02.png - https://artifacts.cutter.sh/8797/run-2f4827c-2026-06-30T18-57-25/preview/change-03.png ## Risks - Medium: overly broad fingerprints could start fresh sessions, workspaces, or sandbox leases more often than necessary. - Medium: missing a config category would allow stale reuse to persist for that category. - Medium: legacy direct absolute instruction paths are no longer content-hashed unless they are paired with an absolute managed instructions root. - Low data risk: fingerprint metadata stores hashes and category names, not raw secrets, raw env values, provider credentials, or private path details. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 via Codex CLI / Codex coding agent, tool-enabled with shell, Git, GitHub CLI, local test execution, and code editing. The exact deployed model variant and context window are not exposed by this environment. ## 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 (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 - [x] 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 Co-authored-by: Cody --- doc/CLI.md | 2 + doc/DEVELOPING.md | 6 + .../shared/src/types/workspace-operation.ts | 1 + .../effective-run-config-fingerprints.test.ts | 303 ++++ .../src/__tests__/environment-runtime.test.ts | 409 +++++- .../heartbeat-workspace-session.test.ts | 648 ++++++++- .../src/__tests__/workspace-runtime.test.ts | 40 + .../effective-run-config-fingerprints.ts | 492 +++++++ server/src/services/environment-runtime.ts | 361 ++++- server/src/services/heartbeat.ts | 1274 +++++++++++++++-- server/src/services/secrets.ts | 2 + server/src/services/workspace-runtime.ts | 3 + ui/src/pages/AgentDetail.tsx | 8 + ui/src/pages/CompanyEnvironments.tsx | 2 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 23 +- 15 files changed, 3325 insertions(+), 249 deletions(-) create mode 100644 server/src/__tests__/effective-run-config-fingerprints.test.ts create mode 100644 server/src/services/effective-run-config-fingerprints.ts diff --git a/doc/CLI.md b/doc/CLI.md index fd190a0e80..b42399d0ce 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -300,6 +300,8 @@ pnpm paperclipai agent instructions-file:put --path AGENTS.md --conte pnpm paperclipai agent instructions-file:delete --path AGENTS.md ``` +Agent config, instructions, skills, project env, environment, secret, and workspace edits affect the next run. Active runs finish with the config they started with. When a saved session, reused workspace, or sandbox lease no longer matches the effective next-run config, Paperclip may start fresh execution and records non-sensitive freshness categories in run result JSON and workspace operation logs. + `agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: - creates a new long-lived agent API key diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index aa47541b0a..7b784a3213 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -277,6 +277,12 @@ If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs f Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`. +## Config Freshness + +Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. + +When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details. + ## Worktree-local Instances When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 30043396c9..18a3788e02 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -1,5 +1,6 @@ export type WorkspaceOperationPhase = | "worktree_prepare" + | "workspace_config_freshness" | "workspace_provision" | "workspace_teardown" | "worktree_cleanup" diff --git a/server/src/__tests__/effective-run-config-fingerprints.test.ts b/server/src/__tests__/effective-run-config-fingerprints.test.ts new file mode 100644 index 0000000000..02afa38a4c --- /dev/null +++ b/server/src/__tests__/effective-run-config-fingerprints.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeEffectiveRunConfigCategory, + createEffectiveRunConfigFingerprints, + diffEffectiveRunConfigFingerprints, +} from "../services/effective-run-config-fingerprints.ts"; + +describe("effective run config fingerprints", () => { + it("emits versioned deterministic fingerprints with stable object ordering", () => { + const first = createEffectiveRunConfigFingerprints({ + session: { + adapterType: "codex_local", + adapterConfig: { + env: { + ZETA: "raw-zeta", + ALPHA: "raw-alpha", + PAPERCLIP_RUN_ID: "run-one", + }, + model: "gpt-5.2", + }, + }, + workspace: { + repoRef: "main", + repoUrl: "https://github.com/example/repo.git", + workspaceRuntime: { + services: [ + { + env: { PORT: "3100", FEATURE_FLAG: "enabled" }, + command: "pnpm dev", + }, + ], + }, + }, + lease: { + environmentId: "environment-1", + config: { beta: true, alpha: 1 }, + }, + }); + + const second = createEffectiveRunConfigFingerprints({ + lease: { + config: { alpha: 1, beta: true }, + environmentId: "environment-1", + }, + workspace: { + workspaceRuntime: { + services: [ + { + command: "pnpm dev", + env: { FEATURE_FLAG: "enabled", PORT: "3100" }, + }, + ], + }, + repoUrl: "https://github.com/example/repo.git", + repoRef: "main", + }, + session: { + adapterConfig: { + model: "gpt-5.2", + env: { + PAPERCLIP_RUN_ID: "run-two", + ALPHA: "raw-alpha", + ZETA: "raw-zeta", + }, + }, + adapterType: "codex_local", + }, + }); + + expect(first.version).toBe(1); + expect(first.categories).toEqual(["session", "workspace", "lease"]); + expect(first.sessionFingerprint).toMatchObject({ + version: 1, + category: "session", + algorithm: "sha256", + }); + expect(first.sessionFingerprint.fingerprint).toMatch(/^v1:sha256:[a-f0-9]{64}$/); + expect(second).toEqual(first); + }); + + it("reports changed categories independently", () => { + const base = createEffectiveRunConfigFingerprints({ + session: { adapterType: "codex_local", model: "gpt-5.2" }, + workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } }, + lease: { environmentId: "environment-1", reuseLease: true }, + }); + + expect(diffEffectiveRunConfigFingerprints( + base, + createEffectiveRunConfigFingerprints({ + session: { adapterType: "codex_local", model: "gpt-5.3" }, + workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } }, + lease: { environmentId: "environment-1", reuseLease: true }, + }), + )).toMatchObject({ + hasChanges: true, + changedCategories: ["session"], + changed: { session: true, workspace: false, lease: false }, + }); + + expect(diffEffectiveRunConfigFingerprints( + base, + createEffectiveRunConfigFingerprints({ + session: { adapterType: "codex_local", model: "gpt-5.2" }, + workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "custom-{{issue.identifier}}" } }, + lease: { environmentId: "environment-1", reuseLease: true }, + }), + ).changedCategories).toEqual(["workspace"]); + + expect(diffEffectiveRunConfigFingerprints( + base, + createEffectiveRunConfigFingerprints({ + session: { adapterType: "codex_local", model: "gpt-5.2" }, + workspace: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" } }, + lease: { environmentId: "environment-2", reuseLease: true }, + }), + ).changedCategories).toEqual(["lease"]); + }); + + it("uses resolved secret version metadata without raw secret values", () => { + const v7 = createEffectiveRunConfigFingerprints({ + session: { + adapterConfig: { + env: { + OPENAI_API_KEY: "resolved-secret-value", + PLAIN_TEXT: "plain env value", + }, + }, + }, + secretManifest: [ + { + configPath: "env.OPENAI_API_KEY", + envKey: "OPENAI_API_KEY", + secretId: "secret-1", + bindingId: "binding-1", + version: 7, + provider: "local_encrypted", + providerVersionRef: "provider-version-7", + outcome: "success", + }, + ], + }); + const canonical = v7.sessionFingerprint.canonicalJson; + + expect(canonical).toContain("secret-1"); + expect(canonical).toContain("binding-1"); + expect(canonical).toContain("provider-version-7"); + expect(canonical).toContain("\"version\":7"); + expect(canonical).not.toContain("resolved-secret-value"); + expect(canonical).not.toContain("plain env value"); + expect(canonical).not.toContain("fingerprintSha256"); + + const rawValueChanged = createEffectiveRunConfigFingerprints({ + session: { + adapterConfig: { + env: { + OPENAI_API_KEY: "different-resolved-secret-value", + PLAIN_TEXT: "plain env value", + }, + }, + }, + secretManifest: [ + { + configPath: "env.OPENAI_API_KEY", + envKey: "OPENAI_API_KEY", + secretId: "secret-1", + bindingId: "binding-1", + version: 7, + provider: "local_encrypted", + providerVersionRef: "provider-version-7", + outcome: "success", + }, + ], + }); + expect(rawValueChanged.sessionFingerprint.fingerprint).toBe(v7.sessionFingerprint.fingerprint); + + const versionChanged = createEffectiveRunConfigFingerprints({ + session: { + adapterConfig: { + env: { + OPENAI_API_KEY: "different-resolved-secret-value", + PLAIN_TEXT: "plain env value", + }, + }, + }, + secretManifest: [ + { + configPath: "env.OPENAI_API_KEY", + envKey: "OPENAI_API_KEY", + secretId: "secret-1", + bindingId: "binding-1", + version: 8, + provider: "local_encrypted", + providerVersionRef: "provider-version-8", + outcome: "success", + }, + ], + }); + expect(versionChanged.sessionFingerprint.fingerprint).not.toBe(v7.sessionFingerprint.fingerprint); + }); + + it("detects plain env value drift without storing raw values", () => { + const base = createEffectiveRunConfigFingerprints({ + session: { + adapterConfig: { + env: { + FEATURE_FLAG: "enabled", + }, + }, + }, + }); + const changed = createEffectiveRunConfigFingerprints({ + session: { + adapterConfig: { + env: { + FEATURE_FLAG: "disabled", + }, + }, + }, + }); + + expect(changed.sessionFingerprint.fingerprint).not.toBe(base.sessionFingerprint.fingerprint); + expect(base.sessionFingerprint.canonicalJson).toContain('"valueHash":"sha256:'); + expect(base.sessionFingerprint.canonicalJson).not.toContain("enabled"); + expect(changed.sessionFingerprint.canonicalJson).not.toContain("disabled"); + }); + + it("excludes generated run values, sensitive tokens, timestamps, and session path noise", () => { + const first = createEffectiveRunConfigFingerprints({ + session: { + sessionId: "generated-session-one", + runId: "run-one", + cwd: "/runtime/noise/project-a", + updatedAt: "2026-06-01T00:00:00.000Z", + adapterConfig: { + token: "token-one", + nested: { + authorization: "Bearer first", + }, + env: { + PAPERCLIP_API_KEY: "runtime-api-key-one", + NORMAL_VALUE: "first", + }, + }, + }, + lease: { + leaseId: "lease-one", + providerLeaseId: "provider-lease-one", + remoteCwd: "/runtime/noise/remote-a", + createdAt: "2026-06-01T00:00:00.000Z", + driver: "daytona", + }, + }); + const second = createEffectiveRunConfigFingerprints({ + session: { + sessionId: "generated-session-two", + runId: "run-two", + cwd: "/runtime/noise/project-b", + updatedAt: "2026-06-02T00:00:00.000Z", + adapterConfig: { + token: "token-two", + nested: { + authorization: "Bearer second", + }, + env: { + PAPERCLIP_API_KEY: "runtime-api-key-two", + NORMAL_VALUE: "first", + }, + }, + }, + lease: { + leaseId: "lease-two", + providerLeaseId: "provider-lease-two", + remoteCwd: "/runtime/noise/remote-b", + createdAt: "2026-06-02T00:00:00.000Z", + driver: "daytona", + }, + }); + + expect(second.sessionFingerprint.fingerprint).toBe(first.sessionFingerprint.fingerprint); + expect(second.leaseFingerprint.fingerprint).toBe(first.leaseFingerprint.fingerprint); + const canonical = [ + first.sessionFingerprint.canonicalJson, + first.leaseFingerprint.canonicalJson, + ].join("\n"); + expect(canonical).not.toContain("generated-session-one"); + expect(canonical).not.toContain("run-one"); + expect(canonical).not.toContain("/runtime/noise/project-a"); + expect(canonical).not.toContain("runtime-api-key-one"); + expect(canonical).not.toContain("token-one"); + expect(canonical).not.toContain("Bearer first"); + expect(canonical).not.toContain("lease-one"); + expect(canonical).not.toContain("/runtime/noise/remote-a"); + + expect(canonicalizeEffectiveRunConfigCategory({ + category: "workspace", + value: { cwd: "/explicit/runtime/workspace", workspaceStrategy: { type: "git_worktree" } }, + })).toEqual({ + cwd: "/explicit/runtime/workspace", + workspaceStrategy: { type: "git_worktree" }, + }); + }); +}); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 4ec89cbe1c..ee7ce887f0 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -392,7 +392,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }); - return { pluginId, companyId, executionWorkspaceId, reusableLease }; + return { pluginId, companyId, agentId, environment, runId, executionWorkspaceId, reusableLease }; } it("acquires and releases a local run lease through the runtime seam", async () => { @@ -1217,6 +1217,413 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); }); + it("does not resume released reusable plugin sandbox leases after provider config drift", async () => { + const pluginId = randomUUID(); + const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); + const providerConfig = { + provider: "fake-plugin", + image: "template-a", + timeoutMs: 1234, + reuseLease: true, + }; + const environment = { + ...baseEnvironment, + name: "Reusable Plugin Sandbox", + driver: "sandbox", + config: providerConfig, + }; + await environmentService(db).update(environment.id, { + driver: "sandbox", + name: environment.name, + config: providerConfig, + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.fake-sandbox-provider", + packageName: "@acme/fake-sandbox-provider", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.fake-sandbox-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + const executionWorkspaceId = randomUUID(); + const projectId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, params: any) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: `lease-${params.config.image}`, + metadata: { + provider: "fake-plugin", + image: params.config.image, + timeoutMs: params.config.timeoutMs, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + if (method === "environmentReleaseLease" || method === "environmentDestroyLease") { + return undefined; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const first = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId, + heartbeatRunId: runId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + expect(first.lease.metadata?.reusableSandboxLease).toMatchObject({ + provider: "fake-plugin", + leaseFingerprint: expect.objectContaining({ + category: "lease", + fingerprint: expect.stringMatching(/^v1:sha256:[a-f0-9]{64}$/), + }), + }); + await runtimeWithPlugin.releaseRunLeases(runId); + + const nextRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: nextRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }); + const updatedEnvironment = { + ...environment, + config: { + ...providerConfig, + image: "template-b", + }, + }; + await environmentService(db).update(environment.id, { + config: updatedEnvironment.config, + }); + + const second = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment: updatedEnvironment, + issueId: null, + agentId, + heartbeatRunId: nextRunId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + + expect(second.lease.providerLeaseId).toBe("lease-template-b"); + expect(workerManager.call).not.toHaveBeenCalledWith( + pluginId, + "environmentResumeLease", + expect.anything(), + expect.anything(), + ); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", expect.objectContaining({ + providerLeaseId: "lease-template-a", + }), 31234); + await expect(environmentService(db).getLeaseById(first.lease.id)).resolves.toMatchObject({ + status: "expired", + cleanupStatus: "success", + failureReason: "lease_fingerprint_mismatch", + }); + }); + + it("does not resume released reusable plugin sandbox leases after secret version drift", async () => { + const pluginId = randomUUID(); + const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); + const apiSecret = await secretService(db).create(companyId, { + name: `secure-plugin-api-key-${randomUUID()}`, + provider: "local_encrypted", + value: "resolved-provider-key", + }); + const providerConfig = { + provider: "secure-plugin", + template: "base", + apiKey: apiSecret.id, + timeoutMs: 1234, + reuseLease: true, + }; + const environment = { + ...baseEnvironment, + name: "Secure Plugin Sandbox", + driver: "sandbox", + config: providerConfig, + }; + await secretService(db).createBinding({ + companyId, + secretId: apiSecret.id, + targetType: "environment", + targetId: environment.id, + configPath: "apiKey", + }); + await environmentService(db).update(environment.id, { + driver: "sandbox", + name: environment.name, + config: providerConfig, + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "acme.secure-sandbox-provider", + packageName: "@acme/secure-sandbox-provider", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.secure-sandbox-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + const executionWorkspaceId = randomUUID(); + const projectId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, params: any) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: `lease-${params.config.apiKey}`, + metadata: { + provider: "secure-plugin", + template: params.config.template, + apiKey: params.config.apiKey, + timeoutMs: params.config.timeoutMs, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + if (method === "environmentReleaseLease" || method === "environmentDestroyLease") { + return undefined; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const first = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId, + heartbeatRunId: runId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + await runtimeWithPlugin.releaseRunLeases(runId); + await secretService(db).rotate(apiSecret.id, { value: "rotated-provider-key" }); + + const nextRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: nextRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }); + const second = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId, + heartbeatRunId: nextRunId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + + expect(second.lease.providerLeaseId).toBe("lease-rotated-provider-key"); + expect(workerManager.call).not.toHaveBeenCalledWith( + pluginId, + "environmentResumeLease", + expect.anything(), + expect.anything(), + ); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", expect.objectContaining({ + providerLeaseId: "lease-resolved-provider-key", + }), 31234); + await expect(environmentService(db).getLeaseById(first.lease.id)).resolves.toMatchObject({ + status: "expired", + cleanupStatus: "success", + failureReason: "lease_fingerprint_mismatch", + }); + const firstMetadata = JSON.stringify(first.lease.metadata); + expect(firstMetadata).not.toContain("resolved-provider-key"); + expect(firstMetadata).not.toContain("rotated-provider-key"); + }); + + it("preserves active reusable sandbox leases held by another running run", async () => { + const { pluginId, companyId, agentId, environment, executionWorkspaceId, reusableLease } = + await seedReusablePluginSandboxLease(); + const nextRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: nextRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-plugin-lease", + metadata: { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const acquired = await runtimeWithPlugin.acquireRunLease({ + companyId, + environment, + issueId: null, + agentId, + heartbeatRunId: nextRunId, + persistedExecutionWorkspace: { + id: executionWorkspaceId, + mode: "shared_workspace", + }, + }); + + expect(acquired.lease.providerLeaseId).toBe("fresh-plugin-lease"); + expect(workerManager.call).toHaveBeenCalledOnce(); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + agentId, + executionWorkspaceId, + runId: nextRunId, + }), 31234); + await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({ + status: "active", + cleanupStatus: null, + }); + }); + it("does not retain or resume plugin-backed sandbox leases unless the provider opts in", async () => { const pluginId = randomUUID(); const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index a22507f7fd..cdce55dd7b 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -11,8 +11,10 @@ import { applyPersistedExecutionWorkspaceConfig, assertGitSensitiveAdapterWorkspaceValid, assertPushCapabilityCheckoutValid, - buildRealizedExecutionWorkspaceFromPersisted, buildExplicitResumeSessionOverride, + buildEffectiveRunSessionConfigMetadata, + buildEffectiveRunWorkspaceConfigMetadata, + buildWorkspaceConfigFreshnessOperation, deriveTaskKeyWithHeartbeatFallback, extractWakeCommentIds, formatRuntimeWorkspaceWarningLog, @@ -21,7 +23,9 @@ import { preflightLowTrustWorkspaceIsolation, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, + resolveExecutionWorkspaceConfigFreshness, resolveNextSessionState, + resolveTaskSessionConfigFreshness, requiresPushCapabilityPreflight, resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, @@ -30,6 +34,7 @@ import { stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForModelChange, stripConfiguredModelFromSessionParams, + stripPaperclipSessionMetadataFromSessionParams, normalizeSessionParams, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -812,48 +817,266 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => { }); }); -describe("buildRealizedExecutionWorkspaceFromPersisted", () => { - it("reuses the persisted execution workspace path instead of deriving a new worktree", () => { - const result = buildRealizedExecutionWorkspaceFromPersisted({ - base: buildResolvedWorkspace({ - cwd: "/tmp/project-primary", - repoRef: "main", - }), - workspace: { - id: "execution-workspace-1", - companyId: "company-1", - projectId: "project-1", - projectWorkspaceId: "workspace-1", - sourceIssueId: "issue-1", - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "PAP-880-thumbs-capture-for-evals-feature", - status: "active", - cwd: "/tmp/reused-worktree", - repoUrl: "https://example.com/paperclip.git", - baseRef: "main", - branchName: "PAP-880-thumbs-capture-for-evals-feature", - providerType: "git_worktree", - providerRef: "/tmp/reused-worktree", - derivedFromExecutionWorkspaceId: null, - lastUsedAt: new Date(), - openedAt: new Date(), - closedAt: null, - cleanupEligibleAt: null, - cleanupReason: null, - config: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), +type WorkspaceConfigMetadata = ReturnType; + +function buildWorkspaceConfigMetadata( + overrides: Partial[0]> = {}, +) { + return buildEffectiveRunWorkspaceConfigMetadata({ + mode: "isolated_workspace", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + strategyType: "git_worktree", + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/main", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/worktrees", + }, + repoUrl: "https://github.com/example/repo.git", + repoRef: "origin/main", + configSnapshot: { + provisionCommand: "pnpm install", + teardownCommand: "pnpm stop", + cleanupCommand: "pnpm clean", + desiredState: "running", + serviceStates: { "0": "running" }, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }, + environment: { + selectedEnvironmentId: "environment-1", + driver: "local", + config: { provider: "local" }, + }, + realization: { + environmentDriver: "local", + environmentProvider: "local", + }, + evaluatedAt: "2026-06-26T00:00:00.000Z", + ...overrides, + }); +} + +function persistedWorkspaceConfigFingerprint(metadata: WorkspaceConfigMetadata) { + return { + configFingerprint: { + version: metadata.version, + workspaceHash: metadata.fingerprint, + categories: metadata.categories, + categoryFingerprints: metadata.categoryFingerprints, + lastEvaluatedAt: metadata.evaluatedAt, + }, + }; +} + +describe("effective run execution workspace config freshness", () => { + it("reuses an existing workspace when the stored workspace fingerprint is unchanged", () => { + const metadata = buildWorkspaceConfigMetadata(); + + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata), + nextMetadata: metadata, + }); + + expect(decision).toMatchObject({ + action: "reuse", + shouldReuseExisting: true, + shouldRefreshConfigSnapshot: false, + changedCategories: [], + storedFingerprintPresent: true, + }); + }); + + it("refreshes metadata and config for runtime-service-only drift without replacing the workspace", () => { + const base = buildWorkspaceConfigMetadata(); + const next = buildWorkspaceConfigMetadata({ + configSnapshot: { + provisionCommand: "pnpm install", + teardownCommand: "pnpm stop", + cleanupCommand: "pnpm clean", + desiredState: "running", + serviceStates: { "0": "running" }, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }], + }, }, }); - expect(result.created).toBe(false); - expect(result.strategy).toBe("git_worktree"); - expect(result.cwd).toBe("/tmp/reused-worktree"); - expect(result.worktreePath).toBe("/tmp/reused-worktree"); - expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); - expect(result.source).toBe("task_session"); + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base), + nextMetadata: next, + }); + + expect(decision).toMatchObject({ + action: "refresh", + shouldReuseExisting: true, + shouldRefreshConfigSnapshot: true, + changedCategories: ["runtimeServices"], + }); + + const metadata = mergeExecutionWorkspaceMetadataForPersistence({ + existingMetadata: { + config: { + workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }] }, + }, + ...persistedWorkspaceConfigFingerprint(base), + }, + source: "task_session", + createdByRuntime: false, + configSnapshot: { + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }], + }, + desiredState: "running", + serviceStates: { "0": "running" }, + }, + shouldReuseExisting: true, + shouldRefreshConfigSnapshot: true, + workspaceConfigMetadata: next, + baseRef: "origin/main", + baseRefSha: "abc123", + }); + + expect(metadata?.config).toMatchObject({ + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }], + }, + }); + expect(metadata?.configFingerprint).toMatchObject({ + workspaceHash: next.fingerprint, + categories: next.categories, + }); + }); + + it.each([ + { + name: "mode", + category: "mode", + next: buildWorkspaceConfigMetadata({ mode: "shared_workspace" }), + }, + { + name: "workspace strategy", + category: "strategy", + next: buildWorkspaceConfigMetadata({ + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/main", + branchTemplate: "custom-{{issue.identifier}}", + worktreeParentDir: ".paperclip/worktrees", + }, + }), + }, + { + name: "project workspace", + category: "projectWorkspace", + next: buildWorkspaceConfigMetadata({ projectWorkspaceId: "workspace-2" }), + }, + { + name: "base ref", + category: "repo", + next: buildWorkspaceConfigMetadata({ + repoRef: "origin/release", + workspaceStrategy: { + type: "git_worktree", + baseRef: "origin/release", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/worktrees", + }, + }), + }, + { + name: "environment realization", + category: "environment", + next: buildWorkspaceConfigMetadata({ + environment: { + selectedEnvironmentId: "environment-2", + driver: "sandbox", + config: { provider: "daytona" }, + }, + realization: { + environmentDriver: "sandbox", + environmentProvider: "daytona", + }, + }), + }, + ] as const)("replaces the workspace when $name changes", ({ category, next }) => { + const base = buildWorkspaceConfigMetadata(); + + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base), + nextMetadata: next, + }); + + expect(decision.action).toBe("replace"); + expect(decision.shouldReuseExisting).toBe(false); + expect(decision.changedCategories).toContain(category); + }); + + it("formats a safe workspace operation payload for config drift decisions", () => { + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(buildWorkspaceConfigMetadata()), + nextMetadata: buildWorkspaceConfigMetadata({ + configSnapshot: { + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }], + }, + }, + }), + }); + + const operation = buildWorkspaceConfigFreshnessOperation({ + decision, + hasExistingWorkspace: true, + reuseRequested: true, + workspaceReused: true, + configSnapshotRefreshed: true, + previousWorkspaceId: "workspace-old", + activeWorkspaceId: "workspace-old", + }); + + expect(operation).toMatchObject({ + metadata: { + kind: "config_freshness", + action: "refresh", + changedCategories: ["lifecycleCommands", "runtimeServices"], + changedCategoryLabels: ["workspace lifecycle commands", "runtime services"], + reuseRequested: true, + workspaceReused: true, + configSnapshotRefreshed: true, + previousWorkspaceId: "workspace-old", + activeWorkspaceId: "workspace-old", + }, + system: expect.stringContaining("refreshed execution workspace config"), + }); + const serialized = JSON.stringify(operation); + expect(serialized).toContain("runtime services"); + expect(serialized).not.toContain("pnpm dev"); + expect(serialized).not.toContain("0.0.0.0"); + }); + + it("does not record a freshness operation when an unchanged workspace is simply reused", () => { + const metadata = buildWorkspaceConfigMetadata(); + const decision = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: true, + existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata), + nextMetadata: metadata, + }); + + expect(buildWorkspaceConfigFreshnessOperation({ + decision, + hasExistingWorkspace: true, + reuseRequested: true, + workspaceReused: true, + configSnapshotRefreshed: false, + previousWorkspaceId: "workspace-1", + activeWorkspaceId: "workspace-1", + })).toBeNull(); }); }); @@ -1075,6 +1298,332 @@ describe("shouldResetTaskSessionForModelChange", () => { }); }); +type SessionConfigMetadata = Awaited>; + +async function buildSessionConfigMetadata( + overrides: Partial[0]> = {}, +) { + return buildEffectiveRunSessionConfigMetadata({ + adapterType: "codex_local", + effectiveAdapterConfig: { + command: "codex", + model: "gpt-5.4-mini", + env: { + OPENAI_API_KEY: "resolved-secret-value", + PLAIN_FLAG: "plain-value", + }, + }, + agentRuntimeConfig: { + heartbeat: { + maxConcurrentRuns: 1, + }, + }, + modelProfile: null, + issueOverrides: null, + workspaceConfig: { + requestedMode: "agent_default", + effectiveMode: "agent_default", + projectConfigRevisionAt: "2026-06-01T00:00:00.000Z", + }, + environment: { + selectionSource: "default", + selectedEnvironmentId: "environment-1", + selectedEnvironment: { + id: "environment-1", + driver: "local", + configRevisionAt: "2026-06-01T00:00:00.000Z", + }, + }, + environmentEnv: { + ENVIRONMENT_FLAG: "enabled", + }, + projectEnv: { + PROJECT_FLAG: "enabled", + }, + routineEnv: null, + secretManifest: [ + { + configPath: "env.OPENAI_API_KEY", + envKey: "OPENAI_API_KEY", + secretId: "secret-1", + bindingId: "binding-1", + secretKey: "openai-api-key", + version: 7, + provider: "local_encrypted", + outcome: "success", + }, + ], + runtimeSkills: [ + { + key: "paperclip", + runtimeName: "paperclip", + source: "/tmp/paperclip/runtime-skills/paperclip", + versionId: null, + currentVersionId: "skill-version-1", + sourceStatus: "available", + missingDetail: null, + }, + ], + agentConfigRevision: { + id: "agent-config-revision-1", + changedKeys: ["adapterConfig"], + configRevisionAt: "2026-06-01T00:00:00.000Z", + }, + ...overrides, + }); +} + +function sessionParamsWithConfigMetadata( + metadata: SessionConfigMetadata, + configuredModel = "gpt-5.4-mini", +) { + return { + sessionId: "thread-1", + __paperclipConfiguredModel: configuredModel, + __paperclipConfigFingerprint: metadata.fingerprint, + __paperclipConfigFingerprintVersion: metadata.version, + __paperclipConfigCategories: metadata.categories, + __paperclipConfigCategoryFingerprints: metadata.categoryFingerprints, + }; +} + +describe("effective run session config freshness", () => { + it("resets when effective adapter config changes after model/profile/env resolution", async () => { + const base = await buildSessionConfigMetadata(); + const next = await buildSessionConfigMetadata({ + effectiveAdapterConfig: { + command: "codex", + model: "gpt-5.4-mini", + approvalPolicy: "never", + }, + }); + + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(base), + configMetadata: next, + }); + + expect(decision).toMatchObject({ + reset: true, + changedCategories: ["adapterConfig"], + }); + expect(decision.reasons.join("\n")).toContain("adapter config"); + }); + + it("keeps model-only compatibility as an additional reset reason", async () => { + const base = await buildSessionConfigMetadata(); + + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(base, "opencode/mimo-v2-pro-free"), + configMetadata: base, + }); + + expect(decision.reset).toBe(true); + expect(decision.reasons).toEqual([ + 'configured model changed from "opencode/mimo-v2-pro-free" to "gpt-5.4-mini"', + ]); + }); + + it("freshens legacy task sessions that lack versioned config metadata", async () => { + const metadata = await buildSessionConfigMetadata(); + + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + configMetadata: metadata, + }); + + expect(decision.reset).toBe(true); + expect(decision.changedCategories).toEqual(metadata.categories); + expect(decision.reasons).toEqual(["effective run configuration fingerprint metadata is missing"]); + }); + + it("preserves legacy metadata gaps only for active accepted-plan continuation sessions", async () => { + const metadata = await buildSessionConfigMetadata(); + + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + configMetadata: metadata, + preserveLegacySessionWithoutConfigMetadata: true, + }); + + expect(decision.reset).toBe(false); + expect(decision.changedCategories).toEqual([]); + expect(decision.reasons).toEqual([]); + }); + + it("names safe categories for model profile, issue override, env, secret, and runtime skill drift", async () => { + const base = await buildSessionConfigMetadata(); + const cases: Array<{ + name: string; + category: string; + metadata: SessionConfigMetadata; + }> = [ + { + name: "model profile", + category: "modelProfile", + metadata: await buildSessionConfigMetadata({ + modelProfile: { + requested: "cheap", + applied: true, + configSource: "agent_runtime", + }, + }), + }, + { + name: "issue overrides", + category: "issueOverrides", + metadata: await buildSessionConfigMetadata({ + issueOverrides: { + adapterConfig: { + reasoningEffort: "high", + }, + }, + }), + }, + { + name: "project env bindings", + category: "envBindings", + metadata: await buildSessionConfigMetadata({ + projectEnv: { + PROJECT_FLAG: "enabled", + NEW_PROJECT_FLAG: "present", + }, + }), + }, + { + name: "secret version", + category: "secrets", + metadata: await buildSessionConfigMetadata({ + secretManifest: [ + { + configPath: "env.OPENAI_API_KEY", + envKey: "OPENAI_API_KEY", + secretId: "secret-1", + bindingId: "binding-1", + secretKey: "openai-api-key", + version: 8, + provider: "local_encrypted", + outcome: "success", + }, + ], + }), + }, + { + name: "runtime skills", + category: "runtimeSkills", + metadata: await buildSessionConfigMetadata({ + runtimeSkills: [ + { + key: "paperclip", + runtimeName: "paperclip", + source: "/tmp/paperclip/runtime-skills/paperclip", + versionId: null, + currentVersionId: "skill-version-2", + sourceStatus: "available", + missingDetail: null, + }, + ], + }), + }, + ]; + + for (const testCase of cases) { + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(base), + configMetadata: testCase.metadata, + }); + + expect(decision.reset, testCase.name).toBe(true); + expect(decision.changedCategories, testCase.name).toContain(testCase.category); + } + }); + + it("detects instructions content drift without storing the contents", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-")); + const instructionsPath = path.join(root, "AGENTS.md"); + await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8"); + const base = await buildSessionConfigMetadata({ + effectiveAdapterConfig: { + command: "codex", + model: "gpt-5.4-mini", + instructionsBundleMode: "managed", + instructionsRootPath: root, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: instructionsPath, + }, + }); + await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8"); + const next = await buildSessionConfigMetadata({ + effectiveAdapterConfig: { + command: "codex", + model: "gpt-5.4-mini", + instructionsBundleMode: "managed", + instructionsRootPath: root, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: instructionsPath, + }, + }); + + const decision = resolveTaskSessionConfigFreshness({ + hasTaskSession: true, + configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(base), + configMetadata: next, + }); + + expect(decision.reset).toBe(true); + expect(decision.changedCategories).toContain("instructions"); + expect(next.fingerprints.sessionFingerprint.canonicalJson).not.toContain("Version two instructions"); + }); + + it("does not read unbounded legacy instructions paths for config fingerprints", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-")); + const instructionsPath = path.join(root, "AGENTS.md"); + await fs.writeFile(instructionsPath, "Legacy direct-path instructions.\n", "utf8"); + const metadata = await buildSessionConfigMetadata({ + effectiveAdapterConfig: { + command: "codex", + model: "gpt-5.4-mini", + instructionsFilePath: instructionsPath, + }, + }); + + const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson; + + expect(canonical).toContain("missing_absolute_root"); + expect(canonical).not.toContain("Legacy direct-path instructions"); + expect(canonical).not.toContain("contentHash"); + }); + + it("does not include raw secret or plain env values in canonical session metadata", async () => { + const metadata = await buildSessionConfigMetadata(); + const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson; + + expect(canonical).toContain("secret-1"); + expect(canonical).toContain('"version":7'); + expect(canonical).not.toContain("resolved-secret-value"); + expect(canonical).not.toContain("plain-value"); + expect(canonical).not.toContain("enabled"); + expect(canonical).not.toContain("openai-api-key"); + }); +}); + describe("stripConfiguredModelFromSessionParams", () => { it("removes the internal model key from persisted session params", () => { expect( @@ -1108,6 +1657,25 @@ describe("stripConfiguredModelFromSessionParams", () => { }); }); +describe("stripPaperclipSessionMetadataFromSessionParams", () => { + it("removes all internal Paperclip session metadata before adapter invocation", () => { + expect( + stripPaperclipSessionMetadataFromSessionParams({ + sessionId: "thread-1", + cwd: "/tmp/project", + __paperclipConfiguredModel: "gpt-5.4-mini", + __paperclipConfigFingerprint: "v1:sha256:abc", + __paperclipConfigFingerprintVersion: 1, + __paperclipConfigCategories: ["adapterConfig"], + __paperclipConfigCategoryFingerprints: { adapterConfig: "v1:sha256:def" }, + }), + ).toEqual({ + sessionId: "thread-1", + cwd: "/tmp/project", + }); + }); +}); + describe("normalizeSessionParams", () => { it("collapses an empty object to null", () => { expect(normalizeSessionParams({})).toBeNull(); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index e9111520c3..4f066822dd 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -2057,6 +2057,45 @@ describe("realizeExecutionWorkspace", () => { expect(actualHead).toBe(expectedHead); }, 15_000); + it("does not reuse a missing persisted local filesystem workspace", async () => { + const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-")); + const missingCwd = path.join(baseCwd, "missing-workspace"); + + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd, + source: "project_primary", + projectId: "project-1", + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + workspace: { + mode: "shared_workspace", + strategyType: "project_primary", + cwd: missingCwd, + providerRef: null, + projectId: "project-1", + projectWorkspaceId: null, + repoUrl: null, + baseRef: null, + branchName: null, + }, + issue: { + id: "issue-1", + identifier: "PAP-453", + title: "Missing local workspace", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(restored).toBeNull(); + }); + it("reprovisions an existing persisted git worktree before manual control starts it", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); @@ -2195,6 +2234,7 @@ describe("realizeExecutionWorkspace", () => { it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => { const repoRoot = await createTempRepo("main"); + await runGit(repoRoot, ["branch", "-f", "master", "main"]); const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-")); await runGit(bareRemote, ["init", "--bare"]); diff --git a/server/src/services/effective-run-config-fingerprints.ts b/server/src/services/effective-run-config-fingerprints.ts new file mode 100644 index 0000000000..3dd19c1782 --- /dev/null +++ b/server/src/services/effective-run-config-fingerprints.ts @@ -0,0 +1,492 @@ +import { createHash } from "node:crypto"; +import type { RuntimeSecretManifestEntry } from "./secrets.js"; + +export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION = 1; +export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM = "sha256"; +export const EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES = ["session", "workspace", "lease"] as const; + +export type EffectiveRunConfigFingerprintCategory = + (typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES)[number]; + +export type EffectiveRunConfigChangedCategory = EffectiveRunConfigFingerprintCategory; + +export type EffectiveRunConfigCanonicalValue = + | null + | boolean + | number + | string + | EffectiveRunConfigCanonicalValue[] + | { [key: string]: EffectiveRunConfigCanonicalValue }; + +export interface EffectiveRunConfigSecretVersionMetadata { + configPath: string; + envKey: string | null; + secretId: string; + bindingId?: string | null; + version: number | string; + provider?: string | null; + providerVersionRef?: string | null; + outcome?: "success" | "failure" | null; +} + +export type EffectiveRunConfigSecretManifestEntry = + | RuntimeSecretManifestEntry + | EffectiveRunConfigSecretVersionMetadata; + +export interface EffectiveRunConfigFingerprintInput { + session?: unknown; + workspace?: unknown; + lease?: unknown; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; +} + +export interface EffectiveRunConfigFingerprint { + version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; + category: EffectiveRunConfigFingerprintCategory; + algorithm: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM; + fingerprint: string; + canonicalJson: string; +} + +export interface EffectiveRunConfigFingerprints { + version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; + categories: readonly EffectiveRunConfigFingerprintCategory[]; + sessionFingerprint: EffectiveRunConfigFingerprint; + workspaceFingerprint: EffectiveRunConfigFingerprint; + leaseFingerprint: EffectiveRunConfigFingerprint; +} + +export interface EffectiveRunConfigFingerprintDiff { + version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; + hasChanges: boolean; + changedCategories: EffectiveRunConfigChangedCategory[]; + changed: Record; +} + +const OMIT = Symbol("omit-from-effective-run-config-fingerprint"); +const REDACTED_VALUE: EffectiveRunConfigCanonicalValue = { type: "redacted", present: true }; + +const GENERATED_RUNTIME_ENV_KEY_RE = /^PAPERCLIP_/; +const SENSITIVE_CONFIG_KEY_RE = + /(?:api[_-]?key|access[_-]?token|auth(?:orization)?|bearer|cookie|credential|jwt|password|passwd|private[_-]?key|secret|token)$/i; +const VOLATILE_CONFIG_KEYS = new Set([ + "checkoutRunId", + "executionRunId", + "externalRunId", + "heartbeatRunId", + "invocationId", + "leaseId", + "providerLeaseId", + "requestId", + "runId", + "sessionDisplayId", + "sessionId", + "spanId", + "traceId", +]); +const HOST_NOISE_KEYS = new Set([ + "agentHome", + "homeDir", + "hostCwd", + "localHome", + "tempDir", + "tmpDir", + "userHome", +]); +const SESSION_HOST_PATH_KEYS = new Set([ + "cwd", + "localPath", + "remoteCwd", + "workspaceCwd", + "workspacePath", + "workspaceRemoteDir", + "worktreePath", +]); + +type SecretManifestIndex = { + byConfigPath: Map; + byEnvKey: Map; +}; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) + && typeof value === "object" + && !Array.isArray(value) + && !(value instanceof Date); +} + +function readString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readVersion(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + return readString(value); +} + +function normalizeSecretManifestEntry( + entry: EffectiveRunConfigSecretManifestEntry, +): EffectiveRunConfigSecretVersionMetadata | null { + const record = entry as Record; + const secretId = readString(record.secretId); + const version = readVersion(record.version); + if (!secretId || version === null) return null; + + const normalized: EffectiveRunConfigSecretVersionMetadata = { + configPath: readString(record.configPath) ?? "", + envKey: readString(record.envKey), + secretId, + version, + }; + const bindingId = readString(record.bindingId); + const provider = readString(record.provider); + const providerVersionRef = readString(record.providerVersionRef); + const outcome = record.outcome === "success" || record.outcome === "failure" + ? record.outcome + : null; + if (bindingId !== null) normalized.bindingId = bindingId; + if (provider !== null) normalized.provider = provider; + if (providerVersionRef !== null) normalized.providerVersionRef = providerVersionRef; + if (outcome !== null) normalized.outcome = outcome; + return normalized; +} + +function buildSecretManifestIndex( + manifest: readonly EffectiveRunConfigSecretManifestEntry[] | undefined, +): SecretManifestIndex { + const byConfigPath = new Map(); + const byEnvKey = new Map(); + for (const entry of manifest ?? []) { + const normalized = normalizeSecretManifestEntry(entry); + if (!normalized) continue; + if (normalized.configPath) byConfigPath.set(normalized.configPath, normalized); + if (normalized.envKey) byEnvKey.set(normalized.envKey, normalized); + } + return { byConfigPath, byEnvKey }; +} + +function canonicalSecretMetadata( + metadata: EffectiveRunConfigSecretVersionMetadata, +): EffectiveRunConfigCanonicalValue { + return omitNullish({ + type: "secret_ref", + configPath: metadata.configPath || undefined, + envKey: metadata.envKey ?? undefined, + secretId: metadata.secretId, + bindingId: metadata.bindingId ?? undefined, + version: metadata.version, + provider: metadata.provider ?? undefined, + providerVersionRef: metadata.providerVersionRef ?? undefined, + outcome: metadata.outcome ?? undefined, + }); +} + +function isSecretRefBinding(value: unknown): value is Record { + return isPlainObject(value) + && value.type === "secret_ref" + && typeof value.secretId === "string" + && value.secretId.trim().length > 0; +} + +function canonicalSecretRefBinding( + value: Record, + configPath: string, +): EffectiveRunConfigCanonicalValue { + return omitNullish({ + type: "secret_ref", + configPath, + secretId: readString(value.secretId), + versionSelector: readVersion(value.version) ?? "latest", + unresolved: true, + }); +} + +function omitNullish(record: Record): EffectiveRunConfigCanonicalValue { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined && value !== null), + ) as EffectiveRunConfigCanonicalValue; +} + +function isTimestampNoiseKey(key: string) { + return /(created|updated|started|finished|completed|cancelled|resolved|used|heartbeat)At$/i.test(key) + && !/(revision|version)/i.test(key); +} + +function shouldOmitObjectKey( + category: EffectiveRunConfigFingerprintCategory, + key: string, +) { + if (VOLATILE_CONFIG_KEYS.has(key)) return true; + if (HOST_NOISE_KEYS.has(key)) return true; + if (isTimestampNoiseKey(key)) return true; + if (category === "session" && SESSION_HOST_PATH_KEYS.has(key)) return true; + if (category === "lease" && (key === "remoteCwd" || key === "workspaceRemoteDir")) return true; + return false; +} + +function stableStringify(value: EffectiveRunConfigCanonicalValue): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key] ?? null)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function canonicalizePlainEnvValueForHash(value: unknown): EffectiveRunConfigCanonicalValue { + if (value === undefined || value === null) return null; + if (Array.isArray(value)) { + return value.map((entry) => canonicalizePlainEnvValueForHash(entry)); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + const next = canonicalizePlainEnvValueForHash(value[key]); + if (next !== null) out[key] = next; + } + return out; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "boolean" || typeof value === "string") { + return value; + } + return String(value); +} + +function hashPlainEnvValue(value: unknown): string | null { + if (value === undefined || value === null) return null; + const canonicalJson = stableStringify(canonicalizePlainEnvValueForHash(value)); + return `sha256:${createHash("sha256").update(canonicalJson).digest("hex")}`; +} + +function canonicalizeEnvRecord( + envValue: unknown, + context: CanonicalizeContext, +): EffectiveRunConfigCanonicalValue | typeof OMIT { + if (!isPlainObject(envValue)) return {}; + const canonicalEnv: Record = {}; + for (const key of Object.keys(envValue).sort()) { + if (GENERATED_RUNTIME_ENV_KEY_RE.test(key)) continue; + const manifestEntry = context.secrets.byConfigPath.get(`env.${key}`) ?? context.secrets.byEnvKey.get(key); + if (manifestEntry) { + canonicalEnv[key] = canonicalSecretMetadata(manifestEntry); + continue; + } + const rawBinding = envValue[key]; + if (isSecretRefBinding(rawBinding)) { + canonicalEnv[key] = canonicalSecretRefBinding(rawBinding, `env.${key}`); + continue; + } + canonicalEnv[key] = omitNullish({ + type: "plain_env", + present: rawBinding !== undefined && rawBinding !== null, + valueHash: hashPlainEnvValue(rawBinding), + }); + } + return Object.keys(canonicalEnv).length > 0 ? canonicalEnv : OMIT; +} + +type CanonicalizeContext = { + category: EffectiveRunConfigFingerprintCategory; + path: string[]; + secrets: SecretManifestIndex; +}; + +function canonicalizeValue( + value: unknown, + context: CanonicalizeContext, +): EffectiveRunConfigCanonicalValue | typeof OMIT { + if (value === undefined || value instanceof Date) return OMIT; + if (value === null) return null; + if (Array.isArray(value)) { + return value.map((entry, index) => { + const next = canonicalizeValue(entry, { ...context, path: [...context.path, String(index)] }); + return next === OMIT ? null : next; + }); + } + if (isSecretRefBinding(value)) { + return canonicalSecretRefBinding(value, context.path.join(".")); + } + if (isPlainObject(value)) { + const canonicalObject: Record = {}; + for (const key of Object.keys(value).sort()) { + if (key === "env") { + const env = canonicalizeEnvRecord(value[key], { ...context, path: [...context.path, key] }); + if (env !== OMIT) canonicalObject[key] = env; + continue; + } + if (shouldOmitObjectKey(context.category, key)) continue; + if (SENSITIVE_CONFIG_KEY_RE.test(key)) { + canonicalObject[key] = REDACTED_VALUE; + continue; + } + const next = canonicalizeValue(value[key], { ...context, path: [...context.path, key] }); + if (next !== OMIT) canonicalObject[key] = next; + } + return Object.keys(canonicalObject).length > 0 ? canonicalObject : {}; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "boolean" || typeof value === "string") { + return value; + } + return null; +} + +export function canonicalizeEffectiveRunConfigCategory(input: { + category: EffectiveRunConfigFingerprintCategory; + value: unknown; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; +}): EffectiveRunConfigCanonicalValue { + const canonical = canonicalizeValue(input.value ?? {}, { + category: input.category, + path: [], + secrets: buildSecretManifestIndex(input.secretManifest), + }); + return canonical === OMIT ? {} : canonical; +} + +function createCategoryFingerprint(input: { + category: EffectiveRunConfigFingerprintCategory; + value: unknown; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; +}): EffectiveRunConfigFingerprint { + const canonicalValue = canonicalizeEffectiveRunConfigCategory(input); + const canonicalJson = stableStringify({ + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + category: input.category, + value: canonicalValue, + }); + const digest = createHash("sha256").update(canonicalJson).digest("hex"); + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + category: input.category, + algorithm: EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM, + fingerprint: `v${EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION}:sha256:${digest}`, + canonicalJson, + }; +} + +function createCategoryFingerprintFromCanonicalValue(input: { + category: EffectiveRunConfigFingerprintCategory; + value: EffectiveRunConfigCanonicalValue; +}): EffectiveRunConfigFingerprint { + const canonicalJson = stableStringify({ + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + category: input.category, + value: input.value, + }); + const digest = createHash("sha256").update(canonicalJson).digest("hex"); + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + category: input.category, + algorithm: EFFECTIVE_RUN_CONFIG_FINGERPRINT_ALGORITHM, + fingerprint: `v${EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION}:sha256:${digest}`, + canonicalJson, + }; +} + +function canonicalRecord(value: EffectiveRunConfigCanonicalValue) { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +export function createEffectiveRunConfigSubcategoryFingerprints(input: { + category: EffectiveRunConfigFingerprintCategory; + value: Record; + subcategories: readonly T[]; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; +}): Record { + const canonicalValue = canonicalizeEffectiveRunConfigCategory({ + category: input.category, + value: input.value, + secretManifest: input.secretManifest, + }); + const record = canonicalRecord(canonicalValue); + return Object.fromEntries( + input.subcategories.map((subcategory) => { + const value = Object.prototype.hasOwnProperty.call(record, subcategory) + ? { [subcategory]: record[subcategory] ?? null } + : {}; + return [ + subcategory, + createCategoryFingerprintFromCanonicalValue({ + category: input.category, + value, + }).fingerprint, + ]; + }), + ) as Record; +} + +export function createEffectiveRunConfigFingerprints( + input: EffectiveRunConfigFingerprintInput, +): EffectiveRunConfigFingerprints { + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + categories: EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES, + sessionFingerprint: createCategoryFingerprint({ + category: "session", + value: input.session, + secretManifest: input.secretManifest, + }), + workspaceFingerprint: createCategoryFingerprint({ + category: "workspace", + value: input.workspace, + secretManifest: input.secretManifest, + }), + leaseFingerprint: createCategoryFingerprint({ + category: "lease", + value: input.lease, + secretManifest: input.secretManifest, + }), + }; +} + +function fingerprintForCategory( + fingerprints: EffectiveRunConfigFingerprints, + category: EffectiveRunConfigFingerprintCategory, +) { + switch (category) { + case "session": + return fingerprints.sessionFingerprint.fingerprint; + case "workspace": + return fingerprints.workspaceFingerprint.fingerprint; + case "lease": + return fingerprints.leaseFingerprint.fingerprint; + } +} + +export function diffEffectiveRunConfigFingerprints( + previous: EffectiveRunConfigFingerprints, + next: EffectiveRunConfigFingerprints, +): EffectiveRunConfigFingerprintDiff { + const changed = Object.fromEntries( + EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES.map((category) => [ + category, + fingerprintForCategory(previous, category) !== fingerprintForCategory(next, category), + ]), + ) as Record; + const changedCategories = EFFECTIVE_RUN_CONFIG_FINGERPRINT_CATEGORIES.filter((category) => changed[category]); + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + hasChanges: changedCategories.length > 0, + changedCategories, + changed, + }; +} diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index bf3de1e2e5..151b181a7c 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { and, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { environmentLeases } from "@paperclipai/db"; +import { companySecrets, companySecretVersions, environmentLeases } from "@paperclipai/db"; import type { Environment, EnvironmentLease, @@ -18,10 +18,16 @@ import type { import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh"; import { environmentService } from "./environments.js"; import { + collectEnvironmentSecretRefs, parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime, stripSandboxProviderEnvelope, } from "./environment-config.js"; +import { + createEffectiveRunConfigFingerprints, + type EffectiveRunConfigFingerprint, + type EffectiveRunConfigSecretVersionMetadata, +} from "./effective-run-config-fingerprints.js"; import { acquireSandboxProviderLease, destroySandboxProviderLease, @@ -255,6 +261,146 @@ function reusableRuntimeFingerprint(input: { .digest("hex"); } +function serializeLeaseFingerprint( + fingerprint: EffectiveRunConfigFingerprint | null | undefined, +): Record | null { + if (!fingerprint) return null; + return { + version: fingerprint.version, + category: fingerprint.category, + algorithm: fingerprint.algorithm, + fingerprint: fingerprint.fingerprint, + }; +} + +function readLeaseFingerprint(value: unknown): string | null { + return isRecord(value) ? readString(value.fingerprint) : null; +} + +async function buildEnvironmentSecretMetadataForLeaseFingerprint(input: { + db: Db; + companyId: string; + environment: Environment; +}): Promise { + const refs = await collectEnvironmentSecretRefs({ + db: input.db, + environment: input.environment, + }); + if (refs.length === 0) return []; + + const secretIds = [...new Set(refs.map((ref) => ref.secretId))]; + const secretRows = await input.db + .select() + .from(companySecrets) + .where(inArray(companySecrets.id, secretIds)); + const secretsById = new Map( + secretRows + .filter((secret) => secret.companyId === input.companyId) + .map((secret) => [secret.id, secret]), + ); + + const versionRequests = refs.flatMap((ref) => { + const secret = secretsById.get(ref.secretId); + if (!secret) return []; + const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined + ? secret.latestVersion + : ref.versionSelector; + return typeof resolvedVersion === "number" + ? [{ secretId: secret.id, version: resolvedVersion }] + : []; + }); + const versionSecretIds = [...new Set(versionRequests.map((request) => request.secretId))]; + const versions = [...new Set(versionRequests.map((request) => request.version))]; + const versionRows = versionSecretIds.length > 0 && versions.length > 0 + ? await input.db + .select() + .from(companySecretVersions) + .where( + and( + inArray(companySecretVersions.secretId, versionSecretIds), + inArray(companySecretVersions.version, versions), + ), + ) + : []; + const versionsBySecretAndNumber = new Map( + versionRows.map((row) => [`${row.secretId}:${row.version}`, row]), + ); + + const metadata: EffectiveRunConfigSecretVersionMetadata[] = []; + for (const ref of refs) { + const secret = secretsById.get(ref.secretId); + if (!secret) { + metadata.push({ + configPath: ref.configPath, + envKey: null, + secretId: ref.secretId, + version: typeof ref.versionSelector === "number" ? ref.versionSelector : "unresolved", + outcome: "failure", + }); + continue; + } + + const resolvedVersion = ref.versionSelector === "latest" || ref.versionSelector === undefined + ? secret.latestVersion + : ref.versionSelector; + const versionRow = typeof resolvedVersion === "number" + ? versionsBySecretAndNumber.get(`${secret.id}:${resolvedVersion}`) ?? null + : null; + + metadata.push({ + configPath: ref.configPath, + envKey: null, + secretId: secret.id, + version: resolvedVersion, + provider: secret.provider, + providerVersionRef: versionRow?.providerVersionRef ?? null, + outcome: versionRow ? "success" : "failure", + }); + } + + return metadata; +} + +async function buildReusableSandboxLeaseFingerprint(input: { + db: Db; + companyId: string; + environment: Environment; + executionWorkspaceId: string | null; + agentId: string | null; + adapterType: string | null; + provider: string; + providerConfig: Record; + providerPlugin?: { + id: string; + pluginKey: string; + packageName: string; + version: string; + } | null; +}): Promise { + const secretMetadata = await buildEnvironmentSecretMetadataForLeaseFingerprint({ + db: input.db, + companyId: input.companyId, + environment: input.environment, + }); + return createEffectiveRunConfigFingerprints({ + lease: { + companyId: input.companyId, + environment: { + id: input.environment.id, + driver: input.environment.driver, + }, + executionWorkspaceId: input.executionWorkspaceId, + agentId: input.agentId, + adapterType: input.adapterType, + provider: input.provider, + providerPlugin: input.providerPlugin ?? null, + providerConfig: input.providerConfig, + secrets: secretMetadata, + }, + secretManifest: secretMetadata, + }).leaseFingerprint; +} + function buildReusableSandboxLeaseScope(input: { companyId: string; environmentId: string; @@ -263,6 +409,7 @@ function buildReusableSandboxLeaseScope(input: { adapterType: string | null; provider: string; config: Record; + leaseFingerprint?: EffectiveRunConfigFingerprint | null; providerMetadata?: Record | null; }): Record | null { if (!input.executionWorkspaceId || !input.agentId) return null; @@ -285,6 +432,9 @@ function buildReusableSandboxLeaseScope(input: { adapterType, config: input.config, }), + ...(input.leaseFingerprint + ? { leaseFingerprint: serializeLeaseFingerprint(input.leaseFingerprint) } + : {}), ...(remoteCwd ? { remoteCwd } : {}), ...(workspaceSentinel ? { workspaceSentinel } : {}), }; @@ -299,24 +449,48 @@ function reusableSandboxLeaseScopeMatches(input: { adapterType: string | null; provider: string; config: Record; + leaseFingerprint?: EffectiveRunConfigFingerprint | null; + allowLegacyRuntimeFingerprint?: boolean; }): boolean { if (!input.executionWorkspaceId || !input.agentId) return false; const scope = input.lease.metadata?.reusableSandboxLease; if (!isRecord(scope)) return false; const adapterType = input.adapterType ?? null; - return ( + const baseScopeMatches = scope.companyId === input.companyId && scope.environmentId === input.environmentId && scope.executionWorkspaceId === input.executionWorkspaceId && scope.agentId === input.agentId && scope.adapterType === adapterType && - scope.provider === input.provider && - scope.runtimeFingerprint === reusableRuntimeFingerprint({ - provider: input.provider, - adapterType, - config: input.config, - }) - ); + scope.provider === input.provider; + if (!baseScopeMatches) return false; + + const expectedLeaseFingerprint = input.leaseFingerprint?.fingerprint ?? null; + if (expectedLeaseFingerprint) { + const storedLeaseFingerprint = readLeaseFingerprint(scope.leaseFingerprint); + if (storedLeaseFingerprint) { + return storedLeaseFingerprint === expectedLeaseFingerprint; + } + if (!input.allowLegacyRuntimeFingerprint) return false; + } + + return scope.runtimeFingerprint === reusableRuntimeFingerprint({ + provider: input.provider, + adapterType, + config: input.config, + }); +} + +function reusableLeaseCanBeResumed(input: { + lease: Pick; + heartbeatRunId: string | null; +}): boolean { + if (input.lease.status === "released" || input.lease.status === "retained") return true; + return input.lease.status === "active" && input.heartbeatRunId !== null && input.lease.heartbeatRunId === input.heartbeatRunId; +} + +function reusableLeaseCanBeCleanedUp(lease: Pick): boolean { + return lease.status === "released" || lease.status === "retained"; } export function findReusableSandboxLeaseId(input: { @@ -531,6 +705,23 @@ function createSandboxEnvironmentDriver( }; } + async function cleanupObsoleteReusableSandboxLeases(input: { + environment: Environment; + leases: EnvironmentLease[]; + reusableLeases: EnvironmentLease[]; + }) { + const reusableIds = new Set(input.reusableLeases.map((lease) => lease.id)); + for (const lease of input.leases) { + if (reusableIds.has(lease.id)) continue; + if (!reusableLeaseCanBeCleanedUp(lease)) continue; + await destroyReusableSandboxLease({ + environment: input.environment, + lease, + failureReason: "lease_fingerprint_mismatch", + }); + } + } + return { driver: "sandbox", @@ -573,14 +764,38 @@ function createSandboxEnvironmentDriver( const workerConfig = stripSandboxProviderEnvelope(parsed.config); const storedConfig = storedParsed.config; + const providerConfigForLease = sandboxConfigForLeaseMetadata(storedConfig); const supportsReusableLeases = pluginProvider.resolved.driver.supportsReusableLeases === true; + const leaseFingerprint = + supportsReusableLeases && + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null + ? await buildReusableSandboxLeaseFingerprint({ + db, + companyId: input.companyId, + environment: input.environment, + executionWorkspaceId: input.executionWorkspaceId, + agentId: input.agentId, + adapterType: input.adapterType, + provider: parsed.config.provider, + providerConfig: providerConfigForLease, + providerPlugin: { + id: pluginProvider.resolved.plugin.id, + pluginKey: pluginProvider.resolved.plugin.pluginKey, + packageName: pluginProvider.resolved.plugin.packageName, + version: pluginProvider.resolved.plugin.version, + }, + }) + : null; // Ad-hoc tests (heartbeatRunId === null) must never resume an existing // provider lease. If they did, releasing the test lease at the end of // the probe would tear down the live heartbeat run that owns it. // We also filter out leases whose policy is not reuse_by_environment // and whose status is not reusable so non-reusable, cleanup-pending, // or terminal rows cannot be matched. - const reusableExistingLeases = + const reusableCandidateLeases = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null && @@ -589,21 +804,35 @@ function createSandboxEnvironmentDriver( ? (await environmentsSvc.listLeases(input.environment.id)) .filter((lease) => lease.leasePolicy === "reuse_by_environment" && - ["active", "released", "retained"].includes(lease.status) && + reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) && lease.executionWorkspaceId === input.executionWorkspaceId && - lease.metadata?.agentId === input.agentId && - reusableSandboxLeaseScopeMatches({ - lease, - companyId: input.companyId, - environmentId: input.environment.id, - executionWorkspaceId: input.executionWorkspaceId, - agentId: input.agentId, - adapterType: input.adapterType, - provider: parsed.config.provider, - config: sandboxConfigForLeaseMetadata(storedConfig), - }), + lease.metadata?.agentId === input.agentId, ) : []; + const reusableExistingLeases = reusableCandidateLeases.filter((lease) => + reusableSandboxLeaseScopeMatches({ + lease, + companyId: input.companyId, + environmentId: input.environment.id, + executionWorkspaceId: input.executionWorkspaceId, + agentId: input.agentId, + adapterType: input.adapterType, + provider: parsed.config.provider, + config: providerConfigForLease, + leaseFingerprint, + allowLegacyRuntimeFingerprint: + lease.status === "active" && + input.heartbeatRunId !== null && + lease.heartbeatRunId === input.heartbeatRunId, + }), + ); + if (reusableCandidateLeases.length > reusableExistingLeases.length) { + await cleanupObsoleteReusableSandboxLeases({ + environment: input.environment, + leases: reusableCandidateLeases, + reusableLeases: reusableExistingLeases, + }); + } const reusableProviderLeaseId = supportsReusableLeases && parsed.config.reuseLease && @@ -694,7 +923,8 @@ function createSandboxEnvironmentDriver( agentId: input.agentId, adapterType: input.adapterType, provider: parsed.config.provider, - config: sandboxConfigForLeaseMetadata(storedConfig), + config: providerConfigForLease, + leaseFingerprint, providerMetadata: sanitizedProviderMetadata, }) : null; @@ -730,38 +960,72 @@ function createSandboxEnvironmentDriver( // so non-reusable, cleanup-pending, or terminal rows can never be matched. const builtinSandboxProvider = getBuiltinSandboxProvider(parsed.config.provider); const supportsReusableLeases = builtinSandboxProvider?.supportsReusableLeases === true; + const providerConfigForLease = sandboxConfigForLeaseMetadata(parsed.config); + const leaseFingerprint = + supportsReusableLeases && + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null + ? await buildReusableSandboxLeaseFingerprint({ + db, + companyId: input.companyId, + environment: input.environment, + executionWorkspaceId: input.executionWorkspaceId, + agentId: input.agentId, + adapterType: input.adapterType, + provider: parsed.config.provider, + providerConfig: providerConfigForLease, + }) + : null; + const reusableCandidateLeases = + supportsReusableLeases && + parsed.config.reuseLease && + input.heartbeatRunId !== null && + input.executionWorkspaceId !== null && + input.agentId !== null + ? (await environmentsSvc.listLeases(input.environment.id)) + .filter((lease) => + lease.leasePolicy === "reuse_by_environment" && + reusableLeaseCanBeResumed({ lease, heartbeatRunId: input.heartbeatRunId }) && + lease.executionWorkspaceId === input.executionWorkspaceId && + lease.metadata?.agentId === input.agentId, + ) + : []; + const reusableExistingLeases = reusableCandidateLeases.filter((lease) => + reusableSandboxLeaseScopeMatches({ + lease, + companyId: input.companyId, + environmentId: input.environment.id, + executionWorkspaceId: input.executionWorkspaceId, + agentId: input.agentId, + adapterType: input.adapterType, + provider: parsed.config.provider, + config: providerConfigForLease, + leaseFingerprint, + allowLegacyRuntimeFingerprint: + lease.status === "active" && + input.heartbeatRunId !== null && + lease.heartbeatRunId === input.heartbeatRunId, + }), + ); + if (reusableCandidateLeases.length > reusableExistingLeases.length) { + await cleanupObsoleteReusableSandboxLeases({ + environment: input.environment, + leases: reusableCandidateLeases, + reusableLeases: reusableExistingLeases, + }); + } const reusableProviderLeaseId = supportsReusableLeases && parsed.config.reuseLease && input.heartbeatRunId !== null && input.executionWorkspaceId !== null && input.agentId !== null - ? (await environmentsSvc - .listLeases(input.environment.id) - .then((leases) => - findReusableSandboxLeaseId({ - config: parsed.config, - leases: leases.filter((lease) => - lease.leasePolicy === "reuse_by_environment" && - ["active", "released", "retained"].includes(lease.status) && - lease.executionWorkspaceId === input.executionWorkspaceId && - lease.metadata?.agentId === input.agentId && - reusableSandboxLeaseScopeMatches({ - lease, - companyId: input.companyId, - environmentId: input.environment.id, - executionWorkspaceId: input.executionWorkspaceId, - agentId: input.agentId, - adapterType: input.adapterType, - provider: parsed.config.provider, - config: sandboxConfigForLeaseMetadata(parsed.config), - }), - ), - }), - )) + ? findReusableSandboxLeaseId({ config: parsed.config, leases: reusableExistingLeases }) : null; const reusableLease = reusableProviderLeaseId - ? (await environmentsSvc.listLeases(input.environment.id)).find((lease) => lease.providerLeaseId === reusableProviderLeaseId) + ? reusableExistingLeases.find((lease) => lease.providerLeaseId === reusableProviderLeaseId) : null; let providerLease; @@ -806,7 +1070,8 @@ function createSandboxEnvironmentDriver( agentId: input.agentId, adapterType: input.adapterType, provider: parsed.config.provider, - config: sandboxConfigForLeaseMetadata(parsed.config), + config: providerConfigForLease, + leaseFingerprint, providerMetadata: providerLease.metadata, }) : null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 739b4dc47e..5c6a2b17d1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { and, asc, desc, eq, getTableColumns, gt, gte, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -26,6 +26,7 @@ import { } from "@paperclipai/shared"; import { agents, + agentConfigRevisions, agentRuntimeState, agentTaskSessions, agentWakeupRequests, @@ -123,7 +124,7 @@ import { } from "./issue-continuation-summary.js"; import { buildPlanReviewContext } from "./plan-review-context.js"; import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; -import { workspaceOperationService } from "./workspace-operations.js"; +import { workspaceOperationService, type WorkspaceOperationRecorder } from "./workspace-operations.js"; import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js"; import { buildExecutionWorkspaceAdapterConfig, @@ -208,6 +209,13 @@ import { assertLowTrustWorkspaceIsolation, } from "./low-trust-runtime-containment.js"; import { resolveCoreTrustPreset, type TrustPresetResolution } from "./trust-preset-resolver.js"; +import { + createEffectiveRunConfigFingerprints, + createEffectiveRunConfigSubcategoryFingerprints, + EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + type EffectiveRunConfigFingerprints, + type EffectiveRunConfigSecretManifestEntry, +} from "./effective-run-config-fingerprints.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; @@ -915,6 +923,8 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: { createdByRuntime: boolean; configSnapshot: Record | null; shouldReuseExisting: boolean; + shouldRefreshConfigSnapshot?: boolean; + workspaceConfigMetadata?: EffectiveRunWorkspaceConfigMetadata | null; baseRef: string | null | undefined; baseRefSha: string | null | undefined; }) { @@ -935,7 +945,17 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: { }; } - if (input.shouldReuseExisting || !input.configSnapshot) { + if (input.workspaceConfigMetadata) { + base[WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY] = { + version: input.workspaceConfigMetadata.version, + workspaceHash: input.workspaceConfigMetadata.fingerprint, + categories: input.workspaceConfigMetadata.categories, + categoryFingerprints: input.workspaceConfigMetadata.categoryFingerprints, + lastEvaluatedAt: input.workspaceConfigMetadata.evaluatedAt, + }; + } + + if ((input.shouldReuseExisting && !input.shouldRefreshConfigSnapshot) || !input.configSnapshot) { return base; } @@ -948,35 +968,6 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record, environmentId?: string | null, @@ -2473,6 +2464,704 @@ export function shouldDeferFollowupWakeForSameIssue(input: { } const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel"; +const SESSION_CONFIG_FINGERPRINT_KEY = "__paperclipConfigFingerprint"; +const SESSION_CONFIG_FINGERPRINT_VERSION_KEY = "__paperclipConfigFingerprintVersion"; +const SESSION_CONFIG_CATEGORIES_KEY = "__paperclipConfigCategories"; +const SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY = "__paperclipConfigCategoryFingerprints"; +const PAPERCLIP_SESSION_METADATA_KEYS = new Set([ + SESSION_CONFIGURED_MODEL_KEY, + SESSION_CONFIG_FINGERPRINT_KEY, + SESSION_CONFIG_FINGERPRINT_VERSION_KEY, + SESSION_CONFIG_CATEGORIES_KEY, + SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY, +]); +const WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY = "configFingerprint"; +const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES = [ + "adapter", + "adapterConfig", + "agentRuntimeConfig", + "modelProfile", + "instructions", + "issueOverrides", + "workspaceConfig", + "environment", + "envBindings", + "secrets", + "runtimeSkills", +] as const; +const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES = [ + "mode", + "projectWorkspace", + "strategy", + "repo", + "lifecycleCommands", + "runtimeServices", + "environment", + "realization", +] as const; + +type EffectiveRunSessionConfigCategory = (typeof EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES)[number]; +type EffectiveRunWorkspaceConfigCategory = (typeof EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES)[number]; + +type EffectiveRunSessionConfigMetadata = { + version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; + fingerprint: string; + categories: EffectiveRunSessionConfigCategory[]; + categoryFingerprints: Record; + fingerprints: EffectiveRunConfigFingerprints; +}; + +type TaskSessionConfigFreshnessDecision = { + reset: boolean; + reasons: string[]; + changedCategories: EffectiveRunSessionConfigCategory[]; + storedFingerprint: string | null; + nextFingerprint: string | null; +}; + +export type EffectiveRunWorkspaceConfigMetadata = { + version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION; + fingerprint: string; + categories: EffectiveRunWorkspaceConfigCategory[]; + categoryFingerprints: Record; + fingerprints: EffectiveRunConfigFingerprints; + evaluatedAt: string; +}; + +type WorkspaceConfigFreshnessDecisionAction = "create" | "reuse" | "refresh" | "replace"; + +type ExecutionWorkspaceConfigFreshnessDecision = { + action: WorkspaceConfigFreshnessDecisionAction; + shouldReuseExisting: boolean; + shouldRefreshConfigSnapshot: boolean; + reasons: string[]; + changedCategories: EffectiveRunWorkspaceConfigCategory[]; + storedFingerprint: string | null; + inferredFingerprint: string | null; + nextFingerprint: string | null; + storedFingerprintPresent: boolean; +}; + +type WorkspaceConfigFreshnessOperationInput = { + decision: ExecutionWorkspaceConfigFreshnessDecision; + hasExistingWorkspace: boolean; + reuseRequested: boolean; + workspaceReused: boolean; + configSnapshotRefreshed: boolean; + previousWorkspaceId: string | null; + activeWorkspaceId: string | null; +}; + +const EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS: Record = { + adapter: "adapter", + adapterConfig: "adapter config", + agentRuntimeConfig: "agent runtime config", + modelProfile: "model profile", + instructions: "instructions", + issueOverrides: "issue overrides", + workspaceConfig: "workspace config", + environment: "environment", + envBindings: "env bindings", + secrets: "secrets", + runtimeSkills: "runtime skills", +}; +const EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS: Record = { + mode: "workspace mode", + projectWorkspace: "project workspace", + strategy: "workspace strategy", + repo: "repo/base ref", + lifecycleCommands: "workspace lifecycle commands", + runtimeServices: "runtime services", + environment: "environment", + realization: "workspace realization", +}; +const WORKSPACE_REPLACEMENT_CONFIG_CATEGORIES = new Set([ + "mode", + "projectWorkspace", + "strategy", + "repo", + "environment", + "realization", +]); + +function parseStoredConfigCategoryFingerprints(value: unknown) { + const parsed = parseObject(value); + const out: Partial> = {}; + for (const category of EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES) { + const fingerprint = readNonEmptyString(parsed[category]); + if (fingerprint) out[category] = fingerprint; + } + return out; +} + +function readConfigCategoriesFromSessionParams( + sessionParams: Record | null | undefined, +) { + const rawCategories = Array.isArray(sessionParams?.[SESSION_CONFIG_CATEGORIES_KEY]) + ? sessionParams?.[SESSION_CONFIG_CATEGORIES_KEY] + : []; + return rawCategories.filter( + (category): category is EffectiveRunSessionConfigCategory => + typeof category === "string" && + (EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES as readonly string[]).includes(category), + ); +} + +function readConfigFingerprintFromSessionParams( + sessionParams: Record | null | undefined, +) { + if (!sessionParams) return null; + const fingerprint = readNonEmptyString(sessionParams[SESSION_CONFIG_FINGERPRINT_KEY]); + const version = asNumber(sessionParams[SESSION_CONFIG_FINGERPRINT_VERSION_KEY], 0); + if (!fingerprint || version <= 0) return null; + return { + fingerprint, + version, + categories: readConfigCategoriesFromSessionParams(sessionParams), + categoryFingerprints: parseStoredConfigCategoryFingerprints( + sessionParams[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY], + ), + }; +} + +function describeEffectiveRunConfigCategories(categories: readonly EffectiveRunSessionConfigCategory[]) { + return categories.map((category) => EFFECTIVE_RUN_SESSION_CONFIG_CATEGORY_LABELS[category]).join(", "); +} + +function changedEffectiveRunSessionConfigCategories(input: { + previous: Partial>; + next: Record; +}) { + const changed = EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES.filter( + (category) => input.previous[category] !== input.next[category], + ); + return changed.length > 0 ? changed : [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES]; +} + +function parseStoredWorkspaceConfigCategoryFingerprints(value: unknown) { + const parsed = parseObject(value); + const out: Partial> = {}; + for (const category of EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES) { + const fingerprint = readNonEmptyString(parsed[category]); + if (fingerprint) out[category] = fingerprint; + } + return out; +} + +function readWorkspaceConfigCategoriesFromMetadata(value: unknown) { + const rawCategories = Array.isArray(value) ? value : []; + return rawCategories.filter( + (category): category is EffectiveRunWorkspaceConfigCategory => + typeof category === "string" && + (EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES as readonly string[]).includes(category), + ); +} + +function readWorkspaceConfigFingerprintFromMetadata( + metadata: Record | null | undefined, +) { + const raw = parseObject(metadata?.[WORKSPACE_CONFIG_FINGERPRINT_METADATA_KEY]); + const fingerprint = readNonEmptyString(raw.workspaceHash) ?? readNonEmptyString(raw.fingerprint); + const version = asNumber(raw.version, 0); + if (!fingerprint || version <= 0) return null; + return { + fingerprint, + version, + categories: readWorkspaceConfigCategoriesFromMetadata(raw.categories), + categoryFingerprints: parseStoredWorkspaceConfigCategoryFingerprints(raw.categoryFingerprints), + }; +} + +function describeEffectiveRunWorkspaceConfigCategories( + categories: readonly EffectiveRunWorkspaceConfigCategory[], +) { + return categories.map((category) => EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS[category]).join(", "); +} + +function changedEffectiveRunWorkspaceConfigCategories(input: { + previous: Partial>; + next: Record; +}) { + const changed = EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES.filter( + (category) => input.previous[category] !== input.next[category], + ); + return changed.length > 0 ? changed : [...EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES]; +} + +function workspaceConfigFreshnessActionLabel(action: WorkspaceConfigFreshnessDecisionAction) { + switch (action) { + case "refresh": + return "refreshed execution workspace config"; + case "replace": + return "replaced execution workspace"; + case "reuse": + return "updated execution workspace freshness metadata"; + case "create": + return "created execution workspace"; + } +} + +export function buildWorkspaceConfigFreshnessOperation(input: WorkspaceConfigFreshnessOperationInput) { + if (!input.reuseRequested || !input.hasExistingWorkspace || input.decision.reasons.length === 0) { + return null; + } + + const changedCategoryLabels = input.decision.changedCategories.map( + (category) => EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORY_LABELS[category], + ); + const categorySummary = + changedCategoryLabels.length > 0 ? ` (${changedCategoryLabels.join(", ")})` : ""; + const reasonSummary = input.decision.reasons.join("; "); + + return { + metadata: { + kind: "config_freshness", + action: input.decision.action, + changedCategories: input.decision.changedCategories, + changedCategoryLabels, + reasons: input.decision.reasons, + reuseRequested: input.reuseRequested, + workspaceReused: input.workspaceReused, + configSnapshotRefreshed: input.configSnapshotRefreshed, + storedFingerprintPresent: input.decision.storedFingerprintPresent, + previousWorkspaceId: input.previousWorkspaceId, + activeWorkspaceId: input.activeWorkspaceId, + }, + system: + `[paperclip] ${workspaceConfigFreshnessActionLabel(input.decision.action)} after config freshness check${categorySummary}: ${reasonSummary}\n`, + }; +} + +async function recordWorkspaceConfigFreshnessOperation(input: WorkspaceConfigFreshnessOperationInput & { + recorder: WorkspaceOperationRecorder; + runId: string; +}) { + const operation = buildWorkspaceConfigFreshnessOperation(input); + if (!operation) return; + + try { + await input.recorder.recordOperation({ + phase: "workspace_config_freshness", + metadata: operation.metadata, + run: async () => ({ + status: "succeeded", + system: operation.system, + }), + }); + } catch (error) { + logger.warn( + { + err: error instanceof Error ? error.message : String(error), + runId: input.runId, + previousWorkspaceId: input.previousWorkspaceId, + activeWorkspaceId: input.activeWorkspaceId, + action: input.decision.action, + }, + "failed to record workspace config freshness operation", + ); + } +} + +function sanitizeSecretManifestForConfigFingerprint( + manifest: readonly EffectiveRunConfigSecretManifestEntry[], +) { + return manifest.map((entry) => { + const record = entry as Record; + return { + configPath: readNonEmptyString(record.configPath) ?? "", + envKey: readNonEmptyString(record.envKey), + secretId: readNonEmptyString(record.secretId) ?? "", + bindingId: readNonEmptyString(record.bindingId), + version: typeof record.version === "number" && Number.isFinite(record.version) + ? record.version + : readNonEmptyString(record.version), + provider: readNonEmptyString(record.provider), + providerVersionRef: readNonEmptyString(record.providerVersionRef), + outcome: record.outcome === "success" || record.outcome === "failure" ? record.outcome : null, + }; + }); +} + +async function hashFileContentsForConfigFingerprint(filePath: string) { + const contents = await fs.readFile(filePath); + return `sha256:${createHash("sha256").update(contents).digest("hex")}`; +} + +function isPathInsideRoot(input: { rootPath: string; filePath: string }) { + const relative = path.relative(input.rootPath, input.filePath); + return relative === "" || ( + relative.length > 0 + && !relative.startsWith("..") + && !path.isAbsolute(relative) + ); +} + +function resolveRootBoundInstructionsFingerprintPath(input: { + instructionsFilePath: string | null; + instructionsRootPath: string | null; + instructionsEntryFile: string | null; +}): { filePath: string; skippedReason: null } | { filePath: null; skippedReason: string | null } { + if (!input.instructionsRootPath || !path.isAbsolute(input.instructionsRootPath)) { + return { + filePath: null, + skippedReason: input.instructionsFilePath ? "missing_absolute_root" : null, + }; + } + + const rootPath = path.resolve(input.instructionsRootPath); + const candidatePath = input.instructionsEntryFile ?? input.instructionsFilePath; + if (!candidatePath) return { filePath: null, skippedReason: "missing_entry_file" }; + + const resolvedPath = path.isAbsolute(candidatePath) + ? path.resolve(candidatePath) + : path.resolve(rootPath, candidatePath); + + if (!isPathInsideRoot({ rootPath, filePath: resolvedPath })) { + return { filePath: null, skippedReason: "outside_root" }; + } + + return { filePath: resolvedPath, skippedReason: null }; +} + +async function resolveInstructionsConfigFingerprintMetadata(config: Record) { + const instructionsFilePath = readNonEmptyString(config.instructionsFilePath); + const instructionsRootPath = readNonEmptyString(config.instructionsRootPath); + const instructionsEntryFile = readNonEmptyString(config.instructionsEntryFile); + const resolved = resolveRootBoundInstructionsFingerprintPath({ + instructionsFilePath, + instructionsRootPath, + instructionsEntryFile, + }); + const configuredPath = resolved.filePath ?? instructionsFilePath ?? ( + instructionsRootPath && instructionsEntryFile + ? path.resolve(instructionsRootPath, instructionsEntryFile) + : null + ); + if (!configuredPath && !instructionsRootPath && !instructionsEntryFile) return null; + + const metadata: Record = { + configured: true, + bundleMode: readNonEmptyString(config.instructionsBundleMode), + entryFile: instructionsEntryFile, + pathKind: configuredPath ? (path.isAbsolute(configuredPath) ? "absolute" : "relative") : null, + readPolicy: "root_bound", + }; + if (resolved.skippedReason) metadata.readSkippedReason = resolved.skippedReason; + if (resolved.filePath) { + try { + metadata.contentHash = await hashFileContentsForConfigFingerprint(resolved.filePath); + metadata.readable = true; + } catch { + metadata.readable = false; + } + } + return metadata; +} + +function buildSessionConfigCategoryValues(input: { + adapterType: string; + effectiveAdapterConfig: Record; + agentRuntimeConfig: unknown; + modelProfile: unknown; + instructions: unknown; + issueOverrides: unknown; + workspaceConfig: unknown; + environment: unknown; + environmentEnv: unknown; + projectEnv: unknown; + routineEnv: unknown; + secretManifest: readonly EffectiveRunConfigSecretManifestEntry[]; + runtimeSkills: unknown; + agentConfigRevision: unknown; +}) { + const sanitizedSecretManifest = sanitizeSecretManifestForConfigFingerprint(input.secretManifest); + return { + adapter: { + adapterType: input.adapterType, + agentConfigRevision: input.agentConfigRevision, + }, + adapterConfig: input.effectiveAdapterConfig, + agentRuntimeConfig: input.agentRuntimeConfig, + modelProfile: input.modelProfile, + instructions: input.instructions, + issueOverrides: input.issueOverrides, + workspaceConfig: input.workspaceConfig, + environment: input.environment, + envBindings: { + environment: { env: input.environmentEnv }, + project: { env: input.projectEnv }, + routine: { env: input.routineEnv }, + }, + secrets: sanitizedSecretManifest, + runtimeSkills: input.runtimeSkills, + } satisfies Record; +} + +export async function buildEffectiveRunSessionConfigMetadata(input: { + adapterType: string; + effectiveAdapterConfig: Record; + agentRuntimeConfig: unknown; + modelProfile: unknown; + issueOverrides: unknown; + workspaceConfig: unknown; + environment: unknown; + environmentEnv: unknown; + projectEnv: unknown; + routineEnv: unknown; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; + runtimeSkills: unknown; + agentConfigRevision?: unknown; +}): Promise { + const secretManifest = input.secretManifest ?? []; + const instructions = await resolveInstructionsConfigFingerprintMetadata(input.effectiveAdapterConfig); + const categoryValues = buildSessionConfigCategoryValues({ + adapterType: input.adapterType, + effectiveAdapterConfig: input.effectiveAdapterConfig, + agentRuntimeConfig: input.agentRuntimeConfig, + modelProfile: input.modelProfile, + instructions, + issueOverrides: input.issueOverrides, + workspaceConfig: input.workspaceConfig, + environment: input.environment, + environmentEnv: input.environmentEnv, + projectEnv: input.projectEnv, + routineEnv: input.routineEnv, + secretManifest, + runtimeSkills: input.runtimeSkills, + agentConfigRevision: input.agentConfigRevision ?? null, + }); + const fingerprints = createEffectiveRunConfigFingerprints({ + session: categoryValues, + secretManifest, + }); + const categoryFingerprints = createEffectiveRunConfigSubcategoryFingerprints({ + category: "session", + value: categoryValues, + subcategories: EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES, + secretManifest, + }); + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + fingerprint: fingerprints.sessionFingerprint.fingerprint, + categories: [...EFFECTIVE_RUN_SESSION_CONFIG_CATEGORIES], + categoryFingerprints, + fingerprints, + }; +} + +function buildWorkspaceConfigCategoryValues(input: { + mode: unknown; + projectId: unknown; + projectWorkspaceId: unknown; + strategyType: unknown; + workspaceStrategy: unknown; + repoUrl: unknown; + repoRef: unknown; + branchName: unknown; + configSnapshot: Partial | null; + environment: unknown; + realization: unknown; +}) { + const snapshot = input.configSnapshot ?? {}; + return { + mode: { + mode: input.mode ?? null, + }, + projectWorkspace: { + projectId: input.projectId ?? null, + projectWorkspaceId: input.projectWorkspaceId ?? null, + }, + strategy: { + strategyType: input.strategyType ?? null, + workspaceStrategy: input.workspaceStrategy ?? null, + }, + repo: { + repoUrl: input.repoUrl ?? null, + repoRef: input.repoRef ?? null, + branchName: input.branchName ?? null, + }, + lifecycleCommands: { + provisionCommand: snapshot.provisionCommand ?? null, + teardownCommand: snapshot.teardownCommand ?? null, + cleanupCommand: snapshot.cleanupCommand ?? null, + }, + runtimeServices: { + workspaceRuntime: snapshot.workspaceRuntime ?? null, + desiredState: snapshot.desiredState ?? null, + serviceStates: snapshot.serviceStates ?? null, + }, + environment: input.environment ?? null, + realization: input.realization ?? null, + } satisfies Record; +} + +export function buildEffectiveRunWorkspaceConfigMetadata(input: { + mode: unknown; + projectId: unknown; + projectWorkspaceId: unknown; + strategyType: unknown; + workspaceStrategy: unknown; + repoUrl: unknown; + repoRef: unknown; + branchName?: unknown; + configSnapshot: Partial | null; + environment: unknown; + realization: unknown; + secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; + evaluatedAt?: string | Date | null; +}): EffectiveRunWorkspaceConfigMetadata { + const secretManifest = input.secretManifest ?? []; + const categoryValues = buildWorkspaceConfigCategoryValues({ + mode: input.mode, + projectId: input.projectId, + projectWorkspaceId: input.projectWorkspaceId, + strategyType: input.strategyType, + workspaceStrategy: input.workspaceStrategy, + repoUrl: input.repoUrl, + repoRef: input.repoRef, + branchName: input.branchName ?? null, + configSnapshot: input.configSnapshot, + environment: input.environment, + realization: input.realization, + }); + const fingerprints = createEffectiveRunConfigFingerprints({ + workspace: categoryValues, + secretManifest, + }); + const categoryFingerprints = createEffectiveRunConfigSubcategoryFingerprints({ + category: "workspace", + value: categoryValues, + subcategories: EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES, + secretManifest, + }); + const evaluatedAt = input.evaluatedAt instanceof Date + ? input.evaluatedAt.toISOString() + : readNonEmptyString(input.evaluatedAt) ?? new Date().toISOString(); + return { + version: EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION, + fingerprint: fingerprints.workspaceFingerprint.fingerprint, + categories: [...EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES], + categoryFingerprints, + fingerprints, + evaluatedAt, + }; +} + +export function resolveExecutionWorkspaceConfigFreshness(input: { + hasExistingWorkspace: boolean; + existingWorkspaceMetadata: Record | null | undefined; + inferredMetadata?: EffectiveRunWorkspaceConfigMetadata | null; + nextMetadata: EffectiveRunWorkspaceConfigMetadata | null; +}): ExecutionWorkspaceConfigFreshnessDecision { + if (!input.hasExistingWorkspace) { + return { + action: "create", + shouldReuseExisting: false, + shouldRefreshConfigSnapshot: false, + reasons: [], + changedCategories: [], + storedFingerprint: null, + inferredFingerprint: null, + nextFingerprint: input.nextMetadata?.fingerprint ?? null, + storedFingerprintPresent: false, + }; + } + + const stored = readWorkspaceConfigFingerprintFromMetadata(input.existingWorkspaceMetadata); + const previous = stored + ? { + version: stored.version, + fingerprint: stored.fingerprint, + categoryFingerprints: stored.categoryFingerprints, + } + : input.inferredMetadata + ? { + version: input.inferredMetadata.version, + fingerprint: input.inferredMetadata.fingerprint, + categoryFingerprints: input.inferredMetadata.categoryFingerprints, + } + : null; + + if (!input.nextMetadata) { + return { + action: "reuse", + shouldReuseExisting: true, + shouldRefreshConfigSnapshot: false, + reasons: [], + changedCategories: [], + storedFingerprint: stored?.fingerprint ?? null, + inferredFingerprint: stored ? null : input.inferredMetadata?.fingerprint ?? null, + nextFingerprint: null, + storedFingerprintPresent: Boolean(stored), + }; + } + + if (!previous) { + return { + action: "replace", + shouldReuseExisting: false, + shouldRefreshConfigSnapshot: false, + reasons: ["execution workspace configuration fingerprint metadata is missing"], + changedCategories: [...input.nextMetadata.categories], + storedFingerprint: null, + inferredFingerprint: null, + nextFingerprint: input.nextMetadata.fingerprint, + storedFingerprintPresent: false, + }; + } + + if (previous.version !== input.nextMetadata.version) { + return { + action: "replace", + shouldReuseExisting: false, + shouldRefreshConfigSnapshot: false, + reasons: [ + `execution workspace configuration fingerprint version changed from ${previous.version} to ${input.nextMetadata.version}`, + ], + changedCategories: [...input.nextMetadata.categories], + storedFingerprint: stored?.fingerprint ?? null, + inferredFingerprint: stored ? null : input.inferredMetadata?.fingerprint ?? null, + nextFingerprint: input.nextMetadata.fingerprint, + storedFingerprintPresent: Boolean(stored), + }; + } + + if (previous.fingerprint === input.nextMetadata.fingerprint) { + return { + action: "reuse", + shouldReuseExisting: true, + shouldRefreshConfigSnapshot: !stored, + reasons: stored ? [] : ["execution workspace configuration fingerprint metadata is missing"], + changedCategories: [], + storedFingerprint: stored?.fingerprint ?? null, + inferredFingerprint: stored ? null : input.inferredMetadata?.fingerprint ?? null, + nextFingerprint: input.nextMetadata.fingerprint, + storedFingerprintPresent: Boolean(stored), + }; + } + + const changedCategories = changedEffectiveRunWorkspaceConfigCategories({ + previous: previous.categoryFingerprints, + next: input.nextMetadata.categoryFingerprints, + }); + const replacementRequired = changedCategories.some((category) => + WORKSPACE_REPLACEMENT_CONFIG_CATEGORIES.has(category) + ); + const action: WorkspaceConfigFreshnessDecisionAction = replacementRequired ? "replace" : "refresh"; + return { + action, + shouldReuseExisting: action !== "replace", + shouldRefreshConfigSnapshot: action === "refresh", + reasons: [ + `execution workspace configuration changed: ${describeEffectiveRunWorkspaceConfigCategories(changedCategories)}`, + ], + changedCategories, + storedFingerprint: stored?.fingerprint ?? null, + inferredFingerprint: stored ? null : input.inferredMetadata?.fingerprint ?? null, + nextFingerprint: input.nextMetadata.fingerprint, + storedFingerprintPresent: Boolean(stored), + }; +} function readConfiguredModelFromAdapterConfig( adapterConfig: Record | null | undefined, @@ -2480,13 +3169,20 @@ function readConfiguredModelFromAdapterConfig( return readNonEmptyString(adapterConfig?.model); } -function attachConfiguredModelToSessionParams( +function attachPaperclipSessionMetadataToSessionParams( sessionParams: Record | null | undefined, configuredModel: string | null, + configMetadata?: EffectiveRunSessionConfigMetadata | null, ) { - if (!configuredModel) return sessionParams ?? null; + if (!configuredModel && !configMetadata) return sessionParams ?? null; const next = { ...(sessionParams ?? {}) }; - next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel; + if (configuredModel) next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel; + if (configMetadata) { + next[SESSION_CONFIG_FINGERPRINT_KEY] = configMetadata.fingerprint; + next[SESSION_CONFIG_FINGERPRINT_VERSION_KEY] = configMetadata.version; + next[SESSION_CONFIG_CATEGORIES_KEY] = configMetadata.categories; + next[SESSION_CONFIG_CATEGORY_FINGERPRINTS_KEY] = configMetadata.categoryFingerprints; + } return next; } @@ -2515,6 +3211,78 @@ export function stripConfiguredModelFromSessionParams( return next; } +export function stripPaperclipSessionMetadataFromSessionParams( + sessionParams: Record | null | undefined, +) { + if (!sessionParams) return null; + const next = { ...sessionParams }; + for (const key of PAPERCLIP_SESSION_METADATA_KEYS) { + delete next[key]; + } + return next; +} + +export function resolveTaskSessionConfigFreshness(input: { + hasTaskSession: boolean; + configuredModel: string | null; + taskSessionParams: Record | null | undefined; + configMetadata: EffectiveRunSessionConfigMetadata | null; + wakeResetReason?: string | null; + preserveLegacySessionWithoutConfigMetadata?: boolean; +}): TaskSessionConfigFreshnessDecision { + if (!input.hasTaskSession) { + return { + reset: false, + reasons: [], + changedCategories: [], + storedFingerprint: null, + nextFingerprint: input.configMetadata?.fingerprint ?? null, + }; + } + + const reasons: string[] = []; + const storedConfig = readConfigFingerprintFromSessionParams(input.taskSessionParams); + const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(input.taskSessionParams); + const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({ + configuredModel: input.configuredModel, + taskSessionParams: input.taskSessionParams, + }); + if (modelChangedSinceTaskSession && taskSessionConfiguredModel) { + reasons.push(`configured model changed from "${taskSessionConfiguredModel}" to "${input.configuredModel}"`); + } + + let changedCategories: EffectiveRunSessionConfigCategory[] = []; + if (input.configMetadata) { + if (!storedConfig && !input.preserveLegacySessionWithoutConfigMetadata) { + changedCategories = [...input.configMetadata.categories]; + reasons.push("effective run configuration fingerprint metadata is missing"); + } else if (storedConfig && storedConfig.version !== input.configMetadata.version) { + changedCategories = [...input.configMetadata.categories]; + reasons.push( + `effective run configuration fingerprint version changed from ${storedConfig.version} to ${input.configMetadata.version}`, + ); + } else if (storedConfig && storedConfig.fingerprint !== input.configMetadata.fingerprint) { + changedCategories = changedEffectiveRunSessionConfigCategories({ + previous: storedConfig.categoryFingerprints, + next: input.configMetadata.categoryFingerprints, + }); + reasons.push( + `effective run configuration changed: ${describeEffectiveRunConfigCategories(changedCategories)}`, + ); + } + } + + if (input.wakeResetReason) reasons.push(input.wakeResetReason); + + return { + reset: reasons.length > 0, + reasons, + changedCategories, + storedFingerprint: storedConfig?.fingerprint ?? null, + nextFingerprint: input.configMetadata?.fingerprint ?? null, + }; +} + function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -3728,6 +4496,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) originKind: issues.originKind, originId: issues.originId, originRunId: issues.originRunId, + updatedAt: issues.updatedAt, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) @@ -3794,6 +4563,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); } + async function getLatestAgentConfigRevision(companyId: string, agentId: string) { + return db + .select({ + id: agentConfigRevisions.id, + changedKeys: agentConfigRevisions.changedKeys, + createdAt: agentConfigRevisions.createdAt, + }) + .from(agentConfigRevisions) + .where(and(eq(agentConfigRevisions.companyId, companyId), eq(agentConfigRevisions.agentId, agentId))) + .orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + async function getTaskSession( companyId: string, agentId: string, @@ -8567,24 +9350,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) id: projects.id, executionWorkspacePolicy: projects.executionWorkspacePolicy, env: projects.env, + updatedAt: projects.updatedAt, }) .from(projects) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; + const acceptedPlanContinuationWake = issueContext + ? readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" + || ( + issueContext.workMode === "planning" + && readNonEmptyString(context.interactionKind) === "request_confirmation" + && readNonEmptyString(context.interactionStatus) === "accepted" + ) + : false; const acceptedPlanWakeRoutingDecision = issueContext ? await resolveAcceptedPlanWakeRoutingDecision({ db, companyId: agent.companyId, agentId: agent.id, issueId, - acceptedPlanContinuationWake: - readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" - || ( - issueContext.workMode === "planning" - && readNonEmptyString(context.interactionKind) === "request_confirmation" - && readNonEmptyString(context.interactionStatus) === "accepted" - ), + acceptedPlanContinuationWake, contextSnapshot: context, }) : null; @@ -8628,27 +9414,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : null, }); const config = parseObject(agent.adapterConfig); - const configuredModel = readConfiguredModelFromAdapterConfig(config); const taskSession = taskKey ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) : null; const taskSessionDecodedParams = normalizeSessionParams( sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null), ); - const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({ - configuredModel, - taskSessionParams: taskSessionDecodedParams, - }); - const resetTaskSession = shouldResetTaskSessionForWake(context) || modelChangedSinceTaskSession; - const wakeSessionResetReason = describeSessionResetReason(context); - const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(taskSessionDecodedParams); - const modelSessionResetReason = modelChangedSinceTaskSession && taskSessionConfiguredModel - ? `configured model changed from "${taskSessionConfiguredModel}" to "${configuredModel}"` - : null; - const sessionResetReason = [modelSessionResetReason, wakeSessionResetReason] - .filter((value): value is string => Boolean(value)) - .join("; ") || null; - const taskSessionForRun = resetTaskSession ? null : taskSession; const explicitResumeSessionParams = normalizeResumeParamsForAdapter( agent.adapterType, sessionCodec.deserialize(parseObject(context.resumeSessionParams)), @@ -8658,17 +9429,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ?? readNonEmptyString(explicitResumeSessionParams?.sessionId), ); - const previousSessionParams = - explicitResumeSessionParams ?? - (isCanonicalSessionIdForAdapter(agent.adapterType, explicitResumeSessionDisplayId) - ? { sessionId: explicitResumeSessionDisplayId } - : null) ?? - normalizeResumeParamsForAdapter( - agent.adapterType, - stripConfiguredModelFromSessionParams( - sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), - ), - ); const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, @@ -8800,19 +9560,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) instanceDefaultEnvironmentId: resolvedInstanceSettings.defaultEnvironmentId ?? null, localDefaultEnvironmentId: localEnvironment.id, }); - const shouldReuseExisting = requestedShouldReuseExisting; - const reusableExecutionWorkspaceConfig = shouldReuseExisting - ? requestedReusableExecutionWorkspaceConfig - : null; - const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace - ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) - : null; const effectiveExecutionWorkspaceMode: ReturnType = - persistedExecutionWorkspaceMode === "isolated_workspace" || - persistedExecutionWorkspaceMode === "operator_branch" || - persistedExecutionWorkspaceMode === "agent_default" - ? persistedExecutionWorkspaceMode - : requestedExecutionWorkspaceMode; + requestedExecutionWorkspaceMode; const executionPolicy = { executionMode: (await instanceSettings.getGeneral()).executionMode }; let selectedEnvironmentId = environmentResolution.environmentId; if (isExecutionForcedToKubernetes(executionPolicy)) { @@ -8879,50 +9628,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } selectedEnvironmentId = kubernetesEnvironment.id; } - const { - selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver, - workspace: resolvedWorkspace, - } = await resolveWorkspaceAfterLowTrustPreflight({ - db, - trustPreset, - isolatedWorkspacesEnabled, - effectiveExecutionWorkspaceMode, - issue: issueRef - ? { - companyId: agent.companyId, - id: issueRef.id, - projectId: issueRef.projectId, - } - : null, - resolveSelectedEnvironmentDriver: async () => { - const preflightEnvironment = await envOrchestrator.resolveEnvironment({ - companyId: agent.companyId, - selectedEnvironmentId, - localEnvironmentId: localEnvironment.id, - }); - return preflightEnvironment.driver; - }, - resolveWorkspace: () => - resolveWorkspaceForRun( - agent, - context, - previousSessionParams, - { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, - ), - }); - const workspaceManagedConfig = shouldReuseExisting - ? { ...config } - : buildExecutionWorkspaceAdapterConfig({ - agentConfig: config, - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - mode: requestedExecutionWorkspaceMode, - legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); - const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ - config: workspaceManagedConfig, - workspaceConfig: reusableExecutionWorkspaceConfig, - mode: effectiveExecutionWorkspaceMode, + const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ + agentConfig: config, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + mode: requestedExecutionWorkspaceMode, + legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, }); let adapterModelProfiles: AdapterModelProfileDefinition[] = []; let profileResolutionFallbackReason: string | null = null; @@ -8956,7 +9667,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) delete context.paperclipModelProfile; } const mergedConfig = mergeModelProfileAdapterConfig({ - baseConfig: persistedWorkspaceManagedConfig, + baseConfig: workspaceManagedConfig, modelProfile: modelProfileApplication, issueAdapterConfig: issueAssigneeOverrides?.adapterConfig ?? null, }); @@ -9021,17 +9732,126 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; + const latestAgentConfigRevision = await getLatestAgentConfigRevision(agent.companyId, agent.id); + const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata({ + adapterType: agent.adapterType, + effectiveAdapterConfig: runtimeConfig, + agentRuntimeConfig: agent.runtimeConfig, + modelProfile: modelProfileMetadata, + issueOverrides: issueAssigneeOverrides, + workspaceConfig: { + requestedMode: requestedExecutionWorkspaceMode, + effectiveMode: effectiveExecutionWorkspaceMode, + issueConfigRevisionAt: issueContext?.updatedAt instanceof Date + ? issueContext.updatedAt.toISOString() + : issueContext?.updatedAt ?? null, + projectConfigRevisionAt: projectContext?.updatedAt instanceof Date + ? projectContext.updatedAt.toISOString() + : projectContext?.updatedAt ?? null, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + reusableExecutionWorkspaceConfig: requestedReusableExecutionWorkspaceConfig, + existingExecutionWorkspace: existingExecutionWorkspace + ? { + id: existingExecutionWorkspace.id, + mode: existingExecutionWorkspace.mode, + strategyType: existingExecutionWorkspace.strategyType, + projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId, + repoUrl: existingExecutionWorkspace.repoUrl, + baseRef: existingExecutionWorkspace.baseRef, + branchName: existingExecutionWorkspace.branchName, + config: existingExecutionWorkspace.config, + } + : null, + }, + environment: { + selectionSource: environmentResolution.source, + selectedEnvironmentId, + selectedEnvironment: selectedEnvironmentForConfig + ? { + id: selectedEnvironmentForConfig.id, + driver: selectedEnvironmentForConfig.driver, + config: selectedEnvironmentForConfig.config, + configRevisionAt: selectedEnvironmentForConfig.updatedAt instanceof Date + ? selectedEnvironmentForConfig.updatedAt.toISOString() + : selectedEnvironmentForConfig.updatedAt ?? null, + } + : null, + executionPolicy, + }, + environmentEnv: selectedEnvironmentForConfig?.envVars ?? null, + projectEnv: projectContext?.env ?? null, + routineEnv: routineEnvContext.env, + secretManifest, + runtimeSkills: runtimeSkillEntries, + agentConfigRevision: latestAgentConfigRevision + ? { + id: latestAgentConfigRevision.id, + changedKeys: latestAgentConfigRevision.changedKeys, + configRevisionAt: latestAgentConfigRevision.createdAt.toISOString(), + } + : null, + }); + const configuredModel = readConfiguredModelFromAdapterConfig(runtimeConfig); + const wakeSessionResetReason = describeSessionResetReason(context); + const sessionConfigFreshness = resolveTaskSessionConfigFreshness({ + hasTaskSession: taskSession != null, + configuredModel, + taskSessionParams: taskSessionDecodedParams, + configMetadata: sessionConfigMetadata, + wakeResetReason: wakeSessionResetReason, + preserveLegacySessionWithoutConfigMetadata: acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision, + }); + const resetTaskSession = shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset; + const sessionResetReason = sessionConfigFreshness.reasons.join("; ") || null; + const taskSessionForRun = resetTaskSession ? null : taskSession; + const previousSessionParams = + explicitResumeSessionParams ?? + (isCanonicalSessionIdForAdapter(agent.adapterType, explicitResumeSessionDisplayId) + ? { sessionId: explicitResumeSessionDisplayId } + : null) ?? + normalizeResumeParamsForAdapter( + agent.adapterType, + stripPaperclipSessionMetadataFromSessionParams( + sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + ), + ); + const { + selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver, + workspace: resolvedWorkspace, + } = await resolveWorkspaceAfterLowTrustPreflight({ + db, + trustPreset, + isolatedWorkspacesEnabled, + effectiveExecutionWorkspaceMode, + issue: issueRef + ? { + companyId: agent.companyId, + id: issueRef.id, + projectId: issueRef.projectId, + } + : null, + resolveSelectedEnvironmentDriver: async () => { + const preflightEnvironment = await envOrchestrator.resolveEnvironment({ + companyId: agent.companyId, + selectedEnvironmentId, + localEnvironmentId: localEnvironment.id, + }); + return preflightEnvironment.driver; + }, + resolveWorkspace: () => + resolveWorkspaceForRun( + agent, + context, + previousSessionParams, + { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, + ), + }); const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({ - config: runtimeConfig, + config: mergedConfig, trustPreset, selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver, }); - const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ - companyId: agent.companyId, - heartbeatRunId: run.id, - executionWorkspaceId: existingExecutionWorkspace?.id ?? null, - issueId, - }); const executionWorkspaceBase = { baseCwd: resolvedWorkspace.cwd, source: resolvedWorkspace.source, @@ -9040,6 +9860,88 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoUrl: resolvedWorkspace.repoUrl, repoRef: resolvedWorkspace.repoRef, } satisfies ExecutionWorkspaceInput; + const workspaceStrategyForFingerprint = parseObject(hostExecutionWorkspaceConfig.workspaceStrategy); + const workspaceStrategyFingerprintValue = + Object.keys(workspaceStrategyForFingerprint).length > 0 ? workspaceStrategyForFingerprint : null; + const latestWorkspaceStrategyType = + readNonEmptyString(workspaceStrategyForFingerprint.type) ?? + (requestedExecutionWorkspaceMode === "agent_default" + ? "adapter_managed" + : requestedExecutionWorkspaceMode === "isolated_workspace" || + requestedExecutionWorkspaceMode === "operator_branch" + ? "git_worktree" + : "project_primary"); + const selectedEnvironmentConfigForFingerprint = parseObject(selectedEnvironmentForConfig?.config); + const workspaceEnvironmentFingerprint = selectedEnvironmentForConfig + ? { + selectionSource: environmentResolution.source, + selectedEnvironmentId, + driver: selectedEnvironmentForConfig.driver, + provider: readNonEmptyString(selectedEnvironmentConfigForFingerprint.provider), + config: selectedEnvironmentForConfig.config, + configRevisionAt: selectedEnvironmentForConfig.updatedAt instanceof Date + ? selectedEnvironmentForConfig.updatedAt.toISOString() + : selectedEnvironmentForConfig.updatedAt ?? null, + executionPolicy, + } + : null; + const workspaceRealizationFingerprint = { + environmentDriver: selectedEnvironmentForConfig?.driver ?? null, + environmentProvider: readNonEmptyString(selectedEnvironmentConfigForFingerprint.provider), + trustPreset: trustPreset.kind, + lowTrustSandboxDriver: lowTrustPreflightEnvironmentDriver, + }; + const latestWorkspaceConfigMetadata = buildEffectiveRunWorkspaceConfigMetadata({ + mode: requestedExecutionWorkspaceMode, + projectId: executionWorkspaceBase.projectId, + projectWorkspaceId: executionWorkspaceBase.workspaceId, + strategyType: latestWorkspaceStrategyType, + workspaceStrategy: workspaceStrategyFingerprintValue, + repoUrl: executionWorkspaceBase.repoUrl, + repoRef: readNonEmptyString(workspaceStrategyForFingerprint.baseRef) ?? executionWorkspaceBase.repoRef, + configSnapshot, + environment: workspaceEnvironmentFingerprint, + realization: workspaceRealizationFingerprint, + secretManifest, + }); + const inferredExistingWorkspaceConfigMetadata = existingExecutionWorkspace + ? buildEffectiveRunWorkspaceConfigMetadata({ + mode: issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode), + projectId: existingExecutionWorkspace.projectId, + projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId, + strategyType: existingExecutionWorkspace.strategyType, + workspaceStrategy: workspaceStrategyFingerprintValue + ? { + ...workspaceStrategyFingerprintValue, + type: existingExecutionWorkspace.strategyType, + ...(existingExecutionWorkspace.baseRef + ? { baseRef: existingExecutionWorkspace.baseRef } + : {}), + } + : { type: existingExecutionWorkspace.strategyType }, + repoUrl: existingExecutionWorkspace.repoUrl, + repoRef: existingExecutionWorkspace.baseRef, + configSnapshot: existingExecutionWorkspace.config, + environment: workspaceEnvironmentFingerprint, + realization: workspaceRealizationFingerprint, + secretManifest, + evaluatedAt: latestWorkspaceConfigMetadata.evaluatedAt, + }) + : null; + const workspaceConfigFreshness = resolveExecutionWorkspaceConfigFreshness({ + hasExistingWorkspace: requestedShouldReuseExisting && Boolean(existingExecutionWorkspace), + existingWorkspaceMetadata: existingExecutionWorkspace?.metadata ?? null, + inferredMetadata: inferredExistingWorkspaceConfigMetadata, + nextMetadata: latestWorkspaceConfigMetadata, + }); + const shouldReuseExisting = requestedShouldReuseExisting && workspaceConfigFreshness.shouldReuseExisting; + const shouldRefreshWorkspaceConfigSnapshot = shouldReuseExisting && workspaceConfigFreshness.shouldRefreshConfigSnapshot; + const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ + companyId: agent.companyId, + heartbeatRunId: run.id, + executionWorkspaceId: shouldReuseExisting ? existingExecutionWorkspace?.id ?? null : null, + issueId, + }); const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await ensurePersistedExecutionWorkspaceAvailable({ base: executionWorkspaceBase, @@ -9056,7 +9958,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) metadata: existingExecutionWorkspace.metadata as Record | null, config: { provisionCommand: - existingExecutionWorkspace.config?.provisionCommand + configSnapshot?.provisionCommand + ?? existingExecutionWorkspace.config?.provisionCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand ?? null, }, @@ -9068,31 +9971,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, }, recorder: workspaceOperationRecorder, - }) ?? buildRealizedExecutionWorkspaceFromPersisted({ - base: executionWorkspaceBase, - workspace: existingExecutionWorkspace, }) : null; const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ - base: executionWorkspaceBase, - config: hostExecutionWorkspaceConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); + base: executionWorkspaceBase, + config: hostExecutionWorkspaceConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }); const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; let persistedExecutionWorkspace = null; const nextExecutionWorkspaceMetadata = mergeExecutionWorkspaceMetadataForPersistence({ - existingMetadata: existingExecutionWorkspace?.metadata ?? null, + existingMetadata: shouldReuseExisting ? existingExecutionWorkspace?.metadata ?? null : null, source: executionWorkspace.source, createdByRuntime: executionWorkspace.created, configSnapshot, shouldReuseExisting, + shouldRefreshConfigSnapshot: shouldRefreshWorkspaceConfigSnapshot, + workspaceConfigMetadata: latestWorkspaceConfigMetadata, baseRef: executionWorkspace.repoRef, baseRefSha: executionWorkspace.baseRefSha ?? null, }); @@ -9180,6 +10082,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) throw error; } await workspaceOperationRecorder.attachExecutionWorkspaceId(persistedExecutionWorkspace?.id ?? null); + await recordWorkspaceConfigFreshnessOperation({ + recorder: workspaceOperationRecorder, + runId: run.id, + decision: workspaceConfigFreshness, + hasExistingWorkspace: Boolean(existingExecutionWorkspace), + reuseRequested: requestedShouldReuseExisting, + workspaceReused: Boolean(reusedExecutionWorkspace), + configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot, + previousWorkspaceId: existingExecutionWorkspace?.id ?? null, + activeWorkspaceId: persistedExecutionWorkspace?.id ?? null, + }); if ( existingExecutionWorkspace && persistedExecutionWorkspace && @@ -9328,6 +10241,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ...resolvedWorkspace.warnings, ...executionWorkspace.warnings, ...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []), + ...(requestedShouldReuseExisting && workspaceConfigFreshness.reasons.length > 0 + ? [ + `Execution workspace reuse freshness action "${workspaceConfigFreshness.action}" because ${workspaceConfigFreshness.reasons.join("; ")}.`, + ] + : []), ...(resetTaskSession && sessionResetReason ? [ taskKey @@ -9356,7 +10274,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; const runtimeServiceIntents = (() => { - const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime); + const runtimeConfig = parseObject(hostExecutionWorkspaceConfig.workspaceRuntime); return Array.isArray(runtimeConfig.services) ? runtimeConfig.services.filter( (value): value is Record => typeof value === "object" && value !== null, @@ -9397,7 +10315,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let runtimeSessionIdForAdapter = readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; let runtimeSessionParamsForAdapter = normalizeSessionParams( - stripConfiguredModelFromSessionParams(runtimeSessionParams), + stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams), ); const sessionCompaction = await evaluateSessionCompaction({ @@ -9430,6 +10348,36 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) sessionDisplayId: previousSessionDisplayId, taskKey, }; + const configFreshnessResultMetadata = { + version: sessionConfigMetadata.version, + session: { + fingerprintVersion: sessionConfigMetadata.version, + categories: sessionConfigMetadata.categories, + reset: resetTaskSession, + resetReasons: sessionConfigFreshness.reasons, + changedCategories: sessionConfigFreshness.changedCategories, + taskSessionAvailable: taskSession != null, + taskSessionReused: taskSessionForRun != null, + storedFingerprintPresent: Boolean(sessionConfigFreshness.storedFingerprint), + nextFingerprint: sessionConfigFreshness.nextFingerprint, + }, + workspace: { + fingerprintVersion: latestWorkspaceConfigMetadata.version, + categories: latestWorkspaceConfigMetadata.categories, + action: workspaceConfigFreshness.action, + changedCategories: workspaceConfigFreshness.changedCategories, + reasons: workspaceConfigFreshness.reasons, + reuseRequested: requestedShouldReuseExisting, + workspaceReused: Boolean(reusedExecutionWorkspace), + configSnapshotRefreshed: shouldRefreshWorkspaceConfigSnapshot, + storedFingerprintPresent: workspaceConfigFreshness.storedFingerprintPresent, + storedFingerprint: workspaceConfigFreshness.storedFingerprint, + inferredFingerprint: workspaceConfigFreshness.inferredFingerprint, + nextFingerprint: workspaceConfigFreshness.nextFingerprint, + previousWorkspaceId: existingExecutionWorkspace?.id ?? null, + activeWorkspaceId: persistedExecutionWorkspace?.id ?? null, + }, + }; let seq = 1; let handle: RunLogHandle | null = null; @@ -9439,10 +10387,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null; const outputProgressState: { pending: { - at: Date; - seq: number; - stream: "stdout" | "stderr"; - bytes: number; + at: Date; + seq: number; + stream: "stdout" | "stderr"; + bytes: number; } | null; } = { pending: null }; let persistedLogBytes = Number(run.logBytes ?? 0); @@ -9649,7 +10597,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issue: issueRef, workspace: executionWorkspace, executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, - config: effectiveResolvedConfig, + config: hostExecutionWorkspaceConfig, adapterEnv, onLog, }); @@ -9925,6 +10873,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null, sessionRotated: sessionCompaction.rotate, sessionRotationReason: sessionCompaction.reason, + configFreshness: configFreshnessResultMetadata, provider: readNonEmptyString(adapterResult.provider) ?? "unknown", biller: resolveLedgerBiller(adapterResult), model: readNonEmptyString(adapterResult.model) ?? "unknown", @@ -9937,7 +10886,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) mergeRunStopMetadataForAgent(agent, outcome, { resultJson: mergeModelProfileRunMetadata( mergeAdapterRecoveryMetadata({ - resultJson: adapterResult.resultJson ?? null, + resultJson: { + ...parseObject(adapterResult.resultJson), + configFreshness: configFreshnessResultMetadata, + }, errorFamily: adapterResult.errorFamily ?? null, retryNotBefore: adapterResult.retryNotBefore ?? null, }), @@ -10119,7 +11071,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: attachConfiguredModelToSessionParams(nextSessionState.params, configuredModel), + sessionParamsJson: attachPaperclipSessionMetadataToSessionParams( + nextSessionState.params, + configuredModel, + sessionConfigMetadata, + ), sessionDisplayId: nextSessionState.displayId, lastRunId: finalizedRun.id, lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), @@ -10221,7 +11177,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: attachConfiguredModelToSessionParams(previousSessionParams, configuredModel), + sessionParamsJson: attachPaperclipSessionMetadataToSessionParams( + previousSessionParams, + configuredModel, + sessionConfigMetadata, + ), sessionDisplayId: previousSessionDisplayId, lastRunId: failedRun.id, lastError: message, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 69fefbbe3d..860c5a7ff6 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -244,6 +244,7 @@ export type RuntimeSecretManifestEntry = { secretKey: string; version: number; provider: SecretProvider; + providerVersionRef?: string | null; outcome: "success" | "failure"; errorCode?: string | null; }; @@ -696,6 +697,7 @@ export function secretService(db: Db) { secretKey: secret.key, version: resolvedVersion, provider: providerId, + providerVersionRef: versionRow.providerVersionRef, outcome: "success", }, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 6d486a923b..5b29ffcfbb 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1471,6 +1471,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim(); if (strategy !== "git_worktree") { + if (!await directoryExists(cwd)) { + return null; + } return realized; } const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 8005509165..d591392f3e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -431,6 +431,8 @@ function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) { switch (phase) { case "worktree_prepare": return "Worktree setup"; + case "workspace_config_freshness": + return "Config freshness"; case "workspace_provision": return "Provision"; case "workspace_teardown": @@ -1671,6 +1673,9 @@ function ConfigurationTab({ hideInstructionsFile={hideInstructionsFile} sectionLayout="cards" /> +

+ Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session. +

)} +

+ Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session. +

diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index b405d0fc28..2f475fddb3 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1049,7 +1049,7 @@ export function CompanyEnvironments() { {editingEnvironmentId ? "Edit environment" : "Add environment"} - Configure a reusable execution target for your agents. + Configure a reusable execution target for your agents. Saved changes affect future runs; Paperclip may start fresh sessions or sandbox leases after environment config changes. diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 97e333b004..75141c3094 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -259,6 +259,25 @@ function Field({ ); } +function workspaceOperationPhaseLabel(phase: string) { + switch (phase) { + case "worktree_prepare": + return "Worktree setup"; + case "workspace_config_freshness": + return "Config freshness"; + case "workspace_provision": + return "Provision"; + case "workspace_teardown": + return "Teardown"; + case "worktree_cleanup": + return "Worktree cleanup"; + case "workspace_finalize": + return "Finalize"; + default: + return phase; + } +} + function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -845,7 +864,7 @@ export function ExecutionWorkspaceDetail() { Workspace settings - Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace. + Edit the concrete path, repo, branch, provisioning, teardown, and runtime overrides attached to this execution workspace. Saved changes affect future runs; Paperclip may refresh or replace a reused workspace when config changes.