fix(runner): preserve anonymous warm sessions and skip terminal assignment wakes

This commit is contained in:
Dotta committed 2026-09-20 06:17:02 -05:00
1 parent cadc628d9f
commit db827c2af2
5 files changed
+172 -20

No files matched your search

@@ -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({
+3
View File
@@ -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, {
@@ -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);
@@ -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<typeof prepareGitHubOperationLaunchers>[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 };
}
+17 -20
View File
@@ -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 = {