diff --git a/.changeset/randomize-session-branches.md b/.changeset/randomize-session-branches.md new file mode 100644 index 0000000000..c0038a688c --- /dev/null +++ b/.changeset/randomize-session-branches.md @@ -0,0 +1,6 @@ +--- +"@aoagents/ao-core": patch +"@aoagents/ao-web": patch +--- + +Add random suffixes to generated session branches so manually pushed `session/` branches cannot collide with AO-owned freeform sessions. diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index d06dbbe3a3..e99e6750a7 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -420,13 +420,13 @@ describe("spawn", () => { const first = await sm.spawn({ projectId: "my-app" }); expect(first.id).toBe("app-1"); - expect(first.branch).toBe("session/app-1"); + expect(first.branch).toMatch(/^session\/app-1-[a-z0-9]{5}$/); await sm.kill(first.id); const second = await sm.spawn({ projectId: "my-app" }); expect(second.id).toBe("app-2"); - expect(second.branch).toBe("session/app-2"); + expect(second.branch).toMatch(/^session\/app-2-[a-z0-9]{5}$/); }); it("skips remote session branches when allocating a fresh session id", async () => { @@ -438,7 +438,19 @@ describe("spawn", () => { const session = await sm.spawn({ projectId: "my-app" }); expect(session.id).toBe("app-23"); - expect(session.branch).toBe("session/app-23"); + expect(session.branch).toMatch(/^session\/app-23-[a-z0-9]{5}$/); + }); + + it("skips suffixed remote session branches when allocating a fresh session id", async () => { + const mockGitBin = installMockGit(tmpDir, ["session/app-22-k7f2m"]); + process.env.PATH = `${mockGitBin}:${originalPath ?? ""}`; + mkdirSync(config.projects["my-app"]!.path, { recursive: true }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const session = await sm.spawn({ projectId: "my-app" }); + + expect(session.id).toBe("app-23"); + expect(session.branch).toMatch(/^session\/app-23-[a-z0-9]{5}$/); }); it("writes metadata file", async () => { @@ -1095,8 +1107,9 @@ describe("spawn", () => { const session = await sm.spawn({ projectId: "my-app" }); expect(session.issueId).toBeNull(); - // Uses session/{sessionId} to avoid conflicts with default branch - expect(session.branch).toMatch(/^session\/app-\d+$/); + // Uses session/{sessionId}-{suffix} to avoid conflicts with default branch + // and remote branches created by other AO users. + expect(session.branch).toMatch(/^session\/app-\d+-[a-z0-9]{5}$/); expect(session.branch).not.toBe("main"); }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6eb6435944..3008f9f115 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -14,6 +14,7 @@ import { statSync, existsSync, writeFileSync, mkdirSync, utimesSync, unlinkSync } from "node:fs"; import { recordActivityEvent } from "./activity-events.js"; import { execFile } from "node:child_process"; +import { randomInt } from "node:crypto"; import { basename, join, resolve } from "node:path"; import { homedir } from "node:os"; import { promisify } from "node:util"; @@ -214,6 +215,16 @@ function getSessionNumber(sessionId: string, prefix: string): number | undefined return Number.isNaN(parsed) ? undefined : parsed; } +const SESSION_BRANCH_SUFFIX_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; + +function generateSessionBranchSuffix(length = 5): string { + let suffix = ""; + for (let i = 0; i < length; i += 1) { + suffix += SESSION_BRANCH_SUFFIX_ALPHABET[randomInt(SESSION_BRANCH_SUFFIX_ALPHABET.length)]; + } + return suffix; +} + const PR_TRACKING_STATUSES: ReadonlySet = new Set([ "pr_open", "ci_failed", @@ -882,7 +893,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const ref = trimmed.split(/\s+/)[1] ?? ""; const match = ref.match( - new RegExp(`refs/heads/session/${escapeRegex(project.sessionPrefix)}-(\\d+)$`), + new RegExp( + `refs/heads/session/${escapeRegex(project.sessionPrefix)}-(\\d+)(?:-[a-z0-9]{5})?$`, + ), ); if (!match) return []; @@ -1354,7 +1367,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM .replace(/^-+|-+$/g, ""); branch = `feat/${slug || sessionId}`; } else { - branch = `session/${sessionId}`; + branch = `session/${sessionId}-${generateSessionBranchSuffix()}`; } // Create workspace (if workspace plugin is available) diff --git a/packages/web/src/lib/__tests__/format.test.ts b/packages/web/src/lib/__tests__/format.test.ts index 58b47c8295..8babd7514d 100644 --- a/packages/web/src/lib/__tests__/format.test.ts +++ b/packages/web/src/lib/__tests__/format.test.ts @@ -69,6 +69,10 @@ describe("humanizeBranch", () => { expect(humanizeBranch("session/ao-52")).toBe("Ao 52"); }); + it("hides random suffixes on generated session branches", () => { + expect(humanizeBranch("session/ao-52-k7f2m")).toBe("Ao 52"); + }); + it("handles orchestrator/ prefix", () => { expect(humanizeBranch("orchestrator/ao-orchestrator-8")).toBe("Ao Orchestrator 8"); }); @@ -76,6 +80,7 @@ describe("humanizeBranch", () => { it("returns empty when the branch is just the session ID (session/)", () => { // Signals to getSessionTitle that this branch carries no task info. expect(humanizeBranch("session/ao-42", "ao-42")).toBe(""); + expect(humanizeBranch("session/ao-42-k7f2m", "ao-42")).toBe(""); }); it("returns empty when the branch is just the session ID (orchestrator/)", () => { diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index 38e64e8244..e15fc77897 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -24,6 +24,8 @@ import type { DashboardSession } from "./types.js"; * humanizeBranch("session/ao-52", "ao-52") // → "" */ export function humanizeBranch(branch: string, sessionId?: string): string { + const isSessionBranch = branch.startsWith("session/"); + // Remove common prefixes (keep in sync with actual branch-generation logic // in packages/core/src/session-manager.ts — `session/`, `orchestrator/`, and // `feat/` are produced by spawn()/spawnOrchestrator()). @@ -31,16 +33,19 @@ export function humanizeBranch(branch: string, sessionId?: string): string { /^(?:feat|fix|chore|refactor|docs|test|ci|session|orchestrator|release|hotfix|feature|bugfix|build|wip|improvement)\//, "", ); + const displayText = isSessionBranch + ? withoutPrefix.replace(/^([a-zA-Z0-9][a-zA-Z0-9_-]*-\d+)-[a-z0-9]{5}$/, "$1") + : withoutPrefix; // If the remaining text is just the session ID (e.g. "ao-42" or // "ao-orchestrator-8"), there's no task signal here — return empty so the // caller can fall through to the next fallback (displayName, summary, …). - if (sessionId && withoutPrefix === sessionId) { + if (sessionId && displayText === sessionId) { return ""; } // Replace hyphens and underscores with spaces, then title-case each word - return withoutPrefix + return displayText .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()) .trim();