Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
5 changes: 5 additions & 0 deletions .changeset/cold-session-batch-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core-v2": patch
Comment thread
liruifengv marked this conversation as resolved.
Outdated
---

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).
5 changes: 5 additions & 0 deletions .changeset/v2-sessions-ids-projection.md
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`.
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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>;
list(): readonly ISessionScopeHandle[];
close(sessionId: string): Promise<void>;
archive(sessionId: string): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand Down Expand Up @@ -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.
Comment thread
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate resume failures before cold classification

When a concurrent resume fails after materializeSession() has inserted the handle into the workspace handler but before announceCreated() registers it with SessionManager (for example, main-agent creation fails), this catch suppresses the failure and the batch sees no live session through the App registry. It then performs a cold metadata write and reports success without archiving or disposing the handler-owned live scope, leaving the two lifecycle registries inconsistent. Propagate the resume rejection so this batch item becomes an internal error rather than continuing to cold classification.

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()];
}
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 @@ -456,6 +456,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
@@ -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;
Comment thread
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 });
Comment thread
liruifengv marked this conversation as resolved.
Outdated
accessor.get(ISessionIndexMirror).record({ ...summary, archived, archivedAt });
Comment thread
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 };
Comment thread
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[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
return promise;
}

async whenResumeSettled(sessionId: string): Promise<void> {
await this.resuming.get(sessionId)?.catch(() => undefined);
}

private async doResume(
sessionId: string,
opts?: ResumeSessionOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ function registerSessionExportServices(
},
resume: async () => options.lifecycleHandle,
get: () => options.lifecycleHandle,
whenResumeSettled: async () => {},
list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]),
close: async () => {},
archive: async () => {},
Expand Down
Loading
Loading