@hellcoder/companion 0.110.2 → 0.110.3

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.110.2",
3
+ "version": "0.110.3",
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",
@@ -23,6 +23,16 @@ vi.mock("node:crypto", () => ({
23
23
  }));
24
24
 
25
25
  // Mock settings-manager to prevent real file system reads
26
+ // countDescendants is stubbed so the wedge tests can drive the "process has
27
+ // live descendants" branch deterministically, without spawning real children.
28
+ // Default 0 (childless) preserves the existing tests' behaviour; individual
29
+ // tests override it.
30
+ const mockCountDescendants = vi.hoisted(() => vi.fn(() => 0));
31
+ vi.mock("./proc-diagnostics.js", async (importOriginal) => ({
32
+ ...(await importOriginal<typeof import("./proc-diagnostics.js")>()),
33
+ countDescendants: mockCountDescendants,
34
+ }));
35
+
26
36
  vi.mock("./settings-manager.js", () => ({
27
37
  getSettings: () => ({
28
38
  aiValidationEnabled: false,
@@ -1593,6 +1603,44 @@ describe("stdio transport disconnect propagation", () => {
1593
1603
  }
1594
1604
  });
1595
1605
 
1606
+ it("does NOT kill a process that is supervising live descendants", async () => {
1607
+ // The dominant real-world case, from a captured kill:
1608
+ // graceReason=result state="R (running)" threads=28
1609
+ // descendants=[bash, gh, tail]
1610
+ // Those are the agent's own tool subprocesses — a background command still
1611
+ // running after the turn's `result`. The CLI closed stdout normally at
1612
+ // end-of-turn and stayed alive to supervise that work.
1613
+ //
1614
+ // Its descendant count holds STEADY (nothing is being reaped), so any
1615
+ // drop-based test reads it as stalled and kills a process that is actively
1616
+ // working. It must survive well past the stall window.
1617
+ vi.useFakeTimers();
1618
+ mockCountDescendants.mockReturnValue(3); // bash + gh + tail
1619
+ try {
1620
+ const { proc, endStdout } = createMockProc();
1621
+ adapter.attachStdio(proc);
1622
+
1623
+ endStdout();
1624
+ await vi.advanceTimersByTimeAsync(0);
1625
+ expect(proc.kill).not.toHaveBeenCalled();
1626
+
1627
+ // Far beyond the 10s stall window that would fire for a childless
1628
+ // process. Still busy, so still not killed.
1629
+ await vi.advanceTimersByTimeAsync(60_000);
1630
+ expect(proc.kill).not.toHaveBeenCalled();
1631
+
1632
+ // Once the background work finishes and the children are gone, the stall
1633
+ // window starts accumulating and a genuinely idle process is killed —
1634
+ // recovery is deferred, not abandoned.
1635
+ mockCountDescendants.mockReturnValue(0);
1636
+ await vi.advanceTimersByTimeAsync(11_000);
1637
+ expect(proc.kill).toHaveBeenCalledTimes(1);
1638
+ } finally {
1639
+ mockCountDescendants.mockReturnValue(0);
1640
+ vi.useRealTimers();
1641
+ }
1642
+ });
1643
+
1596
1644
  it("does NOT kill a process that exits on its own within the grace period", async () => {
1597
1645
  // The common, healthy case: the CLI closes stdout a beat before it exits
1598
1646
  // cleanly (e.g. right after a `result`). Killing here would convert a code-0
@@ -90,15 +90,16 @@ const STDOUT_CLOSE_RESULT_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_R
90
90
  const SIGKILL_ESCALATION_MS = Number(process.env.COMPANION_SIGKILL_ESCALATION_MS) || 2000;
91
91
 
92
92
  /**
93
- * Absolute ceiling on waiting for MCP teardown, however well it is progressing.
93
+ * Absolute backstop on waiting for a process with live descendants, so a stuck
94
+ * child cannot block recovery forever.
94
95
  *
95
- * The flat 10s grace was not enough: 8 of 12 observed kills were processes
96
- * correctly identified as still reaping children, waited the full 10s for, and
97
- * killed anyway while perfectly healthy (S sleeping, 311-351MB). `npm exec`
98
- * -wrapped MCP servers plus headless chromium routinely exceed 10s, especially
99
- * with ~70 CLIs contending for I/O.
96
+ * Set above the CLI's own maximum background-command duration (10 minutes): the
97
+ * descendants being waited on are frequently the agent's own tool subprocesses,
98
+ * and a ceiling below that would kill a legitimately running command — the very
99
+ * failure this path exists to avoid. Only a process still childless-and-idle,
100
+ * or one that blows this ceiling, is treated as wedged.
100
101
  */
101
- const TEARDOWN_MAX_MS = Number(process.env.COMPANION_TEARDOWN_MAX_MS) || 120_000;
102
+ const TEARDOWN_MAX_MS = Number(process.env.COMPANION_TEARDOWN_MAX_MS) || 660_000;
102
103
 
103
104
  /** How often to re-check the descendant count while waiting for teardown. */
104
105
  const TEARDOWN_POLL_MS = Number(process.env.COMPANION_TEARDOWN_POLL_MS) || 500;
@@ -426,21 +427,32 @@ export class ClaudeAdapter implements IBackendAdapter {
426
427
  private lastTeardownOutcome: string | undefined;
427
428
 
428
429
  /**
429
- * Wait for a process to exit while its descendants are still being reaped.
430
+ * Wait for a process to exit after its stdout transport has died.
430
431
  *
431
- * Returns true if it exited on its own. The wait is adaptive: as long as the
432
- * descendant count keeps dropping, teardown is demonstrably working and we
433
- * keep waiting regardless of elapsed time. We give up only when the count
434
- * sits unchanged for TEARDOWN_STALL_MS (nothing is happening) or the hard
435
- * TEARDOWN_MAX_MS ceiling is hit.
432
+ * Returns true if it exited on its own.
436
433
  *
437
- * This replaces a flat 10s grace that killed 8 of 12 healthy processes: a
438
- * fixed deadline cannot know how long `npm exec`-wrapped MCP servers plus
439
- * headless chromium need, particularly under contention.
434
+ * The rule is: **a process with live descendants is never killed on a timer.**
435
+ *
436
+ * An earlier version only reset the stall window when the descendant count
437
+ * *dropped*, on the assumption that descendants are MCP servers mid-teardown.
438
+ * Captured evidence disproved that. A representative kill:
439
+ *
440
+ * graceReason=result state="R (running)" wchan=0 threads=28
441
+ * descendants=[{comm:"bash"},{comm:"gh"},{comm:"tail"}]
442
+ *
443
+ * Those are the agent's own tool subprocesses — a background command still
444
+ * running after the turn's `result`. The CLI had closed stdout normally at
445
+ * end-of-turn and stayed alive to supervise that work. Its descendant count
446
+ * holds steady for as long as the command runs, so a drop-based test reads it
447
+ * as stalled and kills a process that is actively working (R, on CPU).
448
+ *
449
+ * So any live descendant counts as "busy", not just a falling count. The
450
+ * stall window only accumulates once the process is childless AND idle, which
451
+ * is the one state that genuinely looks like a wedge. TEARDOWN_MAX_MS remains
452
+ * as a backstop so recovery cannot be blocked forever.
440
453
  */
441
454
  private async awaitTeardown(proc: Subprocess, stallMs: number): Promise<boolean> {
442
455
  const started = Date.now();
443
- let lastCount = countDescendants(proc.pid);
444
456
  let lastProgressAt = started;
445
457
  this.lastTeardownOutcome = undefined;
446
458
 
@@ -456,10 +468,10 @@ export class ClaudeAdapter implements IBackendAdapter {
456
468
 
457
469
  const now = Date.now();
458
470
  const count = countDescendants(proc.pid);
459
- if (count < lastCount) {
460
- // Progress: children are being reaped. Reset the stall window — a slow
461
- // but advancing teardown must never be killed.
462
- lastCount = count;
471
+ if (count > 0) {
472
+ // Live descendants: the process is supervising work — a background tool
473
+ // call, or MCP servers still shutting down. Either way it is doing
474
+ // something, so the stall window must not accumulate.
463
475
  lastProgressAt = now;
464
476
  }
465
477
 
@@ -167,6 +167,30 @@ describe("getDescendants / hasLiveDescendants", () => {
167
167
  expect(countDescendants(process.pid)).toBeLessThan(withOne);
168
168
  });
169
169
 
170
+ it.runIf(isLinux)("countDescendants stays steady while a child keeps running", async () => {
171
+ // The case that broke the drop-based test. A background tool call (bash ->
172
+ // gh -> tail in the captured kill) holds a *steady* descendant count for as
173
+ // long as it runs — it never falls, because nothing is being reaped. Any
174
+ // logic keyed on a falling count reads this as a stall and kills a process
175
+ // that is actively working. awaitTeardown therefore treats count > 0 as
176
+ // busy, so this steady-state must be observable as non-zero over time.
177
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
178
+ await new Promise((r) => setTimeout(r, 150));
179
+
180
+ const first = countDescendants(process.pid);
181
+ expect(first).toBeGreaterThan(0);
182
+
183
+ await new Promise((r) => setTimeout(r, 400));
184
+ const second = countDescendants(process.pid);
185
+ // Still alive, still counted, and not decreasing — the signal that a
186
+ // drop-based test would misread.
187
+ expect(second).toBeGreaterThan(0);
188
+ expect(second).toBeGreaterThanOrEqual(first);
189
+
190
+ child.kill("SIGKILL");
191
+ await new Promise((r) => child.on("exit", r));
192
+ });
193
+
170
194
  it.runIf(isLinux)("countDescendants is bounded by maxNodes", () => {
171
195
  // Bounds the poll-loop cost: this runs every 500ms during teardown.
172
196
  expect(countDescendants(1, 3)).toBeLessThanOrEqual(3);