Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d26ba00
feat(kap-server): add page-number mode and total to GET /api/v2/sessions
liruifengv Aug 15, 2026
bd51811
feat(kap-server): add meta.updated_before filter to GET /api/v2/sessions
liruifengv Aug 15, 2026
2855ee5
feat(kap-server): add POST /api/v2/sessions:archive and :restore batc…
liruifengv Aug 15, 2026
6ae1b2f
docs(server-api): document v2 sessions page mode, total, updated_befo…
liruifengv Aug 15, 2026
5614588
fix(kap-server): deep-import workspace lifecycle symbols in the v2 se…
liruifengv Aug 17, 2026
fb0a45d
fix(kap-server): inline the live-handler lookup in the batch route
liruifengv Aug 17, 2026
10bd0cd
fix(kap-server): drive the batch hot path through getLiveSessionById
liruifengv Aug 17, 2026
0273106
refactor(kap-server): move the batch live/cold split into agent-core-v2
liruifengv Aug 17, 2026
d860352
fix(agent-core-v2): import sessionLookup relatively from coldSessionA…
liruifengv Aug 17, 2026
34b0ebf
Merge remote-tracking branch 'origin/main' into feat/v2-sessions-admin
liruifengv Aug 17, 2026
88215ed
fix(agent-core-v2): migrate the batch hot path to ISessionManager
liruifengv Aug 17, 2026
191204c
feat(kap-server): add the id,archived item projection to GET /api/v2/…
liruifengv Aug 17, 2026
19c63c5
Merge remote-tracking branch 'origin/main' into feat/v2-sessions-admin
liruifengv Aug 17, 2026
53a9e3f
fix(agent-core-v2): serialize the batch cold write against in-flight …
liruifengv Aug 17, 2026
0db9570
Merge remote-tracking branch 'origin/main' into feat/v2-sessions-admin
liruifengv Aug 17, 2026
63a8130
fix(agent-core-v2): publish SessionArchived as an Event2 class in col…
liruifengv Aug 17, 2026
46db11d
fix(agent-core-v2): serialize batch archive/restore with session life…
liruifengv Aug 17, 2026
62f2e93
fix(agent-core-v2): serialize session delete with the lifecycle chain
liruifengv Aug 17, 2026
7c7d896
fix(agent-core-v2): mirror the persisted metadata on cold archive, no…
liruifengv Aug 17, 2026
c28156a
docs(agent-core-v2): bring sessionManager comments and new tests to p…
liruifengv Aug 17, 2026
6d0ea97
fix(agent-core-v2): normalize legacy session metadata before the cold…
liruifengv Aug 17, 2026
e825bc2
fix(kap-server): serialize the v1 single-session archive with the lif…
liruifengv Aug 17, 2026
fa04d35
chore: drop changesets for internal-only protocol work
liruifengv Aug 17, 2026
568e47d
fix(agent-core-v2): encode cold-archived metadata for v1 readers
liruifengv Aug 17, 2026
aa05630
fix(agent-core-v2): serialize fork and createChild with the source se…
liruifengv Aug 17, 2026
46d88de
refactor(agent-core-v2): chain every session lifecycle method and han…
liruifengv Aug 18, 2026
2c0e315
fix(agent-core-v2): propagate failed resumes to the next settle
liruifengv Aug 18, 2026
165fdf3
fix(agent-core-v2): roll back the unannounced handle when a resume fa…
liruifengv Aug 18, 2026
b0c43b6
fix(agent-core-v2): read and migrate the legacy session-meta location…
liruifengv Aug 18, 2026
b7a19b6
Merge remote-tracking branch 'origin/main' into feat/v2-sessions-admin
liruifengv Aug 18, 2026
f18d154
fix(agent-core-v2): serialize explicit-id session creation with the l…
liruifengv Aug 18, 2026
b079259
style(kap-server): strip comments from the session routes per the no-…
liruifengv Aug 18, 2026
be79e0e
fix(agent-core-v2): serialize explicit fork and child target ids on t…
liruifengv Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions docs/en/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Error codes are grouped by band:
List endpoints come in two styles:

- **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list, message list, transcript, and others.
- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`.
- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. `GET /api/v2/sessions` also offers a stateless `page` page-number mode as an alternative.

## REST endpoints

Expand Down Expand Up @@ -245,6 +245,8 @@ In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{ac
| `POST /api/v1/search` | Cross-session full-text search; `mode` is `terms` (default) or `literal` (exact substring); `page_token` pagination |
| `GET /api/v1/connections` | List live WebSocket connections |
| `GET /api/v2/sessions` | Next-generation session list, see below |
| `POST /api/v2/sessions:archive` | Batch-archive sessions, see below |
| `POST /api/v2/sessions:restore` | Batch-restore archived sessions, see below |
| `/api/v1/debug/*` | Reflection debug RPC; mounted only with `--debug-endpoints` on loopback, not a stable protocol |

### `GET /api/v2/sessions`
Expand All @@ -256,13 +258,38 @@ A next-generation session query for list views — filtering, sorting, and field
| `workspace.id` | Filter by workspace; repeatable |
| `activity.status` | Filter by activity status: `running` / `approval` / `question` / `failed` / `idle`; repeatable |
| `meta.updated_after` | Only sessions updated after this time (epoch milliseconds) |
| `meta.updated_before` | Only sessions updated before this time (epoch milliseconds) |
| `meta.archived` | `true` / `false` (default) / `all` |
| `sort` | `meta.updated_at_desc` (default) / `meta.updated_at_asc` / `meta.created_at_desc` |
| `include` | Comma-separated extra field groups; currently only `git` (branch and PR info, deduplicated per directory and cached for 60 seconds) |
| `page_size` | 1–100, default 50 |
| `fields` | Comma-separated item projection; currently only `id,archived`, trimming each item to `{ id, archived }` (select-all-matching flows). Not combinable with `include=git` (`40001`) |
| `page_size` | 1–100, default 50; up to 10000 with the `id,archived` projection |
| `page_token` | Pagination token from the previous page |
| `page` | Stateless 1-based page number; mutually exclusive with `page_token` (`40001` when combined) |

Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`.
Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git` — or just `{ id, archived }` under `fields=id,archived`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`.

### `POST /api/v2/sessions:archive` and `POST /api/v2/sessions:restore`

Batch archive/restore for session-management views. The body is `{ "ids": ["session_..."] }` — non-empty, at most 5000 unique ids (duplicates collapse). Live sessions go through the full lifecycle; cold sessions are patched on disk without being loaded.

Only a body validation failure fails the whole request (`40001`). Otherwise the response is per-item: `data.results` keeps the input order with `{ id, ok }` or `{ id, ok: false, error }` (an unknown id reports `40401` in its own item), plus `succeeded` / `failed` counts.

```json
{
"code": 0,
"msg": "success",
"data": {
"results": [
{ "id": "session_a", "ok": true },
{ "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } }
],
"succeeded": 1,
"failed": 1
},
"request_id": "req_..."
}
```

## WebSocket protocol

Expand Down
33 changes: 30 additions & 3 deletions docs/zh/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ HTTP 状态码几乎总是 200,业务结果以 `code` 为准。例外情况:
列表端点有两种分页风格:

- **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表、消息列表、转录等。
- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。
- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。`GET /api/v2/sessions` 另提供无状态的 `page` 页码模式作为替代。

## REST 端点

Expand Down Expand Up @@ -245,6 +245,8 @@ PTY 终端接口,仅 loopback 绑定时挂载。
| `POST /api/v1/search` | 跨会话全文搜索,`mode` 为 `terms`(默认)或 `literal`(精确子串),`page_token` 分页 |
| `GET /api/v1/connections` | 列出当前在线的 WebSocket 连接 |
| `GET /api/v2/sessions` | 新一代会话列表,见下节 |
| `POST /api/v2/sessions:archive` | 批量归档会话,见下节 |
| `POST /api/v2/sessions:restore` | 批量恢复已归档会话,见下节 |
| `/api/v1/debug/*` | 反射式调试 RPC,仅 `--debug-endpoints` 且 loopback 时挂载,不属于稳定协议 |

### `GET /api/v2/sessions`
Expand All @@ -256,13 +258,38 @@ PTY 终端接口,仅 loopback 绑定时挂载。
| `workspace.id` | 按工作区过滤,可重复 |
| `activity.status` | 按活动状态过滤:`running` / `approval` / `question` / `failed` / `idle`,可重复 |
| `meta.updated_after` | 只看该时间(epoch 毫秒)之后更新过的会话 |
| `meta.updated_before` | 只看该时间(epoch 毫秒)之前更新过的会话 |
| `meta.archived` | `true` / `false`(默认)/ `all` |
| `sort` | `meta.updated_at_desc`(默认)/ `meta.updated_at_asc` / `meta.created_at_desc` |
| `include` | 逗号分隔的附加字段组;目前支持 `git`(分支与 PR 信息,按目录去重并缓存 60 秒) |
| `page_size` | 1–100,默认 50 |
| `fields` | 逗号分隔的字段投影;目前仅支持 `id,archived`,每项裁剪为 `{ id, archived }`(用于全选匹配场景)。不可与 `include=git` 同传(`40001`) |
| `page_size` | 1–100,默认 50;使用 `id,archived` 投影时上限放宽至 10000 |
| `page_token` | 上一页返回的翻页令牌 |
| `page` | 无状态的 1 起始页码;与 `page_token` 互斥(同传返回 `40001`) |

响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。
响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组;`fields=id,archived` 时仅返回 `{ id, archived }`。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件(含投影),中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。

### `POST /api/v2/sessions:archive` 与 `POST /api/v2/sessions:restore`

面向会话管理页的批量归档/恢复。请求体为 `{ "ids": ["session_..."] }`——非空、去重后不超过 5000 条。仍在线的会话走完整生命周期;未加载的冷会话直接改写磁盘上的元数据,不会被加载。

只有请求体校验失败才会让整个请求失败(`40001`);其余情况按条返回:`data.results` 保持输入顺序,每项为 `{ id, ok }` 或 `{ id, ok: false, error }`(不存在的 id 在自身条目里报 `40401`),并附 `succeeded` / `failed` 计数。

```json
{
"code": 0,
"msg": "success",
"data": {
"results": [
{ "id": "session_a", "ok": true },
{ "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } }
],
"succeeded": 1,
"failed": 1
},
"request_id": "req_..."
}
```

## WebSocket 协议

Expand Down
10 changes: 10 additions & 0 deletions packages/agent-core-v2/src/app/sessionManager/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface CreateManagedSessionOptions extends CreateSessionOptions {
readonly workspaceId?: string;
}

export interface UnguardedSessionLifecycle {
archive(): Promise<void>;
restore(): Promise<ISessionScopeHandle | undefined>;
}

export interface ISessionManager {
readonly _serviceBrand: undefined;
readonly onWillCreateSession?: Event<SessionWillCreateEvent>;
Expand All @@ -29,6 +34,11 @@ export interface ISessionManager {
create(options: CreateManagedSessionOptions): Promise<ISessionScopeHandle>;
resume(sessionId: string, options?: ResumeSessionOptions): Promise<ISessionScopeHandle | undefined>;
get(sessionId: string): ISessionScopeHandle | undefined;
whenResumeSettled(sessionId: string): Promise<void>;
withLifecycleSerialization<T>(
sessionId: string,
work: (unguarded: UnguardedSessionLifecycle) => Promise<T>,
): Promise<T>;
list(): readonly ISessionScopeHandle[];
close(sessionId: string): Promise<void>;
archive(sessionId: string): Promise<void>;
Expand Down
140 changes: 113 additions & 27 deletions packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import { DisposableStore } from '#/_base/di/lifecycle';
import { Emitter, type Event, type IWaitUntil } from '#/_base/event';
import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope';
Expand All @@ -18,7 +19,11 @@ import {
import type { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService';
import { IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager';

import { ISessionManager, type CreateManagedSessionOptions } from './sessionManager';
import {
ISessionManager,
type CreateManagedSessionOptions,
type UnguardedSessionLifecycle,
} from './sessionManager';

interface SessionControllerEntry {
readonly generation: string;
Expand All @@ -31,6 +36,9 @@ 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 resumeFailures = new Map<string, Error>();
private readonly lifecycleChains = new Map<string, Promise<void>>();
private readonly controllers = new Map<string, SessionControllerEntry>();
private readonly controllerEntries = new Set<SessionControllerEntry>();
private readonly willCreateEmitter = new Emitter<SessionWillCreateEvent>();
Expand All @@ -57,61 +65,139 @@ export class SessionManager implements ISessionManager {
? { root: options.workDir }
: { workspaceId: options.workspaceId, root: options.workDir },
);
return this.controllerForWorkspace(workspace.id).create(options);
const controller = this.controllerForWorkspace(workspace.id);
if (options.sessionId === undefined) return controller.create(options);
return this.serializeLifecycle(options.sessionId, () => controller.create(options));
}

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;
this.resumeFailures.delete(sessionId);
const promise = this.serializeLifecycle(sessionId, async () =>
(await this.controllerForSession(sessionId))?.resume(sessionId, options),
).finally(() => this.pendingResumes.delete(sessionId));
this.pendingResumes.set(sessionId, promise);
void promise.catch((error: unknown) => {
this.resumeFailures.set(sessionId, error instanceof Error ? error : new Error('session resume failed'));
});
return promise;
}

get(sessionId: string): ISessionScopeHandle | undefined {
return this.sessions.get(sessionId);
}

async whenResumeSettled(sessionId: string): Promise<void> {
await this.pendingResumes.get(sessionId);
const failure = this.resumeFailures.get(sessionId);
if (failure !== undefined) throw failure;
await this.owners.get(sessionId)?.whenResumeSettled(sessionId);
}

private serializeLifecycle<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
Comment thread
liruifengv marked this conversation as resolved.
const prev = this.lifecycleChains.get(sessionId) ?? Promise.resolve();
const run = prev.then(work, work);
const next = run.then(
() => undefined,
() => undefined,
);
this.lifecycleChains.set(sessionId, next);
void next.finally(() => {
if (this.lifecycleChains.get(sessionId) === next) this.lifecycleChains.delete(sessionId);
});
return run;
}

private serializeLifecycleForKeys<T>(keys: readonly string[], work: () => Promise<T>): Promise<T> {
const [first, ...rest] = keys;
if (first === undefined) return work();
return this.serializeLifecycle(first, () => this.serializeLifecycleForKeys(rest, work));
}

private lifecycleKeys(...ids: (string | undefined)[]): string[] {
return [...new Set(ids.filter((id): id is string => id !== undefined))].sort();
}

withLifecycleSerialization<T>(
sessionId: string,
work: (unguarded: UnguardedSessionLifecycle) => Promise<T>,
): Promise<T> {
return this.serializeLifecycle(sessionId, () =>
work({
archive: () => this.archiveInner(sessionId),
restore: () => this.restoreInner(sessionId),
}),
);
}

list(): readonly ISessionScopeHandle[] {
return [...this.sessions.values()];
}

async close(sessionId: string): Promise<void> {
await this.owners.get(sessionId)?.close(sessionId);
await this.serializeLifecycle(sessionId, async () => this.owners.get(sessionId)?.close(sessionId));
}

async archive(sessionId: string): Promise<void> {
private async archiveInner(sessionId: string): Promise<void> {
await (await this.controllerForSession(sessionId))?.archive(sessionId);
}

async restore(sessionId: string, options?: ResumeSessionOptions): Promise<ISessionScopeHandle | undefined> {
async archive(sessionId: string): Promise<void> {
await this.serializeLifecycle(sessionId, () => this.archiveInner(sessionId));
}

private async restoreInner(
sessionId: string,
options?: ResumeSessionOptions,
): Promise<ISessionScopeHandle | undefined> {
return (await this.controllerForSession(sessionId))?.restore(sessionId, options);
}

async restore(sessionId: string, options?: ResumeSessionOptions): Promise<ISessionScopeHandle | undefined> {
return this.serializeLifecycle(sessionId, () => this.restoreInner(sessionId, options));
}

async delete(sessionId: string): Promise<void> {
const controller = await this.controllerForSession(sessionId);
if (controller === undefined) {
throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await controller.delete(sessionId);
await this.serializeLifecycle(sessionId, async () => {
const controller = await this.controllerForSession(sessionId);
if (controller === undefined) {
throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await controller.delete(sessionId);
});
}

async fork(options: ForkSessionOptions): Promise<ISessionScopeHandle> {
const controller = await this.controllerForSession(options.sourceSessionId);
if (controller === undefined) {
throw new Error2(
ErrorCodes.SESSION_NOT_FOUND,
`session ${options.sourceSessionId} does not exist`,
);
}
return controller.fork(options);
return this.serializeLifecycleForKeys(
this.lifecycleKeys(options.sourceSessionId, options.newSessionId),
async () => {
const controller = await this.controllerForSession(options.sourceSessionId);
if (controller === undefined) {
throw new Error2(
ErrorCodes.SESSION_NOT_FOUND,
`session ${options.sourceSessionId} does not exist`,
);
}
return controller.fork(options);
},
);
}

async createChild(options: CreateChildSessionOptions): Promise<ISessionScopeHandle> {
const controller = await this.controllerForSession(options.sourceSessionId);
if (controller === undefined) {
throw new Error2(
ErrorCodes.SESSION_NOT_FOUND,
`session ${options.sourceSessionId} does not exist`,
);
}
return controller.createChild(options);
return this.serializeLifecycleForKeys(
this.lifecycleKeys(options.sourceSessionId, options.newSessionId),
async () => {
const controller = await this.controllerForSession(options.sourceSessionId);
if (controller === undefined) {
throw new Error2(
ErrorCodes.SESSION_NOT_FOUND,
`session ${options.sourceSessionId} does not exist`,
);
}
return controller.createChild(options);
},
);
}

dispose(): void {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ export * from '#/workspace/workspaceContext/workspaceContext';
export * from '#/workspace/sessionLifecycle/sessionLifecycle';
export * from '#/workspace/sessionLifecycle/sessionLifecycleEvents';
export * from '#/workspace/sessionLifecycle/sessionLifecycleService';
export * from '#/workspace/sessionLifecycle/coldSessionArchive';
export * from '#/workspace/sessionLifecycle/internal/addressing';
export * from '#/session/externalHooks/externalHooks';
export * from '#/session/externalHooks/externalHooksService';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ function isSessionTitleKind(value: unknown): value is SessionTitleKind {

type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean };

function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta {
export function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta {
return { ...meta, isCustomTitle: meta.titleKind === 'custom' };
}

Expand Down
Loading
Loading