Repository navigation
fix(cli): resume older working-directory sessions in /sessions and --continue #2952
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bb75ffd
bdeb5cf
49ca402
13313d9
158ad79
0b481be
3d9ff1a
672bc0c
24eb747
85ddc6e
031aebb
23d8a83
e335380
44a85c9
9bec158
8aeb4fe
77e76bb
0bef45c
a289bd8
76d8d56
0b2b8b9
7cf47b7
b1b869e
53fdf48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Restore older working-directory sessions in `/sessions` and `kimi --continue`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { isAbsolute, relative } from 'node:path'; | ||
| import { Disposable } from '#/_base/di/lifecycle'; | ||
| import { LifecycleScope } from '#/app/scopes'; | ||
| import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; | ||
|
|
@@ -30,6 +31,7 @@ import { | |
| ISessionIndex, | ||
| ISessionIndexMirror, | ||
| PARENT_SESSION_ID_KEY, | ||
| isSafeSessionId, | ||
| type SessionCountQuery, | ||
| type SessionIndexStatus, | ||
| type SessionIndexState, | ||
|
|
@@ -73,7 +75,9 @@ function isSessionSummaryShape(value: unknown): value is SessionSummary { | |
| typeof summary['workspaceId'] === 'string' && | ||
| typeof summary['createdAt'] === 'number' && | ||
| typeof summary['updatedAt'] === 'number' && | ||
| typeof summary['archived'] === 'boolean' | ||
| typeof summary['archived'] === 'boolean' && | ||
| (summary['cwd'] === undefined || | ||
| (typeof summary['cwd'] === 'string' && summary['cwd'].length > 0)) | ||
|
Comment on lines
+79
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an unscoped AGENTS.md reference: AGENTS.md:L98-L100 Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit |
||
| ); | ||
| } | ||
|
|
||
|
|
@@ -339,6 +343,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| } | ||
|
|
||
| async get(id: string): Promise<SessionSummary | undefined> { | ||
| if (!isSafeSessionId(id)) return undefined; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When upgrading with a session previously created through public AGENTS.md reference: AGENTS.md:L110-L112 Useful? React with 👍 / 👎. |
||
| return this.withReadModel( | ||
| (generation) => this.getFromReadModel(generation, id), | ||
| () => this.getLegacy(id), | ||
|
|
@@ -347,7 +352,20 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
|
|
||
| async listRecent(query: SessionListQuery): Promise<Page<SessionSummary>> { | ||
| return this.withReadModel( | ||
| (generation) => this.listRecentFromReadModel(generation, query), | ||
| async (generation) => { | ||
| const page = await this.listRecentFromReadModel(generation, query); | ||
| if (page.items.length === 0 && query.workspaceIds !== undefined) { | ||
|
creatiVision marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in the reviewed target is that this condition still consults the authoritative directory only when the projected page is empty and AGENTS.md reference: AGENTS.md:L98-L100 Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged. The read model projection in MiniDb serves as the primary indexed store for fast paginated queries, with the directory scan acting as an on-demand fallback during transition. Merging and deduplicating cross-store streams on arbitrary pagination offsets is deliberately avoided to keep cursor contracts deterministic. For CLI print mode |
||
| const cursorId = query.before ?? query.after; | ||
| if (cursorId !== undefined) { | ||
| if (!isSafeSessionId(cursorId)) return page; | ||
| const cursor = await this.get(cursorId); | ||
| if (cursor === undefined) return page; | ||
| } | ||
| const legacy = await this.listLegacy(query); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the read-model page is empty, an authenticated Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit 0bef45c.
Comment on lines
+357
to
+364
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the read model ready, any workspace-scoped request whose Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit |
||
| if (legacy.items.length > 0) return legacy; | ||
| } | ||
| return page; | ||
| }, | ||
| () => this.listLegacy(query), | ||
| ); | ||
| } | ||
|
|
@@ -535,6 +553,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| .pending() | ||
| .filter( | ||
| (summary) => | ||
| (position === undefined || summary.id !== position.id) && | ||
| (query.workspaceIds === undefined || query.workspaceIds.includes(summary.workspaceId)) && | ||
| (query.includeArchived === true || !summary.archived) && | ||
| summaryMatchesChildOf(summary, query.childOf) && | ||
|
|
@@ -545,10 +564,10 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| : summary.updatedAt > position.u || | ||
| (summary.updatedAt === position.u && summary.id > position.id))), | ||
| ); | ||
| if (pending.length === 0) return page; | ||
| const merged = new Map<string, SessionSummary>(); | ||
| for (const summary of pending) merged.set(summary.id, summary); | ||
| for (const summary of page.items) { | ||
| if (position !== undefined && summary.id === position.id) continue; | ||
| if (!merged.has(summary.id)) merged.set(summary.id, summary); | ||
| } | ||
| const items = [...merged.values()].toSorted(canonicalOrder); | ||
|
|
@@ -567,11 +586,36 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| > { | ||
| const id = query.before ?? query.after; | ||
| if (id === undefined) return { filter: {}, bounds: {} }; | ||
| if (!isSafeSessionId(id)) return undefined; | ||
| let u: number | undefined; | ||
| const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); | ||
| const stored = isSessionSummaryShape(storedValue) ? storedValue : undefined; | ||
| const cursor = stored ?? this.mirror.pending().find((summary) => summary.id === id); | ||
| if (cursor === undefined) return undefined; | ||
| const u = cursor.updatedAt; | ||
| const storedUpdatedAt = | ||
| storedValue !== null && | ||
| typeof storedValue === 'object' && | ||
| typeof (storedValue as Record<string, unknown>)['updatedAt'] === 'number' | ||
| ? ((storedValue as Record<string, unknown>)['updatedAt'] as number) | ||
| : undefined; | ||
|
|
||
| if (storedUpdatedAt !== undefined) { | ||
| u = storedUpdatedAt; | ||
| if (!isSessionSummaryShape(storedValue)) { | ||
| const summary = | ||
| this.mirror.pending().find((item) => item.id === id) ?? | ||
| (await this.getLegacy(id)); | ||
| if (summary !== undefined) { | ||
| this.mirror.record(summary); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cached cursor row has malformed Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit |
||
| } | ||
| } | ||
| } else { | ||
| const summary = | ||
| this.mirror.pending().find((item) => item.id === id) ?? | ||
| (await this.getLegacy(id)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the read model enabled and at least one workspace, an authenticated AGENTS.md reference: AGENTS.md:L104-L104 Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit a289bd8. Added |
||
| if (summary !== undefined) { | ||
| this.mirror.record(summary); | ||
| u = summary.updatedAt; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cached cursor row has a malformed Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in commit a289bd8. |
||
| } | ||
| } | ||
| if (u === undefined) return undefined; | ||
| if (query.before !== undefined) { | ||
| return { | ||
| bounds: { lte: u }, | ||
|
|
@@ -618,6 +662,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
|
|
||
| private async listLegacy(query: SessionListQuery): Promise<Page<SessionSummary>> { | ||
| if (query.sessionId !== undefined) { | ||
| if (!isSafeSessionId(query.sessionId)) return { items: [] }; | ||
| const summary = await this.getLegacy(query.sessionId); | ||
| const items = | ||
| summary !== undefined && (!summary.archived || query.includeArchived === true) | ||
|
|
@@ -652,8 +697,9 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| } | ||
|
|
||
| private async getLegacy(id: string): Promise<SessionSummary | undefined> { | ||
| if (!isSafeSessionId(id)) return undefined; | ||
| const hinted = await this.workspaceHintFor(id); | ||
| if (hinted !== undefined) { | ||
| if (hinted !== undefined && this.isSafeBucketId(hinted)) { | ||
| const summary = await readSessionSummary(this.docs, this.sessionsScope, hinted, id); | ||
| if (summary !== undefined) return summary; | ||
| } | ||
|
|
@@ -717,18 +763,27 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| private async collectAuthoritative( | ||
| workspaceIds: readonly string[] | undefined, | ||
| ): Promise<SessionSummary[]> { | ||
| const safeWorkspaceIds = | ||
| workspaceIds === undefined ? undefined : workspaceIds.filter((id) => this.isSafeBucketId(id)); | ||
| if (workspaceIds !== undefined && safeWorkspaceIds!.length === 0) { | ||
| return []; | ||
| } | ||
| let collected: SessionSummary[]; | ||
| if ( | ||
| this.readModelEnabled() && | ||
| (this.state === 'uninitialized' || this.state === 'preparing') | ||
| ) { | ||
| const { summaries } = await this.projector.sharedScanForRead(); | ||
| collected = | ||
| workspaceIds === undefined | ||
| safeWorkspaceIds === undefined | ||
| ? summaries | ||
| : summaries.filter((summary) => workspaceIds.includes(summary.workspaceId)); | ||
| : summaries.filter((summary) => safeWorkspaceIds.includes(summary.workspaceId)); | ||
| } else { | ||
| const ids = workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope)); | ||
| const knownIds = new Set(await listWorkspaceIds(this.storage, this.sessionsScope)); | ||
| const ids = | ||
| safeWorkspaceIds === undefined | ||
| ? [...knownIds] | ||
| : safeWorkspaceIds.filter((id) => knownIds.has(id)); | ||
| collected = []; | ||
| for (const workspaceId of ids) { | ||
| for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) { | ||
|
|
@@ -741,12 +796,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { | |
| if (pending.length === 0) return collected; | ||
| const byId = new Map(collected.map((summary) => [summary.id, summary])); | ||
| for (const summary of pending) { | ||
| if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue; | ||
| if (safeWorkspaceIds !== undefined && !safeWorkspaceIds.includes(summary.workspaceId)) continue; | ||
| byId.set(summary.id, summary); | ||
| } | ||
| return [...byId.values()]; | ||
| } | ||
|
|
||
| private isSafeBucketId(id: string): boolean { | ||
| if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\') || id.includes('\0')) { | ||
| return false; | ||
| } | ||
| const root = this.storage.pathFor(this.sessionsScope, ''); | ||
| if (root !== undefined) { | ||
| const target = this.storage.pathFor(this.sessionsScope, id); | ||
| if (target === undefined) return false; | ||
| const rel = relative(root, target); | ||
| if (rel.startsWith('..') || isAbsolute(rel) || rel !== id) return false; | ||
|
Comment on lines
+813
to
+814
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the sessions directory contains a symlinked bucket (for example, Useful? React with 👍 / 👎. |
||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private readModelEnabled(): boolean { | ||
| return databaseBaseEnabled(this.config); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a persisted read-model row has all required summary fields but a malformed optional
cwdsuch asnullor a number,isSessionSummaryShapeaccepts it, this check treats it as defined, andresolve(summary.cwd)can throw instead of skipping the unusable session. This newly turns a corrupt optional field into a fatal--continuestartup error; validate thatcwdis a nonempty string in the read-model shape guard or before callingresolve.Useful? React with 👍 / 👎.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in commit
158ad7928. UpdatedisSessionSummaryShapeinsessionIndexService.tsto validate thatcwdis either undefined or a nonempty string, and added explicit type and length guards before path resolution inrun-v2-print.ts.