fix(hermes): project native completion and tool activity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 01:57:28 -05:00
1 parent 300afb3fe3
commit f628d54bc8
12 files changed
+225 -12

No files matched your search

+35 -2
View File
@@ -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!({
+11 -4
View File
@@ -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(
+1
View File
@@ -3,4 +3,5 @@ export {
probeAcpxClaudeInstallation,
probeAcpxGrokInstallation,
probeAcpxCursorInstallation,
probeAcpxHermesInstallation,
} from "@paperclipai/paperclip-runner/live";