From 77ed2004f8326a2971460cf89dcf04585ea7d00d Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Thu, 23 Apr 2026 19:34:35 +0000 Subject: [PATCH] fix: port prepareClaudePromptBundle flow to claude_k8s adapter (FAR-11) K8s Job pods were starting without the Paperclip skill loaded, so agents could not find their heartbeat procedure and reported "no issue content in my workspace" on every wake. Root cause: claude_local materialises skills into a PVC-backed prompt-bundle directory and passes --add-dir to Claude, but claude_k8s did neither. Changes: - Add src/server/prompt-cache.ts with prepareClaudePromptBundle (ported from adapter-claude-local). Writes skill symlinks and the agent's instructions file into a content-addressed bundle directory under the shared PVC (/paperclip/instances/.../claude-prompt-cache//). - execute.ts: read desired skills and instructions file before building the Job manifest, then call prepareClaudePromptBundle and pass the resulting bundle to buildJobManifest. - job-manifest.ts: accept optional promptBundle in JobBuildInput; when present, pass --add-dir and use bundle.instructionsFilePath for --append-system-prompt-file. Also fix: skip --append-system-prompt-file on session resumes to avoid wasting tokens on re-injection. - skills.ts: correct the detail string to reflect actual materialisation. - job-manifest.test.ts: add 5 new tests covering --add-dir injection, bundle path preference, session-resume skipping, and fallback behaviour. Co-Authored-By: Paperclip --- src/server/execute.ts | 46 +++++++++- src/server/job-manifest.test.ts | 55 +++++++++++- src/server/job-manifest.ts | 17 +++- src/server/prompt-cache.ts | 150 ++++++++++++++++++++++++++++++++ src/server/skills.ts | 2 +- 5 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 src/server/prompt-cache.ts diff --git a/src/server/execute.ts b/src/server/execute.ts index 87916c4..8de2a35 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -1,5 +1,15 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { + asString, + asNumber, + asBoolean, + parseObject, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { prepareClaudePromptBundle } from "./prompt-cache.js"; import { parseClaudeStreamJson, describeClaudeFailure, @@ -619,6 +629,38 @@ export async function execute(ctx: AdapterExecutionContext): Promise } | null = null; + // Prepare the prompt bundle (skills + instructions) on the server filesystem. + // The K8s Job pod mounts the same PVC at /paperclip, so bundle paths written + // here are accessible inside the pod at the identical absolute path. + const skillEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname); + const desiredSkillNames = new Set(resolvePaperclipDesiredSkillNames(config, skillEntries)); + const desiredSkills = skillEntries.filter((e) => desiredSkillNames.has(e.key)); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsContents: string | null = null; + if (instructionsFilePath) { + try { + const raw = await fs.readFile(instructionsFilePath, "utf-8"); + const pathDirective = + `\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`; + instructionsContents = raw + pathDirective; + } catch (err) { + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + const promptBundle = await prepareClaudePromptBundle({ + companyId: ctx.agent.companyId, + skills: desiredSkills, + instructionsContents, + onLog, + }); + if (reattachTarget) { jobName = reattachTarget.jobName; namespace = reattachTarget.namespace; @@ -646,7 +688,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(claudeArgs).toContain("--dangerously-skip-permissions"); }); - it("adds --append-system-prompt-file when instructionsFilePath set", () => { + it("adds --append-system-prompt-file (config fallback) when instructionsFilePath set and no session", () => { ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); expect(claudeArgs).toContain("--append-system-prompt-file"); expect(claudeArgs).toContain("/paperclip/instructions.md"); }); + it("omits --append-system-prompt-file on session resume (avoids token waste)", () => { + ctx.config = { instructionsFilePath: "/paperclip/instructions.md" }; + ctx.runtime.sessionId = "sess_existing"; + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).not.toContain("--append-system-prompt-file"); + }); + + it("adds --add-dir when promptBundle is provided", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: null, + }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).toContain("--add-dir"); + expect(claudeArgs).toContain(promptBundle.addDir); + }); + + it("uses bundle instructionsFilePath for --append-system-prompt-file when promptBundle provided", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", + }; + ctx.config = { instructionsFilePath: "/raw/path/AGENTS.md" }; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).toContain("--append-system-prompt-file"); + const idx = claudeArgs.indexOf("--append-system-prompt-file"); + expect(claudeArgs[idx + 1]).toBe(promptBundle.instructionsFilePath); + expect(claudeArgs).not.toContain("/raw/path/AGENTS.md"); + }); + + it("omits --append-system-prompt-file from bundle on session resume", () => { + const promptBundle = { + bundleKey: "abc123", + rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123", + instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md", + }; + ctx.runtime.sessionId = "sess_existing"; + const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle }); + expect(claudeArgs).not.toContain("--append-system-prompt-file"); + // --add-dir must still be present even on resume + expect(claudeArgs).toContain("--add-dir"); + }); + + it("omits --add-dir when no promptBundle", () => { + const { claudeArgs } = buildJobManifest({ ctx, selfPod }); + expect(claudeArgs).not.toContain("--add-dir"); + }); + it("appends extraArgs when configured", () => { ctx.config = { extraArgs: ["--no-input", "--verbose"] }; const { claudeArgs } = buildJobManifest({ ctx, selfPod }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index 56569c0..179d0ca 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -10,6 +10,7 @@ import { renderTemplate, } from "@paperclipai/adapter-utils/server-utils"; import { createHash } from "node:crypto"; +import type { ClaudePromptBundle } from "./prompt-cache.js"; /** * Build the shell command prefix that installs a native Node.js PostToolUse @@ -175,6 +176,8 @@ function parseKeyValueConfig(raw: unknown): Record { export interface JobBuildInput { ctx: AdapterExecutionContext; selfPod: SelfPodInfo; + /** Prepared prompt bundle (skills + instructions). When provided, --add-dir and --append-system-prompt-file use bundle paths. */ + promptBundle?: ClaudePromptBundle | null; } /** When the prompt exceeds the env-var size limit, the manifest uses a @@ -327,7 +330,7 @@ function buildEnvVars( } export function buildJobManifest(input: JobBuildInput): JobBuildResult { - const { ctx, selfPod } = input; + const { ctx, selfPod, promptBundle } = input; const { runId, agent, runtime, config: rawConfig, context } = ctx; const config = parseObject(rawConfig); @@ -403,14 +406,22 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { }; // Build Claude CLI args - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + // Prefer the bundle's materialized instructions file over the raw config path. + // Never inject --append-system-prompt-file on session resumes — the instructions + // are already in the session cache and re-injecting wastes tokens. + const rawInstructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const effectiveInstructionsFilePath = + promptBundle?.instructionsFilePath ?? (rawInstructionsFilePath || null); const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"]; if (runtimeSessionId) claudeArgs.push("--resume", runtimeSessionId); if (dangerouslySkipPermissions) claudeArgs.push("--dangerously-skip-permissions"); if (model) claudeArgs.push("--model", model); if (effort) claudeArgs.push("--effort", effort); if (maxTurns > 0) claudeArgs.push("--max-turns", String(maxTurns)); - if (instructionsFilePath) claudeArgs.push("--append-system-prompt-file", instructionsFilePath); + if (effectiveInstructionsFilePath && !runtimeSessionId) { + claudeArgs.push("--append-system-prompt-file", effectiveInstructionsFilePath); + } + if (promptBundle) claudeArgs.push("--add-dir", promptBundle.addDir); if (extraArgs.length > 0) claudeArgs.push(...extraArgs); // Build env vars diff --git a/src/server/prompt-cache.ts b/src/server/prompt-cache.ts new file mode 100644 index 0000000..6ab06fb --- /dev/null +++ b/src/server/prompt-cache.ts @@ -0,0 +1,150 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { + type PaperclipSkillEntry, + ensurePaperclipSkillSymlink, +} from "@paperclipai/adapter-utils/server-utils"; + +export interface ClaudePromptBundle { + bundleKey: string; + /** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */ + rootDir: string; + /** Value to pass as --add-dir to the Claude CLI. */ + addDir: string; + /** Path to the materialized instructions file, or null if no instructions were provided. */ + instructionsFilePath: string | null; +} + +const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; + +function resolveManagedClaudePromptCacheRoot(companyId: string): string { + const paperclipHome = + (typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0 + ? process.env.PAPERCLIP_HOME.trim() + : null) ?? + path.resolve(os.homedir(), ".paperclip"); + const instanceId = + (typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0 + ? process.env.PAPERCLIP_INSTANCE_ID.trim() + : null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache"); +} + +async function hashPathContents( + candidate: string, + hash: ReturnType, + relativePath: string, + seenDirectories: Set, +): Promise { + const stat = await fs.lstat(candidate); + if (stat.isSymbolicLink()) { + hash.update(`symlink:${relativePath}\n`); + const resolved = await fs.realpath(candidate).catch(() => null); + if (!resolved) { + hash.update("missing\n"); + return; + } + await hashPathContents(resolved, hash, relativePath, seenDirectories); + return; + } + if (stat.isDirectory()) { + const realDir = await fs.realpath(candidate).catch(() => candidate); + hash.update(`dir:${relativePath}\n`); + if (seenDirectories.has(realDir)) { + hash.update("loop\n"); + return; + } + seenDirectories.add(realDir); + const entries = await fs.readdir(candidate, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name; + await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories); + } + return; + } + if (stat.isFile()) { + hash.update(`file:${relativePath}\n`); + hash.update(await fs.readFile(candidate)); + hash.update("\n"); + return; + } + hash.update(`other:${relativePath}:${stat.mode}\n`); +} + +async function buildClaudePromptBundleKey(input: { + skills: PaperclipSkillEntry[]; + instructionsContents: string | null; +}): Promise { + const hash = createHash("sha256"); + hash.update("paperclip-claude-prompt-bundle:v1\n"); + if (input.instructionsContents) { + hash.update("instructions\n"); + hash.update(input.instructionsContents); + hash.update("\n"); + } else { + hash.update("instructions:none\n"); + } + const sortedSkills = [...input.skills].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName)); + for (const entry of sortedSkills) { + hash.update(`skill:${entry.key}:${entry.runtimeName}\n`); + await hashPathContents(entry.source, hash, entry.runtimeName, new Set()); + } + return hash.digest("hex"); +} + +async function ensureReadableFile(targetPath: string, contents: string): Promise { + try { + await fs.access(targetPath, fsConstants.R_OK); + return; + } catch { + // Fall through and materialize the file. + } + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; + try { + await fs.writeFile(tempPath, contents, "utf8"); + await fs.rename(tempPath, targetPath); + } catch (err) { + const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false); + if (!targetReadable) throw err; + } finally { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } +} + +export async function prepareClaudePromptBundle(input: { + companyId: string; + skills: PaperclipSkillEntry[]; + instructionsContents: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const { companyId, skills, instructionsContents, onLog } = input; + const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents }); + const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey); + const skillsHome = path.join(rootDir, ".claude", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + + for (const entry of skills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + await ensurePaperclipSkillSymlink(entry.source, target); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + const instructionsFilePath = instructionsContents ? path.join(rootDir, "agent-instructions.md") : null; + if (instructionsFilePath && instructionsContents) { + await ensureReadableFile(instructionsFilePath, instructionsContents); + } + + return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath }; +} diff --git a/src/server/skills.ts b/src/server/skills.ts index c76d8a5..8f3d4bb 100644 --- a/src/server/skills.ts +++ b/src/server/skills.ts @@ -33,7 +33,7 @@ async function buildK8sSkillSnapshot( sourcePath: entry.source, targetPath: null, detail: desiredSet.has(entry.key) - ? "Injected via prompt bundle into ephemeral K8s Job pods." + ? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run." : null, required: Boolean(entry.required), requiredReason: entry.requiredReason ?? null,