@veewo/dsh-better-tasks 0.4.4 → 0.4.5

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
@@ -4,15 +4,15 @@ DSH 侧栏任务泳道:共享原生 Pin、独立任务输入、Final 预览与
4
4
 
5
5
  ## 按 DSH 版本安装
6
6
 
7
- **当前 rc.3 分发组合:DSH Desktop `0.2.2-rc.3` + Better Tasks `0.4.4`(Pre-release)。**
7
+ **当前 rc.3 分发组合:DSH Desktop `0.2.2-rc.3` + Better Tasks `0.4.5`(Pre-release)。**
8
8
 
9
- 修复子/孙 agent 仍运行时任务卡误显示 idle;选中会话标题使用 DSH 主题蓝色。安装必须取齐[rc.3 完整七包](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.4>),按[rc.3 安装说明](<docs/install-rc3.md>)处理;npm 主包不是完整运行时组合。以下 rc.2 入口保留供仍在使用 rc.2 的用户。
9
+ 已合并初始化优化:不扫描会话历史、按持久化的完整顺序恢复任务,并区分顺序恢复与旧队列导入提示;同时保留子/孙 agent 活跃状态修复和选中标题主题蓝色。安装必须取齐[rc.3 完整七包](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>),按[rc.3 安装说明](<docs/install-rc3.md>)处理;npm 主包不是完整运行时组合。以下 rc.2 入口保留供仍在使用 rc.2 的用户。
10
10
 
11
11
  0.4.1 包含新任务排序与斜杠提交修复,必须同步更新主包和本 Release 的 conversation fork,见[安装说明](<docs/install.md>)。旧 [0.4.0 组合](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.2-tasks-v0.4.0>)保留供回滚。
12
12
 
13
13
  | DSH Desktop 运行时 | Better Tasks | 完整安装入口 |
14
14
  |---|---|---|
15
- | `0.2.2-rc.3` | `0.4.4` | [dsh-v0.2.2-rc.3-tasks-v0.4.4](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.4>)(会话活跃态与选中标题修复) |
15
+ | `0.2.2-rc.3` | `0.4.5` | [dsh-v0.2.2-rc.3-tasks-v0.4.5](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>)(会话活跃态与选中标题修复) |
16
16
  | `0.2.2-rc.2` | `0.4.1` | [dsh-v0.2.2-rc.2-tasks-v0.4.1](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.2-tasks-v0.4.1>)(含输入框回退修复) |
17
17
  | `0.2.1-rc.2` | `0.3.2` | [历史插件](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/v0.3.2>) + [对应 fork 库](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/better-tasks-forks-0.2.1-rc.2-r1>) |
18
18
  | `0.2.0-rc.2` | `0.3.1` | [历史发行版](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/v0.3.1>) |
@@ -21,8 +21,8 @@ DSH 侧栏任务泳道:共享原生 Pin、独立任务输入、Final 预览与
21
21
 
22
22
  **完整安装 = 插件本体 + 同一 Release 的六个 UI fork,共 7 个 `.tgz`。** 源码 ZIP 和单独 npm 包都不是完整安装包。六个 fork 保留原生包名,属于本项目兼容性修改,并非 DeepSeek 官方发行包。
23
23
 
24
- 1. 从[当前 Release](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.2-tasks-v0.4.1>)取齐七包、`release-manifest.json`、`SHA256SUMS.txt` 和 `INSTALL.md`。
25
- 2. 按[安装说明](<docs/install.md>)校验并备份自己的 Profile,配置 `nodeLinker: isolated` 与六个精确的 `publicHoistPattern`。
24
+ 1. 按表格中的完整 DSH 版本选择 Release,取齐七包、`release-manifest.json`、`SHA256SUMS.txt` 和 `INSTALL.md`;rc.3 使用 0.4.5,rc.2 保留 0.4.1。
25
+ 2. 按对应的 [rc.3 安装说明](<docs/install-rc3.md>)或 [rc.2 安装说明](<docs/install.md>)校验并备份自己的 Profile,配置 `nodeLinker: isolated` 与六个精确的 `publicHoistPattern`。
26
26
  3. 完全退出 Desktop(含托盘),安装七包,再手动启动验收。升级与卸载见[回滚说明](<docs/rollback.md>)。
27
27
 
28
28
  本次完整 Release 包含最新 `ui-user-questions` 修复。较早的 `better-tasks-forks-0.2.2-rc.2` 是历史 fork 库,不含这次最终修复,不要与本次七包混用。
@@ -47,7 +47,7 @@ Source patches live under [src-patches](<src-patches/>), project-owned modules u
47
47
 
48
48
  **仅在 `main` 上开发和提交**;不再维护按功能、发布或 DSH 版本划分的长期分支。已有分支仅保留历史引用。支持版本只由 `main` 上不可变的 `dsh-v<DSH版本>-tasks-v<Tasks版本>` annotated tag 与完整 Release 标记,`main` 的最新提交不等于已经发布或适配完成,详见[发布规范](<docs/release.md>)。
49
49
 
50
- rc.3 的 HOC 与六个 fork 来源见[0.4.2 适配记录](<docs/dsh-0.2.2-rc.3-support.md>);0.4.4 在其基础上加入[会话活跃状态与选中标题修复](<docs/task-session-activity.md>)。精确七包哈希及启动结果随 Release 的 `validation.json` 交付。rc.2 入口保持不变,不混装旧 rc.3 产物或仅按 npm latest 安装。
50
+ rc.3 的 HOC 与六个 fork 来源见[0.4.2 适配记录](<docs/dsh-0.2.2-rc.3-support.md>);0.4.5 合并[初始化与完整顺序持久化](<docs/task-order-persistence.md>),并保留 0.4.4 的[会话活跃状态与选中标题修复](<docs/task-session-activity.md>)。精确七包哈希及启动结果随 Release 的 `validation.json` 交付。rc.2 入口保持不变,不混装旧 rc.3 产物或仅按 npm latest 安装。
51
51
 
52
52
  侧栏新增按钮、调整排版或适配新 DSH 前,先遵循[侧栏排版与适配规范](<docs/sidebar-layout.md>);数字、原生语义样式与版本合同分层维护。
53
53
 
@@ -1,6 +1,6 @@
1
- # 安装:DSH 0.2.2-rc.3 / Tasks 0.4.4
1
+ # 安装:DSH 0.2.2-rc.3 / Tasks 0.4.5
2
2
 
3
- 组合标识:`dsh-v0.2.2-rc.3-tasks-v0.4.4`。完整七包见[固定版本 Release](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.4>)。这是面向 RC 运行时的 Pre-release;npm 主包单独安装不能替代七包组合。以随包 `validation.json` 的目标版本、启动结果与清理结果为准;没有 PASS 记录的组合不得安装。不自动升级或重启真实 Desktop。
3
+ 组合标识:`dsh-v0.2.2-rc.3-tasks-v0.4.5`。完整七包见[固定版本 Release](<https://github.com/chanyuenpang/dsh-better-tasks/releases/tag/dsh-v0.2.2-rc.3-tasks-v0.4.5>)。这是面向 RC 运行时的 Pre-release;npm 主包单独安装不能替代七包组合。以随包 `validation.json` 的目标版本、启动结果与清理结果为准;没有 PASS 记录的组合不得安装。不自动升级或重启真实 Desktop。
4
4
 
5
5
  ## 1. 先确认目标运行时
6
6
 
@@ -12,7 +12,7 @@ rc.2 用户保留 [Tasks 0.4.1 七包](<https://github.com/chanyuenpang/dsh-bett
12
12
 
13
13
  从固定版本 Release 下载全部独立资产(不是 GitHub 的 Source code ZIP),必须包含以下七包,及 `release-manifest.json`、`SHA256SUMS.txt`、`INSTALL.md`、`ROLLBACK.md`、`validation.json`:
14
14
 
15
- - `veewo-dsh-better-tasks-0.4.4.tgz`
15
+ - `veewo-dsh-better-tasks-0.4.5.tgz`
16
16
  - `deepseek-ai-dsh-client-ui-layout-0.2.2-rc.3.tgz`
17
17
  - `deepseek-ai-dsh-client-ui-workspace-0.2.2-rc.3.tgz`
18
18
  - `deepseek-ai-dsh-client-ui-user-questions-0.2.2-rc.3.tgz`
@@ -25,16 +25,16 @@ rc.2 用户保留 [Tasks 0.4.1 七包](<https://github.com/chanyuenpang/dsh-bett
25
25
  ```powershell
26
26
  $ErrorActionPreference = 'Stop'
27
27
  # 替换为刚解压且已经核对的组合目录;此步不修改 DSH。
28
- $downloadDir = (Resolve-Path './dsh-v0.2.2-rc.3-tasks-v0.4.4').Path
28
+ $downloadDir = (Resolve-Path './dsh-v0.2.2-rc.3-tasks-v0.4.5').Path
29
29
  $manifest = Get-Content (Join-Path $downloadDir 'release-manifest.json') -Raw | ConvertFrom-Json
30
30
  $validation = Get-Content (Join-Path $downloadDir 'validation.json') -Raw | ConvertFrom-Json
31
- if ($manifest.tag -ne 'dsh-v0.2.2-rc.3-tasks-v0.4.4' -or
32
- $manifest.dshVersion -ne '0.2.2-rc.3' -or $manifest.tasksVersion -ne '0.4.4' -or
31
+ if ($manifest.tag -ne 'dsh-v0.2.2-rc.3-tasks-v0.4.5' -or
32
+ $manifest.dshVersion -ne '0.2.2-rc.3' -or $manifest.tasksVersion -ne '0.4.5' -or
33
33
  $validation.result -ne 'PASS' -or $validation.dshVersion -ne '0.2.2-rc.3' -or
34
34
  !$validation.cleanupComplete -or @($manifest.assets).Count -ne 7) {
35
35
  throw '候选版本、数量或启动验证不符;不要安装'
36
36
  }
37
- $expected = @('veewo-dsh-better-tasks-0.4.4.tgz')
37
+ $expected = @('veewo-dsh-better-tasks-0.4.5.tgz')
38
38
  $expected += 'layout','workspace','user-questions','input-trigger','commands','conversation' |
39
39
  ForEach-Object { "deepseek-ai-dsh-client-ui-$_-0.2.2-rc.3.tgz" }
40
40
  if (@(Compare-Object ($expected | Sort-Object) (@($manifest.assets.filename) | Sort-Object)).Count) {
@@ -87,8 +87,8 @@ if ($LASTEXITCODE -ne 0) { throw '安装失败;保持 Desktop 停止并恢复
87
87
 
88
88
  手动启动同一套 rc.3 Desktop。启动证据仅确认核心 shell/侧栏/Session/Workspace/会话可激活,没有启动错误;**不代替用户功能和外观验收**。
89
89
 
90
- 0.4.4 恢复 `sidebar.session.action` 原生 HOC 接点。没有可选 action 时新会话按钮不变;DeepSeek 会话导入仍是官方实验性可选 bundle,默认不由 Better Tasks 启用。需要时在插件页启用官方导入插件,其入口通过标准 HOC 出现在新会话行;工作区选择继续用官方 factory。创建会话权限不足时整行不渲染,Better Tasks 不拥有导入解析或 Session 持久化逻辑。
90
+ 0.4.5 恢复 `sidebar.session.action` 原生 HOC 接点。没有可选 action 时新会话按钮不变;DeepSeek 会话导入仍是官方实验性可选 bundle,默认不由 Better Tasks 启用。需要时在插件页启用官方导入插件,其入口通过标准 HOC 出现在新会话行;工作区选择继续用官方 factory。创建会话权限不足时整行不渲染,Better Tasks 不拥有导入解析或 Session 持久化逻辑。
91
91
 
92
- 0.4.4 还修复任务卡仅按主 agent 判断 idle 的问题:原生目录中已知子/孙 agent 仍在运行时保持 running;最后一个停止后恢复 idle。选中的会话标题改为官方主题蓝色,适配两种标题强调方式,卡片本身仍保留原来的状态色。并行开发的排序重构不在本次组合中,六个 fork 沿用 0.4.2 已验证字节。
92
+ 0.4.5 还修复任务卡仅按主 agent 判断 idle 的问题:原生目录中已知子/孙 agent 仍在运行时保持 running;最后一个停止后恢复 idle。选中的会话标题改为官方主题蓝色,适配两种标题强调方式,卡片本身仍保留原来的状态色。本版已完整合入初始化和顺序持久化优化:恢复已保存的完整任务顺序,不再扫描 Session 目录/历史或计算首工作时间;新增、拖动、移除均持久化顺序,初始化显示“正在恢复任务顺序”,只有真实旧队列导入时才显示合并提示。旧字段保持可读、旧队列保持只读,既有旧数据不删除。详见[完整顺序合同](<task-order-persistence.md>)。六个 fork 仍沿用 0.4.2 已验证字节。
93
93
 
94
94
  请自行验收上述状态和选中标题、原生新会话、任务 Pin/排序、独立输入、问答和斜杠提交;遇到异常保留那次 Console 堆栈。回滚见随包 `ROLLBACK.md` 或[回滚说明](<rollback.md>):先退出、恢复匹配 rc.3 的完整七包与配置;如退回 rc.2,必须把 DSH 运行时和原 rc.2 七包作为整体恢复,不能只降主包。会话、原生 Pin、排序、外观和设置数据不删除。
package/docs/release.md CHANGED
@@ -3,8 +3,8 @@
3
3
  ## 当前公开组合与 tag 规则
4
4
 
5
5
  - DSH Desktop 运行时:**0.2.2-rc.3**。
6
- - Better Tasks npm/package 版本:**0.4.4**。
7
- - Git tag / GitHub Release:**`dsh-v0.2.2-rc.3-tasks-v0.4.4`**(Pre-release)。
6
+ - Better Tasks npm/package 版本:**0.4.5**。
7
+ - Git tag / GitHub Release:**`dsh-v0.2.2-rc.3-tasks-v0.4.5`**(Pre-release)。
8
8
  - 安装入口:[rc.3 安装说明](<install-rc3.md>);旧 rc.2 / Tasks 0.4.1 的[安装说明](<install.md>)和资产保持不变,兼容表见 [README](<../README.md>)。
9
9
 
10
10
  今后统一使用 `dsh-v<DSH版本>-tasks-v<Tasks版本>`。DSH 版本写完整,保留 rc 后缀;Tasks 版本仍由插件自己的 manifest 管理。同一 Tasks 源码可适配多条 DSH 线,但每条线都要有独立验证的组合 Release。已发布的 tag 与资产 URL 不移动、不覆盖;内容变化应升 Tasks 版本并创建新组合 tag。面向 RC 运行时的本次组合标记为 GitHub Pre-release,不依赖全局 Latest 作为安装入口。
@@ -19,6 +19,8 @@
19
19
 
20
20
  ## 本次组合与历史内容
21
21
 
22
+ 0.4.5 / rc.3:在 0.4.4 的 subagent 活跃态和选中标题修复上,完整合入初始化优化、唯一完整顺序持久化及恢复/旧队列导入提示分离。排序不再调用 listSessions/listEvents 或读取历史首工作时间;旧 native-order schema 保持可读,旧队列只读,导入仍有副作用前栅栏与中断不重试规则。完整七包独立通过本轮启动门禁后交付;六个 fork 不改字节。0.4.4 tag 与资产保留,不覆盖。
23
+
22
24
  0.4.4 / rc.3:在已验证的 0.4.2 上修复子/孙 agent 活跃时任务卡误显示 idle,并用官方主题蓝色标识当前选中会话标题。主包更新,六个 fork 保留 0.4.2 的已验证字节;完整七包有独立最终组合启动证据。0.4.3 是已安装的本地先行候选,不覆盖它的本地包或既有 tag。并行开发的排序重构明确不在这次发布中。GitHub 分发完整七包;npm 只发布 `@veewo/dsh-better-tasks` 主包,不向上游 `@deepseek-ai` scope 发布 fork,且不表示 npm 单包可独立安装。
23
25
 
24
26
  0.4.2 / rc.3:主包声明 root/hoc 的 `sidebar.session.action`,把原生新会话内容作为第三参 children 交给渲染器;整行受创建权限保护,可选动作不增加服务硬依赖。六个 fork 按精确 rc.3 基线从最新 overlay/patch 重建,保留 workspace picker factory、问答回退、原生 Pin、导航与真实仲裁 Promise。候选使用新的完整七包、清单与独立 rc.3 Desktop 启动证据,不沿用旧 rc.3 库的接受结论。最终结果以随包 `validation.json` 为准。
package/docs/rollback.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # 卸载 / 回滚 Better Tasks
2
2
 
3
- ## rc.3 / Tasks 0.4.4 回滚
3
+ ## rc.3 / Tasks 0.4.5 回滚
4
+
5
+ 0.4.5 合并初始化与完整顺序持久化优化;回到 0.4.4 应恢复该版本的完整七包与配置,保留现有 Session、原生 Pin 和存储。旧版不会自动恢复从未落盘的历史显示顺序;不要删除排序域或旧队列来“重置”升级。
4
6
 
5
7
  [rc.3 候选](<install-rc3.md>)必须整体保留主包与六个 rc.3 fork。安装失败时保持 Desktop 退出,恢复安装前 Profile 配置与同一运行时的完整七包;仅把主包降到 rc.2 的 0.4.1 不是合法回滚。若要退回 rc.2,须先恢复相应官方运行时,再恢复原 rc.2 / Tasks 0.4.1 七包,不能跨线混用。以下移除插件与六个 fork 的通用步骤同样适用;不删除任何 Session/Pin/排序/设置数据。
6
8
 
@@ -0,0 +1,25 @@
1
+ # 完整任务顺序持久化(Tasks 0.4.5)
2
+
3
+ 任务成员仍由官方 Workspace Pin 唯一拥有。Better Tasks 的 native-order v1 记录只维护完整顺序 sessionIds、已提交 revision 和一次性旧队列导入 journal,不拥有第二份成员集合。
4
+
5
+ - 新 Pin 的成员插入完整顺序顶部并直接触发保存,不必先打开任务视图。同一快照批量新增保留快照顺序,连续新增事件后者在前。
6
+ - 手动拖动保存完整顺序;取消 Pin/归档保存删除后的顺序。隐藏空白、子代理、冷目录项仍占持久位置,客户端过滤不能删除它们。
7
+ - 启动按已保存 sessionIds 原序恢复,补入当前官方新增成员,剔除已移除成员。原生 Pin 数组的纯重排、消息、标题、工作时间及目录加载不会重排已有成员;已 Pin 空白转为非空也不二次提顶。
8
+ - 排序不读取 listSessions/listEvents 或会话历史,不再计算首工作时间。旧 manualSessionIds/manualOrderAt/时间映射保持 schema 可读,但不再参与排序,新提交不再写这些字段。无法恢复旧版从未持久化的临时显示顺序。
9
+ - 启动存储打开期间与写入期间保留有序成员变化,尤其取消 Pin 再重新 Pin 同一 ID 必须作为重新插入,不只采用最后一个成员集合。
10
+
11
+ ## 提交、失败与恢复
12
+
13
+ 所有顺序写入由同一 Host 队列串行执行。成功持久化后才推进 revision;拖动通过 expectedRevision CAS 拒绝过时请求。存储失败不改变官方 Pin,不推进已提交 revision,不伪报拖动成功。成员仍即时可见或移除,未保存的完整顺序保留在内存并显式告警,拖动暂停;后续 GET、焦点刷新或成员事件可重试,成功后清除瞬时错误。客户端刷新有界,不无限轮询。
14
+
15
+ 官方成员域和排序域没有公共原子事务:两者提交之间若进程崩溃,重启会从官方成员补齐缺项,但不能重建崩溃窗口内从未落盘的全部插入先后。不通过私有事务、回滚 Pin 或历史扫描猜测补救。
16
+
17
+ ## 旧队列与提示
18
+
19
+ 旧 better_tasks_pin_queue 保持只读。仅缺少 native-order 时尝试一次导入;保留 seed 顺序、先持久化 attempted 栅栏再调用官方 Pin、用户 touch/archive 优先、失败 partial、中断 uncertain 且不自动重试的规则。没有待导入成员时直接 done,不伪造 running。
20
+
21
+ 初始化显示“正在恢复任务顺序”;只有真正旧队列 running 才显示“正在合并已有置顶任务”。这不是所有外部存储或 Pin I/O 都有固定耗时上限的承诺。
22
+
23
+ ## 验证边界
24
+
25
+ 单元/合同覆盖旧记录原序恢复、查询零调用、未打开任务页即保存、插入/拖动/删除后重启、并发和重入、失败告警与恢复、旧队列安全导入及提示分离。真实 profile 修改前仍须精确候选隔离冷启动,关闭 Cindy、隔离全部存储、确认核心 shell 激活并清理;启动门禁不代替用户功能验收,不自动重启真实 Desktop。
package/lib/client.js CHANGED
@@ -70,7 +70,9 @@ window.__ModuleLoader__.load({
70
70
  const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
71
71
  const armMigrationRefresh = () => {
72
72
  stopTimer()
73
- if (!started || disposed || !['pending', 'initializing', 'running'].includes(migration?.status)) { polls = 0; return }
73
+ const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
74
+ const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
75
+ if (!started || disposed || !(restoring || saving)) { polls = 0; return }
74
76
  if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
75
77
  polls++
76
78
  timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
@@ -118,17 +120,6 @@ window.__ModuleLoader__.load({
118
120
  }
119
121
  binding = next; generation++
120
122
  let signature, closed = false
121
- let visible = new Set()
122
- const observeStartedTasks = () => {
123
- if (closed || disposed || !nativeReady()) return
124
- const sessions = next.sessions?.getSnapshot?.()
125
- if (sessions?.phase !== 'ready') return
126
- const nextVisible = new Set(native.pinnedSessionIds.filter(id => sessions.byId?.[id]?.blank === false))
127
- const appeared = [...nextVisible].some(id => !visible.has(id))
128
- visible = nextVisible
129
- // A pre-existing Pin can become a new task without a workspace change.
130
- if (appeared && started) void refresh().catch(() => {})
131
- }
132
123
  const observe = () => {
133
124
  if (closed || disposed) return
134
125
  const snapshot = next.source.getSnapshot()
@@ -136,14 +127,12 @@ window.__ModuleLoader__.load({
136
127
  const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
137
128
  const changed = signature !== value
138
129
  signature = value; publish()
139
- observeStartedTasks()
140
130
  if (changed && nativeReady() && started) void refresh().catch(() => {})
141
131
  }
142
132
  const off = next.source.subscribe(observe)
143
- const offSessions = typeof next.sessions?.subscribe === 'function' ? next.sessions.subscribe(observeStartedTasks) : () => {}
144
133
  disconnect = () => {
145
134
  if (closed) return
146
- closed = true; off(); offSessions(); stopTimer(); generation++
135
+ closed = true; off(); stopTimer(); generation++
147
136
  if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
148
137
  }
149
138
  observe()
@@ -174,7 +163,7 @@ window.__ModuleLoader__.load({
174
163
  actionError = null; countPending(sessionId, 1)
175
164
  return enqueue(async () => {
176
165
  if (!loaded || !writable) await readOrder()
177
- if (!writable) throw new Error('Task order is not ready; pin migration may still be running')
166
+ if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
178
167
  for (let attempt = 0; attempt < 2; attempt++) {
179
168
  const queue = source.getSnapshot().queue
180
169
  if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')
@@ -856,7 +845,7 @@ window.__ModuleLoader__.load({
856
845
  * browser's localStorage is an append log, so records from earlier builds stay
857
846
  * forever and only payloads whose revision matches the delivered build count.
858
847
  */
859
- const BUILD_REVISION = '4f0d9b3936354d5636750c6b21756510b82d9a276f1aab5d8d701e9bccfef3dc'
848
+ const BUILD_REVISION = 'da11927b17db72c2a819ac8fc74933039cf592c8ba30b2b9efc45149c0b9c0ca'
860
849
  /** Render probe: on by default, so a delivered build can prove the column rendered. */
861
850
  const RENDER_PROBE = true
862
851
  /** Per-render payload; rewritten on every invocation. */
@@ -964,6 +953,7 @@ window.__ModuleLoader__.load({
964
953
  'tasks.todoInProgress': '进行中',
965
954
  'tasks.todoPending': '待处理',
966
955
  'tasks.empty': '还没有置顶会话',
956
+ 'tasks.orderRestoring': '正在恢复任务顺序…',
967
957
  'tasks.pinMigration': '正在合并已有置顶任务…',
968
958
  'tasks.expandGoal': '展开 Goal',
969
959
  'tasks.collapseGoal': '收起 Goal',
@@ -1068,6 +1058,7 @@ window.__ModuleLoader__.load({
1068
1058
  'tasks.todoInProgress': 'In progress',
1069
1059
  'tasks.todoPending': 'Pending',
1070
1060
  'tasks.empty': 'No pinned sessions yet',
1061
+ 'tasks.orderRestoring': 'Restoring task order…',
1071
1062
  'tasks.pinMigration': 'Importing existing pinned tasks…',
1072
1063
  'tasks.expandGoal': 'Expand Goal',
1073
1064
  'tasks.collapseGoal': 'Collapse Goal',
@@ -2209,7 +2200,8 @@ window.__ModuleLoader__.load({
2209
2200
  style: { '--dbt-projection-font-size': `${preferences.detailFontSize}px` },
2210
2201
  },
2211
2202
  appearanceState.status === 'unavailable' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.appearanceUnavailable')) : null,
2212
- ['initializing', 'running'].includes(taskPins.migration) ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
2203
+ taskPins.migration === 'initializing' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.orderRestoring')) : null,
2204
+ taskPins.migration === 'running' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
2213
2205
  taskPins.error !== null || moveError !== '' ? element('div', { className: 'dbt-task-warning', role: 'status' }, moveError || taskPins.error) : null,
2214
2206
  actionWarning === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, actionWarning),
2215
2207
  tasks.length === 0 ? element('div', { className: 'dbt-task-empty' }, t('tasks.empty')) :
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veewo/dsh-better-tasks",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "DSH Desktop sidebar replacement with native pinned-task cards and independent task input surfaces.",
6
6
  "publishConfig": {
@@ -30,6 +30,7 @@
30
30
  "docs/sidebar-layout.md",
31
31
  "docs/rc2-slash-submit-catch.md",
32
32
  "docs/task-session-activity.md",
33
+ "docs/task-order-persistence.md",
33
34
  "docs/release.md",
34
35
  "docs/rollback.md"
35
36
  ],
package/src/client.js CHANGED
@@ -139,6 +139,7 @@ const dictionaries = {
139
139
  'tasks.todoInProgress': '进行中',
140
140
  'tasks.todoPending': '待处理',
141
141
  'tasks.empty': '还没有置顶会话',
142
+ 'tasks.orderRestoring': '正在恢复任务顺序…',
142
143
  'tasks.pinMigration': '正在合并已有置顶任务…',
143
144
  'tasks.expandGoal': '展开 Goal',
144
145
  'tasks.collapseGoal': '收起 Goal',
@@ -243,6 +244,7 @@ const dictionaries = {
243
244
  'tasks.todoInProgress': 'In progress',
244
245
  'tasks.todoPending': 'Pending',
245
246
  'tasks.empty': 'No pinned sessions yet',
247
+ 'tasks.orderRestoring': 'Restoring task order…',
246
248
  'tasks.pinMigration': 'Importing existing pinned tasks…',
247
249
  'tasks.expandGoal': 'Expand Goal',
248
250
  'tasks.collapseGoal': 'Collapse Goal',
@@ -1384,7 +1386,8 @@ function TaskSwimlane({ useSessions, useWorkspaces, useSessionStatus, useTaskCon
1384
1386
  style: { '--dbt-projection-font-size': `${preferences.detailFontSize}px` },
1385
1387
  },
1386
1388
  appearanceState.status === 'unavailable' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.appearanceUnavailable')) : null,
1387
- ['initializing', 'running'].includes(taskPins.migration) ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
1389
+ taskPins.migration === 'initializing' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.orderRestoring')) : null,
1390
+ taskPins.migration === 'running' ? element('div', { className: 'dbt-task-warning', role: 'status' }, t('tasks.pinMigration')) : null,
1388
1391
  taskPins.error !== null || moveError !== '' ? element('div', { className: 'dbt-task-warning', role: 'status' }, moveError || taskPins.error) : null,
1389
1392
  actionWarning === '' ? null : element('div', { className: 'dbt-task-warning', role: 'alert' }, actionWarning),
1390
1393
  tasks.length === 0 ? element('div', { className: 'dbt-task-empty' }, t('tasks.empty')) :
package/src/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from './pin-model.mjs'
10
10
  import { installProjectAppearance } from './project-appearance.mjs'
11
11
  import { createFinalMessageHttpHandler } from './final-message.mjs'
12
- import { createNativePinHost, taskFirstWorkAt } from './native-pin-host.mjs'
12
+ import { createNativePinHost } from './native-pin-host.mjs'
13
13
  import { createTaskExecutionProjection } from './task-execution.mjs'
14
14
 
15
15
  export const name = 'dsh-better-tasks'
@@ -75,10 +75,11 @@ export const pinQueueDomain = defineDomain({
75
75
  export const nativePinOrderDomain = defineDomain({
76
76
  name: 'better_tasks_native_order', version: 1,
77
77
  tables: { state: domainTable(pinQueueRecord.extend({
78
- // Absent on older auto-seeded records; only an explicit drag writes this.
78
+ // Read compatibility only. sessionIds is the sole order; new writes omit
79
+ // the former manual subset and time-derived sorting metadata.
79
80
  manualSessionIds: z.array(z.string().min(1)).refine((ids) => new Set(ids).size === ids.length).optional(),
80
81
  manualOrderAt: z.number().int().nonnegative().optional(),
81
- // Task admission time may differ from an older/reused Session header.
82
+ // Old admission/work caches are accepted, never read to determine order.
82
83
  taskCreatedAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
83
84
  taskFirstWorkAtBySession: z.record(z.string().min(1), z.number().int().nonnegative()).optional(),
84
85
  migration: z.object({
@@ -194,9 +195,6 @@ export async function apply(ctx) {
194
195
  }
195
196
  const controller = createNativePinHost({
196
197
  registry: ctx.workspaceRegistry,
197
- listSessions: (signal) => ctx.sessionQuery.listSessions(signal),
198
- readTaskStartedAt: async (header, signal) => typeof ctx.sessionQuery.listEvents === 'function'
199
- ? taskFirstWorkAt(await ctx.sessionQuery.listEvents(header.id, signal)) : undefined,
200
198
  openOrder: () => open(nativePinOrderDomain, 'state'),
201
199
  openLegacy: () => open(pinQueueDomain, 'queues'),
202
200
  subscribe: listener => ctx.on('domain/changed', listener),
@@ -1,131 +1,75 @@
1
- // Official Workspace pins own membership. This controller owns rank and a sealed,
2
- // at-most-once legacy import journal only; it never compensates native operations.
1
+ // Official Workspace pins own membership. This controller owns one complete order
2
+ // and a sealed, at-most-once legacy import journal; it never compensates native Pin.
3
3
  const NATIVE_ORDER_KEY = 'global'
4
4
  const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index])
5
5
  const ownedIds = value => {
6
6
  if (!Array.isArray(value) || value.some(id => typeof id !== 'string' || id === '') || new Set(value).size !== value.length) throw new Error('Invalid native pin/order identities')
7
7
  return [...value]
8
8
  }
9
- const projectIds = (rank, pins) => {
10
- const pinned = new Set(pins), ranked = new Set(rank)
11
- return [...rank.filter(id => pinned.has(id)), ...pins.filter(id => !ranked.has(id))]
12
- }
13
9
  const messageOf = error => String(error?.message ?? error).slice(0, 1000)
14
10
  const changedIds = (a, b) => {
15
11
  const before = new Set(a), after = new Set(b)
16
12
  return [...a.filter(id => !after.has(id)), ...b.filter(id => !before.has(id))]
17
13
  }
14
+ // New members join the complete sequence immediately. Pure native array reorders
15
+ // cannot move existing members; neither clocks nor Session content are involved.
16
+ const mergeOrder = (sequence, pins, reserved = new Set()) => {
17
+ const members = new Set(pins), ranked = new Set(sequence)
18
+ return [...pins.filter(id => !ranked.has(id)), ...sequence.filter(id => members.has(id) || reserved.has(id))]
19
+ }
18
20
  export class NativePinError extends Error {
19
21
  constructor(code, message, status = 400) { super(message); this.code = code; this.status = status }
20
22
  }
21
23
 
22
- /** First admitted work, not empty-file preparation time or later activity. */
23
- export function taskFirstWorkAt(events) {
24
- for (const event of events) {
25
- if (['agent/inbox/spliced', 'user/message', 'turn/start'].includes(event.type)
26
- && Number.isSafeInteger(event.time) && event.time >= 0) return event.time
27
- }
28
- return undefined
29
- }
30
-
31
24
  /** open* returns {table,close}; subscribe returns an event unsubscriber. */
32
- export function createNativePinHost({ registry, openOrder, openLegacy, subscribe, listSessions, readTaskStartedAt, now = () => new Date().toISOString(), taskCreationNow = Date.now }) {
33
- let pins = [], archived = [], rank = [], revision = 0, updatedAt = now(), epoch = 0
34
- let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true
35
- let tail = Promise.resolve(), initialization, migration, reconcileQueued = false
36
- const handles = new Set(), touched = new Set(), warnings = [], warningSet = new Set()
25
+ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe, now = () => new Date().toISOString() }) {
26
+ let pins = [], archived = [], rank = [], sequence = [], revision = 0, updatedAt = now(), epoch = 0
27
+ let phase = 'initializing', record, table, disposed = false, started = false, nativeValid = true, hydrated = false
28
+ let tail = Promise.resolve(), initialization, migration, reconcileQueued = false, persistenceError = null
29
+ const snapshots = [], handles = new Set(), touched = new Set(), reservedIds = new Set(), warnings = [], warningSet = new Set()
37
30
  const warn = error => { const text = messageOf(error); if (!warningSet.has(text)) { warningSet.add(text); warnings.push(text); if (warnings.length > 30) warnings.shift() } }
38
31
  const enqueue = work => {
39
32
  const next = tail.then(() => { if (disposed) throw new NativePinError('better-tasks/closed', 'Task ordering is closed', 503); return work() })
40
33
  tail = next.catch(() => {})
41
34
  return next
42
35
  }
43
- const createdAt = new Map(), firstWorkAt = new Map(), headers = new Map(), admittedHere = new Set(), lifetime = new AbortController()
44
- let metadataDirty = false
45
- const newestFirst = (ids) => [...ids].sort((a, b) => {
46
- const left = createdAt.get(a), right = createdAt.get(b)
47
- if (left === undefined || right === undefined) return left === right ? 0 : left === undefined ? 1 : -1
48
- return right - left || (a < b ? -1 : a > b ? 1 : 0)
49
- })
50
- const orderedIds = () => {
51
- const ids = projectIds(rank, pins)
52
- if (record?.manualSessionIds === undefined) return newestFirst(ids)
53
- const members = new Set(ids), manual = new Set(record.manualSessionIds)
54
- // New tasks lead; an existing manual sequence never follows message activity.
55
- return [...newestFirst(ids.filter(id => !manual.has(id))), ...record.manualSessionIds.filter(id => members.has(id))]
56
- }
57
- async function refreshCreatedAt() {
58
- const before = orderedIds()
59
- try {
60
- if (typeof listSessions === 'function' && pins.some(id => !headers.has(id))) {
61
- const rows = await listSessions(lifetime.signal)
62
- if (disposed) return
63
- for (const { header } of rows) if (pins.includes(header.id)) {
64
- headers.set(header.id, header)
65
- if (!createdAt.has(header.id) && Number.isSafeInteger(header.createdAt) && header.createdAt >= 0) createdAt.set(header.id, header.createdAt)
66
- }
67
- }
68
- for (const id of [...pins]) {
69
- const header = headers.get(id)
70
- // Forks inherit parent work; their own creation header remains the key.
71
- if (!header || header.isSeeded === true) continue
72
- let timestamp = firstWorkAt.get(id)
73
- if (timestamp === undefined && typeof readTaskStartedAt === 'function') {
74
- try { timestamp = await readTaskStartedAt(header, lifetime.signal) }
75
- catch (error) { if (!disposed) warn(error); continue }
76
- if (disposed) return
77
- if (!pins.includes(id) || !Number.isSafeInteger(timestamp) || timestamp < 0) continue
78
- firstWorkAt.set(id, timestamp); metadataDirty = true
79
- // Only work born after the manual decision leads. A delayed birth
80
- // read must not override an explicit drag made after that work began.
81
- const manualAt = record?.manualOrderAt ?? Date.parse(record?.updatedAt)
82
- const newerThanManual = Number.isFinite(manualAt) ? timestamp > manualAt : phase === 'done' || phase === 'partial'
83
- if (newerThanManual && record?.manualSessionIds?.includes(id)) {
84
- record = { ...record, manualSessionIds: record.manualSessionIds.filter(value => value !== id) }
85
- }
86
- }
87
- if (timestamp !== undefined) createdAt.set(id, Math.max(createdAt.get(id) ?? 0, timestamp))
88
- }
89
- for (const map of [createdAt, firstWorkAt, headers]) for (const id of map.keys()) if (!pins.includes(id)) map.delete(id)
90
- if (!sameIds(before, orderedIds())) { revision++; updatedAt = now() }
91
- } catch (error) { if (!disposed) warn(error) }
36
+ const orderedIds = () => sequence.filter(id => pins.includes(id))
37
+ const orderReady = () => !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial')
38
+ const response = () => {
39
+ const errors = [...warnings, ...(persistenceError ? [persistenceError] : [])]
40
+ return {
41
+ queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
42
+ ...(errors.length ? { warnings: errors } : {}),
43
+ migration: { status: phase },
44
+ writable: orderReady() && !persistenceError && sameIds(rank, orderedIds()),
45
+ }
92
46
  }
93
- const response = () => ({
94
- queue: { schemaVersion: 1, revision, sessionIds: orderedIds(), updatedAt },
95
- ...(warnings.length === 0 ? {} : { warnings: [...warnings] }),
96
- migration: { status: phase }, writable: !disposed && nativeValid && table !== undefined && (phase === 'done' || phase === 'partial'),
97
- })
47
+ const reservations = () => new Set([...reservedIds].filter(id => !touched.has(id) && !archived.includes(id)))
98
48
  const nativeSnapshot = (nextPins, nextArchived, observe = true) => {
99
- const p = ownedIds(nextPins), a = ownedIds(nextArchived)
49
+ const a = ownedIds(nextArchived), excluded = new Set(a), p = ownedIds(nextPins).filter(id => !excluded.has(id))
100
50
  nativeValid = true
101
51
  if (sameIds(p, pins) && sameIds(a, archived)) return
52
+ const membershipChanged = changedIds(pins, p).length > 0 || changedIds(archived, a).length > 0
102
53
  if (observe) for (const id of [...changedIds(pins, p), ...changedIds(archived, a)]) touched.add(id)
103
- const previous = projectIds(rank, pins)
104
- // A newly admitted task goes first even if the native action reuses an old
105
- // blank Session or its header is not in the metadata catalog yet. Only new
106
- // membership counts; message/status updates and native pin-array order do not.
107
- if (observe && phase !== 'running') {
108
- const previousMembers = new Set(pins)
109
- let admittedAt = taskCreationNow()
110
- if (Number.isSafeInteger(admittedAt) && admittedAt >= 0) {
111
- // A same-millisecond click must still precede the prior admission. This
112
- // logical ordering key is never shown as a replacement Session date.
113
- for (const timestamp of createdAt.values()) if (timestamp >= admittedAt && timestamp < Number.MAX_SAFE_INTEGER) admittedAt = timestamp + 1
114
- for (const id of p) if (!previousMembers.has(id)) { createdAt.set(id, admittedAt); admittedHere.add(id) }
115
- }
116
- }
117
- pins = p; archived = a; epoch++
118
- if (!sameIds(previous, projectIds(rank, pins))) { revision++; updatedAt = now() }
54
+ pins = p; archived = a
55
+ if (!observe) { sequence = [...p]; return }
56
+ if (membershipChanged) epoch++
57
+ // Retain all transitions while opening storage, including Unpin -> Pin of
58
+ // the same id. One latest snapshot would lose its re-insertion position.
59
+ if (!hydrated) snapshots.push([...p])
60
+ sequence = mergeOrder(sequence, pins, reservations())
61
+ if (membershipChanged) start()
119
62
  scheduleReconcile()
120
63
  }
121
64
  function scheduleReconcile() {
122
- if (!disposed && nativeValid && started && table !== undefined && (phase === 'done' || phase === 'partial') && !reconcileQueued) {
123
- reconcileQueued = true
124
- void enqueue(async () => { reconcileQueued = false; await refreshCreatedAt(); await reconcile() }).catch(error => { reconcileQueued = false; warn(error) })
125
- }
65
+ if (!orderReady() || reconcileQueued) return
66
+ reconcileQueued = true
67
+ void enqueue(async () => {
68
+ try { await reconcile() } finally { reconcileQueued = false }
69
+ }).catch(() => {})
126
70
  }
127
- // Storage emits before WorkspaceRegistry's private cache is assigned. Payload
128
- // ownership is essential: never synchronously read registry getters here.
71
+ // Storage emits before WorkspaceRegistry's private cache is assigned. Always
72
+ // consume the owned event payload, never registry getters inside this callback.
129
73
  const off = subscribe(change => {
130
74
  if (disposed || change?.domain !== 'workspace' || change.table !== '' || change.key !== '' || change.operation !== 'put') return
131
75
  try { nativeSnapshot(change.value?.pinnedSessionIds, change.value?.archivedSessionIds) }
@@ -133,27 +77,31 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
133
77
  })
134
78
  try { nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds, false) }
135
79
  catch (error) { nativeValid = false; warn(error); phase = 'error' }
136
- const initiallyPinned = new Set(pins)
80
+ const initialPins = [...pins], initiallyPinned = new Set(pins)
137
81
 
138
82
  async function save(next) {
139
83
  const nextRank = ownedIds(next.sessionIds)
140
- const nextRevision = Math.max(revision, record?.revision ?? 0) + (sameIds(rank, nextRank) ? 0 : 1)
141
- const value = { ...next, schemaVersion: 1, revision: nextRevision, sessionIds: nextRank, updatedAt: now(),
142
- taskCreatedAtBySession: Object.fromEntries([...createdAt].filter(([id]) => pins.includes(id))),
143
- taskFirstWorkAtBySession: Object.fromEntries([...firstWorkAt].filter(([id]) => pins.includes(id))),
144
- ...(next.manualSessionIds === undefined ? {} : { manualSessionIds: next.manualSessionIds.filter(id => pins.includes(id)) }) }
145
- // A singleton owner plus this controller's tail owns all writes to this domain.
146
- if (record === undefined) await table.put(NATIVE_ORDER_KEY, value)
147
- else await table.update(NATIVE_ORDER_KEY, () => value)
148
- metadataDirty = false
149
- record = value; rank = [...value.sessionIds]; revision = Math.max(revision, value.revision); updatedAt = value.updatedAt
84
+ // Legacy fields remain readable in the v1 schema, but are no longer writers
85
+ // or authorities. Every new commit contains just one complete order.
86
+ const { manualSessionIds, manualOrderAt, taskCreatedAtBySession, taskFirstWorkAtBySession, ...base } = next
87
+ const value = { ...base, schemaVersion: 1, revision: revision + 1, sessionIds: nextRank, updatedAt: now() }
88
+ try {
89
+ if (record === undefined) await table.put(NATIVE_ORDER_KEY, value)
90
+ else await table.update(NATIVE_ORDER_KEY, () => value)
91
+ } catch (error) {
92
+ persistenceError = 'Task order was not saved; official Pin membership is unchanged: ' + messageOf(error)
93
+ throw error
94
+ }
95
+ // Never advance a committed revision/rank before storage acknowledges. The
96
+ // live sequence may already include newer events received during this write.
97
+ record = value; rank = [...value.sessionIds]; revision = value.revision; updatedAt = value.updatedAt
98
+ persistenceError = null
150
99
  return value
151
100
  }
152
101
  async function reconcile() {
153
- if (record === undefined || phase === 'initializing' || phase === 'running') return
154
- const ids = orderedIds()
155
- if (sameIds(ids, rank) && record.revision === revision && !metadataDirty) return
156
- await save({ ...record, sessionIds: ids })
102
+ if (!orderReady() || record === undefined) return
103
+ while (!sameIds(rank, orderedIds())) await save({ ...record, sessionIds: orderedIds() })
104
+ persistenceError = null
157
105
  }
158
106
  async function open(factory) {
159
107
  const handle = await factory()
@@ -162,7 +110,7 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
162
110
  return handle
163
111
  }
164
112
  async function close(handle) { if (!handles.delete(handle)) return; await handle.close() }
165
- const replaceEntry = (id, patch) => ({ ...record, migration: { ...record.migration,
113
+ const replaceEntry = (id, patch) => ({ ...record, sessionIds: [...sequence], migration: { ...record.migration,
166
114
  entries: record.migration.entries.map(entry => entry.sessionId === id ? { ...entry, ...patch } : entry) } })
167
115
 
168
116
  async function runMigration() {
@@ -190,7 +138,7 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
190
138
  } catch (error) {
191
139
  const known = error?.name === 'WorkspaceUnknownSessionError' || error?.name === 'WorkspaceArchivedSessionPinError'
192
140
  outcome = { status: known ? 'skipped' : 'failed', detail: messageOf(error) }
193
- warn(`Legacy task ${entry.sessionId} was not imported: ${outcome.detail}`)
141
+ warn('Legacy task ' + entry.sessionId + ' was not imported: ' + outcome.detail)
194
142
  }
195
143
  await save(replaceEntry(entry.sessionId, outcome))
196
144
  })
@@ -199,38 +147,38 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
199
147
  await enqueue(async () => {
200
148
  const partial = record.migration.entries.some(entry => entry.status === 'failed' || entry.status === 'attempted')
201
149
  const status = partial ? 'partial' : 'done'
202
- // Until now legacy seed rank was retained even for not-yet-imported ids.
203
- await refreshCreatedAt()
204
- await save({ ...record, sessionIds: orderedIds(), migration: { ...record.migration, status } })
150
+ reservedIds.clear()
151
+ sequence = orderedIds() // Remove only the unimported reservations.
152
+ await save({ ...record, sessionIds: [...sequence], migration: { ...record.migration, status } })
205
153
  phase = status
154
+ await reconcile()
206
155
  })
207
156
  }
157
+ function hydrate(base, reserved = new Set()) {
158
+ for (const id of reserved) reservedIds.add(id)
159
+ sequence = mergeOrder(base, initialPins, reserved)
160
+ for (const snapshot of snapshots) sequence = mergeOrder(sequence, snapshot, reserved)
161
+ snapshots.length = 0; hydrated = true
162
+ }
208
163
  async function initialize() {
209
164
  if (!nativeValid) throw new Error('Native pin state is unavailable')
210
165
  const order = await open(openOrder); table = order.table
211
166
  const stored = table.get(NATIVE_ORDER_KEY)
212
167
  if (stored !== undefined) {
213
- rank = ownedIds(stored.sessionIds); record = stored; revision = Math.max(revision, stored.revision); updatedAt = stored.updatedAt
214
- for (const [id, timestamp] of Object.entries(stored.taskCreatedAtBySession ?? {})) {
215
- if (pins.includes(id) && !admittedHere.has(id) && Number.isSafeInteger(timestamp) && timestamp >= 0) createdAt.set(id, timestamp)
216
- }
217
- for (const [id, timestamp] of Object.entries(stored.taskFirstWorkAtBySession ?? {})) {
218
- if (pins.includes(id) && Number.isSafeInteger(timestamp) && timestamp >= 0) firstWorkAt.set(id, timestamp)
219
- }
220
- await refreshCreatedAt()
221
- for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn(`Legacy task ${entry.sessionId}: ${entry.detail}`)
168
+ rank = ownedIds(stored.sessionIds); record = stored; revision = stored.revision; updatedAt = stored.updatedAt
169
+ hydrate(rank)
170
+ for (const entry of stored.migration.entries) if (entry.detail && (entry.status === 'failed' || entry.status === 'uncertain')) warn('Legacy task ' + entry.sessionId + ': ' + entry.detail)
222
171
  if (stored.migration.status === 'running') {
223
172
  warn('Legacy pin migration was interrupted; unconfirmed entries will not be retried automatically. The original queue is preserved.')
224
173
  await enqueue(async () => {
225
- await save({ ...record, sessionIds: projectIds(rank, pins), migration: { ...record.migration, status: 'partial', entries: record.migration.entries.map(entry => entry.status === 'pending' || entry.status === 'attempted' ? { ...entry, status: 'uncertain', detail: 'Interrupted import; use the official Pin control to recover explicitly' } : entry) } })
174
+ await save({ ...record, sessionIds: orderedIds(), migration: { ...record.migration, status: 'partial', entries: record.migration.entries.map(entry => entry.status === 'pending' || entry.status === 'attempted' ? { ...entry, status: 'uncertain', detail: 'Interrupted import; use the official Pin control to recover explicitly' } : entry) } })
226
175
  })
227
176
  phase = 'partial'
228
177
  } else phase = stored.migration.status
229
178
  await enqueue(reconcile)
230
179
  return
231
180
  }
232
- await refreshCreatedAt()
233
- // The legacy unit is opened with its unchanged v1 schema, read, then closed.
181
+ // The legacy unit is opened unchanged, read and closed; never erase its data.
234
182
  const legacy = await open(openLegacy)
235
183
  let seed
236
184
  try { seed = legacy.table.get(NATIVE_ORDER_KEY); if (seed !== undefined) ownedIds(seed.sessionIds) }
@@ -240,17 +188,25 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
240
188
  status: alreadyNative.has(sessionId) || touched.has(sessionId) || archived.includes(sessionId) ? 'skipped' : 'pending',
241
189
  ...(alreadyNative.has(sessionId) ? { detail: 'Already officially pinned; never an import target' } : {}),
242
190
  }))
191
+ const pending = new Set(entries.filter(entry => entry.status === 'pending').map(entry => entry.sessionId))
192
+ hydrate([...seedIds, ...initialPins.filter(id => !seedIds.includes(id))], pending)
193
+ // No old import work means no fake migration phase or extra journal writes.
194
+ const status = pending.size ? 'running' : 'done'
243
195
  await enqueue(async () => {
244
- await save({ schemaVersion: 1, revision, sessionIds: [...seedIds, ...pins.filter(id => !seedIds.includes(id))], updatedAt: now(),
245
- migration: { status: 'running', sourceRevision: seed?.revision ?? null, entries } })
246
- phase = 'running'
196
+ await save({ sessionIds: [...sequence], migration: { status, sourceRevision: seed?.revision ?? null, entries } })
197
+ phase = status
198
+ if (status === 'done') await reconcile()
247
199
  })
248
- migration = runMigration().catch(error => { if (!disposed) { warn(`Legacy migration stopped: ${messageOf(error)}. The original queue is preserved.`); phase = 'error' } })
200
+ if (status === 'running') migration = runMigration().catch(error => { if (!disposed) { warn('Legacy migration stopped: ' + messageOf(error) + '. The original queue is preserved.'); phase = 'error' } })
249
201
  }
250
- const start = () => {
202
+ function start() {
251
203
  if (started || disposed) return
252
204
  started = true; phase = 'initializing'
253
- initialization = initialize().catch(error => { if (!disposed) { phase = 'error'; warn(`Task order initialization failed: ${messageOf(error)}`) } })
205
+ initialization = initialize().catch(error => {
206
+ // A loaded terminal journal with a dirty sequence can retry a failed save.
207
+ // Opening/fencing/import failures remain terminal and must not replay Pin.
208
+ if (!disposed && !orderReady()) { phase = 'error'; warn('Task order initialization failed: ' + messageOf(error)) }
209
+ })
254
210
  }
255
211
  function requireCommand(command) {
256
212
  if (command === null || typeof command !== 'object' || typeof command.sessionId !== 'string' || command.sessionId === '') throw new NativePinError('better-tasks/invalid-request', 'A Session identity is required')
@@ -261,25 +217,20 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
261
217
  current: response,
262
218
  async mutate(command) {
263
219
  requireCommand(command)
264
- // Explicit compatibility intent also counts when the native call will be
265
- // an idempotent no-op and therefore emit no workspace change event.
266
220
  if (command.action !== 'move') touched.add(command.sessionId)
267
221
  start()
268
- // Do not queue a move behind an in-flight migration I/O operation merely
269
- // to return the already-known initializing/readonly state afterwards.
270
- if (command.action === 'move' && !response().writable) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is initializing, migrating or unavailable; official Pin remains usable', 503)
222
+ if (command.action === 'move' && !orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task order is initializing, importing or unavailable; official Pin remains usable', 503)
271
223
  return enqueue(async () => {
272
224
  if (command.action !== 'move') {
273
- // Compatibility for old clients; all new UI uses official public controls.
274
225
  const observedEpoch = epoch
275
226
  if (command.action === 'unpin') await registry.unpinSession(command.sessionId)
276
227
  else await registry.pinSession(command.sessionId)
277
228
  if (observedEpoch === epoch) nativeSnapshot(registry.pinnedSessionIds, registry.archivedSessionIds)
278
- try { await reconcile() } catch (error) { warn(`Native pin change succeeded, but task order could not be saved: ${messageOf(error)}`) }
229
+ try { await reconcile() } catch { /* save exposes persistenceError; never undo the native result. */ }
279
230
  return response()
280
231
  }
281
232
  if (!Number.isSafeInteger(command.expectedRevision) || command.expectedRevision < 0) throw new NativePinError('better-tasks/invalid-request', 'expectedRevision must be a non-negative safe integer')
282
- if (!response().writable) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is initializing, migrating or unavailable; official Pin remains usable', 503)
233
+ if (!orderReady()) throw new NativePinError('better-tasks/order-unavailable', 'Task ordering is unavailable; official Pin remains usable', 503)
283
234
  await reconcile()
284
235
  if (command.expectedRevision !== revision) throw new NativePinError('better-tasks/revision-conflict', 'Task order changed', 409)
285
236
  const ids = orderedIds(), before = command.beforeSessionId ?? null
@@ -290,18 +241,19 @@ export function createNativePinHost({ registry, openOrder, openLegacy, subscribe
290
241
  next.splice(before === null ? next.length : next.indexOf(before), 0, command.sessionId)
291
242
  if (sameIds(next, ids)) return response()
292
243
  const observedEpoch = epoch
293
- await save({ ...record, sessionIds: next, manualSessionIds: next, manualOrderAt: taskCreationNow() })
244
+ await save({ ...record, sessionIds: next })
294
245
  if (observedEpoch !== epoch) {
295
246
  await reconcile()
296
247
  throw new NativePinError('better-tasks/revision-conflict', 'Official membership changed while saving task order', 409)
297
248
  }
249
+ sequence = next
298
250
  return response()
299
251
  })
300
252
  },
301
253
  async settled() { await initialization; await migration; await tail },
302
254
  async dispose() {
303
255
  if (disposed) return
304
- disposed = true; lifetime.abort(); off?.()
256
+ disposed = true; off?.()
305
257
  await initialization; await migration; await tail
306
258
  await Promise.all([...handles].map(close))
307
259
  },
@@ -67,7 +67,9 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
67
67
  const stopTimer = () => { if (timer !== undefined) { cancel(timer); timer = undefined } }
68
68
  const armMigrationRefresh = () => {
69
69
  stopTimer()
70
- if (!started || disposed || !['pending', 'initializing', 'running'].includes(migration?.status)) { polls = 0; return }
70
+ const restoring = ['pending', 'initializing', 'running'].includes(migration?.status)
71
+ const saving = loaded && !writable && ['done', 'partial'].includes(migration?.status)
72
+ if (!started || disposed || !(restoring || saving)) { polls = 0; return }
71
73
  if (polls >= 60) return // Native follow/focus still refresh; never poll indefinitely after a stuck migration.
72
74
  polls++
73
75
  timer = schedule(() => { timer = undefined; void refresh().catch(() => {}) }, 1000)
@@ -115,17 +117,6 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
115
117
  }
116
118
  binding = next; generation++
117
119
  let signature, closed = false
118
- let visible = new Set()
119
- const observeStartedTasks = () => {
120
- if (closed || disposed || !nativeReady()) return
121
- const sessions = next.sessions?.getSnapshot?.()
122
- if (sessions?.phase !== 'ready') return
123
- const nextVisible = new Set(native.pinnedSessionIds.filter(id => sessions.byId?.[id]?.blank === false))
124
- const appeared = [...nextVisible].some(id => !visible.has(id))
125
- visible = nextVisible
126
- // A pre-existing Pin can become a new task without a workspace change.
127
- if (appeared && started) void refresh().catch(() => {})
128
- }
129
120
  const observe = () => {
130
121
  if (closed || disposed) return
131
122
  const snapshot = next.source.getSnapshot()
@@ -133,14 +124,12 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
133
124
  const value = JSON.stringify([native.pinnedSessionIds, native.archivedSessionIds, native.phase, native.state])
134
125
  const changed = signature !== value
135
126
  signature = value; publish()
136
- observeStartedTasks()
137
127
  if (changed && nativeReady() && started) void refresh().catch(() => {})
138
128
  }
139
129
  const off = next.source.subscribe(observe)
140
- const offSessions = typeof next.sessions?.subscribe === 'function' ? next.sessions.subscribe(observeStartedTasks) : () => {}
141
130
  disconnect = () => {
142
131
  if (closed) return
143
- closed = true; off(); offSessions(); stopTimer(); generation++
132
+ closed = true; off(); stopTimer(); generation++
144
133
  if (binding === next) { binding = undefined; native = undefined; loaded = false; writable = false; publish() }
145
134
  }
146
135
  observe()
@@ -171,7 +160,7 @@ export function createTaskPinsClient({ request = pinRequest, schedule = setTimeo
171
160
  actionError = null; countPending(sessionId, 1)
172
161
  return enqueue(async () => {
173
162
  if (!loaded || !writable) await readOrder()
174
- if (!writable) throw new Error('Task order is not ready; pin migration may still be running')
163
+ if (!writable) throw new Error('Task order is not ready or has not been saved; official Pin remains usable')
175
164
  for (let attempt = 0; attempt < 2; attempt++) {
176
165
  const queue = source.getSnapshot().queue
177
166
  if (!queue.sessionIds.includes(sessionId) || beforeSessionId != null && !queue.sessionIds.includes(beforeSessionId)) throw new Error('Task is no longer pinned')