Skip to content

Commit ada0837

Browse files
LaZzyManclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e847bfc commit ada0837

6 files changed

Lines changed: 214 additions & 30 deletions

File tree

packages/cli/src/acp-integration/acpAgent.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SessionStartSource,
2323
SessionEndReason,
2424
type PermissionMode,
25+
restoreWorktreeContext,
2526
} from '@qwen-code/qwen-code-core';
2627
import {
2728
AgentSideConnection,
@@ -325,7 +326,26 @@ class QwenAgent implements Agent {
325326
this.setupFileSystem(config);
326327

327328
const sessionData = config.getResumedSessionData();
328-
await this.createAndStoreSession(config, sessionData?.conversation);
329+
const session = await this.createAndStoreSession(
330+
config,
331+
sessionData?.conversation,
332+
);
333+
334+
// Phase C: restore worktree context on --resume. Cleans up stale
335+
// sidecars (worktree dir deleted out-of-band) and queues a notice for
336+
// the next prompt when the worktree is alive. Best-effort: failures
337+
// don't block session load.
338+
try {
339+
const sessionPath = config
340+
.getSessionService()
341+
.getWorktreeSessionPath(config.getSessionId());
342+
const restored = await restoreWorktreeContext(sessionPath);
343+
if (restored.contextMessage) {
344+
session.pendingWorktreeNotice = restored.contextMessage;
345+
}
346+
} catch (error) {
347+
debugLogger.warn(`ACP worktree restore failed: ${error}`);
348+
}
329349

330350
const modesData = this.buildModesData(config);
331351
const availableModels = this.buildAvailableModels(config);

packages/cli/src/acp-integration/session/Session.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ export class Session implements SessionContext {
158158
// Message rewrite middleware (optional, installed after history replay)
159159
messageRewriter?: MessageRewriteMiddleware;
160160

161+
/**
162+
* Phase C worktree restore notice. Set by acpAgent.loadSession when a
163+
* resumed session has a live worktree sidecar; prepended to the next
164+
* #executePrompt call as a <system-reminder>, then cleared. TUI uses
165+
* historyManager.addItem(INFO) for the equivalent UX hint and headless
166+
* prepends to the single shot prompt — all three modes share the
167+
* `restoreWorktreeContext` helper that produces this string.
168+
*/
169+
pendingWorktreeNotice: string | null = null;
170+
161171
// Implement SessionContext interface
162172
readonly sessionId: string;
163173

@@ -530,6 +540,20 @@ export class Session implements SessionContext {
530540
parts = [...systemReminders, ...parts];
531541
}
532542

543+
// Phase C: one-shot worktree restore notice, set by acpAgent on
544+
// --resume / loadSession when the session's worktree is still alive.
545+
// Prepended exactly once, then cleared so it doesn't repeat on
546+
// subsequent turns.
547+
if (this.pendingWorktreeNotice) {
548+
parts = [
549+
{
550+
text: `<system-reminder>\n${this.pendingWorktreeNotice}\n</system-reminder>\n\n`,
551+
},
552+
...parts,
553+
];
554+
this.pendingWorktreeNotice = null;
555+
}
556+
533557
let nextMessage: Content | null = { role: 'user', parts };
534558

535559
while (nextMessage !== null) {

packages/cli/src/nonInteractiveCli.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
parseAndFormatApiError,
2727
createDebugLogger,
2828
SendMessageType,
29+
restoreWorktreeContext,
2930
} from '@qwen-code/qwen-code-core';
3031
import type { Content, Part, PartListUnion } from '@google/genai';
3132
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
@@ -375,6 +376,39 @@ export async function runNonInteractive(
375376
initialPartList = [{ text: input }];
376377
}
377378

379+
// Phase C: when --resume restored a session with an active worktree,
380+
// prepend a system-reminder block to the user prompt so the model
381+
// knows to keep using the worktree path. Stale sidecars (worktree
382+
// dir deleted between sessions) are cleaned up inside the helper.
383+
// TUI does this via historyManager.addItem(INFO); headless does it
384+
// here because there is no UI history to write into.
385+
if (config.getResumedSessionData()) {
386+
try {
387+
const sessionPath = config
388+
.getSessionService()
389+
.getWorktreeSessionPath(sessionId);
390+
const restored = await restoreWorktreeContext(sessionPath);
391+
if (restored.contextMessage) {
392+
const reminderPart: Part = {
393+
text: `<system-reminder>\n${restored.contextMessage}\n</system-reminder>\n\n`,
394+
};
395+
const partsArr = Array.isArray(initialPartList)
396+
? initialPartList
397+
: [initialPartList];
398+
initialPartList = [reminderPart, ...partsArr];
399+
// Also surface the notice in the JSON stream so SDK consumers
400+
// can react to it (logging, UI hints, etc.).
401+
adapter.emitSystemMessage('worktree_restored', {
402+
slug: restored.session?.slug,
403+
path: restored.session?.worktreePath,
404+
branch: restored.session?.worktreeBranch,
405+
});
406+
}
407+
} catch (error) {
408+
debugLogger.warn(`worktree restore failed (non-fatal):`, error);
409+
}
410+
}
411+
378412
const initialParts = normalizePartList(initialPartList);
379413
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
380414

packages/cli/src/ui/AppContainer.tsx

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,10 @@ import {
6060
ToolConfirmationOutcome,
6161
type WaitingToolCall,
6262
ToolNames,
63-
readWorktreeSession,
6463
clearWorktreeSession,
64+
restoreWorktreeContext,
6565
GitWorktreeService,
6666
} from '@qwen-code/qwen-code-core';
67-
import * as fsPromises from 'node:fs/promises';
6867
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
6968
import {
7069
getStickyTodos,
@@ -509,37 +508,22 @@ export const AppContainer = (props: AppContainerProps) => {
509508
setSessionName(title);
510509
}
511510

512-
// If the resumed session had an active worktree, inject a context
513-
// message so the model immediately knows to keep using the
514-
// worktree path for file operations (qwen-code can't `chdir` the
515-
// way claude-code does — Config.targetDir is immutable).
516-
//
517-
// Stale sidecars (worktree dir deleted between sessions) get
518-
// cleaned up so Footer / useWorktreeSession don't show a phantom
519-
// worktree indicator.
511+
// Restore worktree context (shared logic — headless and ACP use
512+
// the same helper). Stale sidecars get cleaned up; live ones
513+
// produce an INFO message the model sees on the next turn.
520514
try {
521515
const sessionPath = config
522516
.getSessionService()
523517
.getWorktreeSessionPath(config.getSessionId());
524-
const ws = await readWorktreeSession(sessionPath);
525-
if (ws) {
526-
const worktreeAlive = await fsPromises
527-
.stat(ws.worktreePath)
528-
.then((s) => s.isDirectory())
529-
.catch(() => false);
530-
if (worktreeAlive) {
531-
historyManager.addItem(
532-
{
533-
type: MessageType.INFO,
534-
text:
535-
`[Resumed] Active worktree: "${ws.slug}" at ${ws.worktreePath} ` +
536-
`(branch: ${ws.worktreeBranch}). Continue using this path for all file operations.`,
537-
},
538-
Date.now(),
539-
);
540-
} else {
541-
await clearWorktreeSession(sessionPath);
542-
}
518+
const restored = await restoreWorktreeContext(sessionPath, (err) => {
519+
// eslint-disable-next-line no-console
520+
console.debug('worktree session restore warning:', err);
521+
});
522+
if (restored.contextMessage) {
523+
historyManager.addItem(
524+
{ type: MessageType.INFO, text: restored.contextMessage },
525+
Date.now(),
526+
);
543527
}
544528
} catch (error) {
545529
// Best-effort: failures here only affect UI hint visibility,

packages/core/src/services/worktreeSessionService.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
readWorktreeSession,
1313
writeWorktreeSession,
1414
clearWorktreeSession,
15+
restoreWorktreeContext,
1516
type WorktreeSession,
1617
} from './worktreeSessionService.js';
1718

@@ -84,3 +85,58 @@ describe('clearWorktreeSession', () => {
8485
await expect(clearWorktreeSession(filePath)).resolves.not.toThrow();
8586
});
8687
});
88+
89+
describe('restoreWorktreeContext', () => {
90+
it('returns nulls when no sidecar exists', async () => {
91+
const result = await restoreWorktreeContext(filePath);
92+
expect(result.session).toBeNull();
93+
expect(result.contextMessage).toBeNull();
94+
});
95+
96+
it('returns context message + session when worktree dir is alive', async () => {
97+
// Point sample at a real existing directory (tmpDir itself).
98+
const live: WorktreeSession = { ...sample, worktreePath: tmpDir };
99+
await writeWorktreeSession(filePath, live);
100+
const result = await restoreWorktreeContext(filePath);
101+
102+
expect(result.session).toEqual(live);
103+
expect(result.contextMessage).toContain(`"${live.slug}"`);
104+
expect(result.contextMessage).toContain(live.worktreePath);
105+
expect(result.contextMessage).toContain(live.worktreeBranch);
106+
// Sidecar should remain on disk so subsequent reads still see it.
107+
expect(await readWorktreeSession(filePath)).toEqual(live);
108+
});
109+
110+
it('cleans up stale sidecar when worktree dir is gone', async () => {
111+
// sample.worktreePath points at /repo/.qwen/... which does not exist.
112+
await writeWorktreeSession(filePath, sample);
113+
expect(await readWorktreeSession(filePath)).toEqual(sample);
114+
115+
const result = await restoreWorktreeContext(filePath);
116+
expect(result.session).toBeNull();
117+
expect(result.contextMessage).toBeNull();
118+
// Sidecar should be deleted.
119+
expect(await readWorktreeSession(filePath)).toBeNull();
120+
});
121+
122+
it('treats a regular file at worktreePath as not-a-worktree', async () => {
123+
const filePathTarget = path.join(tmpDir, 'pretend-worktree');
124+
await fs.writeFile(filePathTarget, 'not a dir', 'utf-8');
125+
const bogus: WorktreeSession = { ...sample, worktreePath: filePathTarget };
126+
await writeWorktreeSession(filePath, bogus);
127+
128+
const result = await restoreWorktreeContext(filePath);
129+
expect(result.session).toBeNull();
130+
expect(await readWorktreeSession(filePath)).toBeNull();
131+
});
132+
133+
it('forwards warning when sidecar JSON is malformed', async () => {
134+
await fs.writeFile(filePath, 'not valid json {', 'utf-8');
135+
const warnings: unknown[] = [];
136+
const result = await restoreWorktreeContext(filePath, (e) =>
137+
warnings.push(e),
138+
);
139+
expect(result.session).toBeNull();
140+
expect(warnings.length).toBe(1);
141+
});
142+
});

packages/core/src/services/worktreeSessionService.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,69 @@ export async function clearWorktreeSession(filePath: string): Promise<void> {
5959
throw error;
6060
}
6161
}
62+
63+
export interface WorktreeRestoreResult {
64+
/**
65+
* When non-null, the worktree directory is still alive — callers should
66+
* surface this one-line context message so the model continues using
67+
* the worktree path for file operations after a `--resume`.
68+
*
69+
* Each entry point chooses its own injection mechanism:
70+
* - TUI: `historyManager.addItem({ type: INFO, text })`
71+
* - Headless: prepend as a `<system-reminder>` block to the user prompt
72+
* - ACP: emit as a `system` message and prepend to the next prompt
73+
*/
74+
contextMessage: string | null;
75+
/** Active worktree session, or null when no sidecar / sidecar was stale. */
76+
session: WorktreeSession | null;
77+
}
78+
79+
/**
80+
* Reads the WorktreeSession sidecar for the current session, validates
81+
* that the worktree directory still exists on disk, and either:
82+
*
83+
* - returns a context message + the live session, or
84+
* - deletes the stale sidecar and returns nulls.
85+
*
86+
* Shared by TUI / headless / ACP entry points so all three behave
87+
* consistently on `--resume`. Failures are logged via the supplied
88+
* `onWarn` callback but never thrown — worktree restore is best-effort,
89+
* the session itself must still load.
90+
*/
91+
export async function restoreWorktreeContext(
92+
sidecarPath: string,
93+
onWarn?: (error: unknown) => void,
94+
): Promise<WorktreeRestoreResult> {
95+
let session: WorktreeSession | null = null;
96+
try {
97+
session = await readWorktreeSession(sidecarPath);
98+
} catch (error) {
99+
onWarn?.(error);
100+
return { contextMessage: null, session: null };
101+
}
102+
if (!session) return { contextMessage: null, session: null };
103+
104+
let worktreeAlive = false;
105+
try {
106+
const stat = await fs.stat(session.worktreePath);
107+
worktreeAlive = stat.isDirectory();
108+
} catch {
109+
worktreeAlive = false;
110+
}
111+
112+
if (!worktreeAlive) {
113+
try {
114+
await clearWorktreeSession(sidecarPath);
115+
} catch (error) {
116+
onWarn?.(error);
117+
}
118+
return { contextMessage: null, session: null };
119+
}
120+
121+
return {
122+
session,
123+
contextMessage:
124+
`[Resumed] Active worktree: "${session.slug}" at ${session.worktreePath} ` +
125+
`(branch: ${session.worktreeBranch}). Continue using this path for all file operations.`,
126+
};
127+
}

0 commit comments

Comments
 (0)