diff --git a/docs/design/daemon-session-maintenance-writer-lease.md b/docs/design/daemon-session-maintenance-writer-lease.md new file mode 100644 index 0000000000..ce27a93388 --- /dev/null +++ b/docs/design/daemon-session-maintenance-writer-lease.md @@ -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. diff --git a/packages/cli/src/serve/acp-http/dispatch-error.test.ts b/packages/cli/src/serve/acp-http/dispatch-error.test.ts new file mode 100644 index 0000000000..ed0d4a1f7f --- /dev/null +++ b/packages/cli/src/serve/acp-http/dispatch-error.test.ts @@ -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' }, + }); + }); +}); diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index c02c8b8114..f0400ef94d 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -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; } { + 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 { + return Storage.runWithResolvedRuntimeBaseDir( + this.sessionRuntimeBaseDir, + () => this.handleInRuntime(conn, msg, sessionHeader, reqLoopback), + ); + } + + private async handleInRuntime( + conn: AcpConnection, + msg: JsonRpcInbound, + sessionHeader?: string, + reqLoopback?: boolean, ): Promise { // Loopback is evaluated PER REQUEST (the permission-vote POST may arrive // from a different peer than `initialize`), falling back to the diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index a087c34970..5b1a841dcb 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -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 { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 17ca376f68..d7773cbb51 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -174,6 +174,7 @@ class FakeBridge { gate: Promise | 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((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( fn: (runtimeDir: string) => Promise, ): Promise { - 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, + }, + }), }), ]), ); diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index 435be76c2e..860646e5fc 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -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(fn: () => Promise): Promise { - 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; let secondaryRuntime: WorkspaceRuntime; let workspaceVoiceConnection: ReturnType; + 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((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 { @@ -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 () => { diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index b5388db461..706c37d462 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -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; + 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(); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index fda56ab0b7..e5adae3e26 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -96,6 +96,7 @@ interface Harness { scratch: string; workspace: string; bridge: StubBridge; + cleanupSession: ReturnType; 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 { return { workspaceId: `id-${name}`, workspaceCwd, + sessionRuntimeBaseDir: path.join(scratch, `runtime-${name}`), trusted, bridge: makeStubBridge(), }; @@ -1866,6 +1883,7 @@ async function makeQualifiedHarness(): Promise { 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(); }); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index c44755a071..38380f9d36 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -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; assertGenerationOpen?: () => void; } @@ -154,14 +157,16 @@ function requireOpenGeneration( } async function rollbackCronMutation( - workspaceCwd: string, + target: ScheduledTaskTarget, before: DurableCronTask[] | undefined, after: DurableCronTask[] | undefined, route: string, ): Promise { 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 { + 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; } 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; +} + +function runWithScheduledTaskTarget( + 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, diff --git a/packages/cli/src/serve/routes/session-telemetry.test.ts b/packages/cli/src/serve/routes/session-telemetry.test.ts index a8f94e5695..6f024a27db 100644 --- a/packages/cli/src/serve/routes/session-telemetry.test.ts +++ b/packages/cli/src/serve/routes/session-telemetry.test.ts @@ -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( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 5f9c9f5499..17c676bee4 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -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( runtime: WorkspaceRuntime, read: () => Promise, ): Promise { + 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 diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index 93613670fe..e6aa108834 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -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: [] }, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index a5f1c869c8..f089d8bf22 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -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, + ); + let finishMaintenance!: () => void; + const maintenanceGate = new Promise((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-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a17b02086a..e48116a252 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3108,6 +3108,47 @@ async function runQwenServeImpl( envFileReadFailed: false, envFileReadFailures: Object.freeze([]), }; + const resolveSessionRuntimeBaseDir = ( + workspace: string, + settings: ReturnType | undefined, + effectiveEnv: Readonly, + ): 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, @@ -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) => 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 } | undefined; + const initiallyMountedSessionMaintenance = initiallyMountedApp + ?.locals?.['sessionArchiveCoordinator'] as + | { sealMaintenanceAndWait?: () => Promise } + | 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 | undefined; const startProcessRegistryShutdown = () => { processRegistryShutdown ??= managedProcessRegistry @@ -6492,10 +6560,19 @@ async function runQwenServeImpl( const workspaceManagementHandle = appForCleanup?.locals?.[ 'workspaceManagementHandle' ] as { sealAndWait?: () => Promise } | undefined; + const sessionMaintenance = appForCleanup?.locals?.[ + 'sessionArchiveCoordinator' + ] as + | { sealMaintenanceAndWait?: () => Promise } + | undefined; await initialManagementWait; if (workspaceManagementHandle !== initiallyMountedManagement) { await workspaceManagementHandle?.sealAndWait?.(); } + await initialSessionMaintenanceWait; + if (sessionMaintenance !== initiallyMountedSessionMaintenance) { + await sessionMaintenance?.sealMaintenanceAndWait?.(); + } stopTrustPolicyMonitor(appForCleanup); const waitForTrustPolicyIdle = appForCleanup?.locals?.[ 'waitForTrustPolicyIdle' diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 640acf9e5f..a4a9971b75 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -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((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, () => [ diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index 347629a235..9ed5a35201 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -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, spawnTimeoutMs: number, binding: Set, + cleanupSession: (sessionId: string) => Promise, ): Promise { 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; /** 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(); - const tick = async (): Promise => { + const tickInRuntime = async (): Promise => { let tasks; try { tasks = await readCronTasks(boundWorkspace); @@ -388,8 +394,16 @@ export function startScheduledTaskKeepalive( renamed, spawnTimeoutMs, binding, + cleanupSession, ); }; + const tick = (): Promise => + 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 | 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 | undefined; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 56c3f3f5c0..289c78987e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -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; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f835dc990f..2d9170135d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -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()) { diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 1ed6fdacf1..9dadb02ec0 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -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(), diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 71a2a00074..70b5cedfd6 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -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({ diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 9e1153dc08..beaa275f2e 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -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((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((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((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | 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((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | 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((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | 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( diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 15abaa4832..a580c3f52d 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -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(); private readonly shared = new Map(); + private maintenanceSealed = false; + private activeMaintenance = 0; + private maintenanceDrain: + | { promise: Promise; resolve: () => void } + | undefined; assertNotTransitioning(sessionId: string): void { if (this.exclusive.has(sessionId)) { @@ -60,6 +74,9 @@ export class SessionArchiveCoordinator { sessionIds: string[], fn: () => Promise, ): Promise { + 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 { + this.maintenanceSealed = true; + if (this.activeMaintenance === 0) { + return Promise.resolve(); + } + if (!this.maintenanceDrain) { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + this.maintenanceDrain = { promise, resolve }; + } + return this.maintenanceDrain.promise; + } + async runSharedMany( sessionIds: string[], fn: () => Promise, @@ -105,6 +143,225 @@ export class SessionArchiveCoordinator { } } +type DaemonMaintenanceAction = 'delete' | 'archive' | 'unarchive'; + +interface LeaseMutationResult { + value?: T; + mutationApplied: boolean; + error?: unknown; + maintenanceError?: unknown; +} + +async function runWithDaemonWriterLease(params: { + action: DaemonMaintenanceAction; + sessionId: string; + service: SessionService; + mutate: ( + assertOwnedAndUnchanged: () => Promise, + ) => Promise<{ value: T; mutationApplied: boolean }>; + mutationAppliedAfterError: () => Promise; + afterMutationApplied: () => Promise; +}): Promise> { + 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 { + 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 { + 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 { + 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 { 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; + coordinator: SessionArchiveCoordinator; +}): Promise { + 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 { - 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 { - 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 { - 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 { 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 { 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 }; } diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 028f2797b4..06a9e7777f 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -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'], diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index e8cfaddc54..679653812a 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -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 { // 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( @@ -882,10 +878,8 @@ export class VirtualSubagentSessions { parentSessionId: string, toolCallId: string, ): Promise { - 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( diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index 2a3b22e911..14516e64ff 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -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: [] }, diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index 6d022eeb3a..b4026c2765 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -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; diff --git a/packages/cli/src/serve/workspace-runtime-storage.ts b/packages/cli/src/serve/workspace-runtime-storage.ts new file mode 100644 index 0000000000..7b444ecab2 --- /dev/null +++ b/packages/cli/src/serve/workspace-runtime-storage.ts @@ -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( + runtime: WorkspaceRuntime, + fn: () => T, +): T { + return Storage.runWithResolvedRuntimeBaseDir( + runtime.sessionRuntimeBaseDir, + fn, + ); +} + +export function createWorkspaceRuntimeSessionService( + runtime: WorkspaceRuntime, + options: Omit = {}, +): SessionService { + return new SessionService(runtime.workspaceCwd, { + ...options, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + }); +} diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index cb54ba6db9..abded1a61f 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -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'); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 7d0666d695..08dba1e791 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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(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; diff --git a/packages/core/src/services/session-service-writer-lease.test.ts b/packages/core/src/services/session-service-writer-lease.test.ts new file mode 100644 index 0000000000..a4b7120c9c --- /dev/null +++ b/packages/core/src/services/session-service-writer-lease.test.ts @@ -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(); + +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' }); + }); +}); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index e5088cb1cf..9396ef7564 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -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 { + 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);