fix: wait for pod termination before readPodLogs fallback (FAR-52)

The @kubernetes/client-node v1.x Log.follow stream closes prematurely
(known upstream TODO). Combined with Node.js buffering stdout to pipes,
the live log stream always returns empty. When the 30s grace timer fires
and the stream is empty, the container may still be running.

Add waitForPodTermination() to block in the empty-stdout fallback path
until the container actually exits (up to 120s), then read its complete
output with readNamespacedPodLog. This makes runs complete successfully
instead of looping indefinitely in in_progress.

Bump version to 0.1.20.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 01:21:40 +00:00
parent bf32e81aa6
commit 2625b8ffb3
2 changed files with 42 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.19",
"version": "0.1.20",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+41
View File
@@ -312,6 +312,42 @@ async function readPodLogs(
}
}
/**
* Wait until the named pod's phase transitions to Succeeded, Failed, or Unknown,
* or until the pod is gone (404). Returns immediately if the pod is already in a
* terminal phase. Used as a pre-flight before readPodLogs when the K8s log stream
* returns empty while the container is still running (Node.js stdout buffering +
* the @kubernetes/client-node v1.x follow-stream known premature-close issue).
*/
async function waitForPodTermination(
namespace: string,
podName: string,
timeoutMs: number,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
): Promise<void> {
const coreApi = getCoreApi(kubeconfigPath);
const deadline = Date.now() + timeoutMs;
let notified = false;
while (Date.now() < deadline) {
try {
const pod = await coreApi.readNamespacedPod({ name: podName, namespace });
const phase = pod.status?.phase;
if (phase === "Succeeded" || phase === "Failed" || phase === "Unknown") return;
if (!notified) {
notified = true;
await onLog(
"stdout",
`[paperclip] Container still running — waiting up to ${Math.round(timeoutMs / 1000)}s for it to exit to capture output...\n`,
);
}
} catch {
return; // Pod gone (404) — nothing left to wait for
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
export type JobCompletionResult = { succeeded: boolean; timedOut: boolean; jobGone: boolean };
async function waitForJobCompletion(
@@ -572,6 +608,11 @@ async function streamAndAwaitJob(
if (!stdout.trim()) {
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
// The K8s client v1.x has a known issue where follow-stream closes prematurely,
// causing the log stream to return empty even when the container is still running.
// Node.js also buffers stdout when writing to a pipe, so logs only flush on exit.
// Wait for the pod to actually terminate before attempting to read its final output.
await waitForPodTermination(namespace, podName, 120_000, onLog, kubeconfigPath);
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
if (stdout.trim()) {
await onLog("stdout", stdout);