From c55d6c61fc8b1ee41daa1275c4dea7ee395050e2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 24 Apr 2026 14:14:10 +0000 Subject: [PATCH] feat: declare hasOutOfProcessLiveness and remove onSpawn workarounds (FAR-24) - Add `hasOutOfProcessLiveness: true` to createServerAdapter() so the reaper skips local PID checks and uses the staleness window instead. - Remove the initial onSpawn call and all periodic keepalive onSpawn refreshes that were compensating for the missing flag. - Remove POST_TERMINAL_KEEPALIVE_MS constant and keepaliveTick counter that backed those workarounds. - Cast required: adapter-utils ServerAdapterModule type predates this field. - Bump to 0.1.38. Co-Authored-By: Paperclip --- .../lcov-report/src/cli/format-event.ts.html | 502 ++++++++++++++++ coverage/lcov-report/src/cli/index.html | 131 ++++ coverage/lcov-report/src/cli/index.ts.html | 88 +++ .../src/server/config-schema.ts.html | 547 +++++++++++++++++ .../lcov-report/src/server/log-dedup.ts.html | 523 ++++++++++++++++ .../lcov-report/src/server/models.ts.html | 178 ++++++ .../src/server/prompt-cache.ts.html | 562 ++++++++++++++++++ .../lcov-report/src/server/skills.ts.html | 388 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/server/execute.ts | 77 +-- src/server/index.ts | 6 +- 12 files changed, 2929 insertions(+), 79 deletions(-) create mode 100644 coverage/lcov-report/src/cli/format-event.ts.html create mode 100644 coverage/lcov-report/src/cli/index.html create mode 100644 coverage/lcov-report/src/cli/index.ts.html create mode 100644 coverage/lcov-report/src/server/config-schema.ts.html create mode 100644 coverage/lcov-report/src/server/log-dedup.ts.html create mode 100644 coverage/lcov-report/src/server/models.ts.html create mode 100644 coverage/lcov-report/src/server/prompt-cache.ts.html create mode 100644 coverage/lcov-report/src/server/skills.ts.html diff --git a/coverage/lcov-report/src/cli/format-event.ts.html b/coverage/lcov-report/src/cli/format-event.ts.html new file mode 100644 index 0000000..ae35e92 --- /dev/null +++ b/coverage/lcov-report/src/cli/format-event.ts.html @@ -0,0 +1,502 @@ + + + + + + Code coverage report for src/cli/format-event.ts + + + + + + + + + +
+
+

All files / src/cli format-event.ts

+
+ +
+ 79.78% + Statements + 75/94 +
+ + +
+ 64.13% + Branches + 93/145 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 84.52% + Lines + 71/84 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140  +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +  +  +  +  +13x +13x +  +12x +12x +12x +  +2x +2x +  +  +10x +  +13x +1x +1x +1x +1x +  +  +9x +  +3x +  +  +3x +3x +3x +3x +3x +3x +1x +1x +2x +1x +1x +1x +1x +1x +1x +1x +  +  +  +3x +  +  +6x +  +2x +  +  +2x +2x +2x +2x +2x +2x +  +  +2x +  +  +4x +  +2x +  +  +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +  +2x +2x +1x +1x +1x +  +  +2x +  +  +  +  +2x +  +  +2x +1x +  +  + 
import pc from "picocolors";
+ 
+function asErrorText(value: unknown): string {
+  Eif (typeof value === "string") return value;
+  if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
+  const obj = value as Record<string, unknown>;
+  const message =
+    (typeof obj.message === "string" && obj.message) ||
+    (typeof obj.error === "string" && obj.error) ||
+    (typeof obj.code === "string" && obj.code) ||
+    "";
+  Iif (message) return message;
+  try {
+    return JSON.stringify(obj);
+  } catch {
+    return "";
+  }
+}
+ 
+function printToolResult(block: Record<string, unknown>): void {
+  const isError = block.is_error === true;
+  let text = "";
+  if (typeof block.content === "string") {
+    text = block.content;
+  } else if (EArray.isArray(block.content)) {
+    const parts: string[] = [];
+    for (const part of block.content) {
+      if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
+      const record = part as Record<string, unknown>;
+      if (typeof record.text === "string") parts.push(record.text);
+    }
+    text = parts.join("\n");
+  }
+ 
+  console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
+  Eif (text) {
+    console.log((isError ? pc.red : pc.gray)(text));
+  }
+}
+ 
+export function printClaudeStreamEvent(raw: string, debug: boolean): void {
+  const line = raw.trim();
+  if (!line) return;
+ 
+  let parsed: Record<string, unknown> | null = null;
+  try {
+    parsed = JSON.parse(line) as Record<string, unknown>;
+  } catch {
+    console.log(line);
+    return;
+  }
+ 
+  const type = typeof parsed.type === "string" ? parsed.type : "";
+ 
+  if (type === "system" && parsed.subtype === "init") {
+    const model = typeof parsed.model === "string" ? parsed.model : "unknown";
+    const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
+    console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
+    return;
+  }
+ 
+  if (type === "assistant") {
+    const message =
+      typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
+        ? (parsed.message as Record<string, unknown>)
+        : {};
+    const content = Array.isArray(message.content) ? message.content : [];
+    for (const blockRaw of content) {
+      Iif (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
+      const block = blockRaw as Record<string, unknown>;
+      const blockType = typeof block.type === "string" ? block.type : "";
+      if (blockType === "text") {
+        const text = typeof block.text === "string" ? block.text : "";
+        Eif (text) console.log(pc.green(`assistant: ${text}`));
+      } else if (blockType === "thinking") {
+        const text = typeof block.thinking === "string" ? block.thinking : "";
+        Eif (text) console.log(pc.gray(`thinking: ${text}`));
+      } else if (EblockType === "tool_use") {
+        const name = typeof block.name === "string" ? block.name : "unknown";
+        console.log(pc.yellow(`tool_call: ${name}`));
+        Eif (block.input !== undefined) {
+          console.log(pc.gray(JSON.stringify(block.input, null, 2)));
+        }
+      }
+    }
+    return;
+  }
+ 
+  if (type === "user") {
+    const message =
+      typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
+        ? (parsed.message as Record<string, unknown>)
+        : {};
+    const content = Array.isArray(message.content) ? message.content : [];
+    for (const blockRaw of content) {
+      Iif (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
+      const block = blockRaw as Record<string, unknown>;
+      Eif (typeof block.type === "string" && block.type === "tool_result") {
+        printToolResult(block);
+      }
+    }
+    return;
+  }
+ 
+  if (type === "result") {
+    const usage =
+      typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
+        ? (parsed.usage as Record<string, unknown>)
+        : {};
+    const input = Number(usage.input_tokens ?? 0);
+    const output = Number(usage.output_tokens ?? 0);
+    const cached = Number(usage.cache_read_input_tokens ?? 0);
+    const cost = Number(parsed.total_cost_usd ?? 0);
+    const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
+    const isError = parsed.is_error === true;
+    const resultText = typeof parsed.result === "string" ? parsed.result : "";
+    if (resultText) {
+      console.log(pc.green("result:"));
+      console.log(resultText);
+    }
+    const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
+    if (subtype.startsWith("error") || isError || errors.length > 0) {
+      console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
+      Eif (errors.length > 0) {
+        console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
+      }
+    }
+    console.log(
+      pc.blue(
+        `tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
+      ),
+    );
+    return;
+  }
+ 
+  if (debug) {
+    console.log(pc.gray(line));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/cli/index.html b/coverage/lcov-report/src/cli/index.html new file mode 100644 index 0000000..27d191a --- /dev/null +++ b/coverage/lcov-report/src/cli/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/cli + + + + + + + + + +
+
+

All files src/cli

+
+ +
+ 79.78% + Statements + 75/94 +
+ + +
+ 64.13% + Branches + 93/145 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 84.52% + Lines + 71/84 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
format-event.ts +
+
79.78%75/9464.13%93/145100%3/384.52%71/84
index.ts +
+
0%0/00%0/00%0/00%0/0
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/cli/index.ts.html b/coverage/lcov-report/src/cli/index.ts.html new file mode 100644 index 0000000..73e8737 --- /dev/null +++ b/coverage/lcov-report/src/cli/index.ts.html @@ -0,0 +1,88 @@ + + + + + + Code coverage report for src/cli/index.ts + + + + + + + + + +
+
+

All files / src/cli index.ts

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2  + 
export { printClaudeStreamEvent } from "./format-event.js";
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/server/config-schema.ts.html b/coverage/lcov-report/src/server/config-schema.ts.html new file mode 100644 index 0000000..a223b05 --- /dev/null +++ b/coverage/lcov-report/src/server/config-schema.ts.html @@ -0,0 +1,547 @@ + + + + + + Code coverage report for src/server/config-schema.ts + + + + + + + + + +
+
+

All files / src/server config-schema.ts

+
+ +
+ 100% + Statements + 2/2 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 2/2 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  + 
// NOTE: These types must match what Paperclip's SchemaConfigFields component
+// expects. Paperclip's server at GET /api/adapters/:type/config-schema
+// calls adapter.getConfigSchema() and the UI reads the JSON — types are only
+// used at build time here. The Paperclip types in @paperclipai/adapter-utils
+// may lag behind; these locals are the source of truth for this adapter.
+ 
+interface ConfigFieldOption {
+  label: string;
+  value: string;
+  group?: string;
+}
+ 
+type ConfigFieldSchema =
+  | { type: "text"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+  | { type: "number"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+  | { type: "toggle"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+  | { type: "select"; key: string; label: string; hint?: string; options: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> }
+  | { type: "textarea"; key: string; label: string; hint?: string; default?: unknown; meta?: Record<string, unknown> }
+  | { type: "combobox"; key: string; label: string; hint?: string; options?: ConfigFieldOption[]; default?: unknown; meta?: Record<string, unknown> };
+ 
+interface AdapterConfigSchema {
+  fields: ConfigFieldSchema[];
+}
+ 
+export function getConfigSchema(): AdapterConfigSchema {
+  // model, effort, instructionsFilePath, timeoutSec, graceSec are provided
+  // by the platform UI and must not be duplicated here.
+  const fields: ConfigFieldSchema[] = [
+    // Core Claude fields
+    {
+      type: "number",
+      key: "maxTurnsPerRun",
+      label: "Max Turns Per Run",
+      hint: "Maximum number of agentic turns (tool calls) per heartbeat run. 0 means unlimited.",
+      default: 1000,
+    },
+    // Kubernetes
+    {
+      type: "text",
+      key: "serviceAccountName",
+      label: "Service Account",
+      hint: "Service Account name for Job pods. Defaults to the cluster default.",
+    },
+    {
+      type: "text",
+      key: "namespace",
+      label: "Namespace",
+      hint: "Kubernetes namespace for Jobs. Defaults to the Deployment namespace.",
+    },
+    {
+      type: "text",
+      key: "image",
+      label: "Container Image",
+      hint: "Override the container image used for Job pods. Defaults to the running Deployment image.",
+    },
+    {
+      type: "select",
+      key: "imagePullPolicy",
+      label: "Image Pull Policy",
+      hint: "Image pull policy for the container image.",
+      options: [
+        { value: "IfNotPresent", label: "IfNotPresent" },
+        { value: "Always", label: "Always" },
+        { value: "Never", label: "Never" },
+      ],
+    },
+    {
+      type: "text",
+      key: "kubeconfig",
+      label: "Kubeconfig Path",
+      hint: "Absolute path to a kubeconfig file on disk. Defaults to in-cluster service account auth.",
+    },
+    {
+      type: "number",
+      key: "ttlSecondsAfterFinished",
+      label: "TTL Seconds After Finished",
+      hint: "Auto-cleanup delay for completed Jobs in seconds. Default: 300.",
+    },
+    {
+      type: "toggle",
+      key: "retainJobs",
+      label: "Retain Jobs",
+      hint: "Skip cleanup of completed Jobs for debugging purposes.",
+    },
+    {
+      type: "toggle",
+      key: "reattachOrphanedJobs",
+      label: "Reattach to Orphaned Jobs",
+      hint: "If a prior K8s Job for the same agent/task/session is still running (e.g. Paperclip restarted mid-run), attach to it and stream its output instead of blocking the new run. When false, any non-terminal orphan blocks the new run. Default: on.",
+      default: true,
+    },
+    // Resource Limits
+    {
+      type: "text",
+      key: "resources.requests.cpu",
+      label: "CPU Request",
+      hint: "CPU request for Job pods (e.g. 100m, 0.5, 1).",
+    },
+    {
+      type: "text",
+      key: "resources.requests.memory",
+      label: "Memory Request",
+      hint: "Memory request for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
+    },
+    {
+      type: "text",
+      key: "resources.limits.cpu",
+      label: "CPU Limit",
+      hint: "CPU limit for Job pods (e.g. 100m, 0.5, 1).",
+    },
+    {
+      type: "text",
+      key: "resources.limits.memory",
+      label: "Memory Limit",
+      hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
+    },
+    // Scheduling
+    {
+      type: "textarea",
+      key: "nodeSelector",
+      label: "Node Selector",
+      hint: "Node selector for Job pods. One key=value per line (e.g. disktype=ssd).",
+    },
+    {
+      type: "textarea",
+      key: "tolerations",
+      label: "Tolerations",
+      hint: "Tolerations for Job pods as JSON array.",
+    },
+    {
+      type: "textarea",
+      key: "labels",
+      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 };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/server/log-dedup.ts.html b/coverage/lcov-report/src/server/log-dedup.ts.html new file mode 100644 index 0000000..39b65ce --- /dev/null +++ b/coverage/lcov-report/src/server/log-dedup.ts.html @@ -0,0 +1,523 @@ + + + + + + Code coverage report for src/server/log-dedup.ts + + + + + + + + + +
+
+

All files / src/server log-dedup.ts

+
+ +
+ 89.33% + Statements + 67/75 +
+ + +
+ 80.32% + Branches + 49/61 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 95.08% + Lines + 58/61 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +147x +  +  +  +82x +82x +  +  +  +  +  +  +  +  +  +65x +  +65x +21x +21x +21x +  +  +  +44x +18x +18x +18x +1x +  +  +26x +3x +3x +3x +3x +3x +3x +3x +3x +  +3x +  +  +  +23x +19x +19x +  +  +4x +  +  +  +  +  +  +  +  +27x +27x +  +  +  +  +  +  +  +39x +39x +39x +39x +  +39x +  +35x +35x +  +  +4x +  +  +39x +39x +58x +  +39x +30x +  +  +  +  +  +  +  +20x +20x +20x +2x +  +  +  +60x +60x +  +  +60x +  +  +58x +58x +  +  +  +  +58x +58x +  +  +58x +58x +  +60x +48x +48x +  +  + 
/**
+ * 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 {
+  Iif (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);
+    Eif (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);
+      Iif (!block) continue;
+      const toolUseId = asString(block.tool_use_id);
+      Eif (toolUseId) toolUseIds.push(toolUseId);
+    }
+    Eif (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 {
+    Iif (!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();
+    Iif (!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);
+    Iif (!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;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/server/models.ts.html b/coverage/lcov-report/src/server/models.ts.html new file mode 100644 index 0000000..7cc8794 --- /dev/null +++ b/coverage/lcov-report/src/server/models.ts.html @@ -0,0 +1,178 @@ + + + + + + Code coverage report for src/server/models.ts + + + + + + + + + +
+
+

All files / src/server models.ts

+
+ +
+ 100% + Statements + 4/4 +
+ + +
+ 100% + Branches + 6/6 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 4/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +5x +  + 
import type { AdapterModel } from "@paperclipai/adapter-utils";
+ 
+const DIRECT_MODELS: AdapterModel[] = [
+  { id: "claude-opus-4-7", label: "Claude Opus 4.7" },
+  { 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" },
+];
+ 
+const BEDROCK_MODELS: AdapterModel[] = [
+  { id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" },
+  { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
+  { id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
+  { id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
+  { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
+];
+ 
+function isBedrockEnv(): boolean {
+  return (
+    process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
+    process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
+    (typeof process.env.ANTHROPIC_BEDROCK_BASE_URL === "string" &&
+      process.env.ANTHROPIC_BEDROCK_BASE_URL.trim().length > 0)
+  );
+}
+ 
+export async function listK8sModels(): Promise<AdapterModel[]> {
+  return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/server/prompt-cache.ts.html b/coverage/lcov-report/src/server/prompt-cache.ts.html new file mode 100644 index 0000000..adc3994 --- /dev/null +++ b/coverage/lcov-report/src/server/prompt-cache.ts.html @@ -0,0 +1,562 @@ + + + + + + Code coverage report for src/server/prompt-cache.ts + + + + + + + + + +
+
+

All files / src/server prompt-cache.ts

+
+ +
+ 34.88% + Statements + 30/86 +
+ + +
+ 47.82% + Branches + 22/46 +
+ + +
+ 30.76% + Functions + 4/13 +
+ + +
+ 34.66% + Lines + 26/75 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +9x +7x +4x +3x +  +  +  +8x +  +  +  +  +8x +  +  +  +8x +8x +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +  +  +  +  +8x +  +8x +8x +  +  +  +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +8x +8x +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +8x +  +  +  +1x +  + 
import { constants as fsConstants } from "node:fs";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { createHash } from "node:crypto";
+import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
+import {
+  type PaperclipSkillEntry,
+  ensurePaperclipSkillSymlink,
+} from "@paperclipai/adapter-utils/server-utils";
+ 
+export interface ClaudePromptBundle {
+  bundleKey: string;
+  /** Absolute path to the bundle root directory (contains .claude/skills/ and agent-instructions.md). */
+  rootDir: string;
+  /** Value to pass as --add-dir to the Claude CLI. */
+  addDir: string;
+  /** Path to the materialized instructions file, or null if no instructions were provided. */
+  instructionsFilePath: string | null;
+}
+ 
+const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
+ 
+function validatePathComponent(value: string, fieldName: string): void {
+  if (value.trim().length === 0) throw new Error(`Invalid ${fieldName}: must not be empty`);
+  if (value.includes("/") || value.includes("\\")) throw new Error(`Invalid ${fieldName}: must not contain path separators`);
+  if (value.includes("..")) throw new Error(`Invalid ${fieldName}: must not contain ".."`);
+  if (value.includes("\0")) throw new Error(`Invalid ${fieldName}: must not contain null bytes`);
+}
+ 
+function resolveManagedClaudePromptCacheRoot(companyId: string): string {
+  const paperclipHome =
+    (typeof process.env.PAPERCLIP_HOME === "string" && process.env.PAPERCLIP_HOME.trim().length > 0
+      ? process.env.PAPERCLIP_HOME.trim()
+      : null) ??
+    path.resolve(os.homedir(), ".paperclip");
+  const instanceId =
+    (typeof process.env.PAPERCLIP_INSTANCE_ID === "string" && process.env.PAPERCLIP_INSTANCE_ID.trim().length > 0
+      ? process.env.PAPERCLIP_INSTANCE_ID.trim()
+      : null) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
+  validatePathComponent(companyId, "companyId");
+  validatePathComponent(instanceId, "instanceId");
+  return path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-prompt-cache");
+}
+ 
+async function hashPathContents(
+  candidate: string,
+  hash: ReturnType<typeof createHash>,
+  relativePath: string,
+  seenDirectories: Set<string>,
+): Promise<void> {
+  const stat = await fs.lstat(candidate);
+  if (stat.isSymbolicLink()) {
+    hash.update(`symlink:${relativePath}\n`);
+    const resolved = await fs.realpath(candidate).catch(() => null);
+    if (!resolved) {
+      hash.update("missing\n");
+      return;
+    }
+    await hashPathContents(resolved, hash, relativePath, seenDirectories);
+    return;
+  }
+  if (stat.isDirectory()) {
+    const realDir = await fs.realpath(candidate).catch(() => candidate);
+    hash.update(`dir:${relativePath}\n`);
+    if (seenDirectories.has(realDir)) {
+      hash.update("loop\n");
+      return;
+    }
+    seenDirectories.add(realDir);
+    const entries = await fs.readdir(candidate, { withFileTypes: true });
+    entries.sort((a, b) => a.name.localeCompare(b.name));
+    for (const entry of entries) {
+      const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
+      await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
+    }
+    return;
+  }
+  if (stat.isFile()) {
+    hash.update(`file:${relativePath}\n`);
+    hash.update(await fs.readFile(candidate));
+    hash.update("\n");
+    return;
+  }
+  hash.update(`other:${relativePath}:${stat.mode}\n`);
+}
+ 
+async function buildClaudePromptBundleKey(input: {
+  skills: PaperclipSkillEntry[];
+  instructionsContents: string | null;
+}): Promise<string> {
+  const hash = createHash("sha256");
+  hash.update("paperclip-claude-prompt-bundle:v1\n");
+  Iif (input.instructionsContents) {
+    hash.update("instructions\n");
+    hash.update(input.instructionsContents);
+    hash.update("\n");
+  } else {
+    hash.update("instructions:none\n");
+  }
+  const sortedSkills = [...input.skills].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName));
+  for (const entry of sortedSkills) {
+    hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
+    await hashPathContents(entry.source, hash, entry.runtimeName, new Set());
+  }
+  return hash.digest("hex");
+}
+ 
+async function ensureReadableFile(targetPath: string, contents: string): Promise<void> {
+  try {
+    await fs.access(targetPath, fsConstants.R_OK);
+    return;
+  } catch {
+    // Fall through and materialize the file.
+  }
+  await fs.mkdir(path.dirname(targetPath), { recursive: true });
+  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
+  try {
+    await fs.writeFile(tempPath, contents, "utf8");
+    await fs.rename(tempPath, targetPath);
+  } catch (err) {
+    const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false);
+    if (!targetReadable) throw err;
+  } finally {
+    await fs.rm(tempPath, { force: true }).catch(() => {});
+  }
+}
+ 
+export async function prepareClaudePromptBundle(input: {
+  companyId: string;
+  skills: PaperclipSkillEntry[];
+  instructionsContents: string | null;
+  onLog: AdapterExecutionContext["onLog"];
+}): Promise<ClaudePromptBundle> {
+  const { companyId, skills, instructionsContents, onLog } = input;
+  const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents });
+  const rootDir = path.join(resolveManagedClaudePromptCacheRoot(companyId), bundleKey);
+  const skillsHome = path.join(rootDir, ".claude", "skills");
+  await fs.mkdir(skillsHome, { recursive: true });
+ 
+  for (const entry of skills) {
+    const target = path.join(skillsHome, entry.runtimeName);
+    try {
+      await ensurePaperclipSkillSymlink(entry.source, target);
+    } catch (err) {
+      await onLog(
+        "stderr",
+        `[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
+      );
+    }
+  }
+ 
+  const instructionsFilePath = instructionsContents ? path.join(rootDir, "agent-instructions.md") : null;
+  Iif (instructionsFilePath && instructionsContents) {
+    await ensureReadableFile(instructionsFilePath, instructionsContents);
+  }
+ 
+  return { bundleKey, rootDir, addDir: rootDir, instructionsFilePath };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/server/skills.ts.html b/coverage/lcov-report/src/server/skills.ts.html new file mode 100644 index 0000000..13a1751 --- /dev/null +++ b/coverage/lcov-report/src/server/skills.ts.html @@ -0,0 +1,388 @@ + + + + + + Code coverage report for src/server/skills.ts + + + + + + + + + +
+
+

All files / src/server skills.ts

+
+ +
+ 100% + Statements + 25/25 +
+ + +
+ 88.88% + Branches + 16/18 +
+ + +
+ 100% + Functions + 7/7 +
+ + +
+ 100% + Lines + 19/19 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +9x +9x +9x +9x +9x +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +  +9x +5x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +2x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +  +9x +  +  +  +  +  +  +  +  +  +  +8x +  +  +  +  +  +  +1x +  + 
import type {
+  AdapterSkillContext,
+  AdapterSkillSnapshot,
+  AdapterSkillEntry,
+} from "@paperclipai/adapter-utils";
+import {
+  readPaperclipRuntimeSkillEntries,
+  resolvePaperclipDesiredSkillNames,
+  readInstalledSkillTargets,
+} from "@paperclipai/adapter-utils/server-utils";
+import path from "node:path";
+ 
+const SKILLS_HOME = "/paperclip/.claude/skills";
+ 
+async function buildK8sSkillSnapshot(
+  config: Record<string, unknown>,
+): Promise<AdapterSkillSnapshot> {
+  const availableEntries = await readPaperclipRuntimeSkillEntries(config, import.meta.dirname ?? __dirname);
+  const availableByKey = new Map(availableEntries.map((e) => [e.key, e]));
+  const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
+  const desiredSet = new Set(desiredSkills);
+  const installed = await readInstalledSkillTargets(SKILLS_HOME);
+ 
+  const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
+    key: entry.key,
+    runtimeName: entry.runtimeName,
+    desired: desiredSet.has(entry.key),
+    managed: true,
+    state: desiredSet.has(entry.key) ? "configured" : "available",
+    origin: entry.required ? "paperclip_required" : "company_managed",
+    originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
+    readOnly: false,
+    sourcePath: entry.source,
+    targetPath: null,
+    detail: desiredSet.has(entry.key)
+      ? "Materialized into the PVC-backed Claude prompt bundle before each K8s Job run."
+      : null,
+    required: Boolean(entry.required),
+    requiredReason: entry.requiredReason ?? null,
+  }));
+ 
+  const warnings: string[] = [];
+ 
+  for (const desiredSkill of desiredSkills) {
+    if (availableByKey.has(desiredSkill)) continue;
+    warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
+    entries.push({
+      key: desiredSkill,
+      runtimeName: null,
+      desired: true,
+      managed: true,
+      state: "missing",
+      origin: "external_unknown",
+      originLabel: "External or unavailable",
+      readOnly: false,
+      sourcePath: undefined,
+      targetPath: undefined,
+      detail: "Paperclip cannot find this skill in the runtime skills directory.",
+    });
+  }
+ 
+  for (const [name, installedEntry] of installed.entries()) {
+    if (availableEntries.some((e) => e.runtimeName === name)) continue;
+    entries.push({
+      key: name,
+      runtimeName: name,
+      desired: false,
+      managed: false,
+      state: "external",
+      origin: "user_installed",
+      originLabel: "User-installed",
+      locationLabel: "~/.claude/skills",
+      readOnly: true,
+      sourcePath: null,
+      targetPath: installedEntry.targetPath ?? path.join(SKILLS_HOME, name),
+      detail: "Installed outside Paperclip management in the Claude skills home.",
+    });
+  }
+ 
+  entries.sort((a, b) => a.key.localeCompare(b.key));
+ 
+  return {
+    adapterType: "claude_k8s",
+    supported: true,
+    mode: "ephemeral",
+    desiredSkills,
+    entries,
+    warnings,
+  };
+}
+ 
+export async function listK8sSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
+  return buildK8sSkillSnapshot(ctx.config);
+}
+ 
+export async function syncK8sSkills(
+  ctx: AdapterSkillContext,
+  _desiredSkills: string[],
+): Promise<AdapterSkillSnapshot> {
+  return buildK8sSkillSnapshot(ctx.config);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 78b8b6b..6cb0a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paperclip-adapter-claude-k8s", - "version": "0.1.37", + "version": "0.1.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paperclip-adapter-claude-k8s", - "version": "0.1.37", + "version": "0.1.38", "license": "MIT", "dependencies": { "@kubernetes/client-node": "^1.0.0", diff --git a/package.json b/package.json index eced5c1..600308d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paperclip-adapter-claude-k8s", - "version": "0.1.37", + "version": "0.1.38", "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 67e6c8d..868d256 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -26,10 +26,6 @@ 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; -// How long to keep refreshing onSpawn after the Job reaches a terminal state. -// Covers the cleanup path (delete job, parse stdout) so a slow K8s API call -// doesn't trip the 5-minute reaper staleness window. -const POST_TERMINAL_KEEPALIVE_MS = 90_000; // Upper bound on how long streamPodLogsOnce will wait after stopSignal fires // before force-returning, even if logApi.log has not yet resolved. Defensive // against the K8s client library not propagating writable.destroy() into an @@ -932,16 +928,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { // Fire-and-forget the async work; setInterval callbacks must be // synchronous or the timer will drift. void (async () => { - if (keepaliveJobTerminal) { - // Post-terminal window: keep refreshing onSpawn during cleanup - // (job deletion, log parsing, K8s API calls) so the reaper doesn't - // fire a false process_lost while execute() is still running. - if ( - ctx.onSpawn && - keepaliveJobTerminalAt !== null && - Date.now() - keepaliveJobTerminalAt <= POST_TERMINAL_KEEPALIVE_MS - ) { - keepaliveTick++; - if (keepaliveTick % 6 === 0) { - void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {}); - } - } - return; - } + if (keepaliveJobTerminal) return; // Verify the Job is still alive before announcing or refreshing. // Require two consecutive terminal readings before latching to @@ -1021,16 +972,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise= 2) { keepaliveJobTerminal = true; - keepaliveJobTerminalAt = Date.now(); - if (ctx.onSpawn) { - void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {}); - } - return; - } - // First terminal reading — do not latch yet; next tick confirms. - keepaliveTick++; - if (ctx.onSpawn && (keepaliveTick === 1 || keepaliveTick % 12 === 0)) { - void ctx.onSpawn({ pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() }).catch(() => {}); } return; } @@ -1042,10 +983,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); - } return; } // Log transient errors but leave keepaliveJobTerminal false so @@ -1057,14 +994,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); - - // 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: typeof onLog = async (stream, chunk) => { @@ -1132,9 +1061,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise