Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4631ac756 | |||
| 1fc6a9c626 | |||
| d71ff15443 | |||
| 5e01ae99b3 | |||
| 8c8c2f2ec0 | |||
| b9def0964e | |||
| 20e7ec43ce | |||
| 3e67b34baa | |||
| 2a31fe1f9b | |||
| 99c97c1fb2 | |||
| 5926b302e5 | |||
| 31328dd85b | |||
| 0660749c1f | |||
| b45cc29787 | |||
| 1e517bb9bb | |||
| d74b6d34b3 | |||
| c35253ddd4 | |||
| 5f358b2a26 | |||
| 5c28e6c191 |
Vendored
+1
-4
@@ -1,9 +1,6 @@
|
||||
export declare const type = "claude_k8s";
|
||||
export declare const label = "Claude (Kubernetes)";
|
||||
export declare const models: {
|
||||
id: string;
|
||||
label: string;
|
||||
}[];
|
||||
export declare const models: undefined;
|
||||
export declare const agentConfigurationDoc = "# claude_k8s agent configuration\n\nAdapter: claude_k8s\n\nRuns Claude Code inside an isolated Kubernetes Job pod instead of the main\nPaperclip process. The Job inherits the container image, imagePullSecrets,\nDNS config, and PVC from the running Paperclip Deployment automatically.\n\nCore fields:\n- model (string, optional): Claude model id\n- effort (string, optional): reasoning effort passed via --effort (low|medium|high)\n- maxTurnsPerRun (number, optional): max turns for one run\n- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude\n- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file\n- extraArgs (string[], optional): additional CLI args appended to the claude command\n- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment\n\nKubernetes fields:\n- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace\n- image (string, optional): override container image; defaults to the running Deployment image\n- imagePullPolicy (string, optional): image pull policy; default \"IfNotPresent\"\n- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth\n- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }\n- nodeSelector (object, optional): node selector for Job pods\n- tolerations (array, optional): tolerations for Job pods\n- labels (object, optional): extra labels added to Job metadata\n- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300\n- retainJobs (boolean, optional): skip cleanup on completion for debugging\n\nOperational fields:\n- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout\n- graceSec (number, optional): additional grace before adapter gives up after Job deadline\n\nInherited from Deployment (no config needed):\n- ANTHROPIC_API_KEY, OPENAI_API_KEY, and other provider API keys\n- PAPERCLIP_API_URL\n- Container image, imagePullSecrets, DNS config, PVC mount, security context\n\nNotes:\n- Session resume works via the shared /paperclip PVC (HOME=/paperclip)\n- Skills are bundled in the container image\n- Prompts are delivered via a busybox init container writing to an emptyDir volume\n";
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
export { printClaudeStreamEvent } from "./cli/index.js";
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,eAAe,CAAC;AACjC,eAAO,MAAM,KAAK,wBAAwB,CAAC;AAE3C,eAAO,MAAM,MAAM;;;GAMlB,CAAC;AAEF,eAAO,MAAM,qBAAqB,k1EA0CjC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,eAAe,CAAC;AACjC,eAAO,MAAM,KAAK,wBAAwB,CAAC;AAE3C,eAAO,MAAM,MAAM,EAAE,SAAqB,CAAC;AAE3C,eAAO,MAAM,qBAAqB,k1EA0CjC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
Vendored
+1
-7
@@ -1,12 +1,6 @@
|
||||
export const type = "claude_k8s";
|
||||
export const label = "Claude (Kubernetes)";
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
export const models = undefined;
|
||||
export const agentConfigurationDoc = `# claude_k8s agent configuration
|
||||
|
||||
Adapter: claude_k8s
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AACjC,MAAM,CAAC,MAAM,KAAK,GAAG,qBAAqB,CAAC;AAE3C,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,EAAE,EAAE,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,mBAAmB,EAAE;IACvD,EAAE,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;IACrD,EAAE,EAAE,EAAE,4BAA4B,EAAE,KAAK,EAAE,mBAAmB,EAAE;IAChE,EAAE,EAAE,EAAE,2BAA2B,EAAE,KAAK,EAAE,kBAAkB,EAAE;CAC/D,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CpC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AACjC,MAAM,CAAC,MAAM,KAAK,GAAG,qBAAqB,CAAC;AAE3C,MAAM,CAAC,MAAM,MAAM,GAAc,SAAS,CAAC;AAE3C,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CpC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AA2SlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAgS3F"}
|
||||
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAiUlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAoc3F"}
|
||||
Vendored
+212
-35
@@ -6,6 +6,7 @@ import { Writable } from "node:stream";
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
|
||||
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
|
||||
/**
|
||||
* Wait for the Job's pod to reach a terminal or running state.
|
||||
* Returns the pod name once logs can be streamed, or throws on failure.
|
||||
@@ -131,24 +132,44 @@ async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinc
|
||||
* stream until the stop signal fires (job completed) or the container
|
||||
* exits normally. This handles silent K8s API connection drops that
|
||||
* would otherwise cause the UI to stop receiving real output.
|
||||
*
|
||||
* Capped at MAX_LOG_RECONNECT_ATTEMPTS to prevent infinite reconnect
|
||||
* loops during sustained API partitions.
|
||||
*/
|
||||
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSignal) {
|
||||
const allChunks = [];
|
||||
let attempt = 0;
|
||||
const streamStartedAt = Math.floor(Date.now() / 1000);
|
||||
// Track the timestamp of the last successfully received log line so
|
||||
// reconnects use a tight window instead of an ever-growing one anchored
|
||||
// at stream start. This is the primary fix for FAR-105 duplicative logs.
|
||||
let lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
while (!stopSignal?.stopped) {
|
||||
// On reconnect, ask for logs since the stream originally started to
|
||||
// avoid missing output during the reconnect gap. Duplicates are
|
||||
// tolerable — the UI deduplicates log chunks.
|
||||
if (attempt >= MAX_LOG_RECONNECT_ATTEMPTS) {
|
||||
await onLog("stderr", `[paperclip] Log stream: max reconnect attempts (${MAX_LOG_RECONNECT_ATTEMPTS}) reached — giving up.\n`);
|
||||
break;
|
||||
}
|
||||
// On reconnect, ask for logs since the last received line (+5s buffer)
|
||||
// instead of since stream start. This keeps the window tight and
|
||||
// avoids ever-growing duplicate output.
|
||||
const sinceSeconds = attempt > 0
|
||||
? Math.max(1, Math.floor(Date.now() / 1000) - streamStartedAt + 5)
|
||||
? Math.max(1, Math.floor(Date.now() / 1000) - lastLogReceivedAt + 5)
|
||||
: undefined;
|
||||
if (attempt > 0) {
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt})...\n`);
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt}/${MAX_LOG_RECONNECT_ATTEMPTS})...\n`);
|
||||
}
|
||||
const preStreamTs = Math.floor(Date.now() / 1000);
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds);
|
||||
if (result)
|
||||
if (result) {
|
||||
allChunks.push(result);
|
||||
// Update last-received timestamp to now (the stream just ended,
|
||||
// so any log lines in `result` were received up to this moment).
|
||||
lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
else if (attempt === 0) {
|
||||
// First attempt returned nothing — update timestamp so reconnect
|
||||
// window stays reasonable.
|
||||
lastLogReceivedAt = preStreamTs;
|
||||
}
|
||||
attempt++;
|
||||
// If the job is done or the container exited, no need to reconnect.
|
||||
if (stopSignal?.stopped)
|
||||
@@ -237,7 +258,10 @@ export async function execute(ctx) {
|
||||
const graceSec = asNumber(config.graceSec, 60);
|
||||
const retainJobs = asBoolean(config.retainJobs, false);
|
||||
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
|
||||
// Guard: claude_k8s must not run concurrently for the same agent (shared PVC/session)
|
||||
// Guard: claude_k8s must not run concurrently for the same agent (shared PVC/session).
|
||||
// After a server restart, orphaned K8s Jobs from previous (now-failed) runs may
|
||||
// still be running. We detect those by comparing the Job's run-id label against
|
||||
// the current runId and clean them up so this execution can proceed.
|
||||
const agentId = ctx.agent.id;
|
||||
const selfPod = await getSelfPodInfo(kubeconfigPath);
|
||||
const guardNamespace = asString(config.namespace, "") || selfPod.namespace;
|
||||
@@ -249,22 +273,52 @@ export async function execute(ctx) {
|
||||
});
|
||||
const running = existing.items.filter((j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"));
|
||||
if (running.length > 0) {
|
||||
const names = running.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this agent: ${names}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Concurrent run blocked: Job ${names} is still running for this agent`,
|
||||
errorCode: "k8s_concurrent_run_blocked",
|
||||
};
|
||||
// Separate orphaned jobs (from a previous server-side run) from truly
|
||||
// concurrent jobs (same runId — shouldn't happen but guard defensively).
|
||||
const orphaned = running.filter((j) => (j.metadata?.labels?.["paperclip.io/run-id"] ?? "") !== runId);
|
||||
const samRun = running.filter((j) => (j.metadata?.labels?.["paperclip.io/run-id"] ?? "") === runId);
|
||||
if (orphaned.length > 0) {
|
||||
const orphanNames = orphaned.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stdout", `[paperclip] Cleaning up ${orphaned.length} orphaned K8s Job(s) from previous run(s): ${orphanNames}\n`);
|
||||
for (const j of orphaned) {
|
||||
const name = j.metadata?.name;
|
||||
if (name) {
|
||||
await cleanupJob(guardNamespace, name, onLog, kubeconfigPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there are still running Jobs that belong to THIS run (shouldn't happen
|
||||
// since we haven't created the Job yet), block execution.
|
||||
if (samRun.length > 0) {
|
||||
const names = samRun.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this run: ${names}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Concurrent run blocked: Job ${names} is still running for this agent`,
|
||||
errorCode: "k8s_concurrent_run_blocked",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If we can't check, proceed — the heartbeat service enforces concurrency too
|
||||
catch (err) {
|
||||
// If we can't list jobs, fail closed — the K8s concurrency guard is the
|
||||
// only thing preventing zombie Jobs on a shared PVC from corrupting
|
||||
// sessions. 404 (namespace not found) is treated as a hard failure;
|
||||
// other errors (5xx, network) are also surfaced.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Concurrency guard failed: unable to list jobs: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Concurrency guard unreachable: ${msg}`,
|
||||
errorCode: "k8s_concurrency_guard_unreachable",
|
||||
};
|
||||
}
|
||||
// Build Job manifest
|
||||
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics } = buildJobManifest({
|
||||
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret } = buildJobManifest({
|
||||
ctx,
|
||||
selfPod,
|
||||
});
|
||||
@@ -285,6 +339,42 @@ export async function execute(ctx) {
|
||||
context: ctx.context,
|
||||
});
|
||||
}
|
||||
// If the prompt is large, create a Secret to hold it (avoids the ~1 MiB
|
||||
// PodSpec limit). The Secret is cleaned up in the finally block.
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: promptSecret.namespace,
|
||||
body: {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: promptSecret.name,
|
||||
namespace: promptSecret.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
"paperclip.io/run-id": runId,
|
||||
},
|
||||
},
|
||||
stringData: promptSecret.data,
|
||||
},
|
||||
});
|
||||
await onLog("stdout", `[paperclip] Created prompt Secret: ${promptSecret.name} (${Math.round(Buffer.byteLength(prompt, "utf-8") / 1024)} KiB)\n`);
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Failed to create prompt Secret: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Failed to create prompt Secret: ${msg}`,
|
||||
errorCode: "k8s_prompt_secret_create_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
// Create the Job
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
try {
|
||||
@@ -306,6 +396,9 @@ export async function execute(ctx) {
|
||||
let exitCode = null;
|
||||
let jobTimedOut = false;
|
||||
let keepaliveTimer = null;
|
||||
// Set when we return a mismatch error so the finally block knows not to
|
||||
// delete a job that is still alive and the UI is waiting on.
|
||||
let skipCleanup = false;
|
||||
try {
|
||||
// Wait for pod to be ready for log streaming
|
||||
const scheduleTimeoutMs = 120_000; // 2 minutes for scheduling
|
||||
@@ -313,6 +406,16 @@ export async function execute(ctx) {
|
||||
try {
|
||||
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
|
||||
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
|
||||
// Notify the server that execution has started. This sets
|
||||
// processStartedAt and refreshes updatedAt in the DB, which the
|
||||
// stale-run reaper (reapOrphanedRuns) uses to decide liveness.
|
||||
if (ctx.onSpawn) {
|
||||
await ctx.onSpawn({
|
||||
pid: process.pid, // Paperclip server PID — always alive while adapter runs in-process
|
||||
processGroupId: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -333,10 +436,69 @@ export async function execute(ctx) {
|
||||
// Keepalive: periodically send a status line via onLog so the
|
||||
// Paperclip server knows the adapter is still alive even when the
|
||||
// pod produces no output (e.g. Claude is in a long thinking phase).
|
||||
//
|
||||
// IMPORTANT: onLog alone does NOT update the run's updatedAt in the
|
||||
// DB — it only appends to the log store and publishes SSE events.
|
||||
// The stale-run reaper checks updatedAt, so we must also call
|
||||
// onSpawn periodically to refresh it. Without this, multi-instance
|
||||
// deployments can reap a live run from another server instance
|
||||
// after the 5-minute staleness window.
|
||||
//
|
||||
// BUT: the keepalive must NEVER refresh updatedAt if the underlying
|
||||
// K8s Job is already terminal. Otherwise, if execute() stalls after
|
||||
// the pod finishes (e.g. a slow K8s API call, a hung log stream
|
||||
// drain, or a Job whose Complete condition lags pod termination),
|
||||
// we would keep the run marked "alive" indefinitely while the pod
|
||||
// is actually gone — the exact "UI thinks jobs are running when
|
||||
// they are not" bug. We verify Job liveness every tick and stop
|
||||
// refreshing as soon as the Job reaches a terminal state; if
|
||||
// execute() is truly stuck, the reaper will then catch it within
|
||||
// the normal 5-minute staleness window.
|
||||
let lastLogAt = Date.now();
|
||||
let keepaliveTick = 0;
|
||||
let keepaliveJobTerminal = false;
|
||||
keepaliveTimer = setInterval(() => {
|
||||
const silenceSec = Math.round((Date.now() - lastLogAt) / 1000);
|
||||
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`);
|
||||
// Fire-and-forget the async work; setInterval callbacks must be
|
||||
// synchronous or the timer will drift.
|
||||
void (async () => {
|
||||
if (keepaliveJobTerminal)
|
||||
return;
|
||||
// Verify the Job is still alive before announcing or refreshing.
|
||||
try {
|
||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
const terminal = job.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True");
|
||||
if (terminal) {
|
||||
keepaliveJobTerminal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
// Only treat 404 (Job deleted) as terminal. Transient 5xx or
|
||||
// connection resets should NOT permanently disable the keepalive —
|
||||
// the next tick will re-check and the reaper uses the staleness
|
||||
// window as a safety net.
|
||||
const statusCode = err?.response?.statusCode
|
||||
?? err?.statusCode;
|
||||
if (statusCode === 404) {
|
||||
keepaliveJobTerminal = true;
|
||||
return;
|
||||
}
|
||||
// Log transient errors but leave keepaliveJobTerminal false so
|
||||
// the next tick retries.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
void onLog("stderr", `[paperclip] keepalive: transient error checking job status: ${msg}\n`).catch(() => { });
|
||||
return;
|
||||
}
|
||||
const silenceSec = Math.round((Date.now() - lastLogAt) / 1000);
|
||||
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`).catch(() => { });
|
||||
// Refresh updatedAt every ~3 minutes (12 ticks × 15s = 180s) to
|
||||
// stay well within the 5-minute reaper staleness window. Also
|
||||
// fire on tick 1 for an early safety margin after job start.
|
||||
keepaliveTick++;
|
||||
if (ctx.onSpawn && (keepaliveTick === 1 || keepaliveTick % 12 === 0)) {
|
||||
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => { });
|
||||
}
|
||||
})();
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
const wrappedOnLog = async (stream, chunk) => {
|
||||
lastLogAt = Date.now();
|
||||
@@ -352,6 +514,14 @@ export async function execute(ctx) {
|
||||
return r;
|
||||
}),
|
||||
]);
|
||||
// Stop the keepalive immediately once the job has reached a terminal
|
||||
// state — do not wait for the finally block. Any K8s API call or
|
||||
// cleanup that happens after this point should not keep the run
|
||||
// marked "alive" in the DB via onSpawn refreshes.
|
||||
if (keepaliveTimer) {
|
||||
clearInterval(keepaliveTimer);
|
||||
keepaliveTimer = null;
|
||||
}
|
||||
if (logResult.status === "fulfilled") {
|
||||
stdout = logResult.value;
|
||||
}
|
||||
@@ -364,26 +534,20 @@ export async function execute(ctx) {
|
||||
await onLog("stdout", stdout);
|
||||
}
|
||||
}
|
||||
// If the follow stream missed output (container exited quickly), do a
|
||||
// one-shot log read as fallback before the pod is cleaned up.
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (stdout.trim()) {
|
||||
await onLog("stdout", stdout);
|
||||
}
|
||||
}
|
||||
if (completionResult.status === "fulfilled") {
|
||||
jobTimedOut = completionResult.value.timedOut;
|
||||
}
|
||||
else {
|
||||
// waitForJobCompletion threw — re-check job state to avoid returning
|
||||
// while the job is still running (which would cause UI staleness and
|
||||
// concurrency errors on retry).
|
||||
// concurrency errors on retry). Use a bounded timeout (60s) so we
|
||||
// don't hang the heartbeat indefinitely if the K8s API is degraded.
|
||||
jobTimedOut = false;
|
||||
const actualState = await waitForJobCompletion(namespace, jobName, 0, kubeconfigPath);
|
||||
const RECHECK_TIMEOUT_MS = 60_000;
|
||||
const actualState = await waitForJobCompletion(namespace, jobName, RECHECK_TIMEOUT_MS, kubeconfigPath);
|
||||
if (actualState.timedOut) {
|
||||
// Truly a timeout after re-check — treat as timed out.
|
||||
// Re-check itself timed out — the job may still be running.
|
||||
// Return an error so the UI knows the run is not done.
|
||||
jobTimedOut = true;
|
||||
}
|
||||
else if (!actualState.succeeded) {
|
||||
@@ -391,6 +555,7 @@ export async function execute(ctx) {
|
||||
// Return an error so the UI knows the run is not done, rather than
|
||||
// returning with parsed (potentially incomplete) stdout.
|
||||
await onLog("stderr", `[paperclip] Job ${jobName} still not terminal after log/completion mismatch — returning error to keep UI in sync.\n`);
|
||||
skipCleanup = true;
|
||||
return {
|
||||
exitCode,
|
||||
signal: null,
|
||||
@@ -405,12 +570,24 @@ export async function execute(ctx) {
|
||||
finally {
|
||||
if (keepaliveTimer)
|
||||
clearInterval(keepaliveTimer);
|
||||
if (!retainJobs) {
|
||||
if (skipCleanup) {
|
||||
await onLog("stdout", `[paperclip] Retaining job ${jobName} (state mismatch — UI is waiting on it)\n`);
|
||||
}
|
||||
else if (!retainJobs) {
|
||||
await cleanupJob(namespace, jobName, onLog, kubeconfigPath);
|
||||
}
|
||||
else {
|
||||
await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`);
|
||||
}
|
||||
// Clean up prompt Secret if one was created
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.deleteNamespacedSecret({ name: promptSecret.name, namespace: promptSecret.namespace });
|
||||
}
|
||||
catch {
|
||||
// Best-effort cleanup — TTL or manual deletion will catch stragglers
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse Claude output (reuse claude_local parsing)
|
||||
if (jobTimedOut) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+11
@@ -5,6 +5,14 @@ export interface JobBuildInput {
|
||||
ctx: AdapterExecutionContext;
|
||||
selfPod: SelfPodInfo;
|
||||
}
|
||||
/** When the prompt exceeds the env-var size limit, the manifest uses a
|
||||
* Secret-backed volume instead of the init container's PROMPT_CONTENT env.
|
||||
* The caller must create this Secret before the Job and clean it up after. */
|
||||
export interface PromptSecret {
|
||||
name: string;
|
||||
namespace: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
export interface JobBuildResult {
|
||||
job: k8s.V1Job;
|
||||
jobName: string;
|
||||
@@ -12,6 +20,9 @@ export interface JobBuildResult {
|
||||
prompt: string;
|
||||
claudeArgs: string[];
|
||||
promptMetrics: Record<string, number>;
|
||||
/** Non-null when the prompt is too large for an env var and must be
|
||||
* staged as a K8s Secret before creating the Job. */
|
||||
promptSecret: PromptSecret | null;
|
||||
}
|
||||
export declare function buildJobManifest(input: JobBuildInput): JobBuildResult;
|
||||
//# sourceMappingURL=job-manifest.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AA2C1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAqGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAwOrE"}
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAgD1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA6CnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED;;+EAE+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC;0DACsD;IACtD,YAAY,EAAE,YAAY,GAAG,IAAI,CAAC;CACnC;AAuHD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAkRrE"}
|
||||
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
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+5
-1
@@ -19,8 +19,12 @@ export interface SelfPodInfo {
|
||||
dnsConfig: k8s.V1PodDNSConfig | undefined;
|
||||
pvcClaimName: string | null;
|
||||
secretVolumes: SelfPodSecretVolume[];
|
||||
/** Env vars inherited from the Deployment container. */
|
||||
/** Env vars inherited from the Deployment container (literal name/value pairs). */
|
||||
inheritedEnv: Record<string, string>;
|
||||
/** Env vars with valueFrom (secretKeyRef, configMapKeyRef, etc.) from the Deployment container. */
|
||||
inheritedEnvValueFrom: k8s.V1EnvVar[];
|
||||
/** envFrom sources (secretRef, configMapRef) from the Deployment container. */
|
||||
inheritedEnvFrom: k8s.V1EnvFromSource[];
|
||||
}
|
||||
export declare function getBatchApi(kubeconfigPath?: string): k8s.BatchV1Api;
|
||||
export declare function getCoreApi(kubeconfigPath?: string): k8s.CoreV1Api;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"k8s-client.d.ts","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAG/C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1C,SAAS,EAAE,GAAG,CAAC,cAAc,GAAG,SAAS,CAAC;IAC1C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAyBD,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,UAAU,CAEnE;AAED,wBAAgB,UAAU,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,SAAS,CAEjE;AAED,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,kBAAkB,CAE3E;AAED,wBAAgB,SAAS,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,GAAG,CAE1D;AAmBD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAoElF;AAED,6CAA6C;AAC7C,wBAAgB,UAAU,IAAI,IAAI,CAGjC"}
|
||||
{"version":3,"file":"k8s-client.d.ts","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAG/C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1C,SAAS,EAAE,GAAG,CAAC,cAAc,GAAG,SAAS,CAAC;IAC1C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,mFAAmF;IACnF,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,mGAAmG;IACnG,qBAAqB,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC;IACtC,+EAA+E;IAC/E,gBAAgB,EAAE,GAAG,CAAC,eAAe,EAAE,CAAC;CACzC;AAyBD,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,UAAU,CAEnE;AAED,wBAAgB,UAAU,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,SAAS,CAEjE;AAED,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,kBAAkB,CAE3E;AAED,wBAAgB,SAAS,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,GAAG,CAE1D;AAmBD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CA+ElF;AAED,6CAA6C;AAC7C,wBAAgB,UAAU,IAAI,IAAI,CAGjC"}
|
||||
Vendored
+14
-3
@@ -97,13 +97,22 @@ export async function getSelfPodInfo(kubeconfigPath) {
|
||||
// Collect env vars from the pod spec's container definition.
|
||||
// Agent config env (set in buildEnvVars) will override these.
|
||||
const inheritedEnv = {};
|
||||
const inheritedEnvValueFrom = [];
|
||||
for (const envItem of mainContainer.env ?? []) {
|
||||
if (!envItem.name)
|
||||
continue;
|
||||
const value = envItem.value ?? "";
|
||||
if (value)
|
||||
inheritedEnv[envItem.name] = value;
|
||||
if (envItem.valueFrom) {
|
||||
// Preserve valueFrom entries (secretKeyRef, configMapKeyRef, fieldRef, etc.)
|
||||
inheritedEnvValueFrom.push({ name: envItem.name, valueFrom: envItem.valueFrom });
|
||||
}
|
||||
else {
|
||||
const value = envItem.value ?? "";
|
||||
if (value)
|
||||
inheritedEnv[envItem.name] = value;
|
||||
}
|
||||
}
|
||||
// Capture envFrom sources (secretRef, configMapRef) from the container spec
|
||||
const inheritedEnvFrom = mainContainer.envFrom ?? [];
|
||||
cachedSelfPod = {
|
||||
namespace,
|
||||
image: mainContainer.image,
|
||||
@@ -114,6 +123,8 @@ export async function getSelfPodInfo(kubeconfigPath) {
|
||||
pvcClaimName,
|
||||
secretVolumes,
|
||||
inheritedEnv,
|
||||
inheritedEnvValueFrom,
|
||||
inheritedEnvFrom,
|
||||
};
|
||||
return cachedSelfPod;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"k8s-client.js","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"k8s-client.js","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/server/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAM/D,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM;;;aA4C7B,MAAM,GAAG,IAAI;WACf,YAAY,GAAG,IAAI;;gBAEd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;EAsBvD;AAkCD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUjE;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAatD;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAWlG;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CASpF"}
|
||||
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/server/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAM/D,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM;;;aAkD7B,MAAM,GAAG,IAAI;WACf,YAAY,GAAG,IAAI;;gBAEd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;EAsBvD;AAkCD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUjE;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAatD;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAWlG;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CASpF"}
|
||||
Vendored
+6
-1
@@ -6,6 +6,9 @@ export function parseClaudeStreamJson(stdout) {
|
||||
let model = "";
|
||||
let finalResult = null;
|
||||
const assistantTexts = [];
|
||||
// Belt-and-braces dedup: track seen text blocks to filter duplicates
|
||||
// caused by log stream reconnects replaying overlapping windows.
|
||||
const seenTexts = new Set();
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line)
|
||||
@@ -29,8 +32,10 @@ export function parseClaudeStreamJson(stdout) {
|
||||
const block = entry;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text)
|
||||
if (text && !seenTexts.has(text)) {
|
||||
seenTexts.add(text);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@farhoodliquor/paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.22",
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@farhoodliquor/paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.22",
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@farhoodliquor/paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.23",
|
||||
"name": "paperclip-adapter-claude-k8s",
|
||||
"version": "0.1.28",
|
||||
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -133,6 +133,21 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
label: "Labels",
|
||||
hint: "Extra labels added to Job metadata. One key=value per line.",
|
||||
},
|
||||
// Output filtering (RTK-compatible)
|
||||
{
|
||||
type: "toggle",
|
||||
key: "enableRtk",
|
||||
label: "Enable Output Filtering",
|
||||
hint: "Truncate oversized tool outputs before they reach the model, reducing token consumption. Implemented natively in Node.js — no external binary required. Installs a PostToolUse hook in ~/.claude/settings.json for each run.",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
key: "rtkMaxOutputBytes",
|
||||
label: "Max Tool Output Bytes",
|
||||
hint: "Maximum bytes of tool output to pass to the model when output filtering is enabled. Outputs exceeding this threshold are truncated with a summary. Default: 50000.",
|
||||
default: 50000,
|
||||
},
|
||||
];
|
||||
|
||||
return { fields };
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isK8s404, buildPartialRunError } from "./execute.js";
|
||||
|
||||
describe("isK8s404", () => {
|
||||
it("returns false for non-Error values", () => {
|
||||
expect(isK8s404(null)).toBe(false);
|
||||
expect(isK8s404(undefined)).toBe(false);
|
||||
expect(isK8s404("string error")).toBe(false);
|
||||
expect(isK8s404(404)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", () => {
|
||||
expect(isK8s404(new Error("something went wrong"))).toBe(false);
|
||||
expect(isK8s404(new Error("HTTP-Code: 500 Message: Internal Server Error"))).toBe(false);
|
||||
});
|
||||
|
||||
it("detects 404 from v1.0+ message format", () => {
|
||||
const err = new Error("HTTP-Code: 404 Message: Unknown API Status Code! Body: ...");
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from v0.x response.statusCode", () => {
|
||||
const err = Object.assign(new Error("Not Found"), {
|
||||
response: { statusCode: 404 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from v1.0+ response.status", () => {
|
||||
const err = Object.assign(new Error("Not Found"), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 404 from direct statusCode property", () => {
|
||||
const err = Object.assign(new Error("Not Found"), { statusCode: 404 });
|
||||
expect(isK8s404(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match non-404 status codes on response", () => {
|
||||
const err = Object.assign(new Error("Forbidden"), {
|
||||
response: { statusCode: 403 },
|
||||
});
|
||||
expect(isK8s404(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPartialRunError", () => {
|
||||
const initLine = JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
model: "claude-sonnet-4-6",
|
||||
session_id: "sess_abc",
|
||||
});
|
||||
|
||||
it("returns parse-failure message when exitCode is 0", () => {
|
||||
expect(buildPartialRunError(0, "", "")).toBe("Failed to parse Claude JSON output");
|
||||
expect(buildPartialRunError(0, "claude-sonnet-4-6", initLine)).toBe(
|
||||
"Failed to parse Claude JSON output",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic exit message when stdout is empty", () => {
|
||||
expect(buildPartialRunError(1, "", "")).toBe("Claude exited with code 1");
|
||||
expect(buildPartialRunError(null, "", "")).toBe("Claude exited with code -1");
|
||||
});
|
||||
|
||||
it("skips system/init events and returns generic message when only init captured", () => {
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", initLine);
|
||||
expect(msg).toBe(
|
||||
"Claude started but did not produce a result (model: claude-sonnet-4-6) — check API credentials, model support, and adapter config",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes model from parsedStream when stdout is init-only", () => {
|
||||
const msg = buildPartialRunError(null, "MiniMax-M2.7", initLine);
|
||||
expect(msg).toContain("MiniMax-M2.7");
|
||||
expect(msg).not.toContain("type");
|
||||
expect(msg).not.toContain("system");
|
||||
});
|
||||
|
||||
it("uses first non-system line as content when present", () => {
|
||||
const stdout = [initLine, "Error: no API key configured"].join("\n");
|
||||
const msg = buildPartialRunError(1, "claude-sonnet-4-6", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: Error: no API key configured");
|
||||
});
|
||||
|
||||
it("uses first non-system JSON event as content", () => {
|
||||
const resultLike = JSON.stringify({ type: "result", subtype: "error", result: "rate limit" });
|
||||
const stdout = [initLine, resultLike].join("\n");
|
||||
const msg = buildPartialRunError(2, "claude-sonnet-4-6", stdout);
|
||||
expect(msg).toContain("rate limit");
|
||||
expect(msg).toContain("code 2");
|
||||
});
|
||||
|
||||
it("null exitCode renders as -1 in message", () => {
|
||||
const msg = buildPartialRunError(null, "", "Some plain error text");
|
||||
expect(msg).toBe("Claude exited with code -1: Some plain error text");
|
||||
});
|
||||
|
||||
it("skips multiple consecutive system events", () => {
|
||||
const anotherSystem = JSON.stringify({ type: "system", subtype: "other" });
|
||||
const stdout = [initLine, anotherSystem, "real error line"].join("\n");
|
||||
const msg = buildPartialRunError(1, "model-x", stdout);
|
||||
expect(msg).toBe("Claude exited with code 1: real error line");
|
||||
});
|
||||
});
|
||||
+233
-43
@@ -8,12 +8,67 @@ import {
|
||||
} from "./parse.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
|
||||
import { buildJobManifest } from "./job-manifest.js";
|
||||
import { LogLineDedupFilter } from "./log-dedup.js";
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
|
||||
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
|
||||
|
||||
/**
|
||||
* Detect a Kubernetes 404 (Not Found) error from @kubernetes/client-node.
|
||||
* Works for both v0.x (response.statusCode) and v1.0+ (response.status, message).
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function isK8s404(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const e = err as unknown as Record<string, unknown>;
|
||||
const resp = e.response as Record<string, unknown> | undefined;
|
||||
if (resp?.statusCode === 404 || resp?.status === 404) return true;
|
||||
if (e.statusCode === 404) return true;
|
||||
return /HTTP-Code:\s*404\b/.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the error message when Claude's stdout contains no result event.
|
||||
* Skips system/init event lines so the UI doesn't display the raw init JSON.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function buildPartialRunError(
|
||||
exitCode: number | null,
|
||||
model: string,
|
||||
stdout: string,
|
||||
): string {
|
||||
if (exitCode === 0) return "Failed to parse Claude JSON output";
|
||||
|
||||
// Walk stdout lines, skip system events, return the first real content line.
|
||||
const firstContentLine = stdout.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => {
|
||||
if (!l) return false;
|
||||
try {
|
||||
const obj = JSON.parse(l);
|
||||
if (typeof obj === "object" && obj !== null && (obj as Record<string, unknown>).type === "system") return false;
|
||||
} catch {
|
||||
// not JSON — treat as content
|
||||
}
|
||||
return true;
|
||||
}) ?? "";
|
||||
|
||||
// If we only have system/init events and nothing else, surface the model
|
||||
// name so the operator can diagnose missing credentials or unsupported model.
|
||||
const initOnlyOutput = stdout.trim() !== "" && model !== "" && !firstContentLine;
|
||||
if (initOnlyOutput) {
|
||||
const modelHint = model ? ` (model: ${model})` : "";
|
||||
return `Claude started but did not produce a result${modelHint} — check API credentials, model support, and adapter config`;
|
||||
}
|
||||
|
||||
return firstContentLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${firstContentLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the Job's pod to reach a terminal or running state.
|
||||
@@ -136,6 +191,7 @@ async function streamPodLogsOnce(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
kubeconfigPath?: string,
|
||||
sinceSeconds?: number,
|
||||
dedup?: LogLineDedupFilter,
|
||||
): Promise<string> {
|
||||
const logApi = getLogApi(kubeconfigPath);
|
||||
const chunks: string[] = [];
|
||||
@@ -144,7 +200,12 @@ async function streamPodLogsOnce(
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
const text = chunk.toString("utf-8");
|
||||
chunks.push(text);
|
||||
void onLog("stdout", text).then(() => callback(), callback);
|
||||
const emitted = dedup ? dedup.filter(text) : text;
|
||||
if (!emitted) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
void onLog("stdout", emitted).then(() => callback(), callback);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,6 +228,9 @@ async function streamPodLogsOnce(
|
||||
* stream until the stop signal fires (job completed) or the container
|
||||
* exits normally. This handles silent K8s API connection drops that
|
||||
* would otherwise cause the UI to stop receiving real output.
|
||||
*
|
||||
* Capped at MAX_LOG_RECONNECT_ATTEMPTS to prevent infinite reconnect
|
||||
* loops during sustained API partitions.
|
||||
*/
|
||||
async function streamPodLogs(
|
||||
namespace: string,
|
||||
@@ -177,22 +241,43 @@ async function streamPodLogs(
|
||||
): Promise<string> {
|
||||
const allChunks: string[] = [];
|
||||
let attempt = 0;
|
||||
const streamStartedAt = Math.floor(Date.now() / 1000);
|
||||
// Track the timestamp of the last successfully received log line so
|
||||
// reconnects use a tight window instead of an ever-growing one anchored
|
||||
// at stream start. This is the primary fix for FAR-105 duplicative logs.
|
||||
let lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
// Shared across reconnects so replayed lines inside the `sinceSeconds`
|
||||
// overlap window are dropped before they reach the streaming UI (FAR-123).
|
||||
const dedup = new LogLineDedupFilter();
|
||||
|
||||
while (!stopSignal?.stopped) {
|
||||
// On reconnect, ask for logs since the stream originally started to
|
||||
// avoid missing output during the reconnect gap. Duplicates are
|
||||
// tolerable — the UI deduplicates log chunks.
|
||||
if (attempt >= MAX_LOG_RECONNECT_ATTEMPTS) {
|
||||
await onLog("stderr", `[paperclip] Log stream: max reconnect attempts (${MAX_LOG_RECONNECT_ATTEMPTS}) reached — giving up.\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
// On reconnect, ask for logs since the last received line (+5s buffer)
|
||||
// instead of since stream start. This keeps the window tight and
|
||||
// avoids ever-growing duplicate output.
|
||||
const sinceSeconds = attempt > 0
|
||||
? Math.max(1, Math.floor(Date.now() / 1000) - streamStartedAt + 5)
|
||||
? Math.max(1, Math.floor(Date.now() / 1000) - lastLogReceivedAt + 5)
|
||||
: undefined;
|
||||
|
||||
if (attempt > 0) {
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt})...\n`);
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt}/${MAX_LOG_RECONNECT_ATTEMPTS})...\n`);
|
||||
}
|
||||
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds);
|
||||
if (result) allChunks.push(result);
|
||||
const preStreamTs = Math.floor(Date.now() / 1000);
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup);
|
||||
if (result) {
|
||||
allChunks.push(result);
|
||||
// Update last-received timestamp to now (the stream just ended,
|
||||
// so any log lines in `result` were received up to this moment).
|
||||
lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
} else if (attempt === 0) {
|
||||
// First attempt returned nothing — update timestamp so reconnect
|
||||
// window stays reasonable.
|
||||
lastLogReceivedAt = preStreamTs;
|
||||
}
|
||||
attempt++;
|
||||
|
||||
// If the job is done or the container exited, no need to reconnect.
|
||||
@@ -202,6 +287,11 @@ async function streamPodLogs(
|
||||
await new Promise((resolve) => setTimeout(resolve, LOG_STREAM_RECONNECT_DELAY_MS));
|
||||
}
|
||||
|
||||
// Flush any buffered partial line so the final assistant/result chunk
|
||||
// isn't dropped when the stream ends mid-line.
|
||||
const tail = dedup.flush();
|
||||
if (tail) await onLog("stdout", tail);
|
||||
|
||||
return allChunks.join("");
|
||||
}
|
||||
|
||||
@@ -229,19 +319,32 @@ async function readPodLogs(
|
||||
|
||||
/**
|
||||
* Wait for the Job to reach a terminal state (Complete or Failed).
|
||||
* Returns the Job's final status.
|
||||
* Returns the Job's final status. A 404 (job deleted by TTL or externally)
|
||||
* is treated as a soft terminal: succeeded=false, timedOut=false, jobGone=true.
|
||||
* The caller should log this and fall through to stdout parsing.
|
||||
*/
|
||||
async function waitForJobCompletion(
|
||||
namespace: string,
|
||||
jobName: string,
|
||||
timeoutMs: number,
|
||||
kubeconfigPath?: string,
|
||||
): Promise<{ succeeded: boolean; timedOut: boolean }> {
|
||||
): Promise<{ succeeded: boolean; timedOut: boolean; jobGone?: boolean }> {
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
|
||||
|
||||
while (deadline === 0 || Date.now() < deadline) {
|
||||
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
let job;
|
||||
try {
|
||||
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
} catch (err: unknown) {
|
||||
if (isK8s404(err)) {
|
||||
// Job was deleted (TTL garbage collection or external deletion) before
|
||||
// we detected its terminal condition. The container must have already
|
||||
// exited for TTL to fire, so log streaming will have captured the output.
|
||||
return { succeeded: false, timedOut: false, jobGone: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const conditions = job.status?.conditions ?? [];
|
||||
|
||||
const complete = conditions.find((c) => c.type === "Complete" && c.status === "True");
|
||||
@@ -356,12 +459,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, proceed — the heartbeat service enforces concurrency too
|
||||
} catch (err: unknown) {
|
||||
// If we can't list jobs, fail closed — the K8s concurrency guard is the
|
||||
// only thing preventing zombie Jobs on a shared PVC from corrupting
|
||||
// sessions. 404 (namespace not found) is treated as a hard failure;
|
||||
// other errors (5xx, network) are also surfaced.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Concurrency guard failed: unable to list jobs: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Concurrency guard unreachable: ${msg}`,
|
||||
errorCode: "k8s_concurrency_guard_unreachable",
|
||||
};
|
||||
}
|
||||
|
||||
// Build Job manifest
|
||||
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics } = buildJobManifest({
|
||||
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret } = buildJobManifest({
|
||||
ctx,
|
||||
selfPod,
|
||||
});
|
||||
@@ -384,6 +499,42 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
} as Parameters<typeof onMeta>[0]);
|
||||
}
|
||||
|
||||
// If the prompt is large, create a Secret to hold it (avoids the ~1 MiB
|
||||
// PodSpec limit). The Secret is cleaned up in the finally block.
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: promptSecret.namespace,
|
||||
body: {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: promptSecret.name,
|
||||
namespace: promptSecret.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
"paperclip.io/run-id": runId,
|
||||
},
|
||||
},
|
||||
stringData: promptSecret.data,
|
||||
},
|
||||
});
|
||||
await onLog("stdout", `[paperclip] Created prompt Secret: ${promptSecret.name} (${Math.round(Buffer.byteLength(prompt, "utf-8") / 1024)} KiB)\n`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await onLog("stderr", `[paperclip] Failed to create prompt Secret: ${msg}\n`);
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: `Failed to create prompt Secret: ${msg}`,
|
||||
errorCode: "k8s_prompt_secret_create_failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create the Job
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
try {
|
||||
@@ -423,7 +574,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
// stale-run reaper (reapOrphanedRuns) uses to decide liveness.
|
||||
if (ctx.onSpawn) {
|
||||
await ctx.onSpawn({
|
||||
pid: -1, // no local process; sentinel for K8s Job
|
||||
pid: process.pid, // Paperclip server PID — always alive while adapter runs in-process
|
||||
processGroupId: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -486,22 +637,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
keepaliveJobTerminal = true;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Job may have been deleted out from under us, or the API call
|
||||
// transiently failed. Either way, do not refresh updatedAt —
|
||||
// either the Job really is gone, or the next tick will re-check.
|
||||
keepaliveJobTerminal = true;
|
||||
} catch (err: unknown) {
|
||||
// Only treat 404 (Job deleted) as terminal. Transient 5xx or
|
||||
// connection resets should NOT permanently disable the keepalive —
|
||||
// the next tick will re-check and the reaper uses the staleness
|
||||
// window as a safety net.
|
||||
if (isK8s404(err)) {
|
||||
keepaliveJobTerminal = true;
|
||||
return;
|
||||
}
|
||||
// Log transient errors but leave keepaliveJobTerminal false so
|
||||
// the next tick retries.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
void onLog("stderr", `[paperclip] keepalive: transient error checking job status: ${msg}\n`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const silenceSec = Math.round((Date.now() - lastLogAt) / 1000);
|
||||
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`);
|
||||
void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`).catch(() => {});
|
||||
|
||||
// Refresh updatedAt every ~4 minutes (16 ticks × 15s) to stay
|
||||
// well within the 5-minute reaper staleness window.
|
||||
// Refresh updatedAt every ~3 minutes (12 ticks × 15s = 180s) to
|
||||
// stay well within the 5-minute reaper staleness window. Also
|
||||
// fire on tick 1 for an early safety margin after job start.
|
||||
keepaliveTick++;
|
||||
if (ctx.onSpawn && keepaliveTick % 16 === 0) {
|
||||
void ctx.onSpawn({ pid: -1, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
|
||||
if (ctx.onSpawn && (keepaliveTick === 1 || keepaliveTick % 12 === 0)) {
|
||||
void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {});
|
||||
}
|
||||
})();
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
@@ -535,27 +695,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stdout = logResult.value;
|
||||
}
|
||||
|
||||
// If the follow stream missed output (container exited quickly), do a
|
||||
// one-shot log read as fallback before the pod is cleaned up.
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (stdout.trim()) {
|
||||
// One-shot log fallback: handles two failure modes with a single read.
|
||||
// Mode 1 — empty stream: the follow stream returned nothing (fast exit before connection).
|
||||
// Mode 2 — partial stream: we have some output but no result event (follow stream raced
|
||||
// with container exit and captured only the init line before the connection dropped).
|
||||
// A one-shot readPodLogs is more reliable for already-terminated containers and reads
|
||||
// from the beginning of the log, giving us the full output.
|
||||
// We use a cheap string scan for the result-event guard (avoids a full JSON parse here;
|
||||
// the authoritative parse happens once below after all fallbacks complete).
|
||||
const hasResultEvent = stdout.includes('"type":"result"');
|
||||
const needsOneShot = !stdout.trim() || (stdout.trim() && !hasResultEvent);
|
||||
if (needsOneShot) {
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
}
|
||||
const oneShotLogs = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (!stdout.trim() && oneShotLogs.trim()) {
|
||||
stdout = oneShotLogs;
|
||||
await onLog("stdout", stdout);
|
||||
} else if (oneShotLogs && oneShotLogs.length > stdout.length) {
|
||||
await onLog("stdout", `[paperclip] Log stream captured partial output — supplemental one-shot read returned more content.\n`);
|
||||
stdout = oneShotLogs;
|
||||
}
|
||||
}
|
||||
|
||||
if (completionResult.status === "fulfilled") {
|
||||
jobTimedOut = completionResult.value.timedOut;
|
||||
if (completionResult.value.jobGone) {
|
||||
// Job was deleted by TTL or externally before we observed the Complete/Failed
|
||||
// condition. The container must have exited first (TTL only fires after
|
||||
// completion), so log streaming has captured the full output — continue
|
||||
// to stdout parsing rather than returning an error.
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} was deleted before terminal condition was observed (TTL or external deletion) — proceeding with captured output.\n`);
|
||||
}
|
||||
} else {
|
||||
// waitForJobCompletion threw — re-check job state to avoid returning
|
||||
// while the job is still running (which would cause UI staleness and
|
||||
// concurrency errors on retry).
|
||||
// waitForJobCompletion threw an unexpected error — re-check job state to
|
||||
// avoid returning while the job is still running. Use a bounded timeout
|
||||
// (60s) so we don't hang the heartbeat indefinitely if the K8s API is degraded.
|
||||
jobTimedOut = false;
|
||||
const actualState = await waitForJobCompletion(namespace, jobName, 0, kubeconfigPath);
|
||||
const RECHECK_TIMEOUT_MS = 60_000;
|
||||
const actualState = await waitForJobCompletion(namespace, jobName, RECHECK_TIMEOUT_MS, kubeconfigPath);
|
||||
if (actualState.timedOut) {
|
||||
// Truly a timeout after re-check — treat as timed out.
|
||||
// Re-check itself timed out — the job may still be running.
|
||||
// Return an error so the UI knows the run is not done.
|
||||
jobTimedOut = true;
|
||||
} else if (actualState.jobGone) {
|
||||
// Job was deleted before we could confirm terminal state — same as the
|
||||
// fulfilled+jobGone case above: proceed with captured output.
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} was deleted before terminal condition was observed (TTL or external deletion) — proceeding with captured output.\n`);
|
||||
} else if (!actualState.succeeded) {
|
||||
// Job still not terminal — the completion error was likely transient.
|
||||
// Return an error so the UI knows the run is not done, rather than
|
||||
@@ -582,6 +769,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
} else {
|
||||
await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`);
|
||||
}
|
||||
// Clean up prompt Secret if one was created
|
||||
if (promptSecret) {
|
||||
try {
|
||||
await coreApi.deleteNamespacedSecret({ name: promptSecret.name, namespace: promptSecret.namespace });
|
||||
} catch {
|
||||
// Best-effort cleanup — TTL or manual deletion will catch stragglers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Claude output (reuse claude_local parsing)
|
||||
@@ -613,16 +808,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
const stderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
||||
return {
|
||||
exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: exitCode === 0
|
||||
? "Failed to parse Claude JSON output"
|
||||
: stderrLine
|
||||
? `Claude exited with code ${exitCode ?? -1}: ${stderrLine}`
|
||||
: `Claude exited with code ${exitCode ?? -1}`,
|
||||
errorMessage: buildPartialRunError(exitCode, parsedStream.model, stdout),
|
||||
resultJson: { stdout },
|
||||
};
|
||||
}
|
||||
|
||||
+233
-13
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { buildJobManifest } from "./job-manifest.js";
|
||||
import { buildJobManifest, buildRtkSetupCommands } from "./job-manifest.js";
|
||||
import type { SelfPodInfo } from "./k8s-client.js";
|
||||
|
||||
function makeCtx(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
||||
@@ -24,6 +24,8 @@ function makeSelfPod(overrides: Partial<SelfPodInfo> = {}): SelfPodInfo {
|
||||
pvcClaimName: "paperclip-data",
|
||||
secretVolumes: [],
|
||||
inheritedEnv: {},
|
||||
inheritedEnvValueFrom: [],
|
||||
inheritedEnvFrom: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -38,25 +40,44 @@ describe("buildJobManifest", () => {
|
||||
});
|
||||
|
||||
describe("job naming", () => {
|
||||
it("uses agent-claude- prefix", () => {
|
||||
it("uses ac- prefix", () => {
|
||||
const { jobName } = buildJobManifest({ ctx, selfPod });
|
||||
expect(jobName).toMatch(/^agent-claude-/);
|
||||
expect(jobName).toMatch(/^ac-/);
|
||||
});
|
||||
|
||||
it("includes sanitized agent id slug", () => {
|
||||
it("includes sanitized agent id slug (up to 16 chars)", () => {
|
||||
ctx.agent.id = "Agent-ABC!@#";
|
||||
const { jobName } = buildJobManifest({ ctx, selfPod });
|
||||
// sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-8
|
||||
// "Agent-ABC!@#" -> "agent-abc" (strips !@#, slice to 8 = "agent-ab")
|
||||
expect(jobName).toContain("agent-ab");
|
||||
// sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-16
|
||||
expect(jobName).toContain("agent-abc");
|
||||
});
|
||||
|
||||
it("includes sanitized run id slug", () => {
|
||||
it("includes sanitized run id slug (up to 16 chars)", () => {
|
||||
ctx.runId = "RUN-ABC-12345";
|
||||
const { jobName } = buildJobManifest({ ctx, selfPod });
|
||||
// sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-8
|
||||
// "RUN-ABC-12345" -> "run-abc-12345" (slice to 8 = "run-abc-")
|
||||
expect(jobName).toContain("run-abc-");
|
||||
expect(jobName).toContain("run-abc-12345");
|
||||
});
|
||||
|
||||
it("includes a deterministic hash suffix", () => {
|
||||
const result1 = buildJobManifest({ ctx, selfPod });
|
||||
const result2 = buildJobManifest({ ctx, selfPod });
|
||||
expect(result1.jobName).toBe(result2.jobName);
|
||||
// Hash suffix is 6 hex chars at the end
|
||||
expect(result1.jobName).toMatch(/-[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("different agent+run pairs produce different names", () => {
|
||||
const result1 = buildJobManifest({ ctx, selfPod });
|
||||
ctx.runId = "run-different";
|
||||
const result2 = buildJobManifest({ ctx, selfPod });
|
||||
expect(result1.jobName).not.toBe(result2.jobName);
|
||||
});
|
||||
|
||||
it("stays within 63-char DNS label limit", () => {
|
||||
ctx.agent.id = "a".repeat(100);
|
||||
ctx.runId = "r".repeat(100);
|
||||
const { jobName } = buildJobManifest({ ctx, selfPod });
|
||||
expect(jobName.length).toBeLessThanOrEqual(63);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +202,7 @@ describe("buildJobManifest", () => {
|
||||
it("write-prompt writes PROMPT_CONTENT to /tmp/prompt/prompt.txt", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const init = job.spec?.template?.spec?.initContainers?.[0];
|
||||
expect(init?.command).toEqual(["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]);
|
||||
expect(init?.command).toEqual(["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]);
|
||||
});
|
||||
|
||||
it("write-prompt mounts prompt volume", () => {
|
||||
@@ -331,6 +352,50 @@ describe("buildJobManifest", () => {
|
||||
const apiUrl = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_URL");
|
||||
expect(apiUrl?.value).toBe("http://paperclip:8080");
|
||||
});
|
||||
|
||||
it("includes valueFrom env vars from selfPod", () => {
|
||||
selfPod.inheritedEnvValueFrom = [
|
||||
{ name: "ANTHROPIC_API_KEY", valueFrom: { secretKeyRef: { name: "api-keys", key: "anthropic" } } },
|
||||
];
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const envList = job.spec?.template?.spec?.containers[0]?.env ?? [];
|
||||
const apiKeyEntry = envList.find((e) => e.name === "ANTHROPIC_API_KEY");
|
||||
expect(apiKeyEntry?.valueFrom?.secretKeyRef?.name).toBe("api-keys");
|
||||
expect(apiKeyEntry?.valueFrom?.secretKeyRef?.key).toBe("anthropic");
|
||||
expect(apiKeyEntry?.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("literal env overrides valueFrom with the same name", () => {
|
||||
selfPod.inheritedEnv = { MY_VAR: "literal-value" };
|
||||
selfPod.inheritedEnvValueFrom = [
|
||||
{ name: "MY_VAR", valueFrom: { secretKeyRef: { name: "sec", key: "k" } } },
|
||||
];
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const envList = job.spec?.template?.spec?.containers[0]?.env ?? [];
|
||||
const myVar = envList.filter((e) => e.name === "MY_VAR");
|
||||
expect(myVar).toHaveLength(1);
|
||||
expect(myVar[0]?.value).toBe("literal-value");
|
||||
expect(myVar[0]?.valueFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes envFrom sources from selfPod on the container", () => {
|
||||
selfPod.inheritedEnvFrom = [
|
||||
{ secretRef: { name: "api-secrets" } },
|
||||
{ configMapRef: { name: "app-config" } },
|
||||
];
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const container = job.spec?.template?.spec?.containers[0];
|
||||
expect(container?.envFrom).toHaveLength(2);
|
||||
expect(container?.envFrom?.[0]?.secretRef?.name).toBe("api-secrets");
|
||||
expect(container?.envFrom?.[1]?.configMapRef?.name).toBe("app-config");
|
||||
});
|
||||
|
||||
it("omits envFrom when selfPod has none", () => {
|
||||
selfPod.inheritedEnvFrom = [];
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const container = job.spec?.template?.spec?.containers[0];
|
||||
expect(container?.envFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resources", () => {
|
||||
@@ -498,7 +563,7 @@ describe("buildJobManifest", () => {
|
||||
});
|
||||
|
||||
describe("return value", () => {
|
||||
it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics", () => {
|
||||
it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret", () => {
|
||||
const result = buildJobManifest({ ctx, selfPod });
|
||||
expect(result.job).toBeDefined();
|
||||
expect(result.jobName).toBeDefined();
|
||||
@@ -506,6 +571,161 @@ describe("buildJobManifest", () => {
|
||||
expect(result.prompt).toBeDefined();
|
||||
expect(result.claudeArgs).toBeDefined();
|
||||
expect(result.promptMetrics).toBeDefined();
|
||||
expect(result.promptSecret).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeSelector key=value parsing", () => {
|
||||
it("parses key=value multiline text", () => {
|
||||
ctx.config = { nodeSelector: "disktype=ssd\ntopology.kubernetes.io/zone=us-east-1a" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.spec?.template?.spec?.nodeSelector).toEqual({
|
||||
disktype: "ssd",
|
||||
"topology.kubernetes.io/zone": "us-east-1a",
|
||||
});
|
||||
});
|
||||
|
||||
it("still accepts JSON objects", () => {
|
||||
ctx.config = { nodeSelector: { disktype: "ssd" } };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
||||
});
|
||||
|
||||
it("parses JSON string format", () => {
|
||||
ctx.config = { nodeSelector: '{"disktype":"ssd"}' };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
||||
});
|
||||
|
||||
it("skips comment lines and blank lines", () => {
|
||||
ctx.config = { nodeSelector: "# comment\n\ndisktype=ssd\n" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("labels key=value parsing", () => {
|
||||
it("parses key=value multiline text for extra labels", () => {
|
||||
ctx.config = { labels: "env=prod\nteam=platform" };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(job.metadata?.labels?.env).toBe("prod");
|
||||
expect(job.metadata?.labels?.team).toBe("platform");
|
||||
});
|
||||
});
|
||||
|
||||
describe("large prompt Secret fallback", () => {
|
||||
it("returns null promptSecret for small prompts", () => {
|
||||
const { promptSecret } = buildJobManifest({ ctx, selfPod });
|
||||
expect(promptSecret).toBeNull();
|
||||
});
|
||||
|
||||
it("returns promptSecret for prompts >256 KiB", () => {
|
||||
// Build a prompt >256 KiB via a custom template
|
||||
const largePrompt = "x".repeat(300 * 1024);
|
||||
ctx.config = { promptTemplate: largePrompt };
|
||||
const { promptSecret, job } = buildJobManifest({ ctx, selfPod });
|
||||
expect(promptSecret).not.toBeNull();
|
||||
expect(promptSecret!.data["prompt.txt"]).toBe(largePrompt);
|
||||
// Init container should copy from secret volume, not use PROMPT_CONTENT env
|
||||
const init = job.spec?.template?.spec?.initContainers?.[0];
|
||||
expect(init?.command).toContainEqual(expect.stringContaining("cp"));
|
||||
expect(init?.env).toBeUndefined();
|
||||
// Should have prompt-secret volume
|
||||
const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt-secret");
|
||||
expect(secretVol?.secret?.secretName).toBe(promptSecret!.name);
|
||||
});
|
||||
|
||||
it("uses env var init container for small prompts", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const init = job.spec?.template?.spec?.initContainers?.[0];
|
||||
expect(init?.env?.[0]?.name).toBe("PROMPT_CONTENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rtk output filtering", () => {
|
||||
it("does not modify main command when enableRtk is false (default)", () => {
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
||||
// Command should be the plain `cat ... | claude ...` form with no rtk setup
|
||||
expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude /);
|
||||
expect(cmd?.[2]).not.toContain("rtk-filter");
|
||||
});
|
||||
|
||||
it("prepends RTK setup commands when enableRtk is true", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
||||
expect(cmd?.[2]).toContain(".rtk-filter.js");
|
||||
expect(cmd?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude");
|
||||
});
|
||||
|
||||
it("RTK setup runs before claude invocation", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
const rtkIdx = cmd.indexOf(".rtk-filter.js");
|
||||
const claudeIdx = cmd.indexOf("cat /tmp/prompt/prompt.txt | claude");
|
||||
expect(rtkIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(claudeIdx).toBeGreaterThan(rtkIdx);
|
||||
});
|
||||
|
||||
it("RTK setup uses node (no external binaries)", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
// Should only use `node` — no curl, wget, apt, pip, etc.
|
||||
expect(cmd).not.toMatch(/\b(curl|wget|apt|yum|pip|gem|cargo|go\s+get)\b/);
|
||||
expect(cmd).toContain("node ");
|
||||
});
|
||||
|
||||
it("uses default 50000 byte threshold when rtkMaxOutputBytes not set", () => {
|
||||
ctx.config = { enableRtk: true };
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
// The filter script base64 should decode to contain the MAX constant
|
||||
const b64Match = setup.match(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/);
|
||||
expect(b64Match).not.toBeNull();
|
||||
const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8");
|
||||
expect(decoded).toContain("50000");
|
||||
});
|
||||
|
||||
it("respects custom rtkMaxOutputBytes", () => {
|
||||
ctx.config = { enableRtk: true, rtkMaxOutputBytes: 100000 };
|
||||
const { job } = buildJobManifest({ ctx, selfPod });
|
||||
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
||||
// The custom threshold should appear in the base64-encoded filter script
|
||||
const b64Matches = [...cmd.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const decoded = b64Matches.map((m) => Buffer.from(m[1], "base64").toString("utf-8")).join("\n");
|
||||
expect(decoded).toContain("100000");
|
||||
});
|
||||
|
||||
it("RTK setup installs a PostToolUse hook in claude settings", () => {
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
// The settings script (second base64 block) should reference PostToolUse
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
expect(b64Matches.length).toBeGreaterThanOrEqual(2);
|
||||
const settingsScript = Buffer.from(b64Matches[1]![1], "base64").toString("utf-8");
|
||||
expect(settingsScript).toContain("PostToolUse");
|
||||
expect(settingsScript).toContain("settings.json");
|
||||
});
|
||||
|
||||
it("filter script handles string content truncation", () => {
|
||||
// Decode the filter script and verify it truncates string content
|
||||
const setup = buildRtkSetupCommands(1000);
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
||||
expect(filterScript).toContain("MAX=1000");
|
||||
expect(filterScript).toContain("truncated by paperclip-rtk");
|
||||
expect(filterScript).toContain("tool_response");
|
||||
expect(filterScript).toContain("tool_result");
|
||||
});
|
||||
|
||||
it("filter script handles array content (block format)", () => {
|
||||
const setup = buildRtkSetupCommands(50000);
|
||||
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
||||
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
||||
// Should handle array content blocks (text field on each block)
|
||||
expect(filterScript).toContain("Array.isArray");
|
||||
expect(filterScript).toContain("b.text");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+235
-27
@@ -9,6 +9,91 @@ import {
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Build the shell command prefix that installs a native Node.js PostToolUse
|
||||
* hook into Claude Code's settings. The hook truncates oversized tool outputs
|
||||
* before they reach the model — replacing the RTK binary init-container
|
||||
* approach with a self-contained Node.js implementation.
|
||||
*
|
||||
* Both scripts are base64-encoded so they can be embedded in a sh -c command
|
||||
* string without any quoting or escaping issues.
|
||||
*
|
||||
* @param maxOutputBytes Byte threshold above which tool output is truncated.
|
||||
* @returns A shell command string (suitable for "&&"-chaining
|
||||
* before the claude invocation).
|
||||
*/
|
||||
export function buildRtkSetupCommands(maxOutputBytes: number): string {
|
||||
// --- Filter script ----------------------------------------------------------
|
||||
// This script runs as the PostToolUse hook inside every K8s Job pod.
|
||||
// Claude Code writes the hook event as JSON to the script's stdin; the script
|
||||
// truncates the tool_response/tool_result content when it exceeds the
|
||||
// threshold and writes the (possibly modified) JSON to stdout.
|
||||
//
|
||||
// Field-name coverage:
|
||||
// • tool_response — documented hook event format for PostToolUse
|
||||
// • tool_result — alternative name seen in some Claude Code versions
|
||||
// Content may be a plain string or an array of typed blocks (text/image/…).
|
||||
const filterScript = [
|
||||
`const c=[];`,
|
||||
`process.stdin.on('data',d=>c.push(d));`,
|
||||
`process.stdin.on('end',()=>{`,
|
||||
`const raw=Buffer.concat(c).toString('utf-8');`,
|
||||
`let o;try{o=JSON.parse(raw);}catch{process.stdout.write(raw);return;}`,
|
||||
`const MAX=${maxOutputBytes};`,
|
||||
`function trunc(s){`,
|
||||
`if(typeof s!=='string')return s;`,
|
||||
`const b=Buffer.from(s,'utf-8');`,
|
||||
`if(b.length<=MAX)return s;`,
|
||||
`return b.slice(0,MAX).toString('utf-8')+'\\n[...'+(b.length-MAX)+' bytes truncated by paperclip-rtk]';`,
|
||||
`}`,
|
||||
`const tr=o&&(o.tool_response||o.tool_result);`,
|
||||
`if(tr){`,
|
||||
`if(typeof tr.content==='string'){tr.content=trunc(tr.content);}`,
|
||||
`else if(Array.isArray(tr.content)){`,
|
||||
`tr.content=tr.content.map(function(b){`,
|
||||
`if(b&&typeof b==='object'&&typeof b.text==='string'){`,
|
||||
`return Object.assign({},b,{text:trunc(b.text)});`,
|
||||
`}return b;`,
|
||||
`});`,
|
||||
`}`,
|
||||
`}`,
|
||||
`process.stdout.write(JSON.stringify(o));`,
|
||||
`});`,
|
||||
].join("");
|
||||
|
||||
// --- Settings script --------------------------------------------------------
|
||||
// Reads the existing ~/.claude/settings.json (if any), merges in the RTK
|
||||
// PostToolUse hook, and writes the file back. All other settings sections
|
||||
// are preserved; only PostToolUse is replaced so we own the full hook list
|
||||
// for this run.
|
||||
const settingsScript = [
|
||||
`const fs=require('fs'),pt=require('path');`,
|
||||
`const p=pt.join(process.env.HOME,'.claude','settings.json');`,
|
||||
`let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'));}catch(e){}`,
|
||||
`s.hooks=s.hooks||{};`,
|
||||
`s.hooks.PostToolUse=[{matcher:'.*',hooks:[{type:'command',command:'node /tmp/.rtk-filter.js'}]}];`,
|
||||
`fs.mkdirSync(pt.dirname(p),{recursive:true});`,
|
||||
`fs.writeFileSync(p,JSON.stringify(s));`,
|
||||
].join("");
|
||||
|
||||
// Encode as base64 so the strings can be embedded directly in a shell command
|
||||
// without any quoting concerns (base64 alphabet: A-Za-z0-9+/=).
|
||||
const filterB64 = Buffer.from(filterScript, "utf-8").toString("base64");
|
||||
const settingsB64 = Buffer.from(settingsScript, "utf-8").toString("base64");
|
||||
|
||||
return [
|
||||
// Write the filter script
|
||||
`node -e "require('fs').writeFileSync('/tmp/.rtk-filter.js',Buffer.from('${filterB64}','base64').toString('utf-8'))"`,
|
||||
// Install the Claude Code PostToolUse hook (merge into existing settings)
|
||||
`node -e "eval(Buffer.from('${settingsB64}','base64').toString('utf-8'))"`,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
/** 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: string[], separator = "\n\n"): string {
|
||||
@@ -44,11 +129,63 @@ function renderPaperclipWakePrompt(wake: unknown, _opts?: { resumedSession?: boo
|
||||
}
|
||||
import type { SelfPodInfo } from "./k8s-client.js";
|
||||
|
||||
/**
|
||||
* 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: unknown): Record<string, string> {
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
// Already an object (JSON was parsed upstream)
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
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: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === "string") result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to key=value parsing
|
||||
}
|
||||
// Parse key=value lines
|
||||
const result: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
export interface JobBuildInput {
|
||||
ctx: AdapterExecutionContext;
|
||||
selfPod: SelfPodInfo;
|
||||
}
|
||||
|
||||
/** When the prompt exceeds the env-var size limit, the manifest uses a
|
||||
* Secret-backed volume instead of the init container's PROMPT_CONTENT env.
|
||||
* The caller must create this Secret before the Job and clean it up after. */
|
||||
export interface PromptSecret {
|
||||
name: string;
|
||||
namespace: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface JobBuildResult {
|
||||
job: k8s.V1Job;
|
||||
jobName: string;
|
||||
@@ -56,10 +193,21 @@ export interface JobBuildResult {
|
||||
prompt: string;
|
||||
claudeArgs: string[];
|
||||
promptMetrics: Record<string, number>;
|
||||
/** Non-null when the prompt is too large for an env var and must be
|
||||
* staged as a K8s Secret before creating the Job. */
|
||||
promptSecret: PromptSecret | null;
|
||||
}
|
||||
|
||||
function sanitizeForK8sName(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
|
||||
function sanitizeForK8sName(value: string, maxLen = 16): string {
|
||||
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: string, len = 6): string {
|
||||
return createHash("sha256").update(input).digest("hex").slice(0, len);
|
||||
}
|
||||
|
||||
function buildEnvVars(
|
||||
@@ -148,12 +296,22 @@ function buildEnvVars(
|
||||
// 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: k8s.V1EnvVar[] = 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;
|
||||
}
|
||||
|
||||
@@ -174,9 +332,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
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);
|
||||
const enableRtk = asBoolean(config.enableRtk, false);
|
||||
const rtkMaxOutputBytes = asNumber(config.rtkMaxOutputBytes, 50000);
|
||||
|
||||
// Resolve working directory — use workspace cwd, fall back to /paperclip
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
@@ -184,9 +344,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
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(
|
||||
@@ -265,7 +429,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
"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
|
||||
@@ -326,7 +490,64 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
|
||||
// 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}`;
|
||||
const claudeInvocation = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
|
||||
// When RTK output filtering is enabled, prepend the Node.js hook setup.
|
||||
// This writes a filter script and a Claude Code settings file that installs
|
||||
// it as a PostToolUse hook — no external binary or init container required.
|
||||
const mainCommand = enableRtk
|
||||
? `${buildRtkSetupCommands(rtkMaxOutputBytes)} && ${claudeInvocation}`
|
||||
: claudeInvocation;
|
||||
|
||||
// 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: PromptSecret | null = 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: k8s.V1Container = 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: k8s.V1Job = {
|
||||
apiVersion: "batch/v1",
|
||||
@@ -352,23 +573,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
securityContext: podSecurityContext,
|
||||
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
|
||||
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
|
||||
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector as Record<string, string> } : {}),
|
||||
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector } : {}),
|
||||
...(tolerations.length > 0 ? { tolerations: tolerations as k8s.V1Toleration[] } : {}),
|
||||
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",
|
||||
@@ -377,6 +584,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
workingDir,
|
||||
command: ["sh", "-c", mainCommand],
|
||||
env: envVars,
|
||||
...(selfPod.inheritedEnvFrom.length > 0 ? { envFrom: selfPod.inheritedEnvFrom } : {}),
|
||||
volumeMounts,
|
||||
securityContext,
|
||||
resources: containerResources,
|
||||
@@ -388,5 +596,5 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
},
|
||||
};
|
||||
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret };
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ export interface SelfPodInfo {
|
||||
dnsConfig: k8s.V1PodDNSConfig | undefined;
|
||||
pvcClaimName: string | null;
|
||||
secretVolumes: SelfPodSecretVolume[];
|
||||
/** Env vars inherited from the Deployment container. */
|
||||
/** Env vars inherited from the Deployment container (literal name/value pairs). */
|
||||
inheritedEnv: Record<string, string>;
|
||||
/** Env vars with valueFrom (secretKeyRef, configMapKeyRef, etc.) from the Deployment container. */
|
||||
inheritedEnvValueFrom: k8s.V1EnvVar[];
|
||||
/** envFrom sources (secretRef, configMapRef) from the Deployment container. */
|
||||
inheritedEnvFrom: k8s.V1EnvFromSource[];
|
||||
}
|
||||
|
||||
let cachedSelfPod: SelfPodInfo | null = null;
|
||||
@@ -134,12 +138,21 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
|
||||
// Collect env vars from the pod spec's container definition.
|
||||
// Agent config env (set in buildEnvVars) will override these.
|
||||
const inheritedEnv: Record<string, string> = {};
|
||||
const inheritedEnvValueFrom: k8s.V1EnvVar[] = [];
|
||||
for (const envItem of mainContainer.env ?? []) {
|
||||
if (!envItem.name) continue;
|
||||
const value = envItem.value ?? "";
|
||||
if (value) inheritedEnv[envItem.name] = value;
|
||||
if (envItem.valueFrom) {
|
||||
// Preserve valueFrom entries (secretKeyRef, configMapKeyRef, fieldRef, etc.)
|
||||
inheritedEnvValueFrom.push({ name: envItem.name, valueFrom: envItem.valueFrom });
|
||||
} else {
|
||||
const value = envItem.value ?? "";
|
||||
if (value) inheritedEnv[envItem.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture envFrom sources (secretRef, configMapRef) from the container spec
|
||||
const inheritedEnvFrom: k8s.V1EnvFromSource[] = mainContainer.envFrom ?? [];
|
||||
|
||||
cachedSelfPod = {
|
||||
namespace,
|
||||
image: mainContainer.image,
|
||||
@@ -150,6 +163,8 @@ export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodIn
|
||||
pvcClaimName,
|
||||
secretVolumes,
|
||||
inheritedEnv,
|
||||
inheritedEnvValueFrom,
|
||||
inheritedEnvFrom,
|
||||
};
|
||||
|
||||
return cachedSelfPod;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LogLineDedupFilter, eventDedupKey } from "./log-dedup.js";
|
||||
|
||||
function assistantEvent(id: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: "assistant",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
id,
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function userToolResultEvent(toolUseId: string, content: string): string {
|
||||
return JSON.stringify({
|
||||
type: "user",
|
||||
session_id: "sess_1",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function systemInitEvent(sessionId: string): string {
|
||||
return JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: sessionId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
}
|
||||
|
||||
function resultEvent(sessionId: string): string {
|
||||
return JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: sessionId,
|
||||
result: "done",
|
||||
total_cost_usd: 0.01,
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("eventDedupKey", () => {
|
||||
it("keys assistant events by message.id", () => {
|
||||
const key = eventDedupKey(JSON.parse(assistantEvent("msg_abc", "hi")));
|
||||
expect(key).toBe("assistant:msg_abc");
|
||||
});
|
||||
|
||||
it("keys user tool_result events by tool_use_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(userToolResultEvent("toolu_1", "ok")));
|
||||
expect(key).toBe("user:tool_result:toolu_1");
|
||||
});
|
||||
|
||||
it("keys system init events by session_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(systemInitEvent("sess_xyz")));
|
||||
expect(key).toBe("system:init:sess_xyz");
|
||||
});
|
||||
|
||||
it("keys result events by session_id", () => {
|
||||
const key = eventDedupKey(JSON.parse(resultEvent("sess_xyz")));
|
||||
expect(key).toBe("result:sess_xyz");
|
||||
});
|
||||
|
||||
it("returns null for assistant events missing message.id", () => {
|
||||
const event = { type: "assistant", message: { content: [] } };
|
||||
expect(eventDedupKey(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown event types", () => {
|
||||
expect(eventDedupKey({ type: "unknown" })).toBeNull();
|
||||
expect(eventDedupKey({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogLineDedupFilter", () => {
|
||||
it("passes unique lines through unchanged", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_1", "hello");
|
||||
const b = assistantEvent("msg_2", "world");
|
||||
expect(filter.filter(`${a}\n${b}\n`)).toBe(`${a}\n${b}\n`);
|
||||
});
|
||||
|
||||
it("drops assistant events replayed with the same message.id", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_1", "Three nits to fix.");
|
||||
filter.filter(`${a}\n`);
|
||||
expect(filter.filter(`${a}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("drops user tool_result events replayed with the same tool_use_id", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = userToolResultEvent("toolu_abc", "file contents");
|
||||
filter.filter(`${a}\n`);
|
||||
expect(filter.filter(`${a}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("drops system init and result events on replay", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const init = systemInitEvent("sess_1");
|
||||
const result = resultEvent("sess_1");
|
||||
filter.filter(`${init}\n${result}\n`);
|
||||
expect(filter.filter(`${init}\n${result}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("buffers incomplete trailing lines across chunks", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_1", "hello");
|
||||
const mid = Math.floor(line.length / 2);
|
||||
const out1 = filter.filter(line.slice(0, mid));
|
||||
const out2 = filter.filter(line.slice(mid) + "\n");
|
||||
expect(out1).toBe("");
|
||||
expect(out2).toBe(`${line}\n`);
|
||||
});
|
||||
|
||||
it("flush() emits a final incomplete line that was not replayed", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_tail", "no newline");
|
||||
filter.filter(line);
|
||||
expect(filter.flush()).toBe(line);
|
||||
});
|
||||
|
||||
it("flush() drops an incomplete line that was already seen with a newline", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const line = assistantEvent("msg_same", "x");
|
||||
filter.filter(`${line}\n`);
|
||||
filter.filter(line);
|
||||
expect(filter.flush()).toBe("");
|
||||
});
|
||||
|
||||
it("passes non-JSON lines through every time (does not dedup paperclip status)", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const status = "[paperclip] keepalive — job foo running\n";
|
||||
expect(filter.filter(status)).toBe(status);
|
||||
expect(filter.filter(status)).toBe(status);
|
||||
});
|
||||
|
||||
it("dedups structurally identical JSON with identical content (raw fallback)", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
// No recognized type → raw fallback key.
|
||||
const line = JSON.stringify({ foo: "bar", baz: 1 });
|
||||
filter.filter(`${line}\n`);
|
||||
expect(filter.filter(`${line}\n`)).toBe("");
|
||||
});
|
||||
|
||||
it("handles multiple complete lines in a single chunk with partial trailing", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const a = assistantEvent("msg_a", "a");
|
||||
const b = assistantEvent("msg_b", "b");
|
||||
const c = assistantEvent("msg_c", "c");
|
||||
// a and b are complete, c is partial (no trailing newline).
|
||||
const out = filter.filter(`${a}\n${b}\n${c}`);
|
||||
expect(out).toBe(`${a}\n${b}\n`);
|
||||
// Completing c later should emit exactly c.
|
||||
expect(filter.filter("\n")).toBe(`${c}\n`);
|
||||
});
|
||||
|
||||
it("drops the classic FAR-123 replay scenario across reconnects", () => {
|
||||
const filter = new LogLineDedupFilter();
|
||||
const assistantNits = assistantEvent("msg_nits", "Three nits to fix. Let me look at an existing test file...");
|
||||
const assistantWrite = assistantEvent("msg_write", "Now I need to write unit tests");
|
||||
// First stream attempt emits both events.
|
||||
const out1 = filter.filter(`${assistantNits}\n${assistantWrite}\n`);
|
||||
expect(out1).toBe(`${assistantNits}\n${assistantWrite}\n`);
|
||||
// Reconnect replays both within the sinceSeconds overlap — filter should drop them.
|
||||
const out2 = filter.filter(`${assistantNits}\n${assistantWrite}\n`);
|
||||
expect(out2).toBe("");
|
||||
// And a genuinely new event after the replay should still pass through.
|
||||
const assistantFresh = assistantEvent("msg_fresh", "next turn");
|
||||
expect(filter.filter(`${assistantFresh}\n`)).toBe(`${assistantFresh}\n`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Line-level dedup filter for the K8s log stream.
|
||||
*
|
||||
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
|
||||
* window (integer-second granularity + a safety buffer), which replays a few
|
||||
* seconds of recent output on every reconnect. Without dedup those replayed
|
||||
* lines appear as duplicate events in the streaming UI — the same assistant
|
||||
* text block shows up between every subsequent tool call (FAR-123).
|
||||
*
|
||||
* The filter operates at the chunk → line level: chunks are split on `\n`,
|
||||
* incomplete trailing content is buffered until the next chunk, and each
|
||||
* complete line is emitted at most once. JSON-shaped Claude stream-json
|
||||
* events are keyed by their stable structural IDs; non-JSON lines pass
|
||||
* through unchanged so genuinely-repeated status lines are not swallowed.
|
||||
*/
|
||||
|
||||
type Parsed = Record<string, unknown>;
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Parsed | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for a Claude stream-json event. Returns `null`
|
||||
* when the event is not a recognized Claude event — those lines fall back to
|
||||
* raw-content hashing so non-JSON output (paperclip status lines, shell
|
||||
* output) is never deduped by identity.
|
||||
*/
|
||||
export function eventDedupKey(event: Parsed): string | null {
|
||||
const type = asString(event.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype);
|
||||
const sessionId = asString(event.session_id);
|
||||
if (subtype === "init" && sessionId) return `system:init:${sessionId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(event.message);
|
||||
const id = message ? asString(message.id) : "";
|
||||
if (id) return `assistant:${id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message = asRecord(event.message);
|
||||
const content = message && Array.isArray(message.content) ? message.content : [];
|
||||
const toolUseIds: string[] = [];
|
||||
for (const entry of content) {
|
||||
const block = asRecord(entry);
|
||||
if (!block) continue;
|
||||
const toolUseId = asString(block.tool_use_id);
|
||||
if (toolUseId) toolUseIds.push(toolUseId);
|
||||
}
|
||||
if (toolUseIds.length > 0) return `user:tool_result:${toolUseIds.join(",")}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const sessionId = asString(event.session_id);
|
||||
return sessionId ? `result:${sessionId}` : "result:unknown";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
|
||||
* the caller — preserves original chunk formatting (including trailing
|
||||
* newlines) for lines that pass the dedup check.
|
||||
*/
|
||||
export class LogLineDedupFilter {
|
||||
private buffer = "";
|
||||
private readonly seenKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Process a chunk and return the subset that should be forwarded.
|
||||
* Incomplete trailing content (no terminating newline) is buffered and
|
||||
* emitted on the next chunk that completes the line (or on flush()).
|
||||
*/
|
||||
filter(chunk: string): string {
|
||||
if (!chunk) return "";
|
||||
const combined = this.buffer + chunk;
|
||||
const endsWithNewline = combined.endsWith("\n");
|
||||
const parts = combined.split("\n");
|
||||
|
||||
if (endsWithNewline) {
|
||||
// Discard the final empty element — last line was complete.
|
||||
parts.pop();
|
||||
this.buffer = "";
|
||||
} else {
|
||||
// Last element is an incomplete line — hold it for the next chunk.
|
||||
this.buffer = parts.pop() ?? "";
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const line of parts) {
|
||||
if (this.shouldEmit(line)) out.push(line);
|
||||
}
|
||||
if (out.length === 0) return "";
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any incomplete trailing content. Called when the stream ends
|
||||
* without a terminating newline so the final partial line isn't lost.
|
||||
*/
|
||||
flush(): string {
|
||||
const pending = this.buffer;
|
||||
this.buffer = "";
|
||||
if (!pending) return "";
|
||||
return this.shouldEmit(pending) ? pending : "";
|
||||
}
|
||||
|
||||
private shouldEmit(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
// Only attempt dedup on JSON-shaped lines; pass shell/text output through.
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const event = asRecord(parsed);
|
||||
if (!event) return true;
|
||||
|
||||
// Recognized Claude stream-json event → structural key.
|
||||
const structuralKey = eventDedupKey(event);
|
||||
const key = structuralKey ?? `raw:${trimmed}`;
|
||||
|
||||
if (this.seenKeys.has(key)) return false;
|
||||
this.seenKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,19 @@ more raw output`;
|
||||
expect(result.summary).toContain("JSON output");
|
||||
expect(result.summary).not.toContain("some raw output");
|
||||
});
|
||||
|
||||
it("deduplicates identical assistant text blocks from reconnect replays", () => {
|
||||
const assistantEvent = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Hello world" }] },
|
||||
});
|
||||
// Simulate the same assistant event appearing twice (log stream reconnect replay)
|
||||
const stdout = `${assistantEvent}\n${assistantEvent}\n`;
|
||||
const result = parseClaudeStreamJson(stdout);
|
||||
expect(result.summary).toBe("Hello world");
|
||||
// Should not be "Hello world\n\nHello world"
|
||||
expect(result.summary.split("Hello world").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractClaudeLoginUrl", () => {
|
||||
|
||||
+7
-1
@@ -9,6 +9,9 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
let model = "";
|
||||
let finalResult: Record<string, unknown> | null = null;
|
||||
const assistantTexts: string[] = [];
|
||||
// Belt-and-braces dedup: track seen text blocks to filter duplicates
|
||||
// caused by log stream reconnects replaying overlapping windows.
|
||||
const seenTexts = new Set<string>();
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
@@ -32,7 +35,10 @@ export function parseClaudeStreamJson(stdout: string) {
|
||||
const block = entry as Record<string, unknown>;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text) assistantTexts.push(text);
|
||||
if (text && !seenTexts.has(text)) {
|
||||
seenTexts.add(text);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user