fix(coding-agent): corrected shell session state reset and hard timeout handling for command execution
- Fixed persistent shell session state not being reset after command abort or hard timeout. - Fixed hard timeout handling to properly interrupt long-running commands exceeding grace period. - Introduced hard timeout mechanism with Promise.race() to enforce absolute timeout limit and prevent command hangs. - Replaced shell command execution with explicit timeout and SIGKILL signal handling in shell-snapshot. - Simplified bash command normalization to use only explicit head/tail parameters from tool input. - Exported getAntigravityUserAgent() function for centralized User-Agent header construction.
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Exported `getAntigravityUserAgent()` function for constructing Antigravity User-Agent headers
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated default Antigravity version from 1.15.8 to 1.18.3
|
||||
- Unified User-Agent header generation across Antigravity API calls to use centralized `getAntigravityUserAgent()` function
|
||||
|
||||
## [12.17.1] - 2026-02-21
|
||||
### Added
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAntigravityUserAgent } from "../providers/google-gemini-cli";
|
||||
import type {
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
@@ -9,7 +10,6 @@ import type {
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
|
||||
import { getAntigravityUserAgent } from "../providers/google-gemini-cli";
|
||||
|
||||
interface AntigravityQuotaInfo {
|
||||
remainingFraction?: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import type { Model } from "../../types";
|
||||
import { getAntigravityUserAgent } from "../../providers/google-gemini-cli";
|
||||
import type { Model } from "../../types";
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Modified bash command normalization to only apply explicit head/tail parameters from tool input, removing automatic extraction from command pipes
|
||||
- Updated shell snapshot creation to use explicit timeout and kill signal configuration for more reliable process termination
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed persistent shell session state not being reset after command abort or hard timeout, preventing stale environment variables from affecting subsequent commands
|
||||
- Fixed hard timeout handling to properly interrupt long-running commands that exceed the grace period beyond the configured timeout
|
||||
|
||||
## [12.17.1] - 2026-02-21
|
||||
### Added
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface BashResult {
|
||||
artifactId?: string;
|
||||
}
|
||||
|
||||
const HARD_TIMEOUT_GRACE_MS = 5_000;
|
||||
|
||||
const shellSessions = new Map<string, Shell>();
|
||||
|
||||
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
||||
@@ -65,74 +67,106 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
};
|
||||
}
|
||||
|
||||
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
|
||||
let shellSession = shellSessions.get(sessionKey);
|
||||
if (!shellSession) {
|
||||
shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
|
||||
shellSessions.set(sessionKey, shellSession);
|
||||
}
|
||||
const signal = options?.signal;
|
||||
const abortHandler = () => {
|
||||
shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
let hardTimeoutTimer: NodeJS.Timeout | undefined;
|
||||
const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">();
|
||||
const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
|
||||
const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS;
|
||||
hardTimeoutTimer = setTimeout(() => {
|
||||
shellSession.abort(`Hard timeout after ${Math.round(hardTimeoutMs / 1000)}s`);
|
||||
hardTimeoutDeferred.resolve("hard-timeout");
|
||||
}, hardTimeoutMs);
|
||||
|
||||
let resetSession = false;
|
||||
|
||||
try {
|
||||
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
|
||||
let shellSession = shellSessions.get(sessionKey);
|
||||
if (!shellSession) {
|
||||
shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
|
||||
shellSessions.set(sessionKey, shellSession);
|
||||
}
|
||||
const runPromise = shellSession.run(
|
||||
{
|
||||
command: finalCommand,
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
timeoutMs: options?.timeout,
|
||||
signal,
|
||||
},
|
||||
(err, chunk) => {
|
||||
if (!err) {
|
||||
enqueueChunk(chunk);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const signal = options?.signal;
|
||||
const abortHandler = () => {
|
||||
shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
const winner = await Promise.race([
|
||||
runPromise.then(result => ({ kind: "result" as const, result })),
|
||||
hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await shellSession.run(
|
||||
{
|
||||
command: finalCommand,
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
timeoutMs: options?.timeout,
|
||||
signal,
|
||||
},
|
||||
(err, chunk) => {
|
||||
if (!err) {
|
||||
enqueueChunk(chunk);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await pendingChunks;
|
||||
|
||||
// Handle timeout
|
||||
if (result.timedOut) {
|
||||
const annotation = options?.timeout
|
||||
? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
|
||||
: "Command timed out";
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump(annotation)),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle cancellation
|
||||
if (result.cancelled) {
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump("Command cancelled")),
|
||||
};
|
||||
}
|
||||
|
||||
// Normal completion
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
cancelled: false,
|
||||
...(await sink.dump()),
|
||||
};
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await pendingChunks;
|
||||
|
||||
if (winner.kind === "hard-timeout") {
|
||||
resetSession = true;
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (winner.result.timedOut) {
|
||||
const annotation = options?.timeout
|
||||
? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
|
||||
: "Command timed out";
|
||||
resetSession = true;
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump(annotation)),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle cancellation
|
||||
if (winner.result.cancelled) {
|
||||
resetSession = true;
|
||||
return {
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...(await sink.dump("Command cancelled")),
|
||||
};
|
||||
}
|
||||
|
||||
// Normal completion
|
||||
return {
|
||||
exitCode: winner.result.exitCode,
|
||||
cancelled: false,
|
||||
...(await sink.dump()),
|
||||
};
|
||||
} catch (err) {
|
||||
resetSession = true;
|
||||
throw err;
|
||||
} finally {
|
||||
if (hardTimeoutTimer) {
|
||||
clearTimeout(hardTimeoutTimer);
|
||||
}
|
||||
if (signal) {
|
||||
signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
await pendingChunks;
|
||||
if (resetSession) {
|
||||
shellSessions.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function loadGitContext(cwd: string): Promise<GitContext | null> {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
timeout: timeout,
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
return untilAborted(abortSignal, async () => {
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CachedOutputBlock } from "../tui/output-block";
|
||||
import type { ToolSession } from ".";
|
||||
import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
|
||||
import { checkBashInterception } from "./bash-interceptor";
|
||||
import { applyHeadTail, normalizeBashCommand } from "./bash-normalize";
|
||||
import { applyHeadTail } from "./bash-normalize";
|
||||
import { expandInternalUrls } from "./bash-skill-urls";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
|
||||
@@ -75,13 +75,11 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
||||
onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
|
||||
ctx?: AgentToolContext,
|
||||
): Promise<AgentToolResult<BashToolDetails>> {
|
||||
// Normalize command: strip head/tail pipes and 2>&1
|
||||
const normalized = normalizeBashCommand(rawCommand);
|
||||
let command = normalized.command;
|
||||
let command = rawCommand;
|
||||
|
||||
// Merge explicit params with extracted ones (explicit takes precedence)
|
||||
const headLines = head ?? normalized.headLines;
|
||||
const tailLines = tail ?? normalized.tailLines;
|
||||
// Only apply explicit head/tail params from tool input.
|
||||
const headLines = head;
|
||||
const tailLines = tail;
|
||||
|
||||
// Check interception if enabled and available tools are known
|
||||
if (this.session.settings.get("bashInterceptor.enabled")) {
|
||||
@@ -139,8 +137,8 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
||||
artifactId,
|
||||
})
|
||||
: await executeBash(command, {
|
||||
sessionKey: this.session.getSessionId?.() ?? undefined,
|
||||
cwd: commandCwd,
|
||||
sessionKey: this.session.getSessionId?.() ?? undefined,
|
||||
timeout: timeoutMs,
|
||||
signal,
|
||||
env: extraEnv,
|
||||
|
||||
@@ -9,9 +9,9 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
|
||||
const cachedSnapshotPaths = new Map<string, string>();
|
||||
const SNAPSHOT_TIMEOUT_MS = 2_000;
|
||||
|
||||
function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const sanitized = { ...env };
|
||||
@@ -153,8 +153,23 @@ export async function getOrCreateSnapshot(
|
||||
|
||||
try {
|
||||
const snapshotEnv = sanitizeSnapshotEnv(env);
|
||||
await $`${shell} -c ${script}`.env(snapshotEnv).quiet().text();
|
||||
if (fs.existsSync(snapshotPath)) {
|
||||
const spawnEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(snapshotEnv)) {
|
||||
if (value !== undefined) {
|
||||
spawnEnv[key] = value;
|
||||
}
|
||||
}
|
||||
const child = Bun.spawn([shell, "-c", script], {
|
||||
env: spawnEnv,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
timeout: SNAPSHOT_TIMEOUT_MS,
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
|
||||
await child.exited;
|
||||
if (child.exitCode === 0 && fs.existsSync(snapshotPath)) {
|
||||
cachedSnapshotPaths.set(cacheKey, snapshotPath);
|
||||
return snapshotPath;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,35 @@ describe("executeBash", () => {
|
||||
expect(result.output).not.toContain("done");
|
||||
});
|
||||
|
||||
it("resets persistent session state after abort", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionKey = "reset-on-abort";
|
||||
await executeBash("export PI_RESET_VAR=alive", { cwd: tempDir, timeout: 5000, sessionKey });
|
||||
const beforeAbort = await executeBash("echo $PI_RESET_VAR", { cwd: tempDir, timeout: 5000, sessionKey });
|
||||
expect(beforeAbort.output.trim()).toBe("alive");
|
||||
|
||||
const controller = new AbortController();
|
||||
const abortPromise = executeBash("sleep 10", {
|
||||
cwd: tempDir,
|
||||
timeout: 5000,
|
||||
signal: controller.signal,
|
||||
sessionKey,
|
||||
});
|
||||
await Bun.sleep(50);
|
||||
controller.abort();
|
||||
const aborted = await abortPromise;
|
||||
expect(aborted.cancelled).toBe(true);
|
||||
|
||||
const afterAbort = await executeBash("echo ${PI_RESET_VAR:-unset}", {
|
||||
cwd: tempDir,
|
||||
timeout: 5000,
|
||||
sessionKey,
|
||||
});
|
||||
expect(afterAbort.output.trim()).toBe("unset");
|
||||
});
|
||||
it("streams output chunks", async () => {
|
||||
const chunks: string[] = [];
|
||||
const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", {
|
||||
|
||||
Reference in New Issue
Block a user