From db827c2af294e77e14aa50525b2098dc59b2f990 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 20 Sep 2026 06:17:02 -0500 Subject: [PATCH] fix(runner): preserve anonymous warm sessions and skip terminal assignment wakes --- ...issue-update-comment-wakeup-routes.test.ts | 39 +++++++++ server/src/routes/issues.ts | 3 + .../heartbeat-github-launchers.test.ts | 79 +++++++++++++++++++ .../services/heartbeat-github-launchers.ts | 34 ++++++++ server/src/services/heartbeat.ts | 37 ++++----- 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 server/src/services/heartbeat-github-launchers.test.ts create mode 100644 server/src/services/heartbeat-github-launchers.ts diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 4ec0189d607..d14cc4a878f 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -285,6 +285,45 @@ describe("issue update comment wakeups", () => { expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + it.each(["done", "cancelled"])( + "keeps %s assignment-only updates from waking completed work", + async (status) => { + // A parent may restore the assignee after releasing a completed child. + // That is recordkeeping, not a request to execute the child again. + const existing = makeIssue({ status, assigneeAgentId: null, assigneeUserId: null }); + const updated = makeIssue({ status, assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + + const res = await request(await createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ assigneeAgentId: ASSIGNEE_AGENT_ID }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status, assigneeAgentId: ASSIGNEE_AGENT_ID }); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])("still wakes explicitly reopened work (reassigned: %s)", async (reassigned) => { + const existing = makeIssue({ status: "done", assigneeAgentId: PREVIOUS_AGENT_ID, assigneeUserId: null }); + const agentId = reassigned ? ASSIGNEE_AGENT_ID : PREVIOUS_AGENT_ID; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: agentId, assigneeUserId: null })); + + const res = await request(await createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ status: "todo", ...(reassigned ? { assigneeAgentId: agentId } : {}) }); + + expect(res.status).toBe(200); + await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1)); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({ + reason: reassigned ? "issue_assigned" : "issue_status_changed", + payload: expect.objectContaining({ issueId: existing.id }), + })); + }); + it("includes the new comment in assignment wakes from issue updates", async () => { const existing = makeIssue(); const updated = makeIssue({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e3a2cd38dbd..228fc155471 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -14551,6 +14551,9 @@ export function issueRoutes( assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog" && + // Restoring an assignee on completed work is not a reopen request. + // Explicit reopen/resume transitions are already reflected in issue.status. + !isClosedIssueStatus(issue.status) && deferWakeForGoal !== true ) { addWakeup(issue.assigneeAgentId, { diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts new file mode 100644 index 00000000000..012ab7d771a --- /dev/null +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -0,0 +1,79 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { cleanupGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; + +const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" }; + +describe("heartbeat GitHub launcher lifetime", () => { + it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => { + const createBrokerToken = vi.fn(() => "run-secret"); + const prepareLaunchers = vi.fn(async (input) => input.env); + const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target, + cwd: "/workspace", env: { GH_TOKEN: "ambient-secret" }, brokerUrl: "https://paperclip.test", createBrokerToken }; + const first = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-one" }, prepareLaunchers); + const second = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-two" }, prepareLaunchers); + expect(createBrokerToken).not.toHaveBeenCalled(); + expect(prepareLaunchers.mock.calls.map(([input]) => input.runId)).toEqual([expect.stringMatching(/^anonymous-agent-a-/), prepareLaunchers.mock.calls[0]?.[0].runId]); + expect(first.cleanupLocation).toBeNull(); + expect(second.cleanupLocation).toBeNull(); + expect(first.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: "" }); + }); + it.each([ + { native: true, githubConfigured: true, target }, + { native: false, githubConfigured: false, target }, + { native: true, githubConfigured: false, target: null }, + ])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => { + const createBrokerToken = vi.fn(() => "current-run-secret"); + const prepareLaunchers = vi.fn(async (input) => input.env); + const result = await prepareHeartbeatGitHubLaunchers({ ...mode, agentId: "agent-a", runId: "run-one", + cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", createBrokerToken }, prepareLaunchers); + expect(createBrokerToken).toHaveBeenCalledOnce(); + expect(prepareLaunchers.mock.calls[0]?.[0].runId).toBe("run-one"); + expect(result.cleanupLocation).toEqual({ runId: "run-one", target: mode.target }); + expect(result.env.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("current-run-secret"); + }); +}); + + +it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image credentials", async () => { + const root = await mkdtemp(path.join(tmpdir(), "native-anonymous-github-")); + try { + const bin = path.join(root, "bin"); + await mkdir(bin); + await mkdir(path.join(root, ".config", "gh"), { recursive: true }); + await writeFile(path.join(root, ".config", "gh", "hosts.yml"), "ambient-image-secret"); + await writeFile(path.join(bin, "gh"), `#!${process.execPath} +const fs = require('node:fs'); +process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 }); + const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" }; + const execute: CommandManagedRuntimeRunner["execute"] = async (input) => { + const startedAt = new Date().toISOString(); + const child = promisify(execFile)(input.command, input.args ?? [], { cwd: input.cwd ?? root, env: { ...imageEnv, ...input.env }, timeout: 15_000 }); + child.child.stdin?.on("error", () => undefined); + child.child.stdin?.end(input.stdin ?? ""); + const result = await child; + return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt }; + }; + const sandboxTarget = { ...target, remoteCwd: root, runner: { execute } }; + const base = { native: true, githubConfigured: false, agentId: "agent-a", target: sandboxTarget, + cwd: root, env: imageEnv, brokerUrl: "https://unused.invalid", createBrokerToken: () => { throw new Error("must not mint a capability"); } }; + const first = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-one" }); + await cleanupGitHubOperationLaunchers({ runId: "run-one", target: sandboxTarget }); + const second = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-two" }); + expect(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBe(second.env.PAPERCLIP_GITHUB_LAUNCHER_DIR); + expect(await readFile(path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain("ambient-token"); + // A still-live provider uses its first-turn environment, not the new one. + const command = await execute({ command: path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), env: first.env }); + expect(JSON.parse(command.stdout)).toEqual({ token: "", githubToken: "", ssh: "", global: "/dev/null", imageConfig: false }); + const changedPath = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-three", env: { ...imageEnv, PATH: `${imageEnv.PATH}:/extra` } }); + expect(changedPath.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).not.toBe(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 20_000); diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts new file mode 100644 index 00000000000..8978ef81916 --- /dev/null +++ b/server/src/services/heartbeat-github-launchers.ts @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; +import { prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; + +type LauncherInput = Parameters[0]; + +export async function prepareHeartbeatGitHubLaunchers( + input: LauncherInput & { + native: boolean; + githubConfigured: boolean; + agentId: string; + brokerUrl: string; + createBrokerToken: () => string; + }, + prepareLaunchers = prepareGitHubOperationLaunchers, +) { + // An unconfigured sandbox has no managed GitHub identity to broker. Its + // token-free wrappers still isolate image credentials, but may live as long + // as the workspace so a warm provider never inherits a deleted run path. + // Configured identities keep their run-scoped capability/retirement rules. + const anonymous = input.native && !input.githubConfigured && + input.target?.kind === "remote" && input.target.transport === "sandbox"; + const location = { + runId: anonymous + ? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}` + : input.runId, + target: input.target, + }; + const env = githubBrokerEnvironment(input.env, { + url: anonymous ? "" : input.brokerUrl, + token: anonymous ? "" : input.createBrokerToken(), + }); + return { env: await prepareLaunchers({ ...location, cwd: input.cwd, env }), cleanupLocation: anonymous ? null : location }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 50ad68d2669..2c3cb0110b0 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -44,10 +44,9 @@ import { unadmittedChatWakeupCondition, type DurableChatWakeupRequest, } from "./durable-chat-wakeup.js"; -import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; +import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; import { cleanupGitHubOperationLaunchers, - prepareGitHubOperationLaunchers, prepareGitHubExecutionEnvironment, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; @@ -22241,27 +22240,25 @@ export function heartbeatService( for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key); context.githubAuthenticationMode = useHostGitHub ? "host" : "managed"; if (!useHostGitHub) { - const githubBrokerToken = createRuntimeToolsToken({ + const githubLaunchers = await prepareHeartbeatGitHubLaunchers({ + native: agent.adapterType === "paperclip_runner", + githubConfigured: githubSelection.configured, agentId: agent.id, - companyId: agent.companyId, runId: run.id, - responsibleUserId: responsibleUserId ?? "", - scope: "github_credentials", - }); - const githubBrokerEnv = githubBrokerEnvironment(gitExecutionEnv, { - url: configuredPaperclipApiBaseUrl() ?? "", - token: githubBrokerToken?.token ?? "", - }); - githubLauncherLocation = { runId: run.id, target: executionTarget }; - runtimeConfig = { - ...runtimeConfig, - env: await prepareGitHubOperationLaunchers({ + target: executionTarget, + cwd: executionWorkspace.cwd, + env: gitExecutionEnv, + brokerUrl: configuredPaperclipApiBaseUrl() ?? "", + createBrokerToken: () => createRuntimeToolsToken({ + agentId: agent.id, + companyId: agent.companyId, runId: run.id, - target: executionTarget, - cwd: executionWorkspace.cwd, - env: githubBrokerEnv, - }), - }; + responsibleUserId: responsibleUserId ?? "", + scope: "github_credentials", + })?.token ?? "", + }); + githubLauncherLocation = githubLaunchers.cleanupLocation; + runtimeConfig = { ...runtimeConfig, env: githubLaunchers.env }; secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN"); } context.paperclipEnvironment = {