dsh-delete-session 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,8 +27,9 @@ DSH 侧边栏的会话菜单只有**重命名 / 分叉 / 归档**——归档只
27
27
  - **菜单内删除** —— 侧边栏会话 `...` 菜单直接多出「删除会话」;会话 ID 从菜单的 React fiber 读取,永远作用于你点的那一行,**不会切换当前会话**
28
28
  - **风险确认** —— 首次删除弹出确认对话框:显示会话标题与 ID,勾选「我已了解,永久删除」后确认按钮才可用
29
29
  - **确认一次后可跳过** —— 对话框里勾选「不再询问,以后直接删除」,之后点菜单项直接删除(左下角 toast 提示);**按住 Shift 点击**可临时恢复弹窗;清除 `localStorage['dsh-delete-session:skip-confirm']` 可永久恢复
30
- - **多选批量删除** —— 会话菜单里的「多选」进入选择模式:每行左侧出现勾选圈,点整行即勾选(不会打开会话),底部操作条显示「已选 N 个 / 取消 / 删除」;点删除**立即批量执行**(无确认弹窗、无全选、所有行都可选),完成后 toast 汇总并刷新列表;Esc 或「取消」退出
31
- - **真正的删除** —— 停止运行中的 agent(cancel + 等待空闲)→ flush / detach 活跃会话 → 删除磁盘会话目录(原始 ID 与 `session-` 前缀两种写法)→ 持久化服务复核 → 清理工作区记账,侧栏不会残留「未分组」孤儿
30
+ - **多选批量删除** —— 会话菜单里的「多选」进入选择模式:每行左侧出现勾选圈,点整行即勾选(不会打开会话),底部操作条显示「已选 N 个 / 取消 / 删除」;点删除**立即批量执行**(无确认弹窗、无全选、所有行都可选):被选中的行**乐观隐藏**、操作条实时显示「正在删除 x/N…」、客户端以 6 路并发请求宿主(不同会话并行、工作区记账串行),完成后 toast 汇总并刷新列表;失败的行立即恢复显示并保持选中,方便直接重试;Esc 或「取消」退出
31
+ - **真正的删除** —— 停止运行中的 agent(cancel + 等待空闲)→ flush / detach 活跃会话 → 删除磁盘会话目录(原始 ID 与 `session-` 前缀两种写法)→ 持久化服务复核 → 清理工作区记账,侧栏不会残留「未分组」孤儿,并连带清理项目缓存行(`$DSH_HOME/storages/*/sessions/<id>.json`),不留无法再显示的幽灵记录
32
+ - **删除后回到新会话** —— 若删除的正是当前打开的会话,自动切到一个全新会话(不再落到已不可用的邻居页——那种页面 composer 会被禁用、侧栏也列不出来,看起来像「没删掉」)
32
33
  - **默认安全** —— 宿主路由仅限本机回环(回环 socket + 回环 Host 头 + 同源校验);被 DSH 打开占用的会话会被明确拒绝(409),绝不与活跃写者竞争
33
34
  - **中英双语** —— 菜单项与对话框跟随 DSH 界面语言(zh / en)
34
35
  - **零依赖** —— 不引入任何 DSH SDK,所有服务(`sessions` / `sessionPersistence` / `agents` / `workspaceRegistry` / `webServer`)都在调用时通过 cordis 上下文获取
@@ -76,11 +77,11 @@ dsh plugin --profile web add github:DDDMUC/dsh-delete-session
76
77
  - 被 DSH 当前打开占用的会话:删除会自动等待写所有权释放(通常几秒)后完成;极少数仍被占用的情况会提示稍后重试或重启 DSH
77
78
  - 菜单注入是 DOM shim:核心 UI 若调整菜单结构或行 fiber 形状,可能不再注入菜单项(不影响其他功能)
78
79
  - 删除不可恢复:没有回收站,也没有撤销
79
- - 批量删除目前**逐条顺序执行**:空闲会话很快,被 DSH 打开过的会话每条需要等待写所有权释放(最多数秒),一次选很多时整体耗时会变长(后续版本会并发化)
80
+ - 批量删除为客户端 6 路并发 + 乐观隐藏:不同会话并行删除,宿主端写所有权等待缩短为 200ms × 12(单条最长约 2.2s),多个被占用的会话也并行等待;同一工作区记账变更仍串行,避免并发写交错
80
81
 
81
82
  ### 兼容性
82
83
 
83
- - DSH `0.1.5-rc.1` 实测通过
84
+ - DSH `0.1.5-rc.1` / `0.1.7-alpha.2` 实测通过(0.1.7 起不再导出 `SettingsProvider`;本插件不依赖 DSH SDK,无需改动)
84
85
  - 宿主链路对缺少服务的旧版本会退化为纯文件系统清理
85
86
 
86
87
  如果这个插件帮到了你,欢迎给仓库点个 ⭐([GitHub](https://github.com/DDDMUC/dsh-delete-session))——星标是开发者继续维护的最大动力,感谢支持!
@@ -112,8 +113,9 @@ This plugin puts Delete back where you already click: the session row's `...` me
112
113
  - **Delete from the menu** — a "Delete session" row appears in the sidebar session `...` menu; the session id is read from the menu's React fiber, so the action always targets the row you clicked and **never switches the active conversation**
113
114
  - **Risk consent** — the first delete opens a confirmation dialog with the session title and id; the confirm button unlocks only after ticking "I understand"
114
115
  - **Confirm once, then skip** — tick "Don't ask again" and later deletes run directly from the menu (a toast reports the result); **hold Shift while clicking** to force the dialog back, or clear `localStorage['dsh-delete-session:skip-confirm']` to restore it permanently
115
- - **Multi-select batch delete** — "Select multiple" in the session menu enters selection mode: every row gets a round checkbox, clicking a row toggles it (it never opens the conversation), and a bottom bar shows "N selected / Cancel / Delete". Delete runs **immediately** (no confirm dialog, no select-all, every row selectable) and reports a toast summary; Esc or Cancel exits
116
- - **Real deletion** — stops a running agent (cancel + quiescence), flushes and detaches the live session, removes the on-disk session directories (both the raw and `session-` prefixed id), verifies through the persistence service, then cleans workspace accounting so no orphan shows up under "Ungrouped"
116
+ - **Multi-select batch delete** — "Select multiple" in the session menu enters selection mode: every row gets a round checkbox, clicking a row toggles it (it never opens the conversation), and a bottom bar shows "N selected / Cancel / Delete". Delete runs **immediately** (no confirm dialog, no select-all, every row selectable): selected rows are hidden optimistically, the bar shows live progress ("Deleting x/N..."), and the client runs 6 concurrent requests (independent sessions in parallel, workspace accounting serialized), then reports a toast summary; failed rows reappear immediately and stay selected for a quick retry; Esc or Cancel exits
117
+ - **Real deletion** — stops a running agent (cancel + quiescence), flushes and detaches the live session, removes the on-disk session directories (both the raw and `session-` prefixed id), verifies through the persistence service, then cleans workspace accounting so no orphan shows up under "Ungrouped", and removes the session's projection-cache rows (`$DSH_HOME/storages/*/sessions/<id>.json`) so no ghost the sidebar can no longer show is left behind
118
+ - **Leaves a fresh session behind** — when the deleted session is the one the main view is showing, the app switches to a brand-new session instead of a stale neighbour (an unavailable row with a disabled composer that the sidebar cannot even list, which reads as "the delete did not work")
117
119
  - **Safe by default** — the host route is loopback-only (loopback socket, loopback Host header, same-origin check); a session still held open by DSH is refused with 409 instead of racing the active writer
118
120
  - **Bilingual** — the menu item and dialog follow the DSH interface language (zh / en)
119
121
  - **Zero dependencies** — no DSH SDK imports; every service (`sessions`, `sessionPersistence`, `agents`, `workspaceRegistry`, `webServer`) is resolved through the cordis context at call time
@@ -161,11 +163,11 @@ Restart `dsh web` to apply.
161
163
  - A session DSH currently holds open: the delete waits for the write lease to be released (usually a few seconds) and then completes; in the rare case it stays busy, the dialog asks you to retry later or restart DSH
162
164
  - The menu injection is a DOM shim: core UI changes to the menu markup or row fiber shape may stop the item from being injected (nothing else breaks)
163
165
  - Deletion is permanent: there is no trash bin and no undo
164
- - Batch delete currently runs **sequentially**: idle sessions are fast, while a session DSH has opened waits for its write lease (up to a few seconds), so a large selection takes longer (a future version will parallelize it)
166
+ - Batch delete uses 6-way client concurrency with optimistic hiding: independent sessions delete in parallel and the host write-lease wait is shortened to 200ms x 12 (max ~2.2s per item), so busy sessions wait in parallel too; workspace accounting stays serialized so concurrent deletions never interleave on the same store
165
167
 
166
168
  ### Compatibility
167
169
 
168
- - Tested on DSH `0.1.5-rc.1`
170
+ - Tested on DSH `0.1.5-rc.1` and `0.1.7-alpha.2`
169
171
  - On older versions without the services, the host pipeline degrades to filesystem cleanup
170
172
 
171
173
  If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh-delete-session) would mean a lot — it is the biggest motivation for the developer to keep maintaining it. Thank you!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-delete-session",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Delete sessions from the DSH Web sidebar: adds a Delete action to the session row menu with a risk-consent dialog, then removes the session log, workspace accounting and live session state.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
36
36
  multiSelect: '多选',
37
37
  cancelSelect: '取消',
38
38
  selectedCount: (n) => `已选 ${n} 个`,
39
+ batchRunning: (done, total) => `正在删除 ${done}/${total}…`,
39
40
  deleteSelected: '删除',
40
41
  batchDone: (n) => `已删除 ${n} 个会话`,
41
42
  batchPartial: (ok, fail) => `已删除 ${ok} 个,${fail} 个失败`,
@@ -59,6 +60,7 @@ window.__ModuleLoader__.load({
59
60
  multiSelect: 'Select multiple',
60
61
  cancelSelect: 'Cancel',
61
62
  selectedCount: (n) => `${n} selected`,
63
+ batchRunning: (done, total) => `Deleting ${done}/${total}...`,
62
64
  deleteSelected: 'Delete',
63
65
  batchDone: (n) => `Deleted ${n} session(s)`,
64
66
  batchPartial: (ok, fail) => `Deleted ${ok}, ${fail} failed`,
@@ -102,13 +104,59 @@ window.__ModuleLoader__.load({
102
104
  })
103
105
  }
104
106
 
107
+ // --- current-session handoff ------------------------------------------------
108
+
109
+ // Deleting the session the main view is showing leaves the app to restore a
110
+ // stale neighbour (on 0.1.7 it lands on an unavailable Agent Team row with a
111
+ // disabled composer), which reads as "the delete did not work". When the
112
+ // deleted session is the one the app has open, open a fresh session instead.
113
+ function deletedVariants(id) {
114
+ return id.startsWith('session-') ? [id, id.slice('session-'.length)] : [id, `session-${id}`]
115
+ }
116
+
117
+ function leaveIfCurrent(id) {
118
+ let current = null
119
+ try {
120
+ const raw = localStorage.getItem('dsh.sessions.current')
121
+ current = raw ? (JSON.parse(raw) || {}).sessionId || null : null
122
+ } catch {
123
+ return
124
+ }
125
+ if (!current || !deletedVariants(id).includes(current)) return
126
+ const btn = document.querySelector(
127
+ 'button[class*="newSession"], button[aria-label*="New session" i], button[aria-label*="新会话"]',
128
+ )
129
+ if (btn) {
130
+ try {
131
+ btn.click()
132
+ return
133
+ } catch {
134
+ // fall through to the state reset
135
+ }
136
+ }
137
+ try {
138
+ localStorage.removeItem('dsh.sessions.current')
139
+ window.location.reload()
140
+ } catch {
141
+ // ignore
142
+ }
143
+ }
144
+
105
145
  function deleteNow(info) {
146
+ pendingRemoval.add(info.id)
147
+ scheduleDecorate()
106
148
  postDelete(info.id)
107
149
  .then(() => {
108
150
  toast(t('done'))
109
151
  refreshList()
152
+ settlePendingSoon()
153
+ leaveIfCurrent(info.id)
154
+ })
155
+ .catch((reason) => {
156
+ pendingRemoval.delete(info.id)
157
+ scheduleDecorate()
158
+ toast(t('failed') + String((reason && reason.message) || reason), true)
110
159
  })
111
- .catch((reason) => toast(t('failed') + String((reason && reason.message) || reason), true))
112
160
  }
113
161
 
114
162
  function browserLang() {
@@ -175,6 +223,8 @@ window.__ModuleLoader__.load({
175
223
  '.dsdel-check{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l3,rgba(128,128,128,.5));background:transparent;flex:none;margin-right:8px;box-sizing:border-box;cursor:pointer}',
176
224
  '.dsdel-check-on{background:var(--dsw-alias-state-business-primary,#4c6ef5);border-color:var(--dsw-alias-state-business-primary,#4c6ef5)}',
177
225
  '.dsdel-check-on::after{content:"";width:9px;height:5px;border-left:2px solid #fff;border-bottom:2px solid #fff;transform:rotate(-45deg) translateY(-1px)}',
226
+ // 乐观删除:行先隐藏,失败时随 decorate 恢复
227
+ '.dsdel-row-pending{display:none!important}',
178
228
  '.dsdel-bar{position:fixed;left:12px;bottom:12px;z-index:2147483600;display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l3,rgba(128,128,128,.3));box-shadow:0 12px 32px rgba(0,0,0,.28);color:var(--dsw-alias-label-primary,inherit);font-size:13px;background:linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),linear-gradient(var(--dsw-alias-bg-layer-2,#fff),var(--dsw-alias-bg-layer-2,#fff)),var(--dsw-alias-bg-base,#fff)}',
179
229
  '.dsdel-bar-count{white-space:nowrap}',
180
230
  '.dsdel-bar-btn{appearance:none;min-height:30px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-fill-elevated,rgba(128,128,128,.08));color:var(--dsw-alias-label-primary,inherit);border-radius:8px;font-size:13px;cursor:pointer}',
@@ -310,14 +360,20 @@ window.__ModuleLoader__.load({
310
360
  confirm.textContent = t('deleting')
311
361
  error.style.display = 'none'
312
362
  if (skipBox.checked) setSkipConfirm()
363
+ pendingRemoval.add(info.id)
364
+ scheduleDecorate()
313
365
  postDelete(info.id)
314
366
  .then(() => {
315
367
  backdrop.remove()
316
368
  toast(t('done'))
317
369
  refreshList()
370
+ settlePendingSoon()
371
+ leaveIfCurrent(info.id)
318
372
  })
319
373
  .catch((reason) => {
320
374
  busy = false
375
+ pendingRemoval.delete(info.id)
376
+ scheduleDecorate()
321
377
  confirm.disabled = !checkbox.checked
322
378
  cancel.disabled = false
323
379
  confirm.textContent = t('confirm')
@@ -336,6 +392,13 @@ window.__ModuleLoader__.load({
336
392
 
337
393
  let selecting = false
338
394
  const selected = new Set()
395
+ // 乐观 UI:已发出删除请求的行立即隐藏(decorate 时持续生效,React 重渲染也不会闪回);
396
+ // 请求失败时从集合移除,行随下一次 decorate 恢复显示。
397
+ const pendingRemoval = new Set()
398
+ let batchRunning = false
399
+ let batchTotal = 0
400
+ let batchDone = 0
401
+ let batchFailed = 0
339
402
  let bar = null
340
403
  let decorateRaf = null
341
404
  let rowObserver = null
@@ -364,6 +427,7 @@ window.__ModuleLoader__.load({
364
427
  row.insertBefore(check, row.firstChild)
365
428
  }
366
429
  check.classList.toggle('dsdel-check-on', selected.has(id))
430
+ row.classList.toggle('dsdel-row-pending', pendingRemoval.has(id))
367
431
  })
368
432
  }
369
433
 
@@ -378,9 +442,15 @@ window.__ModuleLoader__.load({
378
442
  function updateBar() {
379
443
  if (!bar) return
380
444
  const count = bar.querySelector('.dsdel-bar-count')
381
- if (count) count.textContent = t('selectedCount')(selected.size)
445
+ if (count) {
446
+ count.textContent = batchRunning
447
+ ? t('batchRunning')(batchDone + batchFailed, batchTotal)
448
+ : t('selectedCount')(selected.size)
449
+ }
450
+ const cancel = bar.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)')
382
451
  const del = bar.querySelector('.dsdel-bar-danger')
383
- if (del) del.disabled = selected.size === 0
452
+ if (cancel) cancel.disabled = batchRunning
453
+ if (del) del.disabled = batchRunning || selected.size === 0
384
454
  }
385
455
 
386
456
  function toggleSelect(id) {
@@ -403,6 +473,7 @@ window.__ModuleLoader__.load({
403
473
 
404
474
  function onSelectKeydown(e) {
405
475
  if (e.key !== 'Escape') return
476
+ if (batchRunning) return
406
477
  e.stopPropagation()
407
478
  exitSelection()
408
479
  }
@@ -447,10 +518,16 @@ window.__ModuleLoader__.load({
447
518
  updateBar()
448
519
  }
449
520
 
450
- function exitSelection() {
521
+ function exitSelection(options) {
451
522
  if (!selecting) return
452
523
  selecting = false
453
524
  selected.clear()
525
+ batchRunning = false
526
+ const keepPending = !!(options && options.keepPending)
527
+ if (!keepPending) {
528
+ pendingRemoval.clear()
529
+ document.querySelectorAll('.dsdel-row-pending').forEach((el) => el.classList.remove('dsdel-row-pending'))
530
+ }
454
531
  if (bar) bar.style.display = 'none'
455
532
  document.removeEventListener('click', onSelectClickCapture, true)
456
533
  document.removeEventListener('keydown', onSelectKeydown, true)
@@ -458,36 +535,74 @@ window.__ModuleLoader__.load({
458
535
  document.querySelectorAll('.dsdel-check').forEach((el) => el.remove())
459
536
  }
460
537
 
461
- function postDeleteMany(sessionIds) {
462
- return fetch('/dsh-delete-session/delete-many', {
463
- method: 'POST',
464
- headers: { 'content-type': 'application/json' },
465
- body: JSON.stringify({ sessionIds }),
466
- }).then(async (res) => {
467
- let data = {}
468
- try { data = await res.json() } catch { /* keep {} */ }
469
- if (!res.ok || !data.ok) throw new Error((data && data.error) || `HTTP ${res.status}`)
470
- return data
471
- })
538
+ // 成功删除后行要保持隐藏直到列表刷新落地,之后再清掉乐观状态(避免行“闪回”)。
539
+ function settlePendingSoon() {
540
+ setTimeout(() => {
541
+ pendingRemoval.clear()
542
+ scheduleDecorate()
543
+ }, 2500)
472
544
  }
473
545
 
546
+ // 批量删除:客户端并发(小池)+ 乐观隐藏 + 进度显示。
547
+ // 走单会话端点(每个请求各自跑宿主端的完整删除管线),宿主端对同一存储的
548
+ // 记账变更已串行化,因此不同会话并发是安全的;失败的行立即恢复显示并保持选中,
549
+ // 方便直接重试。
550
+ // 6 是实测值:64 个 ~2MB 会话的墙钟时间 4 路 281ms / 6 路 189ms / 8 路 142ms
551
+ // (I/O 并行度约在 8 饱和),6 路比 4 路快约 1/3 且仍在饱和点以下。
552
+ const BATCH_CONCURRENCY = 6
553
+
474
554
  function runBatchDelete() {
555
+ if (batchRunning) return
475
556
  const ids = [...selected]
476
557
  if (ids.length === 0) return
477
- const del = bar ? bar.querySelector('.dsdel-bar-danger') : null
478
- if (del) del.disabled = true
479
- postDeleteMany(ids)
480
- .then((data) => {
481
- const failed = data.failed || 0
482
- const deleted = data.deleted || 0
483
- toast(failed > 0 ? t('batchPartial')(deleted, failed) : t('batchDone')(deleted), failed > 0)
484
- exitSelection()
485
- refreshList()
486
- })
487
- .catch((reason) => {
488
- if (del) del.disabled = false
489
- toast(t('batchFailed') + String((reason && reason.message) || reason), true)
490
- })
558
+ batchRunning = true
559
+ batchTotal = ids.length
560
+ batchDone = 0
561
+ batchFailed = 0
562
+ selected.clear()
563
+ for (const id of ids) pendingRemoval.add(id)
564
+ scheduleDecorate()
565
+ updateBar()
566
+
567
+ const failedIds = []
568
+ let cursor = 0
569
+ const worker = async () => {
570
+ while (true) {
571
+ const index = cursor
572
+ cursor += 1
573
+ if (index >= ids.length) return
574
+ const id = ids[index]
575
+ try {
576
+ await postDelete(id)
577
+ batchDone += 1
578
+ } catch {
579
+ batchFailed += 1
580
+ failedIds.push(id)
581
+ pendingRemoval.delete(id)
582
+ }
583
+ scheduleDecorate()
584
+ updateBar()
585
+ }
586
+ }
587
+ const workers = []
588
+ for (let i = 0; i < Math.min(BATCH_CONCURRENCY, ids.length); i += 1) workers.push(worker())
589
+
590
+ Promise.all(workers).then(() => {
591
+ batchRunning = false
592
+ if (batchFailed === 0) {
593
+ toast(t('batchDone')(batchDone))
594
+ exitSelection({ keepPending: true })
595
+ settlePendingSoon()
596
+ } else {
597
+ for (const id of failedIds) selected.add(id)
598
+ toast(t('batchPartial')(batchDone, batchFailed), true)
599
+ updateBar()
600
+ }
601
+ refreshList()
602
+ for (const id of ids) {
603
+ if (!failedIds.includes(id)) leaveIfCurrent(id)
604
+ }
605
+ })
491
606
  }
492
607
 
493
608
  // --- sidebar menu injection ------------------------------------------------
package/src/index.js CHANGED
@@ -92,6 +92,44 @@ function removeSessionDirs(sessionId) {
92
92
  return dirs
93
93
  }
94
94
 
95
+ // Deleting the on-disk directory is not enough: projection caches keep a
96
+ // per-session JSON row (`$DSH_HOME/storages/<store>/sessions/<id>.json`) that
97
+ // would linger as a ghost — the sidebar list is directory-backed, so the row
98
+ // can never be shown or deleted again (issue: "can't delete anymore"). Remove
99
+ // the session's cache units together with its directories.
100
+ function removeSessionCaches(sessionId) {
101
+ const storesRoot = path.join(dshHome(), 'storages')
102
+ const removed = []
103
+ let stores
104
+ try {
105
+ stores = fs.readdirSync(storesRoot, { withFileTypes: true })
106
+ } catch {
107
+ return removed
108
+ }
109
+ const names = idVariants(sessionId).map((variant) => `${variant}.json`)
110
+ for (const store of stores) {
111
+ if (!store.isDirectory()) continue
112
+ const dir = path.join(storesRoot, store.name, 'sessions')
113
+ let entries
114
+ try {
115
+ entries = fs.readdirSync(dir)
116
+ } catch {
117
+ continue
118
+ }
119
+ for (const entry of entries) {
120
+ if (!names.includes(entry)) continue
121
+ const file = path.join(dir, entry)
122
+ try {
123
+ fs.rmSync(file, { force: true })
124
+ removed.push(file)
125
+ } catch {
126
+ // best-effort: a locked cache row must not fail the deletion
127
+ }
128
+ }
129
+ }
130
+ return removed
131
+ }
132
+
95
133
  // --- live state --------------------------------------------------------------
96
134
 
97
135
  // Stop a running agent before its session disappears: cancel the active turn
@@ -162,8 +200,10 @@ async function flushAndDetach(ctx, sessionId) {
162
200
  // lease asynchronously (session/disposed starts a persistence retirement), so
163
201
  // the probe is retried with a short backoff before the request is refused -
164
202
  // in practice the first click then succeeds without forcing anything.
165
- const BUSY_RETRY_ATTEMPTS = 10
166
- const BUSY_RETRY_DELAY_MS = 500
203
+ // The backoff is short (200ms x 12 = ~2.2s worst case) so a batch containing
204
+ // live sessions stays responsive while the lease retirement lands.
205
+ const BUSY_RETRY_ATTEMPTS = 12
206
+ const BUSY_RETRY_DELAY_MS = 200
167
207
 
168
208
  function sleep(ms) {
169
209
  return new Promise((resolve) => setTimeout(resolve, ms))
@@ -197,6 +237,43 @@ async function assertNoActiveWriter(ctx, sessionId) {
197
237
  throw new HttpError(409, 'busy', 'session is currently open in DSH')
198
238
  }
199
239
 
240
+ // Bounded concurrency for batch deletes. Different sessions are independent
241
+ // (agent quiesce, lease probe and directory sweep all key off the session id),
242
+ // so the pipeline runs in parallel through a small pool. The one shared
243
+ // mutation - workspace accounting - is serialized through withWorkspaceLock so
244
+ // two deletions can never interleave on the same store.
245
+ //
246
+ // 6 is measured, not guessed: on 64 sessions of ~2MB the wall time was
247
+ // 281ms at 4, 189ms at 6 and 142ms at 8 (the I/O parallelism saturates around
248
+ // 8), so 6 keeps a ~33% gain over 4 while staying below the saturation knee
249
+ // and limiting the number of simultaneous lease-probe loops.
250
+ const BATCH_CONCURRENCY = 6
251
+
252
+ let workspaceLock = Promise.resolve()
253
+ function withWorkspaceLock(fn) {
254
+ const run = workspaceLock.then(fn, fn)
255
+ workspaceLock = run.then(() => undefined, () => undefined)
256
+ return run
257
+ }
258
+
259
+ async function runWithConcurrency(items, limit, worker) {
260
+ const results = new Array(items.length)
261
+ let cursor = 0
262
+ const runners = []
263
+ for (let i = 0; i < Math.min(limit, items.length); i += 1) {
264
+ runners.push((async () => {
265
+ while (true) {
266
+ const index = cursor
267
+ cursor += 1
268
+ if (index >= items.length) return
269
+ results[index] = await worker(items[index], index)
270
+ }
271
+ })())
272
+ }
273
+ await Promise.all(runners)
274
+ return results
275
+ }
276
+
200
277
  async function detachFromWorkspace(ctx, sessionId) {
201
278
  const workspace = ctx.get('workspaceRegistry')
202
279
  if (!workspace) return false
@@ -264,11 +341,16 @@ async function deleteSession(ctx, sessionId) {
264
341
  throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
265
342
  }
266
343
 
267
- const workspaceDetached = await detachFromWorkspace(ctx, sessionId)
268
- if (removed.length === 0 && !workspaceDetached && !flushed && !detached) {
344
+ // Cache rows are projection state, not the source of truth: clean them even
345
+ // when the directories were already gone, so a deleted session cannot leave
346
+ // a ghost behind.
347
+ const cachesRemoved = removeSessionCaches(sessionId)
348
+
349
+ const workspaceDetached = await withWorkspaceLock(() => detachFromWorkspace(ctx, sessionId))
350
+ if (removed.length === 0 && !workspaceDetached && !flushed && !detached && cachesRemoved.length === 0) {
269
351
  throw new HttpError(404, 'not-found', 'session not found')
270
352
  }
271
- return { removed: removed.length, stopped, flushed, detached, workspaceDetached }
353
+ return { removed: removed.length, caches: cachesRemoved.length, stopped, flushed, detached, workspaceDetached }
272
354
  }
273
355
 
274
356
  // --- http --------------------------------------------------------------------
@@ -400,23 +482,24 @@ export function apply(ctx) {
400
482
  sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
401
483
  return
402
484
  }
403
- // Sequential on purpose: every item runs the same guarded pipeline
404
- // (lease retry, directory sweep, workspace accounting), so a batch can
405
- // never interleave two deletions of the same storage.
406
- const results = []
407
- for (const sessionId of sessionIds) {
485
+ // Bounded-concurrency batch: every item runs the same guarded pipeline
486
+ // (lease retry, directory sweep, workspace accounting); independent
487
+ // sessions run in parallel and the workspace accounting mutation is
488
+ // serialized inside deleteSession, so a batch can never interleave two
489
+ // deletions of the same storage.
490
+ const results = await runWithConcurrency(sessionIds, BATCH_CONCURRENCY, async (sessionId) => {
408
491
  try {
409
492
  const result = await deleteSession(ctx, sessionId)
410
- results.push({ sessionId, ok: true, removed: result.removed })
493
+ return { sessionId, ok: true, removed: result.removed }
411
494
  } catch (error) {
412
- results.push({
495
+ return {
413
496
  sessionId,
414
497
  ok: false,
415
498
  code: error instanceof HttpError ? error.code : 'internal',
416
499
  error: String((error && error.message) || error),
417
- })
500
+ }
418
501
  }
419
- }
502
+ })
420
503
  const deleted = results.filter((item) => item.ok).length
421
504
  sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
422
505
  },