@hellcoder/companion 0.109.1 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.109.1",
3
+ "version": "0.110.0",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@ import type {
46
46
  import type { SocketData } from "./ws-bridge-types.js";
47
47
  import type { PendingControlRequest } from "./ws-bridge-types.js";
48
48
  import type { RecorderManager } from "./recorder.js";
49
+ import { captureProcState } from "./proc-diagnostics.js";
49
50
  import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
50
51
  import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
51
52
  import { reportProtocolDrift } from "./protocol-monitor.js";
@@ -332,10 +333,14 @@ export class ClaudeAdapter implements IBackendAdapter {
332
333
  new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_RESULT_GRACE_MS)),
333
334
  ]);
334
335
  if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
336
+ // Capture kernel state BEFORE the kill: once we SIGTERM, the
337
+ // evidence is gone. A wedged CLI writes nothing to stderr, so this
338
+ // is the only signal about why it is still alive.
335
339
  log.warn("claude-adapter", "stdout closed after result but process did not exit within grace; killing wedged process", {
336
340
  sessionId: this.sessionId,
337
341
  pid: proc.pid,
338
342
  graceMs: STDOUT_CLOSE_RESULT_GRACE_MS,
343
+ proc: captureProcState(proc.pid),
339
344
  });
340
345
  try {
341
346
  proc.kill();
@@ -349,10 +354,15 @@ export class ClaudeAdapter implements IBackendAdapter {
349
354
  new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
350
355
  ]);
351
356
  if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
357
+ // Same rationale as the after-result branch above: snapshot kernel
358
+ // state before the kill destroys it. This is the dominant wedge
359
+ // variant (23 of 29 observed), so it is the one most likely to
360
+ // carry the answer.
352
361
  log.warn("claude-adapter", "stdout closed mid-stream and process did not exit within grace; killing wedged process", {
353
362
  sessionId: this.sessionId,
354
363
  pid: proc.pid,
355
364
  graceMs: STDOUT_CLOSE_GRACE_MS,
365
+ proc: captureProcState(proc.pid),
356
366
  });
357
367
  try {
358
368
  proc.kill();
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { captureProcState, isProcAvailable } from "./proc-diagnostics.js";
3
+
4
+ /**
5
+ * These tests cover the diagnostic that runs on the wedge-kill recovery path in
6
+ * claude-adapter.ts. The overriding requirement is that it NEVER throws — it
7
+ * executes immediately before a SIGTERM that unblocks a stuck session, so a
8
+ * diagnostic that raises would turn a recoverable wedge into a dead session.
9
+ *
10
+ * CI runs on both ubuntu-latest and macos-latest, and macOS has no /proc at
11
+ * all, so every test here must pass on a platform where the feature is inert.
12
+ */
13
+
14
+ const isLinux = process.platform === "linux";
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ describe("isProcAvailable", () => {
21
+ // /proc is a Linux-only filesystem; on macOS the whole capture must no-op.
22
+ it("reports availability based on platform", () => {
23
+ expect(isProcAvailable()).toBe(process.platform === "linux");
24
+ });
25
+ });
26
+
27
+ describe("captureProcState", () => {
28
+ it("returns no_pid when the pid is undefined", () => {
29
+ // Bun's Subprocess.pid is optional, so undefined is reachable in practice.
30
+ expect(captureProcState(undefined)).toEqual({ error: "no_pid" });
31
+ });
32
+
33
+ it("never throws for a pid that does not exist", () => {
34
+ // The common race: the process exits between the liveness check and the
35
+ // capture. This must degrade to a marker, not an exception.
36
+ const snapshot = captureProcState(2_147_483_646);
37
+ expect(() => snapshot).not.toThrow();
38
+ if (isLinux) {
39
+ expect(snapshot.error).toBe("process_gone_or_unreadable");
40
+ }
41
+ });
42
+
43
+ it("degrades cleanly on platforms without /proc", () => {
44
+ // Simulate macOS regardless of the host we are running on, so this
45
+ // assertion is meaningful in both CI matrix legs.
46
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
47
+ expect(captureProcState(1)).toEqual({ error: "unsupported_platform:darwin" });
48
+ });
49
+
50
+ it.runIf(isLinux)("captures live kernel state for the current process", () => {
51
+ // Uses our own pid, which is guaranteed to exist and be readable. This is
52
+ // the field set the wedge investigation actually needs: `state` and `wchan`
53
+ // together distinguish a process blocked on a full pipe from one parked in
54
+ // a futex or spinning in userspace.
55
+ const snapshot = captureProcState(process.pid);
56
+
57
+ expect(snapshot.error).toBeUndefined();
58
+ // e.g. "R (running)" or "S (sleeping)" — always parenthesised on Linux.
59
+ expect(snapshot.state).toMatch(/^[A-Z] \(/);
60
+ expect(snapshot.threads).toBeGreaterThan(0);
61
+ expect(snapshot.fdCount).toBeGreaterThan(0);
62
+ // VmRSS is reported in kB by /proc; absent only for kernel threads.
63
+ expect(snapshot.vmRSS).toMatch(/kB$/);
64
+ });
65
+
66
+ it.runIf(isLinux)("returns a plain object safe to embed in a log payload", () => {
67
+ // The snapshot is passed straight into log.warn, which JSON-serialises it.
68
+ const snapshot = captureProcState(process.pid);
69
+ expect(() => JSON.stringify(snapshot)).not.toThrow();
70
+ expect(JSON.stringify(snapshot)).toContain("state");
71
+ });
72
+ });
@@ -0,0 +1,83 @@
1
+ // Kernel-level process diagnostics for wedged CLI processes.
2
+ //
3
+ // When a CLI wedges, its stdout is closed and it emits nothing on stderr (see
4
+ // claude-adapter's wedge handling). With both output channels gone, the only
5
+ // remaining signal about *why* it is still alive is kernel state, so we read it
6
+ // from /proc immediately before killing the process.
7
+ //
8
+ // Everything here is best-effort and must never throw: this runs on a recovery
9
+ // path, and a diagnostic that breaks recovery is worse than no diagnostic.
10
+
11
+ import { readFileSync, readdirSync } from "node:fs";
12
+
13
+ /** A point-in-time snapshot of kernel state for a process. */
14
+ export interface ProcSnapshot {
15
+ /** Scheduler state, e.g. "S (sleeping)", "R (running)", "Z (zombie)", "D (disk sleep)". */
16
+ state?: string;
17
+ /** Thread count. A live-threads-but-no-output process looks different from a zombie. */
18
+ threads?: number;
19
+ /** Resident set size as reported by /proc (e.g. "123456 kB"). */
20
+ vmRSS?: string;
21
+ /**
22
+ * Kernel wait channel — the syscall the process is blocked in. This is the
23
+ * discriminating field: a process blocked writing to a full pipe, parked in a
24
+ * futex, and spinning in userspace are three different bugs.
25
+ */
26
+ wchan?: string;
27
+ /** Open file descriptor count for this process. */
28
+ fdCount?: number;
29
+ /** Why the snapshot is incomplete, when it is. */
30
+ error?: string;
31
+ }
32
+
33
+ /** True when /proc-based introspection is available (Linux only). */
34
+ export function isProcAvailable(): boolean {
35
+ return process.platform === "linux";
36
+ }
37
+
38
+ /**
39
+ * Capture kernel state for a pid. Returns `{ error }` rather than throwing when
40
+ * the platform has no /proc or the process has already exited (the common race:
41
+ * it exits between the liveness check and this call).
42
+ */
43
+ export function captureProcState(pid: number | undefined): ProcSnapshot {
44
+ if (pid === undefined) return { error: "no_pid" };
45
+ if (!isProcAvailable()) return { error: `unsupported_platform:${process.platform}` };
46
+
47
+ const snapshot: ProcSnapshot = {};
48
+ let readAnything = false;
49
+
50
+ try {
51
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
52
+ readAnything = true;
53
+ for (const line of status.split("\n")) {
54
+ const [rawKey, ...rest] = line.split(":");
55
+ const value = rest.join(":").trim();
56
+ if (!value) continue;
57
+ if (rawKey === "State") snapshot.state = value;
58
+ else if (rawKey === "Threads") snapshot.threads = Number(value) || undefined;
59
+ else if (rawKey === "VmRSS") snapshot.vmRSS = value;
60
+ }
61
+ } catch {
62
+ // Process exited, or /proc entry vanished mid-read.
63
+ }
64
+
65
+ try {
66
+ // wchan has no trailing newline and reads as "0" for a running process.
67
+ const wchan = readFileSync(`/proc/${pid}/wchan`, "utf8").trim();
68
+ readAnything = true;
69
+ if (wchan) snapshot.wchan = wchan;
70
+ } catch {
71
+ // Requires the process to still exist; ignore if it does not.
72
+ }
73
+
74
+ try {
75
+ snapshot.fdCount = readdirSync(`/proc/${pid}/fd`).length;
76
+ readAnything = true;
77
+ } catch {
78
+ // Reading another process's fd dir can fail on permissions; not fatal.
79
+ }
80
+
81
+ if (!readAnything) snapshot.error = "process_gone_or_unreadable";
82
+ return snapshot;
83
+ }