From e760bf93866cac94c4cf9f12525f4d1d4eb2e1c8 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sun, 12 Apr 2026 18:44:09 +0000 Subject: [PATCH] Add keepalive pings during job execution to prevent UI timeout desync The adapter had no mechanism to signal liveness while a K8s Job was running. When Claude entered long thinking phases with no log output, the Paperclip UI could lose sync and consider the run stuck even though the pod was still actively working. Adds a 15-second interval keepalive that sends status messages via onLog during execution. The keepalive tracks time since last real log output and reports it, keeping the connection alive. The timer is cleaned up in the finally block to prevent leaks on any exit path. Bumps version to 0.1.11. Co-Authored-By: Paperclip --- package.json | 2 +- src/server/execute.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ca105c2..4dc7dfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@farhoodliquor/paperclip-adapter-claude-k8s", - "version": "0.1.10", + "version": "0.1.11", "description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs", "license": "MIT", "repository": { diff --git a/src/server/execute.ts b/src/server/execute.ts index 5236a03..64e5170 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -12,6 +12,7 @@ import type * as k8s from "@kubernetes/client-node"; import { Writable } from "node:stream"; const POLL_INTERVAL_MS = 2000; +const KEEPALIVE_INTERVAL_MS = 15_000; /** * Wait for the Job's pod to reach a terminal or running state. @@ -331,6 +332,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise | null = null; try { // Wait for pod to be ready for log streaming @@ -357,8 +359,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? (timeoutSec + graceSec) * 1000 : 0; + // 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). + let lastLogAt = Date.now(); + keepaliveTimer = setInterval(() => { + const silenceSec = Math.round((Date.now() - lastLogAt) / 1000); + void onLog("stdout", `[paperclip] keepalive — job ${jobName} running (${silenceSec}s since last output)\n`); + }, KEEPALIVE_INTERVAL_MS); + const wrappedOnLog: typeof onLog = async (stream, chunk) => { + lastLogAt = Date.now(); + return onLog(stream, chunk); + }; + const [logResult, completionResult] = await Promise.allSettled([ - streamPodLogs(namespace, podName, onLog, kubeconfigPath), + streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath), waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath), ]); @@ -384,6 +399,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise