diff --git a/server/src/__tests__/heartbeat-process-metadata.test.ts b/server/src/__tests__/heartbeat-process-metadata.test.ts index 141e74e4db..4565a91b60 100644 --- a/server/src/__tests__/heartbeat-process-metadata.test.ts +++ b/server/src/__tests__/heartbeat-process-metadata.test.ts @@ -3,7 +3,10 @@ import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, heartbeatRuns, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; import * as processes from "../services/hot-restart.js"; -import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat.js"; +import * as adapters from "../adapters/index.js"; +import * as orchestration from "../services/environment-run-orchestrator.js"; +import * as executionTargets from "@paperclipai/adapter-utils/execution-target"; +import { heartbeatService, persistHeartbeatRunProcessMetadata } from "../services/heartbeat.js"; describe("heartbeat process identity persistence", () => { let database: Awaited>; @@ -12,7 +15,7 @@ describe("heartbeat process identity persistence", () => { beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-process-metadata-"); db = createDb(database.connectionString); - await db.insert(companies).values({ id: companyId, name: "Process identity" }); + await db.insert(companies).values({ id: companyId, name: "Process identity", defaultResponsibleUserId: "responsible-user" }); await db.insert(agents).values({ id: agentId, companyId, name: "Runner", role: "engineer", status: "idle", adapterType: "codex_local" }); }, 60_000); afterEach(() => vi.restoreAllMocks()); @@ -23,6 +26,46 @@ describe("heartbeat process identity persistence", () => { return run!; } + it("passes remote process identity through the legacy adapter spawn callback", async () => { + const remoteStart = "2026-09-09T23:19:43.123Z"; + const originalOrchestrator = orchestration.environmentRunOrchestrator; + vi.spyOn(orchestration, "environmentRunOrchestrator").mockImplementation((...args) => { + const actual = originalOrchestrator(...args); + return { ...actual, realizeForRun: async (input) => ({ + ...await actual.realizeForRun(input), + executionTarget: { kind: "remote", transport: "ssh", remoteCwd: "/remote/task", shellCommand: "sh" } as never, + }) }; + }); + vi.spyOn(executionTargets, "prepareGitHubOperationLaunchers").mockImplementation(async (input) => input.env); + vi.spyOn(executionTargets, "cleanupGitHubOperationLaunchers").mockResolvedValue(undefined); + let spawned!: () => void, release!: () => void; + const observed = new Promise((resolve) => { spawned = resolve; }); + const finish = new Promise((resolve) => { release = resolve; }); + vi.spyOn(adapters, "getServerAdapter").mockReturnValue({ + supportsLocalAgentJwt: false, + execute: async (input) => { + expect(input.executionTarget?.kind).toBe("remote"); + await input.onSpawn?.({ pid: process.pid, processGroupId: null, startedAt: remoteStart }); + spawned(); + await finish; + return { exitCode: 0, signal: null, timedOut: false }; + }, + } as ReturnType); + const heartbeat = heartbeatService(db); + try { + const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); + expect(queued).not.toBeNull(); + await observed; + const actual = await heartbeat.getRun(queued!.id); + expect(actual?.runtimeMode).toBe("legacy"); + expect(actual?.processPid).toBe(process.pid); + expect(actual?.processStartedAt?.toISOString()).toBe(remoteStart); + } finally { + release(); + await heartbeat.drainActiveRunExecutions(); + } + }, 30_000); + it("uses the remote marker even when its PID exists on the host", async () => { const run = await running(); const host = vi.spyOn(processes, "readProcessStartedAt"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8bdcddc337..ddf0cc5f7f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -21703,7 +21703,7 @@ export function heartbeatService( ? meta.processGroupId : null, startedAt: meta.startedAt, - }))); + }, executionTarget?.kind === "remote" ? "remote" : "local"))); }, authToken: authToken ?? undefined, }),