From 540c1b7899d012441d508cd676aed4cf13add1a3 Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 21 Jan 2026 17:46:21 -0800 Subject: [PATCH] feat: elegant cancellation (#620) --- bun.lock | 10 ++++++---- src/cli/App.tsx | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 31b1edc..f41c82d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,11 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@letta-ai/letta-code", "dependencies": { - "@letta-ai/letta-client": "^1.6.8", + "@letta-ai/letta-client": "^1.7.2", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -14,6 +15,7 @@ "@types/bun": "latest", "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", + "@types/react": "^19.2.9", "diff": "^8.0.2", "husky": "9.1.7", "ink": "^5.0.0", @@ -89,7 +91,7 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - "@letta-ai/letta-client": ["@letta-ai/letta-client@1.6.8", "", {}, "sha512-FscN1/c03uGByxD2nnnQ6/DMch234UQVlZJcoamU7ElFqv0ESNXb/3zpjL/SreTEUqk7IzrAp2bw5u/ILMt0UA=="], + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.2", "", {}, "sha512-88XLWwacqTcqL1uZ8QCKmxPIzTlgHAPVbyxVrNLMKeDdqp6Vs7y1WIRFBdEvGy2WJmUgOt5HTUJSnJZevhPX7A=="], "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], @@ -99,7 +101,7 @@ "@types/picomatch": ["@types/picomatch@4.0.2", "", {}, "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA=="], - "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="], @@ -139,7 +141,7 @@ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 6f4f2de..09867ef 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1014,6 +1014,16 @@ export default function App({ }; }, []); + // Cleanup abort timeout on unmount + useEffect(() => { + return () => { + if (abortTimeoutIdRef.current) { + clearTimeout(abortTimeoutIdRef.current); + abortTimeoutIdRef.current = null; + } + }; + }, []); + // Show exit stats on exit (double Ctrl+C) const [showExitStats, setShowExitStats] = useState(false); @@ -1038,6 +1048,9 @@ export default function App({ // AbortController for stream cancellation const abortControllerRef = useRef(null); + // Timeout for graceful cancellation (waits for stop_reason before force-aborting) + const abortTimeoutIdRef = useRef(null); + // Track if user wants to cancel (persists across state updates) const userCancelledRef = useRef(false); @@ -2199,6 +2212,12 @@ export default function App({ // Case 1.5: Stream was cancelled by user if (stopReasonToHandle === "cancelled") { + // Clear the force-abort timeout since we received graceful cancellation + if (abortTimeoutIdRef.current) { + clearTimeout(abortTimeoutIdRef.current); + abortTimeoutIdRef.current = null; + } + setStreaming(false); // Check if this cancel was triggered by queue threshold @@ -3266,11 +3285,18 @@ export default function App({ // Mark any running subagents as interrupted interruptActiveSubagents(INTERRUPTED_BY_USER); - // NOW abort the stream - interrupted flag is already set - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; // Clear ref so isAgentBusy() returns false - } + // DON'T abort immediately - wait for server to send stop_reason: cancelled + // This gives server time to gracefully shut down and avoids GeneratorExit errors + // Set timeout as safety net in case server doesn't respond + const abortTimeoutId = setTimeout(() => { + if (abortControllerRef.current) { + console.warn("[EAGER_CANCEL] Forcing abort after 30s timeout"); + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, 30000); // 30 seconds + + abortTimeoutIdRef.current = abortTimeoutId; // Set cancellation flag to prevent processConversation from starting userCancelledRef.current = true;