feat(daemon): Add session archive support (#6058)

* feat(daemon): add session archive support

Add active versus archived daemon session storage, archive and unarchive APIs, strict live-session close handling, ACP and SDK support, and coverage for archive listing, load rejection, deletion, and route mapping.

Closes #6057

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: fix CI failure on PR #6058

Update the no-AK integration capability baseline to include the new session_archive capability added by this PR.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6058)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): preserve live session on strict close failure

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): serialize session delete with archive transitions

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(daemon): share session archive orchestration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): clean up strict close failures

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(daemon): clarify archive recovery tradeoffs

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): log session archive outcomes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): parallelize archive session closes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): serialize archive restore races

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): satisfy archive lint checks

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): keep strict close retryable

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): distinguish archive conflicts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): warn on unreadable session heads

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(core): document active-only session helpers

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): tighten archive review edges

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): allow concurrent session restores during archive gate

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): avoid archive head reads on session restore

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#6058)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): account for session archive bundle size

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address archive gate review (#6058)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address archive review follow-ups (#6058)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address ACP archive review feedback (#6058)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): Address session archive review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(daemon): Cover close ownership restoration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): Address archive review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(daemon): Cover ACP close prompt fallback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(daemon): Cover archive close channel loss

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): Address archive follow-up review

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): Return archiving conflict for delete gate

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): Treat unreadable archived ids as occupied

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(daemon): Share session delete orchestration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): Address archive review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(deps): Clear critical runtime audit failures

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(deps): Sync runtime dependency ranges with main

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-07-01 16:24:26 +08:00 committed by GitHub
parent 891c59fbaa
commit b4fe43741a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 5399 additions and 1223 deletions

View file

@ -205,8 +205,26 @@ attach or subscriber remains, the session is reaped. The endpoint returns 204.
### Batch Session Delete
`POST /sessions/delete` accepts `{ sessionIds: string[] }` (up to 100 ids),
closes bridge sessions, and deletes transcript files. It uses
`Promise.allSettled` for resilience and returns `{ removed, notFound, errors }`.
closes bridge sessions, and deletes active or archived transcript files. If both
active and archived JSONL files exist for the same id, hard delete removes both
so operators can clear the conflict. It cleans active and archived worktree
sidecars, but leaves file-history snapshots, subagent transcripts, and runtime
sidecars intact. It uses `Promise.allSettled` for resilience and returns
`{ removed, notFound, errors }`.
### Session Archive
`POST /sessions/archive` moves inactive session JSONL files from `chats/` into
`chats/archive/`. If the target session is live, the daemon first enters a
per-session archive gate and performs a strict close that requires the ACP child
to flush `ChatRecordingService`; archive leaves the JSONL in place if close or
flush fails.
`POST /sessions/unarchive` moves archived JSONL files back to `chats/`. This is
only a storage-state transition; clients must call `session/load` or
`session/resume` afterward. Archived sessions return `409 session_archived` for
load/resume, and mutations racing an archive transition return
`409 session_archiving`.
### Context Usage (`session_context_usage` capability tag)

View file

@ -97,7 +97,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T
Foundation: `health`, `daemon_status`, `capabilities`.
Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.
Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.
Streaming: `slow_client_warning`, `typed_event_schema`.

View file

@ -111,6 +111,30 @@ Attaches to existing sessions are NOT counted toward the cap, so an idle daemon'
Fired when a `session/load` is issued for an id that already has a `session/resume` in flight (or vice versa). Wait at least `Retry-After` seconds and retry — the underlying restore completes within `initTimeoutMs` (default 10s). Same-action races (`load` vs `load`, `resume` vs `resume`) coalesce instead of erroring.
`SessionArchivedError` is emitted when a caller tries to load or resume a session whose JSONL is under `chats/archive/`:
```json
{
"error": "Session \"<sid>\" is archived. Unarchive it before loading.",
"code": "session_archived",
"sessionId": "<sid>"
}
```
with status `409`.
`SessionArchivingError` is emitted when a session archive or unarchive transition is already in flight for the same id:
```json
{
"error": "Session \"<sid>\" is being archived or unarchived; retry later.",
"code": "session_archiving",
"sessionId": "<sid>"
}
```
with status `409` and `Retry-After: 5`.
## Capabilities
The daemon advertises its supported feature tags from the serve capability
@ -130,7 +154,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'workspace_preflight', 'session_context', 'session_context_usage',
'session_supported_commands', 'session_tasks', 'session_stats',
'session_lsp', 'session_status',
'session_close', 'session_metadata', 'mcp_guardrails',
'session_close', 'session_metadata', 'session_archive', 'mcp_guardrails',
'workspace_mcp_manage', 'mcp_guardrail_events',
'mcp_server_runtime_mutation',
'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write',
@ -159,6 +183,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
`session_close` and `session_metadata` advertise `DELETE /session/:id` and `PATCH /session/:id/metadata`. Older daemons return `404`; pre-flight these tags before exposing close or rename affordances.
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.
`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.
`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
@ -1151,6 +1177,9 @@ Response:
- `400``workspace_mismatch` (same shape as `POST /session`).
- `503``session_limit_exceeded` (counts against `--max-sessions`; in-flight restores are accounted for too).
- `409``restore_in_progress` (a `session/resume` for the same id is already in flight). `Retry-After: 5`. Same-action races (two concurrent `session/load` for the same id) coalesce — exactly one returns `attached: false`, the rest return `attached: true` with the same `state`.
- `409``session_archived` when the id exists only under `chats/archive/`; call `POST /sessions/unarchive` before `load` or `resume`.
- `409``session_archiving` when archive or unarchive is in flight for the same id. `Retry-After: 5`.
- `409``session_conflict` when the id exists in both `chats/` and `chats/archive/`; delete the session with `POST /sessions/delete` before loading.
### `POST /session/:id/resume`
@ -1164,12 +1193,21 @@ Use `/load` when the client has no history rendered (cold reconnect, picker →
### `GET /workspace/:id/sessions`
List all live sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd).
List persisted sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd). The default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. `archiveState=all` is not supported in v1.
```bash
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions?archiveState=archived
```
Query parameters:
| Field | Required | Notes |
| -------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `archiveState` | no | `active` (default) or `archived`. Any other value returns `400 { code: "invalid_archive_state" }`. |
| `cursor` | no | Pagination cursor from the previous response. |
| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. |
Response:
```json
@ -1181,13 +1219,85 @@ Response:
"createdAt": "2026-05-17T08:30:00.000Z",
"displayName": "My Session",
"clientCount": 2,
"hasActivePrompt": false
"hasActivePrompt": false,
"isArchived": false
}
]
],
"nextCursor": 1772251200000
}
```
Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle.
Active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Archived lists are storage-only: `isArchived` is `true`, and live overlay fields remain absent or false. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle.
### `POST /sessions/delete`
Hard-delete one or more persisted session JSONL files. The daemon first best-effort closes live sessions, then removes the active or archived JSONL. If both active and archived copies exist for the same id, both are removed. Worktree sidecars on both sides are cleaned; file history, subagent transcripts, and runtime sidecars are intentionally preserved.
Request:
```json
{ "sessionIds": ["<uuid>"] }
```
Response:
```json
{
"removed": ["<uuid>"],
"notFound": [],
"errors": []
}
```
### `POST /sessions/archive`
Archive one or more sessions. Archive is a state transition, not deletion: the JSONL moves from `chats/<id>.jsonl` to `chats/archive/<id>.jsonl`. File history, subagent transcripts, and runtime sidecars stay in place. If a session is live, the daemon first performs a strict close and requires the ACP agent's close handler to flush the chat recording; if close or flush fails, the JSONL is not moved. Pre-flight `caps.features.session_archive`.
Request:
```json
{ "sessionIds": ["<uuid>"] }
```
`sessionIds` must be a non-empty string array with at most 100 ids. Duplicates are collapsed.
Response:
```json
{
"archived": ["<uuid>"],
"alreadyArchived": [],
"notFound": [],
"errors": []
}
```
`errors` entries have `{ "sessionId": "<uuid>", "error": "message" }`. Active and archived files with the same id are treated as a conflict and reported in `errors`; no file is overwritten.
### `POST /sessions/unarchive`
Restore archived sessions to the active directory. This does not resume the session by itself; it only moves `chats/archive/<id>.jsonl` back to `chats/<id>.jsonl`. After unarchive succeeds, clients may call `POST /session/:id/load` or `POST /session/:id/resume`.
Request:
```json
{ "sessionIds": ["<uuid>"] }
```
Response:
```json
{
"unarchived": ["<uuid>"],
"alreadyActive": [],
"notFound": [],
"errors": []
}
```
If an active JSONL already exists for the id, unarchive reports a conflict in `errors` and does not overwrite it. Archive or unarchive in flight for the same id returns `409 session_archiving` before starting the batch.
ACP-over-HTTP uses the same request and response bodies through vendor methods `_qwen/sessions/archive` and `_qwen/sessions/unarchive`. The REST route table maps `POST /sessions/archive` and `POST /sessions/unarchive` to those methods for ACP transports.
### `POST /session/:id/prompt`

View file

@ -271,6 +271,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_lsp',
'session_status',
'session_close',
'session_archive',
'session_metadata',
'mcp_guardrails',
'workspace_mcp_manage',

12
package-lock.json generated
View file

@ -12434,9 +12434,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -17158,9 +17158,9 @@
}
},
"node_modules/npm-run-all2/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {

View file

@ -8973,6 +8973,54 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('preserves bridge state when required agent close fails so retry can flush', async () => {
let failClose = true;
const handle = makeChannel({
extMethodImpl: (method) => {
if (method === 'qwen/control/session/close' && failClose) {
failClose = false;
throw new Error('flush failed');
}
return {};
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await expect(
bridge.closeSession(session.sessionId, undefined, {
requireAgentClose: true,
}),
).rejects.toThrow();
expect(handle.agent.extMethodCalls).toHaveLength(1);
expect(handle.agent.extMethodCalls[0]).toEqual({
method: 'qwen/control/session/close',
params: { sessionId: session.sessionId, requireFlush: true },
});
expect(bridge.sessionCount).toBe(1);
expect(() =>
bridge.recordHeartbeat(session.sessionId, {
clientId: session.clientId,
}),
).not.toThrow();
await bridge.closeSession(session.sessionId, undefined, {
requireAgentClose: true,
});
expect(handle.agent.extMethodCalls).toHaveLength(2);
expect(() =>
bridge.recordHeartbeat(session.sessionId, {
clientId: session.clientId,
}),
).toThrow(SessionNotFoundError);
await bridge.shutdown();
});
it('resolves pending permissions as cancelled', async () => {
let capturedConn: AgentSideConnection | undefined;
const factory: ChannelFactory = async () => {

View file

@ -1985,13 +1985,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
entry: SessionEntry,
ci: ChannelInfo | undefined,
label: 'closeSession' | 'killSession',
opts?: { throwOnFailure?: boolean; requireFlush?: boolean },
): Promise<void> => {
if (!ci || ci.channel !== entry.channel) return;
if (!ci || ci.channel !== entry.channel) {
if (opts?.throwOnFailure === true) {
writeStderrLine(
`qwen serve: ${label} ACP session close channel unavailable ` +
`for session ${JSON.stringify(entry.sessionId)}; agent close skipped`,
);
throw new Error(
`ACP session close channel unavailable for ${entry.sessionId}`,
);
}
return;
}
try {
await Promise.race([
withTimeout(
entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, {
sessionId: entry.sessionId,
...(opts?.requireFlush === true ? { requireFlush: true } : {}),
}),
initTimeoutMs,
SERVE_CONTROL_EXT_METHODS.sessionClose,
@ -2005,6 +2018,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
err instanceof Error ? err.message : err,
)}`,
);
if (opts?.throwOnFailure === true) {
throw err;
}
}
};
@ -2664,15 +2680,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
`for session ${JSON.stringify(sessionId)} — channel cleanup skipped (entry's channel already torn down)`,
);
}
const requireAgentClose = closeOpts?.requireAgentClose === true;
if (requireAgentClose) {
await notifyAgentSessionClose(entry, ci, 'closeSession', {
throwOnFailure: true,
requireFlush: true,
});
}
if (ci && ci.channel === entry.channel) {
ci.sessionIds.delete(sessionId);
}
// Synchronous teardown block — intentionally diverges from killSession:
// tombstone + event publish + bus close all run BEFORE
// notifyAgentSessionClose, so concurrent callers see
// byId.get(sessionId) === undefined and throw SessionNotFoundError,
// and late agent frames arriving during the RPC are dropped by the
// closed bus.
// For normal close, tombstone + event publish + bus close run before the
// best-effort agent notification. Strict archive close is different: the
// agent flush must succeed before bridge state is removed, so a failed
// archive close can be retried against the same live session.
permissionMediator.forgetSession(sessionId);
entry.pendingPermissionIds.clear();
if (entry.promptActive) {
@ -2706,7 +2727,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// `session_closed` is terminal. Close the bus before ACP cancel so any
// late cancellation frames from the agent are intentionally dropped.
entry.events.close();
await notifyAgentSessionClose(entry, ci, 'closeSession');
if (!requireAgentClose) {
await notifyAgentSessionClose(entry, ci, 'closeSession');
}
try {
await telemetry.withSpan(
'session.close.cancel_active_prompt',

View file

@ -64,6 +64,46 @@ export class SessionNotFoundError extends Error {
}
}
export class SessionArchivedError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(`Session "${sessionId}" is archived. Unarchive it before loading.`);
this.name = 'SessionArchivedError';
this.sessionId = sessionId;
}
}
export class SessionConflictError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(
`Session "${sessionId}" exists in both active and archived directories. ` +
`Delete the session with POST /sessions/delete before loading.`,
);
this.name = 'SessionConflictError';
this.sessionId = sessionId;
}
}
export class SessionArchivingError extends Error {
readonly sessionId: string;
readonly lockKind: 'exclusive' | 'shared';
constructor(
sessionId: string,
lockKind: 'exclusive' | 'shared' = 'exclusive',
) {
super(
`Session "${sessionId}" is being archived or unarchived; retry later.`,
);
this.name = 'SessionArchivingError';
this.sessionId = sessionId;
this.lockKind = lockKind;
}
}
export class RestoreInProgressError extends Error {
readonly sessionId: string;
readonly activeAction: 'load' | 'resume';

View file

@ -157,6 +157,7 @@ export interface BridgeSessionSummary {
displayName?: string;
clientCount: number;
hasActivePrompt: boolean;
isArchived?: boolean;
}
export interface SessionMetadataUpdate {
@ -166,6 +167,8 @@ export interface SessionMetadataUpdate {
export interface CloseSessionOpts {
/** Override the default `'client_close'` reason in the `session_closed` event. */
reason?: string;
/** Require the ACP child to acknowledge session close before resolving. */
requireAgentClose?: boolean;
}
export interface BridgeClientRequestContext {

View file

@ -293,6 +293,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
updatedModelProviders: {},
}),
unregisterGoalHook: vi.fn(),
uiTelemetryService: {
removeSession: vi.fn(),
},
runManagedRememberByAgent: mockRunManagedRememberByAgent,
clearCachedCredentialFile: vi.fn(),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']),
@ -6204,6 +6207,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
| ((conn: AgentSideConnectionLike) => AgentLike)
| undefined;
let mockConfig: Config;
let liveCancelPendingPrompt: ReturnType<typeof vi.fn>;
// Live session sessionId is whatever `getSessionId()` on the inner config
// returns; matches the existing test scaffolding.
@ -6213,6 +6217,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
vi.clearAllMocks();
mockConnectionState.reset();
capturedAgentFactory = undefined;
liveCancelPendingPrompt = vi.fn().mockResolvedValue(undefined);
vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => {
capturedAgentFactory = factory as typeof capturedAgentFactory;
@ -6274,6 +6279,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(true),
hasHooksForEvent: vi.fn().mockReturnValue(false),
getToolRegistry: vi.fn().mockReturnValue(undefined),
getChatRecordingService: vi.fn().mockReturnValue(recording),
};
}
@ -6298,6 +6304,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
({
getId: vi.fn().mockReturnValue(liveSessionId),
getConfig: vi.fn().mockReturnValue(innerConfig),
cancelPendingPrompt: liveCancelPendingPrompt,
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
replayHistory: vi.fn().mockResolvedValue(undefined),
installRewriter: vi.fn(),
@ -6408,6 +6415,58 @@ describe('QwenAgent extMethod renameSession routing', () => {
mockConnectionState.resolve();
await agentPromise;
});
it('keeps the live session open when strict session close flush fails', async () => {
const recording = makeRecordingService();
recording.flush.mockRejectedValueOnce(new Error('flush failed'));
const innerConfig = makeLiveSessionInnerConfig(recording);
const toolRegistry = { stop: vi.fn().mockResolvedValue(undefined) };
innerConfig.getToolRegistry.mockReturnValue(toolRegistry);
const { agent, agentPromise } = await bootAgent(innerConfig);
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod('qwen/control/session/close', {
sessionId: liveSessionId,
requireFlush: true,
}),
).rejects.toThrow('flush failed');
expect(
(
agent as unknown as {
getActiveSessions: () => Array<{ getId: () => string }>;
}
)
.getActiveSessions()
.map((session) => session.getId()),
).toContain(liveSessionId);
expect(recording.flush).toHaveBeenCalledOnce();
expect(liveCancelPendingPrompt).not.toHaveBeenCalled();
expect(toolRegistry.stop).not.toHaveBeenCalled();
await expect(
agent.extMethod('qwen/control/session/close', {
sessionId: liveSessionId,
requireFlush: true,
}),
).resolves.toEqual({ sessionId: liveSessionId, closed: true });
expect(recording.flush).toHaveBeenCalledTimes(3);
expect(liveCancelPendingPrompt).toHaveBeenCalledOnce();
expect(toolRegistry.stop).toHaveBeenCalledOnce();
expect(
(
agent as unknown as {
getActiveSessions: () => Array<{ getId: () => string }>;
}
)
.getActiveSessions()
.map((session) => session.getId()),
).not.toContain(liveSessionId);
mockConnectionState.resolve();
await agentPromise;
});
});
describe('QwenAgent unstable_listSessions cursor parsing', () => {

View file

@ -2616,13 +2616,38 @@ class QwenAgent implements Agent {
}
}
private async closeStoredSession(sessionId: string): Promise<void> {
private async closeStoredSession(
sessionId: string,
opts?: { requireFlush?: boolean },
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
this.mcpPool?.releaseSession(sessionId);
return;
}
const requireFlush = opts?.requireFlush === true;
const flushRecording = async (): Promise<unknown> => {
try {
await session.getConfig().getChatRecordingService()?.flush();
return undefined;
} catch (err) {
debugLogger.debug(
`Session ${sessionId} chat recording flush during close failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return err;
}
};
if (requireFlush) {
const preCancelFlushError = await flushRecording();
if (preCancelFlushError !== undefined) {
throw preCancelFlushError;
}
}
try {
await session.cancelPendingPrompt();
} catch (err) {
@ -2633,6 +2658,11 @@ class QwenAgent implements Agent {
);
}
const flushError = await flushRecording();
if (flushError !== undefined && requireFlush) {
throw flushError;
}
try {
await session.getConfig().getToolRegistry()?.stop();
} catch (err) {
@ -5874,7 +5904,9 @@ class QwenAgent implements Agent {
'Invalid or missing sessionId',
);
}
await this.closeStoredSession(sessionId);
await this.closeStoredSession(sessionId, {
requireFlush: params['requireFlush'] === true,
});
return { sessionId, closed: true };
}
case SERVE_CONTROL_EXT_METHODS.sessionCd: {

View file

@ -1411,7 +1411,8 @@ export async function loadCliConfig(
): Promise<Config> {
const debugMode = isDebugMode(argv);
const bareMode = isBareMode(argv.bare);
const safeMode = argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv();
const safeMode =
argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv();
// Surface `--insecure` as an env var so it reaches the undici dispatcher
// layer (which controls TLS verification) without threading a flag through
@ -1855,9 +1856,11 @@ export async function loadCliConfig(
// Use provided session ID without session resumption
// Check if session ID is already in use
const sessionService = new SessionService(cwd);
const exists = await sessionService.sessionExists(argv['sessionId']);
const exists = await sessionService.sessionExistsInAnyState(
argv['sessionId'],
);
if (exists) {
const message = `Error: Session Id ${argv['sessionId']} is already in use.`;
const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`;
writeStderrLine(message);
process.exit(1);
}
@ -2093,9 +2096,7 @@ export async function loadCliConfig(
? false
: (settings.memory?.enableTeamMemorySync ?? false),
enableAutoSkill:
bareMode || safeMode
? false
: (settings.memory?.enableAutoSkill ?? true),
bareMode || safeMode ? false : (settings.memory?.enableAutoSkill ?? true),
autoSkillConfirm:
bareMode || safeMode
? false

View file

@ -15,6 +15,7 @@ import {
WorkspaceMemoryFileTooLargeError,
WorkspaceMemoryWriteTimeoutError,
writeWorkspaceContextFile,
type SessionArchiveState,
type SubagentLevel,
} from '@qwen-code/qwen-code-core';
// Import the permission error classes from the same module REST's
@ -25,6 +26,7 @@ import {
InvalidPermissionOptionError,
PermissionForbiddenError,
PermissionPolicyNotImplementedError,
SessionArchivingError,
} from '../acp-session-bridge.js';
import { FsError } from '../fs/errors.js';
import {
@ -37,7 +39,9 @@ import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
SessionShellClientRequiredError,
SessionShellDisabledError,
WorkspaceMismatchError,
} from '@qwen-code/acp-bridge/bridgeErrors';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js';
import {
@ -76,6 +80,14 @@ import {
InvalidCursorError,
listWorkspaceSessionsForResponse,
} from '../server.js';
import {
archiveDaemonSessions,
assertSessionLoadable,
deleteDaemonSessions,
logSessionArchiveWarning,
SessionArchiveCoordinator,
unarchiveDaemonSessions,
} from '../server/session-archive.js';
import type {
DaemonWorkspaceService,
WorkspaceRequestContext,
@ -173,6 +185,8 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [
`${QWEN_METHOD_NS}workspace/mcp/servers/add`,
`${QWEN_METHOD_NS}workspace/mcp/servers/remove`,
`${QWEN_METHOD_NS}sessions/delete`,
`${QWEN_METHOD_NS}sessions/archive`,
`${QWEN_METHOD_NS}sessions/unarchive`,
// Wave 2: agents
`${QWEN_METHOD_NS}workspace/agents/list`,
`${QWEN_METHOD_NS}workspace/agents/get`,
@ -452,6 +466,33 @@ function toRpcError(err: unknown): {
}
const name = err instanceof Error ? err.name : '';
switch (name) {
case 'SessionArchivedError':
return {
code: RPC.INTERNAL_ERROR,
message: errMsg(err),
data: {
errorKind: 'session_archived',
sessionId: (err as { sessionId?: unknown }).sessionId,
},
};
case 'SessionConflictError':
return {
code: RPC.INTERNAL_ERROR,
message: errMsg(err),
data: {
errorKind: 'session_conflict',
sessionId: (err as { sessionId?: unknown }).sessionId,
},
};
case 'SessionArchivingError':
return {
code: RPC.INTERNAL_ERROR,
message: errMsg(err),
data: {
errorKind: 'session_archiving',
sessionId: (err as { sessionId?: unknown }).sessionId,
},
};
case 'SessionNotFoundError':
case 'InvalidSessionScopeError':
case 'WorkspaceMismatchError':
@ -513,6 +554,7 @@ export class AcpDispatcher {
private readonly deviceFlowRegistry?: DeviceFlowRegistry,
private readonly sessionShellCommandEnabled: boolean = false,
private readonly registry?: ConnectionRegistry,
private readonly archiveCoordinator: SessionArchiveCoordinator = new SessionArchiveCoordinator(),
) {
this.agentManager = createDaemonSubagentManager(boundWorkspace);
}
@ -540,6 +582,30 @@ export class AcpDispatcher {
};
}
private parseSessionIds(params: Record<string, unknown>): string[] {
const sessionIds = params['sessionIds'];
if (
!Array.isArray(sessionIds) ||
sessionIds.length === 0 ||
sessionIds.length > 100 ||
!sessionIds.every((s) => typeof s === 'string')
) {
throw new AcpParamError(
'`sessionIds` must be non-empty string array (max 100)',
);
}
return [...new Set(sessionIds as string[])];
}
private serializeSessionErrors(
errors: Array<{ sessionId: string; error: unknown }>,
): Array<{ sessionId: string; error: string }> {
return errors.map((e) => ({
sessionId: e.sessionId,
error: errMsg(e.error),
}));
}
/**
* Build the bridge context for a per-session call. Echoes the clientId the
* bridge STAMPED at create/attach (the connection's own id is unregistered
@ -755,6 +821,18 @@ export class AcpDispatcher {
return false;
}
private async withMutableOwned(
conn: AcpConnection,
sessionId: string,
id: JsonRpcId | undefined,
fn: () => Promise<void> | void,
): Promise<void> {
if (!this.requireOwned(conn, sessionId, id)) return;
await this.archiveCoordinator.runSharedMany([sessionId], async () => {
await fn();
});
}
private findPendingClientRequest(
conn: AcpConnection,
id: string,
@ -931,18 +1009,23 @@ export class AcpDispatcher {
return;
}
const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace);
const restored =
method === 'session/load'
? await this.bridge.loadSession({
sessionId,
workspaceCwd: cwd,
clientId: conn.clientId,
})
: await this.bridge.resumeSession({
sessionId,
workspaceCwd: cwd,
clientId: conn.clientId,
});
const restored = await this.archiveCoordinator.runSharedMany(
[sessionId],
async () => {
await assertSessionLoadable(cwd, sessionId);
return method === 'session/load'
? await this.bridge.loadSession({
sessionId,
workspaceCwd: cwd,
clientId: conn.clientId,
})
: await this.bridge.resumeSession({
sessionId,
workspaceCwd: cwd,
clientId: conn.clientId,
});
},
);
// Teardown raced the restore — EITHER the whole connection was
// destroyed (`conn.destroyed`) OR a `session/close` for this id
// started DURING the await (`closingSessions`); in the latter the
@ -995,6 +1078,27 @@ export class AcpDispatcher {
}
case 'session/list': {
const rawWorkspace =
typeof params['workspaceCwd'] === 'string'
? params['workspaceCwd']
: undefined;
let workspaceCwd =
rawWorkspace === undefined
? this.boundWorkspace
: parseOptionalWorkspaceCwd(
{ cwd: rawWorkspace },
this.boundWorkspace,
);
if (rawWorkspace !== undefined) {
const requestedWorkspace = canonicalizeWorkspace(workspaceCwd);
if (requestedWorkspace !== this.boundWorkspace) {
throw new WorkspaceMismatchError(
this.boundWorkspace,
requestedWorkspace,
);
}
workspaceCwd = requestedWorkspace;
}
const cursor =
typeof params['cursor'] === 'string' ? params['cursor'] : undefined;
const meta = isObject(params['_meta']) ? params['_meta'] : undefined;
@ -1002,17 +1106,41 @@ export class AcpDispatcher {
typeof meta?.['size'] === 'number'
? (meta['size'] as number)
: undefined;
const rawArchiveState =
typeof params['archiveState'] === 'string'
? params['archiveState']
: typeof meta?.['archiveState'] === 'string'
? meta['archiveState']
: undefined;
let archiveState: SessionArchiveState | undefined;
if (rawArchiveState !== undefined) {
if (
rawArchiveState !== 'active' &&
rawArchiveState !== 'archived'
) {
throw new AcpParamError(
'`archiveState` must be "active" or "archived"',
);
}
archiveState = rawArchiveState;
}
const result = await listWorkspaceSessionsForResponse(
this.bridge,
this.boundWorkspace,
{ cursor, size: metaSize },
workspaceCwd,
{ cursor, size: metaSize, archiveState },
);
this.replyConn(conn, id, {
sessions: result.sessions.map((s) => ({
sessionId: s.sessionId,
workspaceCwd: s.workspaceCwd,
cwd: s.workspaceCwd,
title: s.displayName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
displayName: s.displayName,
title: s.displayName,
clientCount: s.clientCount,
hasActivePrompt: s.hasActivePrompt,
isArchived: s.isArchived === true,
})),
...(result.nextCursor != null
? { nextCursor: result.nextCursor }
@ -1024,31 +1152,58 @@ export class AcpDispatcher {
case 'session/close': {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
// Close the ownership gate SYNCHRONOUSLY (before the await) so two
// concurrent `session/close`s don't both pass `requireOwned` —
// the second would otherwise send a misleading error and trigger a
// redundant bridge close.
// Close the ownership gate before the coordinator await so
// concurrent closes from this connection cannot both reach the bridge.
conn.ownedSessions.delete(sessionId);
// Mark closing so a concurrent session/load|resume of the SAME id
// can't grant fresh ownership + create a new binding that this
// close's `finally` teardown would then destroy (TOCTOU).
conn.closingSessions.add(sessionId);
try {
await this.bridge.closeSession(
sessionId,
this.sessionCtx(conn, sessionId, loopback),
);
} finally {
// Local teardown must run even if the bridge close throws —
// otherwise the SSE stream, abort controller, buffered frames and
// pending permissions leak until idle TTL.
let closeStarted = false;
const closeSession = async () => {
closeStarted = true;
try {
conn.closeSessionStream(sessionId);
} catch (teardownErr) {
writeStderrLine(
`qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(errMsg(teardownErr))}`,
await this.bridge.closeSession(
sessionId,
this.sessionCtx(conn, sessionId, loopback),
);
} finally {
// Local teardown must run even if the bridge close throws —
// otherwise the SSE stream, abort controller, buffered frames and
// pending permissions leak until idle TTL.
try {
conn.closeSessionStream(sessionId);
} catch (teardownErr) {
writeStderrLine(
`qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(errMsg(teardownErr))}`,
);
}
}
};
try {
try {
await this.archiveCoordinator.runExclusiveMany(
[sessionId],
closeSession,
);
} catch (err) {
const promptAbort = conn.sessions.get(sessionId)?.promptAbort;
if (
err instanceof SessionArchivingError &&
err.lockKind === 'shared' &&
promptAbort !== undefined
) {
await this.archiveCoordinator.runSharedMany(
[sessionId],
closeSession,
);
} else {
throw err;
}
}
} catch (err) {
if (!closeStarted) {
conn.ownedSessions.add(sessionId);
}
throw err;
} finally {
conn.closingSessions.delete(sessionId);
}
this.replyConn(conn, id, {});
@ -1067,61 +1222,67 @@ export class AcpDispatcher {
}
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const ctx = this.sessionCtx(conn, sessionId, loopback);
const result = await this.bridge.branchSession(
sessionId,
{
name:
typeof params['name'] === 'string' ? params['name'] : undefined,
},
ctx,
);
if (conn.destroyed) {
this.killOrphanSession(result.sessionId);
return;
}
conn.getOrCreateSession(result.sessionId).clientId = result.clientId;
conn.ownSession(result.sessionId);
const configOptions = await this.configOptionsFor(result.sessionId);
const models = this.extractModelState(configOptions);
const modes = this.extractModeState(configOptions);
this.replyConn(conn, id, {
sessionId: result.sessionId,
...(configOptions ? { configOptions } : {}),
...(models ? { models } : {}),
...(modes ? { modes } : {}),
await this.withMutableOwned(conn, sessionId, id, async () => {
const ctx = this.sessionCtx(conn, sessionId, loopback);
const result = await this.bridge.branchSession(
sessionId,
{
name:
typeof params['name'] === 'string'
? params['name']
: undefined,
},
ctx,
);
if (conn.destroyed) {
this.killOrphanSession(result.sessionId);
return;
}
conn.getOrCreateSession(result.sessionId).clientId =
result.clientId;
conn.ownSession(result.sessionId);
const configOptions = await this.configOptionsFor(result.sessionId);
const models = this.extractModelState(configOptions);
const modes = this.extractModeState(configOptions);
this.replyConn(conn, id, {
sessionId: result.sessionId,
...(configOptions ? { configOptions } : {}),
...(models ? { models } : {}),
...(modes ? { modes } : {}),
});
});
return;
}
case 'session/cancel': {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
// Abort our local in-flight prompt controller too — cancelSession
// tells the agent to wind down, but the HTTP-side `sendPrompt`
// await must also be released so the session FIFO unblocks.
conn.sessions.get(sessionId)?.promptAbort?.abort();
await this.bridge.cancelSession(
sessionId,
// Forward client-supplied cancel fields (reason/context) while
// force-stamping sessionId — mirrors the REST surface.
{ ...params, sessionId } as Parameters<
HttpAcpBridge['cancelSession']
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
// `session/cancel` is normally a notification (no id), but answer
// the request-form so a client that sent an id isn't left hanging.
if (id !== undefined) this.replySession(conn, sessionId, id, {});
await this.withMutableOwned(conn, sessionId, id, async () => {
// Abort our local in-flight prompt controller too — cancelSession
// tells the agent to wind down, but the HTTP-side `sendPrompt`
// await must also be released so the session FIFO unblocks.
conn.sessions.get(sessionId)?.promptAbort?.abort();
await this.bridge.cancelSession(
sessionId,
// Forward client-supplied cancel fields (reason/context) while
// force-stamping sessionId — mirrors the REST surface.
{ ...params, sessionId } as Parameters<
HttpAcpBridge['cancelSession']
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
// `session/cancel` is normally a notification (no id), but answer
// the request-form so a client that sent an id isn't left hanging.
if (id !== undefined) this.replySession(conn, sessionId, id, {});
});
return;
}
case 'session/prompt': {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
validatePrompt(params);
await this.handlePrompt(conn, sessionId, id, params, loopback);
await this.withMutableOwned(conn, sessionId, id, async () => {
validatePrompt(params);
await this.handlePrompt(conn, sessionId, id, params, loopback);
});
return;
}
@ -1421,38 +1582,13 @@ export class AcpDispatcher {
// setters. Replaces the old vendor `_qwen/session/set_model`.
case 'session/set_config_option': {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const configId = String(params['configId'] ?? '');
const rawValue = params['value'];
const ctx = this.sessionCtx(conn, sessionId, loopback);
// Validate value at the boundary like REST (empty/null is rejected
// rather than forwarded as "" to the bridge).
if (typeof rawValue !== 'string' || rawValue.length === 0) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(
id,
RPC.INVALID_PARAMS,
'`value` must be a non-empty string',
),
);
}
return;
}
if (configId === 'model') {
await this.bridge.setSessionModel(
sessionId,
{ modelId: rawValue } as unknown as Parameters<
HttpAcpBridge['setSessionModel']
>[1],
ctx,
);
} else if (configId === 'mode') {
if (!APPROVAL_MODES.includes(rawValue as ApprovalMode)) {
await this.withMutableOwned(conn, sessionId, id, async () => {
const configId = String(params['configId'] ?? '');
const rawValue = params['value'];
const ctx = this.sessionCtx(conn, sessionId, loopback);
// Validate value at the boundary like REST (empty/null is rejected
// rather than forwarded as "" to the bridge).
if (typeof rawValue !== 'string' || rawValue.length === 0) {
if (id !== undefined) {
this.replySession(
conn,
@ -1462,33 +1598,63 @@ export class AcpDispatcher {
error(
id,
RPC.INVALID_PARAMS,
`invalid mode "${rawValue}" (expected one of: ${APPROVAL_MODES.join(', ')})`,
'`value` must be a non-empty string',
),
);
}
return;
}
await this.bridge.setSessionApprovalMode(
sessionId,
rawValue as ApprovalMode,
{ persist: params['persist'] === true },
ctx,
);
} else {
if (id !== undefined) {
this.replySession(
conn,
if (configId === 'model') {
await this.bridge.setSessionModel(
sessionId,
id,
undefined,
error(id, RPC.INVALID_PARAMS, `Unknown configId: ${configId}`),
{ modelId: rawValue } as unknown as Parameters<
HttpAcpBridge['setSessionModel']
>[1],
ctx,
);
} else if (configId === 'mode') {
if (!APPROVAL_MODES.includes(rawValue as ApprovalMode)) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(
id,
RPC.INVALID_PARAMS,
`invalid mode "${rawValue}" (expected one of: ${APPROVAL_MODES.join(', ')})`,
),
);
}
return;
}
await this.bridge.setSessionApprovalMode(
sessionId,
rawValue as ApprovalMode,
{ persist: params['persist'] === true },
ctx,
);
} else {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(
id,
RPC.INVALID_PARAMS,
`Unknown configId: ${configId}`,
),
);
}
return;
}
return;
}
// Response returns the updated config option set (per ACP).
const configOptions = await this.configOptionsFor(sessionId);
this.replySession(conn, sessionId, id, { configOptions });
// Response returns the updated config option set (per ACP).
const configOptions = await this.configOptionsFor(sessionId);
this.replySession(conn, sessionId, id, { configOptions });
});
return;
}
@ -1503,32 +1669,33 @@ export class AcpDispatcher {
);
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const modeId = String(params['modeId'] ?? '');
if (!modeId || !APPROVAL_MODES.includes(modeId as ApprovalMode)) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(
await this.withMutableOwned(conn, sessionId, id, async () => {
const modeId = String(params['modeId'] ?? '');
if (!modeId || !APPROVAL_MODES.includes(modeId as ApprovalMode)) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
RPC.INVALID_PARAMS,
`invalid modeId "${modeId}" (expected one of: ${APPROVAL_MODES.join(', ')})`,
),
);
undefined,
error(
id,
RPC.INVALID_PARAMS,
`invalid modeId "${modeId}" (expected one of: ${APPROVAL_MODES.join(', ')})`,
),
);
}
return;
}
return;
}
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionApprovalMode(
sessionId,
modeId as ApprovalMode,
{ persist: false },
ctx,
);
this.replySession(conn, sessionId, id, {});
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionApprovalMode(
sessionId,
modeId as ApprovalMode,
{ persist: false },
ctx,
);
this.replySession(conn, sessionId, id, {});
});
return;
}
@ -1544,27 +1711,28 @@ export class AcpDispatcher {
);
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const modelId = String(params['modelId'] ?? '');
if (!modelId) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(id, RPC.INVALID_PARAMS, '`modelId` is required'),
);
await this.withMutableOwned(conn, sessionId, id, async () => {
const modelId = String(params['modelId'] ?? '');
if (!modelId) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(id, RPC.INVALID_PARAMS, '`modelId` is required'),
);
}
return;
}
return;
}
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionModel(
sessionId,
{ modelId, sessionId },
ctx,
);
this.replySession(conn, sessionId, id, {});
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionModel(
sessionId,
{ modelId, sessionId },
ctx,
);
this.replySession(conn, sessionId, id, {});
});
return;
}
@ -1603,18 +1771,19 @@ export class AcpDispatcher {
case `${QWEN_METHOD_NS}session/update_metadata`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const metadata = isObject(params['metadata'])
? (params['metadata'] as Record<string, unknown>)
: {};
const result = this.bridge.updateSessionMetadata(
sessionId,
metadata as unknown as Parameters<
HttpAcpBridge['updateSessionMetadata']
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
await this.withMutableOwned(conn, sessionId, id, async () => {
const metadata = isObject(params['metadata'])
? (params['metadata'] as Record<string, unknown>)
: {};
const result = this.bridge.updateSessionMetadata(
sessionId,
metadata as unknown as Parameters<
HttpAcpBridge['updateSessionMetadata']
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
@ -1993,41 +2162,43 @@ export class AcpDispatcher {
case `${QWEN_METHOD_NS}session/recap`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const result = await this.bridge.generateSessionRecap(
sessionId,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
await this.withMutableOwned(conn, sessionId, id, async () => {
const result = await this.bridge.generateSessionRecap(
sessionId,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}session/btw`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const rawQ = params['question'];
if (
typeof rawQ !== 'string' ||
rawQ.trim().length === 0 ||
rawQ.length > BTW_MAX_INPUT_LENGTH
) {
if (id !== undefined)
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
`\`question\` required, non-empty, max ${BTW_MAX_INPUT_LENGTH} chars`,
),
);
return;
}
const result = await this.bridge.generateSessionBtw(
sessionId,
rawQ.trim(),
undefined,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
await this.withMutableOwned(conn, sessionId, id, async () => {
const rawQ = params['question'];
if (
typeof rawQ !== 'string' ||
rawQ.trim().length === 0 ||
rawQ.length > BTW_MAX_INPUT_LENGTH
) {
if (id !== undefined)
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
`\`question\` required, non-empty, max ${BTW_MAX_INPUT_LENGTH} chars`,
),
);
return;
}
const result = await this.bridge.generateSessionBtw(
sessionId,
rawQ.trim(),
undefined,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
@ -2039,52 +2210,54 @@ export class AcpDispatcher {
}
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const binding = conn.sessions.get(sessionId);
const clientId = binding?.clientId;
if (!clientId) {
if (id !== undefined) {
conn.sendConn(
rpcErrorFrame(id, new SessionShellClientRequiredError()),
);
await this.withMutableOwned(conn, sessionId, id, async () => {
const binding = conn.sessions.get(sessionId);
const clientId = binding?.clientId;
if (!clientId) {
if (id !== undefined) {
conn.sendConn(
rpcErrorFrame(id, new SessionShellClientRequiredError()),
);
}
return;
}
const rawCmd = params['command'];
if (typeof rawCmd !== 'string' || rawCmd.trim().length === 0) {
if (id !== undefined)
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`command` required and must be non-empty',
),
);
return;
}
return;
}
const rawCmd = params['command'];
if (typeof rawCmd !== 'string' || rawCmd.trim().length === 0) {
if (id !== undefined)
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`command` required and must be non-empty',
),
);
return;
}
const logSessionId = logSafe(sessionId.slice(0, 8));
const logClientId = logSafe(String(conn.clientId?.slice(0, 8)));
const logCommand = logSafe(rawCmd.slice(0, 120));
writeStderrLine(
`qwen serve: /acp session/shell session=${logSessionId} client=${logClientId} cmd=${logCommand}`,
);
const result = await this.bridge.executeShellCommand(
sessionId,
rawCmd,
binding.abort.signal,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
const logSessionId = logSafe(sessionId.slice(0, 8));
const logClientId = logSafe(String(conn.clientId?.slice(0, 8)));
const logCommand = logSafe(rawCmd.slice(0, 120));
writeStderrLine(
`qwen serve: /acp session/shell session=${logSessionId} client=${logClientId} cmd=${logCommand}`,
);
const result = await this.bridge.executeShellCommand(
sessionId,
rawCmd,
binding.abort.signal,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
});
return;
}
case `${QWEN_METHOD_NS}session/detach`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.detachClient(sessionId, ctx.clientId);
this.replyConn(conn, id, { ok: true });
await this.withMutableOwned(conn, sessionId, id, async () => {
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.detachClient(sessionId, ctx.clientId);
this.replyConn(conn, id, { ok: true });
});
return;
}
@ -2877,68 +3050,60 @@ export class AcpDispatcher {
}
case `${QWEN_METHOD_NS}sessions/delete`: {
const sessionIds = params['sessionIds'];
if (
!Array.isArray(sessionIds) ||
sessionIds.length === 0 ||
sessionIds.length > 100 ||
!sessionIds.every((s) => typeof s === 'string')
) {
if (id !== undefined)
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'`sessionIds` must be non-empty string array (max 100)',
),
);
return;
}
const ids = [...new Set(sessionIds as string[])];
const closeErrors: Array<{ sessionId: string; error: string }> = [];
const closedIds: string[] = [];
await Promise.allSettled(
ids.map(async (sid) => {
try {
await this.bridge.closeSession(sid);
closedIds.push(sid);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (
err instanceof Error &&
err.name === 'SessionNotFoundError'
) {
closedIds.push(sid);
} else {
const safeSessionId = logSafe(sid.slice(0, 8));
const safeMessage = logSafe(msg);
writeStderrLine(
`qwen serve: /acp sessions/delete closeSession(${safeSessionId}) failed: ${safeMessage}`,
);
closeErrors.push({ sessionId: sid, error: msg });
}
}
}),
);
const ids = this.parseSessionIds(params);
const svc = new SessionService(this.boundWorkspace);
const removeResult = await svc.removeSessions(closedIds);
for (const e of removeResult.errors) {
const safeSessionId = logSafe(e.sessionId.slice(0, 8));
const safeMessage = logSafe(errMsg(e.error));
writeStderrLine(
`qwen serve: /acp sessions/delete removeSessions(${safeSessionId}) failed: ${safeMessage}`,
);
}
const result = await deleteDaemonSessions({
sessionIds: ids,
service: svc,
bridge: this.bridge,
coordinator: this.archiveCoordinator,
onError: ({ phase, sessionId, error }) => {
const safeSessionId = logSafe(sessionId.slice(0, 8));
const safeMessage = logSafe(error);
writeStderrLine(
`qwen serve: /acp sessions/delete ${phase}Session(${safeSessionId}) failed: ${safeMessage}`,
);
},
});
this.replyConn(conn, id, result as unknown);
return;
}
case `${QWEN_METHOD_NS}sessions/archive`: {
const ids = this.parseSessionIds(params);
const svc = new SessionService(this.boundWorkspace, {
onWarning: logSessionArchiveWarning,
});
const result = await archiveDaemonSessions({
sessionIds: ids,
service: svc,
bridge: this.bridge,
coordinator: this.archiveCoordinator,
});
this.replyConn(conn, id, {
removed: removeResult.removed,
notFound: removeResult.notFound,
errors: [
...closeErrors,
...removeResult.errors.map((e) => ({
sessionId: e.sessionId,
error: errMsg(e.error),
})),
],
archived: result.archived,
alreadyArchived: result.alreadyArchived,
notFound: result.notFound,
errors: this.serializeSessionErrors(result.errors),
} as unknown);
return;
}
case `${QWEN_METHOD_NS}sessions/unarchive`: {
const ids = this.parseSessionIds(params);
const svc = new SessionService(this.boundWorkspace, {
onWarning: logSessionArchiveWarning,
});
const result = await unarchiveDaemonSessions({
sessionIds: ids,
service: svc,
coordinator: this.archiveCoordinator,
});
this.replyConn(conn, id, {
unarchived: result.unarchived,
alreadyActive: result.alreadyActive,
notFound: result.notFound,
errors: this.serializeSessionErrors(result.errors),
} as unknown);
return;
}

View file

@ -24,6 +24,7 @@ import {
import { SseStream } from './sse-stream.js';
import { WsStream } from './ws-stream.js';
import type { RateLimitTier } from '../rate-limit.js';
import { SessionArchiveCoordinator } from '../server/session-archive.js';
import {
RPC,
error as rpcError,
@ -193,6 +194,7 @@ export interface MountAcpHttpOptions {
allowedOrigins?: ParsedAllowOriginPatterns;
/** Effective direct session shell policy for ACP initialize/dispatch. */
sessionShellCommandEnabled?: boolean;
archiveCoordinator?: SessionArchiveCoordinator;
/** Shared lane for sessionless workspace remember tasks. */
workspaceRememberLane: WorkspaceRememberTaskLane;
/** Rate limit checker for WS messages (WS bypasses Express middleware). */
@ -324,6 +326,7 @@ export function mountAcpHttp(
opts.deviceFlowRegistry,
opts.sessionShellCommandEnabled === true,
registry,
opts.archiveCoordinator ?? new SessionArchiveCoordinator(),
);
dispatcherRef.current = dispatcher;

View file

@ -12,7 +12,10 @@ import type { AddressInfo } from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import WebSocket from 'ws';
import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import type {
BridgeSessionSummary,
HttpAcpBridge,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
CancelSentinelCollisionError,
@ -24,7 +27,7 @@ import {
SessionShellClientRequiredError,
SessionShellDisabledError,
} from '@qwen-code/acp-bridge/bridgeErrors';
import { SessionService } from '@qwen-code/qwen-code-core';
import { SessionService, Storage } from '@qwen-code/qwen-code-core';
import {
resetHomeEnvBootstrapForTesting,
SettingScope,
@ -277,8 +280,10 @@ class FakeBridge {
return { sessionId: 'sess-1', lastSeenAt: Date.now() };
}
workspaceSessions: BridgeSessionSummary[] = [];
listWorkspaceSessions() {
return [];
return this.workspaceSessions;
}
detached: Array<{ sessionId: string; clientId?: string }> = [];
@ -832,6 +837,51 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
await new Promise((r) => setTimeout(r, 30)); // let handle() register ownership
}
async function withRuntimeDir<T>(
fn: (runtimeDir: string) => Promise<T>,
): Promise<T> {
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
const runtimeDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-archive-'),
);
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
try {
return await fn(runtimeDir);
} finally {
if (previousRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
}
await fs.rm(runtimeDir, { recursive: true, force: true });
}
}
async function writeStoredSession(
sessionId: string,
state: 'active' | 'archived' = 'active',
): Promise<void> {
const chatsDir = path.join(
new Storage('/ws').getProjectDir(),
'chats',
...(state === 'archived' ? ['archive'] : []),
);
await fs.mkdir(chatsDir, { recursive: true });
await fs.writeFile(
path.join(chatsDir, `${sessionId}.jsonl`),
`${JSON.stringify({
uuid: `${sessionId}-user-1`,
parentUuid: null,
sessionId,
timestamp: '2026-06-30T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello' }] },
cwd: '/ws',
})}\n`,
'utf8',
);
}
it('initialize → 200 + Acp-Connection-Id; unknown conn → 404', async () => {
await initialize();
const bad = await post('nope', {
@ -2676,6 +2726,102 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(frame.result.ok).toBe(true);
});
it('session/list returns REST-compatible session summary fields', async () => {
bridge.workspaceSessions = [
{
sessionId: '11111111-bbbb-cccc-dddd-eeeeeeeeeeee',
workspaceCwd: '/ws',
createdAt: '2026-06-30T00:00:00.000Z',
updatedAt: '2026-06-30T00:01:00.000Z',
displayName: 'Listed Session',
clientCount: 1,
hasActivePrompt: false,
isArchived: false,
},
];
const connId = await initialize();
const connStream = await openStream(connId);
const got = takeFrames(connStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 13,
method: 'session/list',
params: { workspaceCwd: '/ws' },
});
const [frame] = (await got) as Array<{
id: number;
result: { sessions: Array<Record<string, unknown>> };
}>;
expect(frame.id).toBe(13);
expect(frame.result.sessions[0]).toMatchObject({
sessionId: '11111111-bbbb-cccc-dddd-eeeeeeeeeeee',
workspaceCwd: '/ws',
cwd: '/ws',
createdAt: '2026-06-30T00:00:00.000Z',
updatedAt: '2026-06-30T00:01:00.000Z',
displayName: 'Listed Session',
title: 'Listed Session',
clientCount: 1,
hasActivePrompt: false,
isArchived: false,
});
});
it('_qwen/sessions/archive rejects invalid batch params', async () => {
const connId = await initialize();
const connStream = await openStream(connId);
const got = takeFrames(connStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 14,
method: '_qwen/sessions/archive',
params: { sessionIds: 'not-an-array' },
});
const [frame] = (await got) as Array<{
id: number;
error: { code: number; message: string };
}>;
expect(frame.id).toBe(14);
expect(frame.error.code).toBe(-32602);
expect(frame.error.message).toContain('sessionIds');
});
it('_qwen/sessions/unarchive returns per-id result buckets', async () => {
const sessionId = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee';
const connId = await initialize();
const connStream = await openStream(connId);
const got = takeFrames(connStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 15,
method: '_qwen/sessions/unarchive',
params: { sessionIds: [sessionId] },
});
const [frame] = (await got) as Array<{
id: number;
result: {
unarchived: string[];
alreadyActive: string[];
notFound: string[];
errors: unknown[];
};
}>;
expect(frame.id).toBe(15);
expect(frame.result).toEqual({
unarchived: [],
alreadyActive: [],
notFound: [sessionId],
errors: [],
});
});
it('unknown method → JSON-RPC method-not-found on conn stream', async () => {
const connId = await initialize();
const connStream = await openStream(connId);
@ -2784,6 +2930,466 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(frame.result.resumed).toBe(true);
});
it.each(['session/load', 'session/resume'])(
'%s rejects archived sessions',
async (method) => {
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
const runtimeDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-archive-'),
);
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
try {
const chatsDir = path.join(
new Storage('/ws').getProjectDir(),
'chats',
'archive',
);
await fs.mkdir(chatsDir, { recursive: true });
await fs.writeFile(
path.join(chatsDir, `${sessionId}.jsonl`),
`${JSON.stringify({
uuid: `${sessionId}-user-1`,
parentUuid: null,
sessionId,
timestamp: '2026-06-30T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'archived' }] },
cwd: '/ws',
})}\n`,
'utf8',
);
const connId = await initialize();
const connStream = await openStream(connId);
const got = takeFrames(connStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 211,
method,
params: { sessionId },
});
const [frame] = (await got) as Array<{
id: number;
error: { code: number; data?: { errorKind?: string } };
}>;
expect(frame.id).toBe(211);
expect(frame.error.code).toBe(-32603);
expect(frame.error.data?.errorKind).toBe('session_archived');
} finally {
if (previousRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
}
await fs.rm(runtimeDir, { recursive: true, force: true });
}
},
);
it('session/load rejects active/archive conflicts', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440321';
await writeStoredSession(sessionId);
await writeStoredSession(sessionId, 'archived');
const connId = await initialize();
const connStream = await openStream(connId);
const got = takeFrames(connStream, 1);
await new Promise((r) => setTimeout(r, 50));
await post(connId, {
jsonrpc: '2.0',
id: 212,
method: 'session/load',
params: { sessionId },
});
const [frame] = (await got) as Array<{
id: number;
error: { code: number; data?: { errorKind?: string } };
}>;
expect(frame.id).toBe(212);
expect(frame.error.code).toBe(-32603);
expect(frame.error.data?.errorKind).toBe('session_conflict');
});
});
it('session/load holds archive gate while restore is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440124';
await writeStoredSession(sessionId);
let loadStarted!: () => void;
let releaseLoad!: () => void;
const loadStartedPromise = new Promise<void>((resolve) => {
loadStarted = resolve;
});
const loadReleasedPromise = new Promise<void>((resolve) => {
releaseLoad = resolve;
});
bridge.loadSession = async (req) => {
loadStarted();
await loadReleasedPromise;
return {
sessionId: req.sessionId,
workspaceCwd: '/ws',
attached: true,
clientId: 'client-load',
state: { replayed: true },
};
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 212,
method: 'session/load',
params: { sessionId },
});
await loadStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 213,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
expect(await reader.next()).toMatchObject({
id: 213,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
},
});
releaseLoad();
expect(await reader.next()).toMatchObject({
id: 212,
result: { replayed: true },
});
reader.close();
});
});
it('session/prompt holds archive gate while prompt is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
await writeStoredSession(sessionId);
let promptStarted!: () => void;
let releasePrompt!: () => void;
const promptStartedPromise = new Promise<void>((resolve) => {
promptStarted = resolve;
});
const promptReleasedPromise = new Promise<void>((resolve) => {
releasePrompt = resolve;
});
bridge.promptBehavior = async () => {
promptStarted();
await promptReleasedPromise;
return { stopReason: 'end_turn' };
};
const connId = await initialize();
const connStream = await openStream(connId);
const connReader = frameReader(connStream);
await post(connId, {
jsonrpc: '2.0',
id: 217,
method: 'session/load',
params: { sessionId },
});
expect(await connReader.next()).toMatchObject({ id: 217 });
const sessionStream = await openStream(connId, sessionId);
const sessionReader = frameReader(sessionStream);
await post(connId, {
jsonrpc: '2.0',
id: 218,
method: 'session/prompt',
params: {
sessionId,
prompt: [{ type: 'text', text: 'hold archive gate' }],
},
});
await promptStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 219,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
expect(await connReader.next()).toMatchObject({
id: 219,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
},
});
expect(bridge.closedSessions).toEqual([]);
releasePrompt();
expect(await sessionReader.next()).toMatchObject({
id: 218,
result: { stopReason: 'end_turn' },
});
connReader.close();
sessionReader.close();
});
});
it('_qwen/session/heartbeat does not wait for archive gate', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440130';
await writeStoredSession(sessionId);
let closeStarted!: () => void;
let releaseClose!: () => void;
const closeStartedPromise = new Promise<void>((resolve) => {
closeStarted = resolve;
});
const closeReleasedPromise = new Promise<void>((resolve) => {
releaseClose = resolve;
});
bridge.closeSession = async (sid: string) => {
bridge.closedSessions.push(sid);
closeStarted();
await closeReleasedPromise;
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 220,
method: 'session/load',
params: { sessionId },
});
expect(await reader.next()).toMatchObject({ id: 220 });
await post(connId, {
jsonrpc: '2.0',
id: 221,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
await closeStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 222,
method: '_qwen/session/heartbeat',
params: { sessionId },
});
expect(await reader.next()).toMatchObject({
id: 222,
result: { sessionId: 'sess-1' },
});
releaseClose();
expect(await reader.next()).toMatchObject({
id: 221,
result: { archived: [sessionId], errors: [] },
});
reader.close();
});
});
it('session/load allows concurrent restores for the same session', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440126';
await writeStoredSession(sessionId);
let releaseLoad!: () => void;
bridge.gate = new Promise<void>((resolve) => {
releaseLoad = resolve;
});
const connId = await initialize();
const stream = await openStream(connId);
const got = takeFrames(stream, 2);
await post(connId, {
jsonrpc: '2.0',
id: 214,
method: 'session/load',
params: { sessionId },
});
await post(connId, {
jsonrpc: '2.0',
id: 215,
method: 'session/load',
params: { sessionId },
});
releaseLoad();
const frames = (await got) as Array<{
id: number;
result?: { replayed?: boolean };
error?: { data?: { errorKind?: string } };
}>;
expect(frames.map((frame) => frame.id).sort()).toEqual([214, 215]);
expect(frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 214,
result: expect.objectContaining({ replayed: true }),
}),
expect.objectContaining({
id: 215,
result: expect.objectContaining({ replayed: true }),
}),
]),
);
expect(frames.some((frame) => frame.error)).toBe(false);
});
});
it('session/close holds archive gate while close is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440125';
await writeStoredSession(sessionId);
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 214,
method: 'session/load',
params: { sessionId },
});
expect(await reader.next()).toMatchObject({ id: 214 });
let closeStarted!: () => void;
let releaseClose!: () => void;
let secondCloseStarted!: () => void;
const closeStartedPromise = new Promise<void>((resolve) => {
closeStarted = resolve;
});
const closeReleasedPromise = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const secondCloseStartedPromise = new Promise<void>((resolve) => {
secondCloseStarted = resolve;
});
let closeCount = 0;
bridge.closeSession = async (sid: string) => {
bridge.closedSessions.push(sid);
closeCount++;
if (closeCount === 1) {
closeStarted();
await closeReleasedPromise;
} else {
secondCloseStarted();
}
};
await post(connId, {
jsonrpc: '2.0',
id: 215,
method: 'session/close',
params: { sessionId },
});
await closeStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 216,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
const archiveFrame = await reader.next();
const raceResult = await Promise.race([
secondCloseStartedPromise.then(() => 'second-close-started'),
new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)),
]);
expect(archiveFrame).toMatchObject({
id: 216,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
},
});
expect(raceResult).toBe('blocked');
releaseClose();
expect(await reader.next()).toMatchObject({
id: 215,
result: {},
});
reader.close();
});
});
it('session/close falls back to the shared gate for its own in-flight prompt', async () => {
let promptStarted!: () => void;
let promptAborted!: () => void;
const promptStartedPromise = new Promise<void>((resolve) => {
promptStarted = resolve;
});
const promptAbortedPromise = new Promise<void>((resolve) => {
promptAborted = resolve;
});
bridge.promptBehavior = async (_s, _q, signal) => {
promptStarted();
if (signal === undefined) throw new Error('missing prompt signal');
await new Promise<void>((resolve) => {
signal.addEventListener(
'abort',
() => {
promptAborted();
resolve();
},
{ once: true },
);
});
return { stopReason: 'cancelled' };
};
const connId = await initialize();
const connStream = await openStream(connId);
const connReader = frameReader(connStream);
await post(connId, {
jsonrpc: '2.0',
id: 230,
method: 'session/new',
params: {},
});
expect(await connReader.next()).toMatchObject({ id: 230 });
const sessionStream = await openStream(connId, 'sess-1');
await post(connId, {
jsonrpc: '2.0',
id: 231,
method: 'session/prompt',
params: {
sessionId: 'sess-1',
prompt: [{ type: 'text', text: 'hold shared gate' }],
},
});
await promptStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 232,
method: 'session/close',
params: { sessionId: 'sess-1' },
});
await promptAbortedPromise;
const frames = [await connReader.next(), await connReader.next()];
expect(frames).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 231 }),
expect.objectContaining({ id: 232, result: {} }),
]),
);
expect(bridge.closedSessions).toEqual(['sess-1']);
connReader.close();
await sessionStream.body?.cancel().catch(() => {});
});
it('session/close reaches the bridge + replies on the conn stream', async () => {
const connId = await initialize();
const connStream = await openStream(connId);
@ -4153,22 +4759,18 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
releaseClose();
});
it('session/load while close races DURING loadSession → post-await reject + rollback', async () => {
// Distinct from the pre-await guard above: here the pre-await
// `closingSessions` check passes, then a `session/close` for the same id
// starts WHILE `loadSession` is awaiting. The post-await re-check
// (dispatch.ts) must detect `closeRaced`, roll back the just-restored
// attach (detachClient, since loadAttached=true), and reply INTERNAL_ERROR.
it('session/close while load is in-flight → close rejected by archive gate', async () => {
// The archive coordinator now covers the whole load/restore await, so a
// same-id close that starts during load is rejected before it can mark the
// session closing or tear down the just-restored binding.
let releaseLoad: () => void = () => {};
let releaseClose: () => void = () => {};
const connId = await initialize();
await newSession(connId); // own sess-1 so session/close passes requireOwned
// Arm the gates only AFTER ownership is established — otherwise newSession's
// own spawnOrAttach would block on bridge.gate and never grant ownership.
bridge.gate = new Promise<void>((r) => (releaseLoad = r));
bridge.closeGate = new Promise<void>((r) => (releaseClose = r));
const connStream = await openStream(connId);
const got = takeFrames(connStream, 2); // buffered session/new reply + load reject
const got = takeFrames(connStream, 3); // session/new + close reject + load success
await new Promise((r) => setTimeout(r, 50));
// Load goes in-flight (awaits bridge.gate); pre-await closingSessions empty.
void post(connId, {
@ -4186,18 +4788,34 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
params: { sessionId: 'sess-1' },
});
await new Promise((r) => setTimeout(r, 20));
releaseLoad(); // loadSession resolves → post-await sees closeRaced
releaseLoad(); // loadSession resolves after close has been rejected.
const frames = (await got) as Array<{
id: number;
error?: { code: number; message: string };
result?: { replayed?: boolean };
error?: { code: number; message: string; data?: { errorKind?: string } };
}>;
const closeReply = frames.find((f) => f.id === 341);
expect(closeReply?.error?.code).toBe(-32603);
expect(closeReply?.error?.data?.errorKind).toBe('session_archiving');
const loadReply = frames.find((f) => f.id === 340);
expect(loadReply?.error?.code).toBe(-32603);
expect(loadReply?.error?.message).toContain('closed during load');
// attached:true → rollback is a detach, NOT a kill.
expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true);
expect(loadReply?.result?.replayed).toBe(true);
expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(false);
expect(bridge.killed).not.toContain('sess-1');
releaseClose();
const retryCloseStream = await openStream(connId);
const retryCloseReader = frameReader(retryCloseStream);
await post(connId, {
jsonrpc: '2.0',
id: 342,
method: 'session/close',
params: { sessionId: 'sess-1' },
});
expect(await retryCloseReader.next()).toMatchObject({
id: 342,
result: {},
});
retryCloseReader.close();
expect(bridge.closedSessions).toEqual(['sess-1']);
});
it('double-failure permission vote → pending retained + retried on teardown', async () => {
@ -4992,18 +5610,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
const bidiOverride = '\u202e';
const sessionId = `sess${lineSep}FAKE\r\x1b[31m`;
const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`;
const removeSessionsSpy = vi
.spyOn(SessionService.prototype, 'removeSessions')
.mockResolvedValueOnce({
removed: [],
notFound: [],
errors: [
{
sessionId,
error: removeError as unknown as Error,
},
],
});
const removeSessionSpy = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockRejectedValueOnce(new Error(removeError));
try {
const connId = await initialize();
@ -5023,13 +5632,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
errors: [{ sessionId, error: removeError }],
},
});
expect(removeSessionsSpy).toHaveBeenCalledWith([sessionId]);
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
const deleteLog = stdioMocks.writeStderrLine.mock.calls
.map(([line]) => line)
.find((line) => line.includes('sessions/delete'));
expect(deleteLog).toContain(
'removeSessions(sess FAK) failed: remove FAILED [31m',
'removeSession(sess FAK) failed: remove FAILED [31m',
);
expect(deleteLog).not.toContain('\n');
expect(deleteLog).not.toContain('\r');
@ -5037,9 +5646,196 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(deleteLog).not.toContain(lineSep);
expect(deleteLog).not.toContain(bidiOverride);
} finally {
removeSessionsSpy.mockRestore();
removeSessionSpy.mockRestore();
}
});
it('_qwen/sessions/delete deletes available ids when another id is loading', async () => {
await withRuntimeDir(async () => {
const sidOk = '550e8400-e29b-41d4-a716-446655440128';
const sidBusy = '550e8400-e29b-41d4-a716-446655440129';
await writeStoredSession(sidOk);
await writeStoredSession(sidBusy);
let loadStarted!: () => void;
let releaseLoad!: () => void;
const loadStartedPromise = new Promise<void>((resolve) => {
loadStarted = resolve;
});
const loadReleasedPromise = new Promise<void>((resolve) => {
releaseLoad = resolve;
});
bridge.loadSession = async (req) => {
if (req.sessionId === sidBusy) {
loadStarted();
await loadReleasedPromise;
}
return {
sessionId: req.sessionId,
workspaceCwd: '/ws',
attached: true,
clientId: 'client-load',
state: { replayed: true },
};
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 70,
method: 'session/load',
params: { sessionId: sidBusy },
});
await loadStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 71,
method: '_qwen/sessions/delete',
params: { sessionIds: [sidOk, sidBusy] },
});
expect(await reader.next()).toMatchObject({
id: 71,
result: {
removed: [sidOk],
notFound: [],
errors: [expect.objectContaining({ sessionId: sidBusy })],
},
});
expect(bridge.closedSessions).toEqual([sidOk]);
releaseLoad();
expect(await reader.next()).toMatchObject({
id: 70,
result: { replayed: true },
});
reader.close();
});
});
it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => {
const sessionId = 'delete-archive-race';
let firstCloseStarted!: () => void;
let releaseFirstClose!: () => void;
let secondCloseStarted!: () => void;
const firstCloseStartedPromise = new Promise<void>((resolve) => {
firstCloseStarted = resolve;
});
const firstCloseReleasedPromise = new Promise<void>((resolve) => {
releaseFirstClose = resolve;
});
const secondCloseStartedPromise = new Promise<void>((resolve) => {
secondCloseStarted = resolve;
});
let closeCount = 0;
bridge.closeSession = async (sid: string) => {
bridge.closedSessions.push(sid);
closeCount++;
if (closeCount === 1) {
firstCloseStarted();
await firstCloseReleasedPromise;
} else {
secondCloseStarted();
}
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
const deletePost = post(connId, {
jsonrpc: '2.0',
id: 69,
method: '_qwen/sessions/delete',
params: { sessionIds: [sessionId] },
});
await deletePost;
await firstCloseStartedPromise;
const archivePost = post(connId, {
jsonrpc: '2.0',
id: 70,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
await archivePost;
const raceResult = await Promise.race([
secondCloseStartedPromise.then(() => 'archive-started'),
new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)),
]);
releaseFirstClose();
try {
expect(raceResult).toBe('blocked');
const frames = [await reader.next(), await reader.next()];
expect(frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 69,
result: expect.objectContaining({ notFound: [sessionId] }),
}),
expect.objectContaining({
id: 70,
result: expect.objectContaining({ notFound: [sessionId] }),
}),
]),
);
} finally {
reader.close();
}
});
it('_qwen/sessions/delete returns session_archiving during archive gate', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440132';
await writeStoredSession(sessionId);
let closeStarted!: () => void;
let releaseClose!: () => void;
const closeStartedPromise = new Promise<void>((resolve) => {
closeStarted = resolve;
});
const closeReleasedPromise = new Promise<void>((resolve) => {
releaseClose = resolve;
});
bridge.closeSession = async (sid: string) => {
bridge.closedSessions.push(sid);
closeStarted();
await closeReleasedPromise;
};
const connId = await initialize();
const stream = await openStream(connId);
const reader = frameReader(stream);
await post(connId, {
jsonrpc: '2.0',
id: 72,
method: '_qwen/sessions/archive',
params: { sessionIds: [sessionId] },
});
await closeStartedPromise;
await post(connId, {
jsonrpc: '2.0',
id: 73,
method: '_qwen/sessions/delete',
params: { sessionIds: [sessionId] },
});
expect(await reader.next()).toMatchObject({
id: 73,
error: {
data: { errorKind: 'session_archiving', sessionId },
},
});
releaseClose();
try {
expect(await reader.next()).toMatchObject({
id: 72,
result: expect.objectContaining({ archived: [sessionId] }),
});
} finally {
reader.close();
}
});
});
});
describe('auth methods', () => {

View file

@ -87,6 +87,9 @@ export {
CdWhilePromptActiveError,
SessionNotFoundError,
RestoreInProgressError,
SessionArchivedError,
SessionConflictError,
SessionArchivingError,
InvalidSessionScopeError,
SessionLimitExceededError,
PromptQueueFullError,

View file

@ -88,6 +88,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_lsp: { since: 'v1' },
session_status: { since: 'v1' },
session_close: { since: 'v1' },
session_archive: { since: 'v1' },
session_metadata: { since: 'v1' },
// Daemon supports the MCP client guardrail surface: an in-process
// counter exposed on `GET /workspace/mcp`, a `--mcp-client-budget=N`

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -105,6 +105,7 @@ import {
} from './server/error-handlers.js';
import { installRateLimiter } from './server/rate-limiter-setup.js';
import { createServeFeatures } from './server/serve-features.js';
import { SessionArchiveCoordinator } from './server/session-archive.js';
import { installSelfOriginStripMiddleware } from './server/self-origin.js';
import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js';
import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js';
@ -371,6 +372,7 @@ export function createServeApp(
// ext-method by reaching the WS connection that hosts the named server.
clientMcpSender: clientMcpSenderRegistry.lookup,
});
const archiveCoordinator = new SessionArchiveCoordinator();
installSelfOriginStripMiddleware(app, getPort);
@ -727,6 +729,7 @@ export function createServeApp(
registerSessionRoutes(app, {
boundWorkspace,
bridge,
archiveCoordinator,
mutate,
sendBridgeError,
daemonLog,
@ -787,6 +790,7 @@ export function createServeApp(
// route through the JSON error contract below.
acpHandleRef.current = mountAcpHttp(app, bridge, {
boundWorkspace,
archiveCoordinator,
workspace,
fsFactory,
deviceFlowRegistry,

View file

@ -27,7 +27,10 @@ import {
PermissionPolicyNotImplementedError,
PromptQueueFullError,
RestoreInProgressError,
SessionArchivedError,
SessionArchivingError,
SessionBusyError,
SessionConflictError,
SessionLimitExceededError,
SessionNotFoundError,
SessionShellClientRequiredError,
@ -241,6 +244,31 @@ export function sendBridgeError(
res.status(404).json({ error: err.message, sessionId: err.sessionId });
return;
}
if (err instanceof SessionArchivedError) {
res.status(409).json({
error: err.message,
code: 'session_archived',
sessionId: err.sessionId,
});
return;
}
if (err instanceof SessionConflictError) {
res.status(409).json({
error: err.message,
code: 'session_conflict',
sessionId: err.sessionId,
});
return;
}
if (err instanceof SessionArchivingError) {
res.set('Retry-After', '5');
res.status(409).json({
error: err.message,
code: 'session_archiving',
sessionId: err.sessionId,
});
return;
}
if (err instanceof InvalidClientIdError) {
res.status(400).json({
error: err.message,

View file

@ -0,0 +1,333 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionService, Storage } from '@qwen-code/qwen-code-core';
import {
SessionArchivedError,
SessionArchivingError,
SessionConflictError,
} from '../acp-session-bridge.js';
import {
archiveDaemonSessions,
assertSessionLoadable,
SessionArchiveCoordinator,
unarchiveDaemonSessions,
} from './session-archive.js';
describe('assertSessionLoadable', () => {
let runtimeDir: string;
let workspaceDir: string;
beforeEach(() => {
runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-'));
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
Storage.setRuntimeBaseDir(runtimeDir);
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
fs.rmSync(runtimeDir, { recursive: true, force: true });
fs.rmSync(workspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('rejects archived sessions using project-aware JSONL heads', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
writeSessionFile(workspaceDir, sessionId, 'archived');
const getLocationSpy = vi.spyOn(
SessionService.prototype,
'getSessionLocation',
);
await expect(
assertSessionLoadable(workspaceDir, sessionId),
).rejects.toThrow(SessionArchivedError);
expect(getLocationSpy).toHaveBeenCalledWith(sessionId);
});
it('rejects active/archive conflicts using project-aware JSONL heads', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440001';
writeSessionFile(workspaceDir, sessionId, 'active');
writeSessionFile(workspaceDir, sessionId, 'archived');
const getLocationSpy = vi.spyOn(
SessionService.prototype,
'getSessionLocation',
);
await expect(
assertSessionLoadable(workspaceDir, sessionId),
).rejects.toThrow(SessionConflictError);
expect(getLocationSpy).toHaveBeenCalledWith(sessionId);
});
it('ignores archived files that do not belong to this project', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440010';
const otherWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-other-workspace-'),
);
try {
writeSessionFile(workspaceDir, sessionId, 'archived', otherWorkspace);
await expect(
assertSessionLoadable(workspaceDir, sessionId),
).resolves.toBeUndefined();
} finally {
fs.rmSync(otherWorkspace, { recursive: true, force: true });
}
});
});
describe('SessionArchiveCoordinator', () => {
it('rejects shared access while an exclusive lock is held', async () => {
const coordinator = new SessionArchiveCoordinator();
const sessionId = '550e8400-e29b-41d4-a716-446655440020';
await coordinator.runExclusiveMany([sessionId], async () => {
await expect(
coordinator.runSharedMany([sessionId], async () => 'shared'),
).rejects.toThrow(SessionArchivingError);
});
});
it('allows concurrent shared access and reference-counts release', async () => {
const coordinator = new SessionArchiveCoordinator();
const sessionId = '550e8400-e29b-41d4-a716-446655440021';
let releaseFirst!: () => void;
const firstReleased = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const first = coordinator.runSharedMany([sessionId], async () => {
await firstReleased;
return 'first';
});
await expect(
coordinator.runSharedMany([sessionId], async () => 'second'),
).resolves.toBe('second');
await expect(
coordinator.runExclusiveMany([sessionId], async () => 'exclusive'),
).rejects.toThrow(SessionArchivingError);
releaseFirst();
await expect(first).resolves.toBe('first');
await expect(
coordinator.runExclusiveMany([sessionId], async () => 'exclusive'),
).resolves.toBe('exclusive');
});
it('assertNotTransitioning throws during exclusive access', async () => {
const coordinator = new SessionArchiveCoordinator();
const sessionId = '550e8400-e29b-41d4-a716-446655440022';
await coordinator.runExclusiveMany([sessionId], async () => {
expect(() => coordinator.assertNotTransitioning(sessionId)).toThrow(
SessionArchivingError,
);
});
});
it('releases exclusive locks when the callback throws', async () => {
const coordinator = new SessionArchiveCoordinator();
const sessionId = '550e8400-e29b-41d4-a716-446655440023';
await expect(
coordinator.runExclusiveMany([sessionId], async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
await expect(
coordinator.runExclusiveMany([sessionId], async () => 'ok'),
).resolves.toBe('ok');
});
});
describe('archiveDaemonSessions', () => {
let runtimeDir: string;
let workspaceDir: string;
beforeEach(() => {
runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-'));
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
Storage.setRuntimeBaseDir(runtimeDir);
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
fs.rmSync(runtimeDir, { recursive: true, force: true });
fs.rmSync(workspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('deduplicates ids and archives one active session', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440002';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const closeSession = vi.fn().mockResolvedValue(undefined);
const result = await archiveDaemonSessions({
sessionIds: [sessionId, sessionId],
service,
bridge: { closeSession },
coordinator: new SessionArchiveCoordinator(),
});
expect(result).toEqual({
archived: [sessionId],
alreadyArchived: [],
notFound: [],
errors: [],
});
expect(closeSession).toHaveBeenCalledTimes(1);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
false,
);
expect(
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
).toBe(true);
});
it('does not lock ids that are already archived or missing', async () => {
const archivedId = '550e8400-e29b-41d4-a716-446655440003';
const missingId = '550e8400-e29b-41d4-a716-446655440004';
writeSessionFile(workspaceDir, archivedId, 'archived');
const service = new SessionService(workspaceDir);
const closeSession = vi.fn().mockResolvedValue(undefined);
const coordinator = new SessionArchiveCoordinator();
await coordinator.runSharedMany([archivedId, missingId], async () => {
const result = await archiveDaemonSessions({
sessionIds: [archivedId, missingId],
service,
bridge: { closeSession },
coordinator,
});
expect(result).toEqual({
archived: [],
alreadyArchived: [archivedId],
notFound: [missingId],
errors: [],
});
});
expect(closeSession).not.toHaveBeenCalled();
});
});
describe('unarchiveDaemonSessions', () => {
let runtimeDir: string;
let workspaceDir: string;
beforeEach(() => {
runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-'));
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
Storage.setRuntimeBaseDir(runtimeDir);
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
fs.rmSync(runtimeDir, { recursive: true, force: true });
fs.rmSync(workspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('deduplicates ids and does not lock already active or missing ids', async () => {
const archivedId = '550e8400-e29b-41d4-a716-446655440011';
const activeId = '550e8400-e29b-41d4-a716-446655440012';
const missingId = '550e8400-e29b-41d4-a716-446655440013';
writeSessionFile(workspaceDir, archivedId, 'archived');
writeSessionFile(workspaceDir, activeId, 'active');
const service = new SessionService(workspaceDir);
const coordinator = new SessionArchiveCoordinator();
await coordinator.runSharedMany([activeId, missingId], async () => {
const result = await unarchiveDaemonSessions({
sessionIds: [archivedId, activeId, missingId, archivedId],
service,
coordinator,
});
expect(result).toEqual({
unarchived: [archivedId],
alreadyActive: [activeId],
notFound: [missingId],
errors: [],
});
});
expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe(
true,
);
expect(
fs.existsSync(sessionPath(workspaceDir, archivedId, 'archived')),
).toBe(false);
});
it('reports a single error per archived id when unarchive batch fails', async () => {
const archivedId = '550e8400-e29b-41d4-a716-446655440014';
writeSessionFile(workspaceDir, archivedId, 'archived');
const service = new SessionService(workspaceDir);
const failure = new Error('unarchive failed');
vi.spyOn(service, 'unarchiveSessions').mockRejectedValue(failure);
const result = await unarchiveDaemonSessions({
sessionIds: [archivedId, archivedId],
service,
coordinator: new SessionArchiveCoordinator(),
});
expect(result).toEqual({
unarchived: [],
alreadyActive: [],
notFound: [],
errors: [{ sessionId: archivedId, error: failure }],
});
});
});
function writeSessionFile(
workspaceDir: string,
sessionId: string,
state: 'active' | 'archived',
recordCwd = workspaceDir,
): void {
const chatsDir = path.join(
new Storage(workspaceDir).getProjectDir(),
'chats',
);
const targetDir =
state === 'archived' ? path.join(chatsDir, 'archive') : chatsDir;
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(
path.join(targetDir, `${sessionId}.jsonl`),
`${JSON.stringify({
uuid: 'record-1',
parentUuid: null,
sessionId,
timestamp: '2024-01-01T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello' }] },
cwd: recordCwd,
version: '1.0.0',
})}\n`,
);
}
function sessionPath(
workspaceDir: string,
sessionId: string,
state: 'active' | 'archived',
): string {
const chatsDir = path.join(
new Storage(workspaceDir).getProjectDir(),
'chats',
);
return path.join(
state === 'archived' ? path.join(chatsDir, 'archive') : chatsDir,
`${sessionId}.jsonl`,
);
}

View file

@ -0,0 +1,442 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { SessionService } from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import {
SessionArchivedError,
SessionArchivingError,
SessionConflictError,
SessionNotFoundError,
} from '../acp-session-bridge.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { safeLogValue } from './request-helpers.js';
export interface DaemonArchiveSessionsResult {
archived: string[];
alreadyArchived: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: unknown }>;
}
export interface DaemonUnarchiveSessionsResult {
unarchived: string[];
alreadyActive: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: unknown }>;
}
export interface DaemonDeleteSessionsResult {
removed: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: unknown }>;
}
export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete';
export class SessionArchiveCoordinator {
private readonly exclusive = new Set<string>();
private readonly shared = new Map<string, number>();
assertNotTransitioning(sessionId: string): void {
if (this.exclusive.has(sessionId)) {
throw new SessionArchivingError(sessionId);
}
}
async runExclusiveMany<T>(
sessionIds: string[],
fn: () => Promise<T>,
): Promise<T> {
const uniqueSessionIds = [...new Set(sessionIds)];
for (const sessionId of uniqueSessionIds) {
this.assertNotTransitioning(sessionId);
if ((this.shared.get(sessionId) ?? 0) > 0) {
throw new SessionArchivingError(sessionId, 'shared');
}
}
for (const sessionId of uniqueSessionIds) {
this.exclusive.add(sessionId);
}
try {
return await fn();
} finally {
for (const sessionId of uniqueSessionIds) {
this.exclusive.delete(sessionId);
}
}
}
async runSharedMany<T>(
sessionIds: string[],
fn: () => Promise<T>,
): Promise<T> {
const uniqueSessionIds = [...new Set(sessionIds)];
for (const sessionId of uniqueSessionIds) {
this.assertNotTransitioning(sessionId);
}
for (const sessionId of uniqueSessionIds) {
this.shared.set(sessionId, (this.shared.get(sessionId) ?? 0) + 1);
}
try {
return await fn();
} finally {
for (const sessionId of uniqueSessionIds) {
const count = (this.shared.get(sessionId) ?? 1) - 1;
if (count <= 0) {
this.shared.delete(sessionId);
} else {
this.shared.set(sessionId, count);
}
}
}
}
}
export async function deleteDaemonSessions(params: {
sessionIds: string[];
service: SessionService;
bridge: Pick<AcpSessionBridge, 'closeSession'>;
coordinator: SessionArchiveCoordinator;
onError?: (entry: {
phase: DaemonDeleteErrorPhase;
sessionId: string;
error: string;
}) => void;
}): Promise<DaemonDeleteSessionsResult> {
const { sessionIds, service, bridge, coordinator, onError } = params;
const uniqueSessionIds = [...new Set(sessionIds)];
const closeErrors: Array<{ sessionId: string; error: string }> = [];
const removed: string[] = [];
const notFound: string[] = [];
const removeErrors: Array<{ sessionId: string; error: string }> = [];
for (const sessionId of uniqueSessionIds) {
coordinator.assertNotTransitioning(sessionId);
}
await Promise.all(
uniqueSessionIds.map(async (sessionId) => {
try {
// Keep close+remove under one gate so load/resume cannot recreate the
// same live session between bridge close and transcript deletion.
await coordinator.runExclusiveMany([sessionId], async () => {
let shouldRemove = false;
try {
// Intentional: batch delete bypasses per-tab ownership.
await bridge.closeSession(sessionId);
shouldRemove = true;
} catch (closeErr) {
if (
closeErr instanceof SessionNotFoundError ||
(closeErr instanceof Error &&
closeErr.name === 'SessionNotFoundError')
) {
shouldRemove = true;
} else {
const message =
closeErr instanceof Error ? closeErr.message : String(closeErr);
onError?.({ phase: 'close', sessionId, error: message });
closeErrors.push({ sessionId, error: message });
}
}
if (!shouldRemove) return;
try {
if (await service.removeSession(sessionId)) {
removed.push(sessionId);
} else {
notFound.push(sessionId);
}
} catch (removeErr) {
const message =
removeErr instanceof Error
? removeErr.message
: String(removeErr);
onError?.({ phase: 'remove', sessionId, error: message });
removeErrors.push({ sessionId, error: message });
}
});
} catch (err) {
if (
err instanceof SessionArchivingError &&
err.lockKind === 'exclusive'
) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
onError?.({ phase: 'delete', sessionId, error: message });
closeErrors.push({ sessionId, error: message });
}
}),
);
return { removed, notFound, errors: [...closeErrors, ...removeErrors] };
}
export async function assertSessionLoadable(
workspaceCwd: string,
sessionId: string,
): Promise<void> {
const location = await new SessionService(workspaceCwd).getSessionLocation(
sessionId,
);
if (location === 'archived') {
throw new SessionArchivedError(sessionId);
}
if (location === 'conflict') {
throw new SessionConflictError(sessionId);
}
}
function isSessionNotFoundError(err: unknown): boolean {
return (
err instanceof SessionNotFoundError ||
(err instanceof Error && err.name === 'SessionNotFoundError')
);
}
interface SessionLocationBuckets {
active: string[];
archived: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: unknown }>;
}
async function classifySessionLocations(
service: SessionService,
sessionIds: string[],
): Promise<SessionLocationBuckets> {
const result: SessionLocationBuckets = {
active: [],
archived: [],
notFound: [],
errors: [],
};
const locationResults = await Promise.allSettled(
sessionIds.map(async (sessionId) => ({
sessionId,
location: await service.getSessionLocation(sessionId),
})),
);
for (let i = 0; i < locationResults.length; i++) {
const sessionId = sessionIds[i]!;
const locationResult = locationResults[i]!;
if (locationResult.status === 'rejected') {
result.errors.push({ sessionId, error: locationResult.reason });
continue;
}
const location = locationResult.value.location;
if (location === undefined) {
result.notFound.push(sessionId);
} else if (location === 'archived') {
result.archived.push(sessionId);
} else if (location === 'conflict') {
result.errors.push({
sessionId,
error: new Error(`Session archive conflict: ${sessionId}`),
});
} else {
result.active.push(sessionId);
}
}
return result;
}
function logSessionArchiveResult(
action: 'archive' | 'unarchive',
result: {
requested: string[];
changed: string[];
already: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: unknown }>;
},
): void {
const changedLabel = action === 'archive' ? 'archived' : 'unarchived';
const alreadyLabel =
action === 'archive' ? 'alreadyArchived' : 'alreadyActive';
const details = [
`requested=${result.requested.length} requestedIds=${formatSessionIds(result.requested)}`,
`${changedLabel}=${result.changed.length} ${changedLabel}Ids=${formatSessionIds(result.changed)}`,
`${alreadyLabel}=${result.already.length} ${alreadyLabel}Ids=${formatSessionIds(result.already)}`,
`notFound=${result.notFound.length} notFoundIds=${formatSessionIds(result.notFound)}`,
`errors=${result.errors.length} errorIds=${formatSessionErrors(result.errors)}`,
].join(' ');
writeStderrLine(`qwen serve: sessions ${action} result ${details}`);
}
function formatSessionIds(sessionIds: string[]): string {
return `[${sessionIds.map((sessionId) => safeLogValue(sessionId)).join(',')}]`;
}
function formatSessionErrors(
errors: Array<{ sessionId: string; error: unknown }>,
): string {
return `[${errors
.map(
({ sessionId, error }) =>
`${safeLogValue(sessionId)}:${safeLogValue(errorMessage(error))}`,
)
.join(',')}]`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function logSessionArchiveWarning(message: string): void {
writeStderrLine(`qwen serve: ${sanitizeLogLine(message)}`);
}
// Control characters are intentionally stripped from daemon log lines.
/* eslint-disable no-control-regex */
const LOG_LINE_UNSAFE_RE =
/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g;
/* eslint-enable no-control-regex */
function sanitizeLogLine(message: string): string {
return message.replace(LOG_LINE_UNSAFE_RE, ' ').slice(0, 4096);
}
export async function archiveDaemonSessions(params: {
sessionIds: string[];
service: SessionService;
bridge: Pick<AcpSessionBridge, 'closeSession'>;
coordinator: SessionArchiveCoordinator;
}): Promise<DaemonArchiveSessionsResult> {
const { sessionIds, service, bridge, coordinator } = params;
const uniqueSessionIds = [...new Set(sessionIds)];
const archived: string[] = [];
const alreadyArchived: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: unknown }> = [];
const initial = await classifySessionLocations(service, uniqueSessionIds);
const activeIds = initial.active;
alreadyArchived.push(...initial.archived);
notFound.push(...initial.notFound);
errors.push(...initial.errors);
if (activeIds.length > 0) {
await coordinator.runExclusiveMany(activeIds, async () => {
const locked = await classifySessionLocations(service, activeIds);
const closableIds = locked.active;
alreadyArchived.push(...locked.archived);
notFound.push(...locked.notFound);
errors.push(...locked.errors);
// Close+flush before moving JSONL: live writers keep the active path.
// If the later move fails, the active JSONL remains and a retry treats
// SessionNotFound as the recoverable "already closed" state.
const closeResults = await Promise.allSettled(
closableIds.map(async (sessionId) => {
try {
await bridge.closeSession(sessionId, undefined, {
requireAgentClose: true,
});
} catch (err) {
if (!isSessionNotFoundError(err)) {
throw err;
}
}
}),
);
const archiveIds: string[] = [];
for (let i = 0; i < closeResults.length; i++) {
const sessionId = closableIds[i]!;
const result = closeResults[i]!;
if (result.status === 'fulfilled') {
archiveIds.push(sessionId);
} else {
errors.push({ sessionId, error: result.reason });
}
}
try {
const archiveResult = await service.archiveSessions(archiveIds, {
knownLocation: 'active',
});
archived.push(...archiveResult.archived);
alreadyArchived.push(...archiveResult.alreadyArchived);
notFound.push(...archiveResult.notFound);
errors.push(...archiveResult.errors);
} catch (err) {
for (const sessionId of archiveIds) {
errors.push({ sessionId, error: err });
}
}
});
}
logSessionArchiveResult('archive', {
requested: uniqueSessionIds,
changed: archived,
already: alreadyArchived,
notFound,
errors,
});
return { archived, alreadyArchived, notFound, errors };
}
export async function unarchiveDaemonSessions(params: {
sessionIds: string[];
service: SessionService;
coordinator: SessionArchiveCoordinator;
}): Promise<DaemonUnarchiveSessionsResult> {
const { sessionIds, service, coordinator } = params;
const uniqueSessionIds = [...new Set(sessionIds)];
const unarchived: string[] = [];
const alreadyActive: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: unknown }> = [];
const initial = await classifySessionLocations(service, uniqueSessionIds);
const archivedIds = initial.archived;
alreadyActive.push(...initial.active);
notFound.push(...initial.notFound);
errors.push(...initial.errors);
if (archivedIds.length > 0) {
await coordinator.runExclusiveMany(archivedIds, async () => {
const locked = await classifySessionLocations(service, archivedIds);
const unarchiveIds = locked.archived;
alreadyActive.push(...locked.active);
notFound.push(...locked.notFound);
errors.push(...locked.errors);
if (unarchiveIds.length > 0) {
try {
const result = await service.unarchiveSessions(unarchiveIds, {
knownLocation: 'archived',
});
unarchived.push(...result.unarchived);
alreadyActive.push(...result.alreadyActive);
notFound.push(...result.notFound);
errors.push(...result.errors);
} catch (err) {
// The service reports normal per-session failures in `result.errors`.
// Reaching this catch means the batch could not produce a result at all.
for (const sessionId of unarchiveIds) {
errors.push({ sessionId, error: err });
}
}
}
});
}
logSessionArchiveResult('unarchive', {
requested: uniqueSessionIds,
changed: unarchived,
already: alreadyActive,
notFound,
errors,
});
return { unarchived, alreadyActive, notFound, errors };
}

View file

@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SessionService } from '@qwen-code/qwen-code-core';
import {
SessionService,
type SessionArchiveState,
} from '@qwen-code/qwen-code-core';
import type {
AcpSessionBridge,
BridgeSessionSummary,
@ -16,6 +19,7 @@ const MAX_SESSION_PAGE_SIZE = 100;
export interface ListWorkspaceSessionsOptions {
cursor?: string;
size?: number;
archiveState?: SessionArchiveState;
}
export interface ListWorkspaceSessionsResult {
@ -64,9 +68,11 @@ export async function listWorkspaceSessionsForResponse(
const isFirstPage = numericCursor === undefined;
const sessionService = new SessionService(workspaceCwd);
const archiveState = options?.archiveState ?? 'active';
const persisted = await sessionService.listSessions({
cursor: numericCursor,
size: pageSize,
archiveState,
});
const bySessionId = new Map<string, BridgeSessionSummary>();
@ -79,9 +85,17 @@ export async function listWorkspaceSessionsForResponse(
displayName: item.customTitle || item.prompt,
clientCount: 0,
hasActivePrompt: false,
isArchived: item.isArchived === true,
});
}
if (archiveState === 'archived') {
const sessions = [...bySessionId.values()];
const nextCursor =
persisted.nextCursor != null ? String(persisted.nextCursor) : undefined;
return { sessions, nextCursor };
}
const liveSessions = bridge.listWorkspaceSessions(workspaceCwd);
for (const live of liveSessions) {
const existing = bySessionId.get(live.sessionId);
@ -94,6 +108,7 @@ export async function listWorkspaceSessionsForResponse(
updatedAt: live.updatedAt ?? existing.updatedAt,
clientCount: live.clientCount,
hasActivePrompt: live.hasActivePrompt,
isArchived: false,
});
} else if (
isFirstPage &&
@ -104,6 +119,7 @@ export async function listWorkspaceSessionsForResponse(
createdAt: live.createdAt,
clientCount: live.clientCount,
hasActivePrompt: live.hasActivePrompt,
isArchived: false,
});
}
}

View file

@ -40,6 +40,9 @@ describe('SessionService', () => {
let readdirSyncSpy: MockInstance<typeof fs.readdirSync>;
let statSyncSpy: MockInstance<typeof fs.statSync>;
let unlinkSyncSpy: MockInstance<typeof fs.unlinkSync>;
let existsSyncSpy: MockInstance<typeof fs.existsSync>;
let mkdirSyncSpy: MockInstance<typeof fs.mkdirSync>;
let renameSyncSpy: MockInstance<typeof fs.renameSync>;
beforeEach(() => {
vi.mocked(getProjectHash).mockReturnValue('test-project-hash');
@ -63,6 +66,11 @@ describe('SessionService', () => {
unlinkSyncSpy = vi
.spyOn(fs, 'unlinkSync')
.mockImplementation(() => undefined);
existsSyncSpy = vi.spyOn(fs, 'existsSync');
mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
renameSyncSpy = vi
.spyOn(fs, 'renameSync')
.mockImplementation(() => undefined);
// Mock jsonl-utils. `parseLineTolerant` defaults to a no-op so any code
// path that streams lines through it (e.g. countSessionMessages,
@ -176,6 +184,51 @@ describe('SessionService', () => {
expect(result.items[1].sessionId).toBe(sessionIdA);
});
it('should ignore archive directory when listing active sessions', async () => {
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
'archive',
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.listSessions();
expect(result.items.map((item) => item.sessionId)).toEqual([sessionIdA]);
expect(result.items[0].isArchived).toBe(false);
expect(jsonl.readLines).toHaveBeenCalledTimes(1);
expect(vi.mocked(jsonl.readLines).mock.calls[0][0]).not.toContain(
'/archive/',
);
});
it('should list archived sessions from archive directory only', async () => {
readdirSyncSpy.mockImplementation((dir: fs.PathLike) => {
if (dir.toString().endsWith('/chats/archive')) {
return [`${sessionIdB}.jsonl`] as unknown as Array<fs.Dirent<Buffer>>;
}
return [`${sessionIdA}.jsonl`] as unknown as Array<fs.Dirent<Buffer>>;
});
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.readLines).mockResolvedValue([recordB1]);
const result = await sessionService.listSessions({
archiveState: 'archived',
});
expect(result.items.map((item) => item.sessionId)).toEqual([sessionIdB]);
expect(result.items[0].isArchived).toBe(true);
expect(vi.mocked(jsonl.readLines).mock.calls[0][0]).toContain(
'/chats/archive/',
);
});
it('should extract prompt text from first record', async () => {
const now = Date.now();
@ -923,6 +976,409 @@ describe('SessionService', () => {
expect(result).toBe(false);
});
it('should remove archived session files and both worktree sidecars', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) return [recordA1];
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
);
existsSyncSpy.mockImplementation((filePath: fs.PathLike) =>
filePath.toString().endsWith(`${sessionIdA}.worktree.json`),
);
const result = await sessionService.removeSession(sessionIdA);
expect(result).toBe(true);
expect(unlinkSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
expect(unlinkSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.worktree.json`),
);
expect(unlinkSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`),
);
});
it('should remove both JSONL files when active and archived copies conflict', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
existsSyncSpy.mockImplementation((filePath: fs.PathLike) =>
filePath.toString().endsWith(`/chats/archive/${sessionIdA}.jsonl`),
);
const result = await sessionService.removeSession(sessionIdA);
expect(result).toBe(true);
expect(unlinkSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
expect(unlinkSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
});
});
describe('archiveSessions', () => {
beforeEach(() => {
mkdirSyncSpy.mockImplementation(() => undefined);
});
const mockActiveSessionOnly = () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return [recordA1];
},
);
};
const mockActiveWorktreeSidecarOnly = () => {
existsSyncSpy.mockImplementation((filePath) => {
const value = filePath.toString();
if (value.endsWith(`/chats/archive/${sessionIdA}.jsonl`)) {
return false;
}
if (value.endsWith(`/chats/${sessionIdA}.worktree.json`)) {
return true;
}
if (value.endsWith(`/chats/archive/${sessionIdA}.worktree.json`)) {
return false;
}
return false;
});
};
it('should move active sessions into the archive directory', async () => {
mockActiveSessionOnly();
const result = await sessionService.archiveSessions([sessionIdA]);
expect(result.archived).toEqual([sessionIdA]);
expect(result.alreadyArchived).toEqual([]);
expect(result.notFound).toEqual([]);
expect(result.errors).toEqual([]);
expect(mkdirSyncSpy).toHaveBeenCalledWith(
expect.stringContaining('/chats/archive'),
{ recursive: true },
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
});
it('should archive JSONL and warn when archiving worktree sidecar fails', async () => {
mockActiveSessionOnly();
mockActiveWorktreeSidecarOnly();
const warnings: string[] = [];
const service = new SessionService('/test/project/root', {
onWarning: (message) => warnings.push(message),
});
const sidecarError = new Error('sidecar move failed');
renameSyncSpy.mockImplementation((sourcePath) => {
if (sourcePath.toString().endsWith('.worktree.json')) {
throw sidecarError;
}
return undefined;
});
const result = await service.archiveSessions([sessionIdA]);
expect(result.archived).toEqual([sessionIdA]);
expect(result.errors).toEqual([]);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain(
`archiveSessions: failed to move worktree sidecar for ${sessionIdA}`,
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.worktree.json`),
expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`),
);
});
it('should not move worktree sidecar when archiving JSONL fails', async () => {
mockActiveSessionOnly();
mockActiveWorktreeSidecarOnly();
const jsonlError = new Error(
`EACCES: permission denied, rename '/tmp/runtime/chats/${sessionIdA}.jsonl' -> '/tmp/runtime/chats/archive/${sessionIdA}.jsonl'`,
) as NodeJS.ErrnoException;
jsonlError.code = 'EACCES';
renameSyncSpy.mockImplementation((sourcePath) => {
if (sourcePath.toString().endsWith('.jsonl')) {
throw jsonlError;
}
return undefined;
});
const result = await sessionService.archiveSessions([sessionIdA]);
expect(result.archived).toEqual([]);
expect(result.errors[0]?.sessionId).toBe(sessionIdA);
expect(result.errors[0]?.error.message).toBe(
'Failed to archive session file: EACCES',
);
expect(result.errors[0]?.error.message).not.toContain('/tmp/runtime');
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
expect(renameSyncSpy).not.toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.worktree.json`),
expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`),
);
});
it('should skip location reads when archiving known active sessions', async () => {
const getLocationSpy = vi.spyOn(sessionService, 'getSessionLocation');
const result = await sessionService.archiveSessions([sessionIdA], {
knownLocation: 'active',
});
expect(result.archived).toEqual([sessionIdA]);
expect(result.errors).toEqual([]);
expect(getLocationSpy).not.toHaveBeenCalled();
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
);
});
it('should report already archived sessions without moving them', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) return [recordA1];
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
);
const result = await sessionService.archiveSessions([sessionIdA]);
expect(result.archived).toEqual([]);
expect(result.alreadyArchived).toEqual([sessionIdA]);
expect(renameSyncSpy).not.toHaveBeenCalled();
});
it('should report active and archived duplicate ids as errors', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.archiveSessions([sessionIdA]);
expect(result.archived).toEqual([]);
expect(result.errors[0]?.sessionId).toBe(sessionIdA);
expect(result.errors[0]?.error.message).toMatch(/conflict/i);
expect(renameSyncSpy).not.toHaveBeenCalled();
});
});
describe('unarchiveSessions', () => {
beforeEach(() => {
mkdirSyncSpy.mockImplementation(() => undefined);
});
const mockArchivedSessionOnly = () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) return [recordA1];
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
);
};
const mockArchivedWorktreeSidecarOnly = () => {
existsSyncSpy.mockImplementation((filePath) => {
const value = filePath.toString();
if (value.endsWith(`/chats/${sessionIdA}.jsonl`)) {
return false;
}
if (value.endsWith(`/chats/archive/${sessionIdA}.worktree.json`)) {
return true;
}
if (value.endsWith(`/chats/${sessionIdA}.worktree.json`)) {
return false;
}
return false;
});
};
it('should move archived sessions back to the active directory', async () => {
mockArchivedSessionOnly();
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([sessionIdA]);
expect(result.alreadyActive).toEqual([]);
expect(result.notFound).toEqual([]);
expect(result.errors).toEqual([]);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
});
it('should skip location reads when unarchiving known archived sessions', async () => {
mockArchivedSessionOnly();
const getLocationSpy = vi.spyOn(sessionService, 'getSessionLocation');
const result = await sessionService.unarchiveSessions([sessionIdA], {
knownLocation: 'archived',
});
expect(result.unarchived).toEqual([sessionIdA]);
expect(result.errors).toEqual([]);
expect(getLocationSpy).not.toHaveBeenCalled();
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
});
it('should recreate active chats directory before moving archived sessions', async () => {
mockArchivedSessionOnly();
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([sessionIdA]);
expect(mkdirSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(/\/chats$/),
{ recursive: true },
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
});
it('should report not found when neither active nor archived file exists', async () => {
vi.mocked(jsonl.readLines).mockImplementation(async () => {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
});
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([]);
expect(result.alreadyActive).toEqual([]);
expect(result.notFound).toEqual([sessionIdA]);
expect(result.errors).toEqual([]);
expect(renameSyncSpy).not.toHaveBeenCalled();
});
it('should report already active sessions without moving them', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return [recordA1];
},
);
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([]);
expect(result.alreadyActive).toEqual([sessionIdA]);
expect(result.notFound).toEqual([]);
expect(result.errors).toEqual([]);
expect(renameSyncSpy).not.toHaveBeenCalled();
});
it('should unarchive JSONL and warn when worktree sidecar move fails', async () => {
mockArchivedSessionOnly();
mockArchivedWorktreeSidecarOnly();
const warnings: string[] = [];
const service = new SessionService('/test/project/root', {
onWarning: (message) => warnings.push(message),
});
const sidecarError = new Error('sidecar move failed');
renameSyncSpy.mockImplementation((sourcePath) => {
if (sourcePath.toString().endsWith('.worktree.json')) {
throw sidecarError;
}
return undefined;
});
const result = await service.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([sessionIdA]);
expect(result.errors).toEqual([]);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain(
`unarchiveSessions: failed to move worktree sidecar for ${sessionIdA}`,
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`),
expect.stringContaining(`/chats/${sessionIdA}.worktree.json`),
);
});
it('should not move worktree sidecar when unarchiving JSONL fails', async () => {
mockArchivedSessionOnly();
mockArchivedWorktreeSidecarOnly();
const jsonlError = new Error(
`ENOSPC: no space left on device, rename '/tmp/runtime/chats/archive/${sessionIdA}.jsonl' -> '/tmp/runtime/chats/${sessionIdA}.jsonl'`,
) as NodeJS.ErrnoException;
jsonlError.code = 'ENOSPC';
renameSyncSpy.mockImplementation((sourcePath) => {
if (
sourcePath.toString().endsWith('.jsonl') &&
sourcePath.toString().includes('/chats/archive/')
) {
throw jsonlError;
}
return undefined;
});
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([]);
expect(result.errors[0]?.sessionId).toBe(sessionIdA);
expect(result.errors[0]?.error.message).toBe(
'Failed to unarchive session file: ENOSPC',
);
expect(result.errors[0]?.error.message).not.toContain('/tmp/runtime');
expect(renameSyncSpy).toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`),
expect.stringContaining(`/chats/${sessionIdA}.jsonl`),
);
expect(renameSyncSpy).not.toHaveBeenCalledWith(
expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`),
expect.stringContaining(`/chats/${sessionIdA}.worktree.json`),
);
});
it('should reject unarchive when active and archived files both exist', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.unarchiveSessions([sessionIdA]);
expect(result.unarchived).toEqual([]);
expect(result.errors[0]?.sessionId).toBe(sessionIdA);
expect(result.errors[0]?.error.message).toMatch(/conflict/i);
expect(renameSyncSpy).not.toHaveBeenCalled();
});
});
describe('removeSessions', () => {
@ -931,6 +1387,11 @@ describe('SessionService', () => {
// never has a backing record (notFound).
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
if (filePath.includes(sessionIdA)) return [recordA1];
if (filePath.includes(sessionIdB)) return [recordB1];
return [];
@ -950,7 +1411,16 @@ describe('SessionService', () => {
});
it('should de-duplicate input ids', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return [recordA1];
},
);
const result = await sessionService.removeSessions([
sessionIdA,
@ -966,6 +1436,11 @@ describe('SessionService', () => {
it('should keep going when one removal fails', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
if (filePath.includes(sessionIdA)) return [recordA1];
if (filePath.includes(sessionIdB)) return [recordB1];
return [];
@ -1144,6 +1619,35 @@ describe('SessionService', () => {
});
});
describe('getSessionLocation', () => {
it('should report conflict when active and archived files both exist', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
await expect(sessionService.getSessionLocation(sessionIdA)).resolves.toBe(
'conflict',
);
});
it('should warn when reading a session head fails', async () => {
const warnings: string[] = [];
const service = new SessionService('/test/project/root', {
onWarning: (message) => warnings.push(message),
});
const error = new Error('malformed JSON');
vi.mocked(jsonl.readLines).mockRejectedValue(error);
await expect(service.getSessionLocation(sessionIdA)).rejects.toThrow(
error,
);
expect(warnings).toHaveLength(2);
for (const warning of warnings) {
expect(warning).toContain('readProjectSessionHead: failed to read');
expect(warning).toContain(`${sessionIdA}.jsonl`);
expect(warning).toContain('malformed JSON');
}
});
});
describe('loadLastSession', () => {
it('should return the most recent session (same as getLatestSession)', async () => {
const now = Date.now();
@ -1247,6 +1751,41 @@ describe('SessionService', () => {
expect(exists).toBe(true);
});
it('should keep default existence checks active-only', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) return [recordA1];
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
);
await expect(sessionService.sessionExists(sessionIdA)).resolves.toBe(
false,
);
await expect(
sessionService.sessionExistsInAnyState(sessionIdA),
).resolves.toBe(true);
});
it('should treat unreadable active or archived files as existing for any-state checks', async () => {
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes('/chats/archive/')) {
throw new Error('malformed jsonl');
}
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
);
await expect(
sessionService.sessionExistsInAnyState(sessionIdA),
).resolves.toBe(true);
});
});
describe('getResumePromptTokenCount', () => {

View file

@ -75,8 +75,14 @@ export interface SessionListItem {
* chose.
*/
titleSource?: TitleSource;
/** True when the item was read from the archive directory. */
isArchived?: boolean;
}
export type SessionArchiveState = 'active' | 'archived';
export type SessionLocation = SessionArchiveState | 'conflict' | undefined;
/**
* Pagination options for listing sessions.
*/
@ -92,6 +98,11 @@ export interface ListSessionsOptions {
* @default 20
*/
size?: number;
/**
* Which session directory to list.
* @default 'active'
*/
archiveState?: SessionArchiveState;
}
/**
@ -118,6 +129,32 @@ export interface RemoveSessionsResult {
errors: Array<{ sessionId: string; error: Error }>;
}
export interface ArchiveSessionsResult {
archived: string[];
alreadyArchived: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: Error }>;
}
export interface ArchiveSessionsOptions {
knownLocation?: 'active';
}
export interface UnarchiveSessionsResult {
unarchived: string[];
alreadyActive: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: Error }>;
}
export interface UnarchiveSessionsOptions {
knownLocation?: 'archived';
}
export interface SessionServiceOptions {
onWarning?: (message: string) => void;
}
/**
* Complete conversation reconstructed from ChatRecords.
* Used for resuming sessions and API compatibility.
@ -235,17 +272,51 @@ export class SessionService {
private readonly storage: Storage;
private readonly projectHash: string;
private readonly projectRoot: string;
private readonly onWarning: ((message: string) => void) | undefined;
constructor(cwd: string) {
constructor(cwd: string, options: SessionServiceOptions = {}) {
this.storage = new Storage(cwd);
this.projectRoot = cwd;
this.projectHash = getProjectHash(cwd);
this.onWarning = options.onWarning;
}
private warn(message: string): void {
debugLogger.warn(message);
this.onWarning?.(message);
}
private getChatsDir(): string {
return path.join(this.storage.getProjectDir(), 'chats');
}
private getArchiveChatsDir(): string {
return path.join(this.getChatsDir(), 'archive');
}
private getChatsDirForState(state: SessionArchiveState): string {
return state === 'archived'
? this.getArchiveChatsDir()
: this.getChatsDir();
}
private getSessionFilePath(
sessionId: string,
state: SessionArchiveState,
): string {
return path.join(this.getChatsDirForState(state), `${sessionId}.jsonl`);
}
private getWorktreeSessionPathForState(
sessionId: string,
state: SessionArchiveState,
): string {
return path.join(
this.getChatsDirForState(state),
`${sessionId}.worktree.json`,
);
}
private async sessionBelongsToCurrentProject(
sessionId: string,
recordCwd: string,
@ -269,7 +340,93 @@ export class SessionService {
* exist yet consumers must handle ENOENT as "no active worktree".
*/
getWorktreeSessionPath(sessionId: string): string {
return path.join(this.getChatsDir(), `${sessionId}.worktree.json`);
return this.getWorktreeSessionPathForState(sessionId, 'active');
}
private async readProjectSessionHead(
sessionId: string,
filePath: string,
): Promise<ChatRecord | undefined> {
try {
const records = await jsonl.readLines<ChatRecord>(filePath, 1);
if (records.length === 0) {
return undefined;
}
const firstRecord = records[0];
if (
!(await this.sessionBelongsToCurrentProject(sessionId, firstRecord.cwd))
) {
return undefined;
}
return firstRecord;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
this.warn(`readProjectSessionHead: failed to read ${filePath}: ${error}`);
throw error;
}
}
async getSessionLocation(sessionId: string): Promise<SessionLocation> {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
return undefined;
}
const [active, archived] = await Promise.all([
this.readProjectSessionHead(
sessionId,
this.getSessionFilePath(sessionId, 'active'),
),
this.readProjectSessionHead(
sessionId,
this.getSessionFilePath(sessionId, 'archived'),
),
]);
if (active && archived) return 'conflict';
if (active) return 'active';
if (archived) return 'archived';
return undefined;
}
private removeFileIfExists(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
private removeWorktreeSidecars(sessionId: string): void {
for (const state of ['active', 'archived'] as const) {
const sidecar = this.getWorktreeSessionPathForState(sessionId, state);
if (fs.existsSync(sidecar)) {
this.removeFileIfExists(sidecar);
}
}
}
private moveOptionalFile(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath)) {
return false;
}
if (fs.existsSync(targetPath)) {
throw new Error('Archive sidecar conflict: destination already exists');
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.renameSync(sourcePath, targetPath);
return true;
}
private sessionFileMoveError(
action: 'archive' | 'unarchive',
error: unknown,
): Error {
const code = (error as NodeJS.ErrnoException).code ?? 'unknown error';
return new Error(`Failed to ${action} session file: ${code}`);
}
/**
@ -336,6 +493,9 @@ export class SessionService {
* Public accessor: returns both the current custom title and its source
* for a given session. Used by `ChatRecordingService` on resume to
* preserve the persisted `titleSource` rather than defaulting to manual.
*
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
getSessionTitleInfo(sessionId: string): {
title?: string;
@ -473,6 +633,9 @@ export class SessionService {
* of multi-MB sessions. Call this lazily, only when a specific
* session's message count is about to be displayed (e.g., from a
* preview panel) or computed from a resumed conversation.
*
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async countSessionMessages(sessionId: string): Promise<number> {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
@ -544,8 +707,9 @@ export class SessionService {
async listSessions(
options: ListSessionsOptions = {},
): Promise<ListSessionsResult> {
const { cursor, size = 20 } = options;
const chatsDir = this.getChatsDir();
const { cursor, size = 20, archiveState = 'active' } = options;
const chatsDir = this.getChatsDirForState(archiveState);
const isArchived = archiveState === 'archived';
// Get all valid session files (matching UUID pattern) with their stats
let files: Array<{ name: string; mtime: number }> = [];
@ -642,6 +806,7 @@ export class SessionService {
// and `countSessionMessages` for the rationale.
customTitle: titleInfo.title,
titleSource: titleInfo.source,
isArchived,
});
}
@ -772,7 +937,9 @@ export class SessionService {
* Reconstructs the full conversation from tree-structured records.
*
* @param sessionId The session ID to load
* @returns Session data for resumption, or null if not found
* @returns Session data for resumption, or undefined if not found
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async loadSession(
sessionId: string,
@ -867,23 +1034,29 @@ export class SessionService {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
return false;
}
const chatsDir = this.getChatsDir();
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
try {
// Verify the file exists and belongs to this project
const records = await jsonl.readLines<ChatRecord>(filePath, 1);
if (records.length === 0) {
const activePath = this.getSessionFilePath(sessionId, 'active');
const active = await this.readProjectSessionHead(sessionId, activePath);
if (active) {
this.removeFileIfExists(activePath);
const archivedPath = this.getSessionFilePath(sessionId, 'archived');
if (fs.existsSync(archivedPath)) {
this.removeFileIfExists(archivedPath);
}
this.removeWorktreeSidecars(sessionId);
return true;
}
const archivedPath = this.getSessionFilePath(sessionId, 'archived');
const archived = await this.readProjectSessionHead(
sessionId,
archivedPath,
);
if (!archived) {
return false;
}
if (
!(await this.sessionBelongsToCurrentProject(sessionId, records[0].cwd))
) {
return false;
}
fs.unlinkSync(filePath);
this.removeFileIfExists(archivedPath);
this.removeWorktreeSidecars(sessionId);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
@ -893,6 +1066,140 @@ export class SessionService {
}
}
async archiveSessions(
sessionIds: string[],
options: ArchiveSessionsOptions = {},
): Promise<ArchiveSessionsResult> {
const archived: string[] = [];
const alreadyArchived: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: Error }> = [];
for (const sessionId of [...new Set(sessionIds)]) {
try {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
notFound.push(sessionId);
continue;
}
if (options.knownLocation !== 'active') {
const location = await this.getSessionLocation(sessionId);
if (location === undefined) {
notFound.push(sessionId);
continue;
}
if (location === 'archived') {
alreadyArchived.push(sessionId);
continue;
}
if (location === 'conflict') {
throw new Error(`Session archive conflict: ${sessionId}`);
}
}
const sourcePath = this.getSessionFilePath(sessionId, 'active');
const targetPath = this.getSessionFilePath(sessionId, 'archived');
if (fs.existsSync(targetPath)) {
throw new Error(`Session archive conflict: ${sessionId}`);
}
fs.mkdirSync(this.getArchiveChatsDir(), { recursive: true });
const activeSidecar = this.getWorktreeSessionPathForState(
sessionId,
'active',
);
const archivedSidecar = this.getWorktreeSessionPathForState(
sessionId,
'archived',
);
try {
fs.renameSync(sourcePath, targetPath);
} catch (error) {
throw this.sessionFileMoveError('archive', error);
}
try {
this.moveOptionalFile(activeSidecar, archivedSidecar);
} catch (sidecarError) {
this.warn(
`archiveSessions: failed to move worktree sidecar for ${sessionId} from ${activeSidecar} to ${archivedSidecar}: ${sidecarError}`,
);
}
archived.push(sessionId);
} catch (error) {
errors.push({
sessionId,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
return { archived, alreadyArchived, notFound, errors };
}
async unarchiveSessions(
sessionIds: string[],
options: UnarchiveSessionsOptions = {},
): Promise<UnarchiveSessionsResult> {
const unarchived: string[] = [];
const alreadyActive: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: Error }> = [];
for (const sessionId of [...new Set(sessionIds)]) {
try {
if (options.knownLocation !== 'archived') {
const location = await this.getSessionLocation(sessionId);
if (location === undefined) {
notFound.push(sessionId);
continue;
}
if (location === 'active') {
alreadyActive.push(sessionId);
continue;
}
if (location === 'conflict') {
throw new Error(`Session archive conflict: ${sessionId}`);
}
}
const sourcePath = this.getSessionFilePath(sessionId, 'archived');
const targetPath = this.getSessionFilePath(sessionId, 'active');
if (fs.existsSync(targetPath)) {
throw new Error(`Session archive conflict: ${sessionId}`);
}
const archivedSidecar = this.getWorktreeSessionPathForState(
sessionId,
'archived',
);
const activeSidecar = this.getWorktreeSessionPathForState(
sessionId,
'active',
);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
try {
fs.renameSync(sourcePath, targetPath);
} catch (error) {
throw this.sessionFileMoveError('unarchive', error);
}
try {
this.moveOptionalFile(archivedSidecar, activeSidecar);
} catch (sidecarError) {
this.warn(
`unarchiveSessions: failed to move worktree sidecar for ${sessionId} from ${archivedSidecar} to ${activeSidecar}: ${sidecarError}`,
);
}
unarchived.push(sessionId);
} catch (error) {
errors.push({
sessionId,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
return { unarchived, alreadyActive, notFound, errors };
}
/**
* Removes multiple sessions in one call.
*
@ -944,6 +1251,8 @@ export class SessionService {
* existing callers are unchanged pass `'auto'` only for titles produced
* by the auto-title generator.
* @returns true if renamed successfully, false if session not found
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async renameSession(
sessionId: string,
@ -1017,6 +1326,8 @@ export class SessionService {
*
* @throws If source does not exist, source is empty, source belongs to a
* different project, or the target file already exists.
* @remarks Only checks active source sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async forkSession(
sourceSessionId: string,
@ -1147,6 +1458,8 @@ export class SessionService {
*
* @param sessionId The session ID to look up
* @returns The custom title, or undefined if none set
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
getSessionTitle(sessionId: string): string | undefined {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
@ -1163,6 +1476,8 @@ export class SessionService {
*
* @param title The custom title to search for (case-insensitive exact match)
* @returns Array of matching session list items
* @remarks Only searches active sessions. Archived title search is not
* supported.
*/
async findSessionsByTitle(title: string): Promise<SessionListItem[]> {
const normalizedTitle = title.toLowerCase().trim();
@ -1339,6 +1654,8 @@ export class SessionService {
*
* @param sessionId The session ID to check
* @returns true if session exists and belongs to current project
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async sessionExists(sessionId: string): Promise<boolean> {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
@ -1357,6 +1674,15 @@ export class SessionService {
return false;
}
}
async sessionExistsInAnyState(sessionId: string): Promise<boolean> {
try {
const location = await this.getSessionLocation(sessionId);
return location !== undefined;
} catch {
return true;
}
}
}
/**

View file

@ -37,9 +37,10 @@ const rootDir = join(__dirname, '..');
// Bumped from 130KB to 131KB for the workspace MCP resources drill-down
// (workspaceMcpResources client method + route + resource status types).
// Bumped from 131KB to 132KB for the pending prompt queue feature.
// Bumped from 132KB to 133KB for sessionless workspace remember
// (rememberWorkspaceMemory/getWorkspaceMemoryRememberTask + managed memory event validation).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 133 * 1024;
// Bumped from 132KB to 133KB for session archive/unarchive APIs and sessionless
// workspace remember (managed memory client methods + event validation).
// Bumped from 133KB to 134KB after merging both surfaces with main.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 134 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't

View file

@ -19,7 +19,7 @@ import {
import {
matchRoute,
synthesizeResponse,
jsonRpcErrorToHttpStatus,
jsonRpcErrorToHttpStatusWithData,
isRecord,
composeAbortSignals,
mergeHeaders,
@ -284,7 +284,10 @@ export class AcpHttpTransport implements DaemonTransport {
const httpStatus =
isRecord(errorData) && typeof errorData['httpStatus'] === 'number'
? errorData['httpStatus']
: jsonRpcErrorToHttpStatus(response.error.code);
: jsonRpcErrorToHttpStatusWithData(
response.error.code,
response.error.data,
);
return synthesizeResponse(httpStatus, {
error: response.error.message,
...(response.error.data != null ? { data: response.error.data } : {}),

View file

@ -18,7 +18,7 @@ import {
import {
matchRoute,
synthesizeResponse,
jsonRpcErrorToHttpStatus,
jsonRpcErrorToHttpStatusWithData,
isRecord,
} from './acpTransportUtils.js';
@ -214,7 +214,10 @@ export class AcpWsTransport implements DaemonTransport {
const status =
isRecord(errorData) && typeof errorData['httpStatus'] === 'number'
? errorData['httpStatus']
: jsonRpcErrorToHttpStatus(response.error.code);
: jsonRpcErrorToHttpStatusWithData(
response.error.code,
response.error.data,
);
return synthesizeResponse(status, {
error: response.error.message,
...(response.error.data != null ? { data: response.error.data } : {}),

View file

@ -21,6 +21,7 @@ import type {
DaemonAuthStatusSnapshot,
DaemonCapabilities,
DaemonCreateAgentRequest,
DaemonArchiveSessionsResult,
DaemonGeneratedAgentContent,
DaemonDeviceFlowStartResult,
DaemonDeviceFlowState,
@ -32,6 +33,7 @@ import type {
DaemonForkSessionResult,
DaemonRestoredSession,
DaemonSession,
DaemonSessionArchiveState,
DaemonSessionLspStatus,
DaemonSessionSummary,
DaemonSessionSupportedCommandsStatus,
@ -112,6 +114,7 @@ import type {
DaemonWorkspaceTrustChangeRequest,
DaemonWorkspaceTrustChangeResult,
DaemonWorkspaceTrustStatus,
DaemonUnarchiveSessionsResult,
} from './types.js';
const WORKSPACE_MEMORY_REMEMBER_PATH = '/workspace/memory/remember';
@ -1322,7 +1325,10 @@ export class DaemonClient {
*/
async listWorkspaceSessions(
workspaceCwd: string,
options?: { pageSize?: number },
options?: {
pageSize?: number;
archiveState?: DaemonSessionArchiveState;
},
): Promise<DaemonSessionSummary[]> {
const requestedPageSize =
options?.pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE;
@ -1337,10 +1343,14 @@ export class DaemonClient {
),
),
);
const query = new URLSearchParams({ size: String(pageSize) });
if (options?.archiveState !== undefined) {
query.set('archiveState', options.archiveState);
}
const body = await this.jsonRequest<{
sessions: DaemonSessionSummary[];
}>(
`/workspace/${encodeURIComponent(workspaceCwd)}/sessions?size=${pageSize}`,
`/workspace/${encodeURIComponent(workspaceCwd)}/sessions?${query.toString()}`,
'GET /workspace/sessions',
);
return body.sessions;
@ -2735,6 +2745,46 @@ export class DaemonClient {
);
}
async archiveSessionsData(
sessionIds: string[],
clientId?: string,
): Promise<DaemonArchiveSessionsResult> {
return await this.fetchWithTimeout(
`${this.baseUrl}/sessions/archive`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
body: JSON.stringify({ sessionIds }),
},
async (res) => {
if (res.ok) {
return (await res.json()) as DaemonArchiveSessionsResult;
}
throw await this.failOnError(res, 'POST /sessions/archive');
},
);
}
async unarchiveSessionsData(
sessionIds: string[],
clientId?: string,
): Promise<DaemonUnarchiveSessionsResult> {
return await this.fetchWithTimeout(
`${this.baseUrl}/sessions/unarchive`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
body: JSON.stringify({ sessionIds }),
},
async (res) => {
if (res.ok) {
return (await res.json()) as DaemonUnarchiveSessionsResult;
}
throw await this.failOnError(res, 'POST /sessions/unarchive');
},
);
}
// -- Auth device-flow ---------------------------------------------------
/**

View file

@ -655,6 +655,26 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
},
},
// GET /workspace/:id/sessions → session/list
{
httpMethod: 'GET',
pattern: /^\/workspace\/(.+)\/sessions\/?$/,
mapping: {
method: 'session/list',
extractParams: (segs, _body, _method, query) => {
const size = query?.get('size');
return {
workspaceCwd: segs[0],
...strParam(query, 'cursor'),
...strParam(query, 'archiveState'),
...(size == null || size === ''
? {}
: { _meta: { size: Number(size) } }),
};
},
},
},
// ---- Workspace catch-all (must be AFTER all specific workspace routes) --
// Handles any workspace path not matched above (e.g., /workspace/custom/path).
{
@ -765,4 +785,22 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// POST /sessions/archive → _qwen/sessions/archive
{
httpMethod: 'POST',
pattern: /^\/sessions\/archive\/?$/,
mapping: {
method: '_qwen/sessions/archive',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// POST /sessions/unarchive → _qwen/sessions/unarchive
{
httpMethod: 'POST',
pattern: /^\/sessions\/unarchive\/?$/,
mapping: {
method: '_qwen/sessions/unarchive',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
];

View file

@ -48,6 +48,21 @@ export function synthesizeResponse(status: number, body: unknown): Response {
* Map a JSON-RPC error code to an HTTP status code.
*/
export function jsonRpcErrorToHttpStatus(code: number): number {
return jsonRpcErrorToHttpStatusWithData(code);
}
export function jsonRpcErrorToHttpStatusWithData(
code: number,
data?: unknown,
): number {
if (
isRecord(data) &&
(data['errorKind'] === 'session_archived' ||
data['errorKind'] === 'session_conflict' ||
data['errorKind'] === 'session_archiving')
) {
return 409;
}
// JSON-RPC error code → HTTP status mapping.
// -32600 = invalid request → 400
// -32601 = method not found → 404

View file

@ -361,6 +361,7 @@ export type {
DaemonWorkspaceTrustState,
DaemonWorkspaceTrustStatus,
DaemonAvailableCommand,
DaemonArchiveSessionsResult,
DaemonCapabilities,
DaemonContextCategoryBreakdown,
DaemonContextFileScope,
@ -383,6 +384,7 @@ export type {
ForkSessionRequest,
DaemonRestoredSession,
DaemonSession,
DaemonSessionArchiveState,
DaemonAuthProviderId,
DaemonAuthProviderBaseUrlOption,
DaemonAuthProviderCatalog,
@ -455,6 +457,7 @@ export type {
DaemonWorkspaceToolsStatus,
DaemonWriteMemoryRequest,
DaemonWriteMemoryResult,
DaemonUnarchiveSessionsResult,
DaemonCommandHookConfig,
DaemonFunctionHookConfig,
DaemonHookConfig,

View file

@ -211,6 +211,23 @@ export interface DaemonSessionSummary {
displayName?: string;
clientCount?: number;
hasActivePrompt?: boolean;
isArchived?: boolean;
}
export type DaemonSessionArchiveState = 'active' | 'archived';
export interface DaemonArchiveSessionsResult {
archived: string[];
alreadyArchived: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: string }>;
}
export interface DaemonUnarchiveSessionsResult {
unarchived: string[];
alreadyActive: string[];
notFound: string[];
errors: Array<{ sessionId: string; error: string }>;
}
/** Effective mutable metadata returned from `PATCH /session/:id/metadata`. */

View file

@ -1680,6 +1680,47 @@ describe('DaemonClient', () => {
});
});
describe('archiveSessionsData / unarchiveSessionsData', () => {
it('POSTs to /sessions/archive with sessionIds in body and returns result', async () => {
const result = {
archived: ['s-1'],
alreadyArchived: ['s-2'],
notFound: [],
errors: [],
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, result));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const res = await client.archiveSessionsData(['s-1', 's-2']);
expect(res).toEqual(result);
expect(calls[0]?.url).toBe('http://daemon/sessions/archive');
expect(calls[0]?.method).toBe('POST');
expect(JSON.parse(calls[0]!.body!)).toEqual({
sessionIds: ['s-1', 's-2'],
});
});
it('POSTs to /sessions/unarchive with sessionIds in body and returns result', async () => {
const result = {
unarchived: ['s-1'],
alreadyActive: ['s-2'],
notFound: [],
errors: [],
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, result));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const res = await client.unarchiveSessionsData(
['s-1', 's-2'],
'client-1',
);
expect(res).toEqual(result);
expect(calls[0]?.url).toBe('http://daemon/sessions/unarchive');
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
expect(JSON.parse(calls[0]!.body!)).toEqual({
sessionIds: ['s-1', 's-2'],
});
});
});
describe('updateSessionMetadata', () => {
it('sends PATCH to /session/:id/metadata and returns effective metadata', async () => {
const { fetch, calls } = recordingFetch(() =>
@ -1843,6 +1884,28 @@ describe('DaemonClient', () => {
]);
});
it('passes archiveState when listing archived sessions', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
sessions: [
{
sessionId: 's-archived',
workspaceCwd: '/work/a',
isArchived: true,
},
],
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const sessions = await client.listWorkspaceSessions('/work/a', {
archiveState: 'archived',
});
expect(sessions[0]?.isArchived).toBe(true);
expect(calls[0]?.url).toBe(
'http://daemon/workspace/%2Fwork%2Fa/sessions?size=20&archiveState=archived',
);
});
it('throws on non-2xx (e.g. 400 from a relative path)', async () => {
const { fetch } = recordingFetch(() =>
jsonResponse(400, { error: 'must be absolute' }),

View file

@ -191,6 +191,24 @@ describe('acpRouteTable matchRoute', () => {
expect(result!.mapping.method).toBe('_qwen/health');
});
it('GET /workspace/:id/sessions maps to session/list', () => {
const result = matchRoute('/workspace/%2Fwork%2Fa/sessions', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/list');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
new URLSearchParams('size=50&archiveState=archived&cursor=123'),
);
expect(params).toEqual({
workspaceCwd: '/work/a',
cursor: '123',
archiveState: 'archived',
_meta: { size: 50 },
});
});
// ---- POST /session/:id/model → session/set_model --------------------
it('POST /session/:id/model maps to session/set_model', () => {
@ -663,6 +681,21 @@ describe('acpRouteTable matchRoute', () => {
expect(params).toEqual({ sessionIds: ['a', 'b'] });
});
it('POST /sessions/archive maps to _qwen/sessions/archive', () => {
const result = matchRoute('/sessions/archive', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/sessions/archive');
expect(
result!.mapping.extractParams([], { sessionIds: ['s-1'] }, 'POST'),
).toEqual({ sessionIds: ['s-1'] });
});
it('POST /sessions/unarchive maps to _qwen/sessions/unarchive', () => {
const result = matchRoute('/sessions/unarchive', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/sessions/unarchive');
});
// ---- Removed routes (no dispatcher handler) ----------------------------
it('returns null for removed route /session/:id/approval-mode', () => {

View file

@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { jsonRpcErrorToHttpStatusWithData } from '../../src/daemon/acpTransportUtils.js';
describe('jsonRpcErrorToHttpStatusWithData', () => {
it.each(['session_archived', 'session_conflict', 'session_archiving'])(
'maps %s to HTTP 409',
(errorKind) => {
expect(
jsonRpcErrorToHttpStatusWithData(-32603, {
errorKind,
sessionId: 's-1',
}),
).toBe(409);
},
);
});