-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(kap-server): add page mode, updated_before, and batch archive/restore to v2 sessions #2983
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
Changes from 16 commits
d26ba00
bd51811
2855ee5
6ae1b2f
5614588
fb0a45d
10bd0cd
0273106
d860352
34b0ebf
88215ed
191204c
19c63c5
53a9e3f
0db9570
63a8130
46db11d
62f2e93
7c7d896
c28156a
6d0ea97
e825bc2
fa04d35
568e47d
aa05630
46d88de
2c0e315
165fdf3
b0c43b6
b7a19b6
f18d154
b079259
be79e0e
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/agent-core-v2": patch | ||
| --- | ||
|
|
||
| Add a cold-session archive/restore path that patches the persisted metadata document, mirrors the flipped summary into the session-index read model, and republishes the archived bus event without materializing the session, backing the new `POST /api/v2/sessions:archive` / `:restore` batch endpoints (per-item results; live sessions still run the full lifecycle). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kap-server": patch | ||
| --- | ||
|
|
||
| Add an `id,archived` item projection to `GET /api/v2/sessions` (`fields=id,archived`): each item trims to `{ id, archived }` for select-all-matching flows, and only that projection gets the relaxed `page_size` ceiling (10000). The projection binds into the page-token fingerprint, rejects unknown fields and `include=git` with `40001`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ export class SessionManager implements ISessionManager { | |
| declare readonly _serviceBrand: undefined; | ||
| private readonly sessions = new Map<string, ISessionScopeHandle>(); | ||
| private readonly owners = new Map<string, SessionLifecycleService>(); | ||
| private readonly pendingResumes = new Map<string, Promise<ISessionScopeHandle | undefined>>(); | ||
| private readonly controllers = new Map<string, SessionControllerEntry>(); | ||
| private readonly controllerEntries = new Set<SessionControllerEntry>(); | ||
| private readonly willCreateEmitter = new Emitter<SessionWillCreateEvent>(); | ||
|
|
@@ -61,13 +62,29 @@ export class SessionManager implements ISessionManager { | |
| } | ||
|
|
||
| async resume(sessionId: string, options?: ResumeSessionOptions): Promise<ISessionScopeHandle | undefined> { | ||
| return (await this.controllerForSession(sessionId))?.resume(sessionId, options); | ||
| const inflight = this.pendingResumes.get(sessionId); | ||
| if (inflight !== undefined) return inflight; | ||
| // Register synchronously at the App level: controllerForSession is async, | ||
| // so the controller's own resuming map only learns about this resume a | ||
| // few microtasks later — whenResumeSettled must see it from this call's | ||
| // very first tick. | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
| const promise = (async () => | ||
| (await this.controllerForSession(sessionId))?.resume(sessionId, options))().finally(() => | ||
| this.pendingResumes.delete(sessionId), | ||
| ); | ||
| this.pendingResumes.set(sessionId, promise); | ||
| return promise; | ||
| } | ||
|
|
||
| get(sessionId: string): ISessionScopeHandle | undefined { | ||
| return this.sessions.get(sessionId); | ||
| } | ||
|
|
||
| async whenResumeSettled(sessionId: string): Promise<void> { | ||
| await this.pendingResumes.get(sessionId)?.catch(() => 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 a concurrent resume fails after AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L7-L7 Useful? React with 👍 / 👎. |
||
| await this.owners.get(sessionId)?.whenResumeSettled(sessionId); | ||
| } | ||
|
|
||
| list(): readonly ISessionScopeHandle[] { | ||
| return [...this.sessions.values()]; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| /** | ||
| * `sessionLifecycle` domain — cold-session archive/restore without session | ||
| * materialization, plus the live/cold batch orchestration built on top of it. | ||
| * | ||
| * `setSessionArchivedBatch` answers a batch of ids in input order: an | ||
| * in-flight resume is settled through `sessionManager` before classifying | ||
| * (the live registry hides the handle while one runs), live sessions go | ||
| * through the full `sessionManager` lifecycle chain (never resumed), and | ||
| * cold sessions are patched straight into the persisted metadata document | ||
| * through `persistence` (existence reads from `sessionIndex`), mirrored | ||
| * into the `sessionIndex` read model, and announced through `event` — | ||
| * never materialized. Plain functions over a STABLE accessor; own no | ||
| * scoped state. | ||
| */ | ||
|
|
||
| import type { ServicesAccessor } from '#/_base/di/instantiation'; | ||
| import { IBootstrapService } from '#/app/bootstrap/bootstrap'; | ||
| import { IEventService } from '#/app/event/event'; | ||
| import { ISessionManager } from '#/app/sessionManager/sessionManager'; | ||
| import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; | ||
| import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; | ||
| import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; | ||
| import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; | ||
|
|
||
| import { sessionScopeOf, workspacePersistenceScope } from './internal/addressing'; | ||
| import { SessionArchived } from './sessionLifecycleEvents'; | ||
|
|
||
| export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; | ||
|
|
||
| export async function setColdSessionArchived( | ||
| accessor: ServicesAccessor, | ||
| sessionId: string, | ||
| archived: boolean, | ||
| ): Promise<ColdSessionArchiveOutcome> { | ||
| const summary = await accessor.get(ISessionIndex).get(sessionId); | ||
| if (summary === undefined) return 'not_found'; | ||
| const docs = accessor.get(IAtomicDocumentStore); | ||
| const metaScope = sessionScopeOf( | ||
| workspacePersistenceScope( | ||
| accessor.get(IBootstrapService).scope('sessions'), | ||
| summary.workspaceId, | ||
| ), | ||
| sessionId, | ||
| ); | ||
| let persisted: SessionMeta | undefined; | ||
| try { | ||
| persisted = await docs.get<SessionMeta>(metaScope, 'state.json'); | ||
| } catch { | ||
| persisted = undefined; | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
| } | ||
| if (persisted === undefined) return 'not_found'; | ||
| const archivedAt = archived ? Date.now() : undefined; | ||
| await docs.set(metaScope, 'state.json', { ...persisted, archived, archivedAt }); | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
| accessor.get(ISessionIndexMirror).record({ ...summary, archived, archivedAt }); | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
| if (archived) { | ||
| accessor.get(IEventService).publish(new SessionArchived({ payload: { sessionId } })); | ||
| } | ||
| return 'updated'; | ||
| } | ||
|
|
||
| export type SessionArchiveBatchItemOutcome = | ||
| | { id: string; ok: true } | ||
| | { id: string; ok: false; reason: 'not_found' | 'error'; message: string }; | ||
|
|
||
| export async function setSessionArchivedBatch( | ||
| accessor: ServicesAccessor, | ||
| ids: readonly string[], | ||
| archived: boolean, | ||
| ): Promise<SessionArchiveBatchItemOutcome[]> { | ||
| const outcomes: (SessionArchiveBatchItemOutcome | undefined)[] = ids.map(() => undefined); | ||
| const applyOne = async (id: string): Promise<SessionArchiveBatchItemOutcome> => { | ||
| try { | ||
| const manager = accessor.get(ISessionManager); | ||
| await manager.whenResumeSettled(id); | ||
| if (getLiveSessionById(accessor, id) !== undefined) { | ||
| if (archived) await manager.archive(id); | ||
| else await manager.restore(id); | ||
| return { id, ok: true }; | ||
|
liruifengv marked this conversation as resolved.
Outdated
|
||
| } | ||
| const outcome = await setColdSessionArchived(accessor, id, archived); | ||
| return outcome === 'updated' | ||
| ? { id, ok: true } | ||
| : { id, ok: false, reason: 'not_found', message: `session ${id} does not exist` }; | ||
| } catch (error) { | ||
| return { | ||
| id, | ||
| ok: false, | ||
| reason: 'error', | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }; | ||
| } | ||
| }; | ||
|
|
||
| const BATCH_CONCURRENCY = 8; | ||
| let next = 0; | ||
| const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, ids.length) }, async () => { | ||
| while (next < ids.length) { | ||
| const index = next++; | ||
| outcomes[index] = await applyOne(ids[index] as string); | ||
| } | ||
| }); | ||
| await Promise.all(workers); | ||
| return outcomes as SessionArchiveBatchItemOutcome[]; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.