mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-27 17:05:29 +00:00
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:
parent
e847bfce8b
commit
ada0837e2f
6 changed files with 214 additions and 30 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue