mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Merge origin/master for agent chat hardening validation
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(runner): preserve task replies and warm process continuity (#13738)
This commit is contained in:
commit
360eb734af
23 files changed
+502
-42
No files matched your search
@@ -902,6 +902,35 @@ describe("projectHistoricalHeartbeatRunComment", () => {
|
||||
});
|
||||
|
||||
describe("findHeartbeatRunCompletionComment", () => {
|
||||
it.each(["applied", "duplicate"])("does not let a %s file-preparation receipt hide the final reply", (disposition) => {
|
||||
const prepared = { id: "prepared-comment", body: "Prepared Continuity file for this response." };
|
||||
const explicit = { id: "explicit-comment", body: "An intentional agent reply." };
|
||||
const resultJson = {
|
||||
semanticToolReceipts: {
|
||||
file: {
|
||||
operationId: "register_deliverable",
|
||||
result: {
|
||||
commandId: "deliverable-prepared:attachment-1",
|
||||
disposition,
|
||||
attachmentId: "attachment-1",
|
||||
entityRefs: ["attachment-1", "work-product-1", prepared.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const existingComment = findHeartbeatRunCompletionComment([prepared], resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "PAPERCLIP_E2E_WARM_T3", sourceEventId: "final-event", channel: "final" },
|
||||
})).toMatchObject({ text: "PAPERCLIP_E2E_WARM_T3", decision: { commentAction: "create" } });
|
||||
// A final reply already materialized on a retry keeps precedence.
|
||||
expect(findHeartbeatRunCompletionComment([prepared, explicit], resultJson)).toEqual(explicit);
|
||||
// The body alone does not mark an ordinary agent comment as generated.
|
||||
expect(findHeartbeatRunCompletionComment([prepared], {})).toEqual(prepared);
|
||||
});
|
||||
|
||||
it("does not let semantic progress satisfy the final comment", () => {
|
||||
const progress = { id: "progress-comment" };
|
||||
const final = { id: "final-comment" };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,104 @@
|
||||
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([false, true])("cleans partial run-scoped staging and preserves its error (cleanup fails: %s)", async (cleanupFails) => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const prepareLaunchers = vi.fn(async () => { throw stagingError; });
|
||||
const cleanupLaunchers = vi.fn(async () => {
|
||||
if (cleanupFails) throw new Error("cleanup unavailable");
|
||||
});
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: true, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => "current-run-secret",
|
||||
}, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target });
|
||||
});
|
||||
|
||||
it("does not remove shared anonymous wrappers when staging a later run fails", async () => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const cleanupLaunchers = vi.fn(async () => undefined);
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => { throw new Error("must not mint a capability"); },
|
||||
}, async () => { throw stagingError; }, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,46 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
|
||||
import { cleanupGitHubOperationLaunchers, 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,
|
||||
cleanupLaunchers = cleanupGitHubOperationLaunchers,
|
||||
) {
|
||||
// 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(),
|
||||
});
|
||||
try {
|
||||
return {
|
||||
env: await prepareLaunchers({ ...location, cwd: input.cwd, env }),
|
||||
cleanupLocation: anonymous ? null : location,
|
||||
};
|
||||
} catch (error) {
|
||||
// The caller cannot retain a cleanup location until staging returns. Clean
|
||||
// partial run-specific files here, preserving the original staging failure.
|
||||
// Anonymous files may still belong to a live warm provider; never delete them.
|
||||
if (!anonymous) await cleanupLaunchers(location).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -565,22 +565,38 @@ export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
return comments[0] ?? null;
|
||||
}
|
||||
|
||||
const progressCommentIds = new Set<string>();
|
||||
const generatedCommentIds = new Set<string>();
|
||||
for (const receipt of Object.values(receipts)) {
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
||||
continue;
|
||||
const receiptRecord = receipt as Record<string, unknown>;
|
||||
if (receiptRecord.operationId !== "report_progress") continue;
|
||||
const result = receiptRecord.result;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result))
|
||||
continue;
|
||||
const commentId = (result as Record<string, unknown>).commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
progressCommentIds.add(commentId);
|
||||
const receiptResult = result as Record<string, unknown>;
|
||||
if (receiptRecord.operationId === "report_progress") {
|
||||
const commentId = receiptResult.commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
generatedCommentIds.add(commentId);
|
||||
}
|
||||
} else if (
|
||||
receiptRecord.operationId === "register_deliverable" &&
|
||||
typeof receiptResult.attachmentId === "string" &&
|
||||
receiptResult.commandId === `deliverable-prepared:${receiptResult.attachmentId}` &&
|
||||
["applied", "duplicate"].includes(String(receiptResult.disposition)) &&
|
||||
Array.isArray(receiptResult.entityRefs)
|
||||
) {
|
||||
// File preparation binds an attachment to a generated comment. That
|
||||
// receipt is not the agent's final answer. Use the server-issued entity
|
||||
// references, never the comment's wording, to exclude its comment from
|
||||
// final-response deduplication. The attachment and binding stay intact.
|
||||
for (const ref of receiptResult.entityRefs) {
|
||||
if (typeof ref === "string") generatedCommentIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
comments.find((comment) => !progressCommentIds.has(comment.id)) ?? null
|
||||
comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||||
);
|
||||
}
|
||||
@@ -45,10 +45,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";
|
||||
@@ -22270,27 +22269,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 = {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createLocalDiskStorageProvider } from "../../storage/local-disk-provide
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js";
|
||||
import {
|
||||
renderNativeRunnerStagedAttachmentPrompt,
|
||||
stageNativeRunnerWakeAttachments,
|
||||
@@ -298,6 +299,20 @@ describe("native runner file handoff", () => {
|
||||
downloadPath: first.downloadPath,
|
||||
});
|
||||
|
||||
// Exercise real persisted comments with the actual applied/duplicate receipts.
|
||||
// Preparing the attachment must not consume the agent's final reply slot.
|
||||
const preparedComments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
for (const receipt of [first, replayWithNewKey]) {
|
||||
const resultJson = { semanticToolReceipts: { file: { operationId: "register_deliverable", result: receipt } } };
|
||||
const existingComment = findHeartbeatRunCompletionComment(preparedComments, resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "Here is the requested answer.", channel: "final", sourceEventId: "final-1" },
|
||||
})).toMatchObject({ text: "Here is the requested answer.", decision: { commentAction: "create" } });
|
||||
}
|
||||
|
||||
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
const otherIssueId = "00000000-0000-4000-8000-000000009111";
|
||||
|
||||
Reference in new issue
Block a user