@hellcoder/companion 0.110.3-preview.20260726142826.52ddfd8 → 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.3
|
|
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
|
package/server/claude-adapter.ts
CHANGED
|
@@ -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
|
|
93
|
+
* Absolute backstop on waiting for a process with live descendants, so a stuck
|
|
94
|
+
* child cannot block recovery forever.
|
|
94
95
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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) ||
|
|
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
|
|
430
|
+
* Wait for a process to exit after its stdout transport has died.
|
|
430
431
|
*
|
|
431
|
-
* Returns true if it exited on its own.
|
|
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
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
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);
|