mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* fix(cli): recover stale interactive sessions and suppress shutdown hook races This fixes the CLI/TUI regression introduced between `3.0.14` and `3.0.15` where the interactive CLI could enter a broken state after stopping and restarting Cline Hub, then attempting to cancel a request with Escape. The affected release window was: - `49e8c1b32` / `v3.0.14`: known-good baseline - `c33c3176e` / `v3.0.15`: release containing the regression - `fad8271f4 feat: Cline Hub web app (#10969)`: relevant behavior change in the window The Hub web app change introduced new Hub-backed runtime/session lifecycle behavior. After Ctrl+C or Hub shutdown, the CLI could still retain an `activeSessionId` that no longer existed in the Hub/runtime process. On the next interactive send, the CLI attempted to reuse that stale session and received `session not found`. Because cancellation also targeted the stale session, Escape stopped working and OpenTUI ended up receiving failures during input handling, which made the TUI look corrupted. The same lifecycle issue also explains the Ctrl+C errors: ```text error: hook dispatch failed: Hub connection closed (code=1006, reason=Connection ended) error: WebSocket connection to 'ws://127.0.0.1:50168/hub' failed: Failed to connect ``` Those were caused by late hook dispatches racing against Hub shutdown. The CLI was still trying to send hook events over a Hub WebSocket that had already closed. **What changed** - Added missing-session recovery in the interactive runtime. - Detects `session not found` / stale session errors. - Reads any recoverable messages from the missing session. - Clears the stale active session state. - Starts a new interactive runtime session. - Retries the current turn once against the fresh session. - Made hook dispatch shutdown-aware. - Runtime hooks now mark themselves as shutting down before session disposal. - Hook dispatches are skipped once shutdown begins. - Dispatch failures during shutdown are suppressed, since the Hub transport closing is expected at that point. - Reordered CLI cleanup. - Hooks are shut down before stopping/disposing runtime sessions. - This prevents abort/stop lifecycle events from trying to dispatch over a closing Hub connection. **Regression coverage** Added tests for: - Recovering from a disappeared active interactive session and retrying against a new session. - Ensuring hook events are not dispatched after shutdown begins. **Verification** Passed: ```text bunx vitest run apps/cli/src/utils/hooks.test.ts apps/cli/src/runtime/interactive/session-runtime.test.ts bun -F @cline/cli typecheck bun -F @cline/cli test:unit bun -F @cline/cli test:e2e:cli:tui git diff --check ``` * SessionNotFoundError * fix(core): preserve stale session errors in hub runs * clean up --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import {
|
|
type AgentEvent,
|
|
type AgentResult,
|
|
type ProviderSettings,
|
|
prewarmFileIndex,
|
|
SessionSource,
|
|
type UserInstructionConfigService,
|
|
} from "@cline/core";
|
|
import type { ConsecutiveMistakeLimitContext } from "@cline/shared";
|
|
import { createSessionId } from "@cline/shared";
|
|
import { logCliError } from "../logging/errors";
|
|
import { createCliCore } from "../session/session";
|
|
import { resolveClineWelcomeLine } from "../tui/interactive-welcome";
|
|
import {
|
|
askQuestionInTerminal,
|
|
requestToolApproval,
|
|
submitAndExitInTerminal,
|
|
} from "../utils/approval";
|
|
import { handleEvent, handleTeamEvent } from "../utils/events";
|
|
import { createRuntimeHooks } from "../utils/hooks";
|
|
import {
|
|
c,
|
|
emitJsonLine,
|
|
formatUsd,
|
|
getActiveCliSession,
|
|
setActiveCliSession,
|
|
writeErr,
|
|
writeln,
|
|
} from "../utils/output";
|
|
import type { Config } from "../utils/types";
|
|
import { shouldShowCliUsageCost } from "../utils/usage-cost-display";
|
|
import { setActiveRuntimeAbort } from "./active-runtime";
|
|
import {
|
|
CLI_DEFAULT_CHECKPOINT_CONFIG,
|
|
CLI_DEFAULT_LOOP_DETECTION,
|
|
} from "./defaults";
|
|
import { describeAbortSource, resolveMistakeLimitDecision } from "./format";
|
|
import { buildUserInputMessage } from "./prompt";
|
|
import { subscribeToAgentEvents } from "./session-events";
|
|
|
|
function printModelProviderInfo(config: Config): void {
|
|
const catalog = config.knownModels ? "live" : "bundled";
|
|
const thinking = config.thinking ? "on" : "off";
|
|
const { mode, providerId, modelId } = config;
|
|
if (config.outputMode === "json") {
|
|
emitJsonLine("stdout", {
|
|
type: "run_start",
|
|
providerId,
|
|
modelId,
|
|
catalog,
|
|
thinking,
|
|
mode,
|
|
sessionId: getActiveCliSession()?.manifest.session_id,
|
|
});
|
|
return;
|
|
}
|
|
writeln(
|
|
`${c.dim}[model] provider=${providerId} model=${modelId} catalog=${catalog} thinking=${thinking} mode=${mode}${c.reset}\n`,
|
|
);
|
|
}
|
|
|
|
function emitAbortRequested(
|
|
config: Config,
|
|
reason: "sigint" | "sigterm",
|
|
): void {
|
|
if (config.outputMode === "json") {
|
|
emitJsonLine("stdout", { type: "run_abort_requested", reason });
|
|
} else if (reason === "sigint") {
|
|
writeln(`\n${c.dim}[abort] requested${c.reset}`);
|
|
}
|
|
}
|
|
|
|
function emitTeamRestored(config: Config): void {
|
|
const teamName = config.teamName ?? "(unknown team)";
|
|
if (config.outputMode === "json") {
|
|
emitJsonLine("stdout", { type: "team_restored", teamName });
|
|
return;
|
|
}
|
|
writeln(
|
|
`${c.dim}[team] restored persisted team state for "${teamName}"${c.reset}`,
|
|
);
|
|
}
|
|
|
|
function printRunStats(
|
|
config: Config,
|
|
result: AgentResult,
|
|
usage: AgentResult["usage"],
|
|
startTime: number,
|
|
reasoningChunkCount: number,
|
|
redactedReasoningChunkCount: number,
|
|
): void {
|
|
if (config.outputMode !== "text") {
|
|
return;
|
|
}
|
|
if (config.verbose) {
|
|
writeln();
|
|
const parts: string[] = [];
|
|
parts.push(`${((performance.now() - startTime) / 1000).toFixed(2)}s`);
|
|
const tokenParts: string[] = [
|
|
`${usage.inputTokens} in`,
|
|
`${usage.outputTokens} out`,
|
|
];
|
|
if (usage.cacheReadTokens) {
|
|
tokenParts.push(`${usage.cacheReadTokens} cache read`);
|
|
}
|
|
if (usage.cacheWriteTokens) {
|
|
tokenParts.push(`${usage.cacheWriteTokens} cache write`);
|
|
}
|
|
parts.push(tokenParts.join(", "));
|
|
if (
|
|
shouldShowCliUsageCost(config.providerId) &&
|
|
typeof usage.totalCost === "number"
|
|
) {
|
|
parts.push(`${formatUsd(usage.totalCost)} est. cost`);
|
|
}
|
|
if (result.iterations > 1) {
|
|
parts.push(`${result.iterations} iterations`);
|
|
}
|
|
writeln(`${c.dim}[${parts.join(" | ")}]${c.reset}`);
|
|
if (config.thinking) {
|
|
writeln(
|
|
`${c.dim}[thinking] chunks=${reasoningChunkCount} redacted=${redactedReasoningChunkCount}${c.reset}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runAgent(
|
|
prompt: string,
|
|
config: Config,
|
|
userInstructionService?: UserInstructionConfigService,
|
|
options?: {
|
|
clineApiBaseUrl?: string;
|
|
clineProviderSettings?: ProviderSettings;
|
|
},
|
|
): Promise<void> {
|
|
// A clean one-shot run should not inherit a stale nonzero process exit code
|
|
// from lower layers or prior bookkeeping inside the same process.
|
|
process.exitCode = 0;
|
|
|
|
if (config.verbose) {
|
|
const clineWelcomeLine = await resolveClineWelcomeLine({
|
|
config,
|
|
clineApiBaseUrl: options?.clineApiBaseUrl,
|
|
clineProviderSettings: options?.clineProviderSettings,
|
|
});
|
|
if (clineWelcomeLine && config.outputMode !== "json") {
|
|
writeln(clineWelcomeLine);
|
|
}
|
|
}
|
|
|
|
const startTime = performance.now();
|
|
void prewarmFileIndex(config.cwd).catch((error: unknown) => {
|
|
logCliError(config.logger, "File index prewarm failed", { error });
|
|
});
|
|
|
|
const isYoloMode = config.mode === "yolo";
|
|
const toolExecutors = {
|
|
askQuestion: askQuestionInTerminal,
|
|
submit: submitAndExitInTerminal,
|
|
};
|
|
const sessionManager = await createCliCore({
|
|
capabilities: {
|
|
toolExecutors,
|
|
requestToolApproval,
|
|
},
|
|
forceLocalBackend: isYoloMode || config.sandbox === true,
|
|
logger: config.logger,
|
|
cwd: config.cwd,
|
|
workspaceRoot: config.workspaceRoot,
|
|
toolPolicies: config.toolPolicies,
|
|
});
|
|
const runtimeHooks = createRuntimeHooks({
|
|
verbose: config.verbose,
|
|
yolo: isYoloMode,
|
|
cwd: config.cwd,
|
|
workspaceRoot: config.workspaceRoot,
|
|
dispatchHookEvent: async (payload) => {
|
|
await sessionManager.ingestHookEvent(payload);
|
|
},
|
|
});
|
|
|
|
let reasoningChunkCount = 0;
|
|
let redactedReasoningChunkCount = 0;
|
|
const displayedErrorMessages = new Set<string>();
|
|
|
|
const onAgentEvent = (event: AgentEvent): void => {
|
|
if (event.type === "content_start" && event.contentType === "reasoning") {
|
|
reasoningChunkCount += 1;
|
|
if (event.redacted) {
|
|
redactedReasoningChunkCount += 1;
|
|
}
|
|
}
|
|
if (
|
|
event.type === "error" &&
|
|
(!event.recoverable || config.verbose) &&
|
|
event.error.message.trim()
|
|
) {
|
|
displayedErrorMessages.add(event.error.message.trim());
|
|
}
|
|
handleEvent(event, config);
|
|
};
|
|
const plannedSessionId = createSessionId();
|
|
const unsubscribe = subscribeToAgentEvents(sessionManager, onAgentEvent, {
|
|
sessionId: plannedSessionId,
|
|
});
|
|
|
|
// --- Abort & signal handling ---
|
|
let abortRequested = false;
|
|
let timedOut = false;
|
|
let activeSessionId: string | undefined;
|
|
|
|
const abortAll = () => {
|
|
if (abortRequested) return false;
|
|
abortRequested = true;
|
|
if (activeSessionId) {
|
|
sessionManager
|
|
.abort(activeSessionId, new Error("Run-agent runtime abort requested"))
|
|
.catch(() => {});
|
|
}
|
|
return true;
|
|
};
|
|
setActiveRuntimeAbort(abortAll);
|
|
|
|
let cleanupDone: Promise<void> | undefined;
|
|
const cleanupRuntime = () => {
|
|
cleanupDone ??= (async () => {
|
|
process.off("SIGINT", handleSigint);
|
|
process.off("SIGTERM", handleSigterm);
|
|
unsubscribe();
|
|
await runtimeHooks.shutdown().catch(() => {});
|
|
if (activeSessionId) {
|
|
await sessionManager.stop(activeSessionId).catch(() => {});
|
|
}
|
|
await sessionManager.dispose("cli_run_shutdown").catch(() => {});
|
|
setActiveRuntimeAbort(undefined);
|
|
})();
|
|
return cleanupDone;
|
|
};
|
|
|
|
const handleSigint = () => {
|
|
if (abortAll()) {
|
|
emitAbortRequested(config, "sigint");
|
|
return;
|
|
}
|
|
void cleanupRuntime().finally(() => {
|
|
process.exitCode = 0;
|
|
process.exit(0);
|
|
});
|
|
};
|
|
const handleSigterm = () => {
|
|
if (abortAll()) {
|
|
emitAbortRequested(config, "sigterm");
|
|
}
|
|
};
|
|
process.on("SIGINT", handleSigint);
|
|
process.on("SIGTERM", handleSigterm);
|
|
|
|
// --- Main execution ---
|
|
try {
|
|
if (config.verbose) {
|
|
printModelProviderInfo(config);
|
|
}
|
|
const {
|
|
prompt: userInput,
|
|
userImages,
|
|
userFiles,
|
|
} = await buildUserInputMessage(prompt, userInstructionService);
|
|
const started = await sessionManager.start({
|
|
source: SessionSource.CLI,
|
|
config: {
|
|
...config,
|
|
sessionId: plannedSessionId,
|
|
execution: {
|
|
...config.execution,
|
|
loopDetection:
|
|
config.execution?.loopDetection ?? CLI_DEFAULT_LOOP_DETECTION,
|
|
},
|
|
checkpoint: config.checkpoint ?? CLI_DEFAULT_CHECKPOINT_CONFIG,
|
|
hooks: runtimeHooks.hooks,
|
|
onTeamEvent: handleTeamEvent,
|
|
onConsecutiveMistakeLimitReached: async (
|
|
context: ConsecutiveMistakeLimitContext,
|
|
) => resolveMistakeLimitDecision(config, context),
|
|
},
|
|
prompt: userInput,
|
|
userImages: userImages.length > 0 ? userImages : undefined,
|
|
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
|
interactive: false,
|
|
localRuntime: {
|
|
onTeamRestored: () => emitTeamRestored(config),
|
|
},
|
|
});
|
|
|
|
activeSessionId = started.sessionId;
|
|
setActiveCliSession({
|
|
manifest: started.manifest,
|
|
});
|
|
|
|
// Schedule timeout abort if configured.
|
|
const timeoutMs =
|
|
typeof config.timeoutSeconds === "number" &&
|
|
Number.isFinite(config.timeoutSeconds) &&
|
|
config.timeoutSeconds > 0
|
|
? config.timeoutSeconds * 1000
|
|
: undefined;
|
|
const timeoutId = timeoutMs
|
|
? setTimeout(() => {
|
|
timedOut = true;
|
|
abortAll();
|
|
}, timeoutMs)
|
|
: undefined;
|
|
const clearRunTimeout = () => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
};
|
|
|
|
// When start() already ran the first turn (non-interactive with prompt),
|
|
// the session is finalized before start() returns. Use that result
|
|
// directly; calling send() would fail with "session not found".
|
|
let result: AgentResult | undefined;
|
|
if (started.result) {
|
|
clearRunTimeout();
|
|
result = started.result;
|
|
} else {
|
|
result = await sessionManager
|
|
.send({
|
|
sessionId: started.sessionId,
|
|
prompt: userInput,
|
|
userImages: userImages.length > 0 ? userImages : undefined,
|
|
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
|
})
|
|
.finally(clearRunTimeout);
|
|
}
|
|
if (!result) {
|
|
throw new Error("session manager did not return a result");
|
|
}
|
|
|
|
const usageSummary = await sessionManager.getAccumulatedUsage(
|
|
started.sessionId,
|
|
);
|
|
const aggregateUsage = usageSummary?.aggregateUsage;
|
|
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
|
|
|
if (config.outputMode === "json") {
|
|
emitJsonLine("stdout", {
|
|
type: "run_result",
|
|
finishReason: result.finishReason,
|
|
iterations: result.iterations,
|
|
usage,
|
|
...(aggregateUsage ? { aggregateUsage } : {}),
|
|
durationMs: result.durationMs,
|
|
text: result.text,
|
|
model: result.model,
|
|
});
|
|
}
|
|
|
|
if (abortRequested || result.finishReason === "aborted") {
|
|
if (timedOut) {
|
|
writeErr(`run timed out after ${config.timeoutSeconds}s`);
|
|
process.exitCode = 1;
|
|
} else if (config.outputMode === "json") {
|
|
emitJsonLine("stdout", {
|
|
type: "run_aborted",
|
|
reason: abortRequested ? "local_abort" : "external_abort",
|
|
message: describeAbortSource({ abortRequested, timedOut }),
|
|
});
|
|
} else {
|
|
writeln(
|
|
`${c.dim}[abort] ${describeAbortSource({ abortRequested, timedOut })}${c.reset}`,
|
|
);
|
|
}
|
|
writeln();
|
|
return;
|
|
}
|
|
|
|
if (result.finishReason !== "completed") {
|
|
const errorText = result.text.trim();
|
|
if (
|
|
errorText &&
|
|
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
|
|
) {
|
|
writeErr(errorText);
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
printRunStats(
|
|
config,
|
|
result,
|
|
usage,
|
|
startTime,
|
|
reasoningChunkCount,
|
|
redactedReasoningChunkCount,
|
|
);
|
|
process.exitCode = 0;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
logCliError(config.logger, "CLI task run failed", { error: err });
|
|
writeErr(message);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await cleanupRuntime();
|
|
}
|
|
}
|