fix: replace pid:-1 sentinel with process.pid to prevent false process_lost
The adapter was calling onSpawn({ pid: -1 }) as a sentinel value for
K8s Jobs (which run out-of-process), then the server's orphan reaper
was checking isProcessAlive(-1) which always returns false, causing
legitimate runs to be reaped as 'process_lost'.
Using process.pid (the Paperclip server's own PID) is always alive
while the adapter runs in-process, preventing false reaping.
Fixes FAR-116.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vendored
+134
-27
@@ -1,4 +1,8 @@
|
||||
import { asString, asNumber, asBoolean, asStringArray, parseObject, buildPaperclipEnv, renderTemplate, } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
/** Prompts above this size (bytes) are staged via a Secret instead of an
|
||||
* init container env var, protecting against the ~1 MiB PodSpec limit. */
|
||||
const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024;
|
||||
// Inline prompt assembly — these functions are not yet in the published adapter-utils
|
||||
function joinPromptSections(sections, separator = "\n\n") {
|
||||
return sections.filter((s) => s.trim().length > 0).join(separator);
|
||||
@@ -35,8 +39,64 @@ function renderPaperclipWakePrompt(wake, _opts) {
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
function sanitizeForK8sName(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
|
||||
/**
|
||||
* Parse a config value that may be either a JSON object or multiline
|
||||
* `key=value` text (one pair per line). This fixes the config-hint
|
||||
* parity issue where textarea hints promise `key=value` per line but
|
||||
* `parseObject` only handles JSON.
|
||||
*/
|
||||
function parseKeyValueConfig(raw) {
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
// Already an object (JSON was parsed upstream)
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof v === "string")
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (typeof raw !== "string" || !raw.trim())
|
||||
return {};
|
||||
// Try JSON parse first
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === "string")
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Not JSON — fall through to key=value parsing
|
||||
}
|
||||
// Parse key=value lines
|
||||
const result = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#"))
|
||||
continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx <= 0)
|
||||
continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (key)
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sanitizeForK8sName(value, maxLen = 16) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, maxLen);
|
||||
}
|
||||
/**
|
||||
* Build a short deterministic hash suffix from the raw inputs to avoid
|
||||
* collisions when sanitized slugs happen to be identical.
|
||||
*/
|
||||
function shortHash(input, len = 6) {
|
||||
return createHash("sha256").update(input).digest("hex").slice(0, len);
|
||||
}
|
||||
function buildEnvVars(ctx, selfPod, config) {
|
||||
const { runId, agent, context } = ctx;
|
||||
@@ -107,11 +167,20 @@ function buildEnvVars(ctx, selfPod, config) {
|
||||
}
|
||||
// HOME must be /paperclip to match PVC mount and enable session resume
|
||||
merged.HOME = "/paperclip";
|
||||
// Convert to V1EnvVar array
|
||||
// Convert literal env to V1EnvVar array
|
||||
const envVars = Object.entries(merged).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
// Append valueFrom entries from the Deployment container (secretKeyRef,
|
||||
// configMapKeyRef, fieldRef, etc.). Skip any whose name was already set
|
||||
// by a literal value — the literal value wins (same precedence as above).
|
||||
const literalNames = new Set(Object.keys(merged));
|
||||
for (const entry of selfPod.inheritedEnvValueFrom) {
|
||||
if (!literalNames.has(entry.name)) {
|
||||
envVars.push(entry);
|
||||
}
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
export function buildJobManifest(input) {
|
||||
@@ -130,17 +199,21 @@ export function buildJobManifest(input) {
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
|
||||
const resources = parseObject(config.resources);
|
||||
const nodeSelector = parseObject(config.nodeSelector);
|
||||
const nodeSelector = parseKeyValueConfig(config.nodeSelector);
|
||||
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
|
||||
const extraLabels = parseObject(config.labels);
|
||||
const extraLabels = parseKeyValueConfig(config.labels);
|
||||
// Resolve working directory — use workspace cwd, fall back to /paperclip
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
|
||||
const agentSlug = sanitizeForK8sName(agent.id);
|
||||
const runSlug = sanitizeForK8sName(runId);
|
||||
const jobName = `agent-claude-${agentSlug}-${runSlug}`;
|
||||
// Build a deterministic, collision-resistant job name within the 63-char
|
||||
// DNS label limit. Layout: "ac-{agentSlug}-{runSlug}-{hash}" where the
|
||||
// hash is derived from the raw (un-truncated) agent+run IDs.
|
||||
const agentSlug = sanitizeForK8sName(agent.id, 16);
|
||||
const runSlug = sanitizeForK8sName(runId, 16);
|
||||
const hash = shortHash(`${agent.id}:${runId}`);
|
||||
const jobName = `ac-${agentSlug}-${runSlug}-${hash}`;
|
||||
// Build prompt (same logic as claude_local)
|
||||
const promptTemplate = asString(config.promptTemplate, "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.");
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
@@ -217,8 +290,7 @@ export function buildJobManifest(input) {
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
};
|
||||
for (const [key, value] of Object.entries(extraLabels)) {
|
||||
if (typeof value === "string")
|
||||
labels[key] = value;
|
||||
labels[key] = value;
|
||||
}
|
||||
// Volumes
|
||||
const volumes = [
|
||||
@@ -274,6 +346,54 @@ export function buildJobManifest(input) {
|
||||
// Build the claude command string for the main container
|
||||
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
||||
// Decide prompt delivery strategy: env var (small) or Secret volume (large).
|
||||
const promptBytes = Buffer.byteLength(prompt, "utf-8");
|
||||
const useLargePromptPath = promptBytes > LARGE_PROMPT_THRESHOLD_BYTES;
|
||||
let promptSecret = null;
|
||||
const promptSecretName = `${jobName}-prompt`;
|
||||
if (useLargePromptPath) {
|
||||
// Stage prompt as a Secret; the init container copies from the mounted
|
||||
// secret volume to the emptyDir so the main container reads it the
|
||||
// same way regardless of prompt size.
|
||||
promptSecret = {
|
||||
name: promptSecretName,
|
||||
namespace,
|
||||
data: { "prompt.txt": prompt },
|
||||
};
|
||||
volumes.push({
|
||||
name: "prompt-secret",
|
||||
secret: { secretName: promptSecretName, optional: false },
|
||||
});
|
||||
}
|
||||
const initContainer = useLargePromptPath
|
||||
? {
|
||||
name: "write-prompt",
|
||||
image: "busybox:1.36",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt.txt /tmp/prompt/prompt.txt"],
|
||||
volumeMounts: [
|
||||
{ name: "prompt", mountPath: "/tmp/prompt" },
|
||||
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
|
||||
],
|
||||
securityContext,
|
||||
resources: {
|
||||
requests: { cpu: "10m", memory: "16Mi" },
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: "write-prompt",
|
||||
image: "busybox:1.36",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
|
||||
env: [{ name: "PROMPT_CONTENT", value: prompt }],
|
||||
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
|
||||
securityContext,
|
||||
resources: {
|
||||
requests: { cpu: "10m", memory: "16Mi" },
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
};
|
||||
const job = {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
@@ -298,23 +418,9 @@ export function buildJobManifest(input) {
|
||||
securityContext: podSecurityContext,
|
||||
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
|
||||
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
|
||||
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector } : {}),
|
||||
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector } : {}),
|
||||
...(tolerations.length > 0 ? { tolerations: tolerations } : {}),
|
||||
initContainers: [
|
||||
{
|
||||
name: "write-prompt",
|
||||
image: "busybox:1.36",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
|
||||
env: [{ name: "PROMPT_CONTENT", value: prompt }],
|
||||
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
|
||||
securityContext,
|
||||
resources: {
|
||||
requests: { cpu: "10m", memory: "16Mi" },
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
},
|
||||
],
|
||||
initContainers: [initContainer],
|
||||
containers: [
|
||||
{
|
||||
name: "claude",
|
||||
@@ -323,6 +429,7 @@ export function buildJobManifest(input) {
|
||||
workingDir,
|
||||
command: ["sh", "-c", mainCommand],
|
||||
env: envVars,
|
||||
...(selfPod.inheritedEnvFrom.length > 0 ? { envFrom: selfPod.inheritedEnvFrom } : {}),
|
||||
volumeMounts,
|
||||
securityContext,
|
||||
resources: containerResources,
|
||||
@@ -333,6 +440,6 @@ export function buildJobManifest(input) {
|
||||
},
|
||||
},
|
||||
};
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret };
|
||||
}
|
||||
//# sourceMappingURL=job-manifest.js.map
|
||||
Reference in New Issue
Block a user