mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 00:26:31 +00:00
fix(serve): Isolate daemon session maintenance writers (#7975)
* fix(serve): isolate daemon session maintenance writers Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #7975 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#7975) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #7975 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address review feedback on daemon session writer maintenance (#7975) - Extract teardownBoundSession helper to deduplicate bound-session teardown in scheduled-tasks create handler - Extract shared cleanupSession callback in createServeApp to eliminate three verbatim copies of the orphan-deletion wrapper - Fire onError callback on the SessionNotFoundError deletion path in deleteDaemonSessions, matching the normal close-succeeded path - Update stale @priority docstring on Storage.getRuntimeBaseDir() * codex: address PR review feedback (#7975) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): cover orphan-delete paths and draining guards (#7975) --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
This commit is contained in:
parent
079ce5346a
commit
3bdaeac046
31 changed files with 3069 additions and 1015 deletions
116
docs/design/daemon-session-maintenance-writer-lease.md
Normal file
116
docs/design/daemon-session-maintenance-writer-lease.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Daemon Session Maintenance Writer Lease
|
||||
|
||||
## Problem
|
||||
|
||||
The daemon can delete, archive, or unarchive a persisted transcript after its
|
||||
in-process ACP owner has closed. A different daemon process can still own the
|
||||
same transcript, so the in-process archive coordinator alone does not prevent
|
||||
the daemon from racing an external writer.
|
||||
|
||||
The transcript path and writer-lock path must also be resolved from the same
|
||||
workspace runtime. Falling back to the primary daemon runtime can mutate one
|
||||
workspace while checking a lock in another.
|
||||
|
||||
## Scope
|
||||
|
||||
This change covers daemon-owned maintenance:
|
||||
|
||||
- REST and ACP delete, archive, and unarchive requests
|
||||
- disconnect and orphan cleanup
|
||||
- scheduled-task rollback and keepalive cleanup
|
||||
- daemon shutdown while maintenance is already running
|
||||
|
||||
It does not add lease expiry, heartbeat, hostname-based recovery, automatic
|
||||
steal, force unlock, or a lock-schema migration. Writers that do not participate
|
||||
in the lease protocol still require platform-level single-writer fencing.
|
||||
|
||||
## Runtime storage binding
|
||||
|
||||
Each `WorkspaceRuntime` resolves one absolute session runtime base directory at
|
||||
creation. Resolution keeps the existing priority:
|
||||
|
||||
1. `QWEN_RUNTIME_DIR`
|
||||
2. `advanced.runtimeOutputDir`, resolved relative to the workspace
|
||||
3. the normal Qwen runtime directory
|
||||
|
||||
The resolved directory is stored on the runtime and injected as
|
||||
`QWEN_RUNTIME_DIR` into every managed ACP child. Environment reload may update
|
||||
other values but preserves this pinned value because changing
|
||||
`runtimeOutputDir` requires a runtime restart.
|
||||
|
||||
Daemon parent operations that list, read, export, organize, or maintain
|
||||
sessions run inside the selected runtime's storage context. Runtime resolution
|
||||
failures do not fall back to the primary runtime.
|
||||
|
||||
## Lease API
|
||||
|
||||
`SessionService.acquireSessionWriterLease()` derives both the writer-lock root
|
||||
and the active transcript path from the service's fixed `Storage` instance.
|
||||
Callers provide only the session ID, process kind, version, and reclaim policy.
|
||||
Invalid session IDs are rejected before the lock directory is touched.
|
||||
|
||||
Daemon maintenance always uses `processKind: 'daemon'` and
|
||||
`reclaimPolicy: 'never'`. The existing lock schema, key, owner record, and
|
||||
acquire/release protocol remain unchanged.
|
||||
|
||||
## Maintenance protocol
|
||||
|
||||
Every session is processed independently:
|
||||
|
||||
1. Enter the daemon's per-session exclusive archive coordinator.
|
||||
2. Close the local owner. Archive requires agent close; delete uses the normal
|
||||
fast close. A missing local owner is allowed.
|
||||
3. Classify persisted state and preserve existing not-found and idempotent
|
||||
results without creating a lock.
|
||||
4. Acquire the daemon writer lease.
|
||||
5. Reclassify while holding the lease.
|
||||
6. Verify ownership and the transcript fingerprint, then perform one mutation.
|
||||
7. Release the lease with owner-token verification.
|
||||
|
||||
Batch requests may process independent sessions concurrently, but a worker
|
||||
holds at most one cross-process lease and never waits while holding multiple
|
||||
leases.
|
||||
|
||||
A failed mutation remains the reported error when release succeeds. A release
|
||||
or ownership failure is the externally safe error even if mutation also failed.
|
||||
Logs record the workspace, session, action, error kind, and whether the
|
||||
transcript mutation reached disk; they never include owner tokens or lock
|
||||
paths. Scheduled-task reconciliation follows the actual transcript mutation,
|
||||
not whether lease release subsequently succeeded.
|
||||
|
||||
Orphan cleanup first closes the local owner and respects
|
||||
`requireZeroAttaches`. A newly attached owner therefore prevents deletion.
|
||||
Late-spawn cleanup awaits close before acquiring the lease and deleting the
|
||||
transcript.
|
||||
|
||||
## Shutdown
|
||||
|
||||
`SessionArchiveCoordinator.sealMaintenanceAndWait()` synchronously rejects new
|
||||
exclusive maintenance and waits for exclusive operations already admitted.
|
||||
Shared transcript reads are not included, so a long export does not consume the
|
||||
termination budget. REST returns `503 daemon_draining`; ACP returns a JSON-RPC
|
||||
server error with `data.errorKind = daemon_draining`.
|
||||
|
||||
Daemon shutdown seals maintenance before child/process teardown and completes
|
||||
only after admitted maintenance leases have been released.
|
||||
|
||||
## Compatibility and rollout
|
||||
|
||||
Batch response shapes and existing archive/delete/unarchive idempotency
|
||||
remain unchanged. Pre-check local `session_archiving` conflicts (raised by
|
||||
`assertNotTransitioning` before admission) still surface as a request-level
|
||||
`409`. Conflicts raised inside the admission gate are reported per session in
|
||||
the `200` response body (`errors[]`) for archive, unarchive, and delete
|
||||
alike. Mixed-version writers are unsafe, so deployment and rollback must
|
||||
drain the old daemon and managed ACP processes before starting the new
|
||||
version.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests use real temporary runtime roots for writer contention and root
|
||||
isolation, cover state changes between the initial and locked classifications,
|
||||
and verify close, mutation, release, scheduled-task reconciliation, and
|
||||
shutdown ordering. Unit tests also cover invalid IDs, duplicate IDs,
|
||||
active/archive conflicts, lease release failures, orphan reattachment, and log
|
||||
redaction. Relevant package tests, build, and typecheck are required before
|
||||
merge.
|
||||
21
packages/cli/src/serve/acp-http/dispatch-error.test.ts
Normal file
21
packages/cli/src/serve/acp-http/dispatch-error.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DaemonDrainingError } from '../server/session-archive.js';
|
||||
import { toRpcError } from './dispatch.js';
|
||||
import { RPC } from './json-rpc.js';
|
||||
|
||||
describe('toRpcError', () => {
|
||||
it('maps sealed maintenance to a JSON-RPC server error', () => {
|
||||
expect(toRpcError(new DaemonDrainingError())).toEqual({
|
||||
code: RPC.INTERNAL_ERROR,
|
||||
message:
|
||||
'The daemon is draining and no longer accepts session maintenance.',
|
||||
data: { errorKind: 'daemon_draining' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
BTW_MAX_INPUT_LENGTH,
|
||||
createDebugLogger,
|
||||
GROUP_COLOR_OPTIONS,
|
||||
Storage,
|
||||
SessionService,
|
||||
SessionOrganizationError,
|
||||
SESSION_WRITER_RPC_CODES,
|
||||
|
|
@ -103,7 +104,9 @@ import { createSessionOrganizationService } from '../session-organization-helper
|
|||
import {
|
||||
archiveDaemonSessions,
|
||||
assertSessionLoadable,
|
||||
deleteDaemonSessionIfOrphan,
|
||||
deleteDaemonSessions,
|
||||
DaemonDrainingError,
|
||||
logSessionArchiveWarning,
|
||||
SessionArchiveCoordinator,
|
||||
unarchiveDaemonSessions,
|
||||
|
|
@ -561,11 +564,18 @@ function pickSessionArtifactInput(
|
|||
* the operator-facing message is not a cross-tenant leak), and anything
|
||||
* unrecognized collapses to a generic INTERNAL_ERROR string.
|
||||
*/
|
||||
function toRpcError(err: unknown): {
|
||||
export function toRpcError(err: unknown): {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
} {
|
||||
if (err instanceof DaemonDrainingError) {
|
||||
return {
|
||||
code: RPC.INTERNAL_ERROR,
|
||||
message: err.message,
|
||||
data: { errorKind: 'daemon_draining' },
|
||||
};
|
||||
}
|
||||
const writerError = sessionWriterRpcError(err);
|
||||
if (writerError) return writerError;
|
||||
if (err instanceof AcpParamError || err instanceof InvalidCursorError) {
|
||||
|
|
@ -808,6 +818,7 @@ export class AcpDispatcher {
|
|||
private readonly captureGenerationAssertion: () =>
|
||||
| (() => void)
|
||||
| undefined = () => undefined,
|
||||
private readonly sessionRuntimeBaseDir: string = Storage.getRuntimeBaseDir(),
|
||||
) {
|
||||
this.agentManager = createDaemonSubagentManager(boundWorkspace);
|
||||
}
|
||||
|
|
@ -816,20 +827,21 @@ export class AcpDispatcher {
|
|||
sessionId: string,
|
||||
removePersistedSession = false,
|
||||
): void {
|
||||
void this.bridge
|
||||
.killSession(sessionId, { requireZeroAttaches: true })
|
||||
.then(async (killed) => {
|
||||
if (killed && removePersistedSession) {
|
||||
await new SessionService(this.boundWorkspace).removeSession(
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
writeStderrLine(
|
||||
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
|
||||
),
|
||||
);
|
||||
const cleanup = removePersistedSession
|
||||
? deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service: new SessionService(this.boundWorkspace, {
|
||||
runtimeBaseDir: this.sessionRuntimeBaseDir,
|
||||
}),
|
||||
bridge: this.bridge,
|
||||
coordinator: this.archiveCoordinator,
|
||||
})
|
||||
: this.bridge.killSession(sessionId, { requireZeroAttaches: true });
|
||||
void cleanup.catch((err) =>
|
||||
writeStderrLine(
|
||||
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1163,6 +1175,18 @@ export class AcpDispatcher {
|
|||
msg: JsonRpcInbound,
|
||||
sessionHeader?: string,
|
||||
reqLoopback?: boolean,
|
||||
): Promise<void> {
|
||||
return Storage.runWithResolvedRuntimeBaseDir(
|
||||
this.sessionRuntimeBaseDir,
|
||||
() => this.handleInRuntime(conn, msg, sessionHeader, reqLoopback),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInRuntime(
|
||||
conn: AcpConnection,
|
||||
msg: JsonRpcInbound,
|
||||
sessionHeader?: string,
|
||||
reqLoopback?: boolean,
|
||||
): Promise<void> {
|
||||
// Loopback is evaluated PER REQUEST (the permission-vote POST may arrive
|
||||
// from a different peer than `initialize`), falling back to the
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ class FakeBridge {
|
|||
gate: Promise<void> | undefined;
|
||||
/** `attached` value loadSession returns (false = spawned-from-disk). */
|
||||
loadAttached = true;
|
||||
spawnSessionId = 'sess-1';
|
||||
spawnClientId: string | undefined = 'client-1';
|
||||
loadRequests: Array<{
|
||||
sessionId: string;
|
||||
|
|
@ -191,7 +192,7 @@ class FakeBridge {
|
|||
this.lastSpawnScope = req?.sessionScope;
|
||||
if (this.gate) await this.gate;
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
sessionId: this.spawnSessionId,
|
||||
workspaceCwd: '/ws',
|
||||
attached: false,
|
||||
clientId: this.spawnClientId,
|
||||
|
|
@ -839,8 +840,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
let base: string;
|
||||
let bridge: FakeBridge;
|
||||
let acpHandle: AcpHttpHandle | undefined;
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let runtimeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-archive-'));
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
stdioMocks.writeStderrLine.mockClear();
|
||||
setupGithubMocks.setupGithub.mockReset();
|
||||
setupGithubMocks.setupGithub.mockResolvedValue({
|
||||
|
|
@ -902,6 +908,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
// `server.close()` doesn't hang on them.
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function restartServer(opts: {
|
||||
|
|
@ -923,6 +935,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
? createSingleWorkspaceRegistry({
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
primary: true,
|
||||
trusted: opts.primaryTrusted ?? true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -1019,21 +1032,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
async function withRuntimeDir<T>(
|
||||
fn: (runtimeDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-acp-archive-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
try {
|
||||
return await fn(runtimeDir);
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
return fn(runtimeDir);
|
||||
}
|
||||
|
||||
async function writeStoredSession(
|
||||
|
|
@ -3738,13 +3737,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
it.each(['session/load', 'session/resume'])(
|
||||
'%s rejects archived sessions',
|
||||
async (method) => {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-acp-archive-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
|
||||
try {
|
||||
await withRuntimeDir(async () => {
|
||||
const chatsDir = path.join(
|
||||
new Storage('/ws').getProjectDir(),
|
||||
'chats',
|
||||
|
|
@ -3783,14 +3777,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
expect(frame.id).toBe(211);
|
||||
expect(frame.error.code).toBe(-32603);
|
||||
expect(frame.error.data?.errorKind).toBe('session_archived');
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -3861,7 +3848,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('session/load holds archive gate while restore is in flight', async () => {
|
||||
it('session/load reports an archive conflict while restore is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440124';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -3904,9 +3891,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 213,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -3974,7 +3966,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('session/prompt holds archive gate while prompt is in flight', async () => {
|
||||
it('session/prompt reports an archive conflict while prompt is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -4024,9 +4016,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await connReader.next()).toMatchObject({
|
||||
id: 219,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(bridge.closedSessions).toEqual([]);
|
||||
|
|
@ -4881,6 +4878,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
|
||||
it('session/new orphan: DELETE before spawn resolves removes the persisted session', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440126';
|
||||
bridge.spawnSessionId = sessionId;
|
||||
await writeStoredSession(sessionId);
|
||||
const removeSession = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockResolvedValue(true);
|
||||
|
|
@ -4900,8 +4900,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
release(); // spawn resolves AFTER destroy
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(bridge.killed).toContain('sess-1');
|
||||
expect(removeSession).toHaveBeenCalledWith('sess-1');
|
||||
expect(bridge.killed).toContain(sessionId);
|
||||
expect(removeSession).toHaveBeenCalledWith(sessionId);
|
||||
removeSession.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -6607,7 +6607,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
|
||||
it('_qwen/session/artifacts/add reports an archive conflict while mutating', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -6657,9 +6657,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 61,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining(
|
||||
'is being archived or unarchived',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -6672,7 +6679,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/remove holds the archive gate while mutating', async () => {
|
||||
it('_qwen/session/artifacts/remove reports an archive conflict while mutating', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440132';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -6730,9 +6737,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 63,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining(
|
||||
'is being archived or unarchived',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -7554,46 +7568,47 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
it('_qwen/sessions/delete sanitizes stderr remove errors', async () => {
|
||||
const lineSep = '\u2028';
|
||||
const bidiOverride = '\u202e';
|
||||
const sessionId = `sess${lineSep}FAKE\r\x1b[31m`;
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
|
||||
const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`;
|
||||
const removeSessionSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockRejectedValueOnce(new Error(removeError));
|
||||
await withRuntimeDir(async () => {
|
||||
await writeStoredSession(sessionId);
|
||||
const removeSessionSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockRejectedValueOnce(new Error(removeError));
|
||||
|
||||
try {
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 68,
|
||||
method: '_qwen/sessions/delete',
|
||||
params: { sessionIds: [sessionId] },
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
result: {
|
||||
removed: [],
|
||||
notFound: [],
|
||||
errors: [{ sessionId, error: removeError }],
|
||||
},
|
||||
});
|
||||
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
|
||||
try {
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 68,
|
||||
method: '_qwen/sessions/delete',
|
||||
params: { sessionIds: [sessionId] },
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
result: {
|
||||
removed: [],
|
||||
notFound: [],
|
||||
errors: [{ sessionId, error: removeError }],
|
||||
},
|
||||
});
|
||||
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
|
||||
|
||||
const deleteLog = stdioMocks.writeStderrLine.mock.calls
|
||||
.map(([line]) => line)
|
||||
.find((line) => line.includes('sessions/delete'));
|
||||
expect(deleteLog).toContain(
|
||||
'removeSession(sess FAK) failed: remove FAILED [31m',
|
||||
);
|
||||
expect(deleteLog).not.toContain('\n');
|
||||
expect(deleteLog).not.toContain('\r');
|
||||
expect(deleteLog).not.toContain('\x1b');
|
||||
expect(deleteLog).not.toContain(lineSep);
|
||||
expect(deleteLog).not.toContain(bidiOverride);
|
||||
} finally {
|
||||
removeSessionSpy.mockRestore();
|
||||
}
|
||||
const deleteLog = stdioMocks.writeStderrLine.mock.calls
|
||||
.map(([line]) => line)
|
||||
.find((line) => line.includes('sessions/delete'));
|
||||
expect(deleteLog).toContain('remove FAILED [31m');
|
||||
expect(deleteLog).not.toContain('\n');
|
||||
expect(deleteLog).not.toContain('\r');
|
||||
expect(deleteLog).not.toContain('\x1b');
|
||||
expect(deleteLog).not.toContain(lineSep);
|
||||
expect(deleteLog).not.toContain(bidiOverride);
|
||||
} finally {
|
||||
removeSessionSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/sessions/delete deletes available ids when another id is loading', async () => {
|
||||
|
|
@ -7660,7 +7675,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => {
|
||||
it('_qwen/sessions/archive returns session_archiving while delete owns the gate', async () => {
|
||||
const sessionId = 'delete-archive-race';
|
||||
let firstCloseStarted!: () => void;
|
||||
let releaseFirstClose!: () => void;
|
||||
|
|
@ -7721,7 +7736,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
id: 70,
|
||||
result: expect.objectContaining({ notFound: [sessionId] }),
|
||||
error: expect.objectContaining({
|
||||
data: {
|
||||
errorKind: 'session_archiving',
|
||||
sessionId,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function makeRuntime(input: {
|
|||
return {
|
||||
workspaceId: input.id,
|
||||
workspaceCwd: input.cwd,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
primary: input.primary,
|
||||
trusted: input.trusted,
|
||||
env: input.env ?? PARENT_ENV,
|
||||
|
|
@ -115,24 +116,6 @@ async function writeStoredSession(sessionId: string, cwd: string) {
|
|||
);
|
||||
}
|
||||
|
||||
async function withRuntimeDir<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
|
@ -146,8 +129,15 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
let workspaceRegistry: ReturnType<typeof createWorkspaceRegistry>;
|
||||
let secondaryRuntime: WorkspaceRuntime;
|
||||
let workspaceVoiceConnection: ReturnType<typeof vi.fn>;
|
||||
let runtimeDir: string;
|
||||
let previousRuntimeDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
setupGithubMock.mockReset();
|
||||
setupGithubMock.mockImplementation(async ({ cwd }: { cwd: string }) => ({
|
||||
kind: 'github_setup',
|
||||
|
|
@ -247,6 +237,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
deviceFlowRegistry?.dispose();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function postInitialize(pathname: string): Promise<Response> {
|
||||
|
|
@ -571,45 +567,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
});
|
||||
|
||||
it('updates persisted organization in the selected workspace only', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
|
||||
await writeStoredSession(sessionId, '/ws-b');
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
|
||||
await writeStoredSession(sessionId, '/ws-b');
|
||||
|
||||
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: true },
|
||||
});
|
||||
|
||||
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
|
||||
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'session/list',
|
||||
params: { view: 'organized', group: 'pinned' },
|
||||
});
|
||||
expect(listed['result']).toMatchObject({
|
||||
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
|
||||
});
|
||||
|
||||
const legacy = await sendWsRequest('/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: false },
|
||||
});
|
||||
expect(legacy['error']).toMatchObject({ code: -32602 });
|
||||
|
||||
const secondarySnapshot =
|
||||
await createSessionOrganizationService('/ws-b').readSnapshot();
|
||||
const primarySnapshot =
|
||||
await createSessionOrganizationService('/ws').readSnapshot();
|
||||
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
|
||||
isPinned: true,
|
||||
});
|
||||
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
|
||||
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: true },
|
||||
});
|
||||
|
||||
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
|
||||
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'session/list',
|
||||
params: { view: 'organized', group: 'pinned' },
|
||||
});
|
||||
expect(listed['result']).toMatchObject({
|
||||
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
|
||||
});
|
||||
|
||||
const legacy = await sendWsRequest('/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: false },
|
||||
});
|
||||
expect(legacy['error']).toMatchObject({ code: -32602 });
|
||||
|
||||
const secondarySnapshot =
|
||||
await createSessionOrganizationService('/ws-b').readSnapshot();
|
||||
const primarySnapshot =
|
||||
await createSessionOrganizationService('/ws').readSnapshot();
|
||||
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
|
||||
isPinned: true,
|
||||
});
|
||||
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an untrusted workspace with 403 untrusted_workspace', async () => {
|
||||
|
|
|
|||
|
|
@ -703,9 +703,12 @@ function makeRuntime(input: {
|
|||
primary: boolean;
|
||||
trusted: boolean;
|
||||
bridge: AcpSessionBridge;
|
||||
sessionRuntimeBaseDir?: string;
|
||||
}): WorkspaceRuntime {
|
||||
return {
|
||||
...input,
|
||||
sessionRuntimeBaseDir:
|
||||
input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
workspaceService: {} as DaemonWorkspaceService,
|
||||
routeFileSystemFactory: {
|
||||
|
|
@ -747,6 +750,8 @@ function makeHarness(opts?: {
|
|||
secondaryRewindImpl?: AcpSessionBridge['rewindSession'];
|
||||
secondaryShellImpl?: AcpSessionBridge['executeShellCommand'];
|
||||
serveOptions?: Partial<ServeOptions>;
|
||||
primaryRuntimeBaseDir?: string;
|
||||
secondaryRuntimeBaseDir?: string;
|
||||
}) {
|
||||
const primaryBridge = makeBridge(
|
||||
PRIMARY_CWD,
|
||||
|
|
@ -775,6 +780,9 @@ function makeHarness(opts?: {
|
|||
primary: true,
|
||||
trusted: opts?.primaryTrusted ?? true,
|
||||
bridge: primaryBridge,
|
||||
...(opts?.primaryRuntimeBaseDir
|
||||
? { sessionRuntimeBaseDir: opts.primaryRuntimeBaseDir }
|
||||
: {}),
|
||||
}),
|
||||
makeRuntime({
|
||||
workspaceId: 'secondary-id',
|
||||
|
|
@ -783,6 +791,9 @@ function makeHarness(opts?: {
|
|||
primary: false,
|
||||
trusted: opts?.secondaryTrusted ?? true,
|
||||
bridge: secondaryBridge,
|
||||
...(opts?.secondaryRuntimeBaseDir
|
||||
? { sessionRuntimeBaseDir: opts.secondaryRuntimeBaseDir }
|
||||
: {}),
|
||||
}),
|
||||
]);
|
||||
const app = createServeApp(
|
||||
|
|
@ -3631,7 +3642,7 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps archive and delete blocked while a workspace export is in flight', async () => {
|
||||
it('reports archive and delete conflicts while a workspace export is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440283';
|
||||
await writeStoredSession({
|
||||
|
|
@ -3672,10 +3683,15 @@ describe('multi-workspace session dispatch', () => {
|
|||
.post('/workspaces/secondary-id/sessions/archive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] });
|
||||
expect(archive.status).toBe(409);
|
||||
expect(archive.status).toBe(200);
|
||||
expect(archive.body).toMatchObject({
|
||||
code: 'session_archiving',
|
||||
sessionId,
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const remove = await request(app)
|
||||
|
|
@ -3884,7 +3900,7 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps unarchive and delete blocked while archived export is in flight', async () => {
|
||||
it('reports unarchive and delete conflicts while archived export is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440289';
|
||||
await writeStoredSession({
|
||||
|
|
@ -3926,8 +3942,16 @@ describe('multi-workspace session dispatch', () => {
|
|||
.post('/workspaces/secondary-id/sessions/unarchive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] });
|
||||
expect(unarchive.status).toBe(409);
|
||||
expect(unarchive.body.code).toBe('session_archiving');
|
||||
expect(unarchive.status).toBe(200);
|
||||
expect(unarchive.body).toMatchObject({
|
||||
unarchived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const remove = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/delete')
|
||||
|
|
@ -4604,6 +4628,75 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps secondary maintenance inside its fixed runtime root', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
|
||||
const runtimeRoot = Storage.getRuntimeBaseDir();
|
||||
const primaryRuntimeBaseDir = path.join(runtimeRoot, 'primary-runtime');
|
||||
const secondaryRuntimeBaseDir = path.join(
|
||||
runtimeRoot,
|
||||
'secondary-runtime',
|
||||
);
|
||||
await Storage.runWithResolvedRuntimeBaseDir(primaryRuntimeBaseDir, () =>
|
||||
writeStoredSession({
|
||||
sessionId,
|
||||
cwd: PRIMARY_CWD,
|
||||
timestamp: '2026-07-08T00:14:00.000Z',
|
||||
prompt: 'primary fixed-root target',
|
||||
mtime: new Date('2026-07-08T00:14:00.000Z'),
|
||||
}),
|
||||
);
|
||||
await Storage.runWithResolvedRuntimeBaseDir(secondaryRuntimeBaseDir, () =>
|
||||
writeStoredSession({
|
||||
sessionId,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp: '2026-07-08T00:15:00.000Z',
|
||||
prompt: 'secondary fixed-root target',
|
||||
mtime: new Date('2026-07-08T00:15:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const primaryService = new SessionService(PRIMARY_CWD, {
|
||||
runtimeBaseDir: primaryRuntimeBaseDir,
|
||||
});
|
||||
const primaryLease = await primaryService.acquireSessionWriterLease(
|
||||
sessionId,
|
||||
{
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const { app } = makeHarness({
|
||||
primaryRuntimeBaseDir,
|
||||
secondaryRuntimeBaseDir,
|
||||
primarySummaries: [],
|
||||
secondarySummaries: [],
|
||||
});
|
||||
const archived = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/archive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] })
|
||||
.expect(200);
|
||||
|
||||
expect(archived.body).toMatchObject({
|
||||
archived: [sessionId],
|
||||
errors: [],
|
||||
});
|
||||
await expect(
|
||||
primaryService.getSessionLocation(sessionId),
|
||||
).resolves.toBe('active');
|
||||
await expect(
|
||||
new SessionService(SECONDARY_CWD, {
|
||||
runtimeBaseDir: secondaryRuntimeBaseDir,
|
||||
}).getSessionLocation(sessionId),
|
||||
).resolves.toBe('archived');
|
||||
} finally {
|
||||
await primaryLease.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('routes plural session group CRUD to the selected workspace', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const { app } = makeHarness();
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ interface Harness {
|
|||
scratch: string;
|
||||
workspace: string;
|
||||
bridge: StubBridge;
|
||||
cleanupSession: ReturnType<typeof vi.fn>;
|
||||
channelDeliveryAuthorizations: ChannelDeliveryAuthorizationStore;
|
||||
}
|
||||
|
||||
|
|
@ -119,11 +120,20 @@ async function makeHarness(
|
|||
({
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: workspace,
|
||||
sessionRuntimeBaseDir: scratch,
|
||||
primary: true,
|
||||
trusted: runtimeTrusted,
|
||||
bridge,
|
||||
generationGuard,
|
||||
}) as unknown as WorkspaceRuntime;
|
||||
const cleanupSession = vi.fn(
|
||||
async (_runtime: WorkspaceRuntime, sessionId: string) => {
|
||||
await bridge.closeSession(sessionId);
|
||||
await new SessionService(workspace, {
|
||||
runtimeBaseDir: scratch,
|
||||
}).removeSession(sessionId);
|
||||
},
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerScheduledTasksRoutes(app, {
|
||||
|
|
@ -133,13 +143,14 @@ async function makeHarness(
|
|||
safeBody,
|
||||
bridge,
|
||||
channelDeliveryAuthorizations,
|
||||
...(getRuntime ? { getRuntime } : {}),
|
||||
...(getRuntime ? { getRuntime, cleanupSession } : {}),
|
||||
});
|
||||
return {
|
||||
app,
|
||||
scratch,
|
||||
workspace,
|
||||
bridge,
|
||||
cleanupSession,
|
||||
channelDeliveryAuthorizations,
|
||||
};
|
||||
}
|
||||
|
|
@ -226,6 +237,10 @@ describe('scheduled-tasks routes', () => {
|
|||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('workspace_runtime_unavailable');
|
||||
expect(h.cleanupSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceCwd: h.workspace }),
|
||||
'sess-1',
|
||||
);
|
||||
expect(h.bridge.closed).toEqual(['sess-1']);
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.workspace), 'utf8'),
|
||||
|
|
@ -1778,6 +1793,7 @@ describe('scheduledTaskSessionName', () => {
|
|||
interface QualifiedRuntime {
|
||||
workspaceId: string;
|
||||
workspaceCwd: string;
|
||||
sessionRuntimeBaseDir: string;
|
||||
trusted: boolean;
|
||||
bridge: StubBridge;
|
||||
}
|
||||
|
|
@ -1846,6 +1862,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
|
|||
return {
|
||||
workspaceId: `id-${name}`,
|
||||
workspaceCwd,
|
||||
sessionRuntimeBaseDir: path.join(scratch, `runtime-${name}`),
|
||||
trusted,
|
||||
bridge: makeStubBridge(),
|
||||
};
|
||||
|
|
@ -1866,6 +1883,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
|
|||
mutate: () => (_req, _res, next) => next(),
|
||||
safeBody,
|
||||
bridge: primary.bridge,
|
||||
getRuntime: () => primary as unknown as WorkspaceRuntime,
|
||||
});
|
||||
registerWorkspaceQualifiedScheduledTasksRoutes(app, {
|
||||
workspaceRegistry: makeStubRegistry(runtimes),
|
||||
|
|
@ -1888,6 +1906,10 @@ describe('workspace-qualified scheduled-tasks routes', () => {
|
|||
});
|
||||
|
||||
const qualified = (id: string) => `/workspaces/${id}/scheduled-tasks`;
|
||||
const cronFilePath = (runtime: QualifiedRuntime) =>
|
||||
Storage.runWithResolvedRuntimeBaseDir(runtime.sessionRuntimeBaseDir, () =>
|
||||
getCronFilePath(runtime.workspaceCwd),
|
||||
);
|
||||
|
||||
it('creates a task in the targeted workspace, isolated from the primary', async () => {
|
||||
const res = await request(h.app)
|
||||
|
|
@ -1913,12 +1935,15 @@ describe('workspace-qualified scheduled-tasks routes', () => {
|
|||
.post(qualified(h.secondary.workspaceId))
|
||||
.send({ cron: '0 9 * * *', prompt: 'p' });
|
||||
const onDisk = JSON.parse(
|
||||
await fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
|
||||
await fsp.readFile(cronFilePath(h.secondary), 'utf-8'),
|
||||
);
|
||||
expect(onDisk).toHaveLength(1);
|
||||
// The primary's file was never created.
|
||||
// Neither the primary runtime nor the process-global fallback was touched.
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.primary.workspaceCwd), 'utf-8'),
|
||||
fsp.readFile(cronFilePath(h.primary), 'utf-8'),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
nextFireTime,
|
||||
nextDurableFireMs,
|
||||
SessionService,
|
||||
Storage,
|
||||
stripTerminalControlSequences,
|
||||
MAX_JOBS,
|
||||
type CronTaskDelivery,
|
||||
|
|
@ -136,7 +137,9 @@ export function scheduledTaskSessionName(label: string): string {
|
|||
*/
|
||||
interface ScheduledTaskTarget {
|
||||
workspaceCwd: string;
|
||||
runtimeBaseDir?: string;
|
||||
bridge?: ScheduledTasksSessionBridge;
|
||||
cleanupSession?: (sessionId: string) => Promise<unknown>;
|
||||
assertGenerationOpen?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -154,14 +157,16 @@ function requireOpenGeneration(
|
|||
}
|
||||
|
||||
async function rollbackCronMutation(
|
||||
workspaceCwd: string,
|
||||
target: ScheduledTaskTarget,
|
||||
before: DurableCronTask[] | undefined,
|
||||
after: DurableCronTask[] | undefined,
|
||||
route: string,
|
||||
): Promise<void> {
|
||||
if (!before || !after) return;
|
||||
await updateCronTasks(workspaceCwd, (tasks) =>
|
||||
isDeepStrictEqual(tasks, after) ? before : tasks,
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(target.workspaceCwd, (tasks) =>
|
||||
isDeepStrictEqual(tasks, after) ? before : tasks,
|
||||
),
|
||||
).catch((error) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${route} failed to roll back a stale task mutation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -169,6 +174,22 @@ async function rollbackCronMutation(
|
|||
});
|
||||
}
|
||||
|
||||
async function teardownBoundSession(
|
||||
target: ScheduledTaskTarget,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (target.cleanupSession) {
|
||||
await target.cleanupSession(sessionId).catch(() => {});
|
||||
} else if (target.bridge) {
|
||||
await target.bridge.closeSession(sessionId).catch(() => {});
|
||||
await new SessionService(target.workspaceCwd, {
|
||||
runtimeBaseDir: target.runtimeBaseDir,
|
||||
})
|
||||
.removeSession(sessionId)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target workspace for one request. Returns null when it can't be
|
||||
* resolved (unknown or untrusted `:workspace`), in which case the resolver has
|
||||
|
|
@ -201,6 +222,10 @@ interface RegisterScheduledTasksRoutesDeps {
|
|||
bridge?: ScheduledTasksSessionBridge;
|
||||
channelDeliveryAuthorizations?: ChannelDeliveryAuthorizationStore;
|
||||
getRuntime?: () => WorkspaceRuntime | undefined;
|
||||
cleanupSession?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
sessionId: string,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
|
||||
|
|
@ -217,6 +242,20 @@ interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
|
|||
* revives. Off → tasks are created unbound (shared-owner firing).
|
||||
*/
|
||||
manageScheduledTaskSessions: boolean;
|
||||
cleanupSession?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
sessionId: string,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function runWithScheduledTaskTarget<T>(
|
||||
target: ScheduledTaskTarget,
|
||||
fn: () => T,
|
||||
): T {
|
||||
if (target.runtimeBaseDir === undefined) {
|
||||
return fn();
|
||||
}
|
||||
return Storage.runWithResolvedRuntimeBaseDir(target.runtimeBaseDir, fn);
|
||||
}
|
||||
|
||||
/** On-the-wire task shape — normalizes the optional on-disk fields so the
|
||||
|
|
@ -340,7 +379,9 @@ function registerScheduledTaskCrudRoutes(
|
|||
if (!target) return;
|
||||
if (!requireOpenGeneration(target, res)) return;
|
||||
try {
|
||||
const tasks = await readCronTasks(target.workspaceCwd);
|
||||
const tasks = await runWithScheduledTaskTarget(target, () =>
|
||||
readCronTasks(target.workspaceCwd),
|
||||
);
|
||||
if (!requireOpenGeneration(target, res)) return;
|
||||
res.status(200).json({ v: 1, tasks: tasks.map(toView) });
|
||||
} catch (err) {
|
||||
|
|
@ -465,7 +506,13 @@ function registerScheduledTaskCrudRoutes(
|
|||
// an orphan with no owning task. Best-effort — the write-lock cap check
|
||||
// below stays authoritative for the concurrent-create race.
|
||||
try {
|
||||
if ((await readCronTasks(workspaceCwd)).length >= MAX_SCHEDULED_TASKS) {
|
||||
if (
|
||||
(
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
readCronTasks(workspaceCwd),
|
||||
)
|
||||
).length >= MAX_SCHEDULED_TASKS
|
||||
) {
|
||||
res.status(409).json({
|
||||
error: `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`,
|
||||
code: 'max_tasks_reached',
|
||||
|
|
@ -485,10 +532,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
});
|
||||
boundSessionId = session.sessionId;
|
||||
if (!requireOpenGeneration(target, res)) {
|
||||
await bridge.closeSession(boundSessionId).catch(() => {});
|
||||
await new SessionService(workspaceCwd)
|
||||
.removeSession(boundSessionId)
|
||||
.catch(() => {});
|
||||
await teardownBoundSession(target, boundSessionId);
|
||||
return;
|
||||
}
|
||||
// Name the session after the task so it's recognizable in the session
|
||||
|
|
@ -536,11 +580,8 @@ function registerScheduledTaskCrudRoutes(
|
|||
// which passes the pre-check but loses the authoritative write) would leave
|
||||
// a named "⏰ …" session in the list with no owning task.
|
||||
const rollbackSession = async () => {
|
||||
if (boundSessionId !== undefined && bridge) {
|
||||
await bridge.closeSession(boundSessionId).catch(() => {});
|
||||
await new SessionService(workspaceCwd)
|
||||
.removeSession(boundSessionId)
|
||||
.catch(() => {});
|
||||
if (boundSessionId !== undefined) {
|
||||
await teardownBoundSession(target, boundSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -548,21 +589,23 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
// Cap check under the write lock so two concurrent creates can't both
|
||||
// slip past a stale count. Returning the input unchanged is a no-op
|
||||
// (no write), which the flag below turns into a 409.
|
||||
if (tasks.length >= MAX_SCHEDULED_TASKS) {
|
||||
overCap = true;
|
||||
return tasks;
|
||||
}
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = [...tasks, task];
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
// Cap check under the write lock so two concurrent creates can't both
|
||||
// slip past a stale count. Returning the input unchanged is a no-op
|
||||
// (no write), which the flag below turns into a 409.
|
||||
if (tasks.length >= MAX_SCHEDULED_TASKS) {
|
||||
overCap = true;
|
||||
return tasks;
|
||||
}
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = [...tasks, task];
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
await rollbackSession();
|
||||
|
|
@ -581,7 +624,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`POST ${base}`,
|
||||
|
|
@ -727,90 +770,92 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) can't be
|
||||
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
|
||||
// sends for it is the Enable toggle — which would 200 here and then read
|
||||
// back disabled again, an Enable control that can never succeed with no
|
||||
// error explaining why. Reject the enable with the recreate remediation
|
||||
// instead of acknowledging an update that changes nothing runnable.
|
||||
if (patch.enabled === true && taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A task disabled BY archiving its session (`disabledByArchive`) can't
|
||||
// be re-enabled through this generic PATCH: its bound session is still
|
||||
// archived and can't fire, so flipping `enabled: true` here would show
|
||||
// an enabled task with a countdown that never runs. The task/session
|
||||
// lifecycle must stay coupled — the caller has to unarchive the session
|
||||
// (which clears the marker and reloads it). Reject and leave the file
|
||||
// untouched.
|
||||
if (patch.enabled === true && current.disabledByArchive === true) {
|
||||
blockedByArchive = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = { ...current, ...patch };
|
||||
// `name: null/""` clears the field rather than storing an empty name,
|
||||
// so toView reports it as unnamed and isValidTask never sees a "".
|
||||
if (clearName) delete next.name;
|
||||
if (clearDelivery) delete next.delivery;
|
||||
// Re-seat the task's schedule anchor to "now" whenever an edit would
|
||||
// otherwise let the scheduler retroactively fire an already-past slot.
|
||||
const justReEnabled =
|
||||
current.enabled === false && patch.enabled === true;
|
||||
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
|
||||
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
|
||||
// and drop a legitimately-pending catch-up fire.
|
||||
const cronChanged =
|
||||
patch.cron !== undefined &&
|
||||
canonicalCron(patch.cron) !== canonicalCron(current.cron);
|
||||
const becameRecurring =
|
||||
patch.recurring === true && current.recurring !== true;
|
||||
const becameOneShot =
|
||||
patch.recurring === false && current.recurring !== false;
|
||||
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
|
||||
// is paused must not leave a stale anchor that fires retroactively when
|
||||
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
|
||||
// schedule change of its own to trigger the re-seat). Re-seating a paused
|
||||
// task's anchor is harmless — it doesn't fire until enabled.
|
||||
{
|
||||
const now = Date.now();
|
||||
const minute = now - (now % 60_000);
|
||||
if (
|
||||
next.recurring &&
|
||||
(justReEnabled || cronChanged || becameRecurring)
|
||||
) {
|
||||
// A recurring task's anchor is lastFiredAt: resume from now so a
|
||||
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
|
||||
// fire a past slot (matters most for a bound task, whose catch-up runs
|
||||
// on every file-watch reload).
|
||||
next.lastFiredAt = minute;
|
||||
} else if (
|
||||
!next.recurring &&
|
||||
(justReEnabled || cronChanged || becameOneShot)
|
||||
) {
|
||||
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
|
||||
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
|
||||
// at its NEXT occurrence — otherwise the scheduler reads its original
|
||||
// long-past slot as a MISSED one-shot and fires + permanently deletes
|
||||
// it. A one-shot disabled past its slot then re-enabled would
|
||||
// otherwise be silently destroyed on the next reload.
|
||||
next.createdAt = now;
|
||||
next.lastFiredAt = minute;
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) can't be
|
||||
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
|
||||
// sends for it is the Enable toggle — which would 200 here and then read
|
||||
// back disabled again, an Enable control that can never succeed with no
|
||||
// error explaining why. Reject the enable with the recreate remediation
|
||||
// instead of acknowledging an update that changes nothing runnable.
|
||||
if (patch.enabled === true && taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
// A task disabled BY archiving its session (`disabledByArchive`) can't
|
||||
// be re-enabled through this generic PATCH: its bound session is still
|
||||
// archived and can't fire, so flipping `enabled: true` here would show
|
||||
// an enabled task with a countdown that never runs. The task/session
|
||||
// lifecycle must stay coupled — the caller has to unarchive the session
|
||||
// (which clears the marker and reloads it). Reject and leave the file
|
||||
// untouched.
|
||||
if (patch.enabled === true && current.disabledByArchive === true) {
|
||||
blockedByArchive = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = { ...current, ...patch };
|
||||
// `name: null/""` clears the field rather than storing an empty name,
|
||||
// so toView reports it as unnamed and isValidTask never sees a "".
|
||||
if (clearName) delete next.name;
|
||||
if (clearDelivery) delete next.delivery;
|
||||
// Re-seat the task's schedule anchor to "now" whenever an edit would
|
||||
// otherwise let the scheduler retroactively fire an already-past slot.
|
||||
const justReEnabled =
|
||||
current.enabled === false && patch.enabled === true;
|
||||
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
|
||||
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
|
||||
// and drop a legitimately-pending catch-up fire.
|
||||
const cronChanged =
|
||||
patch.cron !== undefined &&
|
||||
canonicalCron(patch.cron) !== canonicalCron(current.cron);
|
||||
const becameRecurring =
|
||||
patch.recurring === true && current.recurring !== true;
|
||||
const becameOneShot =
|
||||
patch.recurring === false && current.recurring !== false;
|
||||
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
|
||||
// is paused must not leave a stale anchor that fires retroactively when
|
||||
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
|
||||
// schedule change of its own to trigger the re-seat). Re-seating a paused
|
||||
// task's anchor is harmless — it doesn't fire until enabled.
|
||||
{
|
||||
const now = Date.now();
|
||||
const minute = now - (now % 60_000);
|
||||
if (
|
||||
next.recurring &&
|
||||
(justReEnabled || cronChanged || becameRecurring)
|
||||
) {
|
||||
// A recurring task's anchor is lastFiredAt: resume from now so a
|
||||
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
|
||||
// fire a past slot (matters most for a bound task, whose catch-up runs
|
||||
// on every file-watch reload).
|
||||
next.lastFiredAt = minute;
|
||||
} else if (
|
||||
!next.recurring &&
|
||||
(justReEnabled || cronChanged || becameOneShot)
|
||||
) {
|
||||
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
|
||||
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
|
||||
// at its NEXT occurrence — otherwise the scheduler reads its original
|
||||
// long-past slot as a MISSED one-shot and fires + permanently deletes
|
||||
// it. A one-shot disabled past its slot then re-enabled would
|
||||
// otherwise be silently destroyed on the next reload.
|
||||
next.createdAt = now;
|
||||
next.lastFiredAt = minute;
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -828,7 +873,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`PATCH ${base}/${id}`,
|
||||
|
|
@ -917,21 +962,23 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
const match = tasks[idx]!.sessionId;
|
||||
if (typeof match === 'string' && match.length > 0) {
|
||||
boundSessionId = match;
|
||||
}
|
||||
removed = true;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.filter((_, i) => i !== idx);
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
const match = tasks[idx]!.sessionId;
|
||||
if (typeof match === 'string' && match.length > 0) {
|
||||
boundSessionId = match;
|
||||
}
|
||||
removed = true;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.filter((_, i) => i !== idx);
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -949,7 +996,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`DELETE ${base}/${id}`,
|
||||
|
|
@ -1007,54 +1054,56 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) must not
|
||||
// run from ANY path. The scheduler already skips it and the list view
|
||||
// reports it disabled; reject a direct `/run` too — its on-disk
|
||||
// `enabled` may still be true, so the disabled check below is not enough.
|
||||
// Executing it here would run the prompt with its safety gate ignored,
|
||||
// which is exactly what the removal must never allow.
|
||||
if (taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A disabled task must not record a manual run: it's paused (and if it
|
||||
// was disabled by archiving its session, that session can't even fire),
|
||||
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
|
||||
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
|
||||
// the UI, where onRunPrompt already rejects before recording.
|
||||
if (current.enabled === false) {
|
||||
blockedDisabled = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = {
|
||||
...current,
|
||||
lastFiredAt: now,
|
||||
runs: appendCronRun(current.runs, {
|
||||
at: now,
|
||||
kind: 'manual',
|
||||
...(current.sessionId ? { sessionId: current.sessionId } : {}),
|
||||
}),
|
||||
};
|
||||
updated = next;
|
||||
// A one-shot's manual run IS its single fire — remove it from the store
|
||||
// so the scheduler doesn't ALSO fire it at its original scheduled time
|
||||
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
|
||||
// stop that fire). The response still returns the recorded run.
|
||||
rollbackBefore = tasks;
|
||||
const nextTasks = !current.recurring
|
||||
? tasks.filter((_, i) => i !== idx)
|
||||
: tasks.map((t, i) => (i === idx ? next : t));
|
||||
rollbackAfter = nextTasks;
|
||||
return nextTasks;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) must not
|
||||
// run from ANY path. The scheduler already skips it and the list view
|
||||
// reports it disabled; reject a direct `/run` too — its on-disk
|
||||
// `enabled` may still be true, so the disabled check below is not enough.
|
||||
// Executing it here would run the prompt with its safety gate ignored,
|
||||
// which is exactly what the removal must never allow.
|
||||
if (taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A disabled task must not record a manual run: it's paused (and if it
|
||||
// was disabled by archiving its session, that session can't even fire),
|
||||
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
|
||||
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
|
||||
// the UI, where onRunPrompt already rejects before recording.
|
||||
if (current.enabled === false) {
|
||||
blockedDisabled = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = {
|
||||
...current,
|
||||
lastFiredAt: now,
|
||||
runs: appendCronRun(current.runs, {
|
||||
at: now,
|
||||
kind: 'manual',
|
||||
...(current.sessionId ? { sessionId: current.sessionId } : {}),
|
||||
}),
|
||||
};
|
||||
updated = next;
|
||||
// A one-shot's manual run IS its single fire — remove it from the store
|
||||
// so the scheduler doesn't ALSO fire it at its original scheduled time
|
||||
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
|
||||
// stop that fire). The response still returns the recorded run.
|
||||
rollbackBefore = tasks;
|
||||
const nextTasks = !current.recurring
|
||||
? tasks.filter((_, i) => i !== idx)
|
||||
: tasks.map((t, i) => (i === idx ? next : t));
|
||||
rollbackAfter = nextTasks;
|
||||
return nextTasks;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -1072,7 +1121,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`POST ${base}/${id}/run`,
|
||||
|
|
@ -1141,6 +1190,17 @@ export function registerScheduledTasksRoutes(
|
|||
if (runtime && !requireTrustedWorkspaceRuntime(runtime, res)) return null;
|
||||
return {
|
||||
workspaceCwd: boundWorkspace,
|
||||
...(runtime
|
||||
? {
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
...(deps.cleanupSession
|
||||
? {
|
||||
cleanupSession: (sessionId: string) =>
|
||||
deps.cleanupSession!(runtime, sessionId),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
bridge: runtime?.bridge ?? bridge,
|
||||
...(runtime?.generationGuard
|
||||
? {
|
||||
|
|
@ -1173,6 +1233,7 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
|
|||
safeBody,
|
||||
manageScheduledTaskSessions,
|
||||
channelDeliveryAuthorizations,
|
||||
cleanupSession,
|
||||
} = deps;
|
||||
registerScheduledTaskCrudRoutes(app, {
|
||||
prefix: '/workspaces/:workspace',
|
||||
|
|
@ -1186,6 +1247,13 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
|
|||
if (!requireTrustedWorkspaceRuntime(runtime, res)) return null;
|
||||
return {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
...(cleanupSession
|
||||
? {
|
||||
cleanupSession: (sessionId: string) =>
|
||||
cleanupSession(runtime, sessionId),
|
||||
}
|
||||
: {}),
|
||||
// Mirror the primary surface: only bind a session when management is on,
|
||||
// so a bound task always has something to keep it resident + rehydrate it.
|
||||
bridge: manageScheduledTaskSessions ? runtime.bridge : undefined,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
BTW_MAX_INPUT_LENGTH,
|
||||
GROUP_COLOR_OPTIONS,
|
||||
GitWorktreeService,
|
||||
SessionService,
|
||||
SessionOrganizationError,
|
||||
SESSION_TRANSCRIPT_MAX_LIMIT,
|
||||
SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
|
||||
|
|
@ -69,6 +68,7 @@ import {
|
|||
archiveDaemonSessions,
|
||||
assertSessionArchived,
|
||||
assertSessionLoadable,
|
||||
deleteDaemonSessionIfOrphan,
|
||||
deleteDaemonSessions,
|
||||
logSessionArchiveWarning,
|
||||
type SessionArchiveCoordinator,
|
||||
|
|
@ -105,6 +105,7 @@ import {
|
|||
type VirtualSubagentSessions,
|
||||
} from '../virtual-subagent-sessions.js';
|
||||
import {
|
||||
resolveWorkspaceEntryFromParam,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
sendUntrustedWorkspaceResponse,
|
||||
sendWorkspaceRuntimeUnavailable,
|
||||
|
|
@ -113,6 +114,10 @@ import type {
|
|||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
import {
|
||||
createWorkspaceRuntimeSessionService,
|
||||
runWithWorkspaceRuntimeStorage,
|
||||
} from '../workspace-runtime-storage.js';
|
||||
import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js';
|
||||
|
||||
// `HEAD` is the most prominent ref name git rejects as a branch name.
|
||||
|
|
@ -178,9 +183,10 @@ function runWorkspaceInspectionWithLogPolicy<T>(
|
|||
runtime: WorkspaceRuntime,
|
||||
read: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const readInRuntime = () => runWithWorkspaceRuntimeStorage(runtime, read);
|
||||
return isReadOnlyWorkspaceInspection(runtime)
|
||||
? runWithoutDebugLogSession(read)
|
||||
: read();
|
||||
? runWithoutDebugLogSession(readInRuntime)
|
||||
: readInRuntime();
|
||||
}
|
||||
|
||||
function requireSessionArtifactClientId(
|
||||
|
|
@ -643,9 +649,35 @@ export function registerSessionRoutes(
|
|||
return runtime;
|
||||
};
|
||||
|
||||
const hasActivePersistedSessions = async (workspaceCwd: string) => {
|
||||
const resolveLegacyPrimaryRuntimeFromParam = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceRuntime | null => {
|
||||
const entry = resolveWorkspaceEntryFromParam(
|
||||
workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
'id',
|
||||
);
|
||||
if (!entry) return null;
|
||||
if (!entry.primary) {
|
||||
sendWorkspaceMismatch(res, entry.workspaceCwd);
|
||||
return null;
|
||||
}
|
||||
const runtime =
|
||||
entry.state === 'active' ? entry.current?.runtime : undefined;
|
||||
if (!runtime) {
|
||||
sendWorkspaceRuntimeUnavailable(res, entry);
|
||||
return null;
|
||||
}
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const hasActivePersistedSessions = async (runtime: WorkspaceRuntime) => {
|
||||
try {
|
||||
const page = await new SessionService(workspaceCwd).listSessions({
|
||||
const page = await createWorkspaceRuntimeSessionService(
|
||||
runtime,
|
||||
).listSessions({
|
||||
archiveState: 'active',
|
||||
size: 1,
|
||||
});
|
||||
|
|
@ -694,7 +726,7 @@ export function registerSessionRoutes(
|
|||
res: Response,
|
||||
target: {
|
||||
route: string;
|
||||
workspaceCwd: string;
|
||||
runtime: WorkspaceRuntime;
|
||||
workspaceQualified?: boolean;
|
||||
archiveState?: SessionArchiveState;
|
||||
},
|
||||
|
|
@ -713,22 +745,29 @@ export function registerSessionRoutes(
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const result = await archiveCoordinator.runSharedMany(
|
||||
[sessionId],
|
||||
async () => {
|
||||
const result = await archiveCoordinator.runSharedMany([sessionId], () =>
|
||||
runWithWorkspaceRuntimeStorage(target.runtime, async () => {
|
||||
if (target.archiveState === 'archived') {
|
||||
await assertSessionArchived(target.workspaceCwd, sessionId);
|
||||
await assertSessionArchived(
|
||||
target.runtime.workspaceCwd,
|
||||
sessionId,
|
||||
target.runtime.sessionRuntimeBaseDir,
|
||||
);
|
||||
} else {
|
||||
await assertSessionLoadable(target.workspaceCwd, sessionId);
|
||||
await assertSessionLoadable(
|
||||
target.runtime.workspaceCwd,
|
||||
sessionId,
|
||||
target.runtime.sessionRuntimeBaseDir,
|
||||
);
|
||||
}
|
||||
return exportSessionTranscript({
|
||||
workspaceCwd: target.workspaceCwd,
|
||||
workspaceCwd: target.runtime.workspaceCwd,
|
||||
sessionId,
|
||||
format,
|
||||
archiveState: target.archiveState,
|
||||
config: { getChannel: () => 'daemon' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
const filename = result.filename.replace(/["\\\r\n]/g, '_');
|
||||
res
|
||||
|
|
@ -751,7 +790,7 @@ export function registerSessionRoutes(
|
|||
route: target.route,
|
||||
sessionId,
|
||||
...(target.workspaceQualified
|
||||
? { workspaceCwd: target.workspaceCwd }
|
||||
? { workspaceCwd: target.runtime.workspaceCwd }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -1030,6 +1069,7 @@ export function registerSessionRoutes(
|
|||
const location = await assertSessionLoadable(
|
||||
runtime.workspaceCwd,
|
||||
sessionId,
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
);
|
||||
return location === 'active';
|
||||
};
|
||||
|
|
@ -1164,25 +1204,6 @@ export function registerSessionRoutes(
|
|||
error: e.error instanceof Error ? e.error.message : String(e.error),
|
||||
}));
|
||||
|
||||
const resolveWorkspaceParam = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): string | null => {
|
||||
const workspaceCwd = req.params['id'] ?? '';
|
||||
if (!path.isAbsolute(workspaceCwd)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: '`:id` must decode to an absolute workspace path' });
|
||||
return null;
|
||||
}
|
||||
const key = canonicalizeWorkspace(workspaceCwd);
|
||||
if (key !== boundWorkspace) {
|
||||
sendWorkspaceMismatch(res, key);
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const withPrimaryOnlyMutableSession = (
|
||||
route: string,
|
||||
handler: (
|
||||
|
|
@ -1558,13 +1579,15 @@ export function registerSessionRoutes(
|
|||
} catch (error) {
|
||||
if (!session.attached) {
|
||||
try {
|
||||
const killed = await runtime.bridge.killSession(session.sessionId, {
|
||||
requireZeroAttaches: true,
|
||||
});
|
||||
if (killed) {
|
||||
await new SessionService(runtime.workspaceCwd).removeSession(
|
||||
session.sessionId,
|
||||
);
|
||||
const removed = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId: session.sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
if (removed) {
|
||||
if (worktreeMeta) {
|
||||
await new GitWorktreeService(workspaceCwd)
|
||||
.removeUserWorktree(worktreeMeta.slug, { deleteBranch: true })
|
||||
|
|
@ -1627,13 +1650,15 @@ export function registerSessionRoutes(
|
|||
// skip the kill. Without the flag, that second client's
|
||||
// session would die mid-prompt.
|
||||
try {
|
||||
const killed = await runtime.bridge.killSession(session.sessionId, {
|
||||
requireZeroAttaches: true,
|
||||
});
|
||||
if (killed) {
|
||||
await new SessionService(runtime.workspaceCwd).removeSession(
|
||||
session.sessionId,
|
||||
);
|
||||
const removed = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId: session.sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
if (removed) {
|
||||
// Clean up the worktree if one was created for this session.
|
||||
if (worktreeMeta) {
|
||||
await new GitWorktreeService(workspaceCwd)
|
||||
|
|
@ -1726,9 +1751,9 @@ export function registerSessionRoutes(
|
|||
// Write the worktree sidecar so the session list can restore
|
||||
// worktree metadata after a daemon restart.
|
||||
await writeWorktreeSession(
|
||||
new SessionService(workspaceCwd).getWorktreeSessionPath(
|
||||
session.sessionId,
|
||||
),
|
||||
createWorkspaceRuntimeSessionService(
|
||||
runtime,
|
||||
).getWorktreeSessionPath(session.sessionId),
|
||||
{
|
||||
slug: worktreeMeta.slug,
|
||||
worktreePath: worktreeMeta.path,
|
||||
|
|
@ -1750,14 +1775,14 @@ export function registerSessionRoutes(
|
|||
error: cdErr instanceof Error ? cdErr.message : String(cdErr),
|
||||
});
|
||||
}
|
||||
const killed = await runtime.bridge
|
||||
.killSession(session.sessionId, { requireZeroAttaches: true })
|
||||
.catch(() => false);
|
||||
if (killed) {
|
||||
await new SessionService(workspaceCwd)
|
||||
.removeSession(session.sessionId)
|
||||
.catch(() => {});
|
||||
}
|
||||
await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId: session.sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
).catch(() => false);
|
||||
// cd failed so the session never entered the worktree — the
|
||||
// worktree is unused regardless of whether the session was
|
||||
// killed or another client keeps it alive in the main checkout.
|
||||
|
|
@ -1899,13 +1924,18 @@ export function registerSessionRoutes(
|
|||
const session = await archiveCoordinator.runSharedMany(
|
||||
[sessionId],
|
||||
async () => {
|
||||
await assertSessionLoadable(workspaceCwd, sessionId);
|
||||
await assertSessionLoadable(
|
||||
workspaceCwd,
|
||||
sessionId,
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
);
|
||||
// Recover the persisted parent lineage so the restored live entry
|
||||
// reports it (the bridge otherwise creates the entry without it, and
|
||||
// status calls would show a restored sub-session as top-level).
|
||||
const metadata = await new SessionService(
|
||||
workspaceCwd,
|
||||
).readCreationMetadata(sessionId);
|
||||
const metadata =
|
||||
await createWorkspaceRuntimeSessionService(
|
||||
runtime,
|
||||
).readCreationMetadata(sessionId);
|
||||
runtime.generationGuard?.assertOpen();
|
||||
return action === 'load'
|
||||
? await runtime.bridge.loadSession({
|
||||
|
|
@ -1980,7 +2010,9 @@ export function registerSessionRoutes(
|
|||
// the main workspace (pre-existing shape, low frequency).
|
||||
if (!session.worktree) {
|
||||
const sidecar = await readWorktreeSession(
|
||||
new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId),
|
||||
createWorkspaceRuntimeSessionService(
|
||||
runtime,
|
||||
).getWorktreeSessionPath(sessionId),
|
||||
).catch(() => null);
|
||||
if (sidecar) {
|
||||
// Defense-in-depth: resolve symlinks on both the target and
|
||||
|
|
@ -2221,14 +2253,14 @@ export function registerSessionRoutes(
|
|||
runtime.generationGuard?.assertOpen();
|
||||
} catch (error) {
|
||||
if (!result.attached) {
|
||||
const killed = await runtime.bridge
|
||||
.killSession(result.sessionId, { requireZeroAttaches: true })
|
||||
.catch(() => false);
|
||||
if (killed) {
|
||||
await new SessionService(runtime.workspaceCwd)
|
||||
.removeSession(result.sessionId)
|
||||
.catch(() => {});
|
||||
}
|
||||
await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId: result.sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
).catch(() => false);
|
||||
} else {
|
||||
await runtime.bridge
|
||||
.detachClient(result.sessionId, result.clientId)
|
||||
|
|
@ -2238,11 +2270,16 @@ export function registerSessionRoutes(
|
|||
}
|
||||
if (!res.writable) {
|
||||
if (!result.attached) {
|
||||
runtime.bridge
|
||||
.killSession(result.sessionId, { requireZeroAttaches: true })
|
||||
.catch(() => {
|
||||
// Best-effort cleanup; channel.exited will eventually reap.
|
||||
});
|
||||
void runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId: result.sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
).catch(() => {
|
||||
// Best-effort cleanup; channel.exited will eventually reap.
|
||||
});
|
||||
} else {
|
||||
runtime.bridge
|
||||
.detachClient(result.sessionId, result.clientId)
|
||||
|
|
@ -2413,7 +2450,7 @@ export function registerSessionRoutes(
|
|||
app.get('/session/:id/export', async (req, res) => {
|
||||
await handleSessionExport(req, res, {
|
||||
route: 'GET /session/:id/export',
|
||||
workspaceCwd: boundWorkspace,
|
||||
runtime: workspaceRegistry.primary,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2423,7 +2460,7 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
await handleSessionExport(req, res, {
|
||||
route,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
runtime,
|
||||
workspaceQualified: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -2436,7 +2473,7 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
await handleSessionExport(req, res, {
|
||||
route,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
runtime,
|
||||
workspaceQualified: true,
|
||||
archiveState: 'archived',
|
||||
});
|
||||
|
|
@ -2537,76 +2574,82 @@ export function registerSessionRoutes(
|
|||
|
||||
try {
|
||||
const result = await runWithoutDebugLogSession(() =>
|
||||
archiveCoordinator.runSharedMany([sessionId], async () => {
|
||||
const service = new SessionService(runtime.workspaceCwd);
|
||||
if (cursor === undefined) {
|
||||
await assertSessionLoadable(runtime.workspaceCwd, sessionId);
|
||||
}
|
||||
const codec = getTranscriptCursorCodec(runtime);
|
||||
const reader = new SessionTranscriptReader(
|
||||
runtime.workspaceCwd,
|
||||
codec,
|
||||
);
|
||||
let page;
|
||||
try {
|
||||
page = await reader.readPage(sessionId, {
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
...(beforeRecordId !== undefined ? { beforeRecordId } : {}),
|
||||
maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
archiveCoordinator.runSharedMany([sessionId], () =>
|
||||
runWithWorkspaceRuntimeStorage(runtime, async () => {
|
||||
const service = createWorkspaceRuntimeSessionService(runtime);
|
||||
if (cursor === undefined) {
|
||||
await assertSessionLoadable(
|
||||
runtime.workspaceCwd,
|
||||
sessionId,
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
);
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
const codec = getTranscriptCursorCodec(runtime);
|
||||
const reader = new SessionTranscriptReader(
|
||||
runtime.workspaceCwd,
|
||||
codec,
|
||||
);
|
||||
let page;
|
||||
try {
|
||||
page = await reader.readPage(sessionId, {
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
...(beforeRecordId !== undefined ? { beforeRecordId } : {}),
|
||||
maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
|
||||
}
|
||||
const location = await service.getSessionLocation(sessionId);
|
||||
if (location === 'archived') {
|
||||
throw new SessionArchivedError(sessionId);
|
||||
}
|
||||
if (location === 'conflict') {
|
||||
throw new SessionConflictError(sessionId);
|
||||
}
|
||||
throw new SessionNotFoundError(sessionId);
|
||||
}
|
||||
if (page.records.some((record) => record.sessionId !== sessionId)) {
|
||||
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
|
||||
}
|
||||
const location = await service.getSessionLocation(sessionId);
|
||||
if (location === 'archived') {
|
||||
throw new SessionArchivedError(sessionId);
|
||||
}
|
||||
if (location === 'conflict') {
|
||||
throw new SessionConflictError(sessionId);
|
||||
}
|
||||
throw new SessionNotFoundError(sessionId);
|
||||
}
|
||||
if (page.records.some((record) => record.sessionId !== sessionId)) {
|
||||
throw new SessionTranscriptSnapshotUnavailableError(sessionId);
|
||||
}
|
||||
const replay = await replayTranscriptRecordPage({
|
||||
sessionId,
|
||||
page,
|
||||
encodeCursor: (state) => codec.encode(state),
|
||||
});
|
||||
const cursorTooLarge =
|
||||
replay.nextCursor !== undefined &&
|
||||
Buffer.byteLength(replay.nextCursor) >
|
||||
WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES;
|
||||
return {
|
||||
v: 1 as const,
|
||||
sessionId,
|
||||
events: replay.updates.map((update) => ({
|
||||
const replay = await replayTranscriptRecordPage({
|
||||
sessionId,
|
||||
page,
|
||||
encodeCursor: (state) => codec.encode(state),
|
||||
});
|
||||
const cursorTooLarge =
|
||||
replay.nextCursor !== undefined &&
|
||||
Buffer.byteLength(replay.nextCursor) >
|
||||
WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES;
|
||||
return {
|
||||
v: 1 as const,
|
||||
type: 'session_update' as const,
|
||||
data: update,
|
||||
})),
|
||||
...(replay.nextCursor && !cursorTooLarge
|
||||
? { nextCursor: replay.nextCursor }
|
||||
: {}),
|
||||
hasMore: cursorTooLarge ? false : replay.hasMore,
|
||||
startTime: replay.startTime,
|
||||
lastUpdated: replay.lastUpdated,
|
||||
...(replay.partial || cursorTooLarge
|
||||
? {
|
||||
partial: true as const,
|
||||
replayError: cursorTooLarge
|
||||
? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR
|
||||
: replay.replayError,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
sessionId,
|
||||
events: replay.updates.map((update) => ({
|
||||
v: 1 as const,
|
||||
type: 'session_update' as const,
|
||||
data: update,
|
||||
})),
|
||||
...(replay.nextCursor && !cursorTooLarge
|
||||
? { nextCursor: replay.nextCursor }
|
||||
: {}),
|
||||
hasMore: cursorTooLarge ? false : replay.hasMore,
|
||||
startTime: replay.startTime,
|
||||
lastUpdated: replay.lastUpdated,
|
||||
...(replay.partial || cursorTooLarge
|
||||
? {
|
||||
partial: true as const,
|
||||
replayError: cursorTooLarge
|
||||
? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR
|
||||
: replay.replayError,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
const serialized = serializeWorkspaceTranscriptResponse(
|
||||
result,
|
||||
|
|
@ -3361,18 +3404,21 @@ export function registerSessionRoutes(
|
|||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
try {
|
||||
const service = new SessionService(boundWorkspace);
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
onError: ({ phase, sessionId, error }) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
const runtime = workspaceRegistry.primary;
|
||||
const service = createWorkspaceRuntimeSessionService(runtime);
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
onError: ({ phase, sessionId, error }) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const removedId of result.removed) {
|
||||
clearBranchSessionEntry(removedId);
|
||||
}
|
||||
|
|
@ -3386,17 +3432,20 @@ export function registerSessionRoutes(
|
|||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
|
||||
const service = new SessionService(boundWorkspace, {
|
||||
const runtime = workspaceRegistry.primary;
|
||||
const service = createWorkspaceRuntimeSessionService(runtime, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
archiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
res.status(200).json({
|
||||
archived: result.archived,
|
||||
alreadyArchived: result.alreadyArchived,
|
||||
|
|
@ -3412,16 +3461,19 @@ export function registerSessionRoutes(
|
|||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
|
||||
const service = new SessionService(boundWorkspace, {
|
||||
const runtime = workspaceRegistry.primary;
|
||||
const service = createWorkspaceRuntimeSessionService(runtime, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
unarchiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
res.status(200).json({
|
||||
unarchived: result.unarchived,
|
||||
alreadyActive: result.alreadyActive,
|
||||
|
|
@ -3445,18 +3497,20 @@ export function registerSessionRoutes(
|
|||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
try {
|
||||
const service = new SessionService(runtime.workspaceCwd);
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
onError: ({ phase, sessionId, error }) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
const service = createWorkspaceRuntimeSessionService(runtime);
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
onError: ({ phase, sessionId, error }) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const removedId of result.removed) {
|
||||
clearBranchSessionEntry(removedId);
|
||||
}
|
||||
|
|
@ -3476,16 +3530,18 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
const service = new SessionService(runtime.workspaceCwd, {
|
||||
const service = createWorkspaceRuntimeSessionService(runtime, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
try {
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
archiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
res.status(200).json({
|
||||
archived: result.archived,
|
||||
alreadyArchived: result.alreadyArchived,
|
||||
|
|
@ -3507,15 +3563,17 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
const service = new SessionService(runtime.workspaceCwd, {
|
||||
const service = createWorkspaceRuntimeSessionService(runtime, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
try {
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
const result = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
unarchiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
res.status(200).json({
|
||||
unarchived: result.unarchived,
|
||||
alreadyActive: result.alreadyActive,
|
||||
|
|
@ -3564,8 +3622,7 @@ export function registerSessionRoutes(
|
|||
);
|
||||
|
||||
type SessionOrganizationTarget = {
|
||||
workspaceCwd: string;
|
||||
bridge: AcpSessionBridge;
|
||||
runtime: WorkspaceRuntime;
|
||||
route: string;
|
||||
};
|
||||
|
||||
|
|
@ -3577,92 +3634,98 @@ export function registerSessionRoutes(
|
|||
const sessionId = requireSessionId(req, res);
|
||||
if (sessionId === null) return;
|
||||
try {
|
||||
await archiveCoordinator.runSharedMany([sessionId], async () => {
|
||||
// Organization is workspace-scoped sidecar state, not live-session
|
||||
// metadata. It intentionally applies to persisted and archived sessions.
|
||||
const sessionService = new SessionService(target.workspaceCwd);
|
||||
let exists = await sessionService.sessionExistsInAnyState(sessionId);
|
||||
if (!exists) {
|
||||
try {
|
||||
const summary = target.bridge.getSessionSummary(sessionId);
|
||||
exists = summary.workspaceCwd === target.workspaceCwd;
|
||||
} catch {
|
||||
exists = false;
|
||||
await archiveCoordinator.runSharedMany([sessionId], () =>
|
||||
runWithWorkspaceRuntimeStorage(target.runtime, async () => {
|
||||
// Organization is workspace-scoped sidecar state, not live-session
|
||||
// metadata. It intentionally applies to persisted and archived sessions.
|
||||
const sessionService = createWorkspaceRuntimeSessionService(
|
||||
target.runtime,
|
||||
);
|
||||
let exists = await sessionService.sessionExistsInAnyState(sessionId);
|
||||
if (!exists) {
|
||||
try {
|
||||
const summary =
|
||||
target.runtime.bridge.getSessionSummary(sessionId);
|
||||
exists = summary.workspaceCwd === target.runtime.workspaceCwd;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
res.status(404).json({
|
||||
error: `No session with id "${sessionId}"`,
|
||||
sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
res.status(404).json({
|
||||
error: `No session with id "${sessionId}"`,
|
||||
sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = safeBody(req);
|
||||
const rawIsPinned = body['isPinned'];
|
||||
if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') {
|
||||
res.status(400).json({
|
||||
error: '`isPinned` must be a boolean',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'isPinned',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawGroupId = body['groupId'];
|
||||
if (
|
||||
rawGroupId !== undefined &&
|
||||
rawGroupId !== null &&
|
||||
typeof rawGroupId !== 'string'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`groupId` must be a string or null',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'groupId',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawColor = body['color'];
|
||||
if (
|
||||
rawColor !== undefined &&
|
||||
rawColor !== null &&
|
||||
(typeof rawColor !== 'string' ||
|
||||
!GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupPresetColor))
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`color` must be a supported color or null',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'color',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = safeBody(req);
|
||||
const rawIsPinned = body['isPinned'];
|
||||
if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') {
|
||||
res.status(400).json({
|
||||
error: '`isPinned` must be a boolean',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'isPinned',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawGroupId = body['groupId'];
|
||||
if (
|
||||
rawGroupId !== undefined &&
|
||||
rawGroupId !== null &&
|
||||
typeof rawGroupId !== 'string'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`groupId` must be a string or null',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'groupId',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawColor = body['color'];
|
||||
if (
|
||||
rawColor !== undefined &&
|
||||
rawColor !== null &&
|
||||
(typeof rawColor !== 'string' ||
|
||||
!GROUP_COLOR_OPTIONS.includes(
|
||||
rawColor as SessionGroupPresetColor,
|
||||
))
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`color` must be a supported color or null',
|
||||
code: 'invalid_session_organization',
|
||||
field: 'color',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await createSessionOrganizationService(
|
||||
target.workspaceCwd,
|
||||
).updateSessionOrganization(sessionId, {
|
||||
...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}),
|
||||
...(rawGroupId !== undefined
|
||||
? { groupId: rawGroupId as string | null }
|
||||
: {}),
|
||||
...(rawColor !== undefined
|
||||
? { color: rawColor as SessionGroupPresetColor | null }
|
||||
: {}),
|
||||
});
|
||||
res.status(200).json({ sessionId, ...organization });
|
||||
});
|
||||
const organization = await createSessionOrganizationService(
|
||||
target.runtime.workspaceCwd,
|
||||
).updateSessionOrganization(sessionId, {
|
||||
...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}),
|
||||
...(rawGroupId !== undefined
|
||||
? { groupId: rawGroupId as string | null }
|
||||
: {}),
|
||||
...(rawColor !== undefined
|
||||
? { color: rawColor as SessionGroupPresetColor | null }
|
||||
: {}),
|
||||
});
|
||||
res.status(200).json({ sessionId, ...organization });
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
sendBridgeError(res, err, {
|
||||
route: target.route,
|
||||
sessionId,
|
||||
workspaceCwd: target.workspaceCwd,
|
||||
workspaceCwd: target.runtime.workspaceCwd,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
app.patch('/session/:id/organization', mutate(), async (req, res) => {
|
||||
await handleSessionOrganizationUpdate(req, res, {
|
||||
workspaceCwd: boundWorkspace,
|
||||
bridge,
|
||||
runtime: workspaceRegistry.primary,
|
||||
route: 'PATCH /session/:id/organization',
|
||||
});
|
||||
});
|
||||
|
|
@ -3675,8 +3738,7 @@ export function registerSessionRoutes(
|
|||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
await handleSessionOrganizationUpdate(req, res, {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
bridge: runtime.bridge,
|
||||
runtime,
|
||||
route,
|
||||
});
|
||||
},
|
||||
|
|
@ -3703,14 +3765,16 @@ export function registerSessionRoutes(
|
|||
});
|
||||
|
||||
app.post('/workspace/:id/session-groups', mutate(), async (req, res) => {
|
||||
const key = resolveWorkspaceParam(req, res);
|
||||
if (key === null) return;
|
||||
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
|
||||
if (runtime === null) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(key).createGroup({
|
||||
name: body['name'] as string,
|
||||
color: body['color'] as SessionGroupColor,
|
||||
});
|
||||
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).createGroup({
|
||||
name: body['name'] as string,
|
||||
color: body['color'] as SessionGroupColor,
|
||||
}),
|
||||
);
|
||||
res.status(201).json({ group });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
|
|
@ -3724,23 +3788,25 @@ export function registerSessionRoutes(
|
|||
'/workspace/:id/session-groups/:groupId',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const key = resolveWorkspaceParam(req, res);
|
||||
if (key === null) return;
|
||||
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
|
||||
if (runtime === null) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(key).updateGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
{
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'name')
|
||||
? { name: body['name'] as string }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'color')
|
||||
? { color: body['color'] as SessionGroupColor }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'order')
|
||||
? { order: body['order'] as number }
|
||||
: {}),
|
||||
},
|
||||
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).updateGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
{
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'name')
|
||||
? { name: body['name'] as string }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'color')
|
||||
? { color: body['color'] as SessionGroupColor }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'order')
|
||||
? { order: body['order'] as number }
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
res.status(200).json({ group });
|
||||
} catch (err) {
|
||||
|
|
@ -3756,11 +3822,13 @@ export function registerSessionRoutes(
|
|||
'/workspace/:id/session-groups/:groupId',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const key = resolveWorkspaceParam(req, res);
|
||||
if (key === null) return;
|
||||
const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res);
|
||||
if (runtime === null) return;
|
||||
try {
|
||||
const deleted = await createSessionOrganizationService(key).deleteGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
const deleted = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).deleteGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
),
|
||||
);
|
||||
res.status(200).json({ deleted });
|
||||
} catch (err) {
|
||||
|
|
@ -3798,12 +3866,12 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).createGroup({
|
||||
name: body['name'] as string,
|
||||
color: body['color'] as SessionGroupColor,
|
||||
});
|
||||
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).createGroup({
|
||||
name: body['name'] as string,
|
||||
color: body['color'] as SessionGroupColor,
|
||||
}),
|
||||
);
|
||||
res.status(201).json({ group });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
|
|
@ -3821,19 +3889,22 @@ export function registerSessionRoutes(
|
|||
if (!runtime) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).updateGroup(req.params['groupId'] ?? '', {
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'name')
|
||||
? { name: body['name'] as string }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'color')
|
||||
? { color: body['color'] as SessionGroupColor }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'order')
|
||||
? { order: body['order'] as number }
|
||||
: {}),
|
||||
});
|
||||
const group = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).updateGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
{
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'name')
|
||||
? { name: body['name'] as string }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'color')
|
||||
? { color: body['color'] as SessionGroupColor }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'order')
|
||||
? { order: body['order'] as number }
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
res.status(200).json({ group });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
|
|
@ -3850,9 +3921,11 @@ export function registerSessionRoutes(
|
|||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
try {
|
||||
const deleted = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).deleteGroup(req.params['groupId'] ?? '');
|
||||
const deleted = await runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
createSessionOrganizationService(runtime.workspaceCwd).deleteGroup(
|
||||
req.params['groupId'] ?? '',
|
||||
),
|
||||
);
|
||||
res.status(200).json({ deleted });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
|
|
@ -3976,7 +4049,7 @@ export function registerSessionRoutes(
|
|||
parsedSource.sourceType !== undefined ||
|
||||
(cursor !== undefined && cursor !== ''
|
||||
? isNumericSessionCursor(cursor)
|
||||
: await hasActivePersistedSessions(key));
|
||||
: await hasActivePersistedSessions(runtime));
|
||||
// The live path only reads cursor/size; persisted-only options
|
||||
// (organized view or archived state) would be silently dropped there.
|
||||
// usePersisted already routes those to the persisted path — assert it so
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -3060,6 +3060,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-env-reload-')),
|
||||
);
|
||||
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
const originalBase = process.env['QWEN_TEST_BOOT_BASE'];
|
||||
const originalLeak = process.env['QWEN_TEST_RELOAD_LEAK'];
|
||||
const originalRemoved = process.env['QWEN_TEST_REMOVED_FROM_DOTENV'];
|
||||
|
|
@ -3076,6 +3078,11 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
() =>
|
||||
({
|
||||
merged: {
|
||||
advanced: {
|
||||
runtimeOutputDir: runtimeMounted
|
||||
? '.runtime-reloaded'
|
||||
: '.runtime-boot',
|
||||
},
|
||||
env: {
|
||||
QWEN_TEST_RUNTIME_VALUE: runtimeMounted ? 'reloaded' : 'boot',
|
||||
},
|
||||
|
|
@ -3110,11 +3117,15 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
effectiveEnv?: NodeJS.ProcessEnv;
|
||||
}
|
||||
| undefined;
|
||||
let primaryRuntime:
|
||||
| import('./workspace-registry.js').WorkspaceRuntime
|
||||
| undefined;
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
|
||||
(_opts, _getPort, deps) => {
|
||||
runtimeMounted = true;
|
||||
workspace = deps?.workspace as typeof workspace;
|
||||
primaryRuntimeEnv = deps?.primaryRuntimeEnv as typeof primaryRuntimeEnv;
|
||||
primaryRuntime = deps?.workspaceRegistry?.primary;
|
||||
return express();
|
||||
},
|
||||
);
|
||||
|
|
@ -3142,6 +3153,9 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(primaryRuntimeEnv?.effectiveEnv).toBeDefined();
|
||||
const capturedRuntimeEnv = primaryRuntimeEnv!.effectiveEnv!;
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('boot');
|
||||
const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot');
|
||||
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
|
||||
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
|
||||
await workspace!.reload({
|
||||
route: 'POST /workspace/reload',
|
||||
|
|
@ -3156,6 +3170,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('reloaded');
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_REMOVED_FROM_DOTENV']).toBe('stale');
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined();
|
||||
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
|
||||
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
} finally {
|
||||
if (originalBase === undefined) {
|
||||
delete process.env['QWEN_TEST_BOOT_BASE'];
|
||||
|
|
@ -3172,6 +3188,11 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
} else {
|
||||
process.env['QWEN_TEST_REMOVED_FROM_DOTENV'] = originalRemoved;
|
||||
}
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
|
||||
}
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -3305,6 +3326,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
);
|
||||
const primary = path.join(tmpDir, 'primary');
|
||||
const secondary = path.join(tmpDir, 'secondary');
|
||||
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
fs.mkdirSync(primary);
|
||||
fs.mkdirSync(secondary);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
|
|
@ -3318,6 +3341,13 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
const isSecondary = workspace === secondary;
|
||||
return {
|
||||
merged: {
|
||||
advanced: {
|
||||
runtimeOutputDir: isSecondary
|
||||
? runtimeMounted
|
||||
? '.secondary-runtime-reloaded'
|
||||
: '.secondary-runtime-boot'
|
||||
: '.primary-runtime',
|
||||
},
|
||||
env: {
|
||||
[isSecondary
|
||||
? 'QWEN_TEST_SECONDARY_ENV'
|
||||
|
|
@ -3381,6 +3411,14 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
const envFilePaths = env.envFilePaths;
|
||||
const envFileReadFailures = env.envFileReadFailures;
|
||||
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('boot');
|
||||
const pinnedRuntimeBaseDir = path.join(
|
||||
secondary,
|
||||
'.secondary-runtime-boot',
|
||||
);
|
||||
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
|
||||
pinnedRuntimeBaseDir,
|
||||
);
|
||||
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
|
||||
await secondaryRuntime!.workspaceService.reload({
|
||||
route: 'POST /workspace/reload',
|
||||
|
|
@ -3391,8 +3429,17 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(env.envFilePaths).toBe(envFilePaths);
|
||||
expect(env.envFileReadFailures).toBe(envFileReadFailures);
|
||||
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('reloaded');
|
||||
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
|
||||
pinnedRuntimeBaseDir,
|
||||
);
|
||||
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
} finally {
|
||||
await handle.close();
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -5296,6 +5343,54 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
).toBeLessThan(vi.mocked(bridge.shutdown).mock.invocationCallOrder[0]!);
|
||||
});
|
||||
|
||||
it('seals and drains admitted session maintenance before bridge shutdown', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-maintenance-drain-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
let finishMaintenance!: () => void;
|
||||
const maintenanceGate = new Promise<void>((resolve) => {
|
||||
finishMaintenance = resolve;
|
||||
});
|
||||
const sealMaintenanceAndWait = vi.fn(() => maintenanceGate);
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
|
||||
const runtimeApp = express();
|
||||
runtimeApp.locals['sessionArchiveCoordinator'] = {
|
||||
sealMaintenanceAndWait,
|
||||
};
|
||||
return runtimeApp;
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{ resolveOnListen: true },
|
||||
);
|
||||
await handle.runtimeReady;
|
||||
|
||||
const close = handle.close();
|
||||
expect(sealMaintenanceAndWait).toHaveBeenCalledOnce();
|
||||
await Promise.resolve();
|
||||
expect(bridge.shutdown).not.toHaveBeenCalled();
|
||||
|
||||
finishMaintenance();
|
||||
await close;
|
||||
expect(bridge.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not cancel deferred runtime once startup is already running', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')),
|
||||
|
|
|
|||
|
|
@ -3108,6 +3108,47 @@ async function runQwenServeImpl(
|
|||
envFileReadFailed: false,
|
||||
envFileReadFailures: Object.freeze([]),
|
||||
};
|
||||
const resolveSessionRuntimeBaseDir = (
|
||||
workspace: string,
|
||||
settings: ReturnType<SettingsRuntime['loadSettings']> | undefined,
|
||||
effectiveEnv: Readonly<NodeJS.ProcessEnv>,
|
||||
): string => {
|
||||
const resolveConfiguredPath = (
|
||||
configuredPath: string,
|
||||
relativeTo: string,
|
||||
): string => {
|
||||
const expanded =
|
||||
configuredPath === '~'
|
||||
? os.homedir()
|
||||
: configuredPath.startsWith('~/') ||
|
||||
configuredPath.startsWith('~\\')
|
||||
? path.join(
|
||||
os.homedir(),
|
||||
...configuredPath
|
||||
.slice(2)
|
||||
.split(/[/\\]+/)
|
||||
.filter(Boolean),
|
||||
)
|
||||
: configuredPath;
|
||||
return path.resolve(relativeTo, expanded);
|
||||
};
|
||||
const runtimeDir = effectiveEnv['QWEN_RUNTIME_DIR'];
|
||||
if (runtimeDir) {
|
||||
return resolveConfiguredPath(runtimeDir, process.cwd());
|
||||
}
|
||||
const settingsDir = settings?.merged.advanced?.runtimeOutputDir;
|
||||
if (settingsDir) {
|
||||
return resolveConfiguredPath(settingsDir, workspace);
|
||||
}
|
||||
const qwenHome = effectiveEnv['QWEN_HOME'];
|
||||
if (qwenHome) {
|
||||
return resolveConfiguredPath(qwenHome, process.cwd());
|
||||
}
|
||||
const homeDir = os.homedir();
|
||||
return homeDir
|
||||
? path.join(homeDir, '.qwen')
|
||||
: path.join(os.tmpdir(), '.qwen');
|
||||
};
|
||||
const logRuntimeEnvFileReadFailures = (
|
||||
workspace: string,
|
||||
snapshot: {
|
||||
|
|
@ -3126,8 +3167,14 @@ async function runQwenServeImpl(
|
|||
});
|
||||
};
|
||||
logRuntimeEnvFileReadFailures(boundWorkspace, runtimeEnvSnapshot);
|
||||
const primarySessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
|
||||
boundWorkspace,
|
||||
runtimeBootSettings,
|
||||
runtimeEnvSnapshot.effectiveEnv,
|
||||
);
|
||||
const runtimeEffectiveEnv: NodeJS.ProcessEnv = {
|
||||
...runtimeEnvSnapshot.effectiveEnv,
|
||||
QWEN_RUNTIME_DIR: primarySessionRuntimeBaseDir,
|
||||
};
|
||||
const replaceRuntimeEffectiveEnv = (
|
||||
nextEnv: Readonly<NodeJS.ProcessEnv>,
|
||||
|
|
@ -3136,6 +3183,7 @@ async function runQwenServeImpl(
|
|||
delete runtimeEffectiveEnv[key];
|
||||
}
|
||||
Object.assign(runtimeEffectiveEnv, nextEnv);
|
||||
runtimeEffectiveEnv['QWEN_RUNTIME_DIR'] = primarySessionRuntimeBaseDir;
|
||||
};
|
||||
const primaryRuntimeEnv: {
|
||||
mode: 'runtime-overlay';
|
||||
|
|
@ -3814,6 +3862,7 @@ async function runQwenServeImpl(
|
|||
{
|
||||
workspaceId: daemonWorkspaceHash,
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionRuntimeBaseDir: primarySessionRuntimeBaseDir,
|
||||
...(workspaceInputs[0]?.displayName
|
||||
? { displayName: workspaceInputs[0].displayName }
|
||||
: {}),
|
||||
|
|
@ -3846,6 +3895,7 @@ async function runQwenServeImpl(
|
|||
fallbackReason?: string;
|
||||
};
|
||||
effectiveEnv: NodeJS.ProcessEnv;
|
||||
sessionRuntimeBaseDir: string;
|
||||
replace: (nextEnv: Readonly<NodeJS.ProcessEnv>) => void;
|
||||
} => {
|
||||
const snapshot = settings
|
||||
|
|
@ -3863,7 +3913,15 @@ async function runQwenServeImpl(
|
|||
envFileReadFailures: Object.freeze([]),
|
||||
};
|
||||
logRuntimeEnvFileReadFailures(workspace, snapshot);
|
||||
const effectiveEnv: NodeJS.ProcessEnv = { ...snapshot.effectiveEnv };
|
||||
const sessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
|
||||
workspace,
|
||||
settings,
|
||||
snapshot.effectiveEnv,
|
||||
);
|
||||
const effectiveEnv: NodeJS.ProcessEnv = {
|
||||
...snapshot.effectiveEnv,
|
||||
QWEN_RUNTIME_DIR: sessionRuntimeBaseDir,
|
||||
};
|
||||
const metadata: {
|
||||
mode: 'runtime-overlay';
|
||||
overlayKeys: string[];
|
||||
|
|
@ -3883,11 +3941,13 @@ async function runQwenServeImpl(
|
|||
return {
|
||||
metadata,
|
||||
effectiveEnv,
|
||||
sessionRuntimeBaseDir,
|
||||
replace(nextEnv) {
|
||||
for (const key of Object.keys(effectiveEnv)) {
|
||||
delete effectiveEnv[key];
|
||||
}
|
||||
Object.assign(effectiveEnv, nextEnv);
|
||||
effectiveEnv['QWEN_RUNTIME_DIR'] = sessionRuntimeBaseDir;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -4177,6 +4237,7 @@ async function runQwenServeImpl(
|
|||
const secondaryRuntime: WorkspaceRuntime = {
|
||||
workspaceId: secondaryWorkspaceHash,
|
||||
workspaceCwd: workspaceInput.cwd,
|
||||
sessionRuntimeBaseDir: secondaryEnv.sessionRuntimeBaseDir,
|
||||
...(workspaceInput.displayName
|
||||
? { displayName: workspaceInput.displayName }
|
||||
: {}),
|
||||
|
|
@ -4711,6 +4772,7 @@ async function runQwenServeImpl(
|
|||
const wsRuntime: WorkspaceRuntime = {
|
||||
workspaceId: wsHash,
|
||||
workspaceCwd: cwd,
|
||||
sessionRuntimeBaseDir: wsEnv.sessionRuntimeBaseDir,
|
||||
...(buildOptions?.displayName !== undefined
|
||||
? { displayName: buildOptions.displayName }
|
||||
: {}),
|
||||
|
|
@ -6345,11 +6407,17 @@ async function runQwenServeImpl(
|
|||
const initiallyMountedManagement = initiallyMountedApp?.locals?.[
|
||||
'workspaceManagementHandle'
|
||||
] as { sealAndWait?: () => Promise<void> } | undefined;
|
||||
const initiallyMountedSessionMaintenance = initiallyMountedApp
|
||||
?.locals?.['sessionArchiveCoordinator'] as
|
||||
| { sealMaintenanceAndWait?: () => Promise<void> }
|
||||
| undefined;
|
||||
// Calling an async function runs through its first await
|
||||
// synchronously. Seal an already-mounted runtime before close()
|
||||
// yields so no management request can enter the shutdown window.
|
||||
const initialManagementWait =
|
||||
initiallyMountedManagement?.sealAndWait?.();
|
||||
const initialSessionMaintenanceWait =
|
||||
initiallyMountedSessionMaintenance?.sealMaintenanceAndWait?.();
|
||||
let processRegistryShutdown: Promise<Error | undefined> | undefined;
|
||||
const startProcessRegistryShutdown = () => {
|
||||
processRegistryShutdown ??= managedProcessRegistry
|
||||
|
|
@ -6492,10 +6560,19 @@ async function runQwenServeImpl(
|
|||
const workspaceManagementHandle = appForCleanup?.locals?.[
|
||||
'workspaceManagementHandle'
|
||||
] as { sealAndWait?: () => Promise<void> } | undefined;
|
||||
const sessionMaintenance = appForCleanup?.locals?.[
|
||||
'sessionArchiveCoordinator'
|
||||
] as
|
||||
| { sealMaintenanceAndWait?: () => Promise<void> }
|
||||
| undefined;
|
||||
await initialManagementWait;
|
||||
if (workspaceManagementHandle !== initiallyMountedManagement) {
|
||||
await workspaceManagementHandle?.sealAndWait?.();
|
||||
}
|
||||
await initialSessionMaintenanceWait;
|
||||
if (sessionMaintenance !== initiallyMountedSessionMaintenance) {
|
||||
await sessionMaintenance?.sealMaintenanceAndWait?.();
|
||||
}
|
||||
stopTrustPolicyMonitor(appForCleanup);
|
||||
const waitForTrustPolicyIdle = appForCleanup?.locals?.[
|
||||
'waitForTrustPolicyIdle'
|
||||
|
|
|
|||
|
|
@ -777,6 +777,46 @@ describe('scheduled-task keepalive', () => {
|
|||
releaseSpawn?.();
|
||||
});
|
||||
|
||||
it('waits for close before deleting a late spawned transcript', async () => {
|
||||
await updateCronTasks(workspace, () => [
|
||||
task({ id: 'hung', prompt: 'will resolve late' }),
|
||||
]);
|
||||
let resolveSpawn!: (value: { sessionId: string }) => void;
|
||||
let finishClose!: () => void;
|
||||
const closeGate = new Promise<void>((resolve) => {
|
||||
finishClose = resolve;
|
||||
});
|
||||
const closeSession = vi.fn(() => closeGate);
|
||||
const removeSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockResolvedValue(true);
|
||||
const ka = startScheduledTaskKeepalive({
|
||||
bridge: {
|
||||
...bridge,
|
||||
spawnOrAttach: () =>
|
||||
new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveSpawn = resolve;
|
||||
}),
|
||||
closeSession,
|
||||
},
|
||||
boundWorkspace: workspace,
|
||||
intervalMs: 50,
|
||||
spawnTimeoutMs: 5,
|
||||
});
|
||||
|
||||
await ka.tick();
|
||||
resolveSpawn({ sessionId: 'late-sess' });
|
||||
await vi.waitFor(() =>
|
||||
expect(closeSession).toHaveBeenCalledWith('late-sess'),
|
||||
);
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
|
||||
finishClose();
|
||||
await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledWith('late-sess'));
|
||||
ka.stop();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rehydration onTasksRead populates the authorization store for delivery-enabled tasks', async () => {
|
||||
const authorizations = new ChannelDeliveryAuthorizationStore();
|
||||
await updateCronTasks(workspace, () => [
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
getCronFilePath,
|
||||
createDebugLogger,
|
||||
SessionService,
|
||||
Storage,
|
||||
taskHasLegacyCondition,
|
||||
type DurableCronTask,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -126,6 +127,7 @@ async function bindAndNameSessions(
|
|||
renamed: Set<string>,
|
||||
spawnTimeoutMs: number,
|
||||
binding: Set<string>,
|
||||
cleanupSession: (sessionId: string) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const unbound = tasks.filter(
|
||||
(t) =>
|
||||
|
|
@ -158,17 +160,14 @@ async function bindAndNameSessions(
|
|||
// binding guard on TRUE settlement so retries are possible.
|
||||
let timedOut = false;
|
||||
rawSpawn
|
||||
.then(({ sessionId }) => {
|
||||
.then(async ({ sessionId }) => {
|
||||
if (timedOut) {
|
||||
log.debug(
|
||||
'keepalive: late spawn resolved, cleaning up',
|
||||
task.id,
|
||||
sessionId,
|
||||
);
|
||||
bridge.closeSession(sessionId).catch(() => {});
|
||||
new SessionService(boundWorkspace)
|
||||
.removeSession(sessionId)
|
||||
.catch(() => {});
|
||||
await cleanupSession(sessionId).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
|
@ -226,10 +225,7 @@ async function bindAndNameSessions(
|
|||
} catch (err) {
|
||||
log.debug('keepalive: failed to bind task', task.id, err);
|
||||
if (spawnedSessionId !== undefined) {
|
||||
await bridge.closeSession(spawnedSessionId).catch(() => {});
|
||||
await new SessionService(boundWorkspace)
|
||||
.removeSession(spawnedSessionId)
|
||||
.catch(() => {});
|
||||
await cleanupSession(spawnedSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,6 +253,8 @@ export interface ScheduledTaskKeepalive {
|
|||
export interface StartScheduledTaskKeepaliveOptions {
|
||||
bridge: KeepaliveBridge;
|
||||
boundWorkspace: string;
|
||||
runtimeBaseDir?: string;
|
||||
cleanupSession?: (sessionId: string) => Promise<unknown>;
|
||||
/** How often to heartbeat; must be comfortably under the reaper timeout. */
|
||||
intervalMs: number;
|
||||
/** Per-session revive timeout; defaults to KEEPALIVE_REVIVE_TIMEOUT_MS. */
|
||||
|
|
@ -272,6 +270,14 @@ export function startScheduledTaskKeepalive(
|
|||
const { bridge, boundWorkspace, intervalMs } = opts;
|
||||
const reviveTimeoutMs = opts.reviveTimeoutMs ?? KEEPALIVE_REVIVE_TIMEOUT_MS;
|
||||
const spawnTimeoutMs = opts.spawnTimeoutMs ?? KEEPALIVE_SPAWN_TIMEOUT_MS;
|
||||
const cleanupSession =
|
||||
opts.cleanupSession ??
|
||||
(async (sessionId: string) => {
|
||||
await bridge.closeSession(sessionId);
|
||||
await new SessionService(boundWorkspace, {
|
||||
runtimeBaseDir: opts.runtimeBaseDir,
|
||||
}).removeSession(sessionId);
|
||||
});
|
||||
|
||||
// Per-session revive state: `nextAttemptAt` gates retries after failures so a
|
||||
// permanently-gone session isn't reloaded every interval; cleared on success.
|
||||
|
|
@ -294,7 +300,7 @@ export function startScheduledTaskKeepalive(
|
|||
// so updateSessionMetadata isn't called every tick.
|
||||
const renamed = new Set<string>();
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
const tickInRuntime = async (): Promise<void> => {
|
||||
let tasks;
|
||||
try {
|
||||
tasks = await readCronTasks(boundWorkspace);
|
||||
|
|
@ -388,8 +394,16 @@ export function startScheduledTaskKeepalive(
|
|||
renamed,
|
||||
spawnTimeoutMs,
|
||||
binding,
|
||||
cleanupSession,
|
||||
);
|
||||
};
|
||||
const tick = (): Promise<void> =>
|
||||
opts.runtimeBaseDir === undefined
|
||||
? tickInRuntime()
|
||||
: Storage.runWithResolvedRuntimeBaseDir(
|
||||
opts.runtimeBaseDir,
|
||||
tickInRuntime,
|
||||
);
|
||||
|
||||
// In-flight guard: a pass can outlast the interval (each revive awaits up to
|
||||
// the revive timeout), so skip a tick while the previous is still running —
|
||||
|
|
@ -410,7 +424,12 @@ export function startScheduledTaskKeepalive(
|
|||
// dedicated session immediately, not after the next interval. Same
|
||||
// directory-watch + debounce pattern the scheduler uses.
|
||||
let bindDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
const cronFilePath = getCronFilePath(boundWorkspace);
|
||||
const cronFilePath =
|
||||
opts.runtimeBaseDir === undefined
|
||||
? getCronFilePath(boundWorkspace)
|
||||
: Storage.runWithResolvedRuntimeBaseDir(opts.runtimeBaseDir, () =>
|
||||
getCronFilePath(boundWorkspace),
|
||||
);
|
||||
const cronDir = path.dirname(cronFilePath);
|
||||
const cronFileName = path.basename(cronFilePath);
|
||||
let fileWatcher: ReturnType<typeof fsSync.watch> | undefined;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import path from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
SessionService,
|
||||
SessionWriterConflictError,
|
||||
SessionWriterLostError,
|
||||
type SessionWriterLease,
|
||||
Storage,
|
||||
getCronFilePath,
|
||||
readCronTasks,
|
||||
updateCronTasks,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -25,9 +29,11 @@ import {
|
|||
archiveDaemonSessions,
|
||||
assertSessionArchived,
|
||||
assertSessionLoadable,
|
||||
deleteDaemonSessionIfOrphan,
|
||||
deleteDaemonSessions,
|
||||
SessionArchiveCoordinator,
|
||||
unarchiveDaemonSessions,
|
||||
DaemonDrainingError,
|
||||
} from './session-archive.js';
|
||||
|
||||
describe('assertSessionLoadable', () => {
|
||||
|
|
@ -188,6 +194,47 @@ describe('SessionArchiveCoordinator', () => {
|
|||
coordinator.runExclusiveMany([sessionId], async () => 'ok'),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('seals new maintenance and waits only for admitted exclusive work', async () => {
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let finish!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const maintenance = coordinator.runExclusiveMany(['session-a'], () => gate);
|
||||
const drain = coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
coordinator.runExclusiveMany(['session-b'], async () => undefined),
|
||||
).rejects.toMatchObject({ code: 'daemon_draining' });
|
||||
let drained = false;
|
||||
void drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(drained).toBe(false);
|
||||
|
||||
finish();
|
||||
await maintenance;
|
||||
await drain;
|
||||
expect(drained).toBe(true);
|
||||
});
|
||||
|
||||
it('does not wait for shared transcript reads when sealed', async () => {
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let finish!: () => void;
|
||||
const shared = coordinator.runSharedMany(
|
||||
['session-a'],
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(coordinator.sealMaintenanceAndWait()).resolves.toBeUndefined();
|
||||
finish();
|
||||
await shared;
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveDaemonSessions', () => {
|
||||
|
|
@ -273,30 +320,354 @@ describe('archiveDaemonSessions', () => {
|
|||
expect(byId['other']!.enabled).toBeUndefined(); // unrelated — untouched
|
||||
});
|
||||
|
||||
it('does not lock ids that are already archived or missing', async () => {
|
||||
it('does not acquire writer leases for ids already archived or missing', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const missingId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const closeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
await coordinator.runSharedMany([archivedId, missingId], async () => {
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, missingId],
|
||||
service,
|
||||
bridge: { closeSession },
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [archivedId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, missingId],
|
||||
service,
|
||||
bridge: { closeSession },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(closeSession).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [archivedId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(closeSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not archive while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const blocked = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(blocked.archived).toEqual([]);
|
||||
expect(blocked.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
const retried = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(retried.archived).toEqual([sessionId]);
|
||||
});
|
||||
|
||||
it('keeps independent batch sessions moving when one writer conflicts', async () => {
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440008';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440009';
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
writeSessionFile(workspaceDir, availableId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(blockedId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [blockedId, availableId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([availableId]);
|
||||
expect(result.errors[0]?.sessionId).toBe(blockedId);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was archived', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440023';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440024';
|
||||
writeSessionFile(workspaceDir, archivedId, 'active');
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, blockedId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: {
|
||||
closeSession: vi.fn(async (sessionId) => {
|
||||
if (sessionId === archivedId) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
coordinator,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(result.archived).toEqual([archivedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.any(SessionArchivingError),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps independent batch sessions moving when one classification fails', async () => {
|
||||
const failedId = '550e8400-e29b-41d4-a716-446655440019';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440020';
|
||||
writeSessionFile(workspaceDir, availableId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const failure = new Error('classification failed');
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
|
||||
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
|
||||
);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [failedId, availableId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([availableId]);
|
||||
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
|
||||
});
|
||||
|
||||
it('does not acquire a lease or mutate when closing the owner fails', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440017';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
const closeError = new Error('agent flush failed');
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockRejectedValue(closeError) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors).toEqual([{ sessionId, error: closeError }]);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the classification made after acquiring the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440010';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const originalGetLocation = service.getSessionLocation.bind(service);
|
||||
let classifications = 0;
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation(async (id) => {
|
||||
classifications++;
|
||||
if (classifications === 2) {
|
||||
fs.mkdirSync(path.dirname(sessionPath(workspaceDir, id, 'archived')), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.renameSync(
|
||||
sessionPath(workspaceDir, id, 'active'),
|
||||
sessionPath(workspaceDir, id, 'archived'),
|
||||
);
|
||||
}
|
||||
return originalGetLocation(id);
|
||||
});
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [sessionId],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
const reacquired = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('does not lock an active/archive conflict', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440016';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report success after release fails but reconciles the task to the applied archive', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440006';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
await updateCronTasks(workspaceDir, () => [
|
||||
{
|
||||
id: 'bound',
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'p',
|
||||
recurring: true,
|
||||
createdAt: 1_700_000_000_000,
|
||||
lastFiredAt: null,
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
const service = new SessionService(workspaceDir);
|
||||
const release = vi.fn(async () => {
|
||||
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
|
||||
throw new SessionWriterLostError();
|
||||
});
|
||||
vi.spyOn(service, 'acquireSessionWriterLease').mockResolvedValue({
|
||||
assertOwnedAndUnchanged: vi.fn().mockResolvedValue(undefined),
|
||||
release,
|
||||
} as unknown as SessionWriterLease);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterLostError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('releases the lease when scheduled-task reconciliation fails', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440018';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
fs.mkdirSync(getCronFilePath(workspaceDir), { recursive: true });
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([sessionId]);
|
||||
const reacquired = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('checks only the selected runtime root for transcripts and locks', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440007';
|
||||
const primaryRuntime = path.join(runtimeDir, 'primary');
|
||||
const secondaryRuntime = path.join(runtimeDir, 'secondary');
|
||||
writeSessionFile(
|
||||
workspaceDir,
|
||||
sessionId,
|
||||
'active',
|
||||
workspaceDir,
|
||||
secondaryRuntime,
|
||||
);
|
||||
const primaryService = new SessionService(workspaceDir, {
|
||||
runtimeBaseDir: primaryRuntime,
|
||||
});
|
||||
const primaryLease = await primaryService.acquireSessionWriterLease(
|
||||
sessionId,
|
||||
{
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
},
|
||||
);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir, {
|
||||
runtimeBaseDir: secondaryRuntime,
|
||||
}),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([sessionId]);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
sessionPath(workspaceDir, sessionId, 'archived', secondaryRuntime),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
sessionPath(workspaceDir, sessionId, 'active', primaryRuntime),
|
||||
),
|
||||
).toBe(false);
|
||||
await primaryLease.release();
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440080';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -324,22 +695,19 @@ describe('unarchiveDaemonSessions', () => {
|
|||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
writeSessionFile(workspaceDir, activeId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
|
||||
await coordinator.runSharedMany([activeId, missingId], async () => {
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [archivedId, activeId, missingId, archivedId],
|
||||
service,
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
unarchived: [archivedId],
|
||||
alreadyActive: [activeId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [archivedId, activeId, missingId, archivedId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
unarchived: [archivedId],
|
||||
alreadyActive: [activeId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
expect(acquire).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
|
@ -348,6 +716,29 @@ describe('unarchiveDaemonSessions', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not unarchive while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440015';
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result.unarchived).toEqual([]);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a single error per archived id when unarchive batch fails', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440014';
|
||||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
|
|
@ -367,6 +758,75 @@ describe('unarchiveDaemonSessions', () => {
|
|||
notFound: [],
|
||||
errors: [{ sessionId: archivedId, error: failure }],
|
||||
});
|
||||
const reacquired = await service.acquireSessionWriterLease(archivedId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('keeps independent unarchive sessions moving when one classification fails', async () => {
|
||||
const failedId = '550e8400-e29b-41d4-a716-446655440021';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440022';
|
||||
writeSessionFile(workspaceDir, availableId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const failure = new Error('classification failed');
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
|
||||
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
|
||||
);
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [failedId, availableId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.unarchived).toEqual([availableId]);
|
||||
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was unarchived', async () => {
|
||||
const unarchivedId = '550e8400-e29b-41d4-a716-446655440025';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440026';
|
||||
writeSessionFile(workspaceDir, unarchivedId, 'archived');
|
||||
writeSessionFile(workspaceDir, blockedId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => {
|
||||
if (sessionId === unarchivedId && !competingMaintenance) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
return getLocation(sessionId);
|
||||
});
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [unarchivedId, blockedId],
|
||||
service,
|
||||
coordinator,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(result.unarchived).toEqual([unarchivedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.any(SessionArchivingError),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('re-enables an archive-disabled task bound to the unarchived session', async () => {
|
||||
|
|
@ -434,6 +894,24 @@ describe('unarchiveDaemonSessions', () => {
|
|||
expect(stranded!.enabled).toBe(true); // recovered
|
||||
expect(stranded!.disabledByArchive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440081';
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
unarchiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDaemonSessions', () => {
|
||||
|
|
@ -487,6 +965,186 @@ describe('deleteDaemonSessions', () => {
|
|||
const ids = (await readCronTasks(workspaceDir)).map((t) => t.id).sort();
|
||||
expect(ids).toEqual(['other']); // bound task deleted, unbound survives
|
||||
});
|
||||
|
||||
it('does not delete while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440071';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId,
|
||||
error: 'This session is already open in another Qwen process.',
|
||||
},
|
||||
]);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was deleted', async () => {
|
||||
const removedId = '550e8400-e29b-41d4-a716-446655440073';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440074';
|
||||
writeSessionFile(workspaceDir, removedId, 'active');
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
|
||||
try {
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: [removedId, blockedId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: {
|
||||
closeSession: vi.fn(async (sessionId) => {
|
||||
if (sessionId === removedId) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result.removed).toEqual([removedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, removedId, 'active')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, blockedId, 'active')),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips orphan deletion when a new owner attached', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440072';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(false) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440082';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the transcript when killSession resolves true', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440083';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(true) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the transcript when killSession throws SessionNotFoundError', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440084';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: {
|
||||
killSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new SessionNotFoundError(sessionId)),
|
||||
},
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the lease is held by another writer', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440085';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(true) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).rejects.toThrow(SessionWriterConflictError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
});
|
||||
|
||||
function writeSessionFile(
|
||||
|
|
@ -494,9 +1152,10 @@ function writeSessionFile(
|
|||
sessionId: string,
|
||||
state: 'active' | 'archived',
|
||||
recordCwd = workspaceDir,
|
||||
runtimeBaseDir?: string,
|
||||
): void {
|
||||
const chatsDir = path.join(
|
||||
new Storage(workspaceDir).getProjectDir(),
|
||||
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
const targetDir =
|
||||
|
|
@ -521,9 +1180,10 @@ function sessionPath(
|
|||
workspaceDir: string,
|
||||
sessionId: string,
|
||||
state: 'active' | 'archived',
|
||||
runtimeBaseDir?: string,
|
||||
): string {
|
||||
const chatsDir = path.join(
|
||||
new Storage(workspaceDir).getProjectDir(),
|
||||
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
return path.join(
|
||||
|
|
|
|||
|
|
@ -46,9 +46,23 @@ export interface DaemonDeleteSessionsResult {
|
|||
|
||||
export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete';
|
||||
|
||||
export class DaemonDrainingError extends Error {
|
||||
override readonly name = 'DaemonDrainingError';
|
||||
readonly code = 'daemon_draining';
|
||||
|
||||
constructor() {
|
||||
super('The daemon is draining and no longer accepts session maintenance.');
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionArchiveCoordinator {
|
||||
private readonly exclusive = new Set<string>();
|
||||
private readonly shared = new Map<string, number>();
|
||||
private maintenanceSealed = false;
|
||||
private activeMaintenance = 0;
|
||||
private maintenanceDrain:
|
||||
| { promise: Promise<void>; resolve: () => void }
|
||||
| undefined;
|
||||
|
||||
assertNotTransitioning(sessionId: string): void {
|
||||
if (this.exclusive.has(sessionId)) {
|
||||
|
|
@ -60,6 +74,9 @@ export class SessionArchiveCoordinator {
|
|||
sessionIds: string[],
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (this.maintenanceSealed) {
|
||||
throw new DaemonDrainingError();
|
||||
}
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
this.assertNotTransitioning(sessionId);
|
||||
|
|
@ -70,15 +87,36 @@ export class SessionArchiveCoordinator {
|
|||
for (const sessionId of uniqueSessionIds) {
|
||||
this.exclusive.add(sessionId);
|
||||
}
|
||||
this.activeMaintenance++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
this.exclusive.delete(sessionId);
|
||||
}
|
||||
this.activeMaintenance--;
|
||||
if (this.activeMaintenance === 0) {
|
||||
this.maintenanceDrain?.resolve();
|
||||
this.maintenanceDrain = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealMaintenanceAndWait(): Promise<void> {
|
||||
this.maintenanceSealed = true;
|
||||
if (this.activeMaintenance === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!this.maintenanceDrain) {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
this.maintenanceDrain = { promise, resolve };
|
||||
}
|
||||
return this.maintenanceDrain.promise;
|
||||
}
|
||||
|
||||
async runSharedMany<T>(
|
||||
sessionIds: string[],
|
||||
fn: () => Promise<T>,
|
||||
|
|
@ -105,6 +143,225 @@ export class SessionArchiveCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
type DaemonMaintenanceAction = 'delete' | 'archive' | 'unarchive';
|
||||
|
||||
interface LeaseMutationResult<T> {
|
||||
value?: T;
|
||||
mutationApplied: boolean;
|
||||
error?: unknown;
|
||||
maintenanceError?: unknown;
|
||||
}
|
||||
|
||||
async function runWithDaemonWriterLease<T>(params: {
|
||||
action: DaemonMaintenanceAction;
|
||||
sessionId: string;
|
||||
service: SessionService;
|
||||
mutate: (
|
||||
assertOwnedAndUnchanged: () => Promise<void>,
|
||||
) => Promise<{ value: T; mutationApplied: boolean }>;
|
||||
mutationAppliedAfterError: () => Promise<boolean>;
|
||||
afterMutationApplied: () => Promise<void>;
|
||||
}): Promise<LeaseMutationResult<T>> {
|
||||
const {
|
||||
action,
|
||||
sessionId,
|
||||
service,
|
||||
mutate,
|
||||
mutationAppliedAfterError,
|
||||
afterMutationApplied,
|
||||
} = params;
|
||||
let lease;
|
||||
try {
|
||||
lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
} catch (error) {
|
||||
return { mutationApplied: false, error };
|
||||
}
|
||||
|
||||
let value: T | undefined;
|
||||
let mutationApplied = false;
|
||||
let mutationError: unknown;
|
||||
try {
|
||||
const mutation = await mutate(() => lease.assertOwnedAndUnchanged());
|
||||
value = mutation.value;
|
||||
mutationApplied = mutation.mutationApplied;
|
||||
} catch (error) {
|
||||
mutationError = error;
|
||||
try {
|
||||
mutationApplied = await mutationAppliedAfterError();
|
||||
} catch {
|
||||
mutationApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
let maintenanceError: unknown;
|
||||
if (mutationApplied) {
|
||||
try {
|
||||
await afterMutationApplied();
|
||||
} catch (error) {
|
||||
maintenanceError = error;
|
||||
logSessionArchiveWarning(
|
||||
`scheduled task lifecycle update failed action=${action} workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(error),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let releaseError: unknown;
|
||||
try {
|
||||
await lease.release();
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
|
||||
if (releaseError !== undefined) {
|
||||
logMaintenanceLeaseReleaseFailure({
|
||||
action,
|
||||
workspace: service.getProjectRoot(),
|
||||
sessionId,
|
||||
error: releaseError,
|
||||
mutationApplied,
|
||||
});
|
||||
if (mutationError !== undefined) {
|
||||
logSessionArchiveWarning(
|
||||
`session maintenance mutation also failed action=${action} workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(mutationError),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return { mutationApplied, error: releaseError, maintenanceError };
|
||||
}
|
||||
if (mutationError !== undefined) {
|
||||
return { mutationApplied, error: mutationError, maintenanceError };
|
||||
}
|
||||
return { value, mutationApplied, maintenanceError };
|
||||
}
|
||||
|
||||
function logMaintenanceLeaseReleaseFailure(params: {
|
||||
action: DaemonMaintenanceAction;
|
||||
workspace: string;
|
||||
sessionId: string;
|
||||
error: unknown;
|
||||
mutationApplied: boolean;
|
||||
}): void {
|
||||
const errorKind =
|
||||
typeof params.error === 'object' &&
|
||||
params.error !== null &&
|
||||
typeof (params.error as { errorKind?: unknown }).errorKind === 'string'
|
||||
? (params.error as { errorKind: string }).errorKind
|
||||
: 'unknown';
|
||||
logSessionArchiveWarning(
|
||||
`session maintenance lease release failed action=${params.action} workspace=${safeLogValue(
|
||||
params.workspace,
|
||||
)} session=${safeLogValue(params.sessionId)} errorKind=${safeLogValue(
|
||||
errorKind,
|
||||
)} mutationApplied=${params.mutationApplied}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function classifySessionLocation(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
): Promise<SessionLocation> {
|
||||
return service.getSessionLocation(sessionId);
|
||||
}
|
||||
|
||||
function sessionLocationError(sessionId: string): Error {
|
||||
return new Error(`Session archive conflict: ${sessionId}`);
|
||||
}
|
||||
|
||||
function updateScheduledTaskForMaintenance(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
action: DaemonMaintenanceAction,
|
||||
): Promise<void> {
|
||||
if (action === 'archive') {
|
||||
return disableTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
if (action === 'unarchive') {
|
||||
return enableTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
return removeTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
|
||||
type DeleteOneResult =
|
||||
| {
|
||||
kind: 'removed';
|
||||
mutationApplied: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'notFound';
|
||||
mutationApplied: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error: unknown;
|
||||
mutationApplied: boolean;
|
||||
};
|
||||
|
||||
async function deletePersistedSessionWithLease(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
): Promise<DeleteOneResult> {
|
||||
const initialLocation = await classifySessionLocation(service, sessionId);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound', mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error',
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'delete',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(service, sessionId);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const removed = await service.removeSession(sessionId);
|
||||
return {
|
||||
value: removed ? ('removed' as const) : ('notFound' as const),
|
||||
mutationApplied: removed,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) === undefined,
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(service, sessionId, 'delete'),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error',
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDaemonSessions(params: {
|
||||
sessionIds: string[];
|
||||
service: SessionService;
|
||||
|
|
@ -118,98 +375,131 @@ export async function deleteDaemonSessions(params: {
|
|||
}): Promise<DaemonDeleteSessionsResult> {
|
||||
const { sessionIds, service, bridge, coordinator, onError } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
const closeErrors: Array<{ sessionId: string; error: string }> = [];
|
||||
const removed: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const removeErrors: Array<{ sessionId: string; error: string }> = [];
|
||||
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
// Keep close+remove under one gate so load/resume cannot recreate the
|
||||
// same live session between bridge close and transcript deletion.
|
||||
await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
let shouldRemove = false;
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
try {
|
||||
// Intentional: batch delete bypasses per-tab ownership.
|
||||
await bridge.closeSession(sessionId);
|
||||
shouldRemove = true;
|
||||
} catch (closeErr) {
|
||||
if (
|
||||
closeErr instanceof SessionNotFoundError ||
|
||||
(closeErr instanceof Error &&
|
||||
closeErr.name === 'SessionNotFoundError')
|
||||
) {
|
||||
shouldRemove = true;
|
||||
} else {
|
||||
const message =
|
||||
closeErr instanceof Error ? closeErr.message : String(closeErr);
|
||||
onError?.({ phase: 'close', sessionId, error: message });
|
||||
closeErrors.push({ sessionId, error: message });
|
||||
} catch (error) {
|
||||
if (isSessionNotFoundError(error)) {
|
||||
const result = await deletePersistedSessionWithLease(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (result.kind === 'error') {
|
||||
onError?.({
|
||||
phase: 'remove',
|
||||
sessionId,
|
||||
error: errorMessage(result.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
onError?.({
|
||||
phase: 'close',
|
||||
sessionId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldRemove) return;
|
||||
|
||||
try {
|
||||
if (await service.removeSession(sessionId)) {
|
||||
removed.push(sessionId);
|
||||
} else {
|
||||
notFound.push(sessionId);
|
||||
}
|
||||
} catch (removeErr) {
|
||||
const message =
|
||||
removeErr instanceof Error
|
||||
? removeErr.message
|
||||
: String(removeErr);
|
||||
onError?.({ phase: 'remove', sessionId, error: message });
|
||||
removeErrors.push({ sessionId, error: message });
|
||||
const result = await deletePersistedSessionWithLease(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (result.kind === 'error') {
|
||||
onError?.({
|
||||
phase: 'remove',
|
||||
sessionId,
|
||||
error: errorMessage(result.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof SessionArchivingError &&
|
||||
err.lockKind === 'exclusive'
|
||||
) {
|
||||
throw err;
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
onError?.({ phase: 'delete', sessionId, error: message });
|
||||
closeErrors.push({ sessionId, error: message });
|
||||
onError?.({
|
||||
phase: 'delete',
|
||||
sessionId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Deleting a session permanently removes any scheduled task bound to it —
|
||||
// the task existed only to run in that session. Best-effort: a failure here
|
||||
// must not turn a successful session delete into an error, but LOG it (like
|
||||
// the archive/unarchive paths) — the session is already gone, so a swallowed
|
||||
// write failure leaves the still-enabled bound task a permanent ghost the
|
||||
// keepalive retries a doomed revive on every tick.
|
||||
await removeTasksForSessions(service.getProjectRoot(), removed).catch(
|
||||
(err: unknown) => {
|
||||
logSessionArchiveWarning(
|
||||
`removeTasksForSessions failed for [${removed.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
const removed: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'removed') {
|
||||
removed.push(sessionId);
|
||||
} else if (result.kind === 'notFound') {
|
||||
notFound.push(sessionId);
|
||||
} else {
|
||||
errors.push({ sessionId, error: errorMessage(result.error) });
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, notFound, errors: [...closeErrors, ...removeErrors] };
|
||||
return { removed, notFound, errors };
|
||||
}
|
||||
|
||||
export async function deleteDaemonSessionIfOrphan(params: {
|
||||
sessionId: string;
|
||||
service: SessionService;
|
||||
bridge: Pick<AcpSessionBridge, 'killSession'>;
|
||||
coordinator: SessionArchiveCoordinator;
|
||||
}): Promise<boolean> {
|
||||
const { sessionId, service, bridge, coordinator } = params;
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
const result = await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
let killed = false;
|
||||
try {
|
||||
killed = await bridge.killSession(sessionId, {
|
||||
requireZeroAttaches: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) throw error;
|
||||
killed = true;
|
||||
}
|
||||
if (!killed) {
|
||||
return undefined;
|
||||
}
|
||||
return deletePersistedSessionWithLease(service, sessionId);
|
||||
});
|
||||
if (result === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (result.kind === 'error') {
|
||||
throw result.error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function assertSessionLoadable(
|
||||
workspaceCwd: string,
|
||||
sessionId: string,
|
||||
runtimeBaseDir?: string,
|
||||
): Promise<SessionLocation> {
|
||||
const location = await new SessionService(workspaceCwd).getSessionLocation(
|
||||
sessionId,
|
||||
);
|
||||
const location = await new SessionService(workspaceCwd, {
|
||||
runtimeBaseDir,
|
||||
}).getSessionLocation(sessionId);
|
||||
if (location === 'archived') {
|
||||
throw new SessionArchivedError(sessionId);
|
||||
}
|
||||
|
|
@ -222,10 +512,11 @@ export async function assertSessionLoadable(
|
|||
export async function assertSessionArchived(
|
||||
workspaceCwd: string,
|
||||
sessionId: string,
|
||||
runtimeBaseDir?: string,
|
||||
): Promise<void> {
|
||||
const location = await new SessionService(workspaceCwd).getSessionLocation(
|
||||
sessionId,
|
||||
);
|
||||
const location = await new SessionService(workspaceCwd, {
|
||||
runtimeBaseDir,
|
||||
}).getSessionLocation(sessionId);
|
||||
if (location === 'active') {
|
||||
throw new SessionNotArchivedError(sessionId);
|
||||
}
|
||||
|
|
@ -244,53 +535,6 @@ function isSessionNotFoundError(err: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
interface SessionLocationBuckets {
|
||||
active: string[];
|
||||
archived: string[];
|
||||
notFound: string[];
|
||||
errors: Array<{ sessionId: string; error: unknown }>;
|
||||
}
|
||||
|
||||
async function classifySessionLocations(
|
||||
service: SessionService,
|
||||
sessionIds: string[],
|
||||
): Promise<SessionLocationBuckets> {
|
||||
const result: SessionLocationBuckets = {
|
||||
active: [],
|
||||
archived: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
};
|
||||
const locationResults = await Promise.allSettled(
|
||||
sessionIds.map(async (sessionId) => ({
|
||||
sessionId,
|
||||
location: await service.getSessionLocation(sessionId),
|
||||
})),
|
||||
);
|
||||
for (let i = 0; i < locationResults.length; i++) {
|
||||
const sessionId = sessionIds[i]!;
|
||||
const locationResult = locationResults[i]!;
|
||||
if (locationResult.status === 'rejected') {
|
||||
result.errors.push({ sessionId, error: locationResult.reason });
|
||||
continue;
|
||||
}
|
||||
const location = locationResult.value.location;
|
||||
if (location === undefined) {
|
||||
result.notFound.push(sessionId);
|
||||
} else if (location === 'archived') {
|
||||
result.archived.push(sessionId);
|
||||
} else if (location === 'conflict') {
|
||||
result.errors.push({
|
||||
sessionId,
|
||||
error: new Error(`Session archive conflict: ${sessionId}`),
|
||||
});
|
||||
} else {
|
||||
result.active.push(sessionId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function logSessionArchiveResult(
|
||||
action: 'archive' | 'unarchive',
|
||||
result: {
|
||||
|
|
@ -355,66 +599,135 @@ export async function archiveDaemonSessions(params: {
|
|||
}): Promise<DaemonArchiveSessionsResult> {
|
||||
const { sessionIds, service, bridge, coordinator } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
const archived: string[] = [];
|
||||
const alreadyArchived: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
|
||||
const initial = await classifySessionLocations(service, uniqueSessionIds);
|
||||
const activeIds = initial.active;
|
||||
alreadyArchived.push(...initial.archived);
|
||||
notFound.push(...initial.notFound);
|
||||
errors.push(...initial.errors);
|
||||
|
||||
if (activeIds.length > 0) {
|
||||
await coordinator.runExclusiveMany(activeIds, async () => {
|
||||
const locked = await classifySessionLocations(service, activeIds);
|
||||
const closableIds = locked.active;
|
||||
alreadyArchived.push(...locked.archived);
|
||||
notFound.push(...locked.notFound);
|
||||
errors.push(...locked.errors);
|
||||
|
||||
// Close+flush before moving JSONL: live writers keep the active path.
|
||||
// If the later move fails, the active JSONL remains and a retry treats
|
||||
// SessionNotFound as the recoverable "already closed" state.
|
||||
const closeResults = await Promise.allSettled(
|
||||
closableIds.map(async (sessionId) => {
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
try {
|
||||
await bridge.closeSession(sessionId, undefined, {
|
||||
requireAgentClose: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isSessionNotFoundError(err)) {
|
||||
throw err;
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
const archiveIds: string[] = [];
|
||||
for (let i = 0; i < closeResults.length; i++) {
|
||||
const sessionId = closableIds[i]!;
|
||||
const result = closeResults[i]!;
|
||||
if (result.status === 'fulfilled') {
|
||||
archiveIds.push(sessionId);
|
||||
} else {
|
||||
errors.push({ sessionId, error: result.reason });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const archiveResult = await service.archiveSessions(archiveIds, {
|
||||
knownLocation: 'active',
|
||||
const initialLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound' as const, mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'archived') {
|
||||
return {
|
||||
kind: 'alreadyArchived' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'archive',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'archived') {
|
||||
return {
|
||||
value: 'alreadyArchived' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const result = await service.archiveSessions([sessionId], {
|
||||
knownLocation: 'active',
|
||||
});
|
||||
if (result.errors[0]) throw result.errors[0].error;
|
||||
if (result.archived.length > 0) {
|
||||
return {
|
||||
value: 'archived' as const,
|
||||
mutationApplied: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value:
|
||||
result.alreadyArchived.length > 0
|
||||
? ('alreadyArchived' as const)
|
||||
: ('notFound' as const),
|
||||
mutationApplied: false,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) ===
|
||||
'archived',
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(service, sessionId, 'archive'),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
});
|
||||
archived.push(...archiveResult.archived);
|
||||
alreadyArchived.push(...archiveResult.alreadyArchived);
|
||||
notFound.push(...archiveResult.notFound);
|
||||
errors.push(...archiveResult.errors);
|
||||
} catch (err) {
|
||||
for (const sessionId of archiveIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
maintenanceError: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const archived: string[] = [];
|
||||
const alreadyArchived: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'archived') archived.push(sessionId);
|
||||
else if (result.kind === 'alreadyArchived') {
|
||||
alreadyArchived.push(sessionId);
|
||||
} else if (result.kind === 'notFound') notFound.push(sessionId);
|
||||
else errors.push({ sessionId, error: result.error });
|
||||
}
|
||||
|
||||
logSessionArchiveResult('archive', {
|
||||
|
|
@ -425,22 +738,6 @@ export async function archiveDaemonSessions(params: {
|
|||
errors,
|
||||
});
|
||||
|
||||
// Archiving a session pauses any scheduled task bound to it (kept on disk,
|
||||
// recoverable on unarchive). Best-effort — never fail the archive over it, but
|
||||
// LOG a write failure: if the task's `enabled` flag isn't flipped, the
|
||||
// keepalive still sees it enabled + bound and will revive the just-archived
|
||||
// session so the task keeps firing. Logging makes that broken coupling
|
||||
// diagnosable rather than silent.
|
||||
await disableTasksForSessions(service.getProjectRoot(), archived).catch(
|
||||
(err: unknown) => {
|
||||
logSessionArchiveWarning(
|
||||
`disableTasksForSessions failed for [${archived.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
} — bound tasks may keep firing until reconciled`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return { archived, alreadyArchived, notFound, errors };
|
||||
}
|
||||
|
||||
|
|
@ -451,43 +748,145 @@ export async function unarchiveDaemonSessions(params: {
|
|||
}): Promise<DaemonUnarchiveSessionsResult> {
|
||||
const { sessionIds, service, coordinator } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
const initialLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound' as const, mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'active') {
|
||||
let maintenanceError: unknown;
|
||||
try {
|
||||
await updateScheduledTaskForMaintenance(
|
||||
service,
|
||||
sessionId,
|
||||
'unarchive',
|
||||
);
|
||||
} catch (error) {
|
||||
maintenanceError = error;
|
||||
logSessionArchiveWarning(
|
||||
`scheduled task lifecycle update failed action=unarchive workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(error),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: 'alreadyActive' as const,
|
||||
mutationApplied: false,
|
||||
maintenanceError,
|
||||
};
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'unarchive',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'active') {
|
||||
return {
|
||||
value: 'alreadyActive' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const result = await service.unarchiveSessions([sessionId], {
|
||||
knownLocation: 'archived',
|
||||
});
|
||||
if (result.errors[0]) throw result.errors[0].error;
|
||||
if (result.unarchived.length > 0) {
|
||||
return {
|
||||
value: 'unarchived' as const,
|
||||
mutationApplied: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value:
|
||||
result.alreadyActive.length > 0
|
||||
? ('alreadyActive' as const)
|
||||
: ('notFound' as const),
|
||||
mutationApplied: false,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) === 'active',
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(
|
||||
service,
|
||||
sessionId,
|
||||
'unarchive',
|
||||
),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
maintenanceError: mutation.maintenanceError,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
maintenanceError: undefined,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const unarchived: string[] = [];
|
||||
const alreadyActive: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
|
||||
const initial = await classifySessionLocations(service, uniqueSessionIds);
|
||||
const archivedIds = initial.archived;
|
||||
alreadyActive.push(...initial.active);
|
||||
notFound.push(...initial.notFound);
|
||||
errors.push(...initial.errors);
|
||||
|
||||
if (archivedIds.length > 0) {
|
||||
await coordinator.runExclusiveMany(archivedIds, async () => {
|
||||
const locked = await classifySessionLocations(service, archivedIds);
|
||||
const unarchiveIds = locked.archived;
|
||||
alreadyActive.push(...locked.active);
|
||||
notFound.push(...locked.notFound);
|
||||
errors.push(...locked.errors);
|
||||
|
||||
if (unarchiveIds.length > 0) {
|
||||
try {
|
||||
const result = await service.unarchiveSessions(unarchiveIds, {
|
||||
knownLocation: 'archived',
|
||||
});
|
||||
unarchived.push(...result.unarchived);
|
||||
alreadyActive.push(...result.alreadyActive);
|
||||
notFound.push(...result.notFound);
|
||||
errors.push(...result.errors);
|
||||
} catch (err) {
|
||||
// The service reports normal per-session failures in `result.errors`.
|
||||
// Reaching this catch means the batch could not produce a result at all.
|
||||
for (const sessionId of unarchiveIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'unarchived') unarchived.push(sessionId);
|
||||
else if (result.kind === 'alreadyActive') alreadyActive.push(sessionId);
|
||||
else if (result.kind === 'notFound') notFound.push(sessionId);
|
||||
else errors.push({ sessionId, error: result.error });
|
||||
if (result.maintenanceError !== undefined) {
|
||||
errors.push({ sessionId, error: result.maintenanceError });
|
||||
}
|
||||
}
|
||||
|
||||
logSessionArchiveResult('unarchive', {
|
||||
|
|
@ -498,29 +897,5 @@ export async function unarchiveDaemonSessions(params: {
|
|||
errors,
|
||||
});
|
||||
|
||||
// Unarchiving a session resumes any scheduled task bound to it (re-enabled,
|
||||
// anchor reset to now). Also run it for sessions that were ALREADY active:
|
||||
// enableTasksForSessions is idempotent (it only re-enables archive-disabled
|
||||
// tasks), so re-unarchiving a session whose task was stranded
|
||||
// (`disabledByArchive: true`) by a PRIOR failed enable recovers it — otherwise
|
||||
// that task is unrecoverable (PATCH-enable 409s on the stale flag, keepalive
|
||||
// skips it). Surface a write failure in `errors` (and log it) instead of
|
||||
// swallowing, so a stranded task isn't left silent.
|
||||
const resumeSessionIds = [...new Set([...unarchived, ...alreadyActive])];
|
||||
try {
|
||||
await enableTasksForSessions(service.getProjectRoot(), resumeSessionIds);
|
||||
} catch (err) {
|
||||
logSessionArchiveWarning(
|
||||
`enableTasksForSessions failed for [${resumeSessionIds.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// Report against the full resume set: a failed already-active recovery must
|
||||
// surface too, or its stranded task stays silently unrecoverable.
|
||||
for (const sessionId of resumeSessionIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
}
|
||||
}
|
||||
|
||||
return { unarchived, alreadyActive, notFound, errors };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -735,10 +735,8 @@ export class VirtualSubagentSessions {
|
|||
};
|
||||
}
|
||||
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const sessionDir = getSubagentSessionDir(projectDir, parentSessionId);
|
||||
|
|
@ -785,10 +783,8 @@ export class VirtualSubagentSessions {
|
|||
): Promise<ResolvedAgentTask | undefined> {
|
||||
// Pre-toolUseId transcripts cannot be linked exactly. This score is only a
|
||||
// best-effort compatibility path and identical parallel launches may tie.
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const parentRecords = await readJsonl<ChatRecord>(
|
||||
|
|
@ -882,10 +878,8 @@ export class VirtualSubagentSessions {
|
|||
parentSessionId: string,
|
||||
toolCallId: string,
|
||||
): Promise<ToolCallMetrics> {
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const records = await readJsonl<ChatRecord>(
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
32
packages/cli/src/serve/workspace-runtime-storage.ts
Normal file
32
packages/cli/src/serve/workspace-runtime-storage.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SessionService,
|
||||
Storage,
|
||||
type SessionServiceOptions,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { WorkspaceRuntime } from './workspace-registry.js';
|
||||
|
||||
export function runWithWorkspaceRuntimeStorage<T>(
|
||||
runtime: WorkspaceRuntime,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkspaceRuntimeSessionService(
|
||||
runtime: WorkspaceRuntime,
|
||||
options: Omit<SessionServiceOptions, 'runtimeBaseDir'> = {},
|
||||
): SessionService {
|
||||
return new SessionService(runtime.workspaceCwd, {
|
||||
...options,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ export class Storage {
|
|||
* When null, falls back to getGlobalQwenDir().
|
||||
*/
|
||||
private static runtimeBaseDir: string | null = null;
|
||||
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<
|
||||
string | null
|
||||
>();
|
||||
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<{
|
||||
dir: string | null;
|
||||
pinned: boolean;
|
||||
}>();
|
||||
|
||||
constructor(
|
||||
targetDir: string,
|
||||
|
|
@ -127,8 +128,24 @@ export class Storage {
|
|||
cwd: string | undefined,
|
||||
fn: () => T,
|
||||
): T {
|
||||
if (Storage.runtimeBaseDirContext.getStore()?.pinned) {
|
||||
return fn();
|
||||
}
|
||||
const resolved = Storage.resolveRuntimeBaseDir(dir, cwd);
|
||||
return Storage.runtimeBaseDirContext.run(resolved, fn);
|
||||
return Storage.runtimeBaseDirContext.run(
|
||||
{ dir: resolved, pinned: false },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
static runWithResolvedRuntimeBaseDir<T>(dir: string, fn: () => T): T {
|
||||
// A managed workspace runtime owns this root for its full lifetime.
|
||||
// Unlike the configurable context above, later process-env reloads must
|
||||
// not redirect storage created inside this context.
|
||||
return Storage.runtimeBaseDirContext.run(
|
||||
{ dir: path.resolve(dir), pinned: true },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
static hasRuntimeBaseDirContext(): boolean {
|
||||
|
|
@ -139,10 +156,14 @@ export class Storage {
|
|||
* Returns the base directory for all runtime output (temp files, debug logs,
|
||||
* session data, todos, insights, etc.).
|
||||
*
|
||||
* Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir()
|
||||
* Priority: pinned runtime context > QWEN_RUNTIME_DIR env var > configurable context > setRuntimeBaseDir() value > getGlobalQwenDir()
|
||||
* @returns Absolute path to the runtime output base directory
|
||||
*/
|
||||
static getRuntimeBaseDir(): string {
|
||||
const contextualDir = Storage.runtimeBaseDirContext.getStore();
|
||||
if (contextualDir?.pinned) {
|
||||
return contextualDir.dir ?? Storage.getGlobalQwenDir();
|
||||
}
|
||||
const envDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
if (envDir) {
|
||||
return (
|
||||
|
|
@ -150,9 +171,8 @@ export class Storage {
|
|||
);
|
||||
}
|
||||
|
||||
const contextualDir = Storage.runtimeBaseDirContext.getStore();
|
||||
if (contextualDir !== undefined) {
|
||||
return contextualDir ?? Storage.getGlobalQwenDir();
|
||||
return contextualDir.dir ?? Storage.getGlobalQwenDir();
|
||||
}
|
||||
if (Storage.runtimeBaseDir) {
|
||||
return Storage.runtimeBaseDir;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { SessionService } from './sessionService.js';
|
||||
import {
|
||||
getSessionWriterLockPath,
|
||||
SessionWriterConflictError,
|
||||
SessionWriterUnavailableError,
|
||||
} from './session-writer-lease.js';
|
||||
|
||||
const temporaryDirectories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...temporaryDirectories].map((directory) =>
|
||||
fs.rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
temporaryDirectories.clear();
|
||||
});
|
||||
|
||||
async function createService(): Promise<{
|
||||
runtimeBaseDir: string;
|
||||
service: SessionService;
|
||||
}> {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-session-service-lease-'),
|
||||
);
|
||||
temporaryDirectories.add(root);
|
||||
const runtimeBaseDir = path.join(root, 'runtime');
|
||||
const workspace = path.join(root, 'workspace');
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
return {
|
||||
runtimeBaseDir,
|
||||
service: new SessionService(workspace, { runtimeBaseDir }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionService.acquireSessionWriterLease', () => {
|
||||
it('uses the service runtime root and rejects a second writer', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const { runtimeBaseDir, service } = await createService();
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.stat(getSessionWriterLockPath(runtimeBaseDir, sessionId)),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
}),
|
||||
).rejects.toThrow(SessionWriterConflictError);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('rejects an invalid id before creating the lock directory', async () => {
|
||||
const { runtimeBaseDir, service } = await createService();
|
||||
|
||||
await expect(
|
||||
service.acquireSessionWriterLease('../invalid', {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
}),
|
||||
).rejects.toThrow(SessionWriterUnavailableError);
|
||||
await expect(
|
||||
fs.stat(path.dirname(getSessionWriterLockPath(runtimeBaseDir, 'valid'))),
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,11 @@ import {
|
|||
} from './session-artifact-persistence.js';
|
||||
import { SessionOrganizationService } from './session-organization-service.js';
|
||||
import { SessionTranscriptTooLargeError } from './session-transcript-reader.js';
|
||||
import {
|
||||
SessionWriterLease,
|
||||
SessionWriterUnavailableError,
|
||||
type SessionWriterProcessKind,
|
||||
} from './session-writer-lease.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SESSION');
|
||||
|
||||
|
|
@ -339,6 +344,25 @@ export class SessionService {
|
|||
return this.projectRoot;
|
||||
}
|
||||
|
||||
async acquireSessionWriterLease(
|
||||
sessionId: string,
|
||||
options: {
|
||||
processKind: SessionWriterProcessKind;
|
||||
qwenVersion?: string | null;
|
||||
reclaimPolicy: 'local' | 'never';
|
||||
},
|
||||
): Promise<SessionWriterLease> {
|
||||
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
|
||||
throw new SessionWriterUnavailableError();
|
||||
}
|
||||
return SessionWriterLease.acquire({
|
||||
runtimeBaseDir: this.storage.getRuntimeBaseDir(),
|
||||
sessionId,
|
||||
transcriptPath: this.getSessionFilePath(sessionId, 'active'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
private warn(message: string): void {
|
||||
debugLogger.warn(message);
|
||||
this.onWarning?.(message);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue