From c05d1d7515b45ecd0c1d80971e214014d27597aa Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 24 Apr 2026 22:14:36 +0000 Subject: [PATCH] feat: log stream reconnect, dedup, bail, keepalive (FAR-38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split streamPodLogs into streamPodLogsOnce (with bail timer + stopSignal) and streamPodLogs (reconnect loop, up to MAX_LOG_RECONNECT_ATTEMPTS=50) - LogLineDedupFilter suppresses replayed JSONL events on reconnect, keyed by type+sessionID+part.id (OpenCode shape) - Bail timer (LOG_STREAM_BAIL_TIMEOUT_MS=3s) forces writable.destroy() + promise resolution when stopSignal fires and logApi.log hangs - Keepalive: emits '[paperclip] keepalive — job X running (Ns since last output)' every 15s during silent phases, with 2-consecutive-reading latch to avoid false-positive terminal detections - completionGraced uses logExitTime + grace poller so log stream stop signal is set immediately when job condition resolves - All 235 tests pass, tsc clean Co-Authored-By: Paperclip --- src/server/execute.ts | 91 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/src/server/execute.ts b/src/server/execute.ts index 9dd029f..92bd2c7 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -8,7 +8,7 @@ import { isOpenCodeStepLimitResult, } from "./parse.js"; import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js"; -import { buildJobManifest } from "./job-manifest.js"; +import { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES } from "./job-manifest.js"; import { LogLineDedupFilter } from "./log-dedup.js"; import type * as k8s from "@kubernetes/client-node"; import { Writable } from "node:stream"; @@ -387,6 +387,7 @@ async function cleanupJob( jobName: string, onLog: AdapterExecutionContext["onLog"], kubeconfigPath?: string, + promptSecretName?: string, ): Promise { try { const batchApi = getBatchApi(kubeconfigPath); @@ -399,6 +400,14 @@ async function cleanupJob( const msg = err instanceof Error ? err.message : String(err); await onLog("stderr", `[paperclip] Warning: failed to cleanup job ${jobName}: ${msg}\n`); } + if (promptSecretName) { + try { + const coreApi = getCoreApi(kubeconfigPath); + await coreApi.deleteNamespacedSecret({ name: promptSecretName, namespace }); + } catch { + // best-effort — Secret may already be GC'd via ownerReference + } + } } export async function execute(ctx: AdapterExecutionContext): Promise { @@ -472,12 +481,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise LARGE_PROMPT_THRESHOLD_BYTES) { + promptSecretName = `${jobName}-prompt`; + job = buildJobManifest({ ...buildArgs, promptSecretName }).job; + } if (onMeta) { await onMeta({ @@ -497,10 +517,44 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? `${timeoutSec}s` : "none"})\n`); let stdout = ""; @@ -670,7 +751,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise