diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 702acb05c..10c5e3cac 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -692,4 +692,42 @@ describe("status command", () => { const parsed = JSON.parse(jsonCalls); expect(parsed[0].activity).toBe("exited"); }); + + it("suppresses orchestrator PR ownership in status output", async () => { + writeFileSync( + join(sessionsDir, "app-orchestrator"), + [ + "worktree=/tmp/wt", + "branch=main", + "status=working", + "role=orchestrator", + "pr=https://github.com/org/repo/pull/77", + ].join("\n"), + ); + + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "list-sessions") return "app-orchestrator"; + if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000)); + return null; + }); + mockGit.mockResolvedValue("main"); + mockDetectPR.mockResolvedValue({ + number: 77, + url: "https://github.com/org/repo/pull/77", + title: "Orchestrator should not own this", + owner: "org", + repo: "repo", + branch: "main", + baseBranch: "main", + isDraft: false, + }); + + await program.parseAsync(["node", "test", "status", "--json"]); + + const parsed = JSON.parse(consoleSpy.mock.calls.map((c) => c[0]).join("")); + expect(parsed[0].name).toBe("app-orchestrator"); + expect(parsed[0].pr).toBeNull(); + expect(parsed[0].prNumber).toBeNull(); + expect(mockDetectPR).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index d03b9c22c..c25a5344a 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -42,16 +42,21 @@ interface SessionInfo { activity: ActivityState | null; } +function isOrchestratorSession(session: Session): boolean { + return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +} + async function gatherSessionInfo( session: Session, agent: Agent, scm: SCM, projectConfig: ReturnType, ): Promise { + const suppressPROwnership = isOrchestratorSession(session); let branch = session.branch; const status = session.status; const summary = session.metadata["summary"] ?? null; - const prUrl = session.metadata["pr"] ?? null; + const prUrl = suppressPROwnership ? null : (session.metadata["pr"] ?? null); const issue = session.issueId; // Get live branch from worktree if available @@ -91,7 +96,7 @@ async function gatherSessionInfo( } } - if (branch) { + if (branch && !suppressPROwnership) { try { const project = projectConfig.projects[session.projectId]; if (project) { diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts new file mode 100644 index 000000000..27e943a4a --- /dev/null +++ b/packages/core/src/__tests__/orchestrator-prompt.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { generateOrchestratorPrompt } from "../orchestrator-prompt.js"; +import type { OrchestratorConfig } from "../types.js"; + +const config: OrchestratorConfig = { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + readyThresholdMs: 300_000, +}; + +describe("generateOrchestratorPrompt", () => { + it("requires read-only investigation from the orchestrator session", () => { + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }); + + expect(prompt).toContain("Investigations from the orchestrator session are **read-only**"); + expect(prompt).toContain("do not edit repository files or implement fixes"); + }); + + it("pushes implementation and PR claiming into worker sessions", () => { + const prompt = generateOrchestratorPrompt({ + config, + projectId: "my-app", + project: config.projects["my-app"]!, + }); + + expect(prompt).toContain("must be delegated to a **worker session**"); + expect(prompt).toContain("Never claim a PR into `app-orchestrator`"); + expect(prompt).toContain("Delegate implementation, test execution, or PR claiming"); + }); +}); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index dd337fa33..1412d2c54 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, + utimesSync, +} from "node:fs"; import { join } from "node:path"; import { homedir, tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -100,6 +108,32 @@ function installMockOpencodeWithNotFoundDelete(sessionListJson: string): string return binDir; } +function installMockGit(remoteBranches: string[]): string { + const binDir = join(tmpDir, "mock-git-bin"); + mkdirSync(binDir, { recursive: true }); + const scriptPath = join(binDir, "git"); + const refs = remoteBranches + .map((branch) => `deadbeef\trefs/heads/${branch}`) + .join("\\n") + .replace(/'/g, "'\\''"); + writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [[ "$1" == "ls-remote" && "$2" == "--heads" && "$3" == "origin" ]]; then', + ` printf '%b\\n' '${refs}'`, + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(scriptPath, 0o755); + return binDir; +} + beforeEach(() => { originalPath = process.env.PATH; tmpDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`); @@ -365,6 +399,32 @@ describe("spawn", () => { expect(session.id).toBe("app-8"); }); + it("does not reuse a killed session branch on recreate", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const first = await sm.spawn({ projectId: "my-app" }); + expect(first.id).toBe("app-1"); + expect(first.branch).toBe("session/app-1"); + + 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"); + }); + + it("skips remote session branches when allocating a fresh session id", async () => { + const mockGitBin = installMockGit(["session/app-22"]); + 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).toBe("session/app-23"); + }); + it("writes metadata file", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); await sm.spawn({ projectId: "my-app", issueId: "INT-42" }); @@ -1080,6 +1140,32 @@ describe("list", () => { expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]); }); + it("preserves lastActivityAt when read-time repair rewrites metadata", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: config.projects["my-app"]!.path, + branch: "main", + status: "merged", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-orch")), + }); + + const oldTime = new Date("2026-01-01T00:00:00.000Z"); + utimesSync(join(sessionsDir, "app-orchestrator"), oldTime, oldTime); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list("my-app"); + const orchestrator = sessions.find((session) => session.id === "app-orchestrator"); + + expect(orchestrator).toBeDefined(); + expect(orchestrator!.lastActivityAt.getTime()).toBe(oldTime.getTime()); + + const repaired = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(repaired!["pr"]).toBeUndefined(); + expect(repaired!["prAutoDetect"]).toBe("off"); + expect(repaired!["status"]).toBe("working"); + }); + it("filters by project ID", async () => { // In hash-based architecture, each project has its own directory // so filtering is implicit. This test verifies list(projectId) only @@ -3869,6 +3955,169 @@ describe("claimPR", () => { expect(raw!["prAutoDetect"]).toBeUndefined(); }); + it("consolidates ownership by disabling PR auto-detect on the previous session", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws-app-1", + branch: "feat/existing-pr", + status: "review_pending", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/other-work", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + const result = await sm.claimPR("app-2", "42"); + + expect(result.takenOverFrom).toEqual(["app-1"]); + + const previous = readMetadataRaw(sessionsDir, "app-1"); + expect(previous!["pr"]).toBeUndefined(); + expect(previous!["prAutoDetect"]).toBe("off"); + expect(previous!["status"]).toBe("working"); + }); + + it("ignores legacy orchestrator metadata when claiming a PR", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: config.projects["my-app"]!.path, + branch: "feat/existing-pr", + status: "pr_open", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-orch")), + }); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/other-work", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + const result = await sm.claimPR("app-2", "42"); + + expect(result.takenOverFrom).toEqual([]); + expect(readMetadataRaw(sessionsDir, "app-2")!["pr"]).toBe( + "https://github.com/org/my-app/pull/42", + ); + }); + + it("repairs legacy orchestrator PR metadata and stale duplicate PR attachments on read", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: config.projects["my-app"]!.path, + branch: "main", + status: "merged", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-orch")), + }); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws-app-1", + branch: "feat/existing-pr", + status: "review_pending", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/existing-pr", + status: "pr_open", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const staleTime = new Date("2026-01-01T00:00:00.000Z"); + const freshTime = new Date("2026-01-02T00:00:00.000Z"); + utimesSync(join(sessionsDir, "app-1"), staleTime, staleTime); + utimesSync(join(sessionsDir, "app-2"), freshTime, freshTime); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list(); + + expect(sessions).toHaveLength(3); + + const orchestrator = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(orchestrator!["role"]).toBe("orchestrator"); + expect(orchestrator!["pr"]).toBeUndefined(); + expect(orchestrator!["prAutoDetect"]).toBe("off"); + expect(orchestrator!["status"]).toBe("working"); + + const staleWorker = readMetadataRaw(sessionsDir, "app-1"); + expect(staleWorker!["pr"]).toBeUndefined(); + expect(staleWorker!["prAutoDetect"]).toBe("off"); + expect(staleWorker!["status"]).toBe("working"); + + const activeWorker = readMetadataRaw(sessionsDir, "app-2"); + expect(activeWorker!["pr"]).toBe("https://github.com/org/my-app/pull/42"); + expect(activeWorker!["status"]).toBe("pr_open"); + }); + + it("repairs stale duplicate PR attachments before claim conflict checks", async () => { + const mockSCM = makeSCM(); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws-app-1", + branch: "feat/existing-pr", + status: "review_pending", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws-app-2", + branch: "feat/existing-pr", + status: "pr_open", + project: "my-app", + pr: "https://github.com/org/my-app/pull/42", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + writeMetadata(sessionsDir, "app-3", { + worktree: "/tmp/ws-app-3", + branch: "feat/other-work", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-3")), + }); + + const staleTime = new Date("2026-01-01T00:00:00.000Z"); + const freshTime = new Date("2026-01-02T00:00:00.000Z"); + utimesSync(join(sessionsDir, "app-1"), staleTime, staleTime); + utimesSync(join(sessionsDir, "app-2"), freshTime, freshTime); + + const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) }); + const result = await sm.claimPR("app-3", "42"); + + expect(result.takenOverFrom).toEqual(["app-2"]); + + const staleWorker = readMetadataRaw(sessionsDir, "app-1"); + expect(staleWorker!["pr"]).toBeUndefined(); + expect(staleWorker!["prAutoDetect"]).toBe("off"); + + const activeWorker = readMetadataRaw(sessionsDir, "app-2"); + expect(activeWorker!["pr"]).toBeUndefined(); + expect(activeWorker!["prAutoDetect"]).toBe("off"); + + const claimant = readMetadataRaw(sessionsDir, "app-3"); + expect(claimant!["pr"]).toBe("https://github.com/org/my-app/pull/42"); + }); + it("automatically consolidates ownership when another session tracks the PR", async () => { const mockSCM = makeSCM(); diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 354d04dbe..084df91e7 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -29,6 +29,13 @@ You are the **orchestrator agent** for the ${project.name} project. Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself — you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help.`); + sections.push(`## Non-Negotiable Rules + +- Investigations from the orchestrator session are **read-only**. Inspect status, logs, metadata, PR state, and worker output, but do not edit repository files or implement fixes from the orchestrator session. +- Any code change, test run tied to implementation, git branch work, or PR takeover must be delegated to a **worker session**. +- The orchestrator session must never own a PR. Never claim a PR into the orchestrator session, and never treat the orchestrator as the worker responsible for implementation. +- If an investigation discovers follow-up work, either spawn a worker session or direct an existing worker session with clear instructions.`); + // Project Info sections.push(`## Project Info @@ -57,7 +64,7 @@ ao session ls -p ${projectId} # Send message to a session ao send ${project.sessionPrefix}-1 "Your message here" -# Claim an existing PR for a session +# Claim an existing PR for a worker session ao session claim-pr 123 ${project.sessionPrefix}-1 # Kill a session @@ -76,7 +83,7 @@ ao open ${projectId} | \`ao spawn [issue] [--claim-pr ]\` | Spawn a worker session, optionally attached to an existing PR | | \`ao batch-spawn \` | Spawn multiple sessions in parallel | | \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | -| \`ao session claim-pr [session]\` | Attach an existing PR to a session | +| \`ao session claim-pr [session]\` | Attach an existing PR to a worker session | | \`ao session attach \` | Attach to a session's tmux window | | \`ao session kill \` | Kill a specific session | | \`ao session cleanup [-p project]\` | Kill completed/merged sessions | @@ -114,14 +121,24 @@ ao send ${project.sessionPrefix}-1 "Please address the review comments on your P ### PR Takeover -If a session needs to continue work on an existing PR: +If a worker session needs to continue work on an existing PR: \`\`\`bash ao session claim-pr 123 ${project.sessionPrefix}-1 # or do it at spawn time ao spawn ${projectId} --claim-pr 123 \`\`\` -This updates AO metadata, switches the worktree onto the PR branch, and lets lifecycle reactions keep routing CI and review feedback to that session. +This updates AO metadata, switches the worker worktree onto the PR branch, and lets lifecycle reactions keep routing CI and review feedback to that worker session. + +Never claim a PR into \`${project.sessionPrefix}-orchestrator\`. If a PR needs implementation or takeover, delegate it to a worker session instead. + +### Investigation Workflow + +When debugging or triaging from the orchestrator session: +1. Inspect with read-only commands such as \`ao status\`, \`ao session ls\`, \`ao session attach\`, and SCM/tracker lookups. +2. Decide whether a worker already owns the work or a new worker is needed. +3. Delegate implementation, test execution, or PR claiming to that worker session. +4. Return to monitoring and coordination once the worker has the task. ### Cleanup @@ -195,7 +212,7 @@ When an agent needs human judgment: 2. Check the dashboard or \`ao status\` for details 3. Attach to the session if needed: \`ao session attach \` 4. Send instructions: \`ao send '...'\` -5. Or handle it yourself (merge PR, close issue, etc.)`); +5. Or handle the human-only action yourself (merge PR, close issue, etc.) while keeping implementation in worker sessions.`); // Tips sections.push(`## Tips diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 658e11b81..84ebf4af0 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,7 +11,7 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ -import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs"; +import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync } from "node:fs"; import { execFile } from "node:child_process"; import { basename, join, resolve } from "node:path"; import { homedir } from "node:os"; @@ -192,6 +192,14 @@ function getNextSessionNumber(existingSessions: string[], prefix: string): numbe return max + 1; } +function getSessionNumber(sessionId: string, prefix: string): number | undefined { + const match = sessionId.match(new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`)); + if (!match) return undefined; + + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + const PR_TRACKING_STATUSES: ReadonlySet = new Set([ "pr_open", "ci_failed", @@ -201,6 +209,11 @@ const PR_TRACKING_STATUSES: ReadonlySet = new Set([ "mergeable", ]); +const STALE_PR_OWNERSHIP_STATUSES: ReadonlySet = new Set([ + ...PR_TRACKING_STATUSES, + "merged", +]); + const SEND_RESTORE_READY_TIMEOUT_MS = 5_000; const SEND_RESTORE_READY_POLL_MS = 500; const SEND_CONFIRMATION_ATTEMPTS = 3; @@ -240,6 +253,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectId: string; } + interface ActiveSessionRecord { + sessionName: string; + raw: Record; + modifiedAt?: Date; + } + /** * Get the sessions directory for a project. */ @@ -305,43 +324,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return roots.some((root) => isPathInside(workspacePath, root)); } - /** - * List all session files across all projects (or filtered by projectId). - * Scans project-specific directories under ~/.agent-orchestrator/{hash}-{projectId}/sessions/ - * - * Note: projectId is the config key (e.g., "test-project"), not the path basename. - */ - function listAllSessions(projectIdFilter?: string): { sessionName: string; projectId: string }[] { - const results: { sessionName: string; projectId: string }[] = []; - - // Scan each project's sessions directory - for (const [projectKey, project] of Object.entries(config.projects)) { - // Use config key as projectId for consistency with metadata - const projectId = projectKey; - - // Filter by project if specified - if (projectIdFilter && projectId !== projectIdFilter) continue; - - const sessionsDir = getSessionsDir(config.configPath, project.path); - if (!existsSync(sessionsDir)) continue; - - const files = readdirSync(sessionsDir); - for (const file of files) { - if (file === "archive" || file.startsWith(".")) continue; - const fullPath = join(sessionsDir, file); - try { - if (statSync(fullPath).isFile()) { - results.push({ sessionName: file, projectId }); - } - } catch { - // Skip files that can't be stat'd - } - } - } - - return results; - } - function listArchivedSessionIds(sessionsDir: string): string[] { const archiveDir = join(sessionsDir, "archive"); if (!existsSync(archiveDir)) return []; @@ -353,6 +335,173 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return [...ids]; } + function isOrchestratorSessionRecord( + sessionId: string, + raw: Record | null | undefined, + ): boolean { + if (!raw) return false; + return raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator"); + } + + function applyMetadataUpdatesToRaw( + raw: Record, + updates: Partial>, + ): Record { + let next = { ...raw }; + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) continue; + if (value === "") { + const { [key]: _removed, ...rest } = next; + void _removed; + next = rest; + continue; + } + next[key] = value; + } + return next; + } + + function updateMetadataPreservingMtime( + sessionsDir: string, + sessionName: string, + updates: Partial>, + modifiedAt?: Date, + ): void { + const metaPath = join(sessionsDir, sessionName); + let preservedMtime = modifiedAt; + if (!preservedMtime) { + try { + preservedMtime = statSync(metaPath).mtime; + } catch { + preservedMtime = undefined; + } + } + + updateMetadata(sessionsDir, sessionName, updates); + + if (!preservedMtime) return; + try { + utimesSync(metaPath, preservedMtime, preservedMtime); + } catch { + void 0; + } + } + + function repairSingleSessionMetadataOnRead( + sessionsDir: string, + record: ActiveSessionRecord, + ): ActiveSessionRecord { + const repaired = { ...record, raw: { ...record.raw } }; + if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw)) { + return repaired; + } + + const updates: Partial> = {}; + if (repaired.raw["role"] !== "orchestrator") { + updates["role"] = "orchestrator"; + } + if (repaired.raw["pr"]) { + updates["pr"] = ""; + } + if (repaired.raw["prAutoDetect"] !== "off") { + updates["prAutoDetect"] = "off"; + } + if (STALE_PR_OWNERSHIP_STATUSES.has(repaired.raw["status"] ?? "")) { + updates["status"] = "working"; + } + + if (Object.keys(updates).length > 0) { + updateMetadataPreservingMtime( + sessionsDir, + repaired.sessionName, + updates, + repaired.modifiedAt, + ); + repaired.raw = applyMetadataUpdatesToRaw(repaired.raw, updates); + } + + return repaired; + } + + function sessionMetadataTimestamp(record: ActiveSessionRecord): number { + const metadataTimestamp = Date.parse(record.raw["restoredAt"] ?? record.raw["createdAt"] ?? ""); + if (record.modifiedAt) return record.modifiedAt.getTime(); + return Number.isNaN(metadataTimestamp) ? 0 : metadataTimestamp; + } + + function repairSessionMetadataOnRead( + sessionsDir: string, + records: ActiveSessionRecord[], + ): ActiveSessionRecord[] { + const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } })); + const duplicatePRAttachments = new Map(); + + for (const record of repaired) { + if (isOrchestratorSessionRecord(record.sessionName, record.raw)) { + record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record).raw; + continue; + } + + const prUrl = record.raw["pr"]; + if (!prUrl) continue; + + const attached = duplicatePRAttachments.get(prUrl) ?? []; + attached.push(record); + duplicatePRAttachments.set(prUrl, attached); + } + + for (const attachedRecords of duplicatePRAttachments.values()) { + if (attachedRecords.length < 2) continue; + + const [owner, ...staleRecords] = [...attachedRecords].sort((a, b) => { + const trackingDiff = + Number(PR_TRACKING_STATUSES.has(b.raw["status"] ?? "")) - + Number(PR_TRACKING_STATUSES.has(a.raw["status"] ?? "")); + if (trackingDiff !== 0) return trackingDiff; + + const timestampDiff = sessionMetadataTimestamp(b) - sessionMetadataTimestamp(a); + if (timestampDiff !== 0) return timestampDiff; + + return b.sessionName.localeCompare(a.sessionName); + }); + + void owner; + + for (const record of staleRecords) { + const updates: Partial> = { + pr: "", + prAutoDetect: "off", + ...(PR_TRACKING_STATUSES.has(record.raw["status"] ?? "") ? { status: "working" } : {}), + }; + updateMetadataPreservingMtime(sessionsDir, record.sessionName, updates, record.modifiedAt); + record.raw = applyMetadataUpdatesToRaw(record.raw, updates); + } + } + + return repaired; + } + + function loadActiveSessionRecords(project: ProjectConfig): ActiveSessionRecord[] { + const sessionsDir = getProjectSessionsDir(project); + if (!existsSync(sessionsDir)) return []; + + const records = listMetadata(sessionsDir).flatMap((sessionName) => { + const raw = readMetadataRaw(sessionsDir, sessionName); + if (!raw) return []; + + let modifiedAt: Date | undefined; + try { + modifiedAt = statSync(join(sessionsDir, sessionName)).mtime; + } catch { + void 0; + } + + return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord]; + }); + + return repairSessionMetadataOnRead(sessionsDir, records); + } + function markArchivedOpenCodeCleanup(sessionsDir: string, sessionId: SessionId): void { updateArchivedMetadata(sessionsDir, sessionId, { opencodeSessionId: "", @@ -442,6 +591,81 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return candidateIds[0]; } + async function listRemoteSessionNumbers(project: ProjectConfig): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["ls-remote", "--heads", "origin", `session/${project.sessionPrefix}-*`], + { + cwd: project.path, + timeout: 5_000, + }, + ); + + return stdout + .split("\n") + .flatMap((line: string) => { + const trimmed = line.trim(); + if (!trimmed) return []; + + const ref = trimmed.split(/\s+/)[1] ?? ""; + const match = ref.match( + new RegExp(`refs/heads/session/${escapeRegex(project.sessionPrefix)}-(\\d+)$`), + ); + if (!match) return []; + + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? [] : [parsed]; + }) + .filter((num: number, index: number, values: number[]) => values.indexOf(num) === index); + } catch { + return []; + } + } + + async function reserveNextSessionIdentity( + project: ProjectConfig, + sessionsDir: string, + ): Promise<{ + num: number; + sessionId: string; + tmuxName: string | undefined; + }> { + const usedNumbers = new Set(); + for (const sessionName of [ + ...listMetadata(sessionsDir), + ...listArchivedSessionIds(sessionsDir), + ]) { + const num = getSessionNumber(sessionName, project.sessionPrefix); + if (num !== undefined) usedNumbers.add(num); + } + for (const num of await listRemoteSessionNumbers(project)) { + usedNumbers.add(num); + } + + let num = getNextSessionNumber( + [...usedNumbers].map((value) => `${project.sessionPrefix}-${value}`), + project.sessionPrefix, + ); + for (let attempts = 0; attempts < 10_000; attempts++) { + const sessionId = `${project.sessionPrefix}-${num}`; + const tmuxName = config.configPath + ? generateTmuxName(config.configPath, project.sessionPrefix, num) + : undefined; + + if (!usedNumbers.has(num) && reserveSessionId(sessionsDir, sessionId)) { + return { num, sessionId, tmuxName }; + } + + usedNumbers.add(num); + num += 1; + } + + throw new Error( + `Failed to reserve session ID after 10000 attempts (prefix: ${project.sessionPrefix})`, + ); + } + /** Resolve which plugins to use for a project. */ function resolvePlugins(project: ProjectConfig, agentOverride?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); @@ -487,7 +711,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const sessionsDir = getProjectSessionsDir(project); const raw = readMetadataRaw(sessionsDir, sessionId); if (!raw) continue; - return { raw, sessionsDir, project, projectId }; + + let modifiedAt: Date | undefined; + try { + modifiedAt = statSync(join(sessionsDir, sessionId)).mtime; + } catch { + modifiedAt = undefined; + } + + const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { + sessionName: sessionId, + raw, + modifiedAt, + }); + + return { raw: repaired.raw, sessionsDir, project, projectId }; } return null; @@ -655,29 +893,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } // Determine session ID — atomically reserve to prevent concurrent collisions - const existingSessions = listMetadata(sessionsDir); - let num = getNextSessionNumber(existingSessions, project.sessionPrefix); - let sessionId: string; - let tmuxName: string | undefined; - for (let attempts = 0; attempts < 10; attempts++) { - sessionId = `${project.sessionPrefix}-${num}`; - // Generate tmux name if using new architecture - if (config.configPath) { - tmuxName = generateTmuxName(config.configPath, project.sessionPrefix, num); - } - if (reserveSessionId(sessionsDir, sessionId)) break; - num++; - if (attempts === 9) { - throw new Error( - `Failed to reserve session ID after 10 attempts (prefix: ${project.sessionPrefix})`, - ); - } - } - // Reassign to satisfy TypeScript's flow analysis (not redundant from compiler's perspective) - sessionId = `${project.sessionPrefix}-${num}`; - if (config.configPath) { - tmuxName = generateTmuxName(config.configPath, project.sessionPrefix, num); - } + const { sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir); // Determine branch name — explicit branch always takes priority let branch: string; @@ -1173,16 +1389,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } async function list(projectId?: string): Promise { - const allSessions = listAllSessions(projectId); + const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => { + if (projectId && entryProjectId !== projectId) return []; + return loadActiveSessionRecords(project).map((record) => ({ + sessionName: record.sessionName, + projectId: entryProjectId, + raw: record.raw, + })); + }); let openCodeSessionListPromise: Promise | undefined; - const tasks = allSessions.map(async ({ sessionName, projectId: sessionProjectId }) => { + const tasks = allSessions.map(async ({ sessionName, projectId: sessionProjectId, raw }) => { const project = config.projects[sessionProjectId]; if (!project) return null; const sessionsDir = getProjectSessionsDir(project); - const raw = readMetadataRaw(sessionsDir, sessionName); - if (!raw) return null; let createdAt: Date | undefined; let modifiedAt: Date | undefined; @@ -1251,9 +1472,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); + const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { + sessionName: sessionId, + raw, + modifiedAt, + }); - const selectedAgentName = raw["agent"]; + const session = metadataToSession(sessionId, repaired.raw, createdAt, modifiedAt); + + const selectedAgentName = repaired.raw["agent"]; const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; const plugins = resolvePlugins(project, effectiveAgentName); await ensureHandleAndEnrich( @@ -1713,7 +1940,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (!reference) throw new Error("PR reference is required"); const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); - if (raw["role"] === "orchestrator") { + if (isOrchestratorSessionRecord(sessionId, raw)) { throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`); } @@ -1732,11 +1959,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const conflictingSessions = new Set(); - for (const { sessionName } of listAllSessions(projectId)) { - if (sessionName === sessionId) continue; + const activeRecords = loadActiveSessionRecords(project).filter( + (record) => record.sessionName !== sessionId, + ); - const otherRaw = readMetadataRaw(sessionsDir, sessionName); - if (!otherRaw || otherRaw["role"] === "orchestrator") continue; + for (const { sessionName, raw: otherRaw } of activeRecords) { + if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw)) continue; const samePr = otherRaw["pr"] === pr.url; const sameBranch =