mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 05:35:34 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
5eef0a6736
3 changed files with 176 additions and 9 deletions
|
|
@ -83,8 +83,8 @@ export interface ISessionLifecycleService {
|
|||
* agent from the persisted wire log. Returns the existing handle when the
|
||||
* session is already live (a no-op in that case — live agents are never
|
||||
* re-restored). Returns `undefined` when the session is unknown to the index
|
||||
* or its workspace is no longer registered (mirrors the cold-source
|
||||
* limitation of `fork`).
|
||||
* or neither the persisted session summary nor the workspace registry can
|
||||
* provide a workdir (mirrors the cold-source limitation of `fork`).
|
||||
*
|
||||
* Lets the read edges (snapshot / messages) serve cold sessions — created by
|
||||
* a previous process or by v1 — without requiring a prior `create` in this
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* its `agentLifecycle` agents, and
|
||||
* broadcasts through `event`. Materializes the session's initial metadata on
|
||||
* creation by resolving `sessionMetadata`. Bound at App scope. Persisted
|
||||
* sessions are the `sessionIndex` read model.
|
||||
* sessions are discovered through the `sessionIndex` read model, and workspace
|
||||
* roots are remembered through `workspaceRegistry`.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -23,7 +24,6 @@ import {
|
|||
} from '#/_base/di/scope';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
|
|
@ -63,6 +63,10 @@ import {
|
|||
ISessionLifecycleService,
|
||||
} from './sessionLifecycle';
|
||||
|
||||
type MaterializeSessionOptions = CreateSessionOptions & {
|
||||
readonly workspaceId?: string;
|
||||
};
|
||||
|
||||
export class SessionLifecycleService extends Disposable implements ISessionLifecycleService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly sessions = new Map<string, ISessionScopeHandle>();
|
||||
|
|
@ -102,8 +106,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
return handle;
|
||||
}
|
||||
|
||||
private async materializeSession(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
|
||||
const workspaceId = encodeWorkDirKey(opts.workDir);
|
||||
private async materializeSession(opts: MaterializeSessionOptions): Promise<ISessionScopeHandle> {
|
||||
const workspace = await this.workspaceRegistry.createOrTouch(opts.workDir);
|
||||
const workspaceId = opts.workspaceId ?? workspace.id;
|
||||
const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId);
|
||||
const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId);
|
||||
// Metadata lives at `<sessionDir>/state.json` (shared with v1's layout; the
|
||||
|
|
@ -169,10 +174,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
|
||||
const summary = await this.index.get(sessionId);
|
||||
if (summary === undefined) return undefined;
|
||||
const workspace = await this.workspaceRegistry.get(summary.workspaceId);
|
||||
if (workspace === undefined) return undefined;
|
||||
const workspace =
|
||||
summary.cwd === undefined
|
||||
? await this.workspaceRegistry.get(summary.workspaceId)
|
||||
: undefined;
|
||||
const workDir = summary.cwd ?? workspace?.root;
|
||||
if (workDir === undefined) return undefined;
|
||||
|
||||
const handle = await this.materializeSession({ sessionId, workDir: workspace.root });
|
||||
const handle = await this.materializeSession({
|
||||
sessionId,
|
||||
workDir,
|
||||
workspaceId: summary.workspaceId,
|
||||
});
|
||||
const agents = handle.accessor.get(IAgentLifecycleService);
|
||||
if (agents.getHandle(MAIN_AGENT_ID) === undefined) {
|
||||
const main = await ensureMainAgent(handle);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
|
|||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry';
|
||||
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent';
|
||||
|
||||
function bootstrapStub(): IBootstrapService {
|
||||
return {
|
||||
|
|
@ -111,6 +114,34 @@ function workspaceRegistryStub(): IWorkspaceRegistry {
|
|||
};
|
||||
}
|
||||
|
||||
function persistentWorkspaceRegistryStub(): IWorkspaceRegistry {
|
||||
const workspaces = new Map<string, Workspace>();
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
list: () => Promise.resolve([...workspaces.values()]),
|
||||
get: (id) => Promise.resolve(workspaces.get(id)),
|
||||
createOrTouch: (root, name) => {
|
||||
const id = encodeWorkDirKey(root);
|
||||
const now = 1;
|
||||
const existing = workspaces.get(id);
|
||||
const workspace: Workspace =
|
||||
existing !== undefined
|
||||
? { ...existing, lastOpenedAt: now }
|
||||
: {
|
||||
id,
|
||||
root,
|
||||
name: name ?? 'proj',
|
||||
createdAt: now,
|
||||
lastOpenedAt: now,
|
||||
};
|
||||
workspaces.set(id, workspace);
|
||||
return Promise.resolve(workspace);
|
||||
},
|
||||
update: () => Promise.resolve(undefined),
|
||||
delete: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionIndexStub(): ISessionIndex {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
|
|
@ -120,6 +151,27 @@ function sessionIndexStub(): ISessionIndex {
|
|||
};
|
||||
}
|
||||
|
||||
function sessionIndexWithSummary(
|
||||
sessionId: string,
|
||||
workDir: string,
|
||||
workspaceId = encodeWorkDirKey(workDir),
|
||||
): ISessionIndex {
|
||||
const summary = {
|
||||
id: sessionId,
|
||||
workspaceId,
|
||||
cwd: workDir,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
archived: false,
|
||||
};
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
list: () => Promise.resolve({ items: [summary], total: 1, hasMore: false }),
|
||||
get: (id) => Promise.resolve(id === sessionId ? summary : undefined),
|
||||
countActive: () => Promise.resolve(1),
|
||||
};
|
||||
}
|
||||
|
||||
function appendLogStoreStub(): IAppendLogStore {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
|
|
@ -167,6 +219,23 @@ function agentLifecycleStub(): IAgentLifecycleService {
|
|||
};
|
||||
}
|
||||
|
||||
function agentLifecycleWithMainStub(): IAgentLifecycleService {
|
||||
const main = {
|
||||
id: MAIN_AGENT_ID,
|
||||
kind: LifecycleScope.Agent,
|
||||
accessor: {
|
||||
get: () => {
|
||||
throw new Error('unexpected main agent service access');
|
||||
},
|
||||
},
|
||||
dispose: () => {},
|
||||
} as IAgentScopeHandle;
|
||||
return {
|
||||
...agentLifecycleStub(),
|
||||
getHandle: (id) => (id === MAIN_AGENT_ID ? main : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
|
@ -263,6 +332,91 @@ describe('SessionLifecycleService', () => {
|
|||
expect(h.kind).toBe(LifecycleScope.Session);
|
||||
});
|
||||
|
||||
it('registers the workspace during create so a cold resume can resolve the workdir', async () => {
|
||||
const workDir = '/tmp/proj';
|
||||
const workspaceRegistry = persistentWorkspaceRegistryStub();
|
||||
const sessionIndex = sessionIndexWithSummary('s1', workDir);
|
||||
const first = build([
|
||||
stubPair(IWorkspaceRegistry, workspaceRegistry),
|
||||
stubPair(ISessionIndex, sessionIndex),
|
||||
]);
|
||||
|
||||
await first.create({ sessionId: 's1', workDir });
|
||||
await expect(workspaceRegistry.get(encodeWorkDirKey(workDir))).resolves.toMatchObject({
|
||||
root: workDir,
|
||||
});
|
||||
host?.dispose();
|
||||
host = undefined;
|
||||
|
||||
const second = build([
|
||||
stubPair(IWorkspaceRegistry, workspaceRegistry),
|
||||
stubPair(ISessionIndex, sessionIndex),
|
||||
stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()),
|
||||
]);
|
||||
const resumed = await second.resume('s1');
|
||||
|
||||
expect(resumed?.id).toBe('s1');
|
||||
expect(resumed?.accessor.get(ISessionContext).cwd).toBe(workDir);
|
||||
});
|
||||
|
||||
it('resumes from the persisted cwd when the workspace registry entry is missing', async () => {
|
||||
const workDir = '/tmp/proj';
|
||||
const svc = build([
|
||||
stubPair(IWorkspaceRegistry, persistentWorkspaceRegistryStub()),
|
||||
stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)),
|
||||
stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()),
|
||||
]);
|
||||
|
||||
const resumed = await svc.resume('s1');
|
||||
|
||||
expect(resumed?.id).toBe('s1');
|
||||
expect(resumed?.accessor.get(ISessionContext).workspaceId).toBe(encodeWorkDirKey(workDir));
|
||||
});
|
||||
|
||||
it('resumes with the persisted cwd and indexed workspace id when the registry root is stale', async () => {
|
||||
const workDir = '/tmp/proj';
|
||||
const staleRoot = '/tmp/stale';
|
||||
const indexedWorkspaceId = 'wd_indexed';
|
||||
const workspaceRegistry: IWorkspaceRegistry = {
|
||||
_serviceBrand: undefined,
|
||||
list: () => Promise.resolve([]),
|
||||
get: (id) =>
|
||||
Promise.resolve(
|
||||
id === indexedWorkspaceId
|
||||
? {
|
||||
id: indexedWorkspaceId,
|
||||
root: staleRoot,
|
||||
name: 'stale',
|
||||
createdAt: 1,
|
||||
lastOpenedAt: 1,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
createOrTouch: (root, name) =>
|
||||
Promise.resolve({
|
||||
id: encodeWorkDirKey(root),
|
||||
root,
|
||||
name: name ?? 'proj',
|
||||
createdAt: 1,
|
||||
lastOpenedAt: 1,
|
||||
}),
|
||||
update: () => Promise.resolve(undefined),
|
||||
delete: () => Promise.resolve(),
|
||||
};
|
||||
const svc = build([
|
||||
stubPair(IWorkspaceRegistry, workspaceRegistry),
|
||||
stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir, indexedWorkspaceId)),
|
||||
stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()),
|
||||
]);
|
||||
|
||||
const resumed = await svc.resume('s1');
|
||||
const ctx = resumed?.accessor.get(ISessionContext);
|
||||
|
||||
expect(ctx?.cwd).toBe(workDir);
|
||||
expect(ctx?.workspaceId).toBe(indexedWorkspaceId);
|
||||
expect(ctx?.sessionDir).toBe(`/tmp/sessions/${indexedWorkspaceId}/s1`);
|
||||
});
|
||||
|
||||
it('archive flags metadata, removes agents, publishes the event, and disposes the session', async () => {
|
||||
let archived: boolean | undefined;
|
||||
const removed: string[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue