Preserve remote process identity in legacy adapter dispatch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-10 00:05:51 -05:00
parent 311f71954e
commit bad9d3f9d2
2 changed files with 46 additions and 3 deletions

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
@ -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<void>((resolve) => { spawned = resolve; });
const finish = new Promise<void>((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<typeof adapters.getServerAdapter>);
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");

View File

@ -21703,7 +21703,7 @@ export function heartbeatService(
? meta.processGroupId
: null,
startedAt: meta.startedAt,
})));
}, executionTarget?.kind === "remote" ? "remote" : "local")));
},
authToken: authToken ?? undefined,
}),