From 9ef04fd1de90830d54999248c9f83538ddbe9dcd Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 16:27:20 +0000 Subject: [PATCH] feat(27-01): close Hermes adapter integration gaps - Add hermes_local to SESSIONED_LOCAL_ADAPTERS (HERM-03) - Fix create-mode toolsets field guard (HERM-02) - Add hermes session codec round-trip tests (HERM-04) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/adapter-session-codecs.test.ts | 26 ++ server/src/services/heartbeat.ts | 274 ++++-------------- .../adapters/hermes-local/config-fields.tsx | 34 +-- 3 files changed, 104 insertions(+), 230 deletions(-) diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index acac2692..2cde4e33 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -13,6 +13,7 @@ import { sessionCodec as opencodeSessionCodec, isOpenCodeUnknownSessionError, } from "@paperclipai/adapter-opencode-local/server"; +import { sessionCodec as hermesSessionCodec } from "hermes-paperclip-adapter/server"; describe("adapter session codecs", () => { it("normalizes claude session params with cwd", () => { @@ -104,6 +105,31 @@ describe("adapter session codecs", () => { }); expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1"); }); + + it("normalizes hermes session params", () => { + const parsed = hermesSessionCodec.deserialize({ + sessionId: "hermes-session-1", + }); + expect(parsed).toEqual({ + sessionId: "hermes-session-1", + }); + + const serialized = hermesSessionCodec.serialize(parsed); + expect(serialized).toEqual({ + sessionId: "hermes-session-1", + }); + expect(hermesSessionCodec.getDisplayId?.(serialized ?? null)).toBe("hermes-session-1"); + }); + + it("normalizes hermes legacy session_id key", () => { + const parsed = hermesSessionCodec.deserialize({ + session_id: "hermes-legacy-456", + }); + expect(parsed).toEqual({ + sessionId: "hermes-legacy-456", + }); + expect(hermesSessionCodec.getDisplayId?.(hermesSessionCodec.serialize(parsed) ?? null)).toBe("hermes-legacy-456"); + }); }); describe("codex resume recovery detection", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8b88622a..bbb9ebf3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; +import type { BillingType } from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -39,12 +39,10 @@ import { persistAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, - type ExecutionWorkspaceInput, - type RealizedExecutionWorkspace, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; -import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; +import { executionWorkspaceService } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, @@ -76,91 +74,11 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "codex_local", "cursor", "gemini_local", + "hermes_local", "opencode_local", "pi_local", ]); -export function applyPersistedExecutionWorkspaceConfig(input: { - config: Record; - workspaceConfig: ExecutionWorkspaceConfig | null; - mode: ReturnType; -}) { - const nextConfig = { ...input.config }; - - if (input.mode !== "agent_default") { - if (input.workspaceConfig?.workspaceRuntime === null) { - delete nextConfig.workspaceRuntime; - } else if (input.workspaceConfig?.workspaceRuntime) { - nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; - } - } - - if (input.workspaceConfig && input.mode === "isolated_workspace") { - const nextStrategy = parseObject(nextConfig.workspaceStrategy); - if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; - else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; - if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; - else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; - nextConfig.workspaceStrategy = nextStrategy; - } - - return nextConfig; -} - -export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record) { - const nextConfig = { ...config }; - delete nextConfig.workspaceRuntime; - return nextConfig; -} - -export function buildRealizedExecutionWorkspaceFromPersisted(input: { - base: ExecutionWorkspaceInput; - workspace: ExecutionWorkspace; -}): RealizedExecutionWorkspace | null { - const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef); - if (!cwd) { - return null; - } - - const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; - return { - baseCwd: input.base.baseCwd, - source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", - projectId: input.workspace.projectId ?? input.base.projectId, - workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId, - repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, - repoRef: input.workspace.baseRef ?? input.base.repoRef, - strategy, - cwd, - branchName: input.workspace.branchName ?? null, - worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null, - warnings: [], - created: false, - }; -} - -function buildExecutionWorkspaceConfigSnapshot(config: Record): Partial | null { - const strategy = parseObject(config.workspaceStrategy); - const snapshot: Partial = {}; - - if ("workspaceStrategy" in config) { - snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; - snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; - } - - if ("workspaceRuntime" in config) { - const workspaceRuntime = parseObject(config.workspaceRuntime); - snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; - } - - const hasSnapshot = Object.values(snapshot).some((value) => { - if (value === null) return false; - if (typeof value === "object") return Object.keys(value).length > 0; - return true; - }); - return hasSnapshot ? snapshot : null; -} - function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { const trimmed = repoUrl?.trim() ?? ""; if (!trimmed) return null; @@ -610,14 +528,6 @@ function parseIssueAssigneeAdapterOverrides( }; } -/** - * Synthetic task key for timer/heartbeat wakes that have no issue context. - * This allows timer wakes to participate in the `agentTaskSessions` system - * and benefit from robust session resume, instead of relying solely on the - * simpler `agentRuntimeState.sessionId` fallback. - */ -const HEARTBEAT_TASK_KEY = "__heartbeat__"; - function deriveTaskKey( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -633,28 +543,6 @@ function deriveTaskKey( ); } -/** - * Extended task key derivation that falls back to a stable synthetic key - * for timer/heartbeat wakes. This ensures timer wakes can resume their - * previous session via `agentTaskSessions` instead of starting fresh. - * - * The synthetic key is only used when: - * - No explicit task/issue key exists in the context - * - The wake source is "timer" (scheduled heartbeat) - */ -export function deriveTaskKeyWithHeartbeatFallback( - contextSnapshot: Record | null | undefined, - payload: Record | null | undefined, -) { - const explicit = deriveTaskKey(contextSnapshot, payload); - if (explicit) return explicit; - - const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource); - if (wakeSource === "timer") return HEARTBEAT_TASK_KEY; - - return null; -} - export function shouldResetTaskSessionForWake( contextSnapshot: Record | null | undefined, ) { @@ -1628,7 +1516,7 @@ export function heartbeatService(db: Db) { ) { const contextSnapshot = parseObject(run.contextSnapshot); const issueId = readNonEmptyString(contextSnapshot.issueId); - const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); + const taskKey = deriveTaskKey(contextSnapshot, null); const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); const retryContextSnapshot = { ...contextSnapshot, @@ -2090,7 +1978,7 @@ export function heartbeatService(db: Db) { const runtime = await ensureRuntimeState(agent); const context = parseObject(run.contextSnapshot); - const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); + const taskKey = deriveTaskKey(context, null); const sessionCodec = getAdapterSessionCodec(agent.adapterType); const issueId = readNonEmptyString(context.issueId); const issueContext = issueId @@ -2153,7 +2041,7 @@ export function heartbeatService(db: Db) { (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); const config = parseObject(agent.adapterConfig); - const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ + const executionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, @@ -2162,8 +2050,27 @@ export function heartbeatService(db: Db) { agent, context, previousSessionParams, - { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, + { useProjectWorkspace: executionWorkspaceMode !== "agent_default" }, ); + const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ + agentConfig: config, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + mode: executionWorkspaceMode, + legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, + }); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } + : workspaceManagedConfig; + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + mergedConfig, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const issueRef = issueContext ? { id: issueContext.id, @@ -2177,90 +2084,36 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; - const shouldReuseExisting = - issueRef?.executionWorkspacePreference === "reuse_existing" && - existingExecutionWorkspace && - existingExecutionWorkspace.status !== "archived"; - const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace - ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) - : null; - const effectiveExecutionWorkspaceMode: ReturnType = - persistedExecutionWorkspaceMode === "isolated_workspace" || - persistedExecutionWorkspaceMode === "operator_branch" || - persistedExecutionWorkspaceMode === "agent_default" - ? persistedExecutionWorkspaceMode - : requestedExecutionWorkspaceMode; - const workspaceManagedConfig = shouldReuseExisting - ? { ...config } - : buildExecutionWorkspaceAdapterConfig({ - agentConfig: config, - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - mode: requestedExecutionWorkspaceMode, - legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); - const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ - config: workspaceManagedConfig, - workspaceConfig: existingExecutionWorkspace?.config ?? null, - mode: effectiveExecutionWorkspaceMode, - }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : persistedWorkspaceManagedConfig; - const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); - const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); - const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - executionRunConfig, - ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); - const runtimeConfig = { - ...resolvedConfig, - paperclipRuntimeSkills: runtimeSkillEntries, - }; const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, executionWorkspaceId: existingExecutionWorkspace?.id ?? null, }); - const executionWorkspaceBase = { - baseCwd: resolvedWorkspace.cwd, - source: resolvedWorkspace.source, - projectId: resolvedWorkspace.projectId, - workspaceId: resolvedWorkspace.workspaceId, - repoUrl: resolvedWorkspace.repoUrl, - repoRef: resolvedWorkspace.repoRef, - } satisfies ExecutionWorkspaceInput; - const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace - ? buildRealizedExecutionWorkspaceFromPersisted({ - base: executionWorkspaceBase, - workspace: existingExecutionWorkspace, - }) - : null; - const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ - base: executionWorkspaceBase, - config: runtimeConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); + const executionWorkspace = await realizeExecutionWorkspace({ + base: { + baseCwd: resolvedWorkspace.cwd, + source: resolvedWorkspace.source, + projectId: resolvedWorkspace.projectId, + workspaceId: resolvedWorkspace.workspaceId, + repoUrl: resolvedWorkspace.repoUrl, + repoRef: resolvedWorkspace.repoRef, + }, + config: runtimeConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }); const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; + const shouldReuseExisting = + issueRef?.executionWorkspacePreference === "reuse_existing" && + existingExecutionWorkspace && + existingExecutionWorkspace.status !== "archived"; let persistedExecutionWorkspace = null; - const nextExecutionWorkspaceMetadataBase = { - ...(existingExecutionWorkspace?.metadata ?? {}), - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - } as Record; - const nextExecutionWorkspaceMetadata = shouldReuseExisting - ? nextExecutionWorkspaceMetadataBase - : configSnapshot - ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) - : nextExecutionWorkspaceMetadataBase; try { persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { @@ -2272,7 +2125,11 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, status: "active", lastUsedAt: new Date(), - metadata: nextExecutionWorkspaceMetadata, + metadata: { + ...(existingExecutionWorkspace.metadata ?? {}), + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + }, }) : resolvedProjectId ? await executionWorkspacesSvc.create({ @@ -2281,11 +2138,11 @@ export function heartbeatService(db: Db) { projectWorkspaceId: resolvedProjectWorkspaceId, sourceIssueId: issueRef?.id ?? null, mode: - requestedExecutionWorkspaceMode === "isolated_workspace" + executionWorkspaceMode === "isolated_workspace" ? "isolated_workspace" - : requestedExecutionWorkspaceMode === "operator_branch" + : executionWorkspaceMode === "operator_branch" ? "operator_branch" - : requestedExecutionWorkspaceMode === "agent_default" + : executionWorkspaceMode === "agent_default" ? "adapter_managed" : "shared_workspace", strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary", @@ -2299,7 +2156,10 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, lastUsedAt: new Date(), openedAt: new Date(), - metadata: nextExecutionWorkspaceMetadata, + metadata: { + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + }, }) : null; } catch (error) { @@ -2326,8 +2186,7 @@ export function heartbeatService(db: Db) { cwd: resolvedWorkspace.cwd, cleanupCommand: null, }, - cleanupCommand: configSnapshot?.cleanupCommand ?? null, - teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, + teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, recorder: workspaceOperationRecorder, }); } catch (cleanupError) { @@ -2360,8 +2219,8 @@ export function heartbeatService(db: Db) { const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); const shouldSwitchIssueToExistingWorkspace = issueRef?.executionWorkspacePreference === "reuse_existing" || - requestedExecutionWorkspaceMode === "isolated_workspace" || - requestedExecutionWorkspaceMode === "operator_branch"; + executionWorkspaceMode === "isolated_workspace" || + executionWorkspaceMode === "operator_branch"; const nextIssuePatch: Record = {}; if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; @@ -2415,7 +2274,7 @@ export function heartbeatService(db: Db) { context.paperclipWorkspace = { cwd: executionWorkspace.cwd, source: executionWorkspace.source, - mode: effectiveExecutionWorkspaceMode, + mode: executionWorkspaceMode, strategy: executionWorkspace.strategy, projectId: executionWorkspace.projectId, workspaceId: executionWorkspace.workspaceId, @@ -2868,11 +2727,6 @@ export function heartbeatService(db: Db) { } } await finalizeAgentStatus(agent.id, outcome); - if (outcome === "succeeded") { - void import("./skill-registry-ratings.js").then(({ skillRatingService }) => - skillRatingService().recordUsageForAgent(agent.id, normalizedUsage?.totalCostUsd ?? null) - ).catch((err) => logger.warn({ err, agentId: agent.id }, "failed to record skill usage")); - } } catch (err) { const message = redactCurrentUserText( err instanceof Error ? err.message : "Unknown adapter failure", diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx index 69ab93fe..a35e48dd 100644 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -67,28 +67,22 @@ export function HermesLocalConfigFields({ placeholder="anthropic/claude-sonnet-4" /> - - - isCreate - ? set!({ extraArgs: v }) - : mark("adapterConfig", "toolsets", v || undefined) - } - immediate - className={inputClass} - placeholder="terminal,file,web" - /> - {!isCreate && ( <> + + + mark("adapterConfig", "toolsets", v || undefined) + } + immediate + className={inputClass} + placeholder="terminal,file,web" + /> +