From 05b84f44581df4fbe5da81b76460f104508c954b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 23:18:51 +0800 Subject: [PATCH] fix(tui): publish welcome acknowledgement atomically --- ...-versioned-tui-first-run-welcome.i18n.yaml | 4 +-- ...6-07-30-versioned-tui-first-run-welcome.md | 4 +-- ...7-30-versioned-tui-first-run-welcome.zh.md | 4 +-- apps/cli/src/tui-first-run-welcome.ts | 27 +++++++------------ apps/cli/tests/tui-first-run-welcome.spec.ts | 2 +- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml index 278a28bba3..c927167d68 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md -2026-07-30-versioned-tui-first-run-welcome.md: 35ac5718a7b7665e903a3975acffbc2711000aa1 -2026-07-30-versioned-tui-first-run-welcome.zh.md: f6edf354e42f2797e1227ed04a3fbc9701a04b8b +2026-07-30-versioned-tui-first-run-welcome.md: 71790b53ec5816a806952a1e9fcf2079e50af8be +2026-07-30-versioned-tui-first-run-welcome.zh.md: 132899b9485cd784344e54aed0fc933bc50a57bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md index 35ac5718a7..71790b53ec 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md @@ -14,7 +14,7 @@ The notice also needs a recognizable DeepSeek composition without copying anothe The official `dsh` launcher owns one versioned acknowledgement marker under the resolved `DSH_HOME`. It checks the immutable marker before boot, then mounts an effect-owned consumer of `ctx.tui.openOverlay()` only after the real TUI service is available. Enter is the sole acknowledgement action: the plugin creates and synchronizes the fixed per-version marker before closing. Escape and every other non-navigation input leave the overlay open; disposal or process exit before Enter writes nothing. The version is part of the marker filename, so incrementing the centrally owned notice version presents materially revised copy once without migrating or rewriting an aggregate settings document. -The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Exclusive file creation makes concurrent Enter actions idempotent without a read-modify-write race or dependency on the independently evolving settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. +The marker is launcher state rather than session persistence because eligibility spans sessions and workspaces but is scoped to one Harness home. Each Enter syncs a random same-directory file before atomically replacing the fixed marker; concurrent launches publish the same immutable fact, so same-value last-writer-wins replacement has no lost-update shape and needs no lock or dependency on the settings stack. The notice never appends a session event, injects model context, or creates a user turn; resume therefore presents it only when the same Harness home has not acknowledged that version and never replays it from the session log. The supplied official `24x24` DeepSeek SVG is committed as the visual source. Static full, compact, and minimal terminal rasters sample that exact path at decreasing square resolutions; they do not redraw the contour. Unicode `▀`/`▄`/`█` cells preserve two vertical source pixels per terminal cell, while an explicitly ASCII-only locale uses the bit-equivalent `'`/`_`/`#` fallback. ANSI styling stays outside both the SVG and editable copy: `ctx.tui` supplies a semantic `brand` role, using the official `#4D6BFE` ink when truecolor is available, standard ANSI blue otherwise, and plain text when color is disabled. The normal startup banner retains its existing gradient. @@ -32,7 +32,7 @@ Focused unit coverage pins the supplied SVG and Chinese copy hashes, version bum **Hand-draw an original whale.** A freehand silhouette can be recognizable yet still disagree with the official mark's body, internal negative space, fin, and tail. Exact-path raster sampling keeps the terminal limitation explicit and makes every tier traceable to one source asset. -**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; a version-named exclusive marker has no lost-update shape. +**Store a boolean in session events or a shared settings document.** Session state has the wrong lifetime and would pollute replay or model-visible history. An aggregate document would require cross-process read-modify-write locking for one immutable fact; an atomically replaced version marker has no lost-update shape. **Allow Escape or a later-reminder action.** Either would make dismissal indistinguishable from acknowledgement or introduce reminder policy that the notice does not need. Normal process exit remains the abort path and leaves the version unacknowledged. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md index f6edf354e4..132899b948 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.zh.md @@ -14,7 +14,7 @@ Status: implemented 官方 `dsh` 启动器在解析后的 `DSH_HOME` 下持有一个版本化确认标记。它会在启动前检查该不可变标记,并仅在真实 TUI 服务可用后,挂载一个由 effect 持有的 `ctx.tui.openOverlay()` 消费方。Enter 是唯一确认操作:插件先创建并同步固定的逐版本标记,再关闭浮层。Escape 和其他所有非导航输入都会让浮层保持打开;在按 Enter 前进行资源释放或退出进程不会写入任何内容。版本号属于标记文件名的一部分,因此只需递增集中持有的通知版本,即可让有实质修改的文案重新展示一次,无需迁移或改写聚合设置文档。 -该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。独占文件创建使并发 Enter 操作保持幂等,无需承担读取、修改、写入竞态,也不依赖独立演进的设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 +该标记属于启动器状态,而非会话持久化,因为展示资格跨越会话与 workspace,但作用域仅限一个 Harness 主目录。每次 Enter 都会先同步一个同目录随机文件,再以原子方式替换固定标记;并发启动发布的是同一个不可变事实,因此同值的最后写入者胜出不会丢失更新,也无需加锁或依赖设置栈。该通知绝不追加会话事件、注入模型上下文或创建用户轮次;因此,恢复会话只会在同一个 Harness 主目录尚未确认该版本时展示通知,也绝不会从会话日志中回放通知。 指定的官方 `24x24` DeepSeek SVG 作为视觉真源提交。静态的完整、紧凑和最小终端栅格图以逐级降低的方形分辨率对该精确路径取样,不会重新绘制轮廓。Unicode `▀`/`▄`/`█` 单元格让每个终端单元格保留两个垂直方向的源像素;明确仅支持 ASCII 的 locale 则使用位级等价的 `'`/`_`/`#` 回退。ANSI 样式与 SVG 和可编辑文案完全分离:`ctx.tui` 提供语义化 `brand` 角色,在真彩色可用时使用官方 `#4D6BFE` 色值,否则使用标准 ANSI 蓝色;禁用颜色时则使用纯文本。普通启动 banner 保留现有渐变。 @@ -32,7 +32,7 @@ Status: implemented **手绘原创鲸鱼。** 自由绘制的轮廓可以具有辨识度,却仍可能与官方标志的身体、内部负空间、鳍和尾部不一致。对精确路径进行栅格取样,可以明确呈现终端限制,并让每个分级都能追溯到同一个源资产。 -**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;按版本命名并独占创建的标记不存在更新丢失问题。 +**在会话事件或共享设置文档中存储布尔值。** 会话状态的生命周期不正确,还会污染回放或模型可见历史。聚合文档为记录一个不可变事实,需要承担跨进程读取、修改、写入锁;以原子方式替换的版本标记不存在更新丢失问题。 **允许 Escape 或稍后提醒操作。** 两者都会让取消与确认无法区分,或引入本通知并不需要的提醒策略。正常退出进程仍是中止路径,并会让该版本保持未确认状态。 diff --git a/apps/cli/src/tui-first-run-welcome.ts b/apps/cli/src/tui-first-run-welcome.ts index d69d35a30a..fa3b487766 100644 --- a/apps/cli/src/tui-first-run-welcome.ts +++ b/apps/cli/src/tui-first-run-welcome.ts @@ -7,8 +7,9 @@ * @module @deepseek-ai/dsh/tui-first-run-welcome */ -import { lstat, mkdir, open, rm } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { lstat, mkdir, open, rename, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' import type { Context } from 'cordis' import { Key, @@ -97,9 +98,9 @@ export async function hasTuiFirstRunWelcomeAcknowledgement( } /** - * Persist one version acknowledgement as an immutable exclusive marker. - * Concurrent launches race only on file creation: the winner syncs the marker, - * and every loser accepts the same already-published regular file. + * Persist one version acknowledgement by syncing a random same-directory file + * before atomically replacing the immutable marker. Concurrent launches publish + * the same fact, so same-value last-writer-wins replacement loses no state. * @param dshHome - Resolved Harness home. * @param version - Copy version being acknowledged. */ @@ -109,32 +110,24 @@ export async function acknowledgeTuiFirstRunWelcome( ): Promise { const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version) const directory = dirname(path) + const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`) await mkdir(directory, { recursive: true, mode: 0o700 }) await syncDirectory(dirname(directory)) let handle: Awaited> | undefined try { - handle = await open(path, 'wx', 0o600) - } catch (error) { - /* v8 ignore else -- the only expected race is another creator publishing this exact marker */ - if ((error as NodeJS.ErrnoException | null)?.code === 'EEXIST') { - /* v8 ignore else -- EEXIST is accepted only after the winner is verified as a regular marker */ - if (await hasTuiFirstRunWelcomeAcknowledgement(dshHome, version)) return - } - /* v8 ignore next -- unexpected filesystem failures pass through unchanged */ - throw error - } - try { + handle = await open(temp, 'wx', 0o600) await handle.sync() const created = handle handle = undefined await created.close() + await rename(temp, path) await syncDirectory(directory) } catch (error) { /* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */ try { await handle?.close() } finally { - await rm(path, { force: true }) + await rm(temp, { force: true }) } throw error /* v8 ignore stop */ diff --git a/apps/cli/tests/tui-first-run-welcome.spec.ts b/apps/cli/tests/tui-first-run-welcome.spec.ts index 783cd1b645..480b2bd07e 100644 --- a/apps/cli/tests/tui-first-run-welcome.spec.ts +++ b/apps/cli/tests/tui-first-run-welcome.spec.ts @@ -101,7 +101,7 @@ describe('TUI first-run welcome acknowledgement', () => { recursive: true, }) await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file') - await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow('is not a file') + await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow() }) it('detects only explicit ASCII-only terminal environments', () => {