fix(serve): Isolate daemon session maintenance writers (#7975)

* fix(serve): isolate daemon session maintenance writers

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

* codex: fix CI failure on PR #7975

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

* codex: address PR review feedback (#7975)

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

* codex: fix CI failure on PR #7975

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

* fix(cli): address review feedback on daemon session writer maintenance (#7975)

- Extract teardownBoundSession helper to deduplicate bound-session
  teardown in scheduled-tasks create handler
- Extract shared cleanupSession callback in createServeApp to eliminate
  three verbatim copies of the orphan-deletion wrapper
- Fire onError callback on the SessionNotFoundError deletion path in
  deleteDaemonSessions, matching the normal close-succeeded path
- Update stale @priority docstring on Storage.getRuntimeBaseDir()

* codex: address PR review feedback (#7975)

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

* test(cli): cover orphan-delete paths and draining guards (#7975)

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
This commit is contained in:
jinye 2026-07-30 22:01:50 +08:00 committed by GitHub
parent 079ce5346a
commit 3bdaeac046
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3069 additions and 1015 deletions

View file

@ -0,0 +1,116 @@
# Daemon Session Maintenance Writer Lease
## Problem
The daemon can delete, archive, or unarchive a persisted transcript after its
in-process ACP owner has closed. A different daemon process can still own the
same transcript, so the in-process archive coordinator alone does not prevent
the daemon from racing an external writer.
The transcript path and writer-lock path must also be resolved from the same
workspace runtime. Falling back to the primary daemon runtime can mutate one
workspace while checking a lock in another.
## Scope
This change covers daemon-owned maintenance:
- REST and ACP delete, archive, and unarchive requests
- disconnect and orphan cleanup
- scheduled-task rollback and keepalive cleanup
- daemon shutdown while maintenance is already running
It does not add lease expiry, heartbeat, hostname-based recovery, automatic
steal, force unlock, or a lock-schema migration. Writers that do not participate
in the lease protocol still require platform-level single-writer fencing.
## Runtime storage binding
Each `WorkspaceRuntime` resolves one absolute session runtime base directory at
creation. Resolution keeps the existing priority:
1. `QWEN_RUNTIME_DIR`
2. `advanced.runtimeOutputDir`, resolved relative to the workspace
3. the normal Qwen runtime directory
The resolved directory is stored on the runtime and injected as
`QWEN_RUNTIME_DIR` into every managed ACP child. Environment reload may update
other values but preserves this pinned value because changing
`runtimeOutputDir` requires a runtime restart.
Daemon parent operations that list, read, export, organize, or maintain
sessions run inside the selected runtime's storage context. Runtime resolution
failures do not fall back to the primary runtime.
## Lease API
`SessionService.acquireSessionWriterLease()` derives both the writer-lock root
and the active transcript path from the service's fixed `Storage` instance.
Callers provide only the session ID, process kind, version, and reclaim policy.
Invalid session IDs are rejected before the lock directory is touched.
Daemon maintenance always uses `processKind: 'daemon'` and
`reclaimPolicy: 'never'`. The existing lock schema, key, owner record, and
acquire/release protocol remain unchanged.
## Maintenance protocol
Every session is processed independently:
1. Enter the daemon's per-session exclusive archive coordinator.
2. Close the local owner. Archive requires agent close; delete uses the normal
fast close. A missing local owner is allowed.
3. Classify persisted state and preserve existing not-found and idempotent
results without creating a lock.
4. Acquire the daemon writer lease.
5. Reclassify while holding the lease.
6. Verify ownership and the transcript fingerprint, then perform one mutation.
7. Release the lease with owner-token verification.
Batch requests may process independent sessions concurrently, but a worker
holds at most one cross-process lease and never waits while holding multiple
leases.
A failed mutation remains the reported error when release succeeds. A release
or ownership failure is the externally safe error even if mutation also failed.
Logs record the workspace, session, action, error kind, and whether the
transcript mutation reached disk; they never include owner tokens or lock
paths. Scheduled-task reconciliation follows the actual transcript mutation,
not whether lease release subsequently succeeded.
Orphan cleanup first closes the local owner and respects
`requireZeroAttaches`. A newly attached owner therefore prevents deletion.
Late-spawn cleanup awaits close before acquiring the lease and deleting the
transcript.
## Shutdown
`SessionArchiveCoordinator.sealMaintenanceAndWait()` synchronously rejects new
exclusive maintenance and waits for exclusive operations already admitted.
Shared transcript reads are not included, so a long export does not consume the
termination budget. REST returns `503 daemon_draining`; ACP returns a JSON-RPC
server error with `data.errorKind = daemon_draining`.
Daemon shutdown seals maintenance before child/process teardown and completes
only after admitted maintenance leases have been released.
## Compatibility and rollout
Batch response shapes and existing archive/delete/unarchive idempotency
remain unchanged. Pre-check local `session_archiving` conflicts (raised by
`assertNotTransitioning` before admission) still surface as a request-level
`409`. Conflicts raised inside the admission gate are reported per session in
the `200` response body (`errors[]`) for archive, unarchive, and delete
alike. Mixed-version writers are unsafe, so deployment and rollback must
drain the old daemon and managed ACP processes before starting the new
version.
## Verification
Tests use real temporary runtime roots for writer contention and root
isolation, cover state changes between the initial and locked classifications,
and verify close, mutation, release, scheduled-task reconciliation, and
shutdown ordering. Unit tests also cover invalid IDs, duplicate IDs,
active/archive conflicts, lease release failures, orphan reattachment, and log
redaction. Relevant package tests, build, and typecheck are required before
merge.

View file

@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { DaemonDrainingError } from '../server/session-archive.js';
import { toRpcError } from './dispatch.js';
import { RPC } from './json-rpc.js';
describe('toRpcError', () => {
it('maps sealed maintenance to a JSON-RPC server error', () => {
expect(toRpcError(new DaemonDrainingError())).toEqual({
code: RPC.INTERNAL_ERROR,
message:
'The daemon is draining and no longer accepts session maintenance.',
data: { errorKind: 'daemon_draining' },
});
});
});

View file

@ -10,6 +10,7 @@ import {
BTW_MAX_INPUT_LENGTH,
createDebugLogger,
GROUP_COLOR_OPTIONS,
Storage,
SessionService,
SessionOrganizationError,
SESSION_WRITER_RPC_CODES,
@ -103,7 +104,9 @@ import { createSessionOrganizationService } from '../session-organization-helper
import {
archiveDaemonSessions,
assertSessionLoadable,
deleteDaemonSessionIfOrphan,
deleteDaemonSessions,
DaemonDrainingError,
logSessionArchiveWarning,
SessionArchiveCoordinator,
unarchiveDaemonSessions,
@ -561,11 +564,18 @@ function pickSessionArtifactInput(
* the operator-facing message is not a cross-tenant leak), and anything
* unrecognized collapses to a generic INTERNAL_ERROR string.
*/
function toRpcError(err: unknown): {
export function toRpcError(err: unknown): {
code: number;
message: string;
data?: Record<string, unknown>;
} {
if (err instanceof DaemonDrainingError) {
return {
code: RPC.INTERNAL_ERROR,
message: err.message,
data: { errorKind: 'daemon_draining' },
};
}
const writerError = sessionWriterRpcError(err);
if (writerError) return writerError;
if (err instanceof AcpParamError || err instanceof InvalidCursorError) {
@ -808,6 +818,7 @@ export class AcpDispatcher {
private readonly captureGenerationAssertion: () =>
| (() => void)
| undefined = () => undefined,
private readonly sessionRuntimeBaseDir: string = Storage.getRuntimeBaseDir(),
) {
this.agentManager = createDaemonSubagentManager(boundWorkspace);
}
@ -816,20 +827,21 @@ export class AcpDispatcher {
sessionId: string,
removePersistedSession = false,
): void {
void this.bridge
.killSession(sessionId, { requireZeroAttaches: true })
.then(async (killed) => {
if (killed && removePersistedSession) {
await new SessionService(this.boundWorkspace).removeSession(
sessionId,
);
}
})
.catch((err) =>
writeStderrLine(
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
),
);
const cleanup = removePersistedSession
? deleteDaemonSessionIfOrphan({
sessionId,
service: new SessionService(this.boundWorkspace, {
runtimeBaseDir: this.sessionRuntimeBaseDir,
}),
bridge: this.bridge,
coordinator: this.archiveCoordinator,
})
: this.bridge.killSession(sessionId, { requireZeroAttaches: true });
void cleanup.catch((err) =>
writeStderrLine(
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
),
);
}
/**
@ -1163,6 +1175,18 @@ export class AcpDispatcher {
msg: JsonRpcInbound,
sessionHeader?: string,
reqLoopback?: boolean,
): Promise<void> {
return Storage.runWithResolvedRuntimeBaseDir(
this.sessionRuntimeBaseDir,
() => this.handleInRuntime(conn, msg, sessionHeader, reqLoopback),
);
}
private async handleInRuntime(
conn: AcpConnection,
msg: JsonRpcInbound,
sessionHeader?: string,
reqLoopback?: boolean,
): Promise<void> {
// Loopback is evaluated PER REQUEST (the permission-vote POST may arrive
// from a different peer than `initialize`), falling back to the

View file

@ -10,7 +10,10 @@ import type { Duplex } from 'node:stream';
import type { Application, Request, Response } from 'express';
import { WebSocketServer, type WebSocket } from 'ws';
import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import { RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG } from '@qwen-code/qwen-code-core';
import {
RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG,
Storage,
} from '@qwen-code/qwen-code-core';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import type { DaemonWorkspaceService } from '../workspace-service/types.js';
import type { WorkspaceFileSystemFactory } from '../fs/index.js';
@ -792,6 +795,8 @@ export function mountAcpHttp(
const guard = opts.workspaceRegistry?.primaryEntry.current?.guard;
return guard ? () => guard.assertOpen() : undefined;
},
opts.workspaceRegistry?.primary.sessionRuntimeBaseDir ??
Storage.getRuntimeBaseDir(),
);
dispatcherRef.current = dispatcher;
@ -1271,6 +1276,7 @@ export function mountAcpHttp(
const guard = rt.generationGuard;
return guard ? () => guard.assertOpen() : undefined;
},
rt.sessionRuntimeBaseDir,
);
secondaryDispatcherRef.current = secondaryDispatcher;
return {

View file

@ -174,6 +174,7 @@ class FakeBridge {
gate: Promise<void> | undefined;
/** `attached` value loadSession returns (false = spawned-from-disk). */
loadAttached = true;
spawnSessionId = 'sess-1';
spawnClientId: string | undefined = 'client-1';
loadRequests: Array<{
sessionId: string;
@ -191,7 +192,7 @@ class FakeBridge {
this.lastSpawnScope = req?.sessionScope;
if (this.gate) await this.gate;
return {
sessionId: 'sess-1',
sessionId: this.spawnSessionId,
workspaceCwd: '/ws',
attached: false,
clientId: this.spawnClientId,
@ -839,8 +840,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
let base: string;
let bridge: FakeBridge;
let acpHandle: AcpHttpHandle | undefined;
let previousRuntimeDir: string | undefined;
let runtimeDir: string;
beforeEach(async () => {
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-archive-'));
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
stdioMocks.writeStderrLine.mockClear();
setupGithubMocks.setupGithub.mockReset();
setupGithubMocks.setupGithub.mockResolvedValue({
@ -902,6 +908,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
// `server.close()` doesn't hang on them.
server.closeAllConnections?.();
await new Promise<void>((r) => server.close(() => r()));
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 restartServer(opts: {
@ -923,6 +935,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
? createSingleWorkspaceRegistry({
workspaceId: 'primary',
workspaceCwd: boundWorkspace,
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
primary: true,
trusted: opts.primaryTrusted ?? true,
env: { mode: 'parent-process', overlayKeys: [] },
@ -1019,21 +1032,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
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 });
}
return fn(runtimeDir);
}
async function writeStoredSession(
@ -3738,13 +3737,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
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 {
await withRuntimeDir(async () => {
const chatsDir = path.join(
new Storage('/ws').getProjectDir(),
'chats',
@ -3783,14 +3777,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
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 });
}
});
},
);
@ -3861,7 +3848,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it('session/load holds archive gate while restore is in flight', async () => {
it('session/load reports an archive conflict while restore is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440124';
await writeStoredSession(sessionId);
@ -3904,9 +3891,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
expect(await reader.next()).toMatchObject({
id: 213,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
result: {
archived: [],
errors: [
{
sessionId,
error: expect.stringContaining('is being archived or unarchived'),
},
],
},
});
@ -3974,7 +3966,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
},
);
it('session/prompt holds archive gate while prompt is in flight', async () => {
it('session/prompt reports an archive conflict while prompt is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
await writeStoredSession(sessionId);
@ -4024,9 +4016,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
expect(await connReader.next()).toMatchObject({
id: 219,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
result: {
archived: [],
errors: [
{
sessionId,
error: expect.stringContaining('is being archived or unarchived'),
},
],
},
});
expect(bridge.closedSessions).toEqual([]);
@ -4881,6 +4878,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
it('session/new orphan: DELETE before spawn resolves removes the persisted session', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440126';
bridge.spawnSessionId = sessionId;
await writeStoredSession(sessionId);
const removeSession = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockResolvedValue(true);
@ -4900,8 +4900,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
release(); // spawn resolves AFTER destroy
await new Promise((r) => setTimeout(r, 40));
expect(bridge.killed).toContain('sess-1');
expect(removeSession).toHaveBeenCalledWith('sess-1');
expect(bridge.killed).toContain(sessionId);
expect(removeSession).toHaveBeenCalledWith(sessionId);
removeSession.mockRestore();
});
@ -6607,7 +6607,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
it('_qwen/session/artifacts/add reports an archive conflict while mutating', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
await writeStoredSession(sessionId);
@ -6657,9 +6657,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
expect(await reader.next()).toMatchObject({
id: 61,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
result: {
archived: [],
errors: [
{
sessionId,
error: expect.stringContaining(
'is being archived or unarchived',
),
},
],
},
});
@ -6672,7 +6679,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it('_qwen/session/artifacts/remove holds the archive gate while mutating', async () => {
it('_qwen/session/artifacts/remove reports an archive conflict while mutating', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440132';
await writeStoredSession(sessionId);
@ -6730,9 +6737,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
expect(await reader.next()).toMatchObject({
id: 63,
error: {
code: -32603,
data: { errorKind: 'session_archiving', sessionId },
result: {
archived: [],
errors: [
{
sessionId,
error: expect.stringContaining(
'is being archived or unarchived',
),
},
],
},
});
@ -7554,46 +7568,47 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
it('_qwen/sessions/delete sanitizes stderr remove errors', async () => {
const lineSep = '\u2028';
const bidiOverride = '\u202e';
const sessionId = `sess${lineSep}FAKE\r\x1b[31m`;
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`;
const removeSessionSpy = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockRejectedValueOnce(new Error(removeError));
await withRuntimeDir(async () => {
await writeStoredSession(sessionId);
const removeSessionSpy = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockRejectedValueOnce(new Error(removeError));
try {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 68,
method: '_qwen/sessions/delete',
params: { sessionIds: [sessionId] },
});
const frames = await takeFrames(await streamRes, 1);
expect(frames[0]).toMatchObject({
result: {
removed: [],
notFound: [],
errors: [{ sessionId, error: removeError }],
},
});
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
try {
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 68,
method: '_qwen/sessions/delete',
params: { sessionIds: [sessionId] },
});
const frames = await takeFrames(await streamRes, 1);
expect(frames[0]).toMatchObject({
result: {
removed: [],
notFound: [],
errors: [{ sessionId, error: removeError }],
},
});
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
const deleteLog = stdioMocks.writeStderrLine.mock.calls
.map(([line]) => line)
.find((line) => line.includes('sessions/delete'));
expect(deleteLog).toContain(
'removeSession(sess FAK) failed: remove FAILED [31m',
);
expect(deleteLog).not.toContain('\n');
expect(deleteLog).not.toContain('\r');
expect(deleteLog).not.toContain('\x1b');
expect(deleteLog).not.toContain(lineSep);
expect(deleteLog).not.toContain(bidiOverride);
} finally {
removeSessionSpy.mockRestore();
}
const deleteLog = stdioMocks.writeStderrLine.mock.calls
.map(([line]) => line)
.find((line) => line.includes('sessions/delete'));
expect(deleteLog).toContain('remove FAILED [31m');
expect(deleteLog).not.toContain('\n');
expect(deleteLog).not.toContain('\r');
expect(deleteLog).not.toContain('\x1b');
expect(deleteLog).not.toContain(lineSep);
expect(deleteLog).not.toContain(bidiOverride);
} finally {
removeSessionSpy.mockRestore();
}
});
});
it('_qwen/sessions/delete deletes available ids when another id is loading', async () => {
@ -7660,7 +7675,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => {
it('_qwen/sessions/archive returns session_archiving while delete owns the gate', async () => {
const sessionId = 'delete-archive-race';
let firstCloseStarted!: () => void;
let releaseFirstClose!: () => void;
@ -7721,7 +7736,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
}),
expect.objectContaining({
id: 70,
result: expect.objectContaining({ notFound: [sessionId] }),
error: expect.objectContaining({
data: {
errorKind: 'session_archiving',
sessionId,
},
}),
}),
]),
);

View file

@ -78,6 +78,7 @@ function makeRuntime(input: {
return {
workspaceId: input.id,
workspaceCwd: input.cwd,
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
primary: input.primary,
trusted: input.trusted,
env: input.env ?? PARENT_ENV,
@ -115,24 +116,6 @@ async function writeStoredSession(sessionId: string, cwd: string) {
);
}
async function withRuntimeDir<T>(fn: () => Promise<T>): Promise<T> {
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
const runtimeDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
);
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
try {
return await fn();
} finally {
if (previousRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
}
await fsp.rm(runtimeDir, { recursive: true, force: true });
}
}
describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
let server: Server;
let base: string;
@ -146,8 +129,15 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
let workspaceRegistry: ReturnType<typeof createWorkspaceRegistry>;
let secondaryRuntime: WorkspaceRuntime;
let workspaceVoiceConnection: ReturnType<typeof vi.fn>;
let runtimeDir: string;
let previousRuntimeDir: string | undefined;
beforeEach(async () => {
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
runtimeDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
);
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
setupGithubMock.mockReset();
setupGithubMock.mockImplementation(async ({ cwd }: { cwd: string }) => ({
kind: 'github_setup',
@ -247,6 +237,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
deviceFlowRegistry?.dispose();
server.closeAllConnections?.();
await new Promise<void>((r) => server.close(() => r()));
if (previousRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
}
await fsp.rm(runtimeDir, { recursive: true, force: true });
});
async function postInitialize(pathname: string): Promise<Response> {
@ -571,45 +567,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
});
it('updates persisted organization in the selected workspace only', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
await writeStoredSession(sessionId, '/ws-b');
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
await writeStoredSession(sessionId, '/ws-b');
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
jsonrpc: '2.0',
id: 2,
method: '_qwen/session/update_organization',
params: { sessionId, isPinned: true },
});
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
jsonrpc: '2.0',
id: 3,
method: 'session/list',
params: { view: 'organized', group: 'pinned' },
});
expect(listed['result']).toMatchObject({
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
});
const legacy = await sendWsRequest('/acp', {
jsonrpc: '2.0',
id: 4,
method: '_qwen/session/update_organization',
params: { sessionId, isPinned: false },
});
expect(legacy['error']).toMatchObject({ code: -32602 });
const secondarySnapshot =
await createSessionOrganizationService('/ws-b').readSnapshot();
const primarySnapshot =
await createSessionOrganizationService('/ws').readSnapshot();
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
isPinned: true,
});
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
jsonrpc: '2.0',
id: 2,
method: '_qwen/session/update_organization',
params: { sessionId, isPinned: true },
});
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
jsonrpc: '2.0',
id: 3,
method: 'session/list',
params: { view: 'organized', group: 'pinned' },
});
expect(listed['result']).toMatchObject({
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
});
const legacy = await sendWsRequest('/acp', {
jsonrpc: '2.0',
id: 4,
method: '_qwen/session/update_organization',
params: { sessionId, isPinned: false },
});
expect(legacy['error']).toMatchObject({ code: -32602 });
const secondarySnapshot =
await createSessionOrganizationService('/ws-b').readSnapshot();
const primarySnapshot =
await createSessionOrganizationService('/ws').readSnapshot();
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
isPinned: true,
});
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
});
it('rejects an untrusted workspace with 403 untrusted_workspace', async () => {

View file

@ -703,9 +703,12 @@ function makeRuntime(input: {
primary: boolean;
trusted: boolean;
bridge: AcpSessionBridge;
sessionRuntimeBaseDir?: string;
}): WorkspaceRuntime {
return {
...input,
sessionRuntimeBaseDir:
input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
workspaceService: {} as DaemonWorkspaceService,
routeFileSystemFactory: {
@ -747,6 +750,8 @@ function makeHarness(opts?: {
secondaryRewindImpl?: AcpSessionBridge['rewindSession'];
secondaryShellImpl?: AcpSessionBridge['executeShellCommand'];
serveOptions?: Partial<ServeOptions>;
primaryRuntimeBaseDir?: string;
secondaryRuntimeBaseDir?: string;
}) {
const primaryBridge = makeBridge(
PRIMARY_CWD,
@ -775,6 +780,9 @@ function makeHarness(opts?: {
primary: true,
trusted: opts?.primaryTrusted ?? true,
bridge: primaryBridge,
...(opts?.primaryRuntimeBaseDir
? { sessionRuntimeBaseDir: opts.primaryRuntimeBaseDir }
: {}),
}),
makeRuntime({
workspaceId: 'secondary-id',
@ -783,6 +791,9 @@ function makeHarness(opts?: {
primary: false,
trusted: opts?.secondaryTrusted ?? true,
bridge: secondaryBridge,
...(opts?.secondaryRuntimeBaseDir
? { sessionRuntimeBaseDir: opts.secondaryRuntimeBaseDir }
: {}),
}),
]);
const app = createServeApp(
@ -3631,7 +3642,7 @@ describe('multi-workspace session dispatch', () => {
});
});
it('keeps archive and delete blocked while a workspace export is in flight', async () => {
it('reports archive and delete conflicts while a workspace export is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440283';
await writeStoredSession({
@ -3672,10 +3683,15 @@ describe('multi-workspace session dispatch', () => {
.post('/workspaces/secondary-id/sessions/archive')
.set('Host', host())
.send({ sessionIds: [sessionId] });
expect(archive.status).toBe(409);
expect(archive.status).toBe(200);
expect(archive.body).toMatchObject({
code: 'session_archiving',
sessionId,
archived: [],
errors: [
{
sessionId,
error: expect.stringContaining('is being archived or unarchived'),
},
],
});
const remove = await request(app)
@ -3884,7 +3900,7 @@ describe('multi-workspace session dispatch', () => {
});
});
it('keeps unarchive and delete blocked while archived export is in flight', async () => {
it('reports unarchive and delete conflicts while archived export is in flight', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440289';
await writeStoredSession({
@ -3926,8 +3942,16 @@ describe('multi-workspace session dispatch', () => {
.post('/workspaces/secondary-id/sessions/unarchive')
.set('Host', host())
.send({ sessionIds: [sessionId] });
expect(unarchive.status).toBe(409);
expect(unarchive.body.code).toBe('session_archiving');
expect(unarchive.status).toBe(200);
expect(unarchive.body).toMatchObject({
unarchived: [],
errors: [
{
sessionId,
error: expect.stringContaining('is being archived or unarchived'),
},
],
});
const remove = await request(app)
.post('/workspaces/secondary-id/sessions/delete')
@ -4604,6 +4628,75 @@ describe('multi-workspace session dispatch', () => {
});
});
it('keeps secondary maintenance inside its fixed runtime root', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
const runtimeRoot = Storage.getRuntimeBaseDir();
const primaryRuntimeBaseDir = path.join(runtimeRoot, 'primary-runtime');
const secondaryRuntimeBaseDir = path.join(
runtimeRoot,
'secondary-runtime',
);
await Storage.runWithResolvedRuntimeBaseDir(primaryRuntimeBaseDir, () =>
writeStoredSession({
sessionId,
cwd: PRIMARY_CWD,
timestamp: '2026-07-08T00:14:00.000Z',
prompt: 'primary fixed-root target',
mtime: new Date('2026-07-08T00:14:00.000Z'),
}),
);
await Storage.runWithResolvedRuntimeBaseDir(secondaryRuntimeBaseDir, () =>
writeStoredSession({
sessionId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T00:15:00.000Z',
prompt: 'secondary fixed-root target',
mtime: new Date('2026-07-08T00:15:00.000Z'),
}),
);
const primaryService = new SessionService(PRIMARY_CWD, {
runtimeBaseDir: primaryRuntimeBaseDir,
});
const primaryLease = await primaryService.acquireSessionWriterLease(
sessionId,
{
processKind: 'daemon',
reclaimPolicy: 'never',
},
);
try {
const { app } = makeHarness({
primaryRuntimeBaseDir,
secondaryRuntimeBaseDir,
primarySummaries: [],
secondarySummaries: [],
});
const archived = await request(app)
.post('/workspaces/secondary-id/sessions/archive')
.set('Host', host())
.send({ sessionIds: [sessionId] })
.expect(200);
expect(archived.body).toMatchObject({
archived: [sessionId],
errors: [],
});
await expect(
primaryService.getSessionLocation(sessionId),
).resolves.toBe('active');
await expect(
new SessionService(SECONDARY_CWD, {
runtimeBaseDir: secondaryRuntimeBaseDir,
}).getSessionLocation(sessionId),
).resolves.toBe('archived');
} finally {
await primaryLease.release();
}
});
});
it('routes plural session group CRUD to the selected workspace', async () => {
await withRuntimeDir(async () => {
const { app } = makeHarness();

View file

@ -96,6 +96,7 @@ interface Harness {
scratch: string;
workspace: string;
bridge: StubBridge;
cleanupSession: ReturnType<typeof vi.fn>;
channelDeliveryAuthorizations: ChannelDeliveryAuthorizationStore;
}
@ -119,11 +120,20 @@ async function makeHarness(
({
workspaceId: 'primary',
workspaceCwd: workspace,
sessionRuntimeBaseDir: scratch,
primary: true,
trusted: runtimeTrusted,
bridge,
generationGuard,
}) as unknown as WorkspaceRuntime;
const cleanupSession = vi.fn(
async (_runtime: WorkspaceRuntime, sessionId: string) => {
await bridge.closeSession(sessionId);
await new SessionService(workspace, {
runtimeBaseDir: scratch,
}).removeSession(sessionId);
},
);
const app = express();
app.use(express.json());
registerScheduledTasksRoutes(app, {
@ -133,13 +143,14 @@ async function makeHarness(
safeBody,
bridge,
channelDeliveryAuthorizations,
...(getRuntime ? { getRuntime } : {}),
...(getRuntime ? { getRuntime, cleanupSession } : {}),
});
return {
app,
scratch,
workspace,
bridge,
cleanupSession,
channelDeliveryAuthorizations,
};
}
@ -226,6 +237,10 @@ describe('scheduled-tasks routes', () => {
expect(res.status).toBe(503);
expect(res.body.code).toBe('workspace_runtime_unavailable');
expect(h.cleanupSession).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: h.workspace }),
'sess-1',
);
expect(h.bridge.closed).toEqual(['sess-1']);
await expect(
fsp.readFile(getCronFilePath(h.workspace), 'utf8'),
@ -1778,6 +1793,7 @@ describe('scheduledTaskSessionName', () => {
interface QualifiedRuntime {
workspaceId: string;
workspaceCwd: string;
sessionRuntimeBaseDir: string;
trusted: boolean;
bridge: StubBridge;
}
@ -1846,6 +1862,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
return {
workspaceId: `id-${name}`,
workspaceCwd,
sessionRuntimeBaseDir: path.join(scratch, `runtime-${name}`),
trusted,
bridge: makeStubBridge(),
};
@ -1866,6 +1883,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
mutate: () => (_req, _res, next) => next(),
safeBody,
bridge: primary.bridge,
getRuntime: () => primary as unknown as WorkspaceRuntime,
});
registerWorkspaceQualifiedScheduledTasksRoutes(app, {
workspaceRegistry: makeStubRegistry(runtimes),
@ -1888,6 +1906,10 @@ describe('workspace-qualified scheduled-tasks routes', () => {
});
const qualified = (id: string) => `/workspaces/${id}/scheduled-tasks`;
const cronFilePath = (runtime: QualifiedRuntime) =>
Storage.runWithResolvedRuntimeBaseDir(runtime.sessionRuntimeBaseDir, () =>
getCronFilePath(runtime.workspaceCwd),
);
it('creates a task in the targeted workspace, isolated from the primary', async () => {
const res = await request(h.app)
@ -1913,12 +1935,15 @@ describe('workspace-qualified scheduled-tasks routes', () => {
.post(qualified(h.secondary.workspaceId))
.send({ cron: '0 9 * * *', prompt: 'p' });
const onDisk = JSON.parse(
await fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
await fsp.readFile(cronFilePath(h.secondary), 'utf-8'),
);
expect(onDisk).toHaveLength(1);
// The primary's file was never created.
// Neither the primary runtime nor the process-global fallback was touched.
await expect(
fsp.readFile(getCronFilePath(h.primary.workspaceCwd), 'utf-8'),
fsp.readFile(cronFilePath(h.primary), 'utf-8'),
).rejects.toThrow();
await expect(
fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
).rejects.toThrow();
});

View file

@ -42,6 +42,7 @@ import {
nextFireTime,
nextDurableFireMs,
SessionService,
Storage,
stripTerminalControlSequences,
MAX_JOBS,
type CronTaskDelivery,
@ -136,7 +137,9 @@ export function scheduledTaskSessionName(label: string): string {
*/
interface ScheduledTaskTarget {
workspaceCwd: string;
runtimeBaseDir?: string;
bridge?: ScheduledTasksSessionBridge;
cleanupSession?: (sessionId: string) => Promise<unknown>;
assertGenerationOpen?: () => void;
}
@ -154,14 +157,16 @@ function requireOpenGeneration(
}
async function rollbackCronMutation(
workspaceCwd: string,
target: ScheduledTaskTarget,
before: DurableCronTask[] | undefined,
after: DurableCronTask[] | undefined,
route: string,
): Promise<void> {
if (!before || !after) return;
await updateCronTasks(workspaceCwd, (tasks) =>
isDeepStrictEqual(tasks, after) ? before : tasks,
await runWithScheduledTaskTarget(target, () =>
updateCronTasks(target.workspaceCwd, (tasks) =>
isDeepStrictEqual(tasks, after) ? before : tasks,
),
).catch((error) => {
writeStderrLine(
`qwen serve: ${route} failed to roll back a stale task mutation: ${error instanceof Error ? error.message : String(error)}`,
@ -169,6 +174,22 @@ async function rollbackCronMutation(
});
}
async function teardownBoundSession(
target: ScheduledTaskTarget,
sessionId: string,
): Promise<void> {
if (target.cleanupSession) {
await target.cleanupSession(sessionId).catch(() => {});
} else if (target.bridge) {
await target.bridge.closeSession(sessionId).catch(() => {});
await new SessionService(target.workspaceCwd, {
runtimeBaseDir: target.runtimeBaseDir,
})
.removeSession(sessionId)
.catch(() => {});
}
}
/**
* Resolves the target workspace for one request. Returns null when it can't be
* resolved (unknown or untrusted `:workspace`), in which case the resolver has
@ -201,6 +222,10 @@ interface RegisterScheduledTasksRoutesDeps {
bridge?: ScheduledTasksSessionBridge;
channelDeliveryAuthorizations?: ChannelDeliveryAuthorizationStore;
getRuntime?: () => WorkspaceRuntime | undefined;
cleanupSession?: (
runtime: WorkspaceRuntime,
sessionId: string,
) => Promise<unknown>;
}
interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
@ -217,6 +242,20 @@ interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
* revives. Off tasks are created unbound (shared-owner firing).
*/
manageScheduledTaskSessions: boolean;
cleanupSession?: (
runtime: WorkspaceRuntime,
sessionId: string,
) => Promise<unknown>;
}
function runWithScheduledTaskTarget<T>(
target: ScheduledTaskTarget,
fn: () => T,
): T {
if (target.runtimeBaseDir === undefined) {
return fn();
}
return Storage.runWithResolvedRuntimeBaseDir(target.runtimeBaseDir, fn);
}
/** On-the-wire task shape normalizes the optional on-disk fields so the
@ -340,7 +379,9 @@ function registerScheduledTaskCrudRoutes(
if (!target) return;
if (!requireOpenGeneration(target, res)) return;
try {
const tasks = await readCronTasks(target.workspaceCwd);
const tasks = await runWithScheduledTaskTarget(target, () =>
readCronTasks(target.workspaceCwd),
);
if (!requireOpenGeneration(target, res)) return;
res.status(200).json({ v: 1, tasks: tasks.map(toView) });
} catch (err) {
@ -465,7 +506,13 @@ function registerScheduledTaskCrudRoutes(
// an orphan with no owning task. Best-effort — the write-lock cap check
// below stays authoritative for the concurrent-create race.
try {
if ((await readCronTasks(workspaceCwd)).length >= MAX_SCHEDULED_TASKS) {
if (
(
await runWithScheduledTaskTarget(target, () =>
readCronTasks(workspaceCwd),
)
).length >= MAX_SCHEDULED_TASKS
) {
res.status(409).json({
error: `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`,
code: 'max_tasks_reached',
@ -485,10 +532,7 @@ function registerScheduledTaskCrudRoutes(
});
boundSessionId = session.sessionId;
if (!requireOpenGeneration(target, res)) {
await bridge.closeSession(boundSessionId).catch(() => {});
await new SessionService(workspaceCwd)
.removeSession(boundSessionId)
.catch(() => {});
await teardownBoundSession(target, boundSessionId);
return;
}
// Name the session after the task so it's recognizable in the session
@ -536,11 +580,8 @@ function registerScheduledTaskCrudRoutes(
// which passes the pre-check but loses the authoritative write) would leave
// a named "⏰ …" session in the list with no owning task.
const rollbackSession = async () => {
if (boundSessionId !== undefined && bridge) {
await bridge.closeSession(boundSessionId).catch(() => {});
await new SessionService(workspaceCwd)
.removeSession(boundSessionId)
.catch(() => {});
if (boundSessionId !== undefined) {
await teardownBoundSession(target, boundSessionId);
}
};
@ -548,21 +589,23 @@ function registerScheduledTaskCrudRoutes(
let rollbackBefore: DurableCronTask[] | undefined;
let rollbackAfter: DurableCronTask[] | undefined;
try {
await updateCronTasks(
workspaceCwd,
(tasks) => {
// Cap check under the write lock so two concurrent creates can't both
// slip past a stale count. Returning the input unchanged is a no-op
// (no write), which the flag below turns into a 409.
if (tasks.length >= MAX_SCHEDULED_TASKS) {
overCap = true;
return tasks;
}
rollbackBefore = tasks;
rollbackAfter = [...tasks, task];
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
await runWithScheduledTaskTarget(target, () =>
updateCronTasks(
workspaceCwd,
(tasks) => {
// Cap check under the write lock so two concurrent creates can't both
// slip past a stale count. Returning the input unchanged is a no-op
// (no write), which the flag below turns into a 409.
if (tasks.length >= MAX_SCHEDULED_TASKS) {
overCap = true;
return tasks;
}
rollbackBefore = tasks;
rollbackAfter = [...tasks, task];
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
),
);
} catch (err) {
await rollbackSession();
@ -581,7 +624,7 @@ function registerScheduledTaskCrudRoutes(
target.assertGenerationOpen?.();
} catch (error) {
await rollbackCronMutation(
workspaceCwd,
target,
rollbackBefore,
rollbackAfter,
`POST ${base}`,
@ -727,90 +770,92 @@ function registerScheduledTaskCrudRoutes(
let rollbackBefore: DurableCronTask[] | undefined;
let rollbackAfter: DurableCronTask[] | undefined;
try {
await updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
found = true;
const current = tasks[idx]!;
// A legacy guarded task (isolated + precondition, both removed) can't be
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
// sends for it is the Enable toggle — which would 200 here and then read
// back disabled again, an Enable control that can never succeed with no
// error explaining why. Reject the enable with the recreate remediation
// instead of acknowledging an update that changes nothing runnable.
if (patch.enabled === true && taskHasLegacyCondition(current)) {
blockedLegacy = true;
return tasks; // no write
}
// A task disabled BY archiving its session (`disabledByArchive`) can't
// be re-enabled through this generic PATCH: its bound session is still
// archived and can't fire, so flipping `enabled: true` here would show
// an enabled task with a countdown that never runs. The task/session
// lifecycle must stay coupled — the caller has to unarchive the session
// (which clears the marker and reloads it). Reject and leave the file
// untouched.
if (patch.enabled === true && current.disabledByArchive === true) {
blockedByArchive = true;
return tasks; // no write
}
const next: DurableCronTask = { ...current, ...patch };
// `name: null/""` clears the field rather than storing an empty name,
// so toView reports it as unnamed and isValidTask never sees a "".
if (clearName) delete next.name;
if (clearDelivery) delete next.delivery;
// Re-seat the task's schedule anchor to "now" whenever an edit would
// otherwise let the scheduler retroactively fire an already-past slot.
const justReEnabled =
current.enabled === false && patch.enabled === true;
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
// and drop a legitimately-pending catch-up fire.
const cronChanged =
patch.cron !== undefined &&
canonicalCron(patch.cron) !== canonicalCron(current.cron);
const becameRecurring =
patch.recurring === true && current.recurring !== true;
const becameOneShot =
patch.recurring === false && current.recurring !== false;
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
// is paused must not leave a stale anchor that fires retroactively when
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
// schedule change of its own to trigger the re-seat). Re-seating a paused
// task's anchor is harmless — it doesn't fire until enabled.
{
const now = Date.now();
const minute = now - (now % 60_000);
if (
next.recurring &&
(justReEnabled || cronChanged || becameRecurring)
) {
// A recurring task's anchor is lastFiredAt: resume from now so a
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
// fire a past slot (matters most for a bound task, whose catch-up runs
// on every file-watch reload).
next.lastFiredAt = minute;
} else if (
!next.recurring &&
(justReEnabled || cronChanged || becameOneShot)
) {
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
// at its NEXT occurrence — otherwise the scheduler reads its original
// long-past slot as a MISSED one-shot and fires + permanently deletes
// it. A one-shot disabled past its slot then re-enabled would
// otherwise be silently destroyed on the next reload.
next.createdAt = now;
next.lastFiredAt = minute;
await runWithScheduledTaskTarget(target, () =>
updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
found = true;
const current = tasks[idx]!;
// A legacy guarded task (isolated + precondition, both removed) can't be
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
// sends for it is the Enable toggle — which would 200 here and then read
// back disabled again, an Enable control that can never succeed with no
// error explaining why. Reject the enable with the recreate remediation
// instead of acknowledging an update that changes nothing runnable.
if (patch.enabled === true && taskHasLegacyCondition(current)) {
blockedLegacy = true;
return tasks; // no write
}
}
updated = next;
rollbackBefore = tasks;
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
// A task disabled BY archiving its session (`disabledByArchive`) can't
// be re-enabled through this generic PATCH: its bound session is still
// archived and can't fire, so flipping `enabled: true` here would show
// an enabled task with a countdown that never runs. The task/session
// lifecycle must stay coupled — the caller has to unarchive the session
// (which clears the marker and reloads it). Reject and leave the file
// untouched.
if (patch.enabled === true && current.disabledByArchive === true) {
blockedByArchive = true;
return tasks; // no write
}
const next: DurableCronTask = { ...current, ...patch };
// `name: null/""` clears the field rather than storing an empty name,
// so toView reports it as unnamed and isValidTask never sees a "".
if (clearName) delete next.name;
if (clearDelivery) delete next.delivery;
// Re-seat the task's schedule anchor to "now" whenever an edit would
// otherwise let the scheduler retroactively fire an already-past slot.
const justReEnabled =
current.enabled === false && patch.enabled === true;
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
// and drop a legitimately-pending catch-up fire.
const cronChanged =
patch.cron !== undefined &&
canonicalCron(patch.cron) !== canonicalCron(current.cron);
const becameRecurring =
patch.recurring === true && current.recurring !== true;
const becameOneShot =
patch.recurring === false && current.recurring !== false;
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
// is paused must not leave a stale anchor that fires retroactively when
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
// schedule change of its own to trigger the re-seat). Re-seating a paused
// task's anchor is harmless — it doesn't fire until enabled.
{
const now = Date.now();
const minute = now - (now % 60_000);
if (
next.recurring &&
(justReEnabled || cronChanged || becameRecurring)
) {
// A recurring task's anchor is lastFiredAt: resume from now so a
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
// fire a past slot (matters most for a bound task, whose catch-up runs
// on every file-watch reload).
next.lastFiredAt = minute;
} else if (
!next.recurring &&
(justReEnabled || cronChanged || becameOneShot)
) {
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
// at its NEXT occurrence — otherwise the scheduler reads its original
// long-past slot as a MISSED one-shot and fires + permanently deletes
// it. A one-shot disabled past its slot then re-enabled would
// otherwise be silently destroyed on the next reload.
next.createdAt = now;
next.lastFiredAt = minute;
}
}
updated = next;
rollbackBefore = tasks;
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
),
);
} catch (err) {
if (sendGenerationClosedError(res, err)) return;
@ -828,7 +873,7 @@ function registerScheduledTaskCrudRoutes(
target.assertGenerationOpen?.();
} catch (error) {
await rollbackCronMutation(
workspaceCwd,
target,
rollbackBefore,
rollbackAfter,
`PATCH ${base}/${id}`,
@ -917,21 +962,23 @@ function registerScheduledTaskCrudRoutes(
let rollbackBefore: DurableCronTask[] | undefined;
let rollbackAfter: DurableCronTask[] | undefined;
try {
await updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
const match = tasks[idx]!.sessionId;
if (typeof match === 'string' && match.length > 0) {
boundSessionId = match;
}
removed = true;
rollbackBefore = tasks;
rollbackAfter = tasks.filter((_, i) => i !== idx);
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
await runWithScheduledTaskTarget(target, () =>
updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
const match = tasks[idx]!.sessionId;
if (typeof match === 'string' && match.length > 0) {
boundSessionId = match;
}
removed = true;
rollbackBefore = tasks;
rollbackAfter = tasks.filter((_, i) => i !== idx);
return rollbackAfter;
},
{ assertCanCommit: target.assertGenerationOpen },
),
);
} catch (err) {
if (sendGenerationClosedError(res, err)) return;
@ -949,7 +996,7 @@ function registerScheduledTaskCrudRoutes(
target.assertGenerationOpen?.();
} catch (error) {
await rollbackCronMutation(
workspaceCwd,
target,
rollbackBefore,
rollbackAfter,
`DELETE ${base}/${id}`,
@ -1007,54 +1054,56 @@ function registerScheduledTaskCrudRoutes(
let rollbackBefore: DurableCronTask[] | undefined;
let rollbackAfter: DurableCronTask[] | undefined;
try {
await updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
found = true;
const current = tasks[idx]!;
// A legacy guarded task (isolated + precondition, both removed) must not
// run from ANY path. The scheduler already skips it and the list view
// reports it disabled; reject a direct `/run` too — its on-disk
// `enabled` may still be true, so the disabled check below is not enough.
// Executing it here would run the prompt with its safety gate ignored,
// which is exactly what the removal must never allow.
if (taskHasLegacyCondition(current)) {
blockedLegacy = true;
return tasks; // no write
}
// A disabled task must not record a manual run: it's paused (and if it
// was disabled by archiving its session, that session can't even fire),
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
// the UI, where onRunPrompt already rejects before recording.
if (current.enabled === false) {
blockedDisabled = true;
return tasks; // no write
}
const next: DurableCronTask = {
...current,
lastFiredAt: now,
runs: appendCronRun(current.runs, {
at: now,
kind: 'manual',
...(current.sessionId ? { sessionId: current.sessionId } : {}),
}),
};
updated = next;
// A one-shot's manual run IS its single fire — remove it from the store
// so the scheduler doesn't ALSO fire it at its original scheduled time
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
// stop that fire). The response still returns the recorded run.
rollbackBefore = tasks;
const nextTasks = !current.recurring
? tasks.filter((_, i) => i !== idx)
: tasks.map((t, i) => (i === idx ? next : t));
rollbackAfter = nextTasks;
return nextTasks;
},
{ assertCanCommit: target.assertGenerationOpen },
await runWithScheduledTaskTarget(target, () =>
updateCronTasks(
workspaceCwd,
(tasks) => {
const idx = tasks.findIndex((t) => t.id === id);
if (idx === -1) return tasks; // not found → no write
found = true;
const current = tasks[idx]!;
// A legacy guarded task (isolated + precondition, both removed) must not
// run from ANY path. The scheduler already skips it and the list view
// reports it disabled; reject a direct `/run` too — its on-disk
// `enabled` may still be true, so the disabled check below is not enough.
// Executing it here would run the prompt with its safety gate ignored,
// which is exactly what the removal must never allow.
if (taskHasLegacyCondition(current)) {
blockedLegacy = true;
return tasks; // no write
}
// A disabled task must not record a manual run: it's paused (and if it
// was disabled by archiving its session, that session can't even fire),
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
// the UI, where onRunPrompt already rejects before recording.
if (current.enabled === false) {
blockedDisabled = true;
return tasks; // no write
}
const next: DurableCronTask = {
...current,
lastFiredAt: now,
runs: appendCronRun(current.runs, {
at: now,
kind: 'manual',
...(current.sessionId ? { sessionId: current.sessionId } : {}),
}),
};
updated = next;
// A one-shot's manual run IS its single fire — remove it from the store
// so the scheduler doesn't ALSO fire it at its original scheduled time
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
// stop that fire). The response still returns the recorded run.
rollbackBefore = tasks;
const nextTasks = !current.recurring
? tasks.filter((_, i) => i !== idx)
: tasks.map((t, i) => (i === idx ? next : t));
rollbackAfter = nextTasks;
return nextTasks;
},
{ assertCanCommit: target.assertGenerationOpen },
),
);
} catch (err) {
if (sendGenerationClosedError(res, err)) return;
@ -1072,7 +1121,7 @@ function registerScheduledTaskCrudRoutes(
target.assertGenerationOpen?.();
} catch (error) {
await rollbackCronMutation(
workspaceCwd,
target,
rollbackBefore,
rollbackAfter,
`POST ${base}/${id}/run`,
@ -1141,6 +1190,17 @@ export function registerScheduledTasksRoutes(
if (runtime && !requireTrustedWorkspaceRuntime(runtime, res)) return null;
return {
workspaceCwd: boundWorkspace,
...(runtime
? {
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
...(deps.cleanupSession
? {
cleanupSession: (sessionId: string) =>
deps.cleanupSession!(runtime, sessionId),
}
: {}),
}
: {}),
bridge: runtime?.bridge ?? bridge,
...(runtime?.generationGuard
? {
@ -1173,6 +1233,7 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
safeBody,
manageScheduledTaskSessions,
channelDeliveryAuthorizations,
cleanupSession,
} = deps;
registerScheduledTaskCrudRoutes(app, {
prefix: '/workspaces/:workspace',
@ -1186,6 +1247,13 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
if (!requireTrustedWorkspaceRuntime(runtime, res)) return null;
return {
workspaceCwd: runtime.workspaceCwd,
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
...(cleanupSession
? {
cleanupSession: (sessionId: string) =>
cleanupSession(runtime, sessionId),
}
: {}),
// Mirror the primary surface: only bind a session when management is on,
// so a bound task always has something to keep it resident + rehydrate it.
bridge: manageScheduledTaskSessions ? runtime.bridge : undefined,

View file

@ -53,6 +53,7 @@ function runtime(opts: {
}): WorkspaceRuntime {
return {
...opts,
sessionRuntimeBaseDir: path.join(opts.workspaceCwd, '.runtime'),
trusted: opts.trusted !== false,
} as WorkspaceRuntime;
}
@ -220,6 +221,7 @@ describe('special session resolver telemetry publication', () => {
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
secondaryCwd,
'secondary-session',
path.join(secondaryCwd, '.runtime'),
);
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1);
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith(
@ -230,8 +232,15 @@ describe('special session resolver telemetry publication', () => {
it('publishes the sole active transcript runtime after storage lookup', async () => {
archiveMocks.assertSessionLoadable.mockImplementation(
async (workspaceCwd: string) =>
workspaceCwd === secondaryCwd ? 'active' : undefined,
async (
workspaceCwd: string,
_sessionId: string,
runtimeBaseDir: string,
) =>
runtimeBaseDir === path.join(secondaryCwd, '.runtime') &&
workspaceCwd === secondaryCwd
? 'active'
: undefined,
);
const primary = runtime({
workspaceId: 'primary',
@ -255,10 +264,12 @@ describe('special session resolver telemetry publication', () => {
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
primaryCwd,
'stored-secondary',
path.join(primaryCwd, '.runtime'),
);
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
secondaryCwd,
'stored-secondary',
path.join(secondaryCwd, '.runtime'),
);
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1);
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith(

View file

@ -12,7 +12,6 @@ import {
BTW_MAX_INPUT_LENGTH,
GROUP_COLOR_OPTIONS,
GitWorktreeService,
SessionService,
SessionOrganizationError,
SESSION_TRANSCRIPT_MAX_LIMIT,
SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
@ -69,6 +68,7 @@ import {
archiveDaemonSessions,
assertSessionArchived,
assertSessionLoadable,
deleteDaemonSessionIfOrphan,
deleteDaemonSessions,
logSessionArchiveWarning,
type SessionArchiveCoordinator,
@ -105,6 +105,7 @@ import {
type VirtualSubagentSessions,
} from '../virtual-subagent-sessions.js';
import {
resolveWorkspaceEntryFromParam,
resolveWorkspaceRuntimeFromParam,
sendUntrustedWorkspaceResponse,
sendWorkspaceRuntimeUnavailable,
@ -113,6 +114,10 @@ import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from '../workspace-registry.js';
import {
createWorkspaceRuntimeSessionService,
runWithWorkspaceRuntimeStorage,
} from '../workspace-runtime-storage.js';
import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js';
// `HEAD` is the most prominent ref name git rejects as a branch name.
@ -178,9 +183,10 @@ function runWorkspaceInspectionWithLogPolicy<T>(
runtime: WorkspaceRuntime,
read: () => Promise<T>,
): Promise<T> {
const readInRuntime = () => runWithWorkspaceRuntimeStorage(runtime, read);
return isReadOnlyWorkspaceInspection(runtime)
? runWithoutDebugLogSession(read)
: read();
? runWithoutDebugLogSession(readInRuntime)
: readInRuntime();
}
function requireSessionArtifactClientId(
@ -643,9 +649,35 @@ export function registerSessionRoutes(
return runtime;
};
const hasActivePersistedSessions = async (workspaceCwd: string) => {
const resolveLegacyPrimaryRuntimeFromParam = (
req: Request,
res: Response,
): WorkspaceRuntime | null => {
const entry = resolveWorkspaceEntryFromParam(
workspaceRegistry,
req,
res,
'id',
);
if (!entry) return null;
if (!entry.primary) {
sendWorkspaceMismatch(res, entry.workspaceCwd);
return null;
}
const runtime =
entry.state === 'active' ? entry.current?.runtime : undefined;
if (!runtime) {
sendWorkspaceRuntimeUnavailable(res, entry);
return null;
}
return runtime;
};
const hasActivePersistedSessions = async (runtime: WorkspaceRuntime) => {
try {
const page = await new SessionService(workspaceCwd).listSessions({
const page = await createWorkspaceRuntimeSessionService(
runtime,
).listSessions({
archiveState: 'active',
size: 1,
});
@ -694,7 +726,7 @@ export function registerSessionRoutes(
res: Response,
target: {
route: string;
workspaceCwd: string;
runtime: WorkspaceRuntime;
workspaceQualified?: boolean;
archiveState?: SessionArchiveState;
},
@ -713,22 +745,29 @@ export function registerSessionRoutes(
return;
}
try {
const result = await archiveCoordinator.runSharedMany(
[sessionId],
async () => {
const result = await archiveCoordinator.runSharedMany([sessionId], () =>
runWithWorkspaceRuntimeStorage(target.runtime, async () => {
if (target.archiveState === 'archived') {
await assertSessionArchived(target.workspaceCwd, sessionId);
await assertSessionArchived(
target.runtime.workspaceCwd,
sessionId,
target.runtime.sessionRuntimeBaseDir,
);
} else {
await assertSessionLoadable(target.workspaceCwd, sessionId);
await assertSessionLoadable(
target.runtime.workspaceCwd,
sessionId,
target.runtime.sessionRuntimeBaseDir,
);
}
return exportSessionTranscript({
workspaceCwd: target.workspaceCwd,
workspaceCwd: target.runtime.workspaceCwd,
sessionId,
format,
archiveState: target.archiveState,
config: { getChannel: () => 'daemon' },
});
},
}),
);
const filename = result.filename.replace(/["\\\r\n]/g, '_');
res
@ -751,7 +790,7 @@ export function registerSessionRoutes(
route: target.route,
sessionId,
...(target.workspaceQualified
? { workspaceCwd: target.workspaceCwd }
? { workspaceCwd: target.runtime.workspaceCwd }
: {}),
});
}
@ -1030,6 +1069,7 @@ export function registerSessionRoutes(
const location = await assertSessionLoadable(
runtime.workspaceCwd,
sessionId,
runtime.sessionRuntimeBaseDir,
);
return location === 'active';
};
@ -1164,25 +1204,6 @@ export function registerSessionRoutes(
error: e.error instanceof Error ? e.error.message : String(e.error),
}));
const resolveWorkspaceParam = (
req: Request,
res: Response,
): string | null => {
const workspaceCwd = req.params['id'] ?? '';
if (!path.isAbsolute(workspaceCwd)) {
res
.status(400)
.json({ error: '`:id` must decode to an absolute workspace path' });
return null;
}
const key = canonicalizeWorkspace(workspaceCwd);
if (key !== boundWorkspace) {
sendWorkspaceMismatch(res, key);
return null;
}
return key;
};
const withPrimaryOnlyMutableSession = (
route: string,
handler: (
@ -1558,13 +1579,15 @@ export function registerSessionRoutes(
} catch (error) {
if (!session.attached) {
try {
const killed = await runtime.bridge.killSession(session.sessionId, {
requireZeroAttaches: true,
});
if (killed) {
await new SessionService(runtime.workspaceCwd).removeSession(
session.sessionId,
);
const removed = await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId: session.sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
);
if (removed) {
if (worktreeMeta) {
await new GitWorktreeService(workspaceCwd)
.removeUserWorktree(worktreeMeta.slug, { deleteBranch: true })
@ -1627,13 +1650,15 @@ export function registerSessionRoutes(
// skip the kill. Without the flag, that second client's
// session would die mid-prompt.
try {
const killed = await runtime.bridge.killSession(session.sessionId, {
requireZeroAttaches: true,
});
if (killed) {
await new SessionService(runtime.workspaceCwd).removeSession(
session.sessionId,
);
const removed = await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId: session.sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
);
if (removed) {
// Clean up the worktree if one was created for this session.
if (worktreeMeta) {
await new GitWorktreeService(workspaceCwd)
@ -1726,9 +1751,9 @@ export function registerSessionRoutes(
// Write the worktree sidecar so the session list can restore
// worktree metadata after a daemon restart.
await writeWorktreeSession(
new SessionService(workspaceCwd).getWorktreeSessionPath(
session.sessionId,
),
createWorkspaceRuntimeSessionService(
runtime,
).getWorktreeSessionPath(session.sessionId),
{
slug: worktreeMeta.slug,
worktreePath: worktreeMeta.path,
@ -1750,14 +1775,14 @@ export function registerSessionRoutes(
error: cdErr instanceof Error ? cdErr.message : String(cdErr),
});
}
const killed = await runtime.bridge
.killSession(session.sessionId, { requireZeroAttaches: true })
.catch(() => false);
if (killed) {
await new SessionService(workspaceCwd)
.removeSession(session.sessionId)
.catch(() => {});
}
await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId: session.sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
).catch(() => false);
// cd failed so the session never entered the worktree — the
// worktree is unused regardless of whether the session was
// killed or another client keeps it alive in the main checkout.
@ -1899,13 +1924,18 @@ export function registerSessionRoutes(
const session = await archiveCoordinator.runSharedMany(
[sessionId],
async () => {
await assertSessionLoadable(workspaceCwd, sessionId);
await assertSessionLoadable(
workspaceCwd,
sessionId,
runtime.sessionRuntimeBaseDir,
);
// Recover the persisted parent lineage so the restored live entry
// reports it (the bridge otherwise creates the entry without it, and
// status calls would show a restored sub-session as top-level).
const metadata = await new SessionService(
workspaceCwd,
).readCreationMetadata(sessionId);
const metadata =
await createWorkspaceRuntimeSessionService(
runtime,
).readCreationMetadata(sessionId);
runtime.generationGuard?.assertOpen();
return action === 'load'
? await runtime.bridge.loadSession({
@ -1980,7 +2010,9 @@ export function registerSessionRoutes(
// the main workspace (pre-existing shape, low frequency).
if (!session.worktree) {
const sidecar = await readWorktreeSession(
new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId),
createWorkspaceRuntimeSessionService(
runtime,
).getWorktreeSessionPath(sessionId),
).catch(() => null);
if (sidecar) {
// Defense-in-depth: resolve symlinks on both the target and
@ -2221,14 +2253,14 @@ export function registerSessionRoutes(
runtime.generationGuard?.assertOpen();
} catch (error) {
if (!result.attached) {
const killed = await runtime.bridge
.killSession(result.sessionId, { requireZeroAttaches: true })
.catch(() => false);
if (killed) {
await new SessionService(runtime.workspaceCwd)
.removeSession(result.sessionId)
.catch(() => {});
}
await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId: result.sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
).catch(() => false);
} else {
await runtime.bridge
.detachClient(result.sessionId, result.clientId)
@ -2238,11 +2270,16 @@ export function registerSessionRoutes(
}
if (!res.writable) {
if (!result.attached) {
runtime.bridge
.killSession(result.sessionId, { requireZeroAttaches: true })
.catch(() => {
// Best-effort cleanup; channel.exited will eventually reap.
});
void runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId: result.sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
).catch(() => {
// Best-effort cleanup; channel.exited will eventually reap.
});
} else {
runtime.bridge
.detachClient(result.sessionId, result.clientId)
@ -2413,7 +2450,7 @@ export function registerSessionRoutes(
app.get('/session/:id/export', async (req, res) => {
await handleSessionExport(req, res, {
route: 'GET /session/:id/export',
workspaceCwd: boundWorkspace,
runtime: workspaceRegistry.primary,
});
});
@ -2423,7 +2460,7 @@ export function registerSessionRoutes(
if (!runtime) return;
await handleSessionExport(req, res, {
route,
workspaceCwd: runtime.workspaceCwd,
runtime,
workspaceQualified: true,
});
});
@ -2436,7 +2473,7 @@ export function registerSessionRoutes(
if (!runtime) return;
await handleSessionExport(req, res, {
route,
workspaceCwd: runtime.workspaceCwd,
runtime,
workspaceQualified: true,
archiveState: 'archived',
});
@ -2537,76 +2574,82 @@ export function registerSessionRoutes(
try {
const result = await runWithoutDebugLogSession(() =>
archiveCoordinator.runSharedMany([sessionId], async () => {
const service = new SessionService(runtime.workspaceCwd);
if (cursor === undefined) {
await assertSessionLoadable(runtime.workspaceCwd, sessionId);
}
const codec = getTranscriptCursorCodec(runtime);
const reader = new SessionTranscriptReader(
runtime.workspaceCwd,
codec,
);
let page;
try {
page = await reader.readPage(sessionId, {
...(limit !== undefined ? { limit } : {}),
...(cursor !== undefined ? { cursor } : {}),
...(beforeRecordId !== undefined ? { beforeRecordId } : {}),
maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
archiveCoordinator.runSharedMany([sessionId], () =>
runWithWorkspaceRuntimeStorage(runtime, async () => {
const service = createWorkspaceRuntimeSessionService(runtime);
if (cursor === undefined) {
await assertSessionLoadable(
runtime.workspaceCwd,
sessionId,
runtime.sessionRuntimeBaseDir,
);
}
if (cursor !== undefined) {
const codec = getTranscriptCursorCodec(runtime);
const reader = new SessionTranscriptReader(
runtime.workspaceCwd,
codec,
);
let page;
try {
page = await reader.readPage(sessionId, {
...(limit !== undefined ? { limit } : {}),
...(cursor !== undefined ? { cursor } : {}),
...(beforeRecordId !== undefined ? { beforeRecordId } : {}),
maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
if (cursor !== undefined) {
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
}
const location = await service.getSessionLocation(sessionId);
if (location === 'archived') {
throw new SessionArchivedError(sessionId);
}
if (location === 'conflict') {
throw new SessionConflictError(sessionId);
}
throw new SessionNotFoundError(sessionId);
}
if (page.records.some((record) => record.sessionId !== sessionId)) {
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
}
const location = await service.getSessionLocation(sessionId);
if (location === 'archived') {
throw new SessionArchivedError(sessionId);
}
if (location === 'conflict') {
throw new SessionConflictError(sessionId);
}
throw new SessionNotFoundError(sessionId);
}
if (page.records.some((record) => record.sessionId !== sessionId)) {
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
}
const replay = await replayTranscriptRecordPage({
sessionId,
page,
encodeCursor: (state) => codec.encode(state),
});
const cursorTooLarge =
replay.nextCursor !== undefined &&
Buffer.byteLength(replay.nextCursor) >
WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES;
return {
v: 1 as const,
sessionId,
events: replay.updates.map((update) => ({
const replay = await replayTranscriptRecordPage({
sessionId,
page,
encodeCursor: (state) => codec.encode(state),
});
const cursorTooLarge =
replay.nextCursor !== undefined &&
Buffer.byteLength(replay.nextCursor) >
WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES;
return {
v: 1 as const,
type: 'session_update' as const,
data: update,
})),
...(replay.nextCursor && !cursorTooLarge
? { nextCursor: replay.nextCursor }
: {}),
hasMore: cursorTooLarge ? false : replay.hasMore,
startTime: replay.startTime,
lastUpdated: replay.lastUpdated,
...(replay.partial || cursorTooLarge
? {
partial: true as const,
replayError: cursorTooLarge
? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR
: replay.replayError,
}
: {}),
};
}),
sessionId,
events: replay.updates.map((update) => ({
v: 1 as const,
type: 'session_update' as const,
data: update,
})),
...(replay.nextCursor && !cursorTooLarge
? { nextCursor: replay.nextCursor }
: {}),
hasMore: cursorTooLarge ? false : replay.hasMore,
startTime: replay.startTime,
lastUpdated: replay.lastUpdated,
...(replay.partial || cursorTooLarge
? {
partial: true as const,
replayError: cursorTooLarge
? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR
: replay.replayError,
}
: {}),
};
}),
),
);
const serialized = serializeWorkspaceTranscriptResponse(
result,
@ -3361,18 +3404,21 @@ export function registerSessionRoutes(
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
try {
const service = new SessionService(boundWorkspace);
const result = await deleteDaemonSessions({
sessionIds: uniqueIds,
service,
bridge,
coordinator: archiveCoordinator,
onError: ({ phase, sessionId, error }) => {
writeStderrLine(
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
);
},
});
const runtime = workspaceRegistry.primary;
const service = createWorkspaceRuntimeSessionService(runtime);
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessions({
sessionIds: uniqueIds,
service,
bridge,
coordinator: archiveCoordinator,
onError: ({ phase, sessionId, error }) => {
writeStderrLine(
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
);
},
}),
);
for (const removedId of result.removed) {
clearBranchSessionEntry(removedId);
}
@ -3386,17 +3432,20 @@ export function registerSessionRoutes(
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
const service = new SessionService(boundWorkspace, {
const runtime = workspaceRegistry.primary;
const service = createWorkspaceRuntimeSessionService(runtime, {
onWarning: logSessionArchiveWarning,
});
try {
const result = await archiveDaemonSessions({
sessionIds: uniqueIds,
service,
bridge,
coordinator: archiveCoordinator,
});
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
archiveDaemonSessions({
sessionIds: uniqueIds,
service,
bridge,
coordinator: archiveCoordinator,
}),
);
res.status(200).json({
archived: result.archived,
alreadyArchived: result.alreadyArchived,
@ -3412,16 +3461,19 @@ export function registerSessionRoutes(
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
const service = new SessionService(boundWorkspace, {
const runtime = workspaceRegistry.primary;
const service = createWorkspaceRuntimeSessionService(runtime, {
onWarning: logSessionArchiveWarning,
});
try {
const result = await unarchiveDaemonSessions({
sessionIds: uniqueIds,
service,
coordinator: archiveCoordinator,
});
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
unarchiveDaemonSessions({
sessionIds: uniqueIds,
service,
coordinator: archiveCoordinator,
}),
);
res.status(200).json({
unarchived: result.unarchived,
alreadyActive: result.alreadyActive,
@ -3445,18 +3497,20 @@ export function registerSessionRoutes(
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
try {
const service = new SessionService(runtime.workspaceCwd);
const result = await deleteDaemonSessions({
sessionIds: uniqueIds,
service,
bridge: runtime.bridge,
coordinator: archiveCoordinator,
onError: ({ phase, sessionId, error }) => {
writeStderrLine(
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
);
},
});
const service = createWorkspaceRuntimeSessionService(runtime);
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessions({
sessionIds: uniqueIds,
service,
bridge: runtime.bridge,
coordinator: archiveCoordinator,
onError: ({ phase, sessionId, error }) => {
writeStderrLine(
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
);
},
}),
);
for (const removedId of result.removed) {
clearBranchSessionEntry(removedId);
}
@ -3476,16 +3530,18 @@ export function registerSessionRoutes(
if (!runtime) return;
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
const service = new SessionService(runtime.workspaceCwd, {
const service = createWorkspaceRuntimeSessionService(runtime, {
onWarning: logSessionArchiveWarning,
});
try {
const result = await archiveDaemonSessions({
sessionIds: uniqueIds,
service,
bridge: runtime.bridge,
coordinator: archiveCoordinator,
});
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
archiveDaemonSessions({
sessionIds: uniqueIds,
service,
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
);
res.status(200).json({
archived: result.archived,
alreadyArchived: result.alreadyArchived,
@ -3507,15 +3563,17 @@ export function registerSessionRoutes(
if (!runtime) return;
const uniqueIds = parseSessionIdsBody(req, res);
if (uniqueIds === undefined) return;
const service = new SessionService(runtime.workspaceCwd, {
const service = createWorkspaceRuntimeSessionService(runtime, {
onWarning: logSessionArchiveWarning,
});
try {
const result = await unarchiveDaemonSessions({
sessionIds: uniqueIds,
service,
coordinator: archiveCoordinator,
});
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
unarchiveDaemonSessions({
sessionIds: uniqueIds,
service,
coordinator: archiveCoordinator,
}),
);
res.status(200).json({
unarchived: result.unarchived,
alreadyActive: result.alreadyActive,
@ -3564,8 +3622,7 @@ export function registerSessionRoutes(
);
type SessionOrganizationTarget = {
workspaceCwd: string;
bridge: AcpSessionBridge;
runtime: WorkspaceRuntime;
route: string;
};
@ -3577,92 +3634,98 @@ export function registerSessionRoutes(
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
try {
await archiveCoordinator.runSharedMany([sessionId], async () => {
// Organization is workspace-scoped sidecar state, not live-session
// metadata. It intentionally applies to persisted and archived sessions.
const sessionService = new SessionService(target.workspaceCwd);
let exists = await sessionService.sessionExistsInAnyState(sessionId);
if (!exists) {
try {
const summary = target.bridge.getSessionSummary(sessionId);
exists = summary.workspaceCwd === target.workspaceCwd;
} catch {
exists = false;
await archiveCoordinator.runSharedMany([sessionId], () =>
runWithWorkspaceRuntimeStorage(target.runtime, async () => {
// Organization is workspace-scoped sidecar state, not live-session
// metadata. It intentionally applies to persisted and archived sessions.
const sessionService = createWorkspaceRuntimeSessionService(
target.runtime,
);
let exists = await sessionService.sessionExistsInAnyState(sessionId);
if (!exists) {
try {
const summary =
target.runtime.bridge.getSessionSummary(sessionId);
exists = summary.workspaceCwd === target.runtime.workspaceCwd;
} catch {
exists = false;
}
}
if (!exists) {
res.status(404).json({
error: `No session with id "${sessionId}"`,
sessionId,
});
return;
}
}
if (!exists) {
res.status(404).json({
error: `No session with id "${sessionId}"`,
sessionId,
});
return;
}
const body = safeBody(req);
const rawIsPinned = body['isPinned'];
if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') {
res.status(400).json({
error: '`isPinned` must be a boolean',
code: 'invalid_session_organization',
field: 'isPinned',
});
return;
}
const rawGroupId = body['groupId'];
if (
rawGroupId !== undefined &&
rawGroupId !== null &&
typeof rawGroupId !== 'string'
) {
res.status(400).json({
error: '`groupId` must be a string or null',
code: 'invalid_session_organization',
field: 'groupId',
});
return;
}
const rawColor = body['color'];
if (
rawColor !== undefined &&
rawColor !== null &&
(typeof rawColor !== 'string' ||
!GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupPresetColor))
) {
res.status(400).json({
error: '`color` must be a supported color or null',
code: 'invalid_session_organization',
field: 'color',
});
return;
}
const body = safeBody(req);
const rawIsPinned = body['isPinned'];
if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') {
res.status(400).json({
error: '`isPinned` must be a boolean',
code: 'invalid_session_organization',
field: 'isPinned',
});
return;
}
const rawGroupId = body['groupId'];
if (
rawGroupId !== undefined &&
rawGroupId !== null &&
typeof rawGroupId !== 'string'
) {
res.status(400).json({
error: '`groupId` must be a string or null',
code: 'invalid_session_organization',
field: 'groupId',
});
return;
}
const rawColor = body['color'];
if (
rawColor !== undefined &&
rawColor !== null &&
(typeof rawColor !== 'string' ||
!GROUP_COLOR_OPTIONS.includes(
rawColor as SessionGroupPresetColor,
))
) {
res.status(400).json({
error: '`color` must be a supported color or null',
code: 'invalid_session_organization',
field: 'color',
});
return;
}
const organization = await createSessionOrganizationService(
target.workspaceCwd,
).updateSessionOrganization(sessionId, {
...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}),
...(rawGroupId !== undefined
? { groupId: rawGroupId as string | null }
: {}),
...(rawColor !== undefined
? { color: rawColor as SessionGroupPresetColor | null }
: {}),
});
res.status(200).json({ sessionId, ...organization });
});
const organization = await createSessionOrganizationService(
target.runtime.workspaceCwd,
).updateSessionOrganization(sessionId, {
...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}),
...(rawGroupId !== undefined
? { groupId: rawGroupId as string | null }
: {}),
...(rawColor !== undefined
? { color: rawColor as SessionGroupPresetColor | null }
: {}),
});
res.status(200).json({ sessionId, ...organization });
}),
);
} catch (err) {
if (sendSessionOrganizationError(res, err)) return;
sendBridgeError(res, err, {
route: target.route,
sessionId,
workspaceCwd: target.workspaceCwd,
workspaceCwd: target.runtime.workspaceCwd,
});
}
};
app.patch('/session/:id/organization', mutate(), async (req, res) => {
await handleSessionOrganizationUpdate(req, res, {
workspaceCwd: boundWorkspace,
bridge,
runtime: workspaceRegistry.primary,
route: 'PATCH /session/:id/organization',
});
});
@ -3675,8 +3738,7 @@ export function registerSessionRoutes(
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
if (!runtime) return;
await handleSessionOrganizationUpdate(req, res, {
workspaceCwd: runtime.workspaceCwd,
bridge: runtime.bridge,
runtime,
route,
});
},
@ -3703,14 +3765,16 @@ export function registerSessionRoutes(
});
app.post('/workspace/:id/session-groups', mutate(), async (req, res) => {
const key = resolveWorkspaceParam(req, res);
if (key === null) return;
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
if (runtime === null) return;
const body = safeBody(req);
try {
const group = await createSessionOrganizationService(key).createGroup({
name: body['name'] as string,
color: body['color'] as SessionGroupColor,
});
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).createGroup({
name: body['name'] as string,
color: body['color'] as SessionGroupColor,
}),
);
res.status(201).json({ group });
} catch (err) {
if (sendSessionOrganizationError(res, err)) return;
@ -3724,23 +3788,25 @@ export function registerSessionRoutes(
'/workspace/:id/session-groups/:groupId',
mutate(),
async (req, res) => {
const key = resolveWorkspaceParam(req, res);
if (key === null) return;
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
if (runtime === null) return;
const body = safeBody(req);
try {
const group = await createSessionOrganizationService(key).updateGroup(
req.params['groupId'] ?? '',
{
...(Object.prototype.hasOwnProperty.call(body, 'name')
? { name: body['name'] as string }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'color')
? { color: body['color'] as SessionGroupColor }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'order')
? { order: body['order'] as number }
: {}),
},
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).updateGroup(
req.params['groupId'] ?? '',
{
...(Object.prototype.hasOwnProperty.call(body, 'name')
? { name: body['name'] as string }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'color')
? { color: body['color'] as SessionGroupColor }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'order')
? { order: body['order'] as number }
: {}),
},
),
);
res.status(200).json({ group });
} catch (err) {
@ -3756,11 +3822,13 @@ export function registerSessionRoutes(
'/workspace/:id/session-groups/:groupId',
mutate(),
async (req, res) => {
const key = resolveWorkspaceParam(req, res);
if (key === null) return;
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
if (runtime === null) return;
try {
const deleted = await createSessionOrganizationService(key).deleteGroup(
req.params['groupId'] ?? '',
const deleted = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).deleteGroup(
req.params['groupId'] ?? '',
),
);
res.status(200).json({ deleted });
} catch (err) {
@ -3798,12 +3866,12 @@ export function registerSessionRoutes(
if (!runtime) return;
const body = safeBody(req);
try {
const group = await createSessionOrganizationService(
runtime.workspaceCwd,
).createGroup({
name: body['name'] as string,
color: body['color'] as SessionGroupColor,
});
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).createGroup({
name: body['name'] as string,
color: body['color'] as SessionGroupColor,
}),
);
res.status(201).json({ group });
} catch (err) {
if (sendSessionOrganizationError(res, err)) return;
@ -3821,19 +3889,22 @@ export function registerSessionRoutes(
if (!runtime) return;
const body = safeBody(req);
try {
const group = await createSessionOrganizationService(
runtime.workspaceCwd,
).updateGroup(req.params['groupId'] ?? '', {
...(Object.prototype.hasOwnProperty.call(body, 'name')
? { name: body['name'] as string }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'color')
? { color: body['color'] as SessionGroupColor }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'order')
? { order: body['order'] as number }
: {}),
});
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).updateGroup(
req.params['groupId'] ?? '',
{
...(Object.prototype.hasOwnProperty.call(body, 'name')
? { name: body['name'] as string }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'color')
? { color: body['color'] as SessionGroupColor }
: {}),
...(Object.prototype.hasOwnProperty.call(body, 'order')
? { order: body['order'] as number }
: {}),
},
),
);
res.status(200).json({ group });
} catch (err) {
if (sendSessionOrganizationError(res, err)) return;
@ -3850,9 +3921,11 @@ export function registerSessionRoutes(
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
if (!runtime) return;
try {
const deleted = await createSessionOrganizationService(
runtime.workspaceCwd,
).deleteGroup(req.params['groupId'] ?? '');
const deleted = await runWithWorkspaceRuntimeStorage(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).deleteGroup(
req.params['groupId'] ?? '',
),
);
res.status(200).json({ deleted });
} catch (err) {
if (sendSessionOrganizationError(res, err)) return;
@ -3976,7 +4049,7 @@ export function registerSessionRoutes(
parsedSource.sourceType !== undefined ||
(cursor !== undefined && cursor !== ''
? isNumericSessionCursor(cursor)
: await hasActivePersistedSessions(key));
: await hasActivePersistedSessions(runtime));
// The live path only reads cursor/size; persisted-only options
// (organized view or archived state) would be silently dropped there.
// usePersisted already routes those to the persisted path — assert it so

View file

@ -96,6 +96,7 @@ function makeRuntime(
return {
workspaceId: opts.workspaceId,
workspaceCwd,
sessionRuntimeBaseDir: path.join(workspaceCwd, '.runtime'),
primary: opts.primary,
trusted: opts.trusted,
env: { mode: 'parent-process', overlayKeys: [] },

View file

@ -3060,6 +3060,8 @@ describe('runQwenServe runtime startup failures', () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-env-reload-')),
);
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
delete process.env['QWEN_RUNTIME_DIR'];
const originalBase = process.env['QWEN_TEST_BOOT_BASE'];
const originalLeak = process.env['QWEN_TEST_RELOAD_LEAK'];
const originalRemoved = process.env['QWEN_TEST_REMOVED_FROM_DOTENV'];
@ -3076,6 +3078,11 @@ describe('runQwenServe runtime startup failures', () => {
() =>
({
merged: {
advanced: {
runtimeOutputDir: runtimeMounted
? '.runtime-reloaded'
: '.runtime-boot',
},
env: {
QWEN_TEST_RUNTIME_VALUE: runtimeMounted ? 'reloaded' : 'boot',
},
@ -3110,11 +3117,15 @@ describe('runQwenServe runtime startup failures', () => {
effectiveEnv?: NodeJS.ProcessEnv;
}
| undefined;
let primaryRuntime:
| import('./workspace-registry.js').WorkspaceRuntime
| undefined;
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(_opts, _getPort, deps) => {
runtimeMounted = true;
workspace = deps?.workspace as typeof workspace;
primaryRuntimeEnv = deps?.primaryRuntimeEnv as typeof primaryRuntimeEnv;
primaryRuntime = deps?.workspaceRegistry?.primary;
return express();
},
);
@ -3142,6 +3153,9 @@ describe('runQwenServe runtime startup failures', () => {
expect(primaryRuntimeEnv?.effectiveEnv).toBeDefined();
const capturedRuntimeEnv = primaryRuntimeEnv!.effectiveEnv!;
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('boot');
const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot');
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
await workspace!.reload({
route: 'POST /workspace/reload',
@ -3156,6 +3170,8 @@ describe('runQwenServe runtime startup failures', () => {
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('reloaded');
expect(capturedRuntimeEnv['QWEN_TEST_REMOVED_FROM_DOTENV']).toBe('stale');
expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined();
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
} finally {
if (originalBase === undefined) {
delete process.env['QWEN_TEST_BOOT_BASE'];
@ -3172,6 +3188,11 @@ describe('runQwenServe runtime startup failures', () => {
} else {
process.env['QWEN_TEST_REMOVED_FROM_DOTENV'] = originalRemoved;
}
if (originalRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
}
await handle.close();
}
});
@ -3305,6 +3326,8 @@ describe('runQwenServe runtime startup failures', () => {
);
const primary = path.join(tmpDir, 'primary');
const secondary = path.join(tmpDir, 'secondary');
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
delete process.env['QWEN_RUNTIME_DIR'];
fs.mkdirSync(primary);
fs.mkdirSync(secondary);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
@ -3318,6 +3341,13 @@ describe('runQwenServe runtime startup failures', () => {
const isSecondary = workspace === secondary;
return {
merged: {
advanced: {
runtimeOutputDir: isSecondary
? runtimeMounted
? '.secondary-runtime-reloaded'
: '.secondary-runtime-boot'
: '.primary-runtime',
},
env: {
[isSecondary
? 'QWEN_TEST_SECONDARY_ENV'
@ -3381,6 +3411,14 @@ describe('runQwenServe runtime startup failures', () => {
const envFilePaths = env.envFilePaths;
const envFileReadFailures = env.envFileReadFailures;
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('boot');
const pinnedRuntimeBaseDir = path.join(
secondary,
'.secondary-runtime-boot',
);
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
pinnedRuntimeBaseDir,
);
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
await secondaryRuntime!.workspaceService.reload({
route: 'POST /workspace/reload',
@ -3391,8 +3429,17 @@ describe('runQwenServe runtime startup failures', () => {
expect(env.envFilePaths).toBe(envFilePaths);
expect(env.envFileReadFailures).toBe(envFileReadFailures);
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('reloaded');
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
pinnedRuntimeBaseDir,
);
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
} finally {
await handle.close();
if (originalRuntimeDir === undefined) {
delete process.env['QWEN_RUNTIME_DIR'];
} else {
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
}
}
});
@ -5296,6 +5343,54 @@ describe('runQwenServe runtime startup failures', () => {
).toBeLessThan(vi.mocked(bridge.shutdown).mock.invocationCallOrder[0]!);
});
it('seals and drains admitted session maintenance before bridge shutdown', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-maintenance-drain-')),
);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
const bridge = makeRuntimeBridge();
vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue(
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
);
let finishMaintenance!: () => void;
const maintenanceGate = new Promise<void>((resolve) => {
finishMaintenance = resolve;
});
const sealMaintenanceAndWait = vi.fn(() => maintenanceGate);
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
const runtimeApp = express();
runtimeApp.locals['sessionArchiveCoordinator'] = {
sealMaintenanceAndWait,
};
return runtimeApp;
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: tmpDir,
maxSessions: 1,
serveWebShell: false,
},
{ resolveOnListen: true },
);
await handle.runtimeReady;
const close = handle.close();
expect(sealMaintenanceAndWait).toHaveBeenCalledOnce();
await Promise.resolve();
expect(bridge.shutdown).not.toHaveBeenCalled();
finishMaintenance();
await close;
expect(bridge.shutdown).toHaveBeenCalledOnce();
});
it('does not cancel deferred runtime once startup is already running', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')),

View file

@ -3108,6 +3108,47 @@ async function runQwenServeImpl(
envFileReadFailed: false,
envFileReadFailures: Object.freeze([]),
};
const resolveSessionRuntimeBaseDir = (
workspace: string,
settings: ReturnType<SettingsRuntime['loadSettings']> | undefined,
effectiveEnv: Readonly<NodeJS.ProcessEnv>,
): string => {
const resolveConfiguredPath = (
configuredPath: string,
relativeTo: string,
): string => {
const expanded =
configuredPath === '~'
? os.homedir()
: configuredPath.startsWith('~/') ||
configuredPath.startsWith('~\\')
? path.join(
os.homedir(),
...configuredPath
.slice(2)
.split(/[/\\]+/)
.filter(Boolean),
)
: configuredPath;
return path.resolve(relativeTo, expanded);
};
const runtimeDir = effectiveEnv['QWEN_RUNTIME_DIR'];
if (runtimeDir) {
return resolveConfiguredPath(runtimeDir, process.cwd());
}
const settingsDir = settings?.merged.advanced?.runtimeOutputDir;
if (settingsDir) {
return resolveConfiguredPath(settingsDir, workspace);
}
const qwenHome = effectiveEnv['QWEN_HOME'];
if (qwenHome) {
return resolveConfiguredPath(qwenHome, process.cwd());
}
const homeDir = os.homedir();
return homeDir
? path.join(homeDir, '.qwen')
: path.join(os.tmpdir(), '.qwen');
};
const logRuntimeEnvFileReadFailures = (
workspace: string,
snapshot: {
@ -3126,8 +3167,14 @@ async function runQwenServeImpl(
});
};
logRuntimeEnvFileReadFailures(boundWorkspace, runtimeEnvSnapshot);
const primarySessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
boundWorkspace,
runtimeBootSettings,
runtimeEnvSnapshot.effectiveEnv,
);
const runtimeEffectiveEnv: NodeJS.ProcessEnv = {
...runtimeEnvSnapshot.effectiveEnv,
QWEN_RUNTIME_DIR: primarySessionRuntimeBaseDir,
};
const replaceRuntimeEffectiveEnv = (
nextEnv: Readonly<NodeJS.ProcessEnv>,
@ -3136,6 +3183,7 @@ async function runQwenServeImpl(
delete runtimeEffectiveEnv[key];
}
Object.assign(runtimeEffectiveEnv, nextEnv);
runtimeEffectiveEnv['QWEN_RUNTIME_DIR'] = primarySessionRuntimeBaseDir;
};
const primaryRuntimeEnv: {
mode: 'runtime-overlay';
@ -3814,6 +3862,7 @@ async function runQwenServeImpl(
{
workspaceId: daemonWorkspaceHash,
workspaceCwd: boundWorkspace,
sessionRuntimeBaseDir: primarySessionRuntimeBaseDir,
...(workspaceInputs[0]?.displayName
? { displayName: workspaceInputs[0].displayName }
: {}),
@ -3846,6 +3895,7 @@ async function runQwenServeImpl(
fallbackReason?: string;
};
effectiveEnv: NodeJS.ProcessEnv;
sessionRuntimeBaseDir: string;
replace: (nextEnv: Readonly<NodeJS.ProcessEnv>) => void;
} => {
const snapshot = settings
@ -3863,7 +3913,15 @@ async function runQwenServeImpl(
envFileReadFailures: Object.freeze([]),
};
logRuntimeEnvFileReadFailures(workspace, snapshot);
const effectiveEnv: NodeJS.ProcessEnv = { ...snapshot.effectiveEnv };
const sessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
workspace,
settings,
snapshot.effectiveEnv,
);
const effectiveEnv: NodeJS.ProcessEnv = {
...snapshot.effectiveEnv,
QWEN_RUNTIME_DIR: sessionRuntimeBaseDir,
};
const metadata: {
mode: 'runtime-overlay';
overlayKeys: string[];
@ -3883,11 +3941,13 @@ async function runQwenServeImpl(
return {
metadata,
effectiveEnv,
sessionRuntimeBaseDir,
replace(nextEnv) {
for (const key of Object.keys(effectiveEnv)) {
delete effectiveEnv[key];
}
Object.assign(effectiveEnv, nextEnv);
effectiveEnv['QWEN_RUNTIME_DIR'] = sessionRuntimeBaseDir;
},
};
};
@ -4177,6 +4237,7 @@ async function runQwenServeImpl(
const secondaryRuntime: WorkspaceRuntime = {
workspaceId: secondaryWorkspaceHash,
workspaceCwd: workspaceInput.cwd,
sessionRuntimeBaseDir: secondaryEnv.sessionRuntimeBaseDir,
...(workspaceInput.displayName
? { displayName: workspaceInput.displayName }
: {}),
@ -4711,6 +4772,7 @@ async function runQwenServeImpl(
const wsRuntime: WorkspaceRuntime = {
workspaceId: wsHash,
workspaceCwd: cwd,
sessionRuntimeBaseDir: wsEnv.sessionRuntimeBaseDir,
...(buildOptions?.displayName !== undefined
? { displayName: buildOptions.displayName }
: {}),
@ -6345,11 +6407,17 @@ async function runQwenServeImpl(
const initiallyMountedManagement = initiallyMountedApp?.locals?.[
'workspaceManagementHandle'
] as { sealAndWait?: () => Promise<void> } | undefined;
const initiallyMountedSessionMaintenance = initiallyMountedApp
?.locals?.['sessionArchiveCoordinator'] as
| { sealMaintenanceAndWait?: () => Promise<void> }
| undefined;
// Calling an async function runs through its first await
// synchronously. Seal an already-mounted runtime before close()
// yields so no management request can enter the shutdown window.
const initialManagementWait =
initiallyMountedManagement?.sealAndWait?.();
const initialSessionMaintenanceWait =
initiallyMountedSessionMaintenance?.sealMaintenanceAndWait?.();
let processRegistryShutdown: Promise<Error | undefined> | undefined;
const startProcessRegistryShutdown = () => {
processRegistryShutdown ??= managedProcessRegistry
@ -6492,10 +6560,19 @@ async function runQwenServeImpl(
const workspaceManagementHandle = appForCleanup?.locals?.[
'workspaceManagementHandle'
] as { sealAndWait?: () => Promise<void> } | undefined;
const sessionMaintenance = appForCleanup?.locals?.[
'sessionArchiveCoordinator'
] as
| { sealMaintenanceAndWait?: () => Promise<void> }
| undefined;
await initialManagementWait;
if (workspaceManagementHandle !== initiallyMountedManagement) {
await workspaceManagementHandle?.sealAndWait?.();
}
await initialSessionMaintenanceWait;
if (sessionMaintenance !== initiallyMountedSessionMaintenance) {
await sessionMaintenance?.sealMaintenanceAndWait?.();
}
stopTrustPolicyMonitor(appForCleanup);
const waitForTrustPolicyIdle = appForCleanup?.locals?.[
'waitForTrustPolicyIdle'

View file

@ -777,6 +777,46 @@ describe('scheduled-task keepalive', () => {
releaseSpawn?.();
});
it('waits for close before deleting a late spawned transcript', async () => {
await updateCronTasks(workspace, () => [
task({ id: 'hung', prompt: 'will resolve late' }),
]);
let resolveSpawn!: (value: { sessionId: string }) => void;
let finishClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
finishClose = resolve;
});
const closeSession = vi.fn(() => closeGate);
const removeSpy = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockResolvedValue(true);
const ka = startScheduledTaskKeepalive({
bridge: {
...bridge,
spawnOrAttach: () =>
new Promise<{ sessionId: string }>((resolve) => {
resolveSpawn = resolve;
}),
closeSession,
},
boundWorkspace: workspace,
intervalMs: 50,
spawnTimeoutMs: 5,
});
await ka.tick();
resolveSpawn({ sessionId: 'late-sess' });
await vi.waitFor(() =>
expect(closeSession).toHaveBeenCalledWith('late-sess'),
);
expect(removeSpy).not.toHaveBeenCalled();
finishClose();
await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledWith('late-sess'));
ka.stop();
removeSpy.mockRestore();
});
it('rehydration onTasksRead populates the authorization store for delivery-enabled tasks', async () => {
const authorizations = new ChannelDeliveryAuthorizationStore();
await updateCronTasks(workspace, () => [

View file

@ -36,6 +36,7 @@ import {
getCronFilePath,
createDebugLogger,
SessionService,
Storage,
taskHasLegacyCondition,
type DurableCronTask,
} from '@qwen-code/qwen-code-core';
@ -126,6 +127,7 @@ async function bindAndNameSessions(
renamed: Set<string>,
spawnTimeoutMs: number,
binding: Set<string>,
cleanupSession: (sessionId: string) => Promise<unknown>,
): Promise<void> {
const unbound = tasks.filter(
(t) =>
@ -158,17 +160,14 @@ async function bindAndNameSessions(
// binding guard on TRUE settlement so retries are possible.
let timedOut = false;
rawSpawn
.then(({ sessionId }) => {
.then(async ({ sessionId }) => {
if (timedOut) {
log.debug(
'keepalive: late spawn resolved, cleaning up',
task.id,
sessionId,
);
bridge.closeSession(sessionId).catch(() => {});
new SessionService(boundWorkspace)
.removeSession(sessionId)
.catch(() => {});
await cleanupSession(sessionId).catch(() => {});
}
})
.catch(() => {})
@ -226,10 +225,7 @@ async function bindAndNameSessions(
} catch (err) {
log.debug('keepalive: failed to bind task', task.id, err);
if (spawnedSessionId !== undefined) {
await bridge.closeSession(spawnedSessionId).catch(() => {});
await new SessionService(boundWorkspace)
.removeSession(spawnedSessionId)
.catch(() => {});
await cleanupSession(spawnedSessionId).catch(() => {});
}
}
}
@ -257,6 +253,8 @@ export interface ScheduledTaskKeepalive {
export interface StartScheduledTaskKeepaliveOptions {
bridge: KeepaliveBridge;
boundWorkspace: string;
runtimeBaseDir?: string;
cleanupSession?: (sessionId: string) => Promise<unknown>;
/** How often to heartbeat; must be comfortably under the reaper timeout. */
intervalMs: number;
/** Per-session revive timeout; defaults to KEEPALIVE_REVIVE_TIMEOUT_MS. */
@ -272,6 +270,14 @@ export function startScheduledTaskKeepalive(
const { bridge, boundWorkspace, intervalMs } = opts;
const reviveTimeoutMs = opts.reviveTimeoutMs ?? KEEPALIVE_REVIVE_TIMEOUT_MS;
const spawnTimeoutMs = opts.spawnTimeoutMs ?? KEEPALIVE_SPAWN_TIMEOUT_MS;
const cleanupSession =
opts.cleanupSession ??
(async (sessionId: string) => {
await bridge.closeSession(sessionId);
await new SessionService(boundWorkspace, {
runtimeBaseDir: opts.runtimeBaseDir,
}).removeSession(sessionId);
});
// Per-session revive state: `nextAttemptAt` gates retries after failures so a
// permanently-gone session isn't reloaded every interval; cleared on success.
@ -294,7 +300,7 @@ export function startScheduledTaskKeepalive(
// so updateSessionMetadata isn't called every tick.
const renamed = new Set<string>();
const tick = async (): Promise<void> => {
const tickInRuntime = async (): Promise<void> => {
let tasks;
try {
tasks = await readCronTasks(boundWorkspace);
@ -388,8 +394,16 @@ export function startScheduledTaskKeepalive(
renamed,
spawnTimeoutMs,
binding,
cleanupSession,
);
};
const tick = (): Promise<void> =>
opts.runtimeBaseDir === undefined
? tickInRuntime()
: Storage.runWithResolvedRuntimeBaseDir(
opts.runtimeBaseDir,
tickInRuntime,
);
// In-flight guard: a pass can outlast the interval (each revive awaits up to
// the revive timeout), so skip a tick while the previous is still running —
@ -410,7 +424,12 @@ export function startScheduledTaskKeepalive(
// dedicated session immediately, not after the next interval. Same
// directory-watch + debounce pattern the scheduler uses.
let bindDebounce: ReturnType<typeof setTimeout> | undefined;
const cronFilePath = getCronFilePath(boundWorkspace);
const cronFilePath =
opts.runtimeBaseDir === undefined
? getCronFilePath(boundWorkspace)
: Storage.runWithResolvedRuntimeBaseDir(opts.runtimeBaseDir, () =>
getCronFilePath(boundWorkspace),
);
const cronDir = path.dirname(cronFilePath);
const cronFileName = path.basename(cronFilePath);
let fileWatcher: ReturnType<typeof fsSync.watch> | undefined;

View file

@ -2180,12 +2180,15 @@ function makeWorkspaceRuntimeForTest(input: {
workspaceCwd: string;
primary: boolean;
bridge: AcpSessionBridge;
sessionRuntimeBaseDir?: string;
trusted?: boolean;
generationGuard?: WorkspaceGenerationGuard;
}): WorkspaceRuntime {
return {
workspaceId: input.workspaceId,
workspaceCwd: input.workspaceCwd,
sessionRuntimeBaseDir:
input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(),
primary: input.primary,
trusted: input.trusted ?? true,
env: { mode: 'parent-process', overlayKeys: [] },
@ -11732,6 +11735,55 @@ describe('createServeApp', () => {
]);
});
it('rejects singular session-group mutations when the selected runtime is unavailable', async () => {
const runtime = makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
bridge: fakeBridge(),
});
const workspaceRegistry = createWorkspaceRegistry([runtime]);
const app = createServeApp(baseOpts, undefined, {
workspaceRegistry,
});
workspaceRegistry.beginReplacement(
workspaceRegistry.primaryEntry,
'policy-2',
);
workspaceRegistry.blockReplacement(
workspaceRegistry.primaryEntry,
'runtime build failed',
);
const responses = await Promise.all([
request(app)
.post(`/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`)
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ name: 'Frontend', color: 'blue' }),
request(app)
.patch(
`/workspace/${encodeURIComponent(
WS_BOUND,
)}/session-groups/missing-group`,
)
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ name: 'Frontend' }),
request(app)
.delete(
`/workspace/${encodeURIComponent(
WS_BOUND,
)}/session-groups/missing-group`,
)
.set('Host', `127.0.0.1:${baseOpts.port}`),
]);
for (const response of responses) {
expect(response.status).toBe(503);
expect(response.headers['retry-after']).toBe('1');
expect(response.body.code).toBe('workspace_runtime_unavailable');
}
});
it('returns session organization errors for invalid REST inputs', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
await writeStoredSession({
@ -14143,12 +14195,34 @@ describe('createServeApp', () => {
])(
'%s the persisted branch when generation cleanup kills=%s',
async (_label, killed, expectedRemovals) => {
const runtimeDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-branch-cleanup-'),
);
const staleBranchId = '550e8400-e29b-41d4-a716-446655440125';
const chatsDir = path.join(
new Storage(WS_BOUND, runtimeDir).getProjectDir(),
'chats',
);
await fsp.mkdir(chatsDir, { recursive: true });
await fsp.writeFile(
path.join(chatsDir, `${staleBranchId}.jsonl`),
`${JSON.stringify({
uuid: `${staleBranchId}-user-1`,
parentUuid: null,
sessionId: staleBranchId,
timestamp: '2026-07-29T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello' }] },
cwd: WS_BOUND,
})}\n`,
'utf8',
);
const generationGuard = createWorkspaceGenerationGuard();
const bridge = fakeBridge();
bridge.branchSession = vi.fn(async (sessionId) => {
generationGuard.close();
return {
sessionId: 'stale-branch',
sessionId: staleBranchId,
workspaceCwd: WS_BOUND,
attached: false,
clientId: 'stale-client',
@ -14166,6 +14240,7 @@ describe('createServeApp', () => {
const runtime = makeWorkspaceRuntimeForTest({
workspaceId: 'branch-primary',
workspaceCwd: WS_BOUND,
sessionRuntimeBaseDir: runtimeDir,
primary: true,
bridge,
generationGuard,
@ -14184,16 +14259,17 @@ describe('createServeApp', () => {
expect(res.status).toBe(503);
expect(res.body.code).toBe('workspace_runtime_unavailable');
expect(killSpy).toHaveBeenCalledWith('stale-branch', {
expect(killSpy).toHaveBeenCalledWith(staleBranchId, {
requireZeroAttaches: true,
});
expect(removeSpy).toHaveBeenCalledTimes(expectedRemovals);
if (killed) {
expect(removeSpy).toHaveBeenCalledWith('stale-branch');
expect(removeSpy).toHaveBeenCalledWith(staleBranchId);
}
} finally {
killSpy.mockRestore();
removeSpy.mockRestore();
await fsp.rm(runtimeDir, { recursive: true, force: true });
}
},
);
@ -17622,7 +17698,7 @@ describe('createServeApp', () => {
});
});
it('keeps archive blocked while a legacy export is in flight', async () => {
it('reports an archive conflict while a legacy export is in flight', async () => {
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeef';
await writeExportSession(sid);
let loadStarted!: () => void;
@ -17656,10 +17732,15 @@ describe('createServeApp', () => {
.post('/sessions/archive')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ sessionIds: [sid] });
expect(archive.status).toBe(409);
expect(archive.status).toBe(200);
expect(archive.body).toMatchObject({
code: 'session_archiving',
sessionId: sid,
archived: [],
errors: [
{
sessionId: sid,
error: expect.stringContaining('is being archived or unarchived'),
},
],
});
releaseLoad();
@ -18770,6 +18851,8 @@ describe('createServeApp', () => {
});
it('returns per-id errors when removeSession throws unexpectedly', async () => {
const sessionId = 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee';
await writeSession(sessionId);
const spy = vi
.spyOn(SessionService.prototype, 'removeSession')
.mockRejectedValueOnce(new Error('disk on fire'));
@ -18781,11 +18864,11 @@ describe('createServeApp', () => {
const res = await request(app)
.post('/sessions/delete')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ sessionIds: ['aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee'] });
.send({ sessionIds: [sessionId] });
expect(res.status).toBe(200);
expect(res.body.errors).toEqual([
{
sessionId: 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee',
sessionId,
error: 'disk on fire',
},
]);
@ -18971,7 +19054,7 @@ describe('createServeApp', () => {
).rejects.toThrow();
});
it('does not close a live session when no active JSONL exists', async () => {
it('returns notFound after closing a live session with no active JSONL', async () => {
const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee';
const bridge = fakeBridge();
const app = createArchiveApp(bridge);
@ -18988,7 +19071,13 @@ describe('createServeApp', () => {
notFound: [sid],
errors: [],
});
expect(bridge.closeCalls).toHaveLength(0);
expect(bridge.closeCalls).toEqual([
{
sessionId: sid,
clientId: undefined,
closeOpts: { requireAgentClose: true },
},
]);
});
it('unarchives by moving JSONL back into active chats', async () => {
@ -19180,7 +19269,7 @@ describe('createServeApp', () => {
expect(archiveRes.body.archived).toEqual([sid]);
});
it('returns session_archiving for archive while load is in flight', async () => {
it('reports an archive conflict while load is in flight', async () => {
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
await writeSession(sid);
let loadStarted!: () => void;
@ -19218,12 +19307,16 @@ describe('createServeApp', () => {
.post('/sessions/archive')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({ sessionIds: [sid] });
expect(archiveRes.status).toBe(409);
expect(archiveRes.status).toBe(200);
expect(archiveRes.body).toMatchObject({
code: 'session_archiving',
sessionId: sid,
archived: [],
errors: [
{
sessionId: sid,
error: expect.stringContaining('is being archived or unarchived'),
},
],
});
expect(archiveRes.body.error).toContain('being archived or unarchived');
releaseLoad();
const loadRes = await loadPromise;

View file

@ -9,6 +9,7 @@ import type { Application } from 'express';
import type { DaemonStatusProvider } from '@qwen-code/acp-bridge';
import {
hashDaemonWorkspace,
Storage,
type DurableCronTask,
} from '@qwen-code/qwen-code-core';
import type { DaemonLogger } from './daemon-logger.js';
@ -171,7 +172,10 @@ 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 {
deleteDaemonSessionIfOrphan,
SessionArchiveCoordinator,
} from './server/session-archive.js';
import { installSelfOriginStripMiddleware } from './server/self-origin.js';
import {
createSingleWorkspaceRegistry,
@ -181,6 +185,10 @@ import {
type WorkspaceRuntime,
type WorkspaceRuntimeEnvMetadata,
} from './workspace-registry.js';
import {
createWorkspaceRuntimeSessionService,
runWithWorkspaceRuntimeStorage,
} from './workspace-runtime-storage.js';
import {
isScratchRootCompatible,
type ManagedScratchRoot,
@ -935,6 +943,21 @@ export function createServeApp(
defaultBridgeForAdmission = bridge;
}
const archiveCoordinator = new SessionArchiveCoordinator();
(
app.locals as {
sessionArchiveCoordinator?: SessionArchiveCoordinator;
}
).sessionArchiveCoordinator = archiveCoordinator;
const cleanupSession = (runtime: WorkspaceRuntime, sessionId: string) =>
runWithWorkspaceRuntimeStorage(runtime, () =>
deleteDaemonSessionIfOrphan({
sessionId,
service: createWorkspaceRuntimeSessionService(runtime),
bridge: runtime.bridge,
coordinator: archiveCoordinator,
}),
);
installSelfOriginStripMiddleware(app, getPort);
@ -1038,6 +1061,7 @@ export function createServeApp(
{
workspaceId: hashDaemonWorkspace(boundWorkspace),
workspaceCwd: boundWorkspace,
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
primary: true,
trusted: deps.primaryWorkspaceTrusted ?? false,
env: primaryRuntimeEnvMetadata ?? {
@ -1852,6 +1876,7 @@ export function createServeApp(
workspaceRegistry.primaryEntry.state === 'active'
? workspaceRegistry.primaryEntry.current?.runtime
: undefined,
cleanupSession,
channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations,
});
@ -1875,6 +1900,7 @@ export function createServeApp(
safeBody,
manageScheduledTaskSessions: deps.manageScheduledTaskSessions === true,
channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations,
cleanupSession,
});
// Read-only token-usage dashboard (Daemon Status "统计" tab). Aggregate local
@ -1918,28 +1944,27 @@ export function createServeApp(
// restart (a bound task fires only in its own session, which nothing else
// reloads). Fire-and-forget so it never delays the server coming up; a
// no-op when there are no bound tasks. Deliberately not awaited.
const rehydrateWorkspace = (
taskBridge: AcpSessionBridge,
workspaceCwd: string,
) => {
void rehydrateScheduledTaskSessions({
bridge: taskBridge,
boundWorkspace: workspaceCwd,
onTasksRead: (tasks) =>
registerScheduledTaskAuthorizations(workspaceCwd, tasks),
onError: (sessionId, err) => {
process.stderr.write(
`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
},
// Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already
// catches readCronTasks failures and per-session load errors internally
// (returning { loaded, failed }), so this only guards an unexpected throw
// from the function entry itself. Log rather than swallow it — a silent
// failure here leaves every bound task dormant with no diagnostic.
}).catch((err) => {
const rehydrateWorkspace = (runtime: WorkspaceRuntime) => {
void runWithWorkspaceRuntimeStorage(runtime, () =>
rehydrateScheduledTaskSessions({
bridge: runtime.bridge,
boundWorkspace: runtime.workspaceCwd,
onTasksRead: (tasks) =>
registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks),
onError: (sessionId, err) => {
process.stderr.write(
`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
},
// Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already
// catches readCronTasks failures and per-session load errors internally
// (returning { loaded, failed }), so this only guards an unexpected throw
// from the function entry itself. Log rather than swallow it — a silent
// failure here leaves every bound task dormant with no diagnostic.
}),
).catch((err) => {
process.stderr.write(
`qwen serve: unexpected scheduled-task rehydration failure: ${
err instanceof Error ? err.message : String(err)
@ -1961,10 +1986,12 @@ export function createServeApp(
bridge: runtime.bridge,
boundWorkspace: runtime.workspaceCwd,
intervalMs: keepaliveIntervalMs,
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
cleanupSession: (sessionId) => cleanupSession(runtime, sessionId),
onTasksRead: (tasks) =>
registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks),
});
rehydrateWorkspace(runtime.bridge, runtime.workspaceCwd);
rehydrateWorkspace(runtime);
keepaliveStops.set(runtime.workspaceCwd, keepalive.stop);
};
for (const runtime of workspaceRegistry.list()) {

View file

@ -13,6 +13,7 @@ import {
SessionWriterUnavailableError,
} from '@qwen-code/qwen-code-core';
import { sendBridgeError } from './error-response.js';
import { DaemonDrainingError } from './session-archive.js';
function responseMock(): {
response: Response;
@ -28,6 +29,20 @@ function responseMock(): {
}
describe('sendBridgeError session writer errors', () => {
it('maps sealed session maintenance to daemon_draining', () => {
const { response, status, json } = responseMock();
sendBridgeError(response, new DaemonDrainingError());
expect(status).toHaveBeenCalledWith(503);
expect(json).toHaveBeenCalledWith({
error:
'The daemon is draining and no longer accepts session maintenance.',
code: 'daemon_draining',
errorKind: 'daemon_draining',
});
});
it.each([
{
error: new SessionWriterConflictError(),

View file

@ -56,6 +56,7 @@ import {
WorkspaceSkillNotToggleableError,
} from '../workspace-service/types.js';
import { sendGenerationClosedError } from '../workspace-route-runtime.js';
import { DaemonDrainingError } from './session-archive.js';
export type BridgeErrorContext = {
route?: string;
@ -169,6 +170,14 @@ export function sendBridgeError(
ctx?: BridgeErrorContext,
daemonLog?: DaemonLogger,
): void {
if (err instanceof DaemonDrainingError) {
res.status(503).json({
error: err.message,
code: err.code,
errorKind: err.code,
});
return;
}
if (sendGenerationClosedError(res, err)) return;
if (err instanceof SessionWriterError) {
res.status(err.httpStatus).json({

View file

@ -10,7 +10,11 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SessionService,
SessionWriterConflictError,
SessionWriterLostError,
type SessionWriterLease,
Storage,
getCronFilePath,
readCronTasks,
updateCronTasks,
} from '@qwen-code/qwen-code-core';
@ -25,9 +29,11 @@ import {
archiveDaemonSessions,
assertSessionArchived,
assertSessionLoadable,
deleteDaemonSessionIfOrphan,
deleteDaemonSessions,
SessionArchiveCoordinator,
unarchiveDaemonSessions,
DaemonDrainingError,
} from './session-archive.js';
describe('assertSessionLoadable', () => {
@ -188,6 +194,47 @@ describe('SessionArchiveCoordinator', () => {
coordinator.runExclusiveMany([sessionId], async () => 'ok'),
).resolves.toBe('ok');
});
it('seals new maintenance and waits only for admitted exclusive work', async () => {
const coordinator = new SessionArchiveCoordinator();
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const maintenance = coordinator.runExclusiveMany(['session-a'], () => gate);
const drain = coordinator.sealMaintenanceAndWait();
await expect(
coordinator.runExclusiveMany(['session-b'], async () => undefined),
).rejects.toMatchObject({ code: 'daemon_draining' });
let drained = false;
void drain.then(() => {
drained = true;
});
await Promise.resolve();
expect(drained).toBe(false);
finish();
await maintenance;
await drain;
expect(drained).toBe(true);
});
it('does not wait for shared transcript reads when sealed', async () => {
const coordinator = new SessionArchiveCoordinator();
let finish!: () => void;
const shared = coordinator.runSharedMany(
['session-a'],
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
await expect(coordinator.sealMaintenanceAndWait()).resolves.toBeUndefined();
finish();
await shared;
});
});
describe('archiveDaemonSessions', () => {
@ -273,30 +320,354 @@ describe('archiveDaemonSessions', () => {
expect(byId['other']!.enabled).toBeUndefined(); // unrelated — untouched
});
it('does not lock ids that are already archived or missing', async () => {
it('does not acquire writer leases for ids 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();
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
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: [],
});
const result = await archiveDaemonSessions({
sessionIds: [archivedId, missingId],
service,
bridge: { closeSession },
coordinator: new SessionArchiveCoordinator(),
});
expect(closeSession).not.toHaveBeenCalled();
expect(result).toEqual({
archived: [],
alreadyArchived: [archivedId],
notFound: [missingId],
errors: [],
});
expect(acquire).not.toHaveBeenCalled();
expect(closeSession).toHaveBeenCalledTimes(2);
});
it('does not archive while another writer holds the lease', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440005';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
const blocked = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(blocked.archived).toEqual([]);
expect(blocked.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
await lease.release();
const retried = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(retried.archived).toEqual([sessionId]);
});
it('keeps independent batch sessions moving when one writer conflicts', async () => {
const blockedId = '550e8400-e29b-41d4-a716-446655440008';
const availableId = '550e8400-e29b-41d4-a716-446655440009';
writeSessionFile(workspaceDir, blockedId, 'active');
writeSessionFile(workspaceDir, availableId, 'active');
const service = new SessionService(workspaceDir);
const lease = await service.acquireSessionWriterLease(blockedId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
const result = await archiveDaemonSessions({
sessionIds: [blockedId, availableId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([availableId]);
expect(result.errors[0]?.sessionId).toBe(blockedId);
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
await lease.release();
});
it('reports a gate race per session after another batch item was archived', async () => {
const archivedId = '550e8400-e29b-41d4-a716-446655440023';
const blockedId = '550e8400-e29b-41d4-a716-446655440024';
writeSessionFile(workspaceDir, archivedId, 'active');
writeSessionFile(workspaceDir, blockedId, 'active');
const coordinator = new SessionArchiveCoordinator();
let releaseBlocked!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseBlocked = resolve;
});
let competingMaintenance: Promise<void> | undefined;
const result = await archiveDaemonSessions({
sessionIds: [archivedId, blockedId],
service: new SessionService(workspaceDir),
bridge: {
closeSession: vi.fn(async (sessionId) => {
if (sessionId === archivedId) {
competingMaintenance = coordinator.runExclusiveMany(
[blockedId],
() => blocked,
);
}
}),
},
coordinator,
});
try {
expect(result.archived).toEqual([archivedId]);
expect(result.errors).toEqual([
{
sessionId: blockedId,
error: expect.any(SessionArchivingError),
},
]);
} finally {
releaseBlocked();
await competingMaintenance;
}
});
it('keeps independent batch sessions moving when one classification fails', async () => {
const failedId = '550e8400-e29b-41d4-a716-446655440019';
const availableId = '550e8400-e29b-41d4-a716-446655440020';
writeSessionFile(workspaceDir, availableId, 'active');
const service = new SessionService(workspaceDir);
const getLocation = service.getSessionLocation.bind(service);
const failure = new Error('classification failed');
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
);
const result = await archiveDaemonSessions({
sessionIds: [failedId, availableId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([availableId]);
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
});
it('does not acquire a lease or mutate when closing the owner fails', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440017';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
const closeError = new Error('agent flush failed');
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockRejectedValue(closeError) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([]);
expect(result.errors).toEqual([{ sessionId, error: closeError }]);
expect(acquire).not.toHaveBeenCalled();
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
});
it('uses the classification made after acquiring the lease', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440010';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const originalGetLocation = service.getSessionLocation.bind(service);
let classifications = 0;
vi.spyOn(service, 'getSessionLocation').mockImplementation(async (id) => {
classifications++;
if (classifications === 2) {
fs.mkdirSync(path.dirname(sessionPath(workspaceDir, id, 'archived')), {
recursive: true,
});
fs.renameSync(
sessionPath(workspaceDir, id, 'active'),
sessionPath(workspaceDir, id, 'archived'),
);
}
return originalGetLocation(id);
});
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result).toEqual({
archived: [],
alreadyArchived: [sessionId],
notFound: [],
errors: [],
});
const reacquired = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
await reacquired.release();
});
it('does not lock an active/archive conflict', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440016';
writeSessionFile(workspaceDir, sessionId, 'active');
writeSessionFile(workspaceDir, sessionId, 'archived');
const service = new SessionService(workspaceDir);
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([]);
expect(result.errors).toHaveLength(1);
expect(acquire).not.toHaveBeenCalled();
});
it('does not report success after release fails but reconciles the task to the applied archive', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440006';
writeSessionFile(workspaceDir, sessionId, 'active');
await updateCronTasks(workspaceDir, () => [
{
id: 'bound',
cron: '0 9 * * *',
prompt: 'p',
recurring: true,
createdAt: 1_700_000_000_000,
lastFiredAt: null,
sessionId,
},
]);
const service = new SessionService(workspaceDir);
const release = vi.fn(async () => {
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
throw new SessionWriterLostError();
});
vi.spyOn(service, 'acquireSessionWriterLease').mockResolvedValue({
assertOwnedAndUnchanged: vi.fn().mockResolvedValue(undefined),
release,
} as unknown as SessionWriterLease);
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([]);
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterLostError);
expect(
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
).toBe(true);
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
expect(release).toHaveBeenCalledOnce();
});
it('releases the lease when scheduled-task reconciliation fails', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440018';
writeSessionFile(workspaceDir, sessionId, 'active');
fs.mkdirSync(getCronFilePath(workspaceDir), { recursive: true });
const service = new SessionService(workspaceDir);
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([sessionId]);
const reacquired = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
await reacquired.release();
});
it('checks only the selected runtime root for transcripts and locks', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440007';
const primaryRuntime = path.join(runtimeDir, 'primary');
const secondaryRuntime = path.join(runtimeDir, 'secondary');
writeSessionFile(
workspaceDir,
sessionId,
'active',
workspaceDir,
secondaryRuntime,
);
const primaryService = new SessionService(workspaceDir, {
runtimeBaseDir: primaryRuntime,
});
const primaryLease = await primaryService.acquireSessionWriterLease(
sessionId,
{
processKind: 'daemon',
reclaimPolicy: 'never',
},
);
const result = await archiveDaemonSessions({
sessionIds: [sessionId],
service: new SessionService(workspaceDir, {
runtimeBaseDir: secondaryRuntime,
}),
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.archived).toEqual([sessionId]);
expect(
fs.existsSync(
sessionPath(workspaceDir, sessionId, 'archived', secondaryRuntime),
),
).toBe(true);
expect(
fs.existsSync(
sessionPath(workspaceDir, sessionId, 'active', primaryRuntime),
),
).toBe(false);
await primaryLease.release();
});
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440080';
writeSessionFile(workspaceDir, sessionId, 'active');
const coordinator = new SessionArchiveCoordinator();
await coordinator.sealMaintenanceAndWait();
await expect(
archiveDaemonSessions({
sessionIds: [sessionId],
service: new SessionService(workspaceDir),
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator,
}),
).rejects.toThrow(DaemonDrainingError);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
});
});
@ -324,22 +695,19 @@ describe('unarchiveDaemonSessions', () => {
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: [],
});
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
const result = await unarchiveDaemonSessions({
sessionIds: [archivedId, activeId, missingId, archivedId],
service,
coordinator: new SessionArchiveCoordinator(),
});
expect(result).toEqual({
unarchived: [archivedId],
alreadyActive: [activeId],
notFound: [missingId],
errors: [],
});
expect(acquire).toHaveBeenCalledTimes(1);
expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe(
true,
);
@ -348,6 +716,29 @@ describe('unarchiveDaemonSessions', () => {
).toBe(false);
});
it('does not unarchive while another writer holds the lease', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440015';
writeSessionFile(workspaceDir, sessionId, 'archived');
const service = new SessionService(workspaceDir);
const lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
const result = await unarchiveDaemonSessions({
sessionIds: [sessionId],
service,
coordinator: new SessionArchiveCoordinator(),
});
expect(result.unarchived).toEqual([]);
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
expect(
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
).toBe(true);
await lease.release();
});
it('reports a single error per archived id when unarchive batch fails', async () => {
const archivedId = '550e8400-e29b-41d4-a716-446655440014';
writeSessionFile(workspaceDir, archivedId, 'archived');
@ -367,6 +758,75 @@ describe('unarchiveDaemonSessions', () => {
notFound: [],
errors: [{ sessionId: archivedId, error: failure }],
});
const reacquired = await service.acquireSessionWriterLease(archivedId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
await reacquired.release();
});
it('keeps independent unarchive sessions moving when one classification fails', async () => {
const failedId = '550e8400-e29b-41d4-a716-446655440021';
const availableId = '550e8400-e29b-41d4-a716-446655440022';
writeSessionFile(workspaceDir, availableId, 'archived');
const service = new SessionService(workspaceDir);
const getLocation = service.getSessionLocation.bind(service);
const failure = new Error('classification failed');
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
);
const result = await unarchiveDaemonSessions({
sessionIds: [failedId, availableId],
service,
coordinator: new SessionArchiveCoordinator(),
});
expect(result.unarchived).toEqual([availableId]);
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
});
it('reports a gate race per session after another batch item was unarchived', async () => {
const unarchivedId = '550e8400-e29b-41d4-a716-446655440025';
const blockedId = '550e8400-e29b-41d4-a716-446655440026';
writeSessionFile(workspaceDir, unarchivedId, 'archived');
writeSessionFile(workspaceDir, blockedId, 'archived');
const service = new SessionService(workspaceDir);
const getLocation = service.getSessionLocation.bind(service);
const coordinator = new SessionArchiveCoordinator();
let releaseBlocked!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseBlocked = resolve;
});
let competingMaintenance: Promise<void> | undefined;
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => {
if (sessionId === unarchivedId && !competingMaintenance) {
competingMaintenance = coordinator.runExclusiveMany(
[blockedId],
() => blocked,
);
}
return getLocation(sessionId);
});
const result = await unarchiveDaemonSessions({
sessionIds: [unarchivedId, blockedId],
service,
coordinator,
});
try {
expect(result.unarchived).toEqual([unarchivedId]);
expect(result.errors).toEqual([
{
sessionId: blockedId,
error: expect.any(SessionArchivingError),
},
]);
} finally {
releaseBlocked();
await competingMaintenance;
}
});
it('re-enables an archive-disabled task bound to the unarchived session', async () => {
@ -434,6 +894,24 @@ describe('unarchiveDaemonSessions', () => {
expect(stranded!.enabled).toBe(true); // recovered
expect(stranded!.disabledByArchive).toBeUndefined();
});
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440081';
writeSessionFile(workspaceDir, sessionId, 'archived');
const coordinator = new SessionArchiveCoordinator();
await coordinator.sealMaintenanceAndWait();
await expect(
unarchiveDaemonSessions({
sessionIds: [sessionId],
service: new SessionService(workspaceDir),
coordinator,
}),
).rejects.toThrow(DaemonDrainingError);
expect(
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
).toBe(true);
});
});
describe('deleteDaemonSessions', () => {
@ -487,6 +965,186 @@ describe('deleteDaemonSessions', () => {
const ids = (await readCronTasks(workspaceDir)).map((t) => t.id).sort();
expect(ids).toEqual(['other']); // bound task deleted, unbound survives
});
it('does not delete while another writer holds the lease', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440071';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
const result = await deleteDaemonSessions({
sessionIds: [sessionId],
service,
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator: new SessionArchiveCoordinator(),
});
expect(result.removed).toEqual([]);
expect(result.errors).toEqual([
{
sessionId,
error: 'This session is already open in another Qwen process.',
},
]);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
await lease.release();
});
it('reports a gate race per session after another batch item was deleted', async () => {
const removedId = '550e8400-e29b-41d4-a716-446655440073';
const blockedId = '550e8400-e29b-41d4-a716-446655440074';
writeSessionFile(workspaceDir, removedId, 'active');
writeSessionFile(workspaceDir, blockedId, 'active');
const coordinator = new SessionArchiveCoordinator();
let releaseBlocked!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseBlocked = resolve;
});
let competingMaintenance: Promise<void> | undefined;
try {
const result = await deleteDaemonSessions({
sessionIds: [removedId, blockedId],
service: new SessionService(workspaceDir),
bridge: {
closeSession: vi.fn(async (sessionId) => {
if (sessionId === removedId) {
competingMaintenance = coordinator.runExclusiveMany(
[blockedId],
() => blocked,
);
}
}),
},
coordinator,
});
expect(result.removed).toEqual([removedId]);
expect(result.errors).toEqual([
{
sessionId: blockedId,
error: expect.stringContaining('is being archived or unarchived'),
},
]);
expect(
fs.existsSync(sessionPath(workspaceDir, removedId, 'active')),
).toBe(false);
expect(
fs.existsSync(sessionPath(workspaceDir, blockedId, 'active')),
).toBe(true);
} finally {
releaseBlocked();
await competingMaintenance;
}
});
it('skips orphan deletion when a new owner attached', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440072';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
await expect(
deleteDaemonSessionIfOrphan({
sessionId,
service,
bridge: { killSession: vi.fn().mockResolvedValue(false) },
coordinator: new SessionArchiveCoordinator(),
}),
).resolves.toBe(false);
expect(acquire).not.toHaveBeenCalled();
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
});
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440082';
writeSessionFile(workspaceDir, sessionId, 'active');
const coordinator = new SessionArchiveCoordinator();
await coordinator.sealMaintenanceAndWait();
await expect(
deleteDaemonSessions({
sessionIds: [sessionId],
service: new SessionService(workspaceDir),
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
coordinator,
}),
).rejects.toThrow(DaemonDrainingError);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
});
it('deletes the transcript when killSession resolves true', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440083';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
await expect(
deleteDaemonSessionIfOrphan({
sessionId,
service,
bridge: { killSession: vi.fn().mockResolvedValue(true) },
coordinator: new SessionArchiveCoordinator(),
}),
).resolves.toBe(true);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
false,
);
});
it('deletes the transcript when killSession throws SessionNotFoundError', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440084';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
await expect(
deleteDaemonSessionIfOrphan({
sessionId,
service,
bridge: {
killSession: vi
.fn()
.mockRejectedValue(new SessionNotFoundError(sessionId)),
},
coordinator: new SessionArchiveCoordinator(),
}),
).resolves.toBe(true);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
false,
);
});
it('throws when the lease is held by another writer', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440085';
writeSessionFile(workspaceDir, sessionId, 'active');
const service = new SessionService(workspaceDir);
const lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
await expect(
deleteDaemonSessionIfOrphan({
sessionId,
service,
bridge: { killSession: vi.fn().mockResolvedValue(true) },
coordinator: new SessionArchiveCoordinator(),
}),
).rejects.toThrow(SessionWriterConflictError);
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
true,
);
await lease.release();
});
});
function writeSessionFile(
@ -494,9 +1152,10 @@ function writeSessionFile(
sessionId: string,
state: 'active' | 'archived',
recordCwd = workspaceDir,
runtimeBaseDir?: string,
): void {
const chatsDir = path.join(
new Storage(workspaceDir).getProjectDir(),
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
'chats',
);
const targetDir =
@ -521,9 +1180,10 @@ function sessionPath(
workspaceDir: string,
sessionId: string,
state: 'active' | 'archived',
runtimeBaseDir?: string,
): string {
const chatsDir = path.join(
new Storage(workspaceDir).getProjectDir(),
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
'chats',
);
return path.join(

View file

@ -46,9 +46,23 @@ export interface DaemonDeleteSessionsResult {
export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete';
export class DaemonDrainingError extends Error {
override readonly name = 'DaemonDrainingError';
readonly code = 'daemon_draining';
constructor() {
super('The daemon is draining and no longer accepts session maintenance.');
}
}
export class SessionArchiveCoordinator {
private readonly exclusive = new Set<string>();
private readonly shared = new Map<string, number>();
private maintenanceSealed = false;
private activeMaintenance = 0;
private maintenanceDrain:
| { promise: Promise<void>; resolve: () => void }
| undefined;
assertNotTransitioning(sessionId: string): void {
if (this.exclusive.has(sessionId)) {
@ -60,6 +74,9 @@ export class SessionArchiveCoordinator {
sessionIds: string[],
fn: () => Promise<T>,
): Promise<T> {
if (this.maintenanceSealed) {
throw new DaemonDrainingError();
}
const uniqueSessionIds = [...new Set(sessionIds)];
for (const sessionId of uniqueSessionIds) {
this.assertNotTransitioning(sessionId);
@ -70,15 +87,36 @@ export class SessionArchiveCoordinator {
for (const sessionId of uniqueSessionIds) {
this.exclusive.add(sessionId);
}
this.activeMaintenance++;
try {
return await fn();
} finally {
for (const sessionId of uniqueSessionIds) {
this.exclusive.delete(sessionId);
}
this.activeMaintenance--;
if (this.activeMaintenance === 0) {
this.maintenanceDrain?.resolve();
this.maintenanceDrain = undefined;
}
}
}
sealMaintenanceAndWait(): Promise<void> {
this.maintenanceSealed = true;
if (this.activeMaintenance === 0) {
return Promise.resolve();
}
if (!this.maintenanceDrain) {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
this.maintenanceDrain = { promise, resolve };
}
return this.maintenanceDrain.promise;
}
async runSharedMany<T>(
sessionIds: string[],
fn: () => Promise<T>,
@ -105,6 +143,225 @@ export class SessionArchiveCoordinator {
}
}
type DaemonMaintenanceAction = 'delete' | 'archive' | 'unarchive';
interface LeaseMutationResult<T> {
value?: T;
mutationApplied: boolean;
error?: unknown;
maintenanceError?: unknown;
}
async function runWithDaemonWriterLease<T>(params: {
action: DaemonMaintenanceAction;
sessionId: string;
service: SessionService;
mutate: (
assertOwnedAndUnchanged: () => Promise<void>,
) => Promise<{ value: T; mutationApplied: boolean }>;
mutationAppliedAfterError: () => Promise<boolean>;
afterMutationApplied: () => Promise<void>;
}): Promise<LeaseMutationResult<T>> {
const {
action,
sessionId,
service,
mutate,
mutationAppliedAfterError,
afterMutationApplied,
} = params;
let lease;
try {
lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
} catch (error) {
return { mutationApplied: false, error };
}
let value: T | undefined;
let mutationApplied = false;
let mutationError: unknown;
try {
const mutation = await mutate(() => lease.assertOwnedAndUnchanged());
value = mutation.value;
mutationApplied = mutation.mutationApplied;
} catch (error) {
mutationError = error;
try {
mutationApplied = await mutationAppliedAfterError();
} catch {
mutationApplied = false;
}
}
let maintenanceError: unknown;
if (mutationApplied) {
try {
await afterMutationApplied();
} catch (error) {
maintenanceError = error;
logSessionArchiveWarning(
`scheduled task lifecycle update failed action=${action} workspace=${safeLogValue(
service.getProjectRoot(),
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
errorMessage(error),
)}`,
);
}
}
let releaseError: unknown;
try {
await lease.release();
} catch (error) {
releaseError = error;
}
if (releaseError !== undefined) {
logMaintenanceLeaseReleaseFailure({
action,
workspace: service.getProjectRoot(),
sessionId,
error: releaseError,
mutationApplied,
});
if (mutationError !== undefined) {
logSessionArchiveWarning(
`session maintenance mutation also failed action=${action} workspace=${safeLogValue(
service.getProjectRoot(),
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
errorMessage(mutationError),
)}`,
);
}
return { mutationApplied, error: releaseError, maintenanceError };
}
if (mutationError !== undefined) {
return { mutationApplied, error: mutationError, maintenanceError };
}
return { value, mutationApplied, maintenanceError };
}
function logMaintenanceLeaseReleaseFailure(params: {
action: DaemonMaintenanceAction;
workspace: string;
sessionId: string;
error: unknown;
mutationApplied: boolean;
}): void {
const errorKind =
typeof params.error === 'object' &&
params.error !== null &&
typeof (params.error as { errorKind?: unknown }).errorKind === 'string'
? (params.error as { errorKind: string }).errorKind
: 'unknown';
logSessionArchiveWarning(
`session maintenance lease release failed action=${params.action} workspace=${safeLogValue(
params.workspace,
)} session=${safeLogValue(params.sessionId)} errorKind=${safeLogValue(
errorKind,
)} mutationApplied=${params.mutationApplied}`,
);
}
async function classifySessionLocation(
service: SessionService,
sessionId: string,
): Promise<SessionLocation> {
return service.getSessionLocation(sessionId);
}
function sessionLocationError(sessionId: string): Error {
return new Error(`Session archive conflict: ${sessionId}`);
}
function updateScheduledTaskForMaintenance(
service: SessionService,
sessionId: string,
action: DaemonMaintenanceAction,
): Promise<void> {
if (action === 'archive') {
return disableTasksForSessions(service.getProjectRoot(), [sessionId]);
}
if (action === 'unarchive') {
return enableTasksForSessions(service.getProjectRoot(), [sessionId]);
}
return removeTasksForSessions(service.getProjectRoot(), [sessionId]);
}
type DeleteOneResult =
| {
kind: 'removed';
mutationApplied: boolean;
}
| {
kind: 'notFound';
mutationApplied: boolean;
}
| {
kind: 'error';
error: unknown;
mutationApplied: boolean;
};
async function deletePersistedSessionWithLease(
service: SessionService,
sessionId: string,
): Promise<DeleteOneResult> {
const initialLocation = await classifySessionLocation(service, sessionId);
if (initialLocation === undefined) {
return { kind: 'notFound', mutationApplied: false };
}
if (initialLocation === 'conflict') {
return {
kind: 'error',
error: sessionLocationError(sessionId),
mutationApplied: false,
};
}
const mutation = await runWithDaemonWriterLease({
action: 'delete',
sessionId,
service,
mutate: async (assertOwnedAndUnchanged) => {
const lockedLocation = await classifySessionLocation(service, sessionId);
if (lockedLocation === undefined) {
return {
value: 'notFound' as const,
mutationApplied: false,
};
}
if (lockedLocation === 'conflict') {
throw sessionLocationError(sessionId);
}
await assertOwnedAndUnchanged();
const removed = await service.removeSession(sessionId);
return {
value: removed ? ('removed' as const) : ('notFound' as const),
mutationApplied: removed,
};
},
mutationAppliedAfterError: async () =>
(await classifySessionLocation(service, sessionId)) === undefined,
afterMutationApplied: () =>
updateScheduledTaskForMaintenance(service, sessionId, 'delete'),
});
if (mutation.error !== undefined) {
return {
kind: 'error',
error: mutation.error,
mutationApplied: mutation.mutationApplied,
};
}
return {
kind: mutation.value ?? 'notFound',
mutationApplied: mutation.mutationApplied,
};
}
export async function deleteDaemonSessions(params: {
sessionIds: string[];
service: SessionService;
@ -118,98 +375,131 @@ export async function deleteDaemonSessions(params: {
}): 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(
const results = 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;
return await coordinator.runExclusiveMany([sessionId], async () => {
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 });
} catch (error) {
if (isSessionNotFoundError(error)) {
const result = await deletePersistedSessionWithLease(
service,
sessionId,
);
if (result.kind === 'error') {
onError?.({
phase: 'remove',
sessionId,
error: errorMessage(result.error),
});
}
return result;
}
onError?.({
phase: 'close',
sessionId,
error: errorMessage(error),
});
return {
kind: 'error' as const,
error,
mutationApplied: false,
};
}
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 });
const result = await deletePersistedSessionWithLease(
service,
sessionId,
);
if (result.kind === 'error') {
onError?.({
phase: 'remove',
sessionId,
error: errorMessage(result.error),
});
}
return result;
});
} catch (err) {
if (
err instanceof SessionArchivingError &&
err.lockKind === 'exclusive'
) {
throw err;
} catch (error) {
if (error instanceof DaemonDrainingError) {
throw error;
}
const message = err instanceof Error ? err.message : String(err);
onError?.({ phase: 'delete', sessionId, error: message });
closeErrors.push({ sessionId, error: message });
onError?.({
phase: 'delete',
sessionId,
error: errorMessage(error),
});
return {
kind: 'error' as const,
error,
mutationApplied: false,
};
}
}),
);
// Deleting a session permanently removes any scheduled task bound to it —
// the task existed only to run in that session. Best-effort: a failure here
// must not turn a successful session delete into an error, but LOG it (like
// the archive/unarchive paths) — the session is already gone, so a swallowed
// write failure leaves the still-enabled bound task a permanent ghost the
// keepalive retries a doomed revive on every tick.
await removeTasksForSessions(service.getProjectRoot(), removed).catch(
(err: unknown) => {
logSessionArchiveWarning(
`removeTasksForSessions failed for [${removed.join(', ')}]: ${
err instanceof Error ? err.message : String(err)
}`,
);
},
);
const removed: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: unknown }> = [];
for (let i = 0; i < results.length; i++) {
const sessionId = uniqueSessionIds[i]!;
const result = results[i]!;
if (result.kind === 'removed') {
removed.push(sessionId);
} else if (result.kind === 'notFound') {
notFound.push(sessionId);
} else {
errors.push({ sessionId, error: errorMessage(result.error) });
}
}
return { removed, notFound, errors: [...closeErrors, ...removeErrors] };
return { removed, notFound, errors };
}
export async function deleteDaemonSessionIfOrphan(params: {
sessionId: string;
service: SessionService;
bridge: Pick<AcpSessionBridge, 'killSession'>;
coordinator: SessionArchiveCoordinator;
}): Promise<boolean> {
const { sessionId, service, bridge, coordinator } = params;
coordinator.assertNotTransitioning(sessionId);
const result = await coordinator.runExclusiveMany([sessionId], async () => {
let killed = false;
try {
killed = await bridge.killSession(sessionId, {
requireZeroAttaches: true,
});
} catch (error) {
if (!isSessionNotFoundError(error)) throw error;
killed = true;
}
if (!killed) {
return undefined;
}
return deletePersistedSessionWithLease(service, sessionId);
});
if (result === undefined) {
return false;
}
if (result.kind === 'error') {
throw result.error;
}
return true;
}
export async function assertSessionLoadable(
workspaceCwd: string,
sessionId: string,
runtimeBaseDir?: string,
): Promise<SessionLocation> {
const location = await new SessionService(workspaceCwd).getSessionLocation(
sessionId,
);
const location = await new SessionService(workspaceCwd, {
runtimeBaseDir,
}).getSessionLocation(sessionId);
if (location === 'archived') {
throw new SessionArchivedError(sessionId);
}
@ -222,10 +512,11 @@ export async function assertSessionLoadable(
export async function assertSessionArchived(
workspaceCwd: string,
sessionId: string,
runtimeBaseDir?: string,
): Promise<void> {
const location = await new SessionService(workspaceCwd).getSessionLocation(
sessionId,
);
const location = await new SessionService(workspaceCwd, {
runtimeBaseDir,
}).getSessionLocation(sessionId);
if (location === 'active') {
throw new SessionNotArchivedError(sessionId);
}
@ -244,53 +535,6 @@ function isSessionNotFoundError(err: unknown): boolean {
);
}
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: {
@ -355,66 +599,135 @@ export async function archiveDaemonSessions(params: {
}): 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) => {
for (const sessionId of uniqueSessionIds) {
coordinator.assertNotTransitioning(sessionId);
}
const results = await Promise.all(
uniqueSessionIds.map(async (sessionId) => {
try {
return await coordinator.runExclusiveMany([sessionId], async () => {
try {
await bridge.closeSession(sessionId, undefined, {
requireAgentClose: true,
});
} catch (err) {
if (!isSessionNotFoundError(err)) {
throw err;
} catch (error) {
if (!isSessionNotFoundError(error)) {
return {
kind: 'error' as const,
error,
mutationApplied: false,
};
}
}
}),
);
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',
const initialLocation = await classifySessionLocation(
service,
sessionId,
);
if (initialLocation === undefined) {
return { kind: 'notFound' as const, mutationApplied: false };
}
if (initialLocation === 'archived') {
return {
kind: 'alreadyArchived' as const,
mutationApplied: false,
};
}
if (initialLocation === 'conflict') {
return {
kind: 'error' as const,
error: sessionLocationError(sessionId),
mutationApplied: false,
};
}
const mutation = await runWithDaemonWriterLease({
action: 'archive',
sessionId,
service,
mutate: async (assertOwnedAndUnchanged) => {
const lockedLocation = await classifySessionLocation(
service,
sessionId,
);
if (lockedLocation === undefined) {
return {
value: 'notFound' as const,
mutationApplied: false,
};
}
if (lockedLocation === 'archived') {
return {
value: 'alreadyArchived' as const,
mutationApplied: false,
};
}
if (lockedLocation === 'conflict') {
throw sessionLocationError(sessionId);
}
await assertOwnedAndUnchanged();
const result = await service.archiveSessions([sessionId], {
knownLocation: 'active',
});
if (result.errors[0]) throw result.errors[0].error;
if (result.archived.length > 0) {
return {
value: 'archived' as const,
mutationApplied: true,
};
}
return {
value:
result.alreadyArchived.length > 0
? ('alreadyArchived' as const)
: ('notFound' as const),
mutationApplied: false,
};
},
mutationAppliedAfterError: async () =>
(await classifySessionLocation(service, sessionId)) ===
'archived',
afterMutationApplied: () =>
updateScheduledTaskForMaintenance(service, sessionId, 'archive'),
});
if (mutation.error !== undefined) {
return {
kind: 'error' as const,
error: mutation.error,
mutationApplied: mutation.mutationApplied,
};
}
return {
kind: mutation.value ?? 'notFound',
mutationApplied: mutation.mutationApplied,
};
});
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 });
} catch (error) {
if (error instanceof DaemonDrainingError) {
throw error;
}
return {
kind: 'error' as const,
error,
mutationApplied: false,
maintenanceError: undefined,
};
}
});
}),
);
const archived: string[] = [];
const alreadyArchived: string[] = [];
const notFound: string[] = [];
const errors: Array<{ sessionId: string; error: unknown }> = [];
for (let i = 0; i < results.length; i++) {
const sessionId = uniqueSessionIds[i]!;
const result = results[i]!;
if (result.kind === 'archived') archived.push(sessionId);
else if (result.kind === 'alreadyArchived') {
alreadyArchived.push(sessionId);
} else if (result.kind === 'notFound') notFound.push(sessionId);
else errors.push({ sessionId, error: result.error });
}
logSessionArchiveResult('archive', {
@ -425,22 +738,6 @@ export async function archiveDaemonSessions(params: {
errors,
});
// Archiving a session pauses any scheduled task bound to it (kept on disk,
// recoverable on unarchive). Best-effort — never fail the archive over it, but
// LOG a write failure: if the task's `enabled` flag isn't flipped, the
// keepalive still sees it enabled + bound and will revive the just-archived
// session so the task keeps firing. Logging makes that broken coupling
// diagnosable rather than silent.
await disableTasksForSessions(service.getProjectRoot(), archived).catch(
(err: unknown) => {
logSessionArchiveWarning(
`disableTasksForSessions failed for [${archived.join(', ')}]: ${
err instanceof Error ? err.message : String(err)
} bound tasks may keep firing until reconciled`,
);
},
);
return { archived, alreadyArchived, notFound, errors };
}
@ -451,43 +748,145 @@ export async function unarchiveDaemonSessions(params: {
}): Promise<DaemonUnarchiveSessionsResult> {
const { sessionIds, service, coordinator } = params;
const uniqueSessionIds = [...new Set(sessionIds)];
for (const sessionId of uniqueSessionIds) {
coordinator.assertNotTransitioning(sessionId);
}
const results = await Promise.all(
uniqueSessionIds.map(async (sessionId) => {
try {
return await coordinator.runExclusiveMany([sessionId], async () => {
const initialLocation = await classifySessionLocation(
service,
sessionId,
);
if (initialLocation === undefined) {
return { kind: 'notFound' as const, mutationApplied: false };
}
if (initialLocation === 'active') {
let maintenanceError: unknown;
try {
await updateScheduledTaskForMaintenance(
service,
sessionId,
'unarchive',
);
} catch (error) {
maintenanceError = error;
logSessionArchiveWarning(
`scheduled task lifecycle update failed action=unarchive workspace=${safeLogValue(
service.getProjectRoot(),
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
errorMessage(error),
)}`,
);
}
return {
kind: 'alreadyActive' as const,
mutationApplied: false,
maintenanceError,
};
}
if (initialLocation === 'conflict') {
return {
kind: 'error' as const,
error: sessionLocationError(sessionId),
mutationApplied: false,
};
}
const mutation = await runWithDaemonWriterLease({
action: 'unarchive',
sessionId,
service,
mutate: async (assertOwnedAndUnchanged) => {
const lockedLocation = await classifySessionLocation(
service,
sessionId,
);
if (lockedLocation === undefined) {
return {
value: 'notFound' as const,
mutationApplied: false,
};
}
if (lockedLocation === 'active') {
return {
value: 'alreadyActive' as const,
mutationApplied: false,
};
}
if (lockedLocation === 'conflict') {
throw sessionLocationError(sessionId);
}
await assertOwnedAndUnchanged();
const result = await service.unarchiveSessions([sessionId], {
knownLocation: 'archived',
});
if (result.errors[0]) throw result.errors[0].error;
if (result.unarchived.length > 0) {
return {
value: 'unarchived' as const,
mutationApplied: true,
};
}
return {
value:
result.alreadyActive.length > 0
? ('alreadyActive' as const)
: ('notFound' as const),
mutationApplied: false,
};
},
mutationAppliedAfterError: async () =>
(await classifySessionLocation(service, sessionId)) === 'active',
afterMutationApplied: () =>
updateScheduledTaskForMaintenance(
service,
sessionId,
'unarchive',
),
});
if (mutation.error !== undefined) {
return {
kind: 'error' as const,
error: mutation.error,
mutationApplied: mutation.mutationApplied,
};
}
return {
kind: mutation.value ?? 'notFound',
mutationApplied: mutation.mutationApplied,
maintenanceError: mutation.maintenanceError,
};
});
} catch (error) {
if (error instanceof DaemonDrainingError) {
throw error;
}
return {
kind: 'error' as const,
error,
mutationApplied: false,
maintenanceError: undefined,
};
}
}),
);
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 });
}
}
}
});
for (let i = 0; i < results.length; i++) {
const sessionId = uniqueSessionIds[i]!;
const result = results[i]!;
if (result.kind === 'unarchived') unarchived.push(sessionId);
else if (result.kind === 'alreadyActive') alreadyActive.push(sessionId);
else if (result.kind === 'notFound') notFound.push(sessionId);
else errors.push({ sessionId, error: result.error });
if (result.maintenanceError !== undefined) {
errors.push({ sessionId, error: result.maintenanceError });
}
}
logSessionArchiveResult('unarchive', {
@ -498,29 +897,5 @@ export async function unarchiveDaemonSessions(params: {
errors,
});
// Unarchiving a session resumes any scheduled task bound to it (re-enabled,
// anchor reset to now). Also run it for sessions that were ALREADY active:
// enableTasksForSessions is idempotent (it only re-enables archive-disabled
// tasks), so re-unarchiving a session whose task was stranded
// (`disabledByArchive: true`) by a PRIOR failed enable recovers it — otherwise
// that task is unrecoverable (PATCH-enable 409s on the stale flag, keepalive
// skips it). Surface a write failure in `errors` (and log it) instead of
// swallowing, so a stranded task isn't left silent.
const resumeSessionIds = [...new Set([...unarchived, ...alreadyActive])];
try {
await enableTasksForSessions(service.getProjectRoot(), resumeSessionIds);
} catch (err) {
logSessionArchiveWarning(
`enableTasksForSessions failed for [${resumeSessionIds.join(', ')}]: ${
err instanceof Error ? err.message : String(err)
}`,
);
// Report against the full resume set: a failed already-active recovery must
// surface too, or its stranded task stays silently unrecoverable.
for (const sessionId of resumeSessionIds) {
errors.push({ sessionId, error: err });
}
}
return { unarchived, alreadyActive, notFound, errors };
}

View file

@ -165,6 +165,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'workspace-1',
workspaceCwd: '/workspace',
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
bridge: {
getSessionTasksStatus: async () => ({
@ -288,6 +289,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'workspace-refresh-error',
workspaceCwd: '/workspace',
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
bridge: {
getSessionTasksStatus: async () => ({
@ -346,6 +348,7 @@ describe('VirtualSubagentSessions', () => {
return {
workspaceId,
workspaceCwd: `/workspace/${workspaceId}`,
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
bridge: {
getSessionTasksStatus: async () => ({
@ -413,6 +416,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'workspace-batch',
workspaceCwd: '/workspace',
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
bridge: {
getSessionTasksStatus: async () => ({
@ -481,6 +485,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'workspace-reload',
workspaceCwd: '/workspace',
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
env: { mode: 'parent-process', overlayKeys: [] },
bridge: {
getSessionTasksStatus: async () => ({
@ -592,6 +597,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'running-workspace',
workspaceCwd,
sessionRuntimeBaseDir: runtimeDir,
env: {
mode: 'runtime-overlay',
overlayKeys: ['QWEN_RUNTIME_DIR'],
@ -768,6 +774,7 @@ describe('VirtualSubagentSessions', () => {
const runtime = {
workspaceId: 'legacy-workspace',
workspaceCwd,
sessionRuntimeBaseDir: runtimeDir,
env: {
mode: 'runtime-overlay',
overlayKeys: ['QWEN_RUNTIME_DIR'],

View file

@ -735,10 +735,8 @@ export class VirtualSubagentSessions {
};
}
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
const projectDir = Storage.runWithRuntimeBaseDir(
runtimeDir,
runtime.workspaceCwd,
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
runtime.sessionRuntimeBaseDir,
() => new Storage(runtime.workspaceCwd).getProjectDir(),
);
const sessionDir = getSubagentSessionDir(projectDir, parentSessionId);
@ -785,10 +783,8 @@ export class VirtualSubagentSessions {
): Promise<ResolvedAgentTask | undefined> {
// Pre-toolUseId transcripts cannot be linked exactly. This score is only a
// best-effort compatibility path and identical parallel launches may tie.
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
const projectDir = Storage.runWithRuntimeBaseDir(
runtimeDir,
runtime.workspaceCwd,
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
runtime.sessionRuntimeBaseDir,
() => new Storage(runtime.workspaceCwd).getProjectDir(),
);
const parentRecords = await readJsonl<ChatRecord>(
@ -882,10 +878,8 @@ export class VirtualSubagentSessions {
parentSessionId: string,
toolCallId: string,
): Promise<ToolCallMetrics> {
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
const projectDir = Storage.runWithRuntimeBaseDir(
runtimeDir,
runtime.workspaceCwd,
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
runtime.sessionRuntimeBaseDir,
() => new Storage(runtime.workspaceCwd).getProjectDir(),
);
const records = await readJsonl<ChatRecord>(

View file

@ -220,6 +220,7 @@ async function makeHarness(opts?: {
const primary: WorkspaceRuntime = {
workspaceId: 'same-as-path',
workspaceCwd: primaryCwd,
sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'),
primary: true,
trusted: true,
env: { mode: 'parent-process', overlayKeys: [] },
@ -232,6 +233,7 @@ async function makeHarness(opts?: {
const secondary: WorkspaceRuntime = {
workspaceId: hashDaemonWorkspace(secondaryCwd),
workspaceCwd: secondaryCwd,
sessionRuntimeBaseDir: path.join(secondaryCwd, '.runtime'),
primary: false,
trusted: opts?.secondaryTrusted ?? true,
env: { mode: 'parent-process', overlayKeys: [] },
@ -288,6 +290,7 @@ async function makeWindowsSelectorHarness() {
const primary: WorkspaceRuntime = {
workspaceId: 'primary-id',
workspaceCwd: primaryCwd,
sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'),
primary: true,
trusted: true,
env: { mode: 'parent-process', overlayKeys: [] },
@ -299,6 +302,7 @@ async function makeWindowsSelectorHarness() {
const windowsRuntime: WorkspaceRuntime = {
workspaceId: 'windows-id',
workspaceCwd: windowsCwd,
sessionRuntimeBaseDir: '/runtime/windows',
primary: false,
trusted: true,
env: { mode: 'parent-process', overlayKeys: [] },

View file

@ -29,6 +29,7 @@ export interface WorkspaceRuntimeEnvMetadata {
export interface WorkspaceRuntime {
readonly workspaceId: string;
readonly workspaceCwd: string;
readonly sessionRuntimeBaseDir: string;
/** Optional presentation-only name. Workspace identity remains id/cwd. */
displayName?: string;
readonly primary: boolean;

View file

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
SessionService,
Storage,
type SessionServiceOptions,
} from '@qwen-code/qwen-code-core';
import type { WorkspaceRuntime } from './workspace-registry.js';
export function runWithWorkspaceRuntimeStorage<T>(
runtime: WorkspaceRuntime,
fn: () => T,
): T {
return Storage.runWithResolvedRuntimeBaseDir(
runtime.sessionRuntimeBaseDir,
fn,
);
}
export function createWorkspaceRuntimeSessionService(
runtime: WorkspaceRuntime,
options: Omit<SessionServiceOptions, 'runtimeBaseDir'> = {},
): SessionService {
return new SessionService(runtime.workspaceCwd, {
...options,
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
});
}

View file

@ -642,6 +642,35 @@ describe('Storage runtime base dir async context isolation', () => {
expect(b).toBe(path.join(cwdB, '.qwen-b'));
});
it('lets a resolved runtime pin override later process env changes', async () => {
const pinned = path.resolve('workspace', 'pinned-runtime');
process.env['QWEN_RUNTIME_DIR'] = path.resolve(
'workspace',
'ambient-runtime',
);
await Storage.runWithResolvedRuntimeBaseDir(pinned, async () => {
expect(Storage.getRuntimeBaseDir()).toBe(pinned);
await Promise.resolve();
expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned);
});
});
it('keeps a resolved runtime pin across nested configurable contexts', () => {
const pinned = path.resolve('workspace', 'pinned-runtime');
Storage.runWithResolvedRuntimeBaseDir(pinned, () => {
Storage.runWithRuntimeBaseDir(
path.resolve('workspace', 'nested-runtime'),
undefined,
() => {
expect(Storage.getRuntimeBaseDir()).toBe(pinned);
expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned);
},
);
});
});
it('pins an instance to the runtime dir where it was created', () => {
const cwd = path.resolve('workspace', 'pinned');
const runtimeDir = path.join(cwd, '.qwen-a');

View file

@ -42,9 +42,10 @@ export class Storage {
* When null, falls back to getGlobalQwenDir().
*/
private static runtimeBaseDir: string | null = null;
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<
string | null
>();
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<{
dir: string | null;
pinned: boolean;
}>();
constructor(
targetDir: string,
@ -127,8 +128,24 @@ export class Storage {
cwd: string | undefined,
fn: () => T,
): T {
if (Storage.runtimeBaseDirContext.getStore()?.pinned) {
return fn();
}
const resolved = Storage.resolveRuntimeBaseDir(dir, cwd);
return Storage.runtimeBaseDirContext.run(resolved, fn);
return Storage.runtimeBaseDirContext.run(
{ dir: resolved, pinned: false },
fn,
);
}
static runWithResolvedRuntimeBaseDir<T>(dir: string, fn: () => T): T {
// A managed workspace runtime owns this root for its full lifetime.
// Unlike the configurable context above, later process-env reloads must
// not redirect storage created inside this context.
return Storage.runtimeBaseDirContext.run(
{ dir: path.resolve(dir), pinned: true },
fn,
);
}
static hasRuntimeBaseDirContext(): boolean {
@ -139,10 +156,14 @@ export class Storage {
* Returns the base directory for all runtime output (temp files, debug logs,
* session data, todos, insights, etc.).
*
* Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir()
* Priority: pinned runtime context > QWEN_RUNTIME_DIR env var > configurable context > setRuntimeBaseDir() value > getGlobalQwenDir()
* @returns Absolute path to the runtime output base directory
*/
static getRuntimeBaseDir(): string {
const contextualDir = Storage.runtimeBaseDirContext.getStore();
if (contextualDir?.pinned) {
return contextualDir.dir ?? Storage.getGlobalQwenDir();
}
const envDir = process.env['QWEN_RUNTIME_DIR'];
if (envDir) {
return (
@ -150,9 +171,8 @@ export class Storage {
);
}
const contextualDir = Storage.runtimeBaseDirContext.getStore();
if (contextualDir !== undefined) {
return contextualDir ?? Storage.getGlobalQwenDir();
return contextualDir.dir ?? Storage.getGlobalQwenDir();
}
if (Storage.runtimeBaseDir) {
return Storage.runtimeBaseDir;

View file

@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { SessionService } from './sessionService.js';
import {
getSessionWriterLockPath,
SessionWriterConflictError,
SessionWriterUnavailableError,
} from './session-writer-lease.js';
const temporaryDirectories = new Set<string>();
afterEach(async () => {
await Promise.all(
[...temporaryDirectories].map((directory) =>
fs.rm(directory, { recursive: true, force: true }),
),
);
temporaryDirectories.clear();
});
async function createService(): Promise<{
runtimeBaseDir: string;
service: SessionService;
}> {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-session-service-lease-'),
);
temporaryDirectories.add(root);
const runtimeBaseDir = path.join(root, 'runtime');
const workspace = path.join(root, 'workspace');
await fs.mkdir(workspace, { recursive: true });
return {
runtimeBaseDir,
service: new SessionService(workspace, { runtimeBaseDir }),
};
}
describe('SessionService.acquireSessionWriterLease', () => {
it('uses the service runtime root and rejects a second writer', async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
const { runtimeBaseDir, service } = await createService();
const lease = await service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
});
await expect(
fs.stat(getSessionWriterLockPath(runtimeBaseDir, sessionId)),
).resolves.toBeDefined();
await expect(
service.acquireSessionWriterLease(sessionId, {
processKind: 'daemon',
reclaimPolicy: 'never',
}),
).rejects.toThrow(SessionWriterConflictError);
await lease.release();
});
it('rejects an invalid id before creating the lock directory', async () => {
const { runtimeBaseDir, service } = await createService();
await expect(
service.acquireSessionWriterLease('../invalid', {
processKind: 'daemon',
reclaimPolicy: 'never',
}),
).rejects.toThrow(SessionWriterUnavailableError);
await expect(
fs.stat(path.dirname(getSessionWriterLockPath(runtimeBaseDir, 'valid'))),
).rejects.toMatchObject({ code: 'ENOENT' });
});
});

View file

@ -46,6 +46,11 @@ import {
} from './session-artifact-persistence.js';
import { SessionOrganizationService } from './session-organization-service.js';
import { SessionTranscriptTooLargeError } from './session-transcript-reader.js';
import {
SessionWriterLease,
SessionWriterUnavailableError,
type SessionWriterProcessKind,
} from './session-writer-lease.js';
const debugLogger = createDebugLogger('SESSION');
@ -339,6 +344,25 @@ export class SessionService {
return this.projectRoot;
}
async acquireSessionWriterLease(
sessionId: string,
options: {
processKind: SessionWriterProcessKind;
qwenVersion?: string | null;
reclaimPolicy: 'local' | 'never';
},
): Promise<SessionWriterLease> {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
throw new SessionWriterUnavailableError();
}
return SessionWriterLease.acquire({
runtimeBaseDir: this.storage.getRuntimeBaseDir(),
sessionId,
transcriptPath: this.getSessionFilePath(sessionId, 'active'),
...options,
});
}
private warn(message: string): void {
debugLogger.warn(message);
this.onWarning?.(message);