From b0ea9f484980343076319247fe21daf0c84b0063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E7=9B=BC?= Date: Tue, 19 May 2026 13:58:58 +0800 Subject: [PATCH 01/45] fix(core): decouple auto-memory recall from main-agent request path (#4172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add async memory recall design spec and implementation plan * refactor(core): introduce MemoryPrefetchHandle, replace pendingRecallAbortController field * refactor(core): fire memory recall as non-blocking prefetch with settledAt flag * refactor(core): replace blocking await with zero-wait settledAt poll at UserQuery consume point Co-Authored-By: Claude Sonnet 4.6 * feat(core): inject recalled memory on first ToolResult when UserQuery consume point misses Co-Authored-By: Claude Sonnet 4.6 * refactor(core): replace pendingRecallAbortController with pendingMemoryPrefetch in all cleanup paths Co-Authored-By: Claude Sonnet 4.6 * refactor(memory): remove 1s AbortSignal.timeout from relevanceSelector — caller controls lifetime Co-Authored-By: Claude Sonnet 4.6 * test(core): update auto-memory tests for async prefetch pattern — drop fake timers and deadline references Co-Authored-By: Claude Sonnet 4.6 * test(core): add ToolResult inject test — memory injected on first ToolResult when recall settles after UserQuery Co-Authored-By: Claude Sonnet 4.6 * fix(core): address codex review findings on async memory recall Three findings fixed: 1. Abort previous prefetch before installing a new one (line 1059): A new UserQuery/Cron used to overwrite pendingMemoryPrefetch without aborting the old controller, leaking an unbounded background recall now that the 1s side-query timeout is gone. 2. Move the UserQuery consume poll AFTER the async reminder setup: ensureTool + listSubagents are awaited between the old poll location and the final assembly, so recalls that settled during those awaits used to be missed (and a tool-less turn never got a ToolResult retry). The poll now runs immediately before requestToSend assembly, and unshifts memory to the front of systemReminders to preserve ordering. 3. Append memory after functionResponse on ToolResult turns: The Qwen API requires the functionResponse part to immediately follow the model's functionCall (see lines 1209-1213). Prepending memory text risked breaking that pairing on the native Gemini path. Appending keeps the pair intact on Gemini and produces the same OpenAI output (text becomes a separate user message after the tool messages). Tests: - Updated ToolResult inject test to assert memory index > functionResponse - Added abort-previous-prefetch test (mid-flight UserQuery aborts old handle) 224/224 tests pass; tsc clean on changed files. Co-Authored-By: Claude Sonnet 4.6 * docs(core): add JSDoc + clarifying comments per review feedback Annotations only, no behavior change: - MemoryPrefetchHandle: full JSDoc covering lifecycle (create → consume → discard) - UserQuery consume site: explain why we unshift (front of systemReminders) - ToolResult inject site: reference hasPendingToolCall pattern instead of brittle line numbers when citing the Qwen functionCall/Response constraint - relevanceSelector.ts: explain why the side-query has no inline timeout (caller controls lifetime via MemoryPrefetchHandle.controller) Co-Authored-By: Claude Sonnet 4.6 * fix(core): bridge caller abort signal into memory prefetch + doc accuracy fixes Behavior fix (addresses copilot review on client.ts:1071): - When the parent sendMessageStream signal aborts (user Ctrl-C / Esc), the prefetch controller now aborts too. Previously the recall side-query would keep running until a later cleanup (next UserQuery / /clear / etc), wasting fast-model tokens on work whose result no one would consume. - Listener uses { once: true } and is also removed in the promise's finally() so a long-lived parent signal doesn't accumulate listeners across many turns under normal completion. - Edge case: if signal is already aborted when fire runs, abort the controller synchronously instead of attaching a listener. Test: - New regression guard: "should abort the pending prefetch when the caller signal aborts" — verifies the abort handler installed on the recall side fires once the parent signal aborts. Doc accuracy (addresses copilot review on the design spec): - ToolResult inject: was documented as "prepend", actual implementation appends to preserve functionCall/functionResponse pairing. Updated both the prose summary and the code sample. - Cleanup section: was documented as 6 abort-locations including the "post-consume clear"; the consume sites don't actually abort (the promise has already settled). Reorganized as 5 abort-and-clear sites + 2 clear-only sites with the distinction made explicit. - Fire path snippet: added the abort-previous-prefetch line and the caller-signal bridge so the spec matches the current implementation. Co-Authored-By: Claude Sonnet 4.6 * refactor(core): consolidate memory-prefetch lifecycle + safety nets per round-3 review Architectural (root-cause fix for cleanup-path sibling drift): - New private cancelPendingMemoryPrefetch() consolidates the abort+clear idiom (was duplicated across 6 sites). Logs at debug when discarding a settled-but-unconsumed handle so missing-memory scenarios are diagnosable. - New private tryConsumeMemoryPrefetch() consolidates the consume-and-mark-consumed dance (was duplicated UserQuery + ToolResult). - All existing cleanup sites + the two newly-flagged early-return sites (LoopDetected, Error) now use the helper; future early-returns can rely on the finally-block safety net. - sendMessageStream try-finally now uses a `normalCompletion` flag: only the bottom-of-try return path preserves the prefetch (intentional — next ToolResult turn may consume it); every other exit (uncaught exception, abnormal early-return) goes through cancelPendingMemoryPrefetch in finally. Diagnostics: - Restored AbortError debug log in fire-path catch (was silent after removing the deadline mechanism; aborts now come from 4+ sources so a trace is valuable). - Updated stale "deadline" log in recall.ts to reflect current abort sources (caller signal / new UserQuery / cleanup / 30 s safety timeout). Safety net: - Added 30 s ceiling in relevanceSelector via AbortSignal.any(...). Generous enough that normal ~1 s recalls don't trip it; bounds zombie side-queries if the model API hangs and the caller never aborts. Replaces the uncancellable `new AbortController().signal` fallback that would have left callerless invocations running indefinitely. Doc sync: - Design doc updated: UserQuery consume code sample now shows `unshift` (matches implementation) with an inline note on the prepend-vs-append contrast. Tests: - New regression guard: resetChat aborts pending prefetch and clears the handle. - New regression guard: LoopDetected mid-stream aborts pending prefetch and clears the handle (catches the sibling-drift bug this round caught). 227/227 tests pass; tsc clean on changed files. Declined from this round: - `await Promise.resolve()` after fire path: defensive — current code has multiple natural microtask drains before consume point. Added comment documenting the dependency instead. - Renaming `settledAt: number | null` to `settled: boolean`: timestamp has diagnostic value for future instrumentation; current consumers' null-check usage is documented in the JSDoc. Co-Authored-By: Claude Sonnet 4.6 * fix(test): correct getLastLoopType mock return type — null, not undefined CI tsc --build (stricter than --noEmit) caught: src/core/client.test.ts(2996,65): error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'LoopType | null'. getLastLoopType()'s contract returns LoopType | null; the test mock was returning undefined. Switched to null to match the type. Co-Authored-By: Claude Sonnet 4.6 * fix(core): preserve memory prefetch across hook/next-speaker continuations + accurate recall abort log Round-4 review findings (self-inflicted regression from round-3): 1. Preserve pending prefetch on `return hookTurn` (Stop-hook continuation) and `return continueTurn` (next-speaker continuation). The round-3 `normalCompletion = true` was only set at the bottom-of-try `return turn`, leaving these two recursive-yield paths to trip the finally cleanup. When the inner Hook turn produced tool calls, the subsequent ToolResult turn found `pendingMemoryPrefetch === undefined` and memory was silently dropped. 2. recall.ts catch log distinguishes caller-driven aborts (heuristic genuinely skipped below) from the 30s safety-net timeout in relevanceSelector (the caller's signal is NOT aborted by that path, so the heuristic fallback actually runs). Regression guard added: - "should PRESERVE the pending prefetch when next-speaker continueTurn returns" — was red before this commit, green after. 258/258 tests pass; tsc --build clean. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../2026-05-15-async-memory-recall-design.md | 206 ++++++++++ packages/core/src/core/client.test.ts | 388 ++++++++++++++++-- packages/core/src/core/client.ts | 242 +++++++---- packages/core/src/memory/recall.ts | 27 +- packages/core/src/memory/relevanceSelector.ts | 12 +- 5 files changed, 746 insertions(+), 129 deletions(-) create mode 100644 docs/design/2026-05-15-async-memory-recall-design.md diff --git a/docs/design/2026-05-15-async-memory-recall-design.md b/docs/design/2026-05-15-async-memory-recall-design.md new file mode 100644 index 00000000000..f11b2ac5d23 --- /dev/null +++ b/docs/design/2026-05-15-async-memory-recall-design.md @@ -0,0 +1,206 @@ +# Async Memory Recall — Design Spec + +**Date:** 2026-05-15 +**Status:** Approved +**Related issues:** #3761, #3759 +**Related PRs:** #3814, #3866 + +--- + +## Problem + +`relevanceSelector.ts` uses `AbortSignal.timeout(1_000)` (introduced by #3866). On first-session cold starts, qwen3.5-flash averages ~908 ms — consistently hitting the 1 s threshold. The outer 2.5 s deadline in `resolveAutoMemoryWithDeadline` means every UserQuery can block for up to 2.5 s even when recall always fails. + +Root cause: the main-agent request path `await`s the recall result before sending to the model. Any slowness in the recall side-query directly adds to user-visible latency. + +--- + +## Design + +### Core idea + +Fire recall on UserQuery and never await it. Consume the result at two opportunistic points — whichever fires first: + +1. **UserQuery consume point** — synchronous `settledAt !== null` check just before `turn.run()`. Zero-wait: if already settled, use it; if not, skip. +2. **ToolResult inject point** — same check on every ToolResult turn. Injects memory as a `system-reminder` **appended after** the functionResponse parts in `requestToSend`, giving the model memory context before its next response. (Append, not prepend: the Qwen API requires the functionResponse to immediately follow the model's functionCall — see the existing `hasPendingToolCall` IDE-context skip for the same constraint.) + +This matches the pattern used by Claude Code upstream (`startRelevantMemoryPrefetch` / `settledAt` polling in `query.ts`). + +--- + +## Data structures + +### New type `MemoryPrefetchHandle` (in `client.ts`) + +```typescript +type MemoryPrefetchHandle = { + promise: Promise; + /** Set by promise.finally(). null until the promise settles. */ + settledAt: number | null; + /** True after memory has been injected — prevents double-inject. */ + consumed: boolean; + controller: AbortController; +}; +``` + +### Field change on `GeminiClient` + +| Remove | Add | +| ------------------------------------------------------------ | ---------------------------------------------------------- | +| `pendingRecallAbortController: AbortController \| undefined` | `pendingMemoryPrefetch: MemoryPrefetchHandle \| undefined` | + +--- + +## Changes + +### 1. `client.ts` — remove `resolveAutoMemoryWithDeadline` + +Delete the function entirely. It is replaced by the `settledAt` flag mechanism. + +### 2. `client.ts` — UserQuery fire path + +Replace the `resolveAutoMemoryWithDeadline` call with: + +```typescript +// Abort any in-flight prefetch from a previous UserQuery before installing +// the new handle (prevents orphan side-queries when the user types again +// before recall settles). +this.pendingMemoryPrefetch?.controller.abort(); +this.pendingMemoryPrefetch = undefined; + +const controller = new AbortController(); +// Bridge the caller's signal into the prefetch controller so a user abort +// (Ctrl-C / Esc) on the parent turn also terminates the recall side-query. +const onParentAbort = () => controller.abort(); +if (signal.aborted) { + controller.abort(); +} else { + signal.addEventListener('abort', onParentAbort, { once: true }); +} + +const promise = this.config + .getMemoryManager() + .recall(projectRoot, partToString(request), { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + abortSignal: controller.signal, + }) + .catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + debugLogger.warn('Managed auto-memory recall prefetch failed.', error); + } + return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; + }); + +const handle: MemoryPrefetchHandle = { + promise, + settledAt: null, + consumed: false, + controller, +}; +void promise.finally(() => { + handle.settledAt = Date.now(); + signal.removeEventListener('abort', onParentAbort); +}); +this.pendingMemoryPrefetch = handle; +// no await — continue immediately +``` + +### 3. `client.ts` — UserQuery consume point (replaces `await relevantAutoMemoryPromise`) + +```typescript +const prefetchHandle = this.pendingMemoryPrefetch; +if ( + prefetchHandle && + prefetchHandle.settledAt !== null && + !prefetchHandle.consumed +) { + prefetchHandle.consumed = true; + this.pendingMemoryPrefetch = undefined; + const result = await prefetchHandle.promise; // already settled, returns immediately + if (result.prompt) { + // unshift, not push: keep memory at the front of systemReminders so + // it leads the system-reminder block on UserQuery turns. (ToolResult + // turns instead append to requestToSend to preserve functionCall / + // functionResponse pairing — see below.) + systemReminders.unshift(result.prompt); + for (const doc of result.selectedDocs) { + this.surfacedRelevantAutoMemoryPaths.add(doc.filePath); + } + } +} +``` + +### 4. `client.ts` — ToolResult inject point (new) + +After `requestToSend` is assembled, before `turn.run()`, add: + +```typescript +if (messageType === SendMessageType.ToolResult) { + const prefetchHandle = this.pendingMemoryPrefetch; + if ( + prefetchHandle && + prefetchHandle.settledAt !== null && + !prefetchHandle.consumed + ) { + prefetchHandle.consumed = true; + this.pendingMemoryPrefetch = undefined; + const result = await prefetchHandle.promise; + if (result.prompt) { + // Append (not prepend) so functionResponse parts stay first + // and the model's functionCall/functionResponse pairing + // isn't broken on the native Gemini path. + requestToSend = [...requestToSend, result.prompt]; + for (const doc of result.selectedDocs) { + this.surfacedRelevantAutoMemoryPaths.add(doc.filePath); + } + } + } +} +``` + +### 5. `client.ts` — cleanup paths + +The handle is released by two distinct mechanisms: + +**5 abort-and-clear sites** (the prefetch is still pending, abort the controller before dropping the reference). Replace `pendingRecallAbortController?.abort()` + `= undefined` with: + +```typescript +this.pendingMemoryPrefetch?.controller.abort(); +this.pendingMemoryPrefetch = undefined; +``` + +Sites: `resetChat()`, `MaxSessionTurns` early-return, `boundedTurns=0` early-return, `SessionTokenLimitExceeded` early-return, Arena control-signal early-return. The fire path itself also performs this abort-then-replace when a new UserQuery arrives while the previous prefetch is still in flight. + +**2 clear-only sites** (the prefetch has already settled and we're consuming it — no controller to abort, just drop the reference): + +```typescript +prefetchHandle.consumed = true; +this.pendingMemoryPrefetch = undefined; +``` + +Sites: UserQuery consume point, ToolResult inject point. + +### 6. `relevanceSelector.ts` — remove `AbortSignal.timeout(1_000)` + +Remove the combined `AbortSignal.any([AbortSignal.timeout(1_000), callerAbortSignal])` and pass `callerAbortSignal` directly. + +--- + +## Behaviour comparison + +| Scenario | Before | After | +| -------------------------------------------- | ------------------------------ | ------------------------------------------------------ | +| recall completes before model prep | inject on UserQuery, ~0 wait | inject on UserQuery, ~0 wait | +| recall slow (cold start) | block up to 2.5 s | skip UserQuery, inject on first ToolResult | +| recall times out (1 s) | abort, empty result, no memory | no hard timeout; inject whenever settled | +| no tool calls, recall slow | block up to 2.5 s, then skip | skip UserQuery, no ToolResult opportunity — miss | +| user sends 2nd message before recall settles | 2nd recall races 1st handle | 1st handle aborted when 2nd UserQuery fires new handle | + +--- + +## Out of scope + +- Changing the memory injection format from `system-reminder` to `tool-result` attachment (CC style) +- Per-session byte budget skip gate +- Single-word prompt skip gate diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index a45c51eda1a..bde6856c67a 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -39,7 +39,12 @@ import { import type { ModelsConfig } from '../models/modelsConfig.js'; import { UnauthorizedError } from '../utils/errors.js'; import { retryWithBackoff } from '../utils/retry.js'; -import { CompressionStatus, GeminiEventType, Turn } from './turn.js'; +import { + CompressionStatus, + GeminiEventType, + Turn, + type ServerGeminiStreamEvent, +} from './turn.js'; vi.mock('../utils/retry.js', () => ({ retryWithBackoff: vi.fn(async (fn) => await fn()), @@ -2700,20 +2705,9 @@ hello }); it('should not block the main request when auto-memory recall is slow', async () => { - // Simulate a recall that takes longer than the 2.5s deadline - mockMemoryManager.recall.mockReturnValue( - new Promise((resolve) => - setTimeout( - () => - resolve({ - prompt: '## Relevant memory\n\nSlow memory result.', - selectedDocs: [], - strategy: 'model', - }), - 10_000, - ), - ), - ); + // Recall never settles — settledAt stays null so the UserQuery consume + // point skips it and turn.run() is called immediately without memory. + mockMemoryManager.recall.mockReturnValue(new Promise(() => {})); const mockStream = (async function* () { yield { type: 'content', value: 'Hello' }; @@ -2726,38 +2720,28 @@ hello }; client['chat'] = mockChat as GeminiChat; - vi.useFakeTimers(); - try { - const streamPromise = (async () => { - const stream = client.sendMessageStream( - [{ text: 'Quick question' }], - new AbortController().signal, - 'prompt-id-slow-memory', - ); - for await (const _ of stream) { - // consume stream - } - })(); - - // Advance past the 2.5s deadline — the main request should proceed - await vi.advanceTimersByTimeAsync(3_000); - await streamPromise; - - // The main request should have been called without the slow memory - expect(mockTurnRunFn).toHaveBeenCalledWith( - 'test-model', - expect.not.arrayContaining([ - expect.stringContaining('Slow memory result'), - ]), - expect.any(AbortSignal), - ); - } finally { - vi.useRealTimers(); + const stream = client.sendMessageStream( + [{ text: 'Quick question' }], + new AbortController().signal, + 'prompt-id-slow-memory', + ); + for await (const _ of stream) { + // consume stream } + + // turn.run() must have been called without the slow memory + expect(mockTurnRunFn).toHaveBeenCalledWith( + 'test-model', + expect.not.arrayContaining([ + expect.stringContaining('Slow memory result'), + ]), + expect.any(AbortSignal), + ); }); - it('should include auto-memory prompt when recall completes within deadline', async () => { - // Simulate a fast recall that completes well within the deadline + it('should inject auto-memory at UserQuery consume point when recall already settled', async () => { + // mockResolvedValue settles synchronously; by the time the consume-point + // check runs (after at least one await), settledAt is set. mockMemoryManager.recall.mockResolvedValue({ prompt: '## Relevant memory\n\nFast memory result.', selectedDocs: [], @@ -2791,6 +2775,322 @@ hello ); }); + it('should inject auto-memory on first ToolResult when recall settles after UserQuery', async () => { + // Controllable promise — recall stays pending across the UserQuery turn + // and only settles before the ToolResult turn runs. + let resolveRecall: + | ((value: { + prompt: string; + selectedDocs: never[]; + strategy: 'model'; + }) => void) + | undefined; + mockMemoryManager.recall.mockReturnValue( + new Promise((resolve) => { + resolveRecall = resolve; + }), + ); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + // Turn 1: UserQuery — recall still pending, no injection + const userStream = client.sendMessageStream( + [{ text: 'What is my name?' }], + new AbortController().signal, + 'prompt-id-user-query', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of userStream) { + // consume + } + + expect(mockTurnRunFn).toHaveBeenLastCalledWith( + 'test-model', + expect.not.arrayContaining([ + expect.stringContaining('Deferred memory result'), + ]), + expect.any(AbortSignal), + ); + + // Recall settles between turns + resolveRecall!({ + prompt: '## Relevant memory\n\nDeferred memory result.', + selectedDocs: [], + strategy: 'model', + }); + // Drain microtasks so the settledAt finally() callback runs + await Promise.resolve(); + await Promise.resolve(); + + // Turn 2: ToolResult — settledAt is now non-null, memory should inject + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'world' }; + })(), + ); + const toolStream = client.sendMessageStream( + [{ functionResponse: { name: 'foo', response: { ok: true } } }], + new AbortController().signal, + 'prompt-id-tool-result', + { type: SendMessageType.ToolResult }, + ); + for await (const _ of toolStream) { + // consume + } + + // Memory must come AFTER the functionResponse part so the Qwen API + // call/response pairing isn't broken (see client.ts:1209-1213). + const lastCallArgs = mockTurnRunFn.mock.lastCall; + const requestArr = lastCallArgs![1] as unknown[]; + const functionResponseIdx = requestArr.findIndex( + (p) => typeof p === 'object' && p !== null && 'functionResponse' in p, + ); + const memoryIdx = requestArr.findIndex( + (p) => p === '## Relevant memory\n\nDeferred memory result.', + ); + expect(functionResponseIdx).toBeGreaterThanOrEqual(0); + expect(memoryIdx).toBeGreaterThan(functionResponseIdx); + }); + + it('should abort the pending prefetch when the caller signal aborts', async () => { + let abortHandlerInvoked = false; + mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { + opts.abortSignal?.addEventListener('abort', () => { + abortHandlerInvoked = true; + }); + return new Promise(() => {}); + }); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + const callerController = new AbortController(); + const stream = client.sendMessageStream( + [{ text: 'user typed but then aborted' }], + callerController.signal, + 'prompt-id-aborted', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream) { + // consume + } + + expect(abortHandlerInvoked).toBe(false); + callerController.abort(); + expect(abortHandlerInvoked).toBe(true); + }); + + it('should abort the previous prefetch when a new UserQuery arrives mid-flight', async () => { + // Pending recall on first UserQuery — never resolves on its own. + const abortSignals: AbortSignal[] = []; + mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { + abortSignals.push(opts.abortSignal as AbortSignal); + return new Promise(() => {}); + }); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + // First UserQuery — installs prefetch #1 + const stream1 = client.sendMessageStream( + [{ text: 'first' }], + new AbortController().signal, + 'prompt-id-1', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream1) { + // consume + } + expect(abortSignals.length).toBe(1); + expect(abortSignals[0].aborted).toBe(false); + + // Second UserQuery — should abort #1 before installing #2 + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello again' }; + })(), + ); + const stream2 = client.sendMessageStream( + [{ text: 'second' }], + new AbortController().signal, + 'prompt-id-2', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream2) { + // consume + } + + expect(abortSignals.length).toBe(2); + expect(abortSignals[0].aborted).toBe(true); + expect(abortSignals[1].aborted).toBe(false); + }); + + it('should abort the pending prefetch on resetChat', async () => { + let abortHandlerInvoked = false; + mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { + opts.abortSignal?.addEventListener('abort', () => { + abortHandlerInvoked = true; + }); + return new Promise(() => {}); + }); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + const stream = client.sendMessageStream( + [{ text: 'first' }], + new AbortController().signal, + 'prompt-id-reset-1', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream) { + // consume + } + + expect(abortHandlerInvoked).toBe(false); + await client.resetChat(); + expect(abortHandlerInvoked).toBe(true); + expect(client['pendingMemoryPrefetch']).toBeUndefined(); + }); + + it('should abort the pending prefetch when LoopDetected fires mid-stream', async () => { + let abortHandlerInvoked = false; + mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { + opts.abortSignal?.addEventListener('abort', () => { + abortHandlerInvoked = true; + }); + return new Promise(() => {}); + }); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + // Force LoopDetector to trip on the first event. + const loopDetector = client['loopDetector']; + vi.spyOn(loopDetector, 'addAndCheck').mockReturnValue(true); + vi.spyOn(loopDetector, 'getLastLoopType').mockReturnValue(null); + + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'looping' }; + })(), + ); + + const stream = client.sendMessageStream( + [{ text: 'trigger a loop' }], + new AbortController().signal, + 'prompt-id-loop', + { type: SendMessageType.UserQuery }, + ); + const events = []; + for await (const event of stream) { + events.push(event); + } + + expect(events.some((e) => e.type === GeminiEventType.LoopDetected)).toBe( + true, + ); + expect(abortHandlerInvoked).toBe(true); + expect(client['pendingMemoryPrefetch']).toBeUndefined(); + }); + + it('should PRESERVE the pending prefetch when next-speaker continueTurn returns', async () => { + // Self-inflicted-regression guard for the round-4 finding: + // the bottom-of-try `normalCompletion = true` doesn't cover the + // `return continueTurn;` path, so the outer's finally used to cancel + // the still-pending prefetch — meaning a subsequent ToolResult turn + // would have no memory to consume. + let abortHandlerInvoked = false; + mockMemoryManager.recall.mockImplementation((_root, _query, opts) => { + opts.abortSignal?.addEventListener('abort', () => { + abortHandlerInvoked = true; + }); + return new Promise(() => {}); // never settles + }); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'outer reply' }; + })(), + ); + + // Force the next-speaker check to recurse so we hit `return continueTurn`. + // The recursion call passes through this same mock stream and returns. + const { checkNextSpeaker } = await import( + '../utils/nextSpeakerChecker.js' + ); + const mockedCheckNextSpeaker = vi.mocked(checkNextSpeaker); + mockedCheckNextSpeaker + .mockResolvedValueOnce({ + reasoning: 'forced', + next_speaker: 'model', + }) + .mockResolvedValue(null); // inner recursion: stop + // Each recursive sendMessageStream call asks turn.run() for a new stream. + mockTurnRunFn.mockImplementation( + () => + (async function* () { + yield { type: 'content', value: 'reply' }; + })() as unknown as AsyncGenerator, + ); + + const stream = client.sendMessageStream( + [{ text: 'hello' }], + new AbortController().signal, + 'prompt-id-continueturn', + { type: SendMessageType.UserQuery }, + ); + for await (const _ of stream) { + // consume + } + + // The prefetch must survive the continueTurn return so a follow-up + // ToolResult turn can consume it. + expect(abortHandlerInvoked).toBe(false); + expect(client['pendingMemoryPrefetch']).not.toBeUndefined(); + }); + it('should proceed without auto-memory when managed auto-memory is disabled', async () => { // When getManagedAutoMemoryEnabled returns false, no recall is initiated // and sendMessageStream completes without memory content diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2ff9f1bc6f7..e66362900dd 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -153,41 +153,25 @@ function wrapIdeContext(contextText: string): string { } /** - * Resolve the auto-memory recall promise with a hard deadline. - * If the recall (model-driven selection + heuristic fallback) does not complete - * within the deadline, return an empty result so the main request is not delayed. + * Handle for a non-blocking auto-memory recall prefetch. * - * The deadline is set slightly above the model-driven selector's own - * AbortSignal.timeout (2s) to give the heuristic fallback time to complete, - * but low enough that the user does not perceive a delay on every turn. + * Lifecycle: + * 1. Created on UserQuery/Cron — the recall promise fires immediately, + * `pendingMemoryPrefetch` is set to this handle. + * 2. Consumed at either of two opportunistic points: a zero-wait + * `settledAt !== null` poll just before the UserQuery main request, + * or — if recall hadn't settled yet — on the first ToolResult turn. + * 3. Aborted-and-discarded by every cleanup path (resetChat, + * MaxSessionTurns, etc.) or replaced when a new UserQuery arrives. */ -async function resolveAutoMemoryWithDeadline( - promise: Promise | undefined, - onDeadline: () => void, -): Promise { - if (!promise) { - return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; - } - - let timer: ReturnType | undefined; - const deadline = new Promise((resolve) => { - timer = setTimeout(() => { - try { - onDeadline(); - } finally { - resolve(EMPTY_RELEVANT_AUTO_MEMORY_RESULT); - } - }, 2_500); - }); - - try { - return await Promise.race([promise, deadline]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } -} +type MemoryPrefetchHandle = { + promise: Promise; + /** Set by promise.finally(). null until the promise settles. */ + settledAt: number | null; + /** True after memory has been injected — prevents double-inject. */ + consumed: boolean; + controller: AbortController; +}; /** Tools that can write to the skills directory, used to detect skillsModifiedInSession. */ const SKILL_WRITE_TOOL_NAMES: ReadonlySet = new Set([ @@ -207,7 +191,7 @@ export class GeminiClient { private lastPromptId: string | undefined = undefined; private lastSentIdeContext: IdeContext | undefined; private forceFullIdeContext = true; - private pendingRecallAbortController: AbortController | undefined; + private pendingMemoryPrefetch: MemoryPrefetchHandle | undefined; private lastSessionStartContext: string | undefined; private lastSessionStartSource: SessionStartSource | undefined; @@ -432,6 +416,50 @@ export class GeminiClient { }); } + /** + * Abort and release the pending auto-memory prefetch in one step. + * Safe to call when no prefetch is pending — does nothing. Centralises + * the abort-then-clear idiom so every cleanup path (resetChat, early + * returns, finally) cannot half-fix one without the other. + * + * If the handle has already settled (recall completed but consume point + * hadn't run yet), the settled result is discarded — logged at debug so + * operators can diagnose missing-memory scenarios. + */ + private cancelPendingMemoryPrefetch(): void { + const handle = this.pendingMemoryPrefetch; + if (!handle) return; + if (handle.settledAt !== null && !handle.consumed) { + debugLogger.debug('Discarding settled but unconsumed memory prefetch.'); + } + handle.controller.abort(); + this.pendingMemoryPrefetch = undefined; + } + + /** + * Atomically consume the pending prefetch if it has already settled. + * Returns the recall result (caller decides where to inject it in + * `requestToSend`), or `null` if there's nothing to consume yet. + * + * Centralises the consume-and-mark dance so the UserQuery and ToolResult + * inject sites can't drift on the guard logic. + */ + private async tryConsumeMemoryPrefetch(): Promise { + const handle = this.pendingMemoryPrefetch; + if (!handle || handle.settledAt === null || handle.consumed) { + return null; + } + handle.consumed = true; + this.pendingMemoryPrefetch = undefined; + const result = await handle.promise; // already settled, returns immediately + if (result.prompt) { + for (const doc of result.selectedDocs) { + this.surfacedRelevantAutoMemoryPaths.add(doc.filePath); + } + } + return result; + } + async resetChat(): Promise { this.initializedSessionId = undefined; this.surfacedRelevantAutoMemoryPaths.clear(); @@ -445,10 +473,7 @@ export class GeminiClient { this.config.getBaseLlmClient().clearPerModelGeneratorCache(); // Abort any in-flight auto-memory recall so the stale controller // does not leak into the next session. - if (this.pendingRecallAbortController) { - this.pendingRecallAbortController.abort(); - this.pendingRecallAbortController = undefined; - } + this.cancelPendingMemoryPrefetch(); // Drop any deferred tools revealed this session so /clear really gives // a clean slate. We don't clear inside startChat itself because that path // is also taken by compression (which preserves the session), and @@ -1044,9 +1069,6 @@ export class GeminiClient { turns: number = MAX_TURNS, ): AsyncGenerator { const messageType = options?.type ?? SendMessageType.UserQuery; - let relevantAutoMemoryPromise: - | Promise - | undefined; if (messageType === SendMessageType.Retry) { this.stripOrphanedUserEntriesFromHistory(); @@ -1139,28 +1161,54 @@ export class GeminiClient { } } + // Tracks whether the generator reached its natural end (the bottom-of-try + // `return turn`). Only on that path do we want to preserve the pending + // memory prefetch so the next ToolResult turn can consume it. Any other + // exit (LoopDetected, Error, signal abort, uncaught exception, abnormal + // early-return) leaves this `false`, and the `finally` block aborts the + // prefetch as a safety net. + let normalCompletion = false; try { if ( messageType === SendMessageType.UserQuery || messageType === SendMessageType.Cron ) { if (this.config.getManagedAutoMemoryEnabled()) { - const recallAbortController = new AbortController(); - const rawRecallPromise = this.config + // A previous recall may still be pending (slow side-query, new user + // turn arrived before it settled). Abort it before installing the + // new handle so the orphan doesn't keep running indefinitely. + this.cancelPendingMemoryPrefetch(); + const controller = new AbortController(); + // Bridge the caller's signal into the prefetch controller so a user + // abort (Ctrl-C / Esc) on the parent turn also terminates the + // recall side-query. `{ once: true }` lets the listener clean itself + // up after firing; we still call removeEventListener on the promise's + // finally to cover the normal-completion case so a long-lived parent + // signal doesn't accumulate listeners across many turns. + const onParentAbort = () => controller.abort(); + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', onParentAbort, { once: true }); + } + const promise = this.config .getMemoryManager() .recall(this.config.getProjectRoot(), partToString(request), { config: this.config, excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - abortSignal: recallAbortController.signal, + abortSignal: controller.signal, }) .catch((error: unknown) => { + // Abort sources are now numerous (caller signal, new UserQuery, + // cleanup paths, safety-net timeout). Keep a debug trace so + // operators can diagnose missing-memory scenarios without + // raising noise on the common abort path. if ( error instanceof DOMException && error.name === 'AbortError' ) { debugLogger.debug( - 'Auto-memory recall aborted by deadline.', - error, + 'Managed auto-memory recall prefetch aborted.', ); } else { debugLogger.warn( @@ -1170,14 +1218,17 @@ export class GeminiClient { } return EMPTY_RELEVANT_AUTO_MEMORY_RESULT; }); - this.pendingRecallAbortController = recallAbortController; - // Race the recall against the deadline at initiation time so the 2.5s - // budget is not consumed by intermediate work (microcompact, compression, - // token checks, IDE context) between initiation and consumption. - relevantAutoMemoryPromise = resolveAutoMemoryWithDeadline( - rawRecallPromise, - () => recallAbortController.abort(), - ); + const handle: MemoryPrefetchHandle = { + promise, + settledAt: null, + consumed: false, + controller, + }; + void promise.finally(() => { + handle.settledAt = Date.now(); + signal.removeEventListener('abort', onParentAbort); + }); + this.pendingMemoryPrefetch = handle; } // Track prompt count for commit attribution. Only the user typing a @@ -1289,8 +1340,7 @@ export class GeminiClient { this.config.getMaxSessionTurns() > 0 && this.sessionTurnCount > this.config.getMaxSessionTurns() ) { - this.pendingRecallAbortController?.abort(); - this.pendingRecallAbortController = undefined; + this.cancelPendingMemoryPrefetch(); yield { type: GeminiEventType.MaxSessionTurns }; if (isTopLevelInteraction) endInteractionSpan('error', { @@ -1303,8 +1353,7 @@ export class GeminiClient { // Ensure turns never exceeds MAX_TURNS to prevent infinite loops const boundedTurns = Math.min(turns, MAX_TURNS); if (!boundedTurns) { - this.pendingRecallAbortController?.abort(); - this.pendingRecallAbortController = undefined; + this.cancelPendingMemoryPrefetch(); if (isTopLevelInteraction) endInteractionSpan('error', { errorMessage: 'max turns exhausted' }); return new Turn(this.getChat(), prompt_id); @@ -1319,8 +1368,7 @@ export class GeminiClient { const lastPromptTokenCount = uiTelemetryService.getLastPromptTokenCount(); if (lastPromptTokenCount > sessionTokenLimit) { - this.pendingRecallAbortController?.abort(); - this.pendingRecallAbortController = undefined; + this.cancelPendingMemoryPrefetch(); yield { type: GeminiEventType.SessionTokenLimitExceeded, value: { @@ -1380,8 +1428,7 @@ export class GeminiClient { `Arena control signal received: ${controlSignal.type} - ${controlSignal.reason}`, ); await arenaAgentClient.reportCancelled(); - this.pendingRecallAbortController?.abort(); - this.pendingRecallAbortController = undefined; + this.cancelPendingMemoryPrefetch(); if (isTopLevelInteraction) endInteractionSpan('cancelled'); return new Turn(this.getChat(), prompt_id); } @@ -1407,20 +1454,6 @@ export class GeminiClient { messageType === SendMessageType.Cron ) { const systemReminders = []; - // The recall promise was already raced against the 2.5s deadline at - // initiation time; this await just collects the result. - this.pendingRecallAbortController = undefined; - const relevantAutoMemory = relevantAutoMemoryPromise - ? await relevantAutoMemoryPromise - : EMPTY_RELEVANT_AUTO_MEMORY_RESULT; - const relevantAutoMemoryPrompt = relevantAutoMemory.prompt; - - if (relevantAutoMemoryPrompt) { - systemReminders.push(relevantAutoMemoryPrompt); - for (const doc of relevantAutoMemory.selectedDocs) { - this.surfacedRelevantAutoMemoryPaths.add(doc.filePath); - } - } // add subagent system reminder if there are subagents const hasAgentTool = await this.config @@ -1455,9 +1488,41 @@ export class GeminiClient { } } + // Zero-wait poll: consume only if the prefetch has already settled. + // Done AFTER the async reminder setup above so recall settling during + // those awaits still gets caught here. (settledAt is set in + // promise.finally(); microtask ordering guarantees it's visible + // after any await prior to this point — flatMapTextParts above is + // the natural drain.) If still not settled, skip — the ToolResult + // inject point will retry on the next turn. + const userQueryMemory = await this.tryConsumeMemoryPrefetch(); + if (userQueryMemory?.prompt) { + // Unshift to the front of systemReminders: on a UserQuery turn + // requestToSend leads with user text, so positioning memory at + // the very start of the system-reminder block keeps it close to + // the user prompt. Contrast the ToolResult path below, which + // must append to avoid splitting functionCall / functionResponse. + systemReminders.unshift(userQueryMemory.prompt); + } + requestToSend = [...systemReminders, ...requestToSend]; } + if (messageType === SendMessageType.ToolResult) { + const toolResultMemory = await this.tryConsumeMemoryPrefetch(); + if (toolResultMemory?.prompt) { + // Append (not prepend): on a ToolResult turn, requestToSend leads + // with functionResponse parts that must immediately follow the + // model's functionCall (Qwen API constraint — same reason the + // IDE-context block above is skipped while a tool call is pending, + // see the `hasPendingToolCall` guard). Putting the memory text + // after the functionResponse parts keeps the call/response pairing + // intact under native Gemini; the OpenAI converter then emits the + // text as a separate user message after the tool messages. + requestToSend = [...requestToSend, toolResultMemory.prompt]; + } + } + const activeGoalAtTurnStart = getActiveGoal(this.config.getSessionId()); if (activeGoalAtTurnStart) { yield { @@ -1503,6 +1568,9 @@ export class GeminiClient { this.lastApiCompletionTimestamp = Date.now(); if (isTopLevelInteraction) endInteractionSpan('error', { errorMessage: 'loop detected' }); + // finally cleanup catches this, but cancel explicitly to match + // the cleanup pattern at other early-return sites. + this.cancelPendingMemoryPrefetch(); return turn; } } @@ -1553,6 +1621,9 @@ export class GeminiClient { event.value instanceof Error ? '[API error]' : 'unknown error'; endInteractionSpan('error', { errorMessage: errMsg }); } + // finally cleanup catches this, but cancel explicitly to match + // the cleanup pattern at other early-return sites. + this.cancelPendingMemoryPrefetch(); return turn; } } @@ -1723,6 +1794,10 @@ export class GeminiClient { ); if (isTopLevelInteraction) endInteractionSpan(signal.aborted ? 'cancelled' : 'ok'); + // Preserve the pending prefetch: the inner Hook turn we just + // yielded may have produced tool calls, and the caller's next + // ToolResult turn still needs to consume the recall result. + normalCompletion = true; return hookTurn; } @@ -1789,6 +1864,11 @@ export class GeminiClient { ); if (isTopLevelInteraction) endInteractionSpan(signal.aborted ? 'cancelled' : 'ok'); + // Preserve the pending prefetch: same reasoning as the + // `return hookTurn` site above — the recursive Hook turn may + // have produced tool calls whose ToolResult turn still needs + // the recall result. + normalCompletion = true; return continueTurn; } @@ -1808,8 +1888,18 @@ export class GeminiClient { if (isTopLevelInteraction) { endInteractionSpan(signal?.aborted ? 'cancelled' : 'ok'); } + // Reached the bottom of the try — this turn ended cleanly. Preserve + // any still-pending memory prefetch so the next ToolResult turn can + // consume it (the whole point of the fire-and-forget design). + normalCompletion = true; return turn; } finally { + // Belt-and-suspenders: abort the prefetch on any exit other than the + // bottom-of-try `return turn`. Catches uncaught exceptions and guards + // against future early-return sites that forget to call cancel. + if (!normalCompletion) { + this.cancelPendingMemoryPrefetch(); + } if (isTopLevelInteraction) { endInteractionSpan(signal?.aborted ? 'cancelled' : 'error', { errorMessage: 'unexpected exit', diff --git a/packages/core/src/memory/recall.ts b/packages/core/src/memory/recall.ts index 8697496f953..5b476b21ce1 100644 --- a/packages/core/src/memory/recall.ts +++ b/packages/core/src/memory/recall.ts @@ -219,12 +219,25 @@ export async function resolveRelevantAutoMemoryPromptForQuery( strategy, }; } catch (error) { - // Distinguish deadline-triggered cancellation from real model errors - // so oncall debugging is not misled by the fallback log. + // Distinguish three cases so oncall debugging isn't misled: + // - caller-driven abort (user signal / new UserQuery / session + // cleanup): caller signal is aborted → heuristic fallback is + // skipped below at `options.abortSignal?.aborted`, so the + // result really is discarded. + // - 30 s safety-net timeout in relevanceSelector: only the inner + // combined signal aborts; the caller's signal is NOT aborted, + // so the heuristic fallback below DOES run. + // - real model error: warn at the higher level. if (error instanceof DOMException && error.name === 'AbortError') { - debugLogger.debug( - 'Model-driven auto-memory recall cancelled by deadline; heuristic result discarded.', - ); + if (options.abortSignal?.aborted) { + debugLogger.debug( + 'Model-driven auto-memory recall aborted by caller; heuristic result discarded.', + ); + } else { + debugLogger.debug( + 'Model-driven auto-memory recall timed out (30 s safety net); heuristic fallback will run.', + ); + } } else { debugLogger.warn( 'Model-driven auto-memory recall failed; falling back to heuristic selection.', @@ -234,8 +247,8 @@ export async function resolveRelevantAutoMemoryPromptForQuery( } } - // If the caller's abort signal is already set (e.g. deadline fired), skip the - // heuristic fallback — the result would be discarded anyway. + // If the caller's abort signal is already set, skip the heuristic + // fallback — the result would be discarded anyway. if (options.abortSignal?.aborted) { return { prompt: '', diff --git a/packages/core/src/memory/relevanceSelector.ts b/packages/core/src/memory/relevanceSelector.ts index 8962eb94d0c..1ef957671f7 100644 --- a/packages/core/src/memory/relevanceSelector.ts +++ b/packages/core/src/memory/relevanceSelector.ts @@ -91,9 +91,17 @@ export async function selectRelevantAutoMemoryDocumentsByModel( purpose: 'auto-memory-recall', contents, schema: RESPONSE_SCHEMA, + // Caller (`GeminiClient.MemoryPrefetchHandle`) owns lifecycle and aborts + // via its controller on cleanup paths. The 30 s ceiling is a generous + // safety net that only fires if the model API hangs (network partition, + // server stall, runaway retry) AND the caller never aborts. Normal + // recalls take ~1 s; 30 s is far above the long tail so this doesn't + // re-introduce the 1 s timeout regression that motivated this redesign. + // Without this ceiling, a callerless invocation would use an + // unsignalled AbortController and run indefinitely. abortSignal: callerAbortSignal - ? AbortSignal.any([AbortSignal.timeout(1_000), callerAbortSignal]) - : AbortSignal.timeout(1_000), + ? AbortSignal.any([AbortSignal.timeout(30_000), callerAbortSignal]) + : AbortSignal.timeout(30_000), // Uses runSideQuery's default side-query model policy: fast model first, // then main session model when no fast model is configured. From a7e05302e6b5b69844aabf9380bb3d1ad9e7013a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E7=9B=BC?= Date: Tue, 19 May 2026 13:59:35 +0800 Subject: [PATCH 02/45] =?UTF-8?q?feat(worktree):=20Phase=20C=20=E2=80=94?= =?UTF-8?q?=20session=20persistence,=20hooksPath,=20Footer=20+=20WorktreeE?= =?UTF-8?q?xitDialog,=20three-mode=20--resume=20restore=20(#4174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(worktree): update design doc — split Phase C/D, add Future section - Phase C: session persistence + hooksPath + StatusLine + WorktreeExitDialog - Phase D: --worktree CLI flag + symlinkDirectories - Future: sparse checkout, .worktreeinclude, tmux, PR reference parsing - Feature comparison table updated with Phase A/B completion status Co-Authored-By: Claude Sonnet 4.6 * docs(worktree): add Phase C implementation plan 8 tasks: WorktreeSession sidecar storage, hooksPath setup, EnterWorktree/ExitWorktree session wiring, useWorktreeSession hook, Footer display, --resume context injection, WorktreeExitDialog. Co-Authored-By: Claude Sonnet 4.6 * docs(worktree): update Phase C plan after claude-code comparison - WorktreeSession: add originalHeadCommit field - hooksPath: add .husky/ detection + skip-if-already-set logic - StatusLine payload: expand worktree field to match claude-code schema - WorktreeExitDialog: load dirty state on mount, display counts in dialog - UIState.activeWorktree: add originalCwd, originalBranch, originalHeadCommit Co-Authored-By: Claude Sonnet 4.6 * feat(worktree): add WorktreeSession sidecar storage New worktreeSessionService.ts exposes read/write/clear functions for the sidecar JSON file at /.worktree.json. SessionService gains getWorktreeSessionPath() so callers don't need to know the layout. Co-Authored-By: Claude Opus 4.7 * feat(worktree): configure core.hooksPath after worktree creation createUserWorktree() now sets `core.hooksPath` inside the new worktree to the main repo's hooks directory (.husky preferred, .git/hooks fallback) so commits inside the worktree run the same pre-commit checks as the main repo. Mirrors claude-code's performPostCreationSetup logic — skips the subprocess when the value already matches to avoid ~14ms spawn overhead. Failures are non-fatal: the worktree is still usable without hooks. Co-Authored-By: Claude Opus 4.7 * feat(worktree): persist WorktreeSession sidecar in EnterWorktreeTool After creating a worktree, EnterWorktreeTool now writes a sidecar JSON file at /.worktree.json with the full session state (slug, paths, branches, original HEAD SHA). --resume reads this in Phase C task 7 to restore worktree context. Best-effort: write failures don't abort the creation. Co-Authored-By: Claude Opus 4.7 * feat(worktree): clear WorktreeSession sidecar in ExitWorktreeTool After successful keep or remove, ExitWorktreeTool now clears the sidecar JSON file iff its slug matches the worktree being exited. The slug check prevents wiping the sidecar when the user exits a worktree that isn't currently tracked (multiple worktrees on disk, sidecar tracks one). Co-Authored-By: Claude Opus 4.7 * feat(worktree): expose active worktree via useWorktreeSession + UIState New useWorktreeSession hook watches the sidecar JSON file (created by EnterWorktreeTool, deleted by ExitWorktreeTool) and returns the current WorktreeSession or null. AppContainer wires it into a new UIState.activeWorktree field consumed by Footer (Task 6) and WorktreeExitDialog (Task 8). A showWorktreeExitDialog state placeholder is added too, hardcoded false until Task 8 wires the dialog trigger. Co-Authored-By: Claude Opus 4.7 * feat(worktree): show active worktree in Footer + StatusLine payload Footer renders `⎇ ()` when activeWorktree != null, but only when the user has no custom statusline (their script likely handles it from the stdin payload itself). useStatusLine's StatusLineCommandInput gains a `worktree` field with {name, path, branch, original_cwd, original_branch} — matches claude-code's schema so statusline scripts can be shared across both CLIs. Co-Authored-By: Claude Opus 4.7 * feat(worktree): inject context hint on --resume when worktree is active On --resume, if the session has a WorktreeSession sidecar, append an INFO history item pointing the model at the worktree path so it continues using it for file operations. Stale sidecars (worktree dir deleted out-of-band) are cleaned up so the Footer indicator doesn't go stale. qwen-code can't process.chdir() the way claude-code does because Config.targetDir is immutable; the context hint is the equivalent behavioral cue. Co-Authored-By: Claude Opus 4.7 * feat(worktree): add WorktreeExitDialog with dirty-state inspection WorktreeExitDialog renders when the user double-presses Ctrl+C inside a worktree. On mount it runs `git status --porcelain` and `git rev-list --count ..HEAD` to show how many uncommitted files and new commits the user would discard by choosing "Remove". The dialog never auto-removes — every exit goes through explicit user confirmation per requirements. handleExit in AppContainer intercepts the second-press quit when activeWorktree is set and shows the dialog instead. A new UIAction handleWorktreeExit(choice) routes the user's choice through removal (via GitWorktreeService.removeUserWorktree) + sidecar cleanup + /quit. Co-Authored-By: Claude Opus 4.7 * docs(worktree): add Phase C E2E test plan Co-Authored-By: Claude Opus 4.7 * docs(worktree): fix E2E test plan sidecar path + jq selector - sidecar lives at ~/.qwen/projects//chats/, not ~/.qwen/tmp// - qwen --output-format json emits a JSON array, not NDJSON — jq needs .[] Co-Authored-By: Claude Opus 4.7 * fix(worktree): add showWorktreeExitDialog to dialogsVisible Phase C task 8 introduced showWorktreeExitDialog state and the dialog render in DialogManager, but missed adding the flag to the dialogsVisible OR expression. DefaultAppLayout only renders DialogManager when dialogsVisible is true, so the dialog was never shown — second Ctrl+C in a worktree silently absorbed instead of triggering the prompt. Caught by Group E E2E tests. Co-Authored-By: Claude Opus 4.7 * feat(worktree): extend --resume context restore to headless + ACP modes Phase C task 7 originally placed the worktree-restore logic in AppContainer.tsx (TUI only). E2E Group C exposed that headless and ACP modes never run AppContainer, so stale sidecars accumulate and the model loses worktree context after --resume. Refactor to a shared `restoreWorktreeContext` helper in core, then wire the three entry points: - TUI (AppContainer): keep historyManager.addItem(INFO) UX, route via the helper. - Headless (nonInteractiveCli): prepend the notice as a system-reminder block on the user prompt; emit a `worktree_restored` system message to the JSON adapter so SDK consumers can react. - ACP (Session.pendingWorktreeNotice): set by acpAgent.loadSession on resume, consumed and cleared exactly once on the next #executePrompt. All three modes call the same helper, so stale-sidecar cleanup is consistent. Helper covers: missing sidecar, live worktree dir, deleted worktree dir, regular file at worktreePath, malformed JSON. 5 new unit tests for restoreWorktreeContext (13/13 pass total). Co-Authored-By: Claude Opus 4.7 * test(worktree): add ACP-mode integration tests for --resume context Covers: - acpAgent.worktree.test.ts (3 tests): loadSession sets pendingWorktreeNotice only when worktree dir is live, clears stale sidecar otherwise, swallows restoreWorktreeContext errors. - Session.worktree.test.ts (4 tests): #executePrompt prepends the system-reminder block exactly once on first prompt, clears the pending notice, second prompt sees no leakage, no-op when nothing was set. E2E via real ACP protocol is impractical without a Zed client; these tests cover the integration boundaries directly. Co-Authored-By: Claude Opus 4.7 * docs(worktree): clarify hooksPath comment + pendingWorktreeNotice one-shot rationale Two doc-only fixes from PR #4174 review: - gitWorktreeService.ts: previous hooksPath comment overstated the optimization (claimed claude-code's ~14ms saving but we still do a read subprocess). Rewrite to be explicit: write-skip only, read retained, parseGitConfigValue's full optimization deliberately not ported because the read happens once per worktree creation. - Session.ts: pendingWorktreeNotice doc now explains why it's one-shot (after the first prompt the worktree path is already in conversation context; re-injecting would clutter history without adding signal). No behavior change. Co-Authored-By: Claude Opus 4.7 * fix(test): add getResumedSessionData to nonInteractiveCli mock Config CI surfaced TypeError: config.getResumedSessionData is not a function across 12 tests in nonInteractiveCli.test.ts. The Phase C ada0837e2 commit added a worktree-restore call in the headless path that probes config.getResumedSessionData(); the mock Config never had that method. Return undefined to short-circuit the restore block — these tests don't exercise --resume. Co-Authored-By: Claude Opus 4.7 * fix(worktree): address PR #4174 reviewer findings Bundled response to the two review rounds. Per-thread replies follow. CORE — worktree sidecar robustness (Findings 3252368644, 3252368651, 3255171690): - atomicWriteJSON instead of fs.writeFile (no more half-written sidecar after a crash) - readWorktreeSession now schema-validates the parsed object and returns null on missing/wrong-type fields instead of propagating undefined into consumers - restoreWorktreeContext clears the sidecar on JSON parse failure / read I/O error so a corrupted file doesn't block every subsequent --resume CORE — hooksPath setup (Finding 3252368645): - configureHooksPath distinguishes ENOENT (benign "candidate not present") from real stat errors (EACCES/EIO/ENOTDIR); the latter are warn-logged so a silently-degraded hooksPath is visible to operators CLI — handleWorktreeExit Remove path (Findings 3252368637, 3252368640 a+b): - Anchor GitWorktreeService at activeWorktree.originalCwd (the captured repo root), not config.getTargetDir() — fixes monorepo-subdirectory launches where the worktree lives under the repo root but getTargetDir points at a subpackage - Check removeUserWorktree return value; on failure, leave the sidecar intact so --resume can recover (previous code cleared it regardless) - Pass forceDeleteBranch:true to honour the dialog's "discards N commits" label — without it `git branch -d` refused unmerged commits and the branch was silently preserved CLI — useWorktreeSession watcher (Finding 3252368648): - Normalize fs.watch filename via toString() so the Linux-Buffer code path triggers reloads (previous comparison silently never matched) - Treat null filename as "unknown, reload to be safe" (recursive watchers on some platforms emit events without a payload) CLI — WorktreeExitDialog (Findings 3252368650, 3255171694): - execGit now correctly reads numeric exit codes from .code/.status (NodeJS.ErrnoException.code is a string for spawn errors, number for subprocess exits); previous typeof === 'number' check always missed - Dialog body shows an "⚠ Could not measure worktree state (...)" banner when git status / rev-list failed, so the user doesn't see a misleading "0 files, 0 commits" before choosing Remove CLI — closeAnyOpenDialog (Round 2 review body): - Wire WorktreeExitDialog into the standard dialog-dismissal path so Ctrl+C dismisses it the same way it dismisses every other dialog TEST FIXES — vitest timeouts: - Real git invocations + user-global hooks (e.g. trustup post-commit webhooks) can take 10–20s per setUp on CI. Bump testTimeout + hookTimeout to 30s for the three integ test suites that spawn git (Phase B/C worktree integ tests) so the suite isn't flaky. NEW TESTS: - worktreeSessionService.test: 3 new cases covering malformed JSON, missing required fields, wrong-type fields, malformed sidecar cleanup, partial sidecar cleanup (16 total, up from 13). - useWorktreeSession.test.tsx: 4 new cases — null when no sidecar, parsed sidecar at mount, reacts to delete, reacts to creation. - WorktreeExitDialog.test.tsx: 1 new case — loading frame renders before git probes resolve. (Async dialog states tested via E2E — vi.mock of execFile in ink-testing-library doesn't fire mock impl reliably.) - nonInteractiveCli.test: 3 new "Phase C --resume" cases — system-reminder injection on live worktree, no injection when sidecar absent, stale sidecar cleanup when worktree dir is gone. DECLINED FINDINGS (replied on threads): - 3252368642 (Dialog Keep clears sidecar) — declined-design. Dialog Keep = "exit app, keep worktree for next --resume"; tool Keep = "I'm done with this worktree". Intentionally different semantics. - 3252368643 (originalHeadCommit base branch) — false-positive. There is no base_branch parameter; getCurrentCommitHash() returns HEAD which equals the tip of the current branch (== baseBranch in createUserWorktree). - 3252368640 part c (bypass safety guards) — declined-design. The dialog IS the safety affordance for this path — it shows dirty-state counts and asks for explicit user confirmation before removal. - 3255171696 (DialogManager async fire-and-forget) — false-positive. handleSlashCommand('/quit') is inside the await chain in handleWorktreeExit, so the described race ("process.exit before remove completes") cannot occur. Co-Authored-By: Claude Opus 4.7 * fix(test): correct linter-mangled imports in useWorktreeSession.test Pre-commit hook auto-fixed imports collapsed value imports (writeWorktreeSession, clearWorktreeSession) into an `import type` block, breaking runtime resolution. Split back into value + type imports. Co-Authored-By: Claude Opus 4.7 * fix(test): normalize path separators for Windows in worktree session integ Windows CI failure: `repoRoot` from Node's `fs.mkdtemp` returns backslash-separated paths (`C:\Users\runneradmin\…`), but `originalCwd` in the sidecar comes from `getRepoTopLevel()` which delegates to `git rev-parse --show-toplevel` — git on Windows returns forward slashes (`C:/Users/runneradmin/…`). The Windows-only assertion `expect(originalCwd).toBe(repoRoot)` was comparing two different representations of the same canonical path and rightly failed on `Object.is` equality. Compare via path.normalize on both sides so the assertion holds across platforms without changing the runtime path (originalCwd still records git's output verbatim, which is what consumers expect since other places in the codebase that read `getRepoTopLevel()` also work with that shape). Co-Authored-By: Claude Opus 4.7 * fix(worktree): address PR #4174 round 4 findings Finding #3256237933 (Critical, follow-up to #3252368640 part 1): handleWorktreeExit silently /quit'd when removeUserWorktree returned {success:false}, contradicting the user's intent after they clicked "Remove worktree and branch (discards N commits, M files)". Now surfaces an ERROR history item with the underlying error message and STAYS in the session so the user can decide what to do (retry via exit_worktree, fix the lock/permission/corruption issue, or quit anyway). Same treatment applied to the hard-failure catch block — previously it caught the throw and proceeded to /quit with no log; now it emits the error and stays alive. Finding #3256236050 (Nit): originalCwd field name implies "user's launch cwd" but actually stores `getRepoTopLevel()` (different in monorepo subdir launches — the gap closed by #3252368637). Renaming the field would force on-disk migration of every existing sidecar (every active --resume breaks until users wipe the old file). Doc-only fix: WorktreeSession.originalCwd now carries an explicit JSDoc explaining the semantics and warning consumers expecting process.cwd() to NOT use this field. Co-Authored-By: Claude Opus 4.7 * fix(worktree): address PR #4174 round 5 findings Finding #3256241831 (Nit, but awareness UX): the built-in `⎇` indicator used to disappear whenever `statusLineLines.length > 0`, on the assumption that the user's custom statusline rendered worktree itself. That assumption is unsafe — scripts written before Phase C don't know about `payload.worktree`, scripts can deliberately ignore the field, and partial scripts may render some fields but not worktree. In any of those cases the user sees no worktree UI while having an active worktree, risking destructive operations in the wrong cwd. New behavior: indicator shows by default regardless of statusline. Added an opt-out setting `ui.hideBuiltinWorktreeIndicator` (default false) for users whose custom statusline already renders worktree and want to avoid duplication. Finding #3256239608 (Nit): `fs.watch` in useWorktreeSession holds an inode handle to `chatsDir` at mount time. If the directory is deleted out-of-band (manual cleanup, antivirus quarantine, reset scripts) and recreated, the watcher does NOT re-attach to the new inode and the Footer indicator stops reacting to sidecar changes. Reviewer explicitly accepted this as a documented limitation rather than adding polling-fallback or error-event-handler complexity for an edge case that doesn't arise in normal use. Added a JSDoc block on the hook explaining the limitation and pointing to the future fix shapes. Co-Authored-By: Claude Opus 4.7 * chore(worktree): regenerate settings.schema.json for hideBuiltinWorktreeIndicator CI Lint step caught that the JSON schema mirror in packages/vscode-ide-companion was out of date after adding the new ui.hideBuiltinWorktreeIndicator setting in 80f9cb495. Regenerated via `npm run generate:settings-schema`. Co-Authored-By: Claude Opus 4.7 * fix(worktree): address PR #4174 round 6 findings Critical fixes: - #3259975247: TUI dialog Remove now reads the in-worktree session marker and refuses to delete a worktree owned by a different session — same ownership guard ExitWorktreeTool already applies. Stale/copied sidecars can no longer destroy another session's work. - #3259975249: TUI --resume queues a one-shot pendingWorktreeNotice ref consumed by handleFinalSubmit; the user's first prompt is prefixed with the same block headless/ACP use. Previously only the INFO history item showed in the transcript (UI-only), so resumed models could silently edit the parent checkout. - #3259975245: exit_worktree action='keep' no longer clears the sidecar. `keep` means "preserve the worktree for later"; clearing the persisted binding broke --resume / Footer / WorktreeExitDialog for kept worktrees. Now matches the Dialog keep semantics. Test updated to assert preservation instead of clearing. - ACP unstable_resumeSession parity: factored the worktree restore block into #restoreWorktreeOnResume() and called from both loadSession() and unstable_resumeSession(). ACP clients using resume no longer miss the worktree context. Suggestion-level fixes: - #3259975237: configureHooksPath now resolves the canonical hooks dir via `git rev-parse --git-common-dir` instead of constructing `/.git/hooks`. The construction assumed .git is a directory, but when Qwen runs from a linked worktree it's a file pointing at the real gitdir → ENOTDIR → silent no-hooks worktree. - #3259975242: only writes core.hooksPath when the key is unset. A non-empty inherited or user-configured value is preserved instead of being silently replaced. - #3256839787: restoreWorktreeContext adds a structural invariant check — worktreePath must live under /.qwen/worktrees/. A tampered/copied sidecar pointing at an arbitrary existing dir is rejected and cleared so the model can't be redirected. Tests: - worktreeSessionService.test: 17/17 (added prefix-escape rejection case + restructured the existing live-worktree case to satisfy the new structural invariant). - exit-worktree.session.integ.test: rewrote keep test to assert preservation (matches new behavior). - nonInteractiveCli.test: updated fixture worktreeDir to live under /.qwen/worktrees/ for the prefix invariant. - All other suites pass without modification. Test coverage gap acknowledgement (no comment_id reply): per-handler unit tests for handleWorktreeExit + dialog post-load states remain covered by the E2E Group E suite in docs/e2e-tests/worktree-phase-c.md. The execFile mock path in ink-testing-library still doesn't deliver async useEffect state transitions reliably, so unit testing those states adds more harness than signal; deferring. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Sonnet 4.6 --- docs/design/worktree.md | 129 +- docs/e2e-tests/worktree-phase-c.md | 594 ++++++++ .../plans/2026-05-15-worktree-phase-c.md | 1232 +++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 40 +- .../acp-integration/acpAgent.worktree.test.ts | 394 ++++++ .../src/acp-integration/session/Session.ts | 29 + .../session/Session.worktree.test.ts | 260 ++++ packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/nonInteractiveCli.test.ts | 186 +++ packages/cli/src/nonInteractiveCli.ts | 34 + packages/cli/src/ui/AppContainer.tsx | 200 ++- .../cli/src/ui/components/DialogManager.tsx | 14 + packages/cli/src/ui/components/Footer.tsx | 21 + .../ui/components/WorktreeExitDialog.test.tsx | 52 + .../src/ui/components/WorktreeExitDialog.tsx | 256 ++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 16 + packages/cli/src/ui/hooks/useDialogClose.ts | 13 + packages/cli/src/ui/hooks/useStatusLine.ts | 32 +- .../src/ui/hooks/useWorktreeSession.test.tsx | 113 ++ .../cli/src/ui/hooks/useWorktreeSession.ts | 99 ++ packages/core/src/index.ts | 1 + .../gitWorktreeService.hooks.integ.test.ts | 100 ++ .../core/src/services/gitWorktreeService.ts | 95 ++ packages/core/src/services/sessionService.ts | 9 + .../services/worktreeSessionService.test.ts | 226 +++ .../src/services/worktreeSessionService.ts | 250 ++++ .../enter-worktree.session.integ.test.ts | 103 ++ packages/core/src/tools/enter-worktree.ts | 40 + .../tools/exit-worktree.session.integ.test.ts | 156 +++ packages/core/src/tools/exit-worktree.test.ts | 5 + packages/core/src/tools/exit-worktree.ts | 39 + .../schemas/settings.schema.json | 5 + 33 files changed, 4718 insertions(+), 39 deletions(-) create mode 100644 docs/e2e-tests/worktree-phase-c.md create mode 100644 docs/superpowers/plans/2026-05-15-worktree-phase-c.md create mode 100644 packages/cli/src/acp-integration/acpAgent.worktree.test.ts create mode 100644 packages/cli/src/acp-integration/session/Session.worktree.test.ts create mode 100644 packages/cli/src/ui/components/WorktreeExitDialog.test.tsx create mode 100644 packages/cli/src/ui/components/WorktreeExitDialog.tsx create mode 100644 packages/cli/src/ui/hooks/useWorktreeSession.test.tsx create mode 100644 packages/cli/src/ui/hooks/useWorktreeSession.ts create mode 100644 packages/core/src/services/gitWorktreeService.hooks.integ.test.ts create mode 100644 packages/core/src/services/worktreeSessionService.test.ts create mode 100644 packages/core/src/services/worktreeSessionService.ts create mode 100644 packages/core/src/tools/enter-worktree.session.integ.test.ts create mode 100644 packages/core/src/tools/exit-worktree.session.integ.test.ts diff --git a/docs/design/worktree.md b/docs/design/worktree.md index 4de9968b7aa..8c9d4f0d56d 100644 --- a/docs/design/worktree.md +++ b/docs/design/worktree.md @@ -8,23 +8,23 @@ qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实 ## 现状对比 -| 功能 | qwen-code | claude-code | -| --------------------------------- | --------------- | ----------- | -| `EnterWorktree` 工具 | ❌ | ✅ | -| `ExitWorktree` 工具 | ❌ | ✅ | -| AgentTool `isolation: 'worktree'` | ❌ | ✅ | -| worktree 会话状态持久化与恢复 | ❌ | ✅ | -| 过期 worktree 自动清理 | ❌ | ✅ | -| Post-creation setup(hooks 配置) | ❌ | ✅ | -| StatusLine worktree 状态展示 | ❌ | ✅ | -| WorktreeExitDialog(退出提示) | ❌ | ✅ | -| 符号链接目录(node_modules 等) | ❌ | ✅ | -| sparse checkout | ❌ | ✅ | -| `--worktree` CLI 启动标志 | ❌ | ✅ | -| tmux 集成 | ❌ | ✅ | -| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | -| 脏状态覆盖(stash + copy) | ✅ | ✅ | -| Baseline commit 追踪 | ✅(qwen 独有) | ❌ | +| 功能 | qwen-code | claude-code | 阶段 | +| --------------------------------- | --------------- | ----------- | ------- | +| `EnterWorktree` 工具 | ✅(Phase A) | ✅ | — | +| `ExitWorktree` 工具 | ✅(Phase A) | ✅ | — | +| AgentTool `isolation: 'worktree'` | ✅(Phase B) | ✅ | — | +| 过期 worktree 自动清理 | ✅(Phase B) | ✅ | — | +| worktree 会话状态持久化与恢复 | ❌ | ✅ | Phase C | +| Post-creation setup(hooks 配置) | ❌ | ✅ | Phase C | +| StatusLine worktree 状态展示 | ❌ | ✅ | Phase C | +| WorktreeExitDialog(退出提示) | ❌ | ✅ | Phase C | +| `--worktree` CLI 启动标志 | ❌ | ✅ | Phase D | +| 符号链接目录(node_modules 等) | ❌ | ✅ | Phase D | +| sparse checkout | ❌ | ✅ | Future | +| tmux 集成 | ❌ | ✅ | Future | +| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | — | +| 脏状态覆盖(stash + copy) | ✅ | ✅ | — | +| Baseline commit 追踪 | ✅(qwen 独有) | ❌ | — | ## 设计原则 @@ -54,14 +54,14 @@ AgentTool 的 `isolation: 'worktree'` 只走通用路径,Arena 内部不经过 Arena 的 worktree 路径由 `agents.arena.worktreeBaseDir` 控制,默认 `~/.qwen/arena`(`ArenaManager.ts:125`),与通用路径完全独立,不做任何改动。 -### 扩展配置(暂缓至 Phase C/D) +### 扩展配置 | 配置项 | 类型 | 用途 | 阶段 | | ----------------------------- | ---------- | -------------------------------------------------------------- | ------- | -| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase C | -| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Phase D | +| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase D | +| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future | -Phase A / B 不新增任何配置项。 +Phase A / B / C 不新增任何配置项。 ## 工具设计 @@ -187,27 +187,88 @@ _无需改动:_ --- -### Phase C:体验优化(Post-creation setup + UI) +### Phase C:会话完整性(SessionService 持久化 + UI 安全网) -**目标:** worktree 创建后自动初始化环境,状态在界面上可见。 +**目标:** worktree 状态在会话中断后可恢复,用户在界面上始终知道自己在哪个 worktree 里,退出会话时有安全提示。 **要实现的功能:** -- Post-creation setup:配置 `core.hooksPath` 指向主仓库(qwen-code 无 `settings.local.json` 概念,不需要复制) -- StatusLine 展示当前 worktree 名称 / 分支 -- WorktreeExitDialog:会话退出时(检测到 worktree 仍活跃)提示用户选择 keep 或 remove -- 新增 `worktree.symlinkDirectories` 配置项,实现目录符号链接 +_SessionService worktree 状态持久化 + `--resume` 恢复:_ + +- `SessionService` 扩展 `WorktreeSession` 字段,记录 `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch }` +- `EnterWorktreeTool` 调用 `sessionService.setWorktreeSession()` 写入状态 +- `ExitWorktreeTool` 调用 `sessionService.clearWorktreeSession()` 清除状态 +- `--resume` 启动路径读取该字段,恢复 `targetDir` 并向模型注入上下文提示 + +_Post-creation setup:_ + +- 创建 worktree 后自动执行 `git config core.hooksPath /.git/hooks`,确保 worktree 内的提交与主仓库 hooks 行为一致 + +_StatusLine worktree 展示:_ + +- `UIStateContext` 新增 `activeWorktree` 字段(从 session 状态读取),在会话进入 / 退出 worktree 时更新 +- `StatusLineCommandInput` payload 新增 `worktree?: { slug: string; branch: string }` 字段,供用户 statusline 脚本使用 +- `Footer` 在 `activeWorktree` 非空时内置展示一行 `⎇ ()`,无需用户配置 statusline 脚本即可获得基本可见性 + +_WorktreeExitDialog:_ + +- 新增 `WorktreeExitDialog.tsx` 组件,参考现有 Dialog 写法 +- 修改退出键(Ctrl+C / Ctrl+D)处理逻辑:检测到 `activeWorktree` 非空时,拦截第二次确认,展示 Dialog 提示用户选择 keep 或 remove +- keep / remove 操作复用 `ExitWorktreeTool` 的现有路径 + +**影响文件:** + +| 文件 | 变更类型 | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `packages/core/src/services/sessionService.ts` | 新增 `WorktreeSession` 字段及读写方法 | +| `packages/core/src/tools/enter-worktree.ts` | 调用 `sessionService.setWorktreeSession()` | +| `packages/core/src/tools/exit-worktree.ts` | 调用 `sessionService.clearWorktreeSession()` | +| `packages/core/src/services/gitWorktreeService.ts` | `createUserWorktree()` / `createAgentWorktree()` 后追加 `core.hooksPath` 配置 | +| `packages/cli/src/ui/contexts/UIStateContext.tsx` | 新增 `activeWorktree` 字段及 set/clear action | +| `packages/cli/src/ui/hooks/useStatusLine.ts` | `StatusLineCommandInput` 新增 `worktree` 字段 | +| `packages/cli/src/ui/components/Footer.tsx` | 内置 worktree 行展示 | +| `packages/cli/src/ui/components/WorktreeExitDialog.tsx` | 新建 | +| `packages/cli/src/ui/components/DialogManager.tsx` | 注册 `WorktreeExitDialog` | +| `packages/cli/src/ui/components/ExitWarning.tsx` 或退出键处理 | 检测 `activeWorktree` 并拦截退出 | --- -### Phase D:高级功能 +### Phase D:启动时配置(`--worktree` CLI 标志 + 目录符号链接) -**目标:** 对齐 claude-code 的完整特性集。 +**目标:** 支持在启动时直接进入 worktree,并通过目录符号链接减少大型项目的磁盘开销。 **要实现的功能:** -- `--worktree [name]` CLI 启动标志:启动时直接创建 worktree,整个会话在隔离环境中运行 -- sparse checkout 支持:新增 `worktree.sparsePaths` 配置项 -- `.worktreeinclude` 文件:支持将 gitignore 的文件复制到 worktree -- tmux 集成:`--worktree --tmux` 在 tmux 会话中启动 -- PR 引用解析:`--worktree=#123` 自动 fetch 并基于 PR 创建 worktree +_`--worktree [name]` CLI 启动标志:_ + +- `packages/cli/src/args.ts` 新增 `--worktree [name]` 参数 +- 启动流程在进入主循环前调用 `createUserWorktree()`,将 `targetDir` 设为 worktree 路径,并写入 SessionService 状态 +- 整个会话从启动即在 worktree 环境中运行,退出时触发 WorktreeExitDialog + +_`worktree.symlinkDirectories` 配置项:_ + +- settings schema 新增 `worktree.symlinkDirectories: string[]` +- `createUserWorktree()` 后遍历配置,调用 `fs.symlink()` 将主仓库目录链接进 worktree +- 跳过目标不存在的项;目标已存在时跳过(不覆盖) + +**影响文件:** + +| 文件 | 变更类型 | +| -------------------------------------------------- | ------------------------------------------- | +| `packages/cli/src/args.ts` | 新增 `--worktree [name]` 参数 | +| `packages/cli/src/main.ts`(或启动入口) | 解析 `--worktree` 并在主循环前创建 worktree | +| `packages/core/src/services/gitWorktreeService.ts` | `createUserWorktree()` 后追加 symlink 逻辑 | +| `packages/core/src/config/`(settings schema) | 新增 `worktree.symlinkDirectories` 字段 | + +--- + +### Future:高级功能(按需实现) + +以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。 + +| 功能 | 说明 | +| ----------------------- | ------------------------------------------------------------------------------------------- | +| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 | +| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree | +| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 | +| PR 引用解析 | `--worktree=#123` 自动 fetch PR 分支并基于它创建 worktree(依赖 Phase D `--worktree` 标志) | diff --git a/docs/e2e-tests/worktree-phase-c.md b/docs/e2e-tests/worktree-phase-c.md new file mode 100644 index 00000000000..8f7ae8d6c80 --- /dev/null +++ b/docs/e2e-tests/worktree-phase-c.md @@ -0,0 +1,594 @@ +# Worktree Phase C E2E Test Plan + +## Scope + +End-to-end verification of Phase C features against the local build at +`/Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js`. + +Phase C delivers: + +- **Task 1, 3, 4** — `WorktreeSession` sidecar JSON file at + `~/.qwen/tmp//chats/.worktree.json` +- **Task 2** — `core.hooksPath` configured inside new worktrees +- **Task 5–6** — `useWorktreeSession` hook, `UIState.activeWorktree`, Footer + worktree indicator, `StatusLineCommandInput.worktree` field +- **Task 7** — `--resume` injects an INFO history item when active worktree + still exists; cleans up stale sidecar otherwise +- **Task 8** — `WorktreeExitDialog` with dirty-state inspection, intercepts + second Ctrl+C in active worktree + +## Binaries + +- **Local build**: `node /Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js` +- **Baseline (for pre-impl comparison if needed)**: globally installed `qwen` + +## Test environment template + +Each group runs in its own temp git repo and tmux session: + +```bash +TEST_DIR=$(mktemp -d -t qwen-wt-phc-XXXXXX) +TEST_DIR=$(cd "$TEST_DIR" && pwd -P) # resolve symlinks (macOS /var → /private/var) +cd "$TEST_DIR" +git init -q -b main +git config user.email t@e.com +git config user.name t +git config commit.gpgsign false +echo "hello" > README.md +git add README.md +git commit -q -m "initial" --no-verify +``` + +`QWEN=/Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js` + +--- + +## Group A: WorktreeSession sidecar (headless) + +**Mode:** headless, `--approval-mode yolo`, `--output-format json` + +### A1: enter_worktree writes sidecar with all fields + +**Steps:** + +```bash +SESSION=$(node $QWEN "use the enter_worktree tool with name='a1-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq -r '.[] | select(.type=="system") | .session_id' | head -1) + +PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR") +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json + +# Verify all fields present +cat "$SIDECAR" | jq '.slug, .worktreePath, .worktreeBranch, .originalCwd, .originalBranch, .originalHeadCommit' +``` + +**Expected:** + +- `slug` = "a1-test" +- `worktreePath` ends with `.qwen/worktrees/a1-test` +- `worktreeBranch` = "worktree-a1-test" +- `originalCwd` = `$TEST_DIR` (resolved) +- `originalBranch` = "main" +- `originalHeadCommit` matches `[0-9a-f]{40}` + +### A2: exit_worktree (keep) clears sidecar + +**Steps:** + +```bash +SESSION=$(node $QWEN "create a worktree named 'a2-test' using enter_worktree, then immediately exit it with action='keep' using exit_worktree" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq -r '.[] | select(.type=="system") | .session_id' | head -1) + +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json +test ! -f "$SIDECAR" && echo "PASS: sidecar removed" || echo "FAIL: sidecar still exists" +``` + +**Expected:** sidecar file does not exist after the exit_worktree call. + +### A3: exit_worktree (remove) clears sidecar + +**Steps:** + +```bash +SESSION=$(node $QWEN "create a worktree named 'a3-test' using enter_worktree, then immediately exit it with action='remove' and discard_changes=true using exit_worktree" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq -r '.[] | select(.type=="system") | .session_id' | head -1) + +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json +test ! -f "$SIDECAR" && echo "PASS: sidecar removed" || echo "FAIL: sidecar still exists" +# Also verify the worktree dir is gone +test ! -d "$TEST_DIR/.qwen/worktrees/a3-test" && echo "PASS: worktree dir removed" +``` + +**Expected:** both the sidecar AND the worktree directory are gone. + +--- + +## Group B: hooksPath configuration (headless) + +### B1: Without `.husky/`, hooksPath = `/.git/hooks` + +**Steps:** + +```bash +node $QWEN "use enter_worktree with name='b1-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null > /dev/null + +HOOKS_PATH=$(git -C "$TEST_DIR/.qwen/worktrees/b1-test" config --local core.hooksPath) +echo "Got hooksPath: $HOOKS_PATH" +test "$HOOKS_PATH" = "$TEST_DIR/.git/hooks" && echo "PASS" || echo "FAIL" +``` + +**Expected:** `$TEST_DIR/.git/hooks` + +### B2: With `.husky/`, hooksPath = `/.husky` + +**Steps:** + +```bash +mkdir -p "$TEST_DIR/.husky" +echo '#!/bin/sh' > "$TEST_DIR/.husky/pre-commit" +chmod +x "$TEST_DIR/.husky/pre-commit" + +node $QWEN "use enter_worktree with name='b2-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null > /dev/null + +HOOKS_PATH=$(git -C "$TEST_DIR/.qwen/worktrees/b2-test" config --local core.hooksPath) +test "$HOOKS_PATH" = "$TEST_DIR/.husky" && echo "PASS" || echo "FAIL got=$HOOKS_PATH" +``` + +**Expected:** `$TEST_DIR/.husky` + +### B3: Hooks in main repo actually fire from inside worktree + +**Steps:** + +```bash +# Set up a hook that writes a marker file +mkdir -p "$TEST_DIR/.git/hooks" +cat > "$TEST_DIR/.git/hooks/pre-commit" <<'EOF' +#!/bin/sh +echo "hook-fired" > /tmp/qwen-wt-hook-marker +EOF +chmod +x "$TEST_DIR/.git/hooks/pre-commit" + +node $QWEN "use enter_worktree with name='b3-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null > /dev/null + +# Commit something inside the worktree +WT="$TEST_DIR/.qwen/worktrees/b3-test" +echo "x" > "$WT/file.txt" +git -C "$WT" add file.txt +rm -f /tmp/qwen-wt-hook-marker +git -C "$WT" commit -m "trigger hook" 2>&1 +test -f /tmp/qwen-wt-hook-marker && echo "PASS: hook fired" || echo "FAIL: hook did not fire" +rm -f /tmp/qwen-wt-hook-marker +``` + +**Expected:** `/tmp/qwen-wt-hook-marker` exists after the commit. + +--- + +## Group C: --resume worktree restoration (headless) + +### C1: --resume injects worktree context when sidecar present and dir alive + +**Steps:** + +```bash +# Create initial session with worktree +INIT_OUT=$(node $QWEN "use enter_worktree with name='c1-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null) +SESSION=$(echo "$INIT_OUT" | jq -r '.[] | select(.type=="system") | .session_id' | head -1) + +# Resume the session and ask "what's my context?" +RESUMED=$(node $QWEN --resume "$SESSION" "say SIDECAR-CONFIRM" \ + --approval-mode yolo --output-format json 2>/dev/null) + +# Look for the injected INFO message text in the conversation +echo "$RESUMED" | grep -q "Resumed.*Active worktree.*c1-test" && echo "PASS" || echo "FAIL: no context injection" +``` + +**Expected:** the JSON stream contains an INFO message referencing `c1-test`. + +### C2: --resume cleans up stale sidecar when worktree dir is gone + +**Steps:** + +```bash +INIT_OUT=$(node $QWEN "use enter_worktree with name='c2-test' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null) +SESSION=$(echo "$INIT_OUT" | jq -r '.[] | select(.type=="system") | .session_id' | head -1) +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json + +# Delete the worktree directory out-of-band +rm -rf "$TEST_DIR/.qwen/worktrees/c2-test" +test -f "$SIDECAR" || { echo "SKIP: sidecar was already gone"; exit 0; } + +# Resume — should clean up the stale sidecar +node $QWEN --resume "$SESSION" "hello" --approval-mode yolo --output-format json 2>/dev/null > /dev/null +test ! -f "$SIDECAR" && echo "PASS: stale sidecar cleaned" || echo "FAIL: stale sidecar still present" +``` + +**Expected:** sidecar file is removed. + +--- + +## Group D: Footer worktree indicator (interactive tmux) + +### D1: Footer shows worktree indicator after enter_worktree + +**Steps:** + +```bash +tmux new-session -d -s wt-d1 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-d1 "use enter_worktree with name='d1-test'" +sleep 0.5 +tmux send-keys -t wt-d1 Enter + +for i in $(seq 1 30); do + sleep 2 + tmux capture-pane -t wt-d1 -p | grep -q "Type your message" && break +done + +# Capture and look for the worktree indicator line in Footer area +tmux capture-pane -t wt-d1 -p -S -100 > /tmp/wt-d1.out +grep -E "⎇.*worktree-d1-test.*\(d1-test\)" /tmp/wt-d1.out && echo "PASS" || \ + { echo "FAIL — captured output:"; cat /tmp/wt-d1.out; } +tmux kill-session -t wt-d1 +``` + +**Expected:** Footer contains a line like `⎇ worktree-d1-test (d1-test)`. + +### D2: Footer indicator disappears after exit_worktree (keep) + +**Steps:** + +```bash +tmux new-session -d -s wt-d2 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-d2 "use enter_worktree with name='d2-test'" +sleep 0.5 +tmux send-keys -t wt-d2 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-d2 -p | grep -q "Type your message" && break; done + +# Verify indicator showed +tmux capture-pane -t wt-d2 -p -S -100 | grep -q "⎇.*d2-test" || { echo "FAIL: indicator missing before exit"; tmux kill-session -t wt-d2; exit 1; } + +# Exit the worktree (keep) +tmux send-keys -t wt-d2 "use exit_worktree with name='d2-test' action='keep'" +sleep 0.5 +tmux send-keys -t wt-d2 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-d2 -p | grep -q "Kept worktree" && break; done + +sleep 2 # give Footer a tick to refresh after sidecar removal +tmux capture-pane -t wt-d2 -p -S -100 > /tmp/wt-d2-after.out +# After exit, the indicator should be gone from the bottom panel area +tail -5 /tmp/wt-d2-after.out | grep -q "⎇.*d2-test" && \ + echo "FAIL: indicator still showing" || echo "PASS" +tmux kill-session -t wt-d2 +``` + +**Expected:** worktree indicator disappears from Footer within ~2s of `exit_worktree`. + +--- + +## Group E: WorktreeExitDialog (interactive tmux) + +### E1: Second Ctrl+C in worktree shows dialog instead of quitting + +**Steps:** + +```bash +tmux new-session -d -s wt-e1 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-e1 "use enter_worktree with name='e1-test'" +sleep 0.5 +tmux send-keys -t wt-e1 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e1 -p | grep -q "Type your message" && break; done + +# First Ctrl+C (cleanup; should show "Press Ctrl+C again to exit") +tmux send-keys -t wt-e1 C-c +sleep 0.3 +tmux capture-pane -t wt-e1 -p | grep -q "Press Ctrl+C again" || \ + { echo "FAIL: first Ctrl+C didn't show warning"; tmux kill-session -t wt-e1; exit 1; } + +# Second Ctrl+C — should show the WorktreeExitDialog, NOT quit +tmux send-keys -t wt-e1 C-c +sleep 2 + +# Verify the dialog rendered +tmux capture-pane -t wt-e1 -p -S -50 > /tmp/wt-e1.out +grep -q "Active worktree.*e1-test" /tmp/wt-e1.out && \ + grep -q "Keep worktree" /tmp/wt-e1.out && \ + grep -q "Remove worktree" /tmp/wt-e1.out && \ + echo "PASS" || { echo "FAIL — captured:"; cat /tmp/wt-e1.out; } +tmux kill-session -t wt-e1 +``` + +**Expected:** dialog shows three options (Keep / Remove / Cancel) and process is still alive. + +### E2: Dialog shows dirty-state counts (commits + files) + +**Steps:** + +```bash +tmux new-session -d -s wt-e2 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-e2 "use enter_worktree with name='e2-test'" +sleep 0.5 +tmux send-keys -t wt-e2 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e2 -p | grep -q "Type your message" && break; done + +# Make the worktree dirty: 1 new commit + 1 uncommitted file +WT="$TEST_DIR/.qwen/worktrees/e2-test" +echo "new" > "$WT/new.txt" +git -C "$WT" add new.txt +git -C "$WT" commit -q -m "test commit" --no-verify +echo "dirty" > "$WT/uncommitted.txt" + +# Trigger exit dialog via Ctrl+C double-press +tmux send-keys -t wt-e2 C-c +sleep 0.3 +tmux send-keys -t wt-e2 C-c +sleep 3 # allow time for git status / rev-list + +tmux capture-pane -t wt-e2 -p -S -50 > /tmp/wt-e2.out +grep -qE "new commit|uncommitted file" /tmp/wt-e2.out && echo "PASS" || \ + { echo "FAIL — captured:"; cat /tmp/wt-e2.out; } +tmux kill-session -t wt-e2 +``` + +**Expected:** dialog body contains both "X new commit(s)" and "Y uncommitted file(s)". + +### E3: Cancel option dismisses dialog without exiting + +**Steps:** + +```bash +tmux new-session -d -s wt-e3 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-e3 "use enter_worktree with name='e3-test'" +sleep 0.5 +tmux send-keys -t wt-e3 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e3 -p | grep -q "Type your message" && break; done + +# Trigger dialog +tmux send-keys -t wt-e3 C-c +sleep 0.3 +tmux send-keys -t wt-e3 C-c +sleep 3 + +# Navigate to Cancel (DOWN DOWN) and press Enter +tmux send-keys -t wt-e3 Down +sleep 0.2 +tmux send-keys -t wt-e3 Down +sleep 0.2 +tmux send-keys -t wt-e3 Enter +sleep 2 + +# Dialog should be gone; input prompt should be back +tmux capture-pane -t wt-e3 -p | grep -q "Type your message" && echo "PASS" || \ + { echo "FAIL — captured:"; tmux capture-pane -t wt-e3 -p; } + +# Verify the worktree was NOT removed +test -d "$TEST_DIR/.qwen/worktrees/e3-test" && echo "worktree intact" || echo "FAIL: worktree gone" +tmux kill-session -t wt-e3 +``` + +**Expected:** dialog closes, input prompt returns, worktree directory still exists. + +### E4: Keep option exits session but preserves worktree + +**Steps:** + +```bash +tmux new-session -d -s wt-e4 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-e4 "use enter_worktree with name='e4-test'" +sleep 0.5 +tmux send-keys -t wt-e4 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e4 -p | grep -q "Type your message" && break; done + +# Trigger dialog and pick Keep (first option, already selected) +tmux send-keys -t wt-e4 C-c +sleep 0.3 +tmux send-keys -t wt-e4 C-c +sleep 3 +tmux send-keys -t wt-e4 Enter + +# Wait for process to exit +for i in $(seq 1 20); do + sleep 1 + tmux has-session -t wt-e4 2>/dev/null || break + tmux capture-pane -t wt-e4 -p | grep -q "\$ " && break # shell prompt back +done + +# Worktree directory should still exist +test -d "$TEST_DIR/.qwen/worktrees/e4-test" && echo "PASS: worktree preserved" || \ + echo "FAIL: worktree was removed" +tmux kill-session -t wt-e4 2>/dev/null || true +``` + +**Expected:** process exits, worktree directory remains on disk. + +### E5: Remove option exits session and deletes worktree + +**Steps:** + +```bash +tmux new-session -d -s wt-e5 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +tmux send-keys -t wt-e5 "use enter_worktree with name='e5-test'" +sleep 0.5 +tmux send-keys -t wt-e5 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e5 -p | grep -q "Type your message" && break; done + +# Trigger dialog and pick Remove (DOWN, Enter) +tmux send-keys -t wt-e5 C-c +sleep 0.3 +tmux send-keys -t wt-e5 C-c +sleep 3 +tmux send-keys -t wt-e5 Down +sleep 0.2 +tmux send-keys -t wt-e5 Enter + +# Wait for exit +for i in $(seq 1 20); do + sleep 1 + tmux has-session -t wt-e5 2>/dev/null || break + tmux capture-pane -t wt-e5 -p | grep -q "\$ " && break +done + +# Worktree directory should be GONE +test ! -d "$TEST_DIR/.qwen/worktrees/e5-test" && echo "PASS: worktree removed" || \ + echo "FAIL: worktree still on disk" +# Branch should also be deleted +git -C "$TEST_DIR" branch --list | grep -q "worktree-e5-test" && \ + echo "FAIL: branch still present" || echo "PASS: branch removed" +tmux kill-session -t wt-e5 2>/dev/null || true +``` + +**Expected:** process exits, worktree directory deleted, branch `worktree-e5-test` deleted. + +--- + +## Group F: Real-user workflow simulation (interactive tmux) + +### F1: Full enter → edit → commit → resume → exit (keep) flow + +**Steps:** + +```bash +tmux new-session -d -s wt-f1 -x 200 -y 50 \ + "cd $TEST_DIR && node $QWEN --approval-mode yolo" +sleep 3 + +# Step 1: enter worktree +tmux send-keys -t wt-f1 "use enter_worktree with name='f1-feature' to create a worktree" +sleep 0.5 +tmux send-keys -t wt-f1 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Type your message" && break; done + +# Step 2: read the absolute worktree path so the model knows where to write +WT="$TEST_DIR/.qwen/worktrees/f1-feature" +tmux send-keys -t wt-f1 "write the file $WT/hello.txt with content 'hi from worktree'" +sleep 0.5 +tmux send-keys -t wt-f1 Enter +for i in $(seq 1 60); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Type your message" && break; done + +# Verify the file was actually written INSIDE the worktree +test -f "$WT/hello.txt" && grep -q "hi from worktree" "$WT/hello.txt" && \ + echo "PASS: file written inside worktree" || echo "FAIL: file not in worktree" + +# Step 3: Exit with keep via the tool +tmux send-keys -t wt-f1 "use exit_worktree with name='f1-feature' action='keep'" +sleep 0.5 +tmux send-keys -t wt-f1 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Kept worktree" && break; done + +# Step 4: Verify worktree still on disk after exit +test -d "$WT" && echo "PASS: worktree kept" || echo "FAIL: worktree removed" +test -f "$WT/hello.txt" && echo "PASS: file persists" || echo "FAIL" + +tmux kill-session -t wt-f1 +``` + +**Expected:** + +- File written to worktree directory (not main repo) +- After exit `keep`, both the worktree directory and the file remain + +### F2: Custom statusline receives `worktree` payload + +**Steps:** + +```bash +# Create a statusline script that prints the JSON it receives via stdin +SETTINGS_DIR=~/.qwen +SETTINGS_FILE=$SETTINGS_DIR/settings.json +cp -f "$SETTINGS_FILE" /tmp/qwen-settings-backup.json 2>/dev/null || true +mkdir -p "$SETTINGS_DIR" +SL_SCRIPT=/tmp/qwen-wt-statusline.sh +cat > $SL_SCRIPT <<'EOF' +#!/bin/sh +INPUT=$(cat) +echo "$INPUT" > /tmp/qwen-wt-statusline-input.json +WT_NAME=$(echo "$INPUT" | jq -r '.worktree.name // "no-worktree"') +echo "WT=$WT_NAME" +EOF +chmod +x $SL_SCRIPT + +cat > "$SETTINGS_FILE" <