diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index aacc730069d0..4f39764e7747 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -17,7 +17,7 @@ Each worktree lives at: /worktrees// ``` -The repository fingerprint is the first 16 hexadecimal characters of a SHA-256 hash over the canonical git common directory and origin URL. A supplied name must match `[a-z0-9][a-z0-9-]{0,63}`. Without a name, OpenClaw generates `wt-` followed by eight random hexadecimal characters. +The repository fingerprint is the first 16 hexadecimal characters of a SHA-256 hash over the canonical git common directory and origin URL. A supplied name must match `[a-z0-9][a-z0-9-]{0,63}`. Without a name, OpenClaw generates a readable crustacean-themed name such as `brisk-lobster`. Inferred names already occupied by another owner, local branch, or unmanaged path get a numeric suffix such as `brisk-lobster-2`. OpenClaw creates branch `openclaw/` at the requested base ref. Without a base ref, it fetches `origin`, uses the remote default branch when available, and falls back to local `HEAD` when the repository is offline or has no usable remote. @@ -45,7 +45,7 @@ A nonzero exit aborts creation and removes the new worktree and branch. This is ## Session worktrees -Start an isolated chat from a Git-backed folder with a worktree session: on the Control UI's New session page, use the **Place** picker to choose a Gateway source folder, then select **Worktree** (with an optional base branch and worktree name). The choice appears only after the Gateway confirms that the selected folder is a Git checkout; ordinary folders run directly and show no Git isolation control. iOS exposes the same choice from Chat actions, and Android exposes it beside New Chat, when the active agent workspace is Git-backed. +Start an isolated chat from a Git-backed folder with a worktree session: on the Control UI's New session page, use the **Place** picker to choose a Gateway source folder, then select **Worktree** (with an optional base branch and worktree name). When the name is omitted, OpenClaw derives it from the explicit session label or the concise title generated from the first message, then falls back to a crustacean-themed name. The choice appears only after the Gateway confirms that the selected folder is a Git checkout; ordinary folders run directly and show no Git isolation control. iOS exposes the same choice from Chat actions, and Android exposes it beside New Chat, when the active agent workspace is Git-backed. Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything, while a Gateway-backed TUI shows an interactive prompt with the same actions. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the suggestion leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart. @@ -55,7 +55,7 @@ The resulting managed worktree is owned by the session, and every agent run in t `sessions.create` may include an absolute `cwd` to run directly in another Gateway folder, to choose the source checkout together with `worktree: true`, or to set a paired node's working directory. Every explicit host path requires `operator.admin`; ordinary worktree chat creation remains `operator.write` and stays anchored to the configured workspace. -`sessions.create` also accepts `worktreeBaseRef` and `worktreeName` alongside `worktree: true` to pick the base ref and the worktree name (the branch becomes `openclaw/`); both stay at `operator.write`. The created worktree is returned in the create result and persisted on the session row as `worktree: { id, branch, repoRoot }`, so session lists can show the checkout and branch. Deleting a session reports a preserved dirty checkout as `worktreePreserved` instead of silently leaving it behind. +`sessions.create` also accepts `worktreeBaseRef` and `worktreeName` alongside `worktree: true` to pick the base ref and the worktree name (the branch becomes `openclaw/`); both stay at `operator.write`. If `worktreeName` is omitted, the session label or generated first-message title supplies the readable branch name, with a crustacean-themed fallback. The created worktree is returned in the create result and persisted on the session row as `worktree: { id, branch, repoRoot }`, so session lists can show the checkout and branch. Deleting a session reports a preserved dirty checkout as `worktreePreserved` instead of silently leaving it behind. ## Snapshots, cleanup, and restore diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 7af9c25a2e63..3a844fa2719d 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -573,7 +573,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `sessions.preview` returns bounded transcript previews for specific session keys. - `sessions.describe` returns one gateway session row for an exact session key. - `sessions.resolve` resolves or canonicalizes a session target. - - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key). + - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key). - `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC. - `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side. - `sessions.send` sends a message into an existing session. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 22442f335ccc..59450014bc1d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -422,7 +422,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat). - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - - The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. Set `agents.defaults.utilityModel` (or `agents.entries.*.utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat. + - The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. When New Session creates a worktree without an explicit worktree name, OpenClaw also uses the session label or generated title for its branch name, falling back to a readable crustacean-themed name. Set `agents.defaults.utilityModel` (or `agents.entries.*.utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat. - Thread search lives in the command palette (⌘K, or the search button in the top-left control cluster): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Threads page keeps the exhaustive searchable list with filters. - Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. Multi-selected rows (Cmd/Ctrl-click, Shift-click for ranges) get a batch menu covering unread state, grouping, archive, and delete; batch archive/delete stays disabled unless every selected session is archivable. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session. - In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row. diff --git a/src/agents/session-slug.test.ts b/src/agents/session-slug.test.ts index 3c2cdeca668c..4377c7a8586a 100644 --- a/src/agents/session-slug.test.ts +++ b/src/agents/session-slug.test.ts @@ -3,7 +3,7 @@ * Verifies deterministic choices, collision numbering, and fallback suffixes. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createSessionSlug } from "./session-slug.js"; +import { createCrustaceanSlug, createSessionSlug } from "./session-slug.js"; const randomMocks = vi.hoisted(() => ({ generateSecureInt: vi.fn(), @@ -24,6 +24,11 @@ describe("session slug", () => { expect(slug).toBe("amber-atlas"); }); + it("generates a crustacean-themed slug for worktrees", () => { + randomMocks.generateSecureInt.mockReturnValue(0); + expect(createCrustaceanSlug()).toBe("amber-barnacle"); + }); + it("adds a numeric suffix when the base slug is taken", () => { randomMocks.generateSecureInt.mockReturnValue(0); const slug = createSessionSlug((id) => id === "amber-atlas"); diff --git a/src/agents/session-slug.ts b/src/agents/session-slug.ts index 670fdfadb225..6fbc92df8df4 100644 --- a/src/agents/session-slug.ts +++ b/src/agents/session-slug.ts @@ -107,6 +107,19 @@ const SLUG_NOUNS = [ "zephyr", ]; +const CRUSTACEAN_NOUNS = [ + "barnacle", + "claw", + "crab", + "crayfish", + "krill", + "langoustine", + "lobster", + "prawn", + "shrimp", + "shell", +]; + function randomChoice(values: string[], fallback: string) { return values[generateSecureInt(values.length)] ?? fallback; } @@ -121,10 +134,10 @@ function createFallbackSuffix(length: number): string { return suffix; } -function createSlugBase(words = 2) { - const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(SLUG_NOUNS, "harbor")]; +function createSlugBase(words = 2, nouns = SLUG_NOUNS) { + const parts = [randomChoice(SLUG_ADJECTIVES, "steady"), randomChoice(nouns, "harbor")]; if (words > 2) { - parts.push(randomChoice(SLUG_NOUNS, "reef")); + parts.push(randomChoice(nouns, "reef")); } return parts.join("-"); } @@ -132,9 +145,10 @@ function createSlugBase(words = 2) { function createAvailableSlug( words: number, isIdTaken: (id: string) => boolean, + nouns = SLUG_NOUNS, ): string | undefined { for (let attempt = 0; attempt < 12; attempt += 1) { - const base = createSlugBase(words); + const base = createSlugBase(words, nouns); if (!isIdTaken(base)) { return base; } @@ -162,3 +176,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string { const fallback = `${createSlugBase(3)}-${createFallbackSuffix(3)}`; return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; } + +/** Creates a human-readable crustacean-themed slug for unnamed worktrees. */ +export function createCrustaceanSlug(isTaken?: (id: string) => boolean): string { + const isIdTaken = isTaken ?? (() => false); + const twoWord = createAvailableSlug(2, isIdTaken, CRUSTACEAN_NOUNS); + if (twoWord) { + return twoWord; + } + const threeWord = createAvailableSlug(3, isIdTaken, CRUSTACEAN_NOUNS); + if (threeWord) { + return threeWord; + } + const fallback = `${createSlugBase(3, CRUSTACEAN_NOUNS)}-${createFallbackSuffix(3)}`; + return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback; +} diff --git a/src/agents/worktrees/name.test.ts b/src/agents/worktrees/name.test.ts new file mode 100644 index 000000000000..ffd1381605f6 --- /dev/null +++ b/src/agents/worktrees/name.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { slugifyWorktreeTitle, worktreeNameAllocationFamily } from "./name.js"; + +describe("slugifyWorktreeTitle", () => { + it.each([ + ["Fix agent selection menu", "fix-agent-selection-menu"], + ["Crème brûlée & release prep", "creme-brulee-and-release-prep"], + [' Title: "Worktree names" ', "title-worktree-names"], + ])("slugifies %s", (title, expected) => { + expect(slugifyWorktreeTitle(title)).toBe(expected); + }); + + it("truncates at the worktree contract limit without a trailing dash", () => { + expect(slugifyWorktreeTitle(`${"a".repeat(63)} two`)).toBe("a".repeat(63)); + }); + + it("returns undefined when a title has no ASCII slug characters", () => { + expect(slugifyWorktreeTitle("🦞 日本語")).toBeUndefined(); + }); + + it("joins numeric and dash-truncated names into the same allocation family", () => { + const base = `${"a".repeat(58)}-${"b".repeat(5)}`; + const thousandthCandidate = `${"a".repeat(58)}-1000`; + + expect(worktreeNameAllocationFamily(base)).toBe("a".repeat(58)); + expect(worktreeNameAllocationFamily(thousandthCandidate)).toBe("a".repeat(58)); + expect(worktreeNameAllocationFamily("task-2-3")).toBe("task"); + }); +}); diff --git a/src/agents/worktrees/name.ts b/src/agents/worktrees/name.ts new file mode 100644 index 000000000000..c85fe6133ec3 --- /dev/null +++ b/src/agents/worktrees/name.ts @@ -0,0 +1,25 @@ +const WORKTREE_NAME_MAX_LENGTH = 64; +const WORKTREE_ALLOCATION_FAMILY_LENGTH = 59; + +/** Converts a short human-readable title into a valid managed-worktree name. */ +export function slugifyWorktreeTitle(title: string): string | undefined { + const slug = title + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, WORKTREE_NAME_MAX_LENGTH) + .replace(/-+$/g, ""); + return slug || undefined; +} + +/** Maps names that can converge after a `-1000` suffix into one allocation lane. */ +export function worktreeNameAllocationFamily(name: string): string { + let base = name; + while (/-\d+$/.test(base)) { + base = base.replace(/-\d+$/, ""); + } + return (base || name).slice(0, WORKTREE_ALLOCATION_FAMILY_LENGTH).replace(/-+$/g, ""); +} diff --git a/src/agents/worktrees/service.naming.test.ts b/src/agents/worktrees/service.naming.test.ts new file mode 100644 index 000000000000..b3db34e6aa7d --- /dev/null +++ b/src/agents/worktrees/service.naming.test.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { ManagedWorktreeService } from "./service.js"; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" }); + return stdout.trim(); +} + +describe("ManagedWorktreeService naming", () => { + let root: string; + let repo: string; + let service: ManagedWorktreeService; + + beforeEach(async () => { + const tempRoot = await fs.realpath(os.tmpdir()); + root = await fs.mkdtemp(path.join(tempRoot, "openclaw-worktree-naming-")); + repo = path.join(root, "repo"); + await fs.mkdir(repo); + await git(repo, "init", "-b", "main"); + await git(repo, "config", "user.name", "OpenClaw Test"); + await git(repo, "config", "user.email", "openclaw-test@example.invalid"); + await fs.writeFile(path.join(repo, "README.md"), "base\n"); + await git(repo, "add", "README.md"); + await git(repo, "commit", "-m", "initial"); + repo = await fs.realpath(repo); + service = new ManagedWorktreeService({ + env: { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") }, + }); + }); + + afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("uses readable defaults and numbers colliding inferred names", async () => { + const fallback = await service.create({ repoRoot: repo }); + await service.create({ repoRoot: repo, name: "release-planning" }); + const second = await service.create({ repoRoot: repo, suggestedName: "release-planning" }); + + expect(fallback.name).toMatch( + /^[a-z]+-(?:barnacle|claw|crab|crayfish|krill|langoustine|lobster|prawn|shrimp|shell)$/, + ); + expect(second.name).toBe("release-planning-2"); + }); + + it("numbers inferred names around unmanaged Git and filesystem collisions", async () => { + const anchor = await service.create({ repoRoot: repo, name: "anchor" }); + await git(repo, "branch", "openclaw/release-planning"); + await fs.mkdir(path.join(path.dirname(anchor.path), "release-planning-2")); + + const created = await service.create({ repoRoot: repo, suggestedName: "release-planning" }); + + expect(created.name).toBe("release-planning-3"); + }); + + it("serializes concurrent inferred-name creation", async () => { + const created = await Promise.all([ + service.create({ repoRoot: repo, suggestedName: "concurrent-task" }), + service.create({ repoRoot: repo, suggestedName: "concurrent-task" }), + ]); + + expect(created.map((record) => record.name).toSorted()).toEqual([ + "concurrent-task", + "concurrent-task-2", + ]); + }); + + it("reuses concurrent inferred names for the same owner", async () => { + const owner = { + repoRoot: repo, + ownerKind: "session" as const, + ownerId: "agent:main:session-1", + }; + const created = await Promise.all([ + service.create({ ...owner, suggestedName: "first-session-title" }), + service.create({ ...owner, suggestedName: "second-session-title" }), + ]); + + expect(created[0]?.id).toBe(created[1]?.id); + expect(created[0]?.name).toMatch(/^(?:first|second)-session-title$/); + expect( + (await service.list()).filter((record) => record.ownerId === owner.ownerId), + ).toHaveLength(1); + }); + + it("serializes overlapping numeric suffix families", async () => { + await service.create({ repoRoot: repo, name: "task" }); + + const created = await Promise.all([ + service.create({ repoRoot: repo, suggestedName: "task" }), + service.create({ repoRoot: repo, suggestedName: "task-2" }), + ]); + const names = created.map((record) => record.name); + + expect(new Set(names).size).toBe(2); + expect(names).toContain("task-2"); + expect(names.every((name) => /^task-(?:2-2|3|2)$/.test(name))).toBe(true); + }); +}); diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index df0f05acb230..97c424e9af28 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -1,4 +1,4 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -6,6 +6,8 @@ import path from "node:path"; import { resolveStateDir } from "../../config/paths.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { runCommandWithTimeout } from "../../process/exec.js"; +import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js"; +import { createCrustaceanSlug } from "../session-slug.js"; import { resolveWorktreeBase } from "./base-ref.js"; import { lockState, lockWorktreeForProcess, unlockWorktree } from "./git-lock.js"; import { @@ -19,6 +21,7 @@ import { runGit, type GitResult, } from "./git.js"; +import { worktreeNameAllocationFamily } from "./name.js"; import { worktreeOwnerMatches } from "./owner.js"; import { hasUnsnapshotableProvisionedFiles, @@ -61,6 +64,10 @@ export const SNAPSHOT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // Snapshot refs export const WORKTREE_GC_INTERVAL_MS = 60 * 60 * 1000; const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const WORKTREE_CREATE_LEASE_SCOPE = "core:managed-worktrees:create"; +const WORKTREE_OWNER_LEASE_SCOPE = "core:managed-worktrees:owner"; +const WORKTREE_CREATE_LEASE_MS = 60_000; +const WORKTREE_CREATE_LEASE_WAIT_MS = 5 * 60_000; /** Non-forced removal aborted because the safety snapshot failed. */ export class WorktreeSnapshotError extends Error { @@ -104,8 +111,61 @@ function validateName(name: string): string { return name; } -function generateName(): string { - return `wt-${randomBytes(4).toString("hex")}`; +async function nameIsUnavailable( + env: NodeJS.ProcessEnv, + repoRoot: string, + root: string, + name: string, + owner: Pick, +): Promise { + const worktreePath = path.join(root, name); + const registered = findRegistryWorktreeByPath(env, worktreePath); + if (owner.ownerId && registered && worktreeOwnerMatches(registered, owner)) { + // Let createForRepository reuse or restore the caller's existing record. + // Treating it as a collision can create a second checkout for one owner. + return false; + } + if (registered || (await pathExists(worktreePath))) { + return true; + } + const branch = `openclaw/${name}`; + const branchExists = await runGit(repoRoot, [ + "show-ref", + "--quiet", + "--verify", + `refs/heads/${branch}`, + ]); + if (branchExists.code === 0) { + return true; + } + if (branchExists.code !== 1) { + throw commandError("git show-ref --verify", branchExists); + } + return (await listGitWorktrees(repoRoot)).some( + (entry) => path.resolve(entry.path) === path.resolve(worktreePath), + ); +} + +function appendNameOrdinal(name: string, ordinal: number): string { + const suffix = `-${ordinal}`; + return `${name.slice(0, 64 - suffix.length).replace(/-+$/g, "")}${suffix}`; +} + +async function generateName( + env: NodeJS.ProcessEnv, + repoRoot: string, + root: string, + owner: Pick, + suggestedName: string, +): Promise { + validateName(suggestedName); + for (let ordinal = 1; ordinal <= 1_000; ordinal += 1) { + const candidate = ordinal === 1 ? suggestedName : appendNameOrdinal(suggestedName, ordinal); + if (!(await nameIsUnavailable(env, repoRoot, root, candidate, owner))) { + return candidate; + } + } + throw new Error(`no available worktree name for ${suggestedName}`); } type ResolvedRepository = { @@ -470,8 +530,78 @@ export class ManagedWorktreeService { async create(params: CreateManagedWorktreeParams): Promise { const repository = await resolveRepository(params.repoRoot); - const name = validateName(params.name ?? generateName()); + if (params.ownerId) { + const ownerKind = params.ownerKind ?? "manual"; + const ownerId = params.ownerId; + const ownerKey = createHash("sha256").update(`${ownerKind}\0${ownerId}`).digest("hex"); + return await withOpenClawStateLease( + { + scope: WORKTREE_OWNER_LEASE_SCOPE, + key: ownerKey, + database: { scope: "shared", options: { env: this.env } }, + leaseMs: WORKTREE_CREATE_LEASE_MS, + waitMs: WORKTREE_CREATE_LEASE_WAIT_MS, + leaseLabel: "managed worktree owner lease", + operationLabel: "agents.worktrees.create.owner-lease", + }, + async () => { + const existing = findLiveRegistryWorktreeByOwner(this.env, ownerKind, ownerId); + if (existing && (await pathExists(existing.path))) { + if (existing.repoRoot !== repository.repoRoot) { + throw new Error( + `worktree owner ${ownerKind} ${ownerId} is already bound to another repository`, + ); + } + return existing; + } + if (existing) { + updateRegistryWorktree(this.env, existing.id, { removedAt: this.now() }); + } + return await this.createWithAllocationLease(params, repository); + }, + ); + } + return await this.createWithAllocationLease(params, repository); + } + + private async createWithAllocationLease( + params: CreateManagedWorktreeParams, + repository: Awaited>, + ): Promise { + const allocationName = params.name ?? params.suggestedName ?? createCrustaceanSlug(); + // Keep selection and Git branch/path creation under one cross-process lease. + // Numeric suffix families and truncation-equivalent bases can otherwise + // converge on the same ordinal candidate after separate availability checks. + return await withOpenClawStateLease( + { + scope: WORKTREE_CREATE_LEASE_SCOPE, + key: `${repository.fingerprint}:${worktreeNameAllocationFamily(allocationName)}`, + database: { scope: "shared", options: { env: this.env } }, + leaseMs: WORKTREE_CREATE_LEASE_MS, + waitMs: WORKTREE_CREATE_LEASE_WAIT_MS, + leaseLabel: "managed worktree creation lease", + operationLabel: "agents.worktrees.create.lease", + }, + async () => await this.createForRepository(params, repository, allocationName), + ); + } + + private async createForRepository( + params: CreateManagedWorktreeParams, + repository: Awaited>, + inferredName: string, + ): Promise { const root = path.join(resolveStateDir(this.env), "worktrees", repository.fingerprint); + const name = validateName( + params.name ?? + (await generateName( + this.env, + repository.repoRoot, + root, + params, + params.suggestedName ?? inferredName, + )), + ); const worktreePath = path.join(root, name); const existing = findRegistryWorktreeByPath(this.env, worktreePath); // Name reuse only ever adopts the caller's own record. Without this guard a diff --git a/src/agents/worktrees/types.ts b/src/agents/worktrees/types.ts index c2add0bc4bdb..5e728953c9d2 100644 --- a/src/agents/worktrees/types.ts +++ b/src/agents/worktrees/types.ts @@ -25,6 +25,8 @@ export type ManagedWorktreeRecord = { export type CreateManagedWorktreeParams = { repoRoot: string; name?: string; + /** Derived default name; collisions receive a stable numeric suffix. */ + suggestedName?: string; baseRef?: string; ownerKind?: ManagedWorktreeOwnerKind; ownerId?: string; diff --git a/src/gateway/dashboard-session-title.test.ts b/src/gateway/dashboard-session-title.test.ts index 9a1e509d1c30..9e072695e785 100644 --- a/src/gateway/dashboard-session-title.test.ts +++ b/src/gateway/dashboard-session-title.test.ts @@ -13,7 +13,10 @@ vi.mock("../config/sessions/session-accessor.js", () => ({ updateSessionEntry }) import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { maybeGenerateDashboardSessionTitle } from "./dashboard-session-title.js"; +import { + generateDashboardSessionTitle, + maybeGenerateDashboardSessionTitle, +} from "./dashboard-session-title.js"; const cfg = { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, @@ -239,3 +242,49 @@ describe("maybeGenerateDashboardSessionTitle", () => { expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce(); }); }); + +describe("generateDashboardSessionTitle", () => { + beforeEach(() => { + generateConversationLabelWithFallback.mockReset(); + resolveUtilityModelRefForAgent.mockReset(); + generateConversationLabelWithFallback.mockResolvedValue("Worktree Naming Improvements"); + resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna"); + }); + + it("generates the reusable short dashboard title", async () => { + await expect( + generateDashboardSessionTitle({ + cfg, + agentId: "main", + userMessage: "Please improve the default names for managed worktrees", + }), + ).resolves.toBe("Worktree Naming Improvements"); + }); + + it("uses a requested session model as the primary fallback", async () => { + await generateDashboardSessionTitle({ + cfg, + agentId: "main", + entry: { + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + authProfileOverride: "work", + }, + userMessage: "Please improve the default names for managed worktrees", + }); + + expect(generateConversationLabelWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ + regularModelRef: "anthropic/claude-opus-4-5@work", + preferredProfile: "work", + }), + ); + }); + + it.each(["", " ", "/status"])("skips non-title prompt %j", async (userMessage) => { + await expect( + generateDashboardSessionTitle({ cfg, agentId: "main", userMessage }), + ).resolves.toBeNull(); + expect(generateConversationLabelWithFallback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/dashboard-session-title.ts b/src/gateway/dashboard-session-title.ts index 738e5ffc93a3..9fe57f01f07e 100644 --- a/src/gateway/dashboard-session-title.ts +++ b/src/gateway/dashboard-session-title.ts @@ -10,6 +10,11 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +type DashboardSessionTitleModelEntry = Pick< + SessionEntry, + "authProfileOverride" | "model" | "modelOverride" | "modelProvider" | "providerOverride" +>; + const DASHBOARD_SESSION_TITLE_MAX_CHARS = 60; const DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS = 1_000; const DASHBOARD_SESSION_TITLE_PROMPT = @@ -46,7 +51,7 @@ export function isDashboardSessionTitleCandidate(params: { function resolveDashboardTitleAuthProfile(params: { cfg: OpenClawConfig; agentId: string; - entry: SessionEntry | undefined; + entry: DashboardSessionTitleModelEntry | undefined; regularProvider: string; }): string | undefined { const sessionProfile = params.entry?.authProfileOverride?.trim(); @@ -78,6 +83,47 @@ function normalizeDashboardSessionTitle(raw: string): string | null { return normalized ? truncateUtf16Safe(normalized, DASHBOARD_SESSION_TITLE_MAX_CHARS) : null; } +/** Generates the same short title used by dashboard session rows without persisting it. */ +export async function generateDashboardSessionTitle(params: { + cfg: OpenClawConfig; + agentId: string; + entry?: DashboardSessionTitleModelEntry; + userMessage: string; +}): Promise { + const sourceText = params.userMessage.trim(); + if (!sourceText || sourceText.startsWith("/")) { + return null; + } + const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId); + const preferredProfile = resolveDashboardTitleAuthProfile({ + cfg: params.cfg, + agentId: params.agentId, + entry: params.entry, + regularProvider: regularModel.provider, + }); + const regularModelRef = `${regularModel.provider}/${regularModel.model}${ + preferredProfile ? `@${preferredProfile}` : "" + }`; + const utilityModelRef = resolveUtilityModelRefForAgent({ + cfg: params.cfg, + agentId: params.agentId, + primaryProvider: regularModel.provider, + primaryModelRef: regularModelRef, + }); + const generated = await generateConversationLabelWithFallback({ + userMessage: truncateUtf16Safe(sourceText, DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS), + prompt: DASHBOARD_SESSION_TITLE_PROMPT, + cfg: params.cfg, + agentId: params.agentId, + ...(utilityModelRef ? { utilityModelRef } : {}), + regularModelRef, + ...(preferredProfile ? { preferredProfile } : {}), + normalizeLabel: normalizeDashboardSessionTitle, + maxLength: DASHBOARD_SESSION_TITLE_MAX_CHARS, + }); + return generated ? normalizeDashboardSessionTitle(generated) : null; +} + export async function maybeGenerateDashboardSessionTitle(params: { cfg: OpenClawConfig; agentId: string; @@ -106,34 +152,12 @@ export async function maybeGenerateDashboardSessionTitle(params: { } dashboardTitleRequests.add(requestKey); try { - const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId); - const preferredProfile = resolveDashboardTitleAuthProfile({ + const displayName = await generateDashboardSessionTitle({ cfg: params.cfg, agentId: params.agentId, entry: params.entry, - regularProvider: regularModel.provider, + userMessage: sourceText, }); - const regularModelRef = `${regularModel.provider}/${regularModel.model}${ - preferredProfile ? `@${preferredProfile}` : "" - }`; - const utilityModelRef = resolveUtilityModelRefForAgent({ - cfg: params.cfg, - agentId: params.agentId, - primaryProvider: regularModel.provider, - primaryModelRef: regularModelRef, - }); - const generated = await generateConversationLabelWithFallback({ - userMessage: truncateUtf16Safe(sourceText, DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS), - prompt: DASHBOARD_SESSION_TITLE_PROMPT, - cfg: params.cfg, - agentId: params.agentId, - ...(utilityModelRef ? { utilityModelRef } : {}), - regularModelRef, - ...(preferredProfile ? { preferredProfile } : {}), - normalizeLabel: normalizeDashboardSessionTitle, - maxLength: DASHBOARD_SESSION_TITLE_MAX_CHARS, - }); - const displayName = generated ? normalizeDashboardSessionTitle(generated) : null; if (!displayName) { return false; } diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 3a822ff2274c..d1211cf2ecf9 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -10,15 +10,20 @@ import { validateSessionsCreateParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import { insideGitCheckout } from "../../agents/worktrees/git.js"; +import { slugifyWorktreeTitle } from "../../agents/worktrees/name.js"; import { managedWorktrees } from "../../agents/worktrees/service.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; +import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; +import { generateDashboardSessionTitle } from "../dashboard-session-title.js"; import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; import { buildDashboardSessionKey, @@ -28,6 +33,7 @@ import { import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js"; +import { resolveSessionPatchModelSelection } from "../sessions-patch.js"; import { chatHandlers } from "./chat.js"; import { resolveSessionCatalogCreateTarget } from "./session-catalog.js"; import { emitSessionsChanged } from "./session-change-event.js"; @@ -156,6 +162,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { let sessionCwd = requestedExecNode ? undefined : requestedCwd; let sessionSourceRoot: string | undefined; let provisionedSessionWorktree = false; + let generatedDisplayName: string | undefined; if (requestedCwd && !requestedExecNode && p.worktree !== true) { const targetAgentId = normalizeAgentId( sessionAgentId ?? @@ -286,11 +293,52 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { sessionWorktree = existing; } else { const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + if (!requestedWorktreeName && !normalizeOptionalString(p.label) && initialMessage) { + try { + const requestedTitleModel = + catalogTarget?.target.model ?? normalizeOptionalString(p.model); + let titleModelEntry: + | Pick + | undefined; + if (requestedTitleModel) { + const defaultModel = resolveDefaultModelForAgent({ + cfg, + agentId: target.agentId, + }); + const selection = resolveSessionPatchModelSelection({ + cfg, + catalog: await context.loadGatewayModelCatalog(), + raw: requestedTitleModel, + defaultProvider: defaultModel.provider, + defaultModel: defaultModel.model, + }); + if (selection.ok) { + titleModelEntry = { + providerOverride: selection.provider, + modelOverride: selection.model, + ...(selection.profile ? { authProfileOverride: selection.profile } : {}), + }; + } + } + generatedDisplayName = + (await generateDashboardSessionTitle({ + cfg, + agentId: target.agentId, + entry: titleModelEntry, + userMessage: stripInlineDirectiveTagsForDisplay(initialMessage).text, + })) ?? undefined; + } catch (error) { + sessionLog.warn(`worktree title generation failed: ${formatErrorMessage(error)}`); + } + } sessionWorktree = await managedWorktrees.create({ repoRoot: workspace, ownerKind: "session", ownerId: target.canonicalKey, name: requestedWorktreeName, + suggestedName: slugifyWorktreeTitle( + normalizeOptionalString(p.label) ?? generatedDisplayName ?? "", + ), baseRef: requestedWorktreeBaseRef, // Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them // admin-only so this write-scoped path cannot execute gated repo scripts. @@ -333,6 +381,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { key: sessionKey, agentId: sessionAgentId, label: p.label, + generatedDisplayName, ...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }), thinkingLevel: p.thinkingLevel, incognito: p.incognito, diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index fe3af8d21f15..d29265d65a17 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -496,6 +496,30 @@ test("createGatewaySession rechecks admin scope after incognito inheritance reso } }); +test("createGatewaySession persists a generated title only for a new session", async () => { + await createSessionStoreDir(); + const { createGatewaySession } = await import("./session-create-service.js"); + const key = "agent:main:dashboard:generated-worktree-title"; + const base = { + cfg: getRuntimeConfig(), + agentId: "main", + key, + commandSource: "test", + }; + + const created = await createGatewaySession({ + ...base, + generatedDisplayName: "Readable Worktree Names", + }); + expect(created).toMatchObject({ ok: true, entry: { displayName: "Readable Worktree Names" } }); + + const reused = await createGatewaySession({ + ...base, + generatedDisplayName: "Replacement Title", + }); + expect(reused).toMatchObject({ ok: true, entry: { displayName: "Readable Worktree Names" } }); +}); + test("incognito operator RPCs treat identityless connections as owner-equivalent", async () => { const { dir } = await createSessionStoreDir(); const admin = await openClient({ @@ -744,14 +768,14 @@ test("sessions.create provisions and reuses a session worktree for later runs", worktree: { id: string; path: string; branch: string }; }>( "sessions.create", - { agentId: "main", worktree: true }, + { agentId: "main", label: "Release planning", worktree: true }, { client: { connect: { scopes: ["operator.admin"] } } as never }, ); expect(created.ok).toBe(true); const key = requireNonEmptyString(created.payload?.key, "created session key"); const worktree = created.payload?.worktree; - expect(worktree?.branch).toMatch(/^openclaw\/wt-[a-f0-9]{8}$/); + expect(worktree?.branch).toBe("openclaw/release-planning"); expect(created.payload?.entry.spawnedCwd).toBe(worktree?.path); worktreeId = worktree?.id; expect(findLiveRegistryWorktreeByOwner(process.env, "session", key)).toMatchObject({ @@ -1205,7 +1229,7 @@ test("sessions.create preserves a linked-worktree subdirectory", async () => { worktreeId = worktree?.id; // The managed worktree anchors at the repo root even when the workspace is nested; // the session cwd points at the equivalent subdirectory inside the worktree. - expect(worktree?.branch).toMatch(/^openclaw\/wt-[a-f0-9]{8}$/); + expect(worktree?.branch).toMatch(/^openclaw\/[a-z0-9]+(?:-[a-z0-9]+)+$/); expect(created.payload?.entry.spawnedCwd).toBe( path.join(requireNonEmptyString(worktree?.path, "worktree path"), "packages", "app"), ); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index a30817bd30dc..46e09b118ecd 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -274,6 +274,8 @@ export async function createGatewaySession(params: { key?: string; agentId?: string; label?: string; + /** Trusted model-generated title, persisted with a newly created dashboard session. */ + generatedDisplayName?: string; model?: string; thinkingLevel?: string; incognito?: boolean; @@ -323,6 +325,7 @@ export async function createGatewaySession(params: { }): Promise { const requestedKey = normalizeOptionalString(params.key); const parentSessionKey = normalizeOptionalString(params.parentSessionKey); + const generatedDisplayName = normalizeOptionalString(params.generatedDisplayName); const agentId = normalizeAgentId( normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg), ); @@ -961,6 +964,7 @@ export async function createGatewaySession(params: { // the merge-level write-once guard), and legacy rows stay "unknown". ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), ...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}), + ...(generatedDisplayName && createdNewEntry ? { displayName: generatedDisplayName } : {}), ...(catalogResolvedModel && catalogAgentRuntime ? { providerOverride: catalogResolvedModel.provider,