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", + }, ); }); 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 f49483b9d3..aba3167db0 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -247,6 +247,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/__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", diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index 39bb68b602..4d189424af 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -137,7 +137,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/agent-report.ts b/packages/core/src/agent-report.ts index 091f56a3d2..4ab7b70871 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -35,6 +35,7 @@ import { parseCanonicalLifecycle, } from "./lifecycle-state.js"; import { parsePrFromUrl } from "./utils/pr.js"; +import { deriveSessionKindFromMetadata } from "./utils/session-kind.js"; import { assertValidSessionIdComponent } from "./utils/session-id.js"; import { validateStatus } from "./utils/validation.js"; @@ -393,6 +394,7 @@ export function applyAgentReport( dataDir: string, sessionId: SessionId, input: ApplyAgentReportInput, + options: { sessionPrefix?: string } = {}, ): ApplyAgentReportResult { const projectId = inferProjectIdFromDataDir(dataDir); const raw = readMetadataRaw(dataDir, sessionId); @@ -460,6 +462,7 @@ export function applyAgentReport( const current = cloneLifecycle( parseCanonicalLifecycle(existing, { sessionId, + sessionKind: deriveSessionKindFromMetadata(sessionId, existing, options.sessionPrefix), status: validateStatus(existing["status"]), }), ); diff --git a/packages/core/src/lifecycle-state.ts b/packages/core/src/lifecycle-state.ts index 6192c23a96..aabd81bae0 100644 --- a/packages/core/src/lifecycle-state.ts +++ b/packages/core/src/lifecycle-state.ts @@ -274,10 +274,7 @@ function synthesizeCanonicalLifecycle( ): CanonicalSessionLifecycle { const status = options.status ?? validateStatus(meta["status"]); const sessionKind: SessionKind = - options.sessionKind ?? - (meta["role"] === "orchestrator" || options.sessionId?.endsWith("-orchestrator") - ? "orchestrator" - : "worker"); + options.sessionKind ?? (meta["role"] === "orchestrator" ? "orchestrator" : "worker"); const now = options.createdAt?.toISOString() ?? normalizeTimestamp(meta["createdAt"], new Date().toISOString()) ?? diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index c93164f5c9..bc50ffd633 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -37,6 +37,7 @@ import { deriveLegacyStatus, parseCanonicalLifecycle, } from "./lifecycle-state.js"; +import { deriveSessionKindFromMetadata } from "./utils/session-kind.js"; import { assertValidSessionIdComponent, SESSION_ID_COMPONENT_PATTERN } from "./utils/session-id.js"; import { flattenToStringRecord } from "./utils/metadata-flatten.js"; import { validateStatus } from "./utils/validation.js"; @@ -464,7 +465,11 @@ export function readCanonicalLifecycle( ): CanonicalSessionLifecycle | null { const raw = readMetadataRaw(dataDir, sessionId); if (!raw) return null; - return parseCanonicalLifecycle(raw, { sessionId, status: validateStatus(raw["status"]) }); + return parseCanonicalLifecycle(raw, { + sessionId, + sessionKind: deriveSessionKindFromMetadata(sessionId, raw), + status: validateStatus(raw["status"]), + }); } export function writeCanonicalLifecycle( @@ -484,6 +489,7 @@ export function updateCanonicalLifecycle( if (!raw) return null; const current = parseCanonicalLifecycle(raw, { sessionId, + sessionKind: deriveSessionKindFromMetadata(sessionId, raw), status: validateStatus(raw["status"]), }); const next = updater(cloneLifecycle(current)); diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index c9d7728ff3..5d351e8705 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -8,6 +8,7 @@ import type { import { recordActivityEvent } from "../activity-events.js"; import { updateMetadata } from "../metadata.js"; import { getProjectSessionsDir } from "../paths.js"; +import { deriveSessionKindFromMetadata } from "../utils/session-kind.js"; import { validateStatus } from "../utils/validation.js"; import { sessionFromMetadata } from "../utils/session-from-metadata.js"; import { @@ -30,12 +31,18 @@ import type { RecoveryAssessment, RecoveryResult, RecoveryContext } from "./type */ function buildLifecycleRecoveryPatch( rawMetadata: Record, + sessionId: string, + sessionPrefix: string | undefined, next: { state: CanonicalSessionLifecycle["session"]["state"]; reason: CanonicalSessionLifecycle["session"]["reason"]; terminatedAt?: string }, ): Partial> { if (!rawMetadata["lifecycle"] && !(rawMetadata["statePayload"] && rawMetadata["stateVersion"] === "2")) { return {}; } - const current = parseCanonicalLifecycle(rawMetadata); + const current = parseCanonicalLifecycle(rawMetadata, { + sessionId, + sessionKind: deriveSessionKindFromMetadata(sessionId, rawMetadata, sessionPrefix), + status: validateStatus(rawMetadata["status"]), + }); const updated = cloneLifecycle(current); const nowIso = new Date().toISOString(); updated.session = { @@ -108,7 +115,7 @@ export async function recoverSession( escalationReason: `Exceeded max recovery attempts (${context.recoveryConfig.maxRecoveryAttempts})`, recoveryCount: String(recoveryCount), ...preserveSessionAgentPatch(rawMetadata), - ...buildLifecycleRecoveryPatch(rawMetadata, { + ...buildLifecycleRecoveryPatch(rawMetadata, sessionId, project.sessionPrefix, { state: "stuck", reason: "probe_failure", }), @@ -142,6 +149,11 @@ export async function recoverSession( const session = sessionFromMetadata(sessionId, updatedMetadata, { projectId: assessment.projectId, workspacePathFallback: assessment.workspacePath ?? undefined, + sessionKind: deriveSessionKindFromMetadata( + sessionId, + updatedMetadata, + project.sessionPrefix, + ), status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), @@ -234,7 +246,7 @@ export async function cleanupSession( terminatedAt: cleanupAt, terminationReason: "cleanup", ...preserveSessionAgentPatch(rawMetadata), - ...buildLifecycleRecoveryPatch(rawMetadata, { + ...buildLifecycleRecoveryPatch(rawMetadata, sessionId, project.sessionPrefix, { state: "terminated", reason: "auto_cleanup", terminatedAt: cleanupAt, @@ -294,7 +306,7 @@ export async function escalateSession( escalatedAt: new Date().toISOString(), escalationReason: reason, ...preserveSessionAgentPatch(rawMetadata), - ...buildLifecycleRecoveryPatch(rawMetadata, { + ...buildLifecycleRecoveryPatch(rawMetadata, sessionId, project.sessionPrefix, { state: "stuck", reason: "probe_failure", }), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6eb6435944..3f6d3976ff 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 { dedupePrUrls } from "./utils/pr.js"; +import { deriveSessionKindFromMetadata } from "./utils/session-kind.js"; import { safeJsonParse, validateStatus } from "./utils/validation.js"; import { isGitBranchNameSafe } from "./utils.js"; import { resolveAgentSelection, resolveAgentSelectionForSession } from "./agent-selection.js"; @@ -354,13 +355,7 @@ function metadataToSession( meta: Record, options: MetadataToSessionOptions, ): Session { - const sessionKind = - meta["role"] === "orchestrator" || - (options.sessionPrefix - ? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) - : false) - ? "orchestrator" - : "worker"; + const sessionKind = deriveSessionKindFromMetadata(sessionId, meta, options.sessionPrefix); return sessionFromMetadata(sessionId, meta, { projectId: options.projectId, workspacePathFallback: options.workspacePathFallback, @@ -437,17 +432,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionPrefix?: string, ): boolean { if (!raw) return false; - if (raw["role"] === "orchestrator") return true; - // Check the -orchestrator-N pattern only when the prefix is known so the - // regex is anchored to the project prefix, preventing false-positives when - // the user-configured sessionPrefix itself ends with "-orchestrator". - if (sessionPrefix) { - if (sessionId === `${sessionPrefix}-orchestrator`) { - return true; - } - return new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId); - } - return false; + return deriveSessionKindFromMetadata(sessionId, raw, sessionPrefix) === "orchestrator"; } function isCleanupProtectedSession( 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 new file mode 100644 index 0000000000..a8c490c279 --- /dev/null +++ b/packages/core/src/utils/session-kind.ts @@ -0,0 +1,16 @@ +import type { SessionId, SessionKind } from "../types.js"; +import { escapeRegex } from "./regex.js"; + +export function deriveSessionKindFromMetadata( + sessionId: SessionId, + meta: Record, + sessionPrefix?: string, +): SessionKind { + if (meta["role"] === "orchestrator") return "orchestrator"; + if (!sessionPrefix) return "worker"; + if (sessionId === `${sessionPrefix}-orchestrator`) return "orchestrator"; + if (new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)) { + return "orchestrator"; + } + return "worker"; +}