From 4f0ac5bbfc917be0e676fece13e5ec43ba1ff738 Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 27 Sep 2026 13:55:34 +0800 Subject: [PATCH 1/2] docs: propose readable worktree branch lifecycle Model: gpt-6-sol --- .../2026-09-27-readable-worktree-branches.md | 42 +++++++++++++ ...026-09-27-readable-worktree-branches.zh.md | 35 +++++++++++ specs/readable-worktree-branches.md | 63 +++++++++++++++++++ specs/readable-worktree-branches.zh.md | 49 +++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md create mode 100644 .agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md create mode 100644 specs/readable-worktree-branches.md create mode 100644 specs/readable-worktree-branches.zh.md diff --git a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md new file mode 100644 index 000000000..c82acfed3 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md @@ -0,0 +1,42 @@ +# Readable names for new worktree branches + +Status: proposed +Translation: current + +[中文](2026-09-27-readable-worktree-branches.zh.md) + +## Abstract + +Issue #289 reports that opaque worktree branch names make session work hard to +identify and publish. The proposed path keeps the initial ID branch until the +first accepted title arrives, then renames only a new, unchanged and unpublished +branch while reconciling Git with Session metadata. This avoids putting draft +prompt text in preparation RPC, but requires a small durable rename state and +crash-recovery check before it can be implemented safely. + +## Decision and alternatives + +Passing `SessionConfig.title` to the branch allocator alone is insufficient: +the title is optional, speculative worktree creation precedes the durable +Session, and Provider-generated titles arrive after initialization. Deriving a +name from initial prompt text would cross the preparation contract that keeps +draft text out of its RPC and could put sensitive text in a public branch name. +Renaming every existing `lody/` branch on title change would affect old +Sessions and branches that users may have pushed. A one-time new-session marker +with an expected branch and HEAD provides a narrow eligibility boundary. + +The proposal in [the draft Spec](../../../../specs/readable-worktree-branches.md) +defers naming until a title is accepted, then fails closed if the branch or +publication state has changed. It requires an actual Git ref and SessionMeta +reconciliation path; a slug helper by itself has no user-visible value. + +## Evidence and status + +- [Issue #289](https://github.com/LodyAI/Lody/issues/289) has no maintainer + discussion or alternate implementation at the time of this proposal. +- Current [worktree creation](../../../../apps/cli/src/session/worktree/worktree-manager.ts) + allocates before title generation; [ACP title Spec](../../../../specs/acp-session-titles.md) + documents the later Provider event. +- No code behavior or tests are claimed for this design-only reference. The + publication and recovery rules remain open for maintainer review. No upstream + PR exists; the fork PR context-handoff requirement remains separate. diff --git a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md new file mode 100644 index 000000000..c20549b15 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md @@ -0,0 +1,35 @@ +# 新建 worktree 分支的可读名称 + +Status: proposed +Translation: current + +[English](2026-09-27-readable-worktree-branches.md) + +## 摘要 + +#289 指出 worktree 的随机分支名让会话工作难以辨认和发布。建议先保留 ID 分支, +待首个有效标题到达后,仅对新的、未变化且未发布的分支改名,并对账 Git 与会话元数据。 +这样不必在准备阶段的 RPC 中传递草稿提示词,但安全实现仍需要持久化的改名状态和 +崩溃恢复检查。 + +## 决定与替代方案 + +只把 `SessionConfig.title` 传给分支分配器不足以解决问题:标题可缺省,推测性 +worktree 在持久会话之前建立,而 Provider 标题在初始化后才到达。由初始提示词 +直接派生名称会越过准备 RPC 不传草稿文本的约定,还可能把敏感信息放进公开分支名。 +按标题变化改名所有现有 `lody/` 分支则会影响旧会话和可能已经推送的分支。 +记录初始分支与 HEAD 的新会话一次性标记,才有明确的适用边界。 + +[Spec 草案](../../../../specs/readable-worktree-branches.zh.md)建议等标题被接受后, +只在分支及发布状态未变化时改名;真实 Git 引用与 `SessionMeta` 必须有恢复对账 +路径。单独的短名转换辅助函数不会产生用户可见价值。 + +## 证据与状态 + +- 撰写时 [需求 #289](https://github.com/LodyAI/Lody/issues/289)没有维护者讨论或 + 对应的其他实现。 +- 当前 [worktree 创建](../../../../apps/cli/src/session/worktree/worktree-manager.ts) + 早于标题生成;[ACP 标题 Spec](../../../../specs/acp-session-titles.zh.md)记录了较晚的 + Provider 事件。 +- 本参考只有设计,没有实现代码或通过的行为测试。发布状态及恢复规则仍待维护者 + 评审。没有上游 PR;fork PR 的会话来源要求是独立门槛。 diff --git a/specs/readable-worktree-branches.md b/specs/readable-worktree-branches.md new file mode 100644 index 000000000..4eaca2216 --- /dev/null +++ b/specs/readable-worktree-branches.md @@ -0,0 +1,63 @@ +# Readable branches for new worktree Sessions + +Status: draft +Translation: current + +[中文](readable-worktree-branches.zh.md) + +A newly created worktree Session currently starts on an opaque branch such as +`lody/`. A title often arrives only after the worktree and Provider +have started. For new Sessions, Lody should replace that temporary name with a +bounded, readable name derived from the first accepted Session title when doing +so cannot disrupt work already published or a branch chosen by the user. + +## Responsibilities + +- Worktree creation remains independent of title generation. Speculative + preparation must not receive draft prompt text or a prompt-derived name. The + initial branch remains the current session-ID fallback. +- At creation or adoption, the owning machine records a durable, one-time + rename intent: the exact initial branch and HEAD. Existing Sessions have no + such intent and must never be renamed by this feature. +- When the first accepted title is available, the owning machine derives a + lowercase ASCII slug from that title, with a short session-ID suffix and a + bounded branch length. Empty or unusable slugs retain the initial branch. + Later title changes do not rename the branch again. +- Before renaming, verify that the worktree is still on the recorded branch, + its HEAD has not moved, and the branch has no upstream, published remote ref, + or linked pull request. Uncertain publication state keeps the original name. + Allocate a unique Git-valid name under the repository lock, including local + ref namespace conflicts, and never attach to another Session's branch. +- Git and Session metadata cannot be changed atomically. Persist the intent + before the Git operation; after a successful rename, publish the actual + branch to `SessionMeta.branchName`. Recovery reconciles an unfinished intent + against the worktree's actual branch before restore or cleanup uses metadata. + A failed attempt keeps the original branch and does not retry indefinitely. + +This changes only new worktree Sessions. Direct local checkouts, child Tabs, +restored worktrees, and existing Session branches retain their current behavior. +The title is never copied from the initial prompt merely to name a branch: a +branch may later be pushed to a public remote. + +## Acceptance cases + +Repository-backed tests must exercise both speculative adoption and cold +creation, Provider-generated and explicit titles, duplicate titles, non-ASCII +and empty titles, a user-renamed branch, moved HEAD, a published branch, and +recovery after Git rename but before metadata publication. The visible Session +branch, actual Git HEAD ref, and later restore target must agree in each case. + +## Evidence and open design review + +- [Request #289](https://github.com/LodyAI/Lody/issues/289) asks for readable, + unique new-session branches and an empty/non-ASCII fallback. +- [Worktree allocation](../apps/cli/src/session/worktree/worktree-manager.ts) + creates the branch before the Session runs; [speculative preparation](../apps/cli/src/session/worktree/speculative-worktree.ts) + may do so before the durable Session is claimed. +- [Title ownership](acp-session-titles.md) allows Provider titles only after + initialization; [branch observation](workspace-branch-state.md) and + [worktree lifecycle](session-worktree-lifecycle.md) already depend on the + real Git branch matching durable Session metadata. + +The exact publication check and crash-recovery transition need implementation +tests before this draft is treated as an approved behavior guarantee. diff --git a/specs/readable-worktree-branches.zh.md b/specs/readable-worktree-branches.zh.md new file mode 100644 index 000000000..e3d6d5894 --- /dev/null +++ b/specs/readable-worktree-branches.zh.md @@ -0,0 +1,49 @@ +# 新建 worktree 会话的可读分支名 + +Status: draft +Translation: current + +[English](readable-worktree-branches.md) + +新建 worktree 会话目前先使用 `lody/<会话 ID>` 一类不可读分支名。标题通常在 +worktree 和 Provider 启动后才出现。对于新会话,Lody 应在不会影响已发布的工作或 +用户自行选择的分支时,以首次被接受的会话标题生成有长度限制的可读分支名。 + +## 职责 + +- worktree 创建不等待标题生成。推测性准备阶段不得接收草稿提示词或由提示词 + 派生的名称;初始分支仍使用现有会话 ID 兜底名。 +- 创建或接管 worktree 时,所属机器持久记录一次性改名意图,包括准确的初始分支 + 与 HEAD。旧会话没有该标记,绝不因本功能自动改名。 +- 首个有效标题到达后,所属机器将标题转换为小写 ASCII 短名,加上简短会话 ID + 后缀,并限制分支总长度。短名为空或不可用时保留初始分支。后续标题变化不再改名。 +- 改名前确认 worktree 仍在记录的分支、HEAD 未移动,且分支没有上游跟踪、已发布 + 的远端引用或关联 PR。发布状态不明时保留原名。在仓库锁内分配唯一且符合 Git + 规则的名称,避开本地引用命名空间冲突,绝不接入其他会话的分支。 +- Git 与会话元数据不能原子修改。先持久化改名意图;Git 改名成功后,把实际分支 + 写入 `SessionMeta.branchName`。恢复流程在使用元数据恢复或清理 worktree 前,按 + 实际 Git 分支对账未完成意图。失败时保留原名,不无限重试。 + +本行为只影响新建 worktree 会话。直接使用本地目录的会话、子 Tab、恢复中的 +worktree 和旧会话分支保持原行为。不得仅为了命名分支而从初始提示词复制文本; +分支以后可能被推送到公开远端。 + +## 验收场景 + +仓库自带测试需覆盖推测性准备后的接管与普通创建、Provider 生成和显式标题、 +重复标题、非 ASCII 与空标题、用户已改名分支、HEAD 已移动、分支已发布,以及 +Git 改名成功但元数据尚未写入时的恢复。每种场景都要核实 UI 中的会话分支、 +真实 Git HEAD 引用和之后的恢复目标一致。 + +## 证据及待评审设计 + +- [需求 #289](https://github.com/LodyAI/Lody/issues/289) 要求新会话分支可读、唯一, + 且空标题或非 ASCII 标题有兜底。 +- [worktree 分配](../apps/cli/src/session/worktree/worktree-manager.ts) 在会话运行前建 + 分支;[推测性准备](../apps/cli/src/session/worktree/speculative-worktree.ts) 甚至可能 + 在持久会话被接管前建分支。 +- [标题归属](acp-session-titles.md)允许 Provider 在初始化后提供标题; + [分支观察](workspace-branch-state.md)和 [worktree 生命周期](session-worktree-lifecycle.md) + 已依赖真实 Git 分支与持久会话元数据一致。 + +发布状态检查和崩溃恢复的准确转换仍需实现测试,不能把本草案称为已批准的行为保证。 From 1555aa42360eec95f877aa18b93b7743d775ddda Mon Sep 17 00:00:00 2001 From: Dante Date: Sun, 27 Sep 2026 14:49:14 +0800 Subject: [PATCH 2/2] feat: name new worktree branches from accepted titles Keep an initial ID branch until a title is accepted, then rename only when the recorded branch and HEAD are unchanged and unpublished. Persist a one-time intent and reconcile Git state after interruption. Model: gpt-6-sol --- .../2026-09-27-readable-worktree-branches.md | 18 ++- ...026-09-27-readable-worktree-branches.zh.md | 11 +- apps/cli/src/lib/loro/doc.ts | 1 + apps/cli/src/lib/message-handler.ts | 2 + apps/cli/src/session/session-manager.test.ts | 77 ++++++++++- apps/cli/src/session/session-manager.ts | 130 ++++++++++++++++++ apps/cli/src/session/worktree/AGENTS.md | 3 + .../src/session/worktree/worktree-manager.ts | 87 ++++++++++++ .../worktree/worktree-title-branch.test.ts | 128 +++++++++++++++++ .../session/worktree/worktree-title-branch.ts | 22 +++ apps/cli/tests/message-handler-title.test.ts | 15 +- packages/shared/src/schema.ts | 7 + specs/readable-worktree-branches.md | 9 +- specs/readable-worktree-branches.zh.md | 6 +- 14 files changed, 499 insertions(+), 17 deletions(-) create mode 100644 apps/cli/src/session/worktree/worktree-title-branch.test.ts create mode 100644 apps/cli/src/session/worktree/worktree-title-branch.ts diff --git a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md index c82acfed3..1a08c7191 100644 --- a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md +++ b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.md @@ -10,9 +10,10 @@ Translation: current Issue #289 reports that opaque worktree branch names make session work hard to identify and publish. The proposed path keeps the initial ID branch until the first accepted title arrives, then renames only a new, unchanged and unpublished -branch while reconciling Git with Session metadata. This avoids putting draft -prompt text in preparation RPC, but requires a small durable rename state and -crash-recovery check before it can be implemented safely. +branch while reconciling Git with Session metadata. The reference implementation +now has a durable one-time rename intent and checks the current Git branch, +HEAD and remote publication before acting. The design remains proposed while +the issue's UX feedback and contribution handoff are outstanding. ## Decision and alternatives @@ -37,6 +38,11 @@ reconciliation path; a slug helper by itself has no user-visible value. - Current [worktree creation](../../../../apps/cli/src/session/worktree/worktree-manager.ts) allocates before title generation; [ACP title Spec](../../../../specs/acp-session-titles.md) documents the later Provider event. -- No code behavior or tests are claimed for this design-only reference. The - publication and recovery rules remain open for maintainer review. No upstream - PR exists; the fork PR context-handoff requirement remains separate. +- The reference branch now includes a bounded ASCII slug, a one-time intent in + `SessionMeta`, and a Git rename guarded by the repository lock. A focused + local Git test covers an untouched branch, moved HEAD, a published ref, + collision suffix, and an unusable title. A SessionManager test covers both + speculative adoption eligibility and recovery after Git rename. Title callback + ordering still needs integration coverage before this is ready for an upstream PR. +- No upstream PR exists. The fork PR context-handoff requirement remains a + separate entry gate; this note does not claim user publication or approval. diff --git a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md index c20549b15..b3badecc3 100644 --- a/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md +++ b/.agents/notes/proposed/feature/2026-09-27-readable-worktree-branches.zh.md @@ -9,8 +9,8 @@ Translation: current #289 指出 worktree 的随机分支名让会话工作难以辨认和发布。建议先保留 ID 分支, 待首个有效标题到达后,仅对新的、未变化且未发布的分支改名,并对账 Git 与会话元数据。 -这样不必在准备阶段的 RPC 中传递草稿提示词,但安全实现仍需要持久化的改名状态和 -崩溃恢复检查。 +参考实现已记录持久的一次性改名意图,并在操作前核实 Git 分支、HEAD 和远端发布 +状态。issue 的体验反馈与投稿来源交接尚未完成,设计仍标为 proposed。 ## 决定与替代方案 @@ -31,5 +31,8 @@ worktree 在持久会话之前建立,而 Provider 标题在初始化后才到 - 当前 [worktree 创建](../../../../apps/cli/src/session/worktree/worktree-manager.ts) 早于标题生成;[ACP 标题 Spec](../../../../specs/acp-session-titles.zh.md)记录了较晚的 Provider 事件。 -- 本参考只有设计,没有实现代码或通过的行为测试。发布状态及恢复规则仍待维护者 - 评审。没有上游 PR;fork PR 的会话来源要求是独立门槛。 +- 参考分支现有受限 ASCII 短名、`SessionMeta` 中的一次性意图和仓库锁保护的 Git + 改名。本地 Git 定向测试覆盖未改动分支、HEAD 已移动、已发布引用、碰撞后缀及 + 无法转换的标题。SessionManager 测试覆盖推测性接管资格与 Git 改名后的恢复; + 标题回调时序仍需集成验证,才能准备上游 PR。 +- 没有上游 PR。fork PR 的公开会话交接是独立门槛;本文不声称用户已发布或批准。 diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 8368cdbfc..9bba5cc47 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -334,6 +334,7 @@ export interface LoroDocumentManagerOptions { export type LoroRepoPersistReason = | 'session-local-base-ref' + | 'session-title-branch-rename-intent' | 'session-fork-prepare' | 'session-fork-commit' | 'session-fork-rollback' diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 351a221f9..62fedf4ce 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -8705,6 +8705,7 @@ export class MessageHandler { ]); if (applied) { this.logger.debug(`[${sessionId}] Session title updated from agent: ${sanitized}`); + await this.sessionManager.maybeRenameWorktreeAfterTitle(sessionId, sanitized); } } catch (error) { this.logger.debug( @@ -8841,6 +8842,7 @@ export class MessageHandler { ); } else { this.logger.debug(`[${sessionId}] Session title stored in metadata: ${title}`); + await this.sessionManager.maybeRenameWorktreeAfterTitle(sessionId, title); } // The generated value is still safe to reuse for a branch name even when a // concurrent user rename prevented it from being written as the session title. diff --git a/apps/cli/src/session/session-manager.test.ts b/apps/cli/src/session/session-manager.test.ts index 9a706a83d..939d3fba9 100644 --- a/apps/cli/src/session/session-manager.test.ts +++ b/apps/cli/src/session/session-manager.test.ts @@ -74,6 +74,7 @@ const createTestCloudPort = () => createLocalCloudPort({ identity: { userId: 'user-1' }, workspaces: [] }); type FakeSessionDoc = { + roomId: string; getMetaState: ReturnType Promise>>; setRepoFullName: ReturnType Promise>>; setBaseBranch: ReturnType Promise>>; @@ -82,6 +83,7 @@ type FakeSessionDoc = { }; const createSessionDoc = (meta?: SessionMeta): FakeSessionDoc => ({ + roomId: 'session-test-room', getMetaState: vi.fn(async () => meta), setRepoFullName: vi.fn(async () => undefined), setBaseBranch: vi.fn(async () => undefined), @@ -761,12 +763,13 @@ describe('SessionManager worktree setup', () => { const repoId = deriveRepoIdFromLocalProjectPath(originalRootPath); const logger = createLogger(); const docs = new Map(); + const workspaceDocument = createWorkspaceDocument(docs); const manager = new SessionManager( logger, 'token', 'machine-1' as MachineId, 'workspace-1' as WorkspaceId, - createWorkspaceDocument(docs), + workspaceDocument, { sessionSandboxFactory: async () => createNoopSessionSandbox(), cloudPort: createTestCloudPort(), @@ -818,10 +821,82 @@ describe('SessionManager worktree setup', () => { expect(session.getWorkdir()).toBe(preparedWorktree.info.hostPath); expect(runWorktreeSetup).toHaveBeenCalledTimes(1); + expect(workspaceDocument.repo.upsertDocMeta).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + titleBranchRename: expect.objectContaining({ + initialBranch: preparedWorktree.info.branch, + initialHead: preparedWorktree.info.headSha, + state: 'pending', + }), + }) + ); await preparedWorktree.dispose(); expect(existsSync(preparedWorktree.info.hostPath)).toBe(true); }); + it('reconciles a Git rename interrupted before branch metadata was published', async () => { + const sourceDir = createLocalRepo(tempHome); + const originalRootPath = normalizeLocalProjectRootPath(sourceDir); + const sessionId = 'recover-title-branch' as SessionId; + const repoId = deriveRepoIdFromLocalProjectPath(originalRootPath); + const logger = createLogger(); + const worktreeManager = getWorktreeManager({ + repoId, + source: { kind: 'local-shared', originalRootPath }, + logger, + }); + await worktreeManager.ensureRepo(); + const worktree = await worktreeManager.createWorktree(sessionId, 'main'); + const renamedBranch = 'lody/recovered-title-recovert'; + runGit(worktree.hostPath, ['branch', '-m', worktree.branch, renamedBranch]); + + let meta = { + branchName: worktree.branch, + titleBranchRename: { + initialBranch: worktree.branch, + initialHead: worktree.headSha!, + state: 'attempted', + targetBranch: renamedBranch, + }, + } as SessionMeta; + const doc = createSessionDoc(); + doc.getMetaState.mockImplementation(async () => meta); + doc.setBranchName.mockImplementation(async (branchName) => { + meta = { ...meta, branchName }; + }); + const docs = new Map([[sessionId, doc]]); + const workspaceDocument = createWorkspaceDocument(docs); + const upsert = workspaceDocument.repo.upsertDocMeta as ReturnType; + upsert.mockImplementation(async (_roomId: unknown, patch: Partial) => { + meta = { ...meta, ...patch }; + }); + const manager = new SessionManager( + logger, + 'token', + 'machine-1' as MachineId, + 'workspace-1' as WorkspaceId, + workspaceDocument, + { + sessionSandboxFactory: async () => createNoopSessionSandbox(), + cloudPort: createTestCloudPort(), + } + ); + const config = createSessionConfig({ sessionId, workdir: sourceDir }); + ( + manager as unknown as { titleWorktreeManagers: Map } + ).titleWorktreeManagers.set(sessionId, { + manager: worktreeManager, + source: { kind: 'local-shared', originalRootPath }, + config, + }); + + await manager.maybeRenameWorktreeAfterTitle(sessionId, 'Recovered title'); + expect(meta.branchName).toBe(renamedBranch); + expect(meta.titleBranchRename?.state).toBe('finished'); + expect(runGit(worktree.hostPath, ['branch', '--show-current'])).toBe(renamedBranch); + }); + it('retries setup after a durable create fails with a prepared worktree', async () => { const sourceDir = createLocalRepo(tempHome); const originalRootPath = normalizeLocalProjectRootPath(sourceDir); diff --git a/apps/cli/src/session/session-manager.ts b/apps/cli/src/session/session-manager.ts index c8bfa3ca4..708ec0faa 100644 --- a/apps/cli/src/session/session-manager.ts +++ b/apps/cli/src/session/session-manager.ts @@ -33,6 +33,8 @@ import { buildSessionLaunchConfig, getMachineFlockDocId, getSessionRoomId, + isSessionDocRoomId, + SESSION_DOC_PREFIX, normalizeSessionPreparationRunConfigForDedup, isLoroRepoDocDeleted, type SessionLaunchConfig, @@ -40,6 +42,7 @@ import { type McpServerId, } from '@lody/shared'; import { Logger } from '@/utils/logger'; +import type { RepoWatchHandle } from 'loro-repo'; import { SessionConfig, SessionOutputEvent, SessionErrorEvent, SessionExitEvent } from './types'; import { LoroDocumentManager } from '../lib/loro/doc'; import { @@ -456,6 +459,12 @@ export class SessionManager extends EventEmitter { private githubTokenManager: CloudGithubTokenManager | null = null; private gitCredentialBroker: GitCredentialBroker | null = null; private readonly sessions = new Map(); + private readonly titleWorktreeManagers = new Map< + SessionId, + { manager: WorktreeManager; source: WorktreeManagerSource; config: SessionConfig } + >(); + private readonly titleBranchRenameInFlight = new Map>(); + private titleBranchMetadataWatch: RepoWatchHandle | null = null; /** Per-instance listener teardown for `detachSession`; see `registerSessionEvents`. */ private readonly sessionEventDetachers = new WeakMap void>(); private readonly pendingSessionCreates = new Map>(); @@ -836,6 +845,30 @@ export class SessionManager extends EventEmitter { } async initialize(): Promise { + this.titleBranchMetadataWatch?.unsubscribe(); + this.titleBranchMetadataWatch = this.workspaceDocument.repo.watch( + (event) => { + if (event.kind !== 'doc-metadata' || !isSessionDocRoomId(event.docId)) return; + const sessionId = event.docId.slice(SESSION_DOC_PREFIX.length) as SessionId; + if (!this.titleWorktreeManagers.has(sessionId)) return; + void (async () => { + const doc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); + const meta = await doc.getMetaState(); + if ( + meta?.title && + (meta.titleSource === 'user' || meta.titleSource === 'generated') && + meta.titleBranchRename?.state !== 'finished' + ) { + await this.maybeRenameWorktreeAfterTitle(sessionId, meta.title); + } + })().catch((error: unknown) => { + this.logger.debug( + `[${sessionId}] Failed to reconcile title branch: ${formatErrorMessage(error)}` + ); + }); + }, + { kinds: ['doc-metadata'] } + ); const recoveryGeneration = ++this.preparationRecoveryGeneration; this.detachPreparationRecovery?.(); this.detachPreparationRecovery = this.workspaceDocument.onMetaRoomSynced((reason) => { @@ -2094,6 +2127,27 @@ export class SessionManager extends EventEmitter { if (!config.deferWorktreeMetaPersistence) { await sessionDoc.setBranchName(worktreeInfo.branch); await sessionDoc.setIsWorktree(true); + const currentMeta = await sessionDoc.getMetaState(); + if ( + !config.resume && + !config.restoreBranchName && + (!worktreeAlreadyExisted || (preparedWorktreeUsable && speculativeClaim === 'claimed')) && + worktreeInfo.headSha && + !currentMeta?.titleBranchRename + ) { + await this.workspaceDocument.repo.upsertDocMeta(sessionDoc.roomId, { + titleBranchRename: { + initialBranch: worktreeInfo.branch, + initialHead: worktreeInfo.headSha, + state: 'pending', + }, + } as Partial); + } + this.titleWorktreeManagers.set(config.sessionId!, { + manager: worktreeManager, + source: worktreeTarget.target.source, + config, + }); } workdir = worktreeInfo.hostPath; this.logger.debug(`[${config.sessionId}] Using worktree as workdir: ${workdir}`); @@ -2157,10 +2211,82 @@ export class SessionManager extends EventEmitter { } this.registerSessionEvents(session); this.sessions.set(config.sessionId!, session); + if (worktreeTarget && !config.deferWorktreeMetaPersistence) { + const titleMeta = await sessionDoc.getMetaState(); + if ( + titleMeta?.title && + (titleMeta.titleSource === 'user' || titleMeta.titleSource === 'generated') + ) { + await this.maybeRenameWorktreeAfterTitle(config.sessionId!, titleMeta.title); + } + } await this.rebalanceSessionSandboxes(); return session; } + /** Apply the first accepted title to a newly allocated worktree branch. */ + async maybeRenameWorktreeAfterTitle(sessionId: SessionId, title: string): Promise { + const target = this.titleWorktreeManagers.get(sessionId); + if (!target) return; + const { manager } = target; + const previous = this.titleBranchRenameInFlight.get(sessionId); + if (previous) return previous; + const work = (async () => { + const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); + const meta = await sessionDoc.getMetaState(); + const intent = meta?.titleBranchRename; + if (!intent || intent.state === 'finished') return; + if (intent.state === 'attempted') { + const actual = await manager.getCurrentBranchName(sessionId); + if (!actual) return; + if (actual && actual !== meta.branchName) await sessionDoc.setBranchName(actual); + await this.workspaceDocument.repo.upsertDocMeta(sessionDoc.roomId, { + titleBranchRename: { ...intent, state: 'finished' }, + } as Partial); + return; + } + let renamed: string | null = null; + try { + renamed = await manager.renameInitialBranchFromTitle({ + sessionId, + title, + initialBranch: intent.initialBranch, + initialHead: intent.initialHead, + hasPullRequest: Boolean(meta.pullRequests?.length), + brokerAuth: await this.resolveHostGitBrokerAuth(target.source, target.config), + beforeRename: async (targetBranch) => { + await this.workspaceDocument.repo.upsertDocMeta(sessionDoc.roomId, { + titleBranchRename: { ...intent, state: 'attempted', targetBranch }, + } as Partial); + await this.workspaceDocument.persistPendingChanges( + 'session-title-branch-rename-intent' + ); + }, + }); + } finally { + // Git rename and metadata are separate writes. On recovery an attempted + // intent is reconciled against Git before any further title can act. + const actual = await manager.getCurrentBranchName(sessionId); + if (actual) { + if (actual !== meta.branchName) await sessionDoc.setBranchName(actual); + await this.workspaceDocument.repo.upsertDocMeta(sessionDoc.roomId, { + titleBranchRename: { + ...intent, + state: 'finished', + ...(renamed ? { targetBranch: renamed } : {}), + }, + } as Partial); + } + } + })(); + this.titleBranchRenameInFlight.set(sessionId, work); + try { + await work; + } finally { + this.titleBranchRenameInFlight.delete(sessionId); + } + } + async terminateSession(sessionId: SessionId, force: boolean = false): Promise { const session = this.sessions.get(sessionId); if (!session) { @@ -2169,16 +2295,20 @@ export class SessionManager extends EventEmitter { } await session.terminate(force); + this.titleWorktreeManagers.delete(sessionId); this.logger.debug(`[${sessionId}] Session terminated`); } async cleanUp(options: { keepWorkspaceDocumentOpen?: boolean } = {}) { + this.titleBranchMetadataWatch?.unsubscribe(); + this.titleBranchMetadataWatch = null; this.preparationRecoveryGeneration += 1; this.detachPreparationRecovery?.(); this.detachPreparationRecovery = null; await this.preparationService.disposeAll(); await this.preparationRecoveryChain; this.preparationSessions.clear(); + this.titleWorktreeManagers.clear(); this.preparationUserResolver.clear(); await this.cleanupSessions(); // MessageHandler owns the final ACP/Code Collab drain. It first stops all diff --git a/apps/cli/src/session/worktree/AGENTS.md b/apps/cli/src/session/worktree/AGENTS.md index 7520d7fbb..8b47c261e 100644 --- a/apps/cli/src/session/worktree/AGENTS.md +++ b/apps/cli/src/session/worktree/AGENTS.md @@ -63,6 +63,9 @@ and file responsibilities: [../README.md](../README.md). - A fresh local/GitHub worktree always owns a newly allocated branch from its selected base ref; suffix collisions instead of attaching to an existing ref. Reattaching an existing branch is reserved for an explicit `restoreBranchName` from the same Session. +- Title-derived branch renaming applies only to a new Session with a recorded one-time intent; + verify the original Git branch and HEAD and absence of remote publication or a PR before + renaming, then reconcile the real branch into Session metadata before restore uses it. - Worktree setup scripts are per worktree-directory lifetime: session runtime restore after idle GC must skip setup when the session's worktree directory already exists, but setup still runs when a missing worktree directory is materialized again. diff --git a/apps/cli/src/session/worktree/worktree-manager.ts b/apps/cli/src/session/worktree/worktree-manager.ts index cc43b4bc8..ddbbf7ec6 100644 --- a/apps/cli/src/session/worktree/worktree-manager.ts +++ b/apps/cli/src/session/worktree/worktree-manager.ts @@ -11,6 +11,7 @@ import { formatErrorMessage } from '@/utils/format-error'; import { ensureLodyDataDir, getLodyDataDir } from '@lody/shared/node/installation-profile'; import { mapGitSpawnError } from './git-process-error'; import { resolveAvailableBranchName } from './branch-name-allocation'; +import { branchNameFromSessionTitle } from './worktree-title-branch'; const SAFE_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; const LODY_LOCAL_BRANCH_PREFIX = 'lody/'; @@ -1781,6 +1782,92 @@ export class WorktreeManager { }); } + /** Rename only an untouched, unpublished initial branch. A failed check leaves it alone. */ + async renameInitialBranchFromTitle(options: { + sessionId: SessionId; + title: string; + initialBranch: string; + initialHead: string; + hasPullRequest: boolean; + brokerAuth?: GitCredentialBrokerAuth; + beforeRename?: (target: string) => Promise; + }): Promise { + return withRepoLock(this.repoId, async () => { + const { sessionId, initialBranch, initialHead } = options; + assertSafeSessionId(sessionId); + const worktreePath = this.getWorktreeHostPath(sessionId); + if (!fs.existsSync(worktreePath) || options.hasPullRequest) return null; + + const desired = branchNameFromSessionTitle(options.title, sessionId); + if (!desired) return null; + const currentBranch = await this.getCurrentBranchName(sessionId); + if (currentBranch !== initialBranch) return null; + const currentHead = await this.runGit(['rev-parse', 'HEAD'], worktreePath); + if (currentHead !== initialHead) return null; + + // Refuse to rename when publication cannot be ruled out. The local upstream + // and remote-tracking refs catch ordinary pushes; ls-remote catches pushes + // that occurred since the last fetch. No network/auth means no rename. + const upstream = await this.runGit( + ['for-each-ref', '--format=%(upstream)', `refs/heads/${initialBranch}`], + this.getGitAdminCwd() + ); + if (upstream.trim()) return null; + const remotes = (await this.runGit(['remote'], this.getGitAdminCwd())) + .split('\n') + .map((remote) => remote.trim()) + .filter(Boolean); + const remoteNames = ( + await this.runGit( + ['for-each-ref', '--format=%(refname:lstrip=3)', 'refs/remotes'], + this.getGitAdminCwd() + ) + ) + .split('\n') + .map((ref) => ref.trim()) + .filter(Boolean); + if (remoteNames.includes(initialBranch)) return null; + for (const remote of remotes) { + let publishedRefs: string; + try { + publishedRefs = await this.runGit( + ['ls-remote', '--heads', remote, `refs/heads/${initialBranch}`, 'refs/heads/lody/*'], + this.getGitAdminCwd(), + buildBrokerAuthEnv(options.brokerAuth) + ); + } catch { + return null; + } + for (const line of publishedRefs.split('\n')) { + const ref = line.split('\t')[1]?.trim(); + if (!ref?.startsWith('refs/heads/')) continue; + const branch = ref.slice('refs/heads/'.length); + if (branch === initialBranch) return null; + remoteNames.push(branch); + } + } + + const refs = await this.runGit( + ['for-each-ref', '--format=%(refname:lstrip=2)', 'refs/heads'], + this.getGitAdminCwd() + ); + const target = resolveAvailableBranchName( + desired, + [ + ...refs + .split('\n') + .map((ref) => ref.trim()) + .filter(Boolean), + ...remoteNames, + ], + { maxLength: 64 } + ); + await options.beforeRename?.(target); + await this.runGit(['branch', '-m', initialBranch, target], worktreePath); + return target; + }); + } + /** * Get the current branch name for a session's worktree. * This resolves the actual branch name from git, which may differ from `session/` diff --git a/apps/cli/src/session/worktree/worktree-title-branch.test.ts b/apps/cli/src/session/worktree/worktree-title-branch.test.ts new file mode 100644 index 000000000..1e2d5cd73 --- /dev/null +++ b/apps/cli/src/session/worktree/worktree-title-branch.test.ts @@ -0,0 +1,128 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { RepoId, SessionId } from '@lody/shared'; +import type { Logger } from '@/utils/logger'; +import { WorktreeManager } from './worktree-manager'; +import { branchNameFromSessionTitle } from './worktree-title-branch'; + +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + +describe('title-derived worktree branches', () => { + const roots: string[] = []; + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it('bounds the name and keeps unrepresentable titles on the ID branch', () => { + expect(branchNameFromSessionTitle('Add system notifications', 'a8e6488f-0bd')).toBe( + 'lody/add-system-notifications-a8e6488f' + ); + expect(branchNameFromSessionTitle('中文标题', 'a8e6488f-0bd')).toBeNull(); + expect(branchNameFromSessionTitle('x'.repeat(200), 'a8e6488f-0bd')?.length).toBeLessThanOrEqual( + 64 + ); + }); + + it('renames an untouched local worktree once and preserves user or committed work', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'lody-title-branch-')); + roots.push(root); + const source = path.join(root, 'source'); + git(root, 'init', '-b', 'main', source); + git(source, 'config', 'user.name', 'Test'); + git(source, 'config', 'user.email', 'test@example.invalid'); + writeFileSync(path.join(source, 'readme'), 'initial\n'); + git(source, 'add', 'readme'); + git(source, 'commit', '-m', 'initial'); + const previous = process.env.LODY_DATA_DIR; + process.env.LODY_DATA_DIR = root; + try { + const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + success() {}, + } as unknown as Logger; + const manager = new WorktreeManager({ + repoId: 'local-title-branch' as RepoId, + source: { kind: 'local-shared', originalRootPath: source }, + logger, + }); + await manager.ensureRepo(); + const sessionId = 'a8e6488f-0bd' as SessionId; + const initial = await manager.createWorktree(sessionId, 'main'); + expect(initial.headSha).toBeTruthy(); + const branch = await manager.renameInitialBranchFromTitle({ + sessionId, + title: 'Add system notifications', + initialBranch: initial.branch, + initialHead: initial.headSha!, + hasPullRequest: false, + }); + expect(branch).toBe('lody/add-system-notifications-a8e6488f'); + expect(git(initial.hostPath, 'branch', '--show-current')).toBe(branch); + expect( + await manager.renameInitialBranchFromTitle({ + sessionId, + title: 'A later title', + initialBranch: initial.branch, + initialHead: initial.headSha!, + hasPullRequest: false, + }) + ).toBeNull(); + + const second = 'b9e6488f-0bd' as SessionId; + const worktree = await manager.createWorktree(second, 'main'); + writeFileSync(path.join(worktree.hostPath, 'new-file'), 'work\n'); + git(worktree.hostPath, 'add', 'new-file'); + git(worktree.hostPath, 'commit', '-m', 'work'); + expect( + await manager.renameInitialBranchFromTitle({ + sessionId: second, + title: 'Committed work', + initialBranch: worktree.branch, + initialHead: worktree.headSha!, + hasPullRequest: false, + }) + ).toBeNull(); + expect(git(worktree.hostPath, 'branch', '--show-current')).toBe(worktree.branch); + + const remote = path.join(root, 'remote.git'); + git(root, 'init', '--bare', remote); + git(source, 'remote', 'add', 'origin', remote); + const third = 'c9e6488f-0bd' as SessionId; + const published = await manager.createWorktree(third, 'main'); + git(published.hostPath, 'push', 'origin', `HEAD:refs/heads/${published.branch}`); + expect( + await manager.renameInitialBranchFromTitle({ + sessionId: third, + title: 'Published work', + initialBranch: published.branch, + initialHead: published.headSha!, + hasPullRequest: false, + }) + ).toBeNull(); + expect(git(published.hostPath, 'branch', '--show-current')).toBe(published.branch); + + const fourth = 'd9e6488f-0bd' as SessionId; + const duplicate = await manager.createWorktree(fourth, 'main'); + git(source, 'branch', 'lody/duplicate-title-d9e6488f', 'main'); + expect( + await manager.renameInitialBranchFromTitle({ + sessionId: fourth, + title: 'Duplicate title', + initialBranch: duplicate.branch, + initialHead: duplicate.headSha!, + hasPullRequest: false, + }) + ).toBe('lody/duplicate-title-d9e6488f-2'); + } finally { + if (previous === undefined) delete process.env.LODY_DATA_DIR; + else process.env.LODY_DATA_DIR = previous; + } + }); +}); diff --git a/apps/cli/src/session/worktree/worktree-title-branch.ts b/apps/cli/src/session/worktree/worktree-title-branch.ts new file mode 100644 index 000000000..fc73106f9 --- /dev/null +++ b/apps/cli/src/session/worktree/worktree-title-branch.ts @@ -0,0 +1,22 @@ +const MAX_BRANCH_LENGTH = 64; + +/** A title is only a hint: an unrepresentable title leaves the ID branch alone. */ +export function branchNameFromSessionTitle(title: string, sessionId: string): string | null { + const slug = title + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!slug) return null; + + const suffix = sessionId + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .slice(0, 8); + if (!suffix) return null; + const prefix = 'lody/'; + const available = MAX_BRANCH_LENGTH - prefix.length - suffix.length - 1; + const boundedSlug = slug.slice(0, available).replace(/-+$/g, ''); + return boundedSlug ? `${prefix}${boundedSlug}-${suffix}` : null; +} diff --git a/apps/cli/tests/message-handler-title.test.ts b/apps/cli/tests/message-handler-title.test.ts index 9b9ef75ef..b507462ef 100644 --- a/apps/cli/tests/message-handler-title.test.ts +++ b/apps/cli/tests/message-handler-title.test.ts @@ -83,6 +83,7 @@ const createHandler = async ( hasSession: vi.fn(), initialize: vi.fn(), createSession: vi.fn(), + maybeRenameWorktreeAfterTitle: vi.fn(async () => undefined), }; const handler = new MessageHandler( @@ -100,7 +101,7 @@ const createHandler = async ( } ); - return { handler, sessionDoc, workspaceDocument }; + return { handler, sessionDoc, workspaceDocument, sessionManager }; }; describe('MessageHandler title generation', () => { @@ -129,7 +130,7 @@ describe('MessageHandler title generation', () => { }); it('runs isolated generation for Kimi when title is missing', async () => { - const { handler, sessionDoc } = await createHandler(undefined); + const { handler, sessionDoc, sessionManager } = await createHandler(undefined); const titleHost = handler as unknown as { maybeGenerateAndStoreSessionTitle: ( @@ -150,6 +151,10 @@ describe('MessageHandler title generation', () => { expect(sessionDoc.setTitleIfSourceIn).toHaveBeenCalledWith('Generated Title', 'generated', [ 'draft', ]); + expect(sessionManager.maybeRenameWorktreeAfterTitle).toHaveBeenCalledWith( + 's-2', + 'Generated Title' + ); }); it('shares one in-flight generation across duplicate title requests', async () => { @@ -328,7 +333,7 @@ describe('MessageHandler title generation', () => { ); it('filters Lody internal prompt instructions before storing an ACP title', async () => { - const { handler, sessionDoc } = await createHandler(undefined); + const { handler, sessionDoc, sessionManager } = await createHandler(undefined); const titleHost = handler as unknown as { maybeStoreAgentSessionTitle: (sessionId: SessionId, title: string) => Promise; }; @@ -343,6 +348,10 @@ describe('MessageHandler title generation', () => { 'draft', 'generated', ]); + expect(sessionManager.maybeRenameWorktreeAfterTitle).toHaveBeenCalledWith( + 's-9', + 'Fix flaky login' + ); }); it('does not store an ACP title containing only Lody internal instructions', async () => { diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 67d1c7305..2b125d81a 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -945,6 +945,13 @@ export type SessionMeta = { baseBranch?: string; /** Runtime working branch for the session (changes as session runs). */ branchName?: string; + /** One-time rename eligibility for newly created worktree Sessions. */ + titleBranchRename?: { + initialBranch: string; + initialHead: string; + state: 'pending' | 'attempted' | 'finished'; + targetBranch?: string; + }; /** True if this session runs inside a git worktree (local or GitHub). */ isWorktree?: boolean; pullRequests?: SessionPullRequestMeta[]; diff --git a/specs/readable-worktree-branches.md b/specs/readable-worktree-branches.md index 4eaca2216..21634e6df 100644 --- a/specs/readable-worktree-branches.md +++ b/specs/readable-worktree-branches.md @@ -59,5 +59,10 @@ branch, actual Git HEAD ref, and later restore target must agree in each case. [worktree lifecycle](session-worktree-lifecycle.md) already depend on the real Git branch matching durable Session metadata. -The exact publication check and crash-recovery transition need implementation -tests before this draft is treated as an approved behavior guarantee. +The reference implementation checks the local upstream, remote-tracking refs, +and remote heads before renaming; it records an attempted target before Git +changes the ref, then reconciles the actual branch into Session metadata. A +local Git test covers an untouched branch, moved HEAD, published ref, collision +suffix, and unusable title. SessionManager tests cover speculative adoption and +recovery after Git rename. Title callback ordering still needs integration +coverage. This draft is not an approved behavior guarantee. diff --git a/specs/readable-worktree-branches.zh.md b/specs/readable-worktree-branches.zh.md index e3d6d5894..651f65b7b 100644 --- a/specs/readable-worktree-branches.zh.md +++ b/specs/readable-worktree-branches.zh.md @@ -46,4 +46,8 @@ Git 改名成功但元数据尚未写入时的恢复。每种场景都要核实 [分支观察](workspace-branch-state.md)和 [worktree 生命周期](session-worktree-lifecycle.md) 已依赖真实 Git 分支与持久会话元数据一致。 -发布状态检查和崩溃恢复的准确转换仍需实现测试,不能把本草案称为已批准的行为保证。 +参考实现会在改名前检查本地 upstream、远端跟踪引用和远端分支;Git 更改引用前 +先记录拟改名称,随后将实际分支对账回会话元数据。本地 Git 测试覆盖未改动分支、 +HEAD 已移动、已发布引用、碰撞后缀与不可用标题。SessionManager 测试覆盖 +推测性接管及 Git 改名后的恢复;标题回调时序仍需集成验证。本草案不是已批准的 +行为保证。