Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e1644ba
feat(serve): allow relocating session attachment storage via env var
Aug 26, 2026
da506f7
fix(serve): keep attachment root resolver off fast path
Aug 26, 2026
97e64bc
fix(serve): harden session attachment fallback against degraded roots
Aug 26, 2026
789b808
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
Aug 26, 2026
3b522e4
fix(serve): address round-2 review findings on session attachment sto…
Aug 26, 2026
3c4072a
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 27, 2026
539f0d7
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 27, 2026
6f42945
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
qwen-code-dev-bot Aug 27, 2026
0f3e4a3
fix(cli): stop restore-probe tests depending on ambient host git stat…
qwen-code-dev-bot Aug 27, 2026
35298df
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 28, 2026
c0eedf8
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 28, 2026
027a2a5
Merge branch 'main' into feat/session-attachments-root-env
qwen-code-dev-bot Aug 28, 2026
6610d34
fix(acp): stabilize attachment fallback handling
Aug 28, 2026
5657260
Merge remote-tracking branch 'origin/main' into feat/session-attachme…
wenshao Aug 29, 2026
6589015
fix(acp): skip attachments being removed during copy
Aug 31, 2026
4106ef3
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 31, 2026
702c665
Merge branch 'main' into feat/session-attachments-root-env
ytahdn Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions docs/developers/daemon/17-configuration.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,21 @@ Scope and limits:
- Binary uploads (`POST /file/upload`) always create at `0600` regardless of this setting.
- The daemon reads the variable at workspace-filesystem construction; restart the daemon after changing it.

### Session attachment storage

Session attachments (files and images uploaded by Web Shell through `POST /session/:id/attachments`) are stored by default under the workspace's runtime temp dir: `<runtimeBaseDir>/tmp/<projectHash>/attachments`, keyed per session as `session-<sessionId>`. Operators who want attachments to persist outside the runtime temp dir (e.g. on a dedicated volume) can override the root:

| Env var | Values | Default | What it does |
| ------------------------------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `QWEN_SERVE_SESSION_ATTACHMENTS_ROOT` | path | unset | Stores session attachments under this directory instead of the default runtime temp dir. Accepts an absolute path, a path relative to the daemon's cwd, or `~` / `~/…` expanded against the home directory. |

Scope and limits:

- **One-way migration.** When the env is set, new attachments are written only under the configured root. Reads and removes that miss the configured root fall back to the default runtime temp dir, so attachments uploaded **before** the switch remain readable, and removable while the default fallback dir stays writable — a remove whose legacy copy cannot be unlinked (e.g. a read-only fallback volume) surfaces the error rather than reporting success. The reverse direction — removing the env after attachments were written to the configured root — makes those attachments unreachable; keep the variable stable for a given workspace.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Reviewer Test Plan step 3 instructs verifying the REVERSE of the one-way migration this bullet documents: "Then remove the env var, restart, and confirm the same attachment is still readable (fallback path)". With the env unset, sessionAttachmentsRoots() returns only { root: defaultRoot } — no fallback is constructed (packages/cli/src/serve/session-attachments-root.ts) — so an attachment written to the configured root is unreachable by design and the step always fails. This bullet itself states the reverse direction makes configured-root attachments unreachable, and the PR's Risk & Scope section says the same. A reviewer following step 3 as written uploads with QWEN_SERVE_SESSION_ATTACHMENTS_ROOT set (bytes land under the configured root), unsets the var, restarts, reads the attachment — unreachable — and files a false fallback-regression against correct, test-pinned code, costing a review round. Fix: rewrite step 3 to exercise the shipped direction — upload an attachment with the env unset, then set QWEN_SERVE_SESSION_ATTACHMENTS_ROOT, restart, and confirm new uploads land under the configured root while the pre-switch attachment stays readable/removable via the fallback; note that unsetting the var afterwards makes configured-root attachments unreachable.

中文说明

Reviewer Test Plan 第 3 步要求验证的恰是本条目所记载的单向迁移的反方向:"取消该环境变量、重启,并确认同一附件仍可读(回退路径)"。环境变量未设置时,sessionAttachmentsRoots() 只返回 { root: defaultRoot }——不会构造任何回退目录(packages/cli/src/serve/session-attachments-root.ts)——因此写入配置根目录的附件按设计就不可达,该步骤必然失败。本条目自身已写明反向操作会使配置根目录中的附件不可达,PR 的 Risk & Scope 部分也有同样说明。按第 3 步原文操作的评审者会在设置 QWEN_SERVE_SESSION_ATTACHMENTS_ROOT 时上传(字节落入配置根目录),然后取消变量、重启、读取附件——不可达——并对正确且有测试钉住的代码提交一份虚假的"回退回归"报告,浪费一轮评审。修复:把第 3 步改写为实际支持的方向——先在未设置环境变量时上传附件,然后设置 QWEN_SERVE_SESSION_ATTACHMENTS_ROOT 并重启,确认新上传落入配置根目录、同时切换前上传的附件仍可经回退目录读取/删除;并注明此后再取消变量会使配置根目录中的附件不可达。

— qwen3.8-max via Qwen Code /review (v0.22.2)

- **Per-session layout.** Files live under `<root>/<projectHash>/attachments/session-<sessionId>/` in both locations, where `<projectHash>` is the same workspace hash used by the default runtime temp dir; the fallback lookup uses the same session layout in the default dir. Two workspaces pointing at the same configured root stay isolated from each other.
- **Delete cleanup.** When a session is deleted, its attachment directory is removed from both the configured root and the default fallback dir. Archiving a session keeps its attachments so they survive unarchive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This bullet promises more than the code delivers: the orphan-reap deletion path deletes sessions without ever deleting their attachments. deleteDaemonSessionIfOrphan (session-archive.ts:622-656) is typed Pick<AcpSessionBridge, 'killSession' | 'markSessionCatalogChanged'> — it structurally cannot call deleteSessionAttachments — and SessionAttachmentStore.close() on a persistent store early-returns without deleting anything. Sessions reaped as orphans therefore leave their session-<id>/ directories in BOTH roots forever, unreferenced by anything that will ever look them up again. The leak predates this PR for the default tmpdir, but this diff newly asserts the guarantee and extends the leak onto the operator-configured persistent volume, where no temp cleanup will ever sweep it.

Verified by execution with a real bridge: upload → orphan reap completes (sessionCount=0) → bytes still present in the configured root; only the explicit deleteSessionAttachments call — the one the reap path never makes — removes them:

after reap: sessionCount=0; main=["leak.txt"], fallback=ERR:ENOENT — session gone, bytes remain
after deleteSessionAttachments: main=ERR:ENOENT — removed

Concrete trigger: an ACP-HTTP session/new with a {removePersisted: true} ownership receipt uploads attachments while the env var is set, then disconnects orphan-eligible — removeOrphanSession(sessionId, true) reaps it, and the bytes stay.

Fix: in deleteDaemonSessionIfOrphan, after the persisted removal succeeds, call bridge.deleteSessionAttachments(sessionId) (widening its Pick accordingly), mirroring deleteDaemonSessions (session-archive.ts:536-540); or narrow this sentence to explicit delete-request cleanup. The code fix is the better side — the bytes are orphaned either way.

中文说明

该条目的承诺超出了代码实际行为:孤儿回收删除路径删除会话时从不删除附件。deleteDaemonSessionIfOrphansession-archive.ts:622-656)的类型是 Pick<AcpSessionBridge, 'killSession' | 'markSessionCatalogChanged'>——结构上无法调用 deleteSessionAttachments——而持久化存储上的 SessionAttachmentStore.close() 会直接提前返回、不删除任何内容。因此被当作孤儿回收的会话会在两个根目录中永久留下 session-<id>/ 目录,再无任何东西会引用它们。该泄漏在默认临时目录中在本 PR 之前就存在,但本 diff 新作出了该保证,并把泄漏扩展到运维配置的持久卷——那里没有任何临时清理会回收它。

实测(真实 bridge):上传 → 孤儿回收完成(sessionCount=0)→ 字节仍在配置根目录中;只有显式的 deleteSessionAttachments 调用(回收路径从不调用)才能删除它们。具体触发:携带 {removePersisted: true} 所有权回执的 ACP-HTTP session/new 在设置环境变量后上传附件、随后以孤儿状态断开——removeOrphanSession(sessionId, true) 回收会话,字节残留。

修复:在 deleteDaemonSessionIfOrphan 的持久化删除成功后调用 bridge.deleteSessionAttachments(sessionId)(相应放宽其 Pick),参照 deleteDaemonSessionssession-archive.ts:536-540);或把该句收窄为"显式删除请求会清理"。修代码是更好的一侧——否则字节两边都会成为孤儿。

— qwen3.8-max via Qwen Code /review (v0.22.0)

- The daemon reads the variable at startup; restart the daemon after changing it. The directory must be writable by the daemon process.

## Multi-session & multi-workspace deployment

Pass `--workspace` more than once to register several non-overlapping workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime boundary, while the daemon-wide listener, authentication policy, and total-session limit are shared. Production attempts to preheat the primary ACP child for compatibility and retries on first use after failure; trusted secondaries start their own child on demand, and untrusted secondaries do not start ACP. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace. Use one daemon per user or security principal; workspace trust is an execution gate, not an ACL.
Expand Down
80 changes: 80 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14802,6 +14802,86 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('reads attachments from the fallback root when the primary misses', async () => {
const mainRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-main-'),
);
const fallbackRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-fallback-'),
);
const bridge = makeBridge({
sessionAttachmentsRoot: mainRoot,
sessionAttachmentsFallbackRoot: fallbackRoot,
channelFactory: async () => makeChannel({}).channel,
});
try {
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sessionDir = `session-${encodeURIComponent(session.sessionId)}`;
await fsp.mkdir(path.join(fallbackRoot, sessionDir), {
recursive: true,
});
await fsp.writeFile(
path.join(fallbackRoot, sessionDir, 'notes.txt'),
'legacy attachment',
);

const read = await bridge.readSessionAttachment(
session.sessionId,
'notes.txt',
{ clientId: session.clientId },
);
expect(read?.data.toString()).toBe('legacy attachment');
expect(read?.mimeType).toBe('text/plain');
} finally {
await bridge.shutdown();
await fsp.rm(mainRoot, { recursive: true, force: true });
await fsp.rm(fallbackRoot, { recursive: true, force: true });
}
});

it('deleteSessionAttachments clears both roots for a non-live session', async () => {
const mainRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-main-'),
);
const fallbackRoot = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-bridge-fallback-'),
);
const bridge = makeBridge({
sessionAttachmentsRoot: mainRoot,
sessionAttachmentsFallbackRoot: fallbackRoot,
channelFactory: async () => makeChannel({}).channel,
});
try {
const sessionId = 'sess:unknown';
const sessionDir = `session-${encodeURIComponent(sessionId)}`;
await fsp.mkdir(path.join(mainRoot, sessionDir), { recursive: true });
await fsp.writeFile(
path.join(mainRoot, sessionDir, 'current.txt'),
'current',
);
await fsp.mkdir(path.join(fallbackRoot, sessionDir), {
recursive: true,
});
await fsp.writeFile(
path.join(fallbackRoot, sessionDir, 'notes.txt'),
'legacy',
);

await bridge.deleteSessionAttachments(sessionId);

await expect(
fsp.readdir(path.join(mainRoot, sessionDir)),
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(
fsp.readdir(path.join(fallbackRoot, sessionDir)),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await bridge.shutdown();
await fsp.rm(mainRoot, { recursive: true, force: true });
await fsp.rm(fallbackRoot, { recursive: true, force: true });
}
});

it('resolves text and binary file attachment references for ACP', async () => {
const prompts: PromptRequest[] = [];
const bridge = makeBridge({
Expand Down
8 changes: 7 additions & 1 deletion packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6415,6 +6415,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
attachments: new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
sessionId,
opts.sessionAttachmentsFallbackRoot,
),
recordingDegraded: false,
closing: false,
Expand Down Expand Up @@ -10315,6 +10316,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const branchAttachments = new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
result.newSessionId,
opts.sessionAttachmentsFallbackRoot,
);
try {
await branchAttachments.copyFrom(entry.attachments);
Expand Down Expand Up @@ -12242,7 +12244,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
async deleteSessionAttachments(sessionId, options) {
const store =
byId.get(sessionId)?.attachments ??
new SessionAttachmentStore(opts.sessionAttachmentsRoot, sessionId);
new SessionAttachmentStore(
opts.sessionAttachmentsRoot,
sessionId,
opts.sessionAttachmentsFallbackRoot,
);
await store.delete(options);
},

Expand Down
8 changes: 8 additions & 0 deletions packages/acp-bridge/src/bridgeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ export interface BridgeOptions {
* root. Direct embedded callers may omit it for process-local storage.
*/
sessionAttachmentsRoot?: string;
/**
* Fallback root for reading session attachments stored before
* `sessionAttachmentsRoot` was reconfigured (e.g. the previous default
* directory). Writes always go to `sessionAttachmentsRoot`; reads and
* removes that miss there consult this root so existing attachments
* survive a root switch.
*/
sessionAttachmentsFallbackRoot?: string;
/**
* `single` shares one session per workspace across HTTP
* clients (live-collaboration default); `thread` gives each `spawnOrAttach`
Expand Down
Loading
Loading