From b66f59b14f27b6c67b45d05659d36e30a256b8b3 Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Mon, 20 Apr 2026 11:12:21 +0530 Subject: [PATCH 1/4] fix(core): prevent legacy foreign orchestrator kind leakage Derive in-memory session kind from project-scoped orchestrator ID rules during metadata reconstruction so foreign-prefix legacy records stay worker-kind unless explicitly marked orchestrator. Made-with: Cursor --- .../__tests__/session-manager/query.test.ts | 5 ++- packages/core/src/session-manager.ts | 42 +++++++++++++++++-- .../core/src/utils/session-from-metadata.ts | 11 ++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index f9494d5e28..0f6f282f99 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -79,7 +79,10 @@ describe("list", () => { }); const sm = createSessionManager({ config, registry: mockRegistry }); - await sm.list("my-app"); + const sessions = await sm.list("my-app"); + const legacy = sessions.find((session) => session.id === "my-app-orchestrator"); + expect(legacy).toBeDefined(); + expect(legacy?.lifecycle.session.kind).toBe("worker"); // After list(), the record on disk must still have no role metadata. const raw = readMetadataRaw(sessionsDir, "my-app-orchestrator"); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 0ded119ff1..2e3e185425 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -246,15 +246,32 @@ async function getTmuxForegroundCommand(sessionName: string): Promise, + sessionPrefix: string, +): "worker" | "orchestrator" { + if (meta["role"] === "orchestrator") return "orchestrator"; + if (sessionId === `${sessionPrefix}-orchestrator`) return "orchestrator"; + if (new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)) { + return "orchestrator"; + } + return "worker"; +} + function metadataToSession( sessionId: SessionId, meta: Record, projectId: string, + sessionPrefix: string | undefined, createdAt?: Date, modifiedAt?: Date, ): Session { + const normalizedSessionPrefix = sessionPrefix ?? projectId; + const sessionKind = deriveSessionKindFromMetadata(sessionId, meta, normalizedSessionPrefix); return sessionFromMetadata(sessionId, meta, { projectId, + sessionKind, createdAt, lastActivityAt: modifiedAt ?? new Date(), }); @@ -1784,7 +1801,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt); + const session = metadataToSession( + sessionName, + raw, + sessionProjectId, + config.projects[sessionProjectId]?.sessionPrefix, + createdAt, + modifiedAt, + ); const selection = resolveSelectionForSession(project, sessionName, raw); const effectiveAgentName = selection.agentName; const plugins = resolvePlugins(project, effectiveAgentName); @@ -1858,7 +1882,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM project.sessionPrefix, ); - const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt); + const session = metadataToSession( + sessionId, + repaired.raw, + projectId, + config.projects[projectId]?.sessionPrefix, + createdAt, + modifiedAt, + ); const selection = resolveSelectionForSession(project, sessionId, repaired.raw); const effectiveAgentName = selection.agentName; @@ -2600,7 +2631,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // metadataToSession sets activity: null, so without enrichment a crashed // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. - const session = metadataToSession(sessionId, raw, projectId); + const session = metadataToSession( + sessionId, + raw, + projectId, + config.projects[projectId]?.sessionPrefix, + ); const plugins = resolvePlugins(project, selection.agentName); await enrichSessionWithRuntimeState(session, plugins, true); diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index d20f9d0566..85fe626518 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -1,4 +1,11 @@ -import type { ActivitySignal, RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js"; +import type { + ActivitySignal, + RuntimeHandle, + Session, + SessionId, + SessionKind, + SessionStatus, +} from "../types.js"; import { deriveLegacyStatus, parseCanonicalLifecycle } from "../lifecycle-state.js"; import { createActivitySignal } from "../activity-signal.js"; import { AGENT_REPORT_METADATA_KEYS } from "../agent-report.js"; @@ -7,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js"; interface SessionFromMetadataOptions { projectId?: string; + sessionKind?: SessionKind; status?: SessionStatus; activity?: Session["activity"]; activitySignal?: ActivitySignal; @@ -45,6 +53,7 @@ export function sessionFromMetadata( : null; const lifecycle = parseCanonicalLifecycle(meta, { sessionId, + sessionKind: options.sessionKind, status: options.status ?? validateStatus(meta["status"]), runtimeHandle, createdAt: options.createdAt, From 5c0fd2b5bbd1669a2a22b42c408ea6aefeb3ad6f Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Fri, 1 May 2026 16:31:49 +0530 Subject: [PATCH 2/4] test(core): cover no implicit orchestrator kind from session-id suffix Made-with: Cursor --- packages/core/src/__tests__/lifecycle-state.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/src/__tests__/lifecycle-state.test.ts b/packages/core/src/__tests__/lifecycle-state.test.ts index 97c0ac61b6..b43d3f3877 100644 --- a/packages/core/src/__tests__/lifecycle-state.test.ts +++ b/packages/core/src/__tests__/lifecycle-state.test.ts @@ -77,6 +77,19 @@ describe("deriveLegacyStatus", () => { }); describe("parseCanonicalLifecycle", () => { + it("does not infer orchestrator kind from sessionId suffix without explicit signal", () => { + const parsed = parseCanonicalLifecycle( + { + status: "working", + }, + { + sessionId: "foreign-project-orchestrator", + }, + ); + + expect(parsed.session.kind).toBe("worker"); + }); + it("rehydrates legacy merged sessions with a merged PR state", () => { const parsed = parseCanonicalLifecycle({ status: "merged", From 11e43843658e8d8cf8b0884ea3376e575f38caca Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Sun, 17 May 2026 05:20:34 +0530 Subject: [PATCH 3/4] fix(core): preserve session kind in agent reports Co-authored-by: Cursor --- packages/cli/src/commands/report.ts | 23 ++++++++++------ .../core/src/__tests__/agent-report.test.ts | 26 +++++++++++++++++++ packages/core/src/agent-report.ts | 5 ++-- packages/core/src/session-manager.ts | 6 +---- packages/core/src/utils/regex.ts | 3 +++ packages/core/src/utils/session-kind.ts | 5 +--- 6 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/utils/regex.ts diff --git a/packages/cli/src/commands/report.ts b/packages/cli/src/commands/report.ts index 927ae2b897..4e94a6accf 100644 --- a/packages/cli/src/commands/report.ts +++ b/packages/cli/src/commands/report.ts @@ -61,14 +61,21 @@ async function writeReport( } const sessionsDir = getProjectSessionsDir(session.projectId); try { - const result = applyAgentReport(sessionsDir, sessionName, { - state, - note, - prUrl, - prNumber, - source, - actor: process.env["USER"] ?? process.env["LOGNAME"] ?? process.env["USERNAME"], - }); + const result = applyAgentReport( + sessionsDir, + sessionName, + { + state, + note, + prUrl, + prNumber, + source, + actor: process.env["USER"] ?? process.env["LOGNAME"] ?? process.env["USERNAME"], + }, + { + sessionPrefix: project.sessionPrefix, + }, + ); const label = result.previousState === result.nextState ? chalk.dim(`(${result.nextState})`) diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index 7e3fddce1e..c5a20923f3 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -239,6 +239,32 @@ describe("applyAgentReport", () => { }); }); + it("uses sessionPrefix to reject legacy orchestrator self-reports", () => { + const orchestratorSessionId = "demo-orchestrator"; + writeMetadata(dataDir, orchestratorSessionId, { + worktree: "/tmp/worktree", + branch: "orchestrator/demo-orchestrator", + status: "working", + project: "demo", + }); + + expect(() => + applyAgentReport( + dataDir, + orchestratorSessionId, + { + state: "needs_input", + now: new Date("2025-01-01T12:00:00.000Z"), + }, + { sessionPrefix: "demo" }, + ), + ).toThrow("orchestrator sessions cannot self-report"); + + const meta = readMetadataRaw(dataDir, orchestratorSessionId); + expect(meta).not.toBeNull(); + expect(meta!["lifecycle"]).toBeUndefined(); + }); + it("records pr_created with PR metadata and pr_open lifecycle", () => { const now = new Date("2025-01-02T09:30:00.000Z"); const result = applyAgentReport(dataDir, sessionId, { diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 89565714c4..69c7478c70 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -383,6 +383,7 @@ export function applyAgentReport( dataDir: string, sessionId: SessionId, input: ApplyAgentReportInput, + options: { sessionPrefix?: string } = {}, ): ApplyAgentReportResult { const raw = readMetadataRaw(dataDir, sessionId); if (!raw) { @@ -433,7 +434,7 @@ export function applyAgentReport( const current = cloneLifecycle( parseCanonicalLifecycle(existing, { sessionId, - sessionKind: deriveSessionKindFromMetadata(sessionId, existing), + sessionKind: deriveSessionKindFromMetadata(sessionId, existing, options.sessionPrefix), status: validateStatus(existing["status"]), }), ); @@ -522,7 +523,7 @@ export function applyAgentReport( const nextLifecycle = parseCanonicalLifecycle(nextMetadata, { sessionId, - sessionKind: deriveSessionKindFromMetadata(sessionId, nextMetadata), + sessionKind: deriveSessionKindFromMetadata(sessionId, nextMetadata, options.sessionPrefix), status: validateStatus(nextMetadata["status"]), }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 012036d571..6fff3b43ca 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -93,6 +93,7 @@ import { } from "./orchestrator-session-strategy.js"; import { sessionFromMetadata } from "./utils/session-from-metadata.js"; import { deriveSessionKindFromMetadata } from "./utils/session-kind.js"; +import { escapeRegex } from "./utils/regex.js"; import { safeJsonParse, validateStatus } from "./utils/validation.js"; import { isGitBranchNameSafe } from "./utils.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; @@ -186,11 +187,6 @@ async function discoverOpenCodeSessionIdByTitle( return matches[0]; } -/** Escape regex metacharacters in a string. */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** Get the next session number for a project. */ function getNextSessionNumber(existingSessions: string[], prefix: string): number { let max = 0; diff --git a/packages/core/src/utils/regex.ts b/packages/core/src/utils/regex.ts new file mode 100644 index 0000000000..fce35d0eda --- /dev/null +++ b/packages/core/src/utils/regex.ts @@ -0,0 +1,3 @@ +export function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/core/src/utils/session-kind.ts b/packages/core/src/utils/session-kind.ts index 034b973d0c..a8c490c279 100644 --- a/packages/core/src/utils/session-kind.ts +++ b/packages/core/src/utils/session-kind.ts @@ -1,8 +1,5 @@ import type { SessionId, SessionKind } from "../types.js"; - -function escapeRegex(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +import { escapeRegex } from "./regex.js"; export function deriveSessionKindFromMetadata( sessionId: SessionId, From ba095e09be8eddfcdde8862365256d578f7c18db Mon Sep 17 00:00:00 2001 From: ChiragArora31 Date: Sun, 17 May 2026 05:26:23 +0530 Subject: [PATCH 4/4] test(cli): expect report session prefix option Co-authored-by: Cursor --- packages/cli/__tests__/commands/report.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/__tests__/commands/report.test.ts b/packages/cli/__tests__/commands/report.test.ts index a288a6c5c4..a10e43a6a2 100644 --- a/packages/cli/__tests__/commands/report.test.ts +++ b/packages/cli/__tests__/commands/report.test.ts @@ -58,6 +58,7 @@ describe("report commands", () => { app: { name: "app", path: "/tmp/app", + sessionPrefix: "app", }, }, }; @@ -97,6 +98,9 @@ describe("report commands", () => { source: "acknowledge", actor: "codex", }), + { + sessionPrefix: "app", + }, ); });