dsh-workspace-mover 2.4.0 → 2.4.2

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.
@@ -0,0 +1,68 @@
1
+ # 2.4.2 即时迁移与标题修复验收
2
+
3
+ 正式版本:2.4.2。验收候选:2.4.2-rc.4;正式版仅调整版本和发布文档,运行逻辑相同。
4
+
5
+ ## 人工验收结论(2026-10-26)
6
+
7
+ 用户在 DSH Desktop 0.2.1-rc.2 / Windows 上确认:
8
+
9
+ - 空闲会话拖动后立即出现在目标工作区,标题正常。
10
+ - rc.4 修复后不切换会话即可继续发送,未再出现“会话不可用”。
11
+ - 运行中拖动选择取消:任务继续、会话不移动。
12
+ - 明确选择停止任务并迁移:任务停止,迁移后可在当前界面继续发送。
13
+ - 彻底退出重启后标题、历史、归属正常,继续发送成功。
14
+
15
+ 证据来源:维护者在本次验收会话中先确认“一切正常”,随后按最后三项测试回复“都成功。进入发布流程和issue解决流程”。这是维护者本机验收,不代表原报告者已在旧版 DSH 复测。
16
+
17
+
18
+ 用户已在 rc.2 确认迁移后立即显示在目标工作区,且未出现未命名会话;随后发现当前输入框显示“会话不可用”,手动切走再切回才恢复。rc.3 尝试同步重新打开,但用户复验仍不可用,并出现重新连接失败提示。rc.4 改为等待引用释放后打开,用户随后确认通过。
19
+
20
+ ## 迁移后的当前对话
21
+
22
+ 官方 SessionController 在 dispose 时发布 api-session/removed;客户端 Session.handleRemoved 将 removed 置为 true,后续 added 事件只更新列表,并不清除此标记。因此后端已恢复的 Agent 对应的旧界面仍不可用。rc.3 在迁移 RPC 结束后检查仍选中的会话,仅在 removed 时通过 uiWorkspace.clearMain/openSession 释放旧视图并重新打开,等待新会话打开。失败回滚也执行同样检查;用户切换到其他会话或插件卸载后不主动跳转。不改宿主文件或私有 removed 字段。
23
+
24
+ rc.3 漏掉了延迟释放:clearMain 仅立即释放主视图引用,React Provider 的其他引用要等界面更新才释放,立刻 openSession 会重新持有 removed 的旧对象。rc.4 用 sessions.retainInfo 观察旧 binding 的实际退出后再打开;最多等待 3 秒,插件卸载会取消等待。等待期间用户选了其他会话则不重新打开。
25
+
26
+ 新增已安装客户端验证:执行 Desktop 内的真实 ClientSessions / SessionManager / Session 代码,使用真实 Cordis,替换界面 store 和未使用的传输边界。确认 added 不清除 removed、双引用下同步重新取得旧对象、延迟释放最后引用后才能取得 removed=false 的新对象。该验证不运行 React 界面,不能替代 Desktop 复验。
27
+
28
+ 若其他界面仍持有旧会话引用,或重新打开失败,明确提示彻底退出并重启,不重复执行物理迁移。自动回归覆盖成功、回滚、连接报错、用户切换、正常界面和插件卸载;完整 Desktop 界面、草稿及预设保持情况仍需实际验收。
29
+
30
+ ## 行为
31
+
32
+ - 插件观察官方 agents.create/resume,保留返回的生命周期 dispose;不修改持久化 writer、不可变 Session header 或安装文件。
33
+ - 空闲且已接入生命周期的会话:锁定迁移操作,阻止新请求及后台恢复,等待官方 dispose 排空并关闭 writer,真实搬移档案,通过官方 SessionController.resolveAgent 重新按宿主预设加载。
34
+ - 运行中会话:弹窗明确显示“停止任务并迁移”;取消不调用迁移。状态在确认后发生变化时,后端返回 stop-required,不擅自停止。
35
+ - 放行操作使用 AsyncLocalStorage 限定为当前迁移的恢复调用,不放行其他并发恢复。旧 Agent 引用在替换后拒绝发送。
36
+ - 迁移失败沿用文件和工作区归属回滚,并请求宿主从原位置重新加载。若重新加载失败,明确返回错误,不伪称全部成功。
37
+ - 热更新前已打开、没有保留生命周期句柄的会话不强行释放。完全重启一次后重新打开会话即可接入;未知后端仍保留原安全检查。
38
+ - 拖动迁移不自动创建 pending。历史 API 的排队能力和已有队列保持兼容;没有明确停止授权的后台任务不取消正在运行的 Agent。
39
+ - 活跃子代理迁移暂不支持;不能在其父任务仍持有生命周期时单独卸载。
40
+
41
+ ## Issue #10:标题
42
+
43
+ 原实现调用底层 table.update,但已安装宿主的缓存表使用 get/put。现在以完整 identity/rows 读取并写回,在 attachSession 发布新归属之前对齐 identity.cwd;保留 title 行,移除旧 workingDirectory 行。attach 失败恢复原 identity/rows。
44
+
45
+ 本机“标题保留测试”归档后,标题仍保存在新版 per-record 投影缓存中;修复面板原来只读取旧 session_projcache.json 汇总文件,因此误显示“未命名会话”。现在扫描、归档、预览和备份列表按内存缓存、新版单记录文件、旧版汇总、档案标题的顺序读取。回归测试覆盖新旧缓存共存、内存更新和损坏缓存降级。
46
+
47
+ 报告者 DSH 0.2.0-rc.2 环境尚未直接复现;这是发现并修复的具体代码缺陷,不声称已完成该环境验收。
48
+
49
+ ## 自动验证
50
+
51
+ - 常规套件包含即时迁移的 DOM 确认/取消、无自动排队、生命周期并发拒绝和卸载恢复检查。
52
+ - test/live-migration.test.mjs 的可选集成项使用从本机 Desktop 提取的 DSH 0.2.1-rc.2、真实 Cordis、SessionStore、AgentRegistry、AgentLoop、JSONL/Zstd writer 和原生文件锁。
53
+ - 检查真实 Agent 创建、释放、物理迁移、目标路径恢复、继续追加、失败回滚后恢复原路径,以及运行中拒绝/明确停止后迁移。
54
+ - 集成测试的 SessionController 入口使用精简实现调用真实 agents.resume;没有启动完整 Desktop、加载真实用户预设或调用在线模型。实际预设、权限与模型恢复依赖下面的 Desktop 人工验收。
55
+ - DSH_TEST_NODE_MODULES 指向已有 runtime/node_modules,运行 npm run test:installed-host;未配置时明确 skip。
56
+ - rc.2 本机 Windows / Node 22.23.2:npm run check 通过,319 项通过、1 项真实宿主可选测试按预期 skip;单独配置真实宿主后 npm run test:installed-host 的 3 项全部通过。rc.3 增补当前对话重新打开的回归检查。跨平台 CI 尚未运行。
57
+ - rc.3:npm run check 通过,325 项通过、1 项可选测试跳过;语法、打包预检和 git diff --check 通过。新增的 6 项界面回归使用 DOM 与宿主服务替身,尚不能替代 Desktop 人工复验。
58
+ - rc.4:npm run check 通过,327 项通过、2 项宿主可选测试跳过;配置真实宿主后 npm run test:installed-host 的 4 项全部通过,包括上述客户端引用释放复现。语法、打包预检和 git diff --check 通过。Desktop 人工复验通过,见本文开头。
59
+
60
+ ## Desktop 验收
61
+
62
+ 1. 保存正在做的工作,从应用菜单或托盘彻底退出 DSH,再启动。自检版本应为 2.4.2-rc.4。
63
+ 2. 新建测试会话并命名“标题保留测试”,发送一句消息,等回复结束。拖到 B,确认后应直接显示在 B,无 pending、无需重启。
64
+ 3. 不切换到其他会话,在 B 当前界面直接连续发两条消息,确认输入框可用、正常回复,模型/预设/权限及未发送草稿保持预期,有效工作目录为 B。
65
+ 4. 让另一个测试会话执行可随时停止的任务,生成回复期间拖动。先取消,原任务继续;再次拖动选择“停止任务并迁移”,确认任务停止后会话出现在 B,可继续发送。
66
+ 5. 重启后标题、历史、归属和工作目录仍正确。分别检查手动标题和自动生成标题,并在归档后检查设置中的会话修复列表标题。若异常,保留测试会话,不重复强行搬移。
67
+
68
+ 发布后在 issue #10 说明修复及升级步骤,并邀请原报告者在 DSH 0.2.0-rc.2 复测;待其确认后关闭。
package/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import { join, dirname, basename, relative, resolve, isAbsolute } from 'node:pat
36
36
  import { homedir } from 'node:os';
37
37
  import { spawn } from 'node:child_process';
38
38
  import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
39
+ import { installLifecycle, lifecycleFor, withReleasedSession } from './lifecycle.js';
39
40
 
40
41
  const name = 'workspace-mover';
41
42
  // 只声明真正必需的两个服务。cordis 把 inject 里的**每一项**都当必需:任一缺失,
@@ -913,7 +914,9 @@ async function moveSessionEntry(ctx, payload = {}, hooks = {}) {
913
914
  `workspace:${targetWorkspaceId}`
914
915
  );
915
916
  try {
916
- return await moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks);
917
+ if (!ctx.workspaceRegistry?.get(targetWorkspaceId)) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
918
+ return await withReleasedSession(ctx, sessionId, payload.stopRunning === true,
919
+ () => moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks));
917
920
  } finally {
918
921
  release();
919
922
  }
@@ -955,7 +958,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
955
958
  const sourceCwd = meta.cwd;
956
959
  const title = typeof sessionTitle === 'string' && sessionTitle.trim()
957
960
  ? sessionTitle.trim()
958
- : (typeof meta.title === 'string' && meta.title.trim() ? meta.title.trim() : '未命名会话');
961
+ : readSessionTitle(ctx, sessionId, readProjectionTitles().get(String(sessionId)) ?? meta.title ?? '未命名会话');
959
962
  if (sourceCwd === undefined) throw new Error('session header carries no cwd');
960
963
  if (samePath(sourceCwd, targetPath)) {
961
964
  // 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
@@ -1129,7 +1132,11 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
1129
1132
  // 才会读取 header 并经 mutate 发出 domain/changed。不能直接预置
1130
1133
  // target.record.sessionIds,否则 attach 变成 no-op,WorkspaceFeed 不会推送目标
1131
1134
  // 工作区的更新帧,浏览器就会暂时把会话显示在“未分组”。
1135
+ const originalProjection = captureProjection(ctx, sessionId);
1132
1136
  try {
1137
+ // Listings can read the title during attach: align BEFORE publishing the
1138
+ // new header, otherwise the host rejects the old cwd-bound checkpoint.
1139
+ await realignProjectionIdentity(ctx, sessionId, targetPath);
1133
1140
  await target.attachSession(sessionId);
1134
1141
  } catch (err) {
1135
1142
  // Roll back original bytes and reattach the source workspace.
@@ -1142,6 +1149,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
1142
1149
  throw new Error(`physical rollback could not move the target directory: ${relocateErr?.message ?? relocateErr}`);
1143
1150
  }
1144
1151
  await storage.replace(sourceLocation, original);
1152
+ await writeProjection(ctx, String(sessionId), originalProjection, sourceCwd, true);
1145
1153
  for (const e of allOwners) {
1146
1154
  try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
1147
1155
  }
@@ -1400,6 +1408,26 @@ function readProjectionTitles() {
1400
1408
  return map;
1401
1409
  }
1402
1410
 
1411
+ /** Current hosts use per-record checkpoints; legacy aggregate files can remain
1412
+ * on disk after an upgrade and must never override a current title. */
1413
+ function readSessionTitle(ctx, sessionId, fallback = null) {
1414
+ const titleOf = (record) => {
1415
+ const title = record?.rows?.title?.val;
1416
+ return typeof title === 'string' && title.trim() ? title.trim() : null;
1417
+ };
1418
+ const live = titleOf(captureProjection(ctx, sessionId));
1419
+ if (live) return live;
1420
+ try {
1421
+ const id = validId(String(sessionId), 'sessionId');
1422
+ if (/[\\/:]/.test(id) || id === '.' || id === '..') return fallback;
1423
+ const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
1424
+ const document = JSON.parse(readFileSync(join(base, 'storages', 'session_projcache', 'sessions', `${id}.json`), 'utf8'));
1425
+ const current = titleOf(document?.record);
1426
+ if (current) return current;
1427
+ } catch { /* Cache absence/corruption must not prevent session rescue. */ }
1428
+ return fallback;
1429
+ }
1430
+
1403
1431
  /**
1404
1432
  * 扫描存储根下的全部会话档案并分类:
1405
1433
  * - orphaned 头部 cwd 指向的目录已不存在(项目文件夹被移动/改名/删除,官方讨论 #3012)
@@ -1504,8 +1532,8 @@ async function scanSessions(ctx) {
1504
1532
  : 'misfiled'; // 记账方存在但不是 cwd 匹配的分组
1505
1533
  items.push({
1506
1534
  sessionId,
1507
- title: projectionTitles.get(sessionId)
1508
- ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话'),
1535
+ title: readSessionTitle(ctx, sessionId, projectionTitles.get(sessionId)
1536
+ ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话')),
1509
1537
  cwd,
1510
1538
  status,
1511
1539
  targetWorkspaceId: matching?.id ?? null,
@@ -1615,12 +1643,8 @@ function invalidateFileReferenceSearches(ctx, stalePath) {
1615
1643
  */
1616
1644
  async function realignProjectionIdentity(ctx, sessionId, nextCwd) {
1617
1645
  try {
1618
- const table = peekService(ctx, 'sessionProjectionCache')?.table;
1619
- if (typeof table?.update !== 'function') return;
1620
- await table.update(String(sessionId), (rec) =>
1621
- rec?.identity ? { ...rec, identity: { ...rec.identity, cwd: nextCwd },
1622
- rows: Object.fromEntries(Object.entries(rec.rows ?? {}).filter(([key]) => key !== 'workingDirectory')) } : rec
1623
- );
1646
+ const record = captureProjection(ctx, sessionId);
1647
+ await writeProjection(ctx, String(sessionId), record, nextCwd);
1624
1648
  } catch (err) {
1625
1649
  ctx.logger?.warn?.(`workspace-mover: projection identity alignment failed for ${sessionId}: ${err?.message ?? err}`);
1626
1650
  }
@@ -1999,13 +2023,16 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
1999
2023
  const repairMembership = sameWorkspace && !rawRecordSessionIds(target).includes(id);
2000
2024
  let backupPlan = { reused: false, estimatedBytes: 0 };
2001
2025
  let status = 'safe';
2026
+ const liveMigration = Boolean(lifecycleFor(ctx, id));
2002
2027
  if (running) {
2003
- status = 'queued';
2004
- warnings.push('session is currently running and will wait for idle');
2028
+ status = 'warning';
2029
+ warnings.push('session is running; moving requires explicit stop confirmation');
2005
2030
  }
2006
2031
  try { assertNoResidentWriter(ctx, id); } catch (err) {
2007
- status = 'queued';
2008
- warnings.push(err.message);
2032
+ if (!liveMigration) {
2033
+ status = 'queued';
2034
+ warnings.push(err.message);
2035
+ }
2009
2036
  }
2010
2037
  if (adapter.mode === 'filesystem-fallback') warnings.push('physical move uses the JSONL path fallback; official raw-byte path API is unavailable');
2011
2038
  if (!adapter.capabilities.physicalPath) {
@@ -2054,10 +2081,11 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
2054
2081
  status,
2055
2082
  canMove: status === 'safe' || status === 'warning' || status === 'queued',
2056
2083
  sessionId: id,
2057
- title: readProjectionTitles().get(id) ?? meta.title ?? null,
2084
+ title: readSessionTitle(ctx, id, readProjectionTitles().get(id) ?? meta.title ?? null),
2058
2085
  source,
2059
2086
  target: targetInfo,
2060
2087
  running,
2088
+ liveMigration,
2061
2089
  branches: { available: true, count: branches.relations.length, mode, sessionIds: branches.ids, relations: branches.relations },
2062
2090
  estimatedBackupBytes: sameWorkspace ? 0 : backupPlan.estimatedBytes,
2063
2091
  backup: sameWorkspace ? { reused: false, estimatedBytes: 0 } : backupPlan,
@@ -2067,7 +2095,7 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
2067
2095
  }
2068
2096
 
2069
2097
  /** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
2070
- async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
2098
+ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only', stopRunning = false, allowQueue = true } = {}) {
2071
2099
  if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
2072
2100
  if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
2073
2101
  if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
@@ -2113,7 +2141,8 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
2113
2141
  sessionId,
2114
2142
  targetWorkspaceId,
2115
2143
  sessionTitle: typeof item.sessionTitle === 'string' ? item.sessionTitle : undefined,
2116
- recordHistory: false
2144
+ recordHistory: false,
2145
+ stopRunning
2117
2146
  });
2118
2147
  if (result.attached) attachedCount += 1; else movedCount += 1;
2119
2148
  let sourceWorkspaceId = null;
@@ -2123,7 +2152,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
2123
2152
  moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId, backup: result.backup ?? null });
2124
2153
  results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
2125
2154
  } catch (err) {
2126
- if (isBusyError(err)) {
2155
+ if (allowQueue && isBusyError(err)) {
2127
2156
  const queued = createOrReuseMigration(ctx, {
2128
2157
  sessionId,
2129
2158
  targetWorkspaceId,
@@ -3196,14 +3225,14 @@ async function dropProjection(ctx, sessionId) {
3196
3225
  }
3197
3226
  }
3198
3227
 
3199
- async function writeProjection(ctx, sessionId, record, cwd) {
3228
+ async function writeProjection(ctx, sessionId, record, cwd, preserveWorkingDirectory = false) {
3200
3229
  try {
3201
3230
  const table = projectionTable(ctx);
3202
3231
  if (!table || typeof table.put !== 'function' || !record?.identity || !record?.rows) return;
3203
3232
  // 注意:table 是底层 KvTable,put(key, value) 两参——value 必须是完整 {identity, rows};
3204
3233
  // 服务层的 put(id, identity, rows) 三参签名在这里不可用(会整条写坏,域重开时崩)。
3205
3234
  await table.put(String(sessionId), { identity: { ...record.identity, cwd },
3206
- rows: Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
3235
+ rows: preserveWorkingDirectory ? record.rows : Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
3207
3236
  } catch (err) {
3208
3237
  ctx.logger?.warn?.(`workspace-mover: projection restore failed for ${sessionId} (title will rebuild lazily): ${err?.message ?? err}`);
3209
3238
  }
@@ -3537,7 +3566,7 @@ async function listBackups(ctx) {
3537
3566
  const { id, ts } = parsed;
3538
3567
  let sizeBytes = 0;
3539
3568
  try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
3540
- const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
3569
+ const g = groups.get(id) ?? { sessionId: id, title: readSessionTitle(ctx, id, titles.get(id) ?? null), count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
3541
3570
  g.count++;
3542
3571
  g.totalBytes += sizeBytes;
3543
3572
  g.oldest = Math.min(g.oldest, Number(ts));
@@ -4177,6 +4206,7 @@ async function runDoctor(ctx, agentToolsState) {
4177
4206
 
4178
4207
  export function apply(ctx) {
4179
4208
  const logger = ctx.logger ?? console;
4209
+ ctx.inject?.(['agents'], (owner) => installLifecycle(ctx, owner));
4180
4210
  const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
4181
4211
  const agentToolsState = { registered: false };
4182
4212
  const mount = (owner) => {
@@ -4271,15 +4301,15 @@ export function apply(ctx) {
4271
4301
  return ok(await moveManySessions(ctx, {
4272
4302
  sessions: [{ sessionId, sessionTitle: payload?.sessionTitle }],
4273
4303
  targetWorkspaceId,
4274
- mode: 'include-descendants'
4304
+ mode: 'include-descendants', stopRunning: payload?.stopRunning === true, allowQueue: payload?.allowQueue !== false
4275
4305
  }));
4276
4306
  }
4277
- const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
4307
+ const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle, stopRunning: payload?.stopRunning === true });
4278
4308
  logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
4279
4309
  return ok(result);
4280
4310
  } catch (err) {
4281
4311
  logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
4282
- if (isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
4312
+ if (payload?.allowQueue !== false && isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
4283
4313
  const queued = createOrReuseMigration(ctx, {
4284
4314
  sessionId: payload.sessionId,
4285
4315
  targetWorkspaceId: payload.targetWorkspaceId,
@@ -0,0 +1,104 @@
1
+ // Retain the public create/resume disposer. Never close a borrowed persistence
2
+ // writer or rewrite an immutable live Session header.
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ const migrationContext = new AsyncLocalStorage();
5
+ const original = Symbol.for('cordis.original');
6
+ const runtimes = new WeakMap();
7
+ const raw = (value) => value?.[original] ?? value;
8
+
9
+ function blocked() {
10
+ const error = new Error('This session is being migrated; retry after migration completes.');
11
+ error.code = 'migration-in-progress';
12
+ return error;
13
+ }
14
+
15
+ export function installLifecycle(ctx, owner) {
16
+ const agents = raw(owner.agents);
17
+ if (!agents || runtimes.has(agents)) return;
18
+ const runtime = { handles: new Map(), moving: new Set() };
19
+ runtimes.set(agents, runtime);
20
+ const restore = [];
21
+ for (const method of ['create', 'resume']) {
22
+ const fn = agents[method];
23
+ if (typeof fn !== 'function') continue;
24
+ const wrapped = async function (options, ...args) {
25
+ const id = options?.resumeSessionId ?? options?.sessionId;
26
+ if (runtime.moving.has(id) && migrationContext.getStore() !== runtime) throw blocked();
27
+ const handle = await Reflect.apply(fn, this, [options, ...args]);
28
+ const agent = raw(handle?.agent);
29
+ if (agent && typeof handle.dispose === 'function') {
30
+ const guards = [];
31
+ runtime.handles.set(agent.id, { handle, agent, guards });
32
+ // Requests that already resolved the old Agent must also be refused
33
+ // while its lifecycle is draining or after it has been replaced.
34
+ for (const entry of ['send', 'runMaintenance']) {
35
+ const action = agent[entry];
36
+ if (typeof action !== 'function') continue;
37
+ const guarded = function (...params) {
38
+ if (runtime.moving.has(agent.id) || raw(agents.get(agent.id)) !== agent) throw blocked();
39
+ return Reflect.apply(action, this, params);
40
+ };
41
+ agent[entry] = guarded;
42
+ guards.push(() => { if (agent[entry] === guarded) agent[entry] = action; });
43
+ }
44
+ }
45
+ return handle;
46
+ };
47
+ agents[method] = wrapped;
48
+ restore.push(() => { if (agents[method] === wrapped) agents[method] = fn; });
49
+ }
50
+ owner.on?.('agent/disposed', ({ agent }) => {
51
+ if (runtime.handles.get(agent.id)?.agent === raw(agent)) runtime.handles.delete(agent.id);
52
+ });
53
+ owner.effect(() => () => {
54
+ for (const entry of runtime.handles.values()) for (const undo of entry.guards) undo();
55
+ runtime.handles.clear();
56
+ for (const undo of restore.reverse()) undo();
57
+ runtimes.delete(agents);
58
+ }, 'workspace-mover: restore lifecycle observers');
59
+ }
60
+
61
+ export function lifecycleFor(ctx, id) {
62
+ const agents = raw(typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents);
63
+ const runtime = agents && runtimes.get(agents);
64
+ const entry = runtime?.handles.get(id);
65
+ return entry && raw(agents.get(id)) === entry.agent ? { ...entry, runtime, agents } : null;
66
+ }
67
+
68
+ export async function withReleasedSession(ctx, id, stopRunning, operation) {
69
+ const owned = lifecycleFor(ctx, id);
70
+ if (!owned) return operation();
71
+ const { handle, agent, runtime } = owned;
72
+ if (agent.status === 'running' && !stopRunning) {
73
+ const error = new Error('Session is running. Confirm stop and move, or cancel migration.');
74
+ error.code = 'stop-required';
75
+ throw error;
76
+ }
77
+ if (agent.session.header?.parentSession || agent.session.header?.origin === 'subagent') {
78
+ throw new Error('Live subagent migration is unsupported; stop its owning task first.');
79
+ }
80
+ runtime.moving.add(id);
81
+ let result;
82
+ let failure;
83
+ try {
84
+ await handle.dispose(); // owns cancel → drain → close → unregister
85
+ result = await operation();
86
+ } catch (error) {
87
+ failure = error;
88
+ } finally {
89
+ runtime.handles.delete(id);
90
+ try {
91
+ const controller = ctx.get?.('sessionController');
92
+ if (typeof controller?.resolveAgent === 'function') {
93
+ const restored = await migrationContext.run(runtime, () => controller.resolveAgent(id));
94
+ if (restored?.error) throw restored.error;
95
+ }
96
+ } catch (error) {
97
+ failure = new Error(`${failure ? failure.message : 'Files migrated successfully'}; session reload failed: ${error.message}`, { cause: error });
98
+ } finally {
99
+ runtime.moving.delete(id);
100
+ }
101
+ }
102
+ if (failure) throw failure;
103
+ return result;
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-mover",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -21,10 +21,11 @@
21
21
  "docs/compatibility",
22
22
  "docs/media",
23
23
  "LICENSE"
24
- ], "scripts": {
25
- "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs",
26
- "test:installed-host": "node --test test/installed-host.test.mjs",
27
- "check:syntax": "node --check lib/index.js && node --check client/client.js",
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
27
+ "test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
28
+ "check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
28
29
  "check:pack": "npm pack --dry-run",
29
30
  "check": "npm run check:syntax && npm test && npm run check:pack"
30
31
  },
@@ -566,7 +566,7 @@ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', asy
566
566
  assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
567
567
  });
568
568
 
569
- test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列提示可能需重启', async () => {
569
+ test('即时迁移使用权威标题,不包含侧边栏时间,不启用排队', async () => {
570
570
  resetDom();
571
571
  mount();
572
572
  const row = sessionRow('测试刚刚', 'session-aaa');
@@ -575,22 +575,114 @@ test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列
575
575
  const prior = rpcResponder;
576
576
  setResponder((endpoint, payload) => endpoint === 'mover.preview' ? {
577
577
  ok: true, value: {
578
- title: '测试', status: 'queued', source: { cwd: 'C:/proj-alpha' },
578
+ title: '测试', status: 'safe', liveMigration: true, source: { cwd: 'C:/proj-alpha' },
579
579
  target: { path: 'C:/proj-beta' }, estimatedBackupBytes: 0,
580
- warnings: ['resident writer; restart required']
580
+ warnings: []
581
581
  }
582
582
  } : prior(endpoint, payload));
583
583
  attach(beta);
584
584
  await dragDrop(row, beta, 'session-aaa', { confirm: false });
585
585
  const overlay = doc._body.querySelector('.wsm-overlay');
586
586
  assert.equal(overlay.querySelector('.wsm-session').textContent, '测试');
587
- assert.match(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
587
+ assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
588
588
  assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /进入空闲队列/);
589
589
  dispatch({ type: 'click', target: overlay.querySelector('.wsm-ok') });
590
590
  await new Promise((resolve) => setTimeout(resolve, 0));
591
591
  assert.equal(moveCalls()[0].payload.sessionTitle, '测试');
592
+ assert.equal(moveCalls()[0].payload.allowQueue, false);
592
593
  });
593
594
 
595
+ for (const outcome of ['success', 'render-release', 'navigation-during-release', 'rollback', 'transport-error', 'navigated', 'healthy', 'unloaded']) {
596
+ test(`migration reconnects the removed current conversation via host navigation: ${outcome}`, async () => {
597
+ resetDom(); mount();
598
+ const row = sessionRow('标题保留测试', 'session-aaa');
599
+ const beta = header('proj-beta');
600
+ fixture({ rows: [row] });
601
+ attach(beta);
602
+ let selected = 'session-aaa';
603
+ let removed = false;
604
+ const releaseListeners = new Set();
605
+ let binding;
606
+ const navigation = [];
607
+ const ui = {
608
+ selection: { getSnapshot: () => ({ sessionId: selected }) },
609
+ clearMain() {
610
+ navigation.push('release'); selected = undefined;
611
+ const release = () => {
612
+ binding = undefined;
613
+ if (outcome === 'navigation-during-release') selected = 'session-other';
614
+ for (const listener of releaseListeners) listener();
615
+ };
616
+ if (outcome === 'render-release' || outcome === 'navigation-during-release') setTimeout(release, 0);
617
+ else release();
618
+ },
619
+ openSession(id) {
620
+ assert.equal(binding, undefined, 'must wait for Provider reference release before retaining again');
621
+ navigation.push(id); selected = id; removed = false; binding = { session };
622
+ }
623
+ };
624
+ const session = {
625
+ getSnapshot: () => ({ removed }),
626
+ async open() { navigation.push('ready'); }
627
+ };
628
+ binding = { session };
629
+ const sessions = { binding: () => binding, retainInfo: () => ({
630
+ subscribe(listener) { releaseListeners.add(listener); return () => releaseListeners.delete(listener); }
631
+ }) };
632
+ ctx.get = (key) => ({ uiWorkspace: ui, sessions })[key];
633
+ const prior = rpcResponder;
634
+ setResponder((endpoint, payload) => {
635
+ if (endpoint !== 'mover.move') return prior(endpoint, payload);
636
+ removed = outcome !== 'healthy';
637
+ if (outcome === 'navigated') selected = 'session-other';
638
+ if (outcome === 'unloaded') unmount();
639
+ if (outcome === 'transport-error') throw new Error('Connection lost after release');
640
+ if (outcome === 'rollback') return { ok: false, error: { code: 'move-failed', message: 'rolled back cleanly' } };
641
+ return prior(endpoint, payload);
642
+ });
643
+ await dragDrop(row, beta, 'session-aaa');
644
+ await new Promise((resolve) => setTimeout(resolve, 10));
645
+ assert.equal(moveCalls().length, 1, 'never repeats the physical migration');
646
+ if (['navigated', 'healthy', 'unloaded'].includes(outcome)) {
647
+ assert.deepEqual(navigation, []);
648
+ } else if (outcome === 'navigation-during-release') {
649
+ assert.deepEqual(navigation, ['release']);
650
+ assert.equal(selected, 'session-other');
651
+ } else {
652
+ assert.deepEqual(navigation, ['release', 'session-aaa', 'ready']);
653
+ assert.equal(removed, false, 'new view can accept messages without manually switching');
654
+ }
655
+ assert.equal(releaseListeners.size, 0, 'retention observer is released');
656
+ if (outcome === 'success') {
657
+ const text = doc._body.querySelector('.wsm-toast')?.textContent;
658
+ assert.match(text, /重启应用/);
659
+ assert.doesNotMatch(text, /刷新页面/);
660
+ }
661
+ if (outcome === 'rollback') assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /rolled back cleanly/);
662
+ });
663
+ }
664
+
665
+ for (const approve of [false, true]) {
666
+ test(`运行中拖动会话:${approve ? '明确停止后迁移' : '取消不执行'}`, async () => {
667
+ resetDom(); mount();
668
+ const row = sessionRow('运行中', 'session-aaa');
669
+ const beta = header('proj-beta');
670
+ fixture({ rows: [row] });
671
+ const prior = rpcResponder;
672
+ setResponder((endpoint, payload) => endpoint === 'mover.preview' ? { ok: true, value: {
673
+ status: 'warning', running: true, liveMigration: true, warnings: ['session is running; moving requires explicit stop confirmation']
674
+ } } : prior(endpoint, payload));
675
+ attach(beta);
676
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
677
+ const overlay = doc._body.querySelector('.wsm-overlay');
678
+ assert.equal(overlay.querySelector('.wsm-ok').textContent, '停止任务并迁移');
679
+ dispatch({ type: 'click', target: overlay.querySelector(approve ? '.wsm-ok' : '.wsm-cancel') });
680
+ await new Promise((resolve) => setTimeout(resolve, 0));
681
+ assert.equal(moveCalls().length, approve ? 1 : 0);
682
+ if (approve) assert.equal(moveCalls()[0].payload.stopRunning, true);
683
+ });
684
+ }
685
+
594
686
  test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
595
687
  resetDom();
596
688
  mount();
@@ -62,13 +62,18 @@ for (const compression of ['none', 'zstd']) {
62
62
  writeFileSync(src, original);
63
63
  const projections = { identity: { cwd: A }, rows: { workingDirectory: { val: A }, title: { val: 'Keep title' } } };
64
64
  let updated;
65
- ctx.get = (key) => key === 'sessionProjectionCache' ? { table: { async update(id, fn) { updated = fn(projections); } } } : undefined;
65
+ let cached = projections;
66
+ ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
67
+ get() { return cached; }, async put(id, value) { cached = updated = value; }
68
+ } } : undefined;
66
69
  entityA.record.sessionIds.push('session-aaa');
67
70
  entityB.failAttach = true;
68
71
  const failure = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
69
72
  assert.equal(failure.ok, false);
70
73
  assert.match(failure.error.message, /rolled back cleanly/);
71
74
  assert.deepEqual(readFileSync(src), original);
75
+ assert.equal(cached.identity.cwd, A, 'failed attach restores the title identity');
76
+ assert.deepEqual(cached.rows, projections.rows, 'rollback restores every original projection row');
72
77
  entityB.failAttach = false;
73
78
  const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
74
79
  assert.equal(moved.ok, true, JSON.stringify(moved));
@@ -1437,6 +1442,46 @@ test('mover.archived 列出归档会话:归属取自原始记账,cwd 挂错
1437
1442
  assert.equal(bbb.suggestedTitle, 'wid-b');
1438
1443
  });
1439
1444
 
1445
+ test('current per-record and live titles override legacy titles across repair, archive, preview and backups', async () => {
1446
+ apply(ctx);
1447
+ const id = 'session-aaa';
1448
+ writeFileSync(artifactPath(root, A, id), makeArtifact({ type: 'session', id, cwd: A }));
1449
+ const storage = join(root, 'storages');
1450
+ const checkpoint = join(storage, 'session_projcache', 'sessions', `${id}.json`);
1451
+ mkdirSync(dirname(checkpoint), { recursive: true });
1452
+ writeFileSync(join(storage, 'session_projcache.json'), JSON.stringify({ tables: {
1453
+ sessions: { [id]: { rows: { title: { val: 'Outdated title' } } } }
1454
+ } }), 'utf8');
1455
+ writeFileSync(checkpoint, JSON.stringify({ version: 7, record: {
1456
+ identity: { cwd: A }, rows: { title: { val: '标题保留测试' } }
1457
+ } }), 'utf8');
1458
+ entityA.record.sessionIds.push(id);
1459
+ sharedIndex.sessionPaths.set(id, A);
1460
+ ctx.workspaceRegistry.archivedSessionIds = [id];
1461
+ stashBackup(id, readFileSync(artifactPath(root, A, id)));
1462
+ const titles = async () => {
1463
+ const scan = await call('mover.scan');
1464
+ const archived = await call('mover.archived');
1465
+ const preview = await call('mover.preview', { sessionId: id, targetWorkspaceId: 'wid-b' });
1466
+ const backups = await call('mover.backups.list');
1467
+ for (const result of [scan, archived, preview, backups]) assert.equal(result.ok, true, JSON.stringify(result));
1468
+ return [scan.value.items.find((item) => item.sessionId === id).title,
1469
+ archived.value.items.find((item) => item.sessionId === id).title,
1470
+ preview.value.title, backups.value.items.find((item) => item.sessionId === id).title];
1471
+ };
1472
+ assert.deepEqual(await titles(), Array(4).fill('标题保留测试'));
1473
+ ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
1474
+ get: () => ({ rows: { title: { val: 'Latest live title' } } })
1475
+ } } : undefined;
1476
+ assert.deepEqual(await titles(), Array(4).fill('Latest live title'));
1477
+ ctx.get = () => undefined;
1478
+ writeFileSync(checkpoint, '{broken', 'utf8');
1479
+ assert.deepEqual(await titles(), Array(4).fill('Outdated title'));
1480
+ rmSync(join(storage, 'session_projcache.json'));
1481
+ const scan = await call('mover.scan');
1482
+ assert.equal(scan.value.items.find((item) => item.sessionId === id).title, '未命名会话');
1483
+ });
1484
+
1440
1485
  test('mover.unarchive 取消归档:归档集移除、账本不动、无迁移', async () => {
1441
1486
  apply(ctx);
1442
1487
  entityA.record.sessionIds.push('session-aaa');