fix(coding-agent): resolve captured commands on close closes #3027
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed flaky git package update notifications by waiting for captured git command stdio to fully drain before comparing local and remote commit SHAs ([#3027](https://github.com/badlogic/pi-mono/issues/3027))
|
||||||
- Fixed auto-retry transient error detection to treat `Network connection lost.` as retryable, so dropped provider connections retry instead of terminating the agent ([#3317](https://github.com/badlogic/pi-mono/issues/3317))
|
- Fixed auto-retry transient error detection to treat `Network connection lost.` as retryable, so dropped provider connections retry instead of terminating the agent ([#3317](https://github.com/badlogic/pi-mono/issues/3317))
|
||||||
- Fixed compact interactive extension startup summaries to disambiguate package extensions and repeated local `index.ts` entries by using package-aware labels and the minimal parent path needed to make local entries unique ([#3308](https://github.com/badlogic/pi-mono/issues/3308))
|
- Fixed compact interactive extension startup summaries to disambiguate package extensions and repeated local `index.ts` entries by using package-aware labels and the minimal parent path needed to make local entries unique ([#3308](https://github.com/badlogic/pi-mono/issues/3308))
|
||||||
- Fixed git package dependency installation to use production installs (`npm install --omit=dev`) during both install and update flows, so extension runtime dependencies must come from `dependencies` and not `devDependencies` ([#3009](https://github.com/badlogic/pi-mono/issues/3009))
|
- Fixed git package dependency installation to use production installs (`npm install --omit=dev`) during both install and update flows, so extension runtime dependencies must come from `dependencies` and not `devDependencies` ([#3009](https://github.com/badlogic/pi-mono/issues/3009))
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { spawn, spawnSync } from "node:child_process";
|
import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
import ignore from "ignore";
|
import ignore from "ignore";
|
||||||
import { minimatch } from "minimatch";
|
import { minimatch } from "minimatch";
|
||||||
import { CONFIG_DIR_NAME } from "../config.js";
|
import { CONFIG_DIR_NAME } from "../config.js";
|
||||||
@@ -2177,18 +2178,34 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess {
|
||||||
|
return spawn(command, args, {
|
||||||
|
cwd: options?.cwd,
|
||||||
|
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnCaptureCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { cwd?: string; env?: Record<string, string> },
|
||||||
|
): ChildProcessByStdio<null, Readable, Readable> {
|
||||||
|
return spawn(command, args, {
|
||||||
|
cwd: options?.cwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private runCommandCapture(
|
private runCommandCapture(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
|
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = this.spawnCaptureCommand(command, args, options);
|
||||||
cwd: options?.cwd,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
||||||
});
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
@@ -2206,11 +2223,11 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
child.stderr?.on("data", (data) => {
|
child.stderr?.on("data", (data) => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
});
|
});
|
||||||
child.on("error", (error) => {
|
child.once("error", (error) => {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
child.on("exit", (code) => {
|
child.once("close", (code, signal) => {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`));
|
reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`));
|
||||||
@@ -2220,18 +2237,15 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
resolvePromise(stdout.trim());
|
resolvePromise(stdout.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reject(new Error(`${command} ${args.join(" ")} failed with code ${code}: ${stderr || stdout}`));
|
const exitStatus = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`;
|
||||||
|
reject(new Error(`${command} ${args.join(" ")} failed with ${exitStatus}: ${stderr || stdout}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
|
private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
const child = spawn(command, args, {
|
const child = this.spawnCommand(command, args, options);
|
||||||
cwd: options?.cwd,
|
|
||||||
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join, relative } from "node:path";
|
import { join, relative } from "node:path";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js";
|
import { DefaultPackageManager, type ProgressEvent, type ResolvedResource } from "../src/core/package-manager.js";
|
||||||
import { SettingsManager } from "../src/core/settings-manager.js";
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
||||||
@@ -13,6 +15,16 @@ function pathEndsWith(actualPath: string, suffix: string): boolean {
|
|||||||
return normalizeForMatch(actualPath).endsWith(normalizeForMatch(suffix));
|
return normalizeForMatch(actualPath).endsWith(normalizeForMatch(suffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockSpawnedProcess extends EventEmitter {
|
||||||
|
stdout = new PassThrough();
|
||||||
|
stderr = new PassThrough();
|
||||||
|
|
||||||
|
kill(): boolean {
|
||||||
|
this.emit("close", null, "SIGTERM");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to check if a resource is enabled
|
// Helper to check if a resource is enabled
|
||||||
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
|
const isEnabled = (r: ResolvedResource, pathMatch: string, matchFn: "endsWith" | "includes" = "endsWith") => {
|
||||||
const normalizedPath = normalizeForMatch(r.path);
|
const normalizedPath = normalizeForMatch(r.path);
|
||||||
@@ -1553,5 +1565,38 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
|||||||
expect.objectContaining({ cwd: tempDir }),
|
expect.objectContaining({ cwd: tempDir }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should wait for close before resolving captured stdout", async () => {
|
||||||
|
const managerWithInternals = packageManager as unknown as {
|
||||||
|
spawnCaptureCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { cwd?: string; env?: Record<string, string> },
|
||||||
|
): MockSpawnedProcess;
|
||||||
|
runCommandCapture(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { cwd?: string; timeoutMs?: number; env?: Record<string, string> },
|
||||||
|
): Promise<string>;
|
||||||
|
};
|
||||||
|
const child = new MockSpawnedProcess();
|
||||||
|
vi.spyOn(managerWithInternals, "spawnCaptureCommand").mockReturnValue(child);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const capturePromise = managerWithInternals.runCommandCapture("git", ["rev-parse", "HEAD"]).then((value) => {
|
||||||
|
settled = true;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.emit("exit", 0, null);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
child.stdout.write("abc123\n");
|
||||||
|
child.stdout.end();
|
||||||
|
child.emit("close", 0, null);
|
||||||
|
|
||||||
|
await expect(capturePromise).resolves.toBe("abc123");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user