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:
can1357
2026-02-21 16:52:37 +01:00
parent 13430b961b
commit fc2c3c2c7c
9 changed files with 171 additions and 77 deletions
+8
View File
@@ -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 -1
View File
@@ -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";
+9
View File
@@ -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
+98 -64
View File
@@ -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;
+6 -8
View File
@@ -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", {