mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-28 01:25:14 +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,
|
SessionStartSource,
|
||||||
SessionEndReason,
|
SessionEndReason,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
|
restoreWorktreeContext,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
AgentSideConnection,
|
AgentSideConnection,
|
||||||
|
|
@ -325,7 +326,26 @@ class QwenAgent implements Agent {
|
||||||
this.setupFileSystem(config);
|
this.setupFileSystem(config);
|
||||||
|
|
||||||
const sessionData = config.getResumedSessionData();
|
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 modesData = this.buildModesData(config);
|
||||||
const availableModels = this.buildAvailableModels(config);
|
const availableModels = this.buildAvailableModels(config);
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,16 @@ export class Session implements SessionContext {
|
||||||
// Message rewrite middleware (optional, installed after history replay)
|
// Message rewrite middleware (optional, installed after history replay)
|
||||||
messageRewriter?: MessageRewriteMiddleware;
|
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
|
// Implement SessionContext interface
|
||||||
readonly sessionId: string;
|
readonly sessionId: string;
|
||||||
|
|
||||||
|
|
@ -530,6 +540,20 @@ export class Session implements SessionContext {
|
||||||
parts = [...systemReminders, ...parts];
|
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 };
|
let nextMessage: Content | null = { role: 'user', parts };
|
||||||
|
|
||||||
while (nextMessage !== null) {
|
while (nextMessage !== null) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
createDebugLogger,
|
createDebugLogger,
|
||||||
SendMessageType,
|
SendMessageType,
|
||||||
|
restoreWorktreeContext,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||||
|
|
@ -375,6 +376,39 @@ export async function runNonInteractive(
|
||||||
initialPartList = [{ text: input }];
|
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);
|
const initialParts = normalizePartList(initialPartList);
|
||||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,10 @@ import {
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
type WaitingToolCall,
|
type WaitingToolCall,
|
||||||
ToolNames,
|
ToolNames,
|
||||||
readWorktreeSession,
|
|
||||||
clearWorktreeSession,
|
clearWorktreeSession,
|
||||||
|
restoreWorktreeContext,
|
||||||
GitWorktreeService,
|
GitWorktreeService,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import * as fsPromises from 'node:fs/promises';
|
|
||||||
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
||||||
import {
|
import {
|
||||||
getStickyTodos,
|
getStickyTodos,
|
||||||
|
|
@ -509,37 +508,22 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||||
setSessionName(title);
|
setSessionName(title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the resumed session had an active worktree, inject a context
|
// Restore worktree context (shared logic — headless and ACP use
|
||||||
// message so the model immediately knows to keep using the
|
// the same helper). Stale sidecars get cleaned up; live ones
|
||||||
// worktree path for file operations (qwen-code can't `chdir` the
|
// produce an INFO message the model sees on the next turn.
|
||||||
// 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.
|
|
||||||
try {
|
try {
|
||||||
const sessionPath = config
|
const sessionPath = config
|
||||||
.getSessionService()
|
.getSessionService()
|
||||||
.getWorktreeSessionPath(config.getSessionId());
|
.getWorktreeSessionPath(config.getSessionId());
|
||||||
const ws = await readWorktreeSession(sessionPath);
|
const restored = await restoreWorktreeContext(sessionPath, (err) => {
|
||||||
if (ws) {
|
// eslint-disable-next-line no-console
|
||||||
const worktreeAlive = await fsPromises
|
console.debug('worktree session restore warning:', err);
|
||||||
.stat(ws.worktreePath)
|
});
|
||||||
.then((s) => s.isDirectory())
|
if (restored.contextMessage) {
|
||||||
.catch(() => false);
|
historyManager.addItem(
|
||||||
if (worktreeAlive) {
|
{ type: MessageType.INFO, text: restored.contextMessage },
|
||||||
historyManager.addItem(
|
Date.now(),
|
||||||
{
|
);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Best-effort: failures here only affect UI hint visibility,
|
// Best-effort: failures here only affect UI hint visibility,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
readWorktreeSession,
|
readWorktreeSession,
|
||||||
writeWorktreeSession,
|
writeWorktreeSession,
|
||||||
clearWorktreeSession,
|
clearWorktreeSession,
|
||||||
|
restoreWorktreeContext,
|
||||||
type WorktreeSession,
|
type WorktreeSession,
|
||||||
} from './worktreeSessionService.js';
|
} from './worktreeSessionService.js';
|
||||||
|
|
||||||
|
|
@ -84,3 +85,58 @@ describe('clearWorktreeSession', () => {
|
||||||
await expect(clearWorktreeSession(filePath)).resolves.not.toThrow();
|
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;
|
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