fix(agent-core-v2): let session archive proceed after a failed resume (#3139)

A failed resume is cached by SessionManager and rethrown from
whenResumeSettled, so archiving a session whose workspace is gone
failed with the stale resume error even though cold archive only
rewrites session metadata. Swallow the settle failure: still wait for
an in-flight resume before the live/cold classification, but fall
through to the cold metadata path after a failed one.
This commit is contained in:
Haozhe 2026-08-20 22:40:27 +08:00 committed by GitHub
parent e9a99e5ec6
commit 381142aff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 35 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix sessions failing to archive when their workspace folder no longer exists.

View file

@ -72,7 +72,7 @@ export async function setSessionArchived(
): Promise<ColdSessionArchiveOutcome> {
const manager = accessor.get(ISessionManager);
return manager.withLifecycleSerialization(sessionId, async (unguarded) => {
await manager.whenResumeSettled(sessionId);
await manager.whenResumeSettled(sessionId).catch(() => undefined);
const live = getLiveSessionById(accessor, sessionId);
if (live !== undefined) {
if (archived) await unguarded.archive();

View file

@ -171,20 +171,16 @@ describe('setSessionArchivedBatch', () => {
expect(persisted['isCustomTitle']).toBe(true);
});
it('fails the item when a concurrent resume failed instead of cold-classifying', async () => {
it('cold-classifies the item when a concurrent resume failed', async () => {
const outcomes = await setSessionArchivedBatch(
coldPathAccessor({
storeGet: async () => {
throw new Error('unreachable — the settle throws first');
},
storeGet: async () => ({ id: 's1', createdAt: 1, updatedAt: 2, archived: false }),
resumeError: new Error('resume boom'),
}),
['s1'],
true,
);
expect(outcomes).toEqual([
{ id: 's1', ok: false, reason: 'error', message: 'resume boom' },
]);
expect(outcomes).toEqual([{ id: 's1', ok: true }]);
});
it('reads and migrates the legacy session-meta location before answering not_found', async () => {

View file

@ -20,9 +20,11 @@ import {
IEventService,
ISessionCronService,
ISessionManager,
IWorkspaceService,
MAIN_AGENT_ID,
closeSessionById,
getLiveSessionById,
resumeSessionById,
sessionDirOf,
type ServiceIdentifier,
type ScopeSeed,
@ -782,6 +784,30 @@ describe('server-v2 /api/v1/sessions', () => {
expect(got.body.data.archived).toBe(true);
});
it('archives a cold session after a failed resume when the workspace root is gone', async () => {
const cwd = join(home as string, 'gone-ws');
await mkdir(cwd);
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
await closeSessionById((server as RunningServer).core.accessor, id);
await (server as RunningServer).core.accessor
.get(IWorkspaceService)
.delete(encodeWorkDirKey(cwd));
await rm(cwd, { recursive: true, force: true });
await expect(
resumeSessionById((server as RunningServer).core.accessor, id),
).rejects.toThrow(/does not exist/);
const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`);
expect(archived.body.code).toBe(0);
expect(archived.body.data).toEqual({ archived: true });
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
expect(got.body.code).toBe(0);
expect(got.body.data.archived).toBe(true);
});
it('restores an archived session via :restore and returns it to the default list', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });