Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/randomize-session-branches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---

Add random suffixes to generated session branches so manually pushed `session/<id>` branches cannot collide with AO-owned freeform sessions.
23 changes: 18 additions & 5 deletions packages/core/src/__tests__/session-manager/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
});

Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> = new Set([
"pr_open",
"ci_failed",
Expand Down Expand Up @@ -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 [];

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/lib/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ 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");
});

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/)", () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,28 @@ 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()).
const withoutPrefix = branch.replace(
/^(?: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();
Expand Down
Loading