mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
fix: harden heartbeat and adapter runtime workflows
This commit is contained in:
parent
548721248e
commit
c566a9236c
48 changed files with 14922 additions and 600 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
|
|
@ -33,35 +32,10 @@ import {
|
|||
} from "./parse.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(
|
||||
resolveClaudeDesiredSkillNames(
|
||||
config,
|
||||
availableEntries,
|
||||
),
|
||||
);
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
await fs.symlink(
|
||||
entry.source,
|
||||
path.join(target, entry.runtimeName),
|
||||
);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
|
|
@ -361,30 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
),
|
||||
);
|
||||
const billingType = resolveClaudeBillingType(effectiveEnv);
|
||||
const skillsDir = await buildSkillsDir(config);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
let effectiveInstructionsFilePath: string | undefined;
|
||||
let preparedInstructionsFile = false;
|
||||
|
||||
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
|
||||
if (resumeSessionId || !instructionsFilePath) return undefined;
|
||||
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
|
||||
|
||||
preparedInstructionsFile = true;
|
||||
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
|
||||
// When instructionsFilePath is configured, build a stable content-addressed
|
||||
// file that includes both the file content and the path directive, so we only
|
||||
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
|
||||
let combinedInstructionsContents: string | null = null;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||
const pathDirective =
|
||||
|
|
@ -392,20 +349,50 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`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.`;
|
||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||
effectiveInstructionsFilePath = combinedPath;
|
||||
combinedInstructionsContents = instructionsContent + pathDirective;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
effectiveInstructionsFilePath = undefined;
|
||||
}
|
||||
}
|
||||
const promptBundle = await prepareClaudePromptBundle({
|
||||
companyId: agent.companyId,
|
||||
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
|
||||
instructionsContents: combinedInstructionsContents,
|
||||
onLog,
|
||||
});
|
||||
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
||||
|
||||
return effectiveInstructionsFilePath;
|
||||
};
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
||||
const hasMatchingPromptBundle =
|
||||
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
hasMatchingPromptBundle &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (
|
||||
runtimeSessionId &&
|
||||
runtimeSessionCwd.length > 0 &&
|
||||
path.resolve(runtimeSessionCwd) !== path.resolve(cwd)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
|
||||
);
|
||||
}
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
|
|
@ -460,7 +447,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
||||
}
|
||||
args.push("--add-dir", skillsDir);
|
||||
args.push("--add-dir", promptBundle.addDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
|
@ -482,14 +469,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
|
||||
const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath;
|
||||
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
|
||||
const commandNotes =
|
||||
attemptInstructionsFilePath && !resumeSessionId
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
const commandNotes: string[] = [];
|
||||
if (!resumeSessionId) {
|
||||
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
|
||||
}
|
||||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||
commandNotes.push(
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
);
|
||||
}
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
|
|
@ -586,6 +576,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
promptBundleKey: promptBundle.bundleKey,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
|
|
@ -618,25 +609,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
} finally {
|
||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue