improve runtest, optimize CI batching (#210)

This commit is contained in:
David Blass
2026-02-01 21:48:53 +00:00
committed by pullfrog[bot]
parent 2b3bd97b86
commit 18ba8e5fd0
19 changed files with 1534 additions and 514 deletions
+300 -79
View File
@@ -98759,9 +98759,6 @@ configure({
}
});
// mcp/server.ts
import { createServer } from "node:net";
// node_modules/.pnpm/@modelcontextprotocol+sdk@1.25.2_hono@4.11.3_zod@4.3.5/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js
init_v3();
@@ -128227,6 +128224,9 @@ var FastMCP = class extends FastMCPEventEmitter {
}
};
// mcp/server.ts
import { createServer } from "node:net";
// node_modules/.pnpm/@ark+schema@0.56.0/node_modules/@ark/schema/out/shared/errors.js
var ArkError = class _ArkError extends CastableBase {
[arkKind] = "error";
@@ -141505,8 +141505,118 @@ function isMetadataYarnClassic(metadataPath) {
// utils/subprocess.ts
import { spawn as nodeSpawn } from "node:child_process";
// utils/activity.ts
var DEFAULT_ACTIVITY_TIMEOUT_MS = 3e4;
var DEFAULT_ACTIVITY_CHECK_INTERVAL_MS = 5e3;
function wrapWrite(original, onActivity) {
const wrapped = (chunk, encodingOrCb, cb) => {
onActivity();
if (typeof encodingOrCb === "function") {
return original(chunk, encodingOrCb);
}
return original(chunk, encodingOrCb, cb);
};
return wrapped;
}
function startProcessOutputMonitor(ctx) {
let lastActivity = Date.now();
let timedOut = false;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
function markActivity() {
lastActivity = Date.now();
}
process.stdout.write = wrapWrite(originalStdoutWrite, markActivity);
process.stderr.write = wrapWrite(originalStderrWrite, markActivity);
const intervalId = setInterval(() => {
const idleMs = Date.now() - lastActivity;
if (timedOut || idleMs <= ctx.timeoutMs) return;
timedOut = true;
ctx.onTimeout(idleMs);
}, ctx.checkIntervalMs);
function stop() {
clearInterval(intervalId);
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
return { stop };
}
function createProcessOutputActivityTimeout(ctx) {
let rejectFn = null;
const promise2 = new Promise((_, reject) => {
rejectFn = reject;
});
let monitor = null;
monitor = startProcessOutputMonitor({
timeoutMs: ctx.timeoutMs,
checkIntervalMs: ctx.checkIntervalMs,
onTimeout: (idleMs) => {
if (!rejectFn) return;
const idleSec = Math.round(idleMs / 1e3);
if (monitor) {
monitor.stop();
}
rejectFn(new Error(`activity timeout: no output for ${idleSec}s`));
}
});
return {
promise: promise2,
stop: monitor.stop
};
}
// utils/subprocess.ts
var activeChildren = /* @__PURE__ */ new Map();
var externalSignalHandler = null;
function trackChild(options) {
activeChildren.set(options.child, options.killGroup ?? false);
}
function untrackChild(child) {
activeChildren.delete(child);
}
function killTrackedChildren() {
const count = activeChildren.size;
for (const entry of activeChildren) {
const child = entry[0];
const killGroup = entry[1];
if (killGroup && child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
continue;
} catch {
}
}
child.kill("SIGKILL");
}
return count;
}
function cleanupAndExit(signal) {
const count = killTrackedChildren();
if (count > 0) {
log.info(`\xBB received ${signal}, killing ${count} subprocess(es)...`);
}
setTimeout(() => process.exit(1), 500).unref();
process.exit(1);
}
function handleSignal(signal) {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
cleanupAndExit(signal);
}
var handlersInstalled = false;
function installSignalHandlers() {
if (handlersInstalled) return;
handlersInstalled = true;
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
}
async function spawn2(options) {
const { cmd, args: args3, env: env3, input, timeout, cwd: cwd2, stdio, onStdout, onStderr } = options;
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
installSignalHandlers();
const startTime = Date.now();
let stdoutBuffer = "";
let stderrBuffer = "";
@@ -141519,8 +141629,12 @@ async function spawn2(options) {
stdio: stdio || ["pipe", "pipe", "pipe"],
cwd: cwd2 || process.cwd()
});
trackChild({ child });
let timeoutId;
let activityCheckIntervalId;
let isTimedOut = false;
let isActivityTimedOut = false;
let lastActivityTime = Date.now();
if (timeout) {
timeoutId = setTimeout(() => {
isTimedOut = true;
@@ -141532,8 +141646,24 @@ async function spawn2(options) {
}, 5e3);
}, timeout);
}
if (activityTimeoutMs > 0) {
activityCheckIntervalId = setInterval(() => {
const idleMs = Date.now() - lastActivityTime;
if (idleMs > activityTimeoutMs) {
isActivityTimedOut = true;
const idleSec = Math.round(idleMs / 1e3);
log.error(`no output for ${idleSec}s, killing process`);
child.kill("SIGKILL");
clearInterval(activityCheckIntervalId);
}
}, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS);
}
function updateActivity() {
lastActivityTime = Date.now();
}
if (child.stdout) {
child.stdout.on("data", (data) => {
updateActivity();
const chunk = data.toString();
stdoutBuffer += chunk;
onStdout?.(chunk);
@@ -141541,6 +141671,7 @@ async function spawn2(options) {
}
if (child.stderr) {
child.stderr.on("data", (data) => {
updateActivity();
const chunk = data.toString();
stderrBuffer += chunk;
onStderr?.(chunk);
@@ -141548,11 +141679,16 @@ async function spawn2(options) {
}
child.on("close", (exitCode) => {
const durationMs = Date.now() - startTime;
if (timeoutId) {
clearTimeout(timeoutId);
}
untrackChild(child);
if (timeoutId) clearTimeout(timeoutId);
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
if (isTimedOut) {
reject(new Error(`Process timed out after ${timeout}ms`));
reject(new Error(`process timed out after ${timeout}ms`));
return;
}
if (isActivityTimedOut) {
const idleSec = Math.round((Date.now() - lastActivityTime) / 1e3);
reject(new Error(`activity timeout: no output for ${idleSec}s`));
return;
}
resolve2({
@@ -141564,10 +141700,10 @@ async function spawn2(options) {
});
child.on("error", (error50) => {
const durationMs = Date.now() - startTime;
if (timeoutId) {
clearTimeout(timeoutId);
}
console.error(`[spawn] Process spawn error: ${error50.message}`);
untrackChild(child);
if (timeoutId) clearTimeout(timeoutId);
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
console.error(`[spawn] process spawn error: ${error50.message}`);
resolve2({
stdout: stdoutBuffer,
stderr: stderrBuffer,
@@ -143164,53 +143300,39 @@ function initToolState(ctx) {
backgroundProcesses: /* @__PURE__ */ new Map()
};
}
async function findAvailablePort(startPort) {
const checkPort = (port2) => {
return new Promise((resolve2) => {
const server = createServer();
server.once("error", () => {
server.close();
resolve2(false);
});
server.listen(port2, () => {
server.close(() => {
resolve2(true);
});
});
var mcpPortStart = 3764;
var mcpPortAttempts = 100;
var mcpHost = "127.0.0.1";
var mcpEndpoint = "/mcp";
function readEnvPort() {
const rawPort = process.env.PULLFROG_MCP_PORT;
if (!rawPort) return null;
const parsed2 = Number.parseInt(rawPort, 10);
if (!Number.isInteger(parsed2) || parsed2 <= 0 || parsed2 > 65535) {
throw new Error(`invalid PULLFROG_MCP_PORT: ${rawPort}`);
}
return parsed2;
}
function isPortAvailable(port) {
return new Promise((resolve2) => {
const server = createServer();
server.unref();
server.once("error", () => resolve2(false));
server.once("listening", () => {
server.close(() => resolve2(true));
});
};
let port = startPort;
while (port < startPort + 100) {
if (await checkPort(port)) {
return port;
}
port++;
}
throw new Error(`Could not find available port starting from ${startPort}`);
}
async function killBackgroundProcesses(toolState) {
const backgroundProcesses = toolState.backgroundProcesses;
if (backgroundProcesses.size === 0) return;
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
}
}
await new Promise((resolve2) => setTimeout(resolve2, 200));
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
}
}
backgroundProcesses.clear();
}
async function startMcpHttpServer(ctx) {
const server = new FastMCP({
name: ghPullfrogMcpName,
version: "0.0.1"
server.listen(port, mcpHost);
});
}
function getErrorMessage(error50) {
if (error50 instanceof Error) return error50.message;
return String(error50);
}
function isAddressInUse(error50) {
const message = getErrorMessage(error50).toLowerCase();
return message.includes("eaddrinuse") || message.includes("address already in use");
}
function buildTools(ctx) {
const tools = [
SelectModeTool(ctx),
StartDependencyInstallationTool(ctx),
@@ -143242,24 +143364,96 @@ async function startMcpHttpServer(ctx) {
tools.push(KillBackgroundTool(ctx));
}
tools.push(ReportProgressTool(ctx));
addTools(ctx, server, tools);
const port = await findAvailablePort(3764);
const host = "127.0.0.1";
const endpoint2 = "/mcp";
await server.start({
transportType: "httpStream",
httpStream: {
port,
host,
endpoint: endpoint2
}
return tools;
}
async function tryStartMcpServer(ctx, port) {
const server = new FastMCP({
name: ghPullfrogMcpName,
version: "0.0.1"
});
const url4 = `http://${host}:${port}${endpoint2}`;
const tools = buildTools(ctx);
addTools(ctx, server, tools);
try {
await server.start({
transportType: "httpStream",
httpStream: {
port,
host: mcpHost,
endpoint: mcpEndpoint
}
});
const url4 = `http://${mcpHost}:${port}${mcpEndpoint}`;
return { server, url: url4, port };
} catch (error50) {
if (!isAddressInUse(error50)) {
throw error50;
}
try {
await server.stop();
} catch {
}
return null;
}
}
async function selectMcpPort(ctx) {
let lastError = null;
const requestedPort = readEnvPort();
if (requestedPort !== null) {
if (await isPortAvailable(requestedPort)) {
const requestedResult = await tryStartMcpServer(ctx, requestedPort);
if (requestedResult) {
return requestedResult;
}
}
}
const randomOffset = Math.floor(Math.random() * 50);
for (let offset = 0; offset < mcpPortAttempts; offset++) {
const port = mcpPortStart + randomOffset + offset;
try {
if (!await isPortAvailable(port)) {
continue;
}
const result = await tryStartMcpServer(ctx, port);
if (result) {
return result;
}
} catch (error50) {
lastError = error50;
if (!isAddressInUse(error50)) {
throw error50;
}
}
}
const message = getErrorMessage(lastError);
throw new Error(
`could not find available mcp port starting at ${mcpPortStart} (last error: ${message})`
);
}
async function killBackgroundProcesses(toolState) {
const backgroundProcesses = toolState.backgroundProcesses;
if (backgroundProcesses.size === 0) return;
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGTERM");
} catch {
}
}
await new Promise((resolve2) => setTimeout(resolve2, 200));
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
}
}
backgroundProcesses.clear();
}
async function startMcpHttpServer(ctx) {
const startResult = await selectMcpPort(ctx);
return {
url: url4,
url: startResult.url,
[Symbol.asyncDispose]: async () => {
await killBackgroundProcesses(ctx.toolState);
await server.stop();
await startResult.server.stop();
}
};
}
@@ -160990,6 +161184,10 @@ var messageHandlers2 = {
} else if (item.type === "mcp_tool_call") {
if (item.status === "failed" && item.error) {
log.warning(`MCP tool call failed: ${item.error.message}`);
} else if (item.output) {
const output = item.output;
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
log.debug(`tool output: ${outputStr}`);
}
} else if (item.type === "reasoning") {
const reasoningText = item.text.trim();
@@ -161093,9 +161291,11 @@ var cursor = agent({
if (isError) {
log.warning("Tool call failed");
} else {
const text = result?.content?.[0]?.text?.text;
const contentItem = result?.content?.[0];
const textValue = contentItem?.text;
const text = typeof textValue === "string" ? textValue : textValue?.text;
if (text) {
console.log(text);
log.debug(`tool output: ${text}`);
}
}
}
@@ -161289,6 +161489,9 @@ var messageHandlers3 = {
if (event.status === "error") {
const errorMsg = typeof event.output === "string" ? event.output : JSON.stringify(event.output);
log.warning(`Tool call failed: ${errorMsg}`);
} else if (event.output) {
const outputStr = typeof event.output === "string" ? event.output : JSON.stringify(event.output);
log.debug(`tool output: ${outputStr}`);
}
},
result: async (event) => {
@@ -161735,6 +161938,9 @@ var messageHandlers4 = {
if (status === "error") {
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
log.error(`\xBB \u274C tool call failed: ${errorMsg}`);
} else if (output) {
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
log.debug(`tool output: ${outputStr}`);
}
},
result: async (event) => {
@@ -162049,12 +162255,12 @@ The workflow encountered an error before any progress could be reported. Please
}
}
cleanupFn = cleanup;
function handleSignal() {
function handleSignal2() {
log.info("\xBB workflow cancelled, cleaning up...");
cleanup(true).finally(() => process.exit(1));
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
process.on("SIGINT", handleSignal2);
process.on("SIGTERM", handleSignal2);
}
async function runCleanup() {
try {
@@ -162693,6 +162899,7 @@ async function main() {
try {
normalizeEnv();
const timer = new Timer();
let activityTimeout = null;
const tokenRef = __using(_stack2, await resolveInstallationToken(), true);
const octokit = createOctokit(tokenRef.token);
const runContext = await resolveRunContextData({ octokit, token: tokenRef.token });
@@ -162757,6 +162964,12 @@ async function main() {
repo: runContext.repo,
modes: modes2
});
activityTimeout = createProcessOutputActivityTimeout({
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS
});
activityTimeout.promise.catch(() => {
});
const agentPromise = agent2.run({
payload,
mcpServerUrl: mcpHttpServer.url,
@@ -162765,11 +162978,11 @@ async function main() {
});
let result;
if (payload.timeout === TIMEOUT_DISABLED) {
result = await agentPromise;
result = await Promise.race([agentPromise, activityTimeout.promise]);
} else {
const parsed2 = payload.timeout ? parseTimeString(payload.timeout) : null;
if (payload.timeout && parsed2 === null) {
log.warning(`Invalid timeout format "${payload.timeout}", using default 1h`);
log.warning(`invalid timeout format "${payload.timeout}", using default 1h`);
}
const timeoutMs = parsed2 ?? 36e5;
const actualTimeout = parsed2 !== null ? payload.timeout : "1h";
@@ -162782,7 +162995,7 @@ async function main() {
timeoutPromise.catch(() => {
});
try {
result = await Promise.race([agentPromise, timeoutPromise]);
result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]);
} finally {
clearTimeout(timeoutId);
}
@@ -162790,6 +163003,9 @@ async function main() {
if (toolState.lastProgressBody) {
await writeSummary(toolState.lastProgressBody);
}
if (toolState.output) {
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
}
return {
...handleAgentResult(result),
result: toolState.output
@@ -162801,7 +163017,8 @@ async function main() {
_promise2 && await _promise2;
}
} catch (error50) {
const errorMessage = error50 instanceof Error ? error50.message : "Unknown error occurred";
const errorMessage = error50 instanceof Error ? error50.message : "unknown error occurred";
killTrackedChildren();
log.error(errorMessage);
try {
await reportErrorToComment({ toolState, error: errorMessage });
@@ -162811,6 +163028,10 @@ async function main() {
success: false,
error: errorMessage
};
} finally {
if (activityTimeout) {
activityTimeout.stop();
}
}
} catch (_2) {
var _error2 = _2, _hasError2 = true;