mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(hermes): project native completion and tool activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
300afb3fe3
commit
f628d54bc8
12 files changed
+225
-12
No files matched your search
@@ -4,13 +4,15 @@ import { listServerAdapters, requireServerAdapter } from "./registry.js";
|
||||
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
|
||||
const { probeInstallation, probeGrokInstallation } = vi.hoisted(() => ({
|
||||
const { probeInstallation, probeGrokInstallation, probeHermesInstallation } = vi.hoisted(() => ({
|
||||
probeInstallation: vi.fn(),
|
||||
probeGrokInstallation: vi.fn(),
|
||||
probeHermesInstallation: vi.fn(),
|
||||
}));
|
||||
vi.mock("@paperclipai/paperclip-runner/live", () => ({
|
||||
probeAcpxClaudeInstallation: probeInstallation,
|
||||
probeAcpxGrokInstallation: probeGrokInstallation,
|
||||
probeAcpxHermesInstallation: probeHermesInstallation,
|
||||
probeAcpxCursorInstallation: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
@@ -96,8 +98,9 @@ describe("native ACPX environment checks", () => {
|
||||
beforeEach(() => {
|
||||
probeInstallation.mockReset().mockResolvedValue(undefined);
|
||||
probeGrokInstallation.mockReset().mockResolvedValue(undefined);
|
||||
probeHermesInstallation.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
||||
|
||||
const context = {
|
||||
companyId: "company-test",
|
||||
@@ -132,6 +135,36 @@ describe("native ACPX environment checks", () => {
|
||||
expect(probeInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])("checks the admitted Hermes runtime without claiming production qualification (%s)", async ready => {
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "hermes", model: "hermes-validation-fixture" }]));
|
||||
if (!ready) probeHermesInstallation.mockRejectedValueOnce(new Error("Hermes runtime is unavailable; run paperclipai runtime setup hermes"));
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({
|
||||
...context, config: { provider: "acpx", acpxAgent: "hermes", model: "hermes-validation-fixture" },
|
||||
});
|
||||
expect(result.status).toBe(ready ? "warn" : "fail");
|
||||
expect(result.checks[0]).toMatchObject({ code: ready ? "acpx_candidate_qualification_only" : "acpx_runtime_unavailable" });
|
||||
expect(probeHermesInstallation).toHaveBeenCalledWith("hermes-validation-fixture");
|
||||
expect(probeInstallation).not.toHaveBeenCalled();
|
||||
expect(probeGrokInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Linux\nx86_64\n", "warn"], ["Darwin\narm64\n", "warn"],
|
||||
["Darwin\nx86_64\n", "fail"], ["Linux\naarch64\n", "fail"],
|
||||
])("admits only supported Hermes remote platforms %j", async (stdout, status) => {
|
||||
vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "hermes", model: "hermes-validation-fixture" }]));
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({
|
||||
...context, config: { provider: "acpx", acpxAgent: "hermes", model: "hermes-validation-fixture" },
|
||||
executionTarget: {
|
||||
kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "test",
|
||||
runner: { execute: vi.fn().mockResolvedValue({ exitCode: 0, timedOut: false, stdout }) },
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(status);
|
||||
expect(result.checks[0].code).toBe(status === "warn" ? "acpx_remote_runtime_unverified" : "acpx_runtime_unavailable");
|
||||
expect(probeHermesInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use the host platform to reject a remote environment", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({
|
||||
|
||||
@@ -420,7 +420,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner.");
|
||||
if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor" && profile.acpxAgent !== "hermes") throw new Error("Select Codex to use the native Codex runner.");
|
||||
const target = context.executionTarget;
|
||||
if (target?.kind === "remote") {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
@@ -429,7 +429,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
);
|
||||
if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform.");
|
||||
const [os, arch] = probe.stdout.trim().split(/\s+/);
|
||||
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
|
||||
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (!["grok", "hermes"].includes(profile.acpxAgent) && arch === "x86_64"))))) {
|
||||
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
|
||||
}
|
||||
return {
|
||||
@@ -438,8 +438,15 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }],
|
||||
};
|
||||
}
|
||||
const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation } = await import("../vendor/paperclip-runner/live/index.js");
|
||||
await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model);
|
||||
const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxHermesInstallation } = await import("../vendor/paperclip-runner/live/index.js");
|
||||
await (profile.acpxAgent === "hermes" ? probeAcpxHermesInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model);
|
||||
if (profile.acpxAgent === "hermes") {
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const,
|
||||
message: "The pinned Hermes runtime and host sandbox are verified. This exact model is admitted for operator-controlled qualification only; model access and the selected connection are checked before execution. Production qualification remains pending." }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }],
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
routines,
|
||||
routineRuns,
|
||||
statusDecisionEffects,
|
||||
statusDecisions,
|
||||
workAssessments,
|
||||
@@ -290,6 +292,8 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(completionContracts);
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routines);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
@@ -974,7 +978,18 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
expect(response.decision.chosenSource).toBe("final_agent_message");
|
||||
expect(stored.findIndex((row) => row.eventType === "run.result.accepted"))
|
||||
.toBeGreaterThan(stored.findIndex((row) => row.eventType === "item.completed"));
|
||||
const routineId = randomUUID();
|
||||
const firingId = randomUUID();
|
||||
await db.insert(routines).values({ id: routineId, companyId: identity.companyId,
|
||||
title: "Native completion routine", assigneeAgentId: identity.agentId });
|
||||
await db.insert(routineRuns).values({ id: firingId, companyId: identity.companyId,
|
||||
routineId, source: "schedule", status: "issue_created", linkedIssueId: taskIssueId });
|
||||
await db.update(issues).set({ originKind: "routine_execution", originId: routineId,
|
||||
originRunId: firingId }).where(eq(issues.id, taskIssueId));
|
||||
await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded" });
|
||||
const [completedFiring] = await db.select().from(routineRuns).where(eq(routineRuns.id, firingId));
|
||||
expect(completedFiring).toMatchObject({ status: "completed", failureReason: null });
|
||||
expect(completedFiring.completedAt).not.toBeNull();
|
||||
await expect(port.completeRun({
|
||||
result: taskResult,
|
||||
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
|
||||
@@ -996,6 +1011,9 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
await expect(db.select().from(issues).where(eq(issues.id, taskIssueId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "done", statusVersion: 1 }),
|
||||
]);
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, firingId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "completed", completedAt: completedFiring.completedAt }),
|
||||
]);
|
||||
await expect(db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, taskRunId))).resolves.toHaveLength(1);
|
||||
await expect(db.select().from(workAssessments).where(eq(workAssessments.runId, taskRunId))).resolves.toHaveLength(1);
|
||||
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, taskIssueId))).resolves.toHaveLength(1);
|
||||
@@ -2161,6 +2179,14 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
backendKind: "mock",
|
||||
sourceInstanceId: runnerInstanceId,
|
||||
});
|
||||
const routineId = randomUUID();
|
||||
const firingId = randomUUID();
|
||||
await db.insert(routines).values({ id: routineId, companyId: identity.companyId,
|
||||
title: `Atomic routine ${entry.failpoint}`, assigneeAgentId: identity.agentId });
|
||||
await db.insert(routineRuns).values({ id: firingId, companyId: identity.companyId,
|
||||
routineId, source: "schedule", status: "issue_created", linkedIssueId: issueId });
|
||||
await db.update(issues).set({ originKind: "routine_execution", originId: routineId,
|
||||
originRunId: firingId }).where(eq(issues.id, issueId));
|
||||
await port.completeRun({ result: entry.result, terminal, callerResultId: `atomic-result-${entry.suffix}` });
|
||||
await expect(finalizeNativeRun({
|
||||
db,
|
||||
@@ -2171,6 +2197,9 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
await expect(db.select().from(issues).where(eq(issues.id, issueId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "in_progress", statusVersion: 0, lastStatusDecisionId: null }),
|
||||
]);
|
||||
await expect(db.select().from(routineRuns).where(eq(routineRuns.id, firingId))).resolves.toEqual([
|
||||
expect.objectContaining({ status: "issue_created", completedAt: null, failureReason: null }),
|
||||
]);
|
||||
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, issueId))).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).resolves.toHaveLength(0);
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
resolveExternalChatResponseWaitAuthorizationInTransaction,
|
||||
} from "./chat-attachment-reuse.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { routineService } from "../routines.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { buildIssueBlockersResolvedWakeIdempotencyKey } from "../issue-dependency-wakeups.js";
|
||||
@@ -2037,6 +2038,12 @@ export async function commitNativeStatusDecision(input: {
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the firing projection atomic with the authoritative task status.
|
||||
// Recovery and normal completion use the same status-decision transaction.
|
||||
if (updated.originKind === "routine_execution") {
|
||||
await routineService(tx as unknown as Db).syncRunStatusForIssue(input.issueId);
|
||||
}
|
||||
|
||||
if (input.decision.statusAction === "done" && issue.status !== "done") {
|
||||
const issueSvc = issueService(tx as unknown as Db);
|
||||
const dependents = await issueSvc.listWakeableBlockedDependents(
|
||||
|
||||
@@ -3,4 +3,5 @@ export {
|
||||
probeAcpxClaudeInstallation,
|
||||
probeAcpxGrokInstallation,
|
||||
probeAcpxCursorInstallation,
|
||||
probeAcpxHermesInstallation,
|
||||
} from "@paperclipai/paperclip-runner/live";
|
||||
Reference in new issue
Block a user