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>
This commit is contained in:
LaZzyMan 2026-05-15 17:26:03 +08:00
parent e847bfce8b
commit ada0837e2f
6 changed files with 214 additions and 30 deletions

View file

@ -22,6 +22,7 @@ import {
SessionStartSource,
SessionEndReason,
type PermissionMode,
restoreWorktreeContext,
} from '@qwen-code/qwen-code-core';
import {
AgentSideConnection,
@ -325,7 +326,26 @@ class QwenAgent implements Agent {
this.setupFileSystem(config);
const sessionData = config.getResumedSessionData();
await this.createAndStoreSession(config, sessionData?.conversation);
const session = await this.createAndStoreSession(
config,
sessionData?.conversation,
);
// Phase C: restore worktree context on --resume. Cleans up stale
// sidecars (worktree dir deleted out-of-band) and queues a notice for
// the next prompt when the worktree is alive. Best-effort: failures
// don't block session load.
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(config.getSessionId());
const restored = await restoreWorktreeContext(sessionPath);
if (restored.contextMessage) {
session.pendingWorktreeNotice = restored.contextMessage;
}
} catch (error) {
debugLogger.warn(`ACP worktree restore failed: ${error}`);
}
const modesData = this.buildModesData(config);
const availableModels = this.buildAvailableModels(config);

View file

@ -158,6 +158,16 @@ export class Session implements SessionContext {
// Message rewrite middleware (optional, installed after history replay)
messageRewriter?: MessageRewriteMiddleware;
/**
* Phase C worktree restore notice. Set by acpAgent.loadSession when a
* resumed session has a live worktree sidecar; prepended to the next
* #executePrompt call as a <system-reminder>, then cleared. TUI uses
* historyManager.addItem(INFO) for the equivalent UX hint and headless
* prepends to the single shot prompt all three modes share the
* `restoreWorktreeContext` helper that produces this string.
*/
pendingWorktreeNotice: string | null = null;
// Implement SessionContext interface
readonly sessionId: string;
@ -530,6 +540,20 @@ export class Session implements SessionContext {
parts = [...systemReminders, ...parts];
}
// Phase C: one-shot worktree restore notice, set by acpAgent on
// --resume / loadSession when the session's worktree is still alive.
// Prepended exactly once, then cleared so it doesn't repeat on
// subsequent turns.
if (this.pendingWorktreeNotice) {
parts = [
{
text: `<system-reminder>\n${this.pendingWorktreeNotice}\n</system-reminder>\n\n`,
},
...parts,
];
this.pendingWorktreeNotice = null;
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {

View file

@ -26,6 +26,7 @@ import {
parseAndFormatApiError,
createDebugLogger,
SendMessageType,
restoreWorktreeContext,
} from '@qwen-code/qwen-code-core';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
@ -375,6 +376,39 @@ export async function runNonInteractive(
initialPartList = [{ text: input }];
}
// Phase C: when --resume restored a session with an active worktree,
// prepend a system-reminder block to the user prompt so the model
// knows to keep using the worktree path. Stale sidecars (worktree
// dir deleted between sessions) are cleaned up inside the helper.
// TUI does this via historyManager.addItem(INFO); headless does it
// here because there is no UI history to write into.
if (config.getResumedSessionData()) {
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(sessionId);
const restored = await restoreWorktreeContext(sessionPath);
if (restored.contextMessage) {
const reminderPart: Part = {
text: `<system-reminder>\n${restored.contextMessage}\n</system-reminder>\n\n`,
};
const partsArr = Array.isArray(initialPartList)
? initialPartList
: [initialPartList];
initialPartList = [reminderPart, ...partsArr];
// Also surface the notice in the JSON stream so SDK consumers
// can react to it (logging, UI hints, etc.).
adapter.emitSystemMessage('worktree_restored', {
slug: restored.session?.slug,
path: restored.session?.worktreePath,
branch: restored.session?.worktreeBranch,
});
}
} catch (error) {
debugLogger.warn(`worktree restore failed (non-fatal):`, error);
}
}
const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];

View file

@ -60,11 +60,10 @@ import {
ToolConfirmationOutcome,
type WaitingToolCall,
ToolNames,
readWorktreeSession,
clearWorktreeSession,
restoreWorktreeContext,
GitWorktreeService,
} from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import {
getStickyTodos,
@ -509,37 +508,22 @@ export const AppContainer = (props: AppContainerProps) => {
setSessionName(title);
}
// If the resumed session had an active worktree, inject a context
// message so the model immediately knows to keep using the
// worktree path for file operations (qwen-code can't `chdir` the
// way claude-code does — Config.targetDir is immutable).
//
// Stale sidecars (worktree dir deleted between sessions) get
// cleaned up so Footer / useWorktreeSession don't show a phantom
// worktree indicator.
// Restore worktree context (shared logic — headless and ACP use
// the same helper). Stale sidecars get cleaned up; live ones
// produce an INFO message the model sees on the next turn.
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(config.getSessionId());
const ws = await readWorktreeSession(sessionPath);
if (ws) {
const worktreeAlive = await fsPromises
.stat(ws.worktreePath)
.then((s) => s.isDirectory())
.catch(() => false);
if (worktreeAlive) {
historyManager.addItem(
{
type: MessageType.INFO,
text:
`[Resumed] Active worktree: "${ws.slug}" at ${ws.worktreePath} ` +
`(branch: ${ws.worktreeBranch}). Continue using this path for all file operations.`,
},
Date.now(),
);
} else {
await clearWorktreeSession(sessionPath);
}
const restored = await restoreWorktreeContext(sessionPath, (err) => {
// eslint-disable-next-line no-console
console.debug('worktree session restore warning:', err);
});
if (restored.contextMessage) {
historyManager.addItem(
{ type: MessageType.INFO, text: restored.contextMessage },
Date.now(),
);
}
} catch (error) {
// Best-effort: failures here only affect UI hint visibility,

View file

@ -12,6 +12,7 @@ import {
readWorktreeSession,
writeWorktreeSession,
clearWorktreeSession,
restoreWorktreeContext,
type WorktreeSession,
} from './worktreeSessionService.js';
@ -84,3 +85,58 @@ describe('clearWorktreeSession', () => {
await expect(clearWorktreeSession(filePath)).resolves.not.toThrow();
});
});
describe('restoreWorktreeContext', () => {
it('returns nulls when no sidecar exists', async () => {
const result = await restoreWorktreeContext(filePath);
expect(result.session).toBeNull();
expect(result.contextMessage).toBeNull();
});
it('returns context message + session when worktree dir is alive', async () => {
// Point sample at a real existing directory (tmpDir itself).
const live: WorktreeSession = { ...sample, worktreePath: tmpDir };
await writeWorktreeSession(filePath, live);
const result = await restoreWorktreeContext(filePath);
expect(result.session).toEqual(live);
expect(result.contextMessage).toContain(`"${live.slug}"`);
expect(result.contextMessage).toContain(live.worktreePath);
expect(result.contextMessage).toContain(live.worktreeBranch);
// Sidecar should remain on disk so subsequent reads still see it.
expect(await readWorktreeSession(filePath)).toEqual(live);
});
it('cleans up stale sidecar when worktree dir is gone', async () => {
// sample.worktreePath points at /repo/.qwen/... which does not exist.
await writeWorktreeSession(filePath, sample);
expect(await readWorktreeSession(filePath)).toEqual(sample);
const result = await restoreWorktreeContext(filePath);
expect(result.session).toBeNull();
expect(result.contextMessage).toBeNull();
// Sidecar should be deleted.
expect(await readWorktreeSession(filePath)).toBeNull();
});
it('treats a regular file at worktreePath as not-a-worktree', async () => {
const filePathTarget = path.join(tmpDir, 'pretend-worktree');
await fs.writeFile(filePathTarget, 'not a dir', 'utf-8');
const bogus: WorktreeSession = { ...sample, worktreePath: filePathTarget };
await writeWorktreeSession(filePath, bogus);
const result = await restoreWorktreeContext(filePath);
expect(result.session).toBeNull();
expect(await readWorktreeSession(filePath)).toBeNull();
});
it('forwards warning when sidecar JSON is malformed', async () => {
await fs.writeFile(filePath, 'not valid json {', 'utf-8');
const warnings: unknown[] = [];
const result = await restoreWorktreeContext(filePath, (e) =>
warnings.push(e),
);
expect(result.session).toBeNull();
expect(warnings.length).toBe(1);
});
});

View file

@ -59,3 +59,69 @@ export async function clearWorktreeSession(filePath: string): Promise<void> {
throw error;
}
}
export interface WorktreeRestoreResult {
/**
* When non-null, the worktree directory is still alive callers should
* surface this one-line context message so the model continues using
* the worktree path for file operations after a `--resume`.
*
* Each entry point chooses its own injection mechanism:
* - TUI: `historyManager.addItem({ type: INFO, text })`
* - Headless: prepend as a `<system-reminder>` block to the user prompt
* - ACP: emit as a `system` message and prepend to the next prompt
*/
contextMessage: string | null;
/** Active worktree session, or null when no sidecar / sidecar was stale. */
session: WorktreeSession | null;
}
/**
* Reads the WorktreeSession sidecar for the current session, validates
* that the worktree directory still exists on disk, and either:
*
* - returns a context message + the live session, or
* - deletes the stale sidecar and returns nulls.
*
* Shared by TUI / headless / ACP entry points so all three behave
* consistently on `--resume`. Failures are logged via the supplied
* `onWarn` callback but never thrown worktree restore is best-effort,
* the session itself must still load.
*/
export async function restoreWorktreeContext(
sidecarPath: string,
onWarn?: (error: unknown) => void,
): Promise<WorktreeRestoreResult> {
let session: WorktreeSession | null = null;
try {
session = await readWorktreeSession(sidecarPath);
} catch (error) {
onWarn?.(error);
return { contextMessage: null, session: null };
}
if (!session) return { contextMessage: null, session: null };
let worktreeAlive = false;
try {
const stat = await fs.stat(session.worktreePath);
worktreeAlive = stat.isDirectory();
} catch {
worktreeAlive = false;
}
if (!worktreeAlive) {
try {
await clearWorktreeSession(sidecarPath);
} catch (error) {
onWarn?.(error);
}
return { contextMessage: null, session: null };
}
return {
session,
contextMessage:
`[Resumed] Active worktree: "${session.slug}" at ${session.worktreePath} ` +
`(branch: ${session.worktreeBranch}). Continue using this path for all file operations.`,
};
}