Skip to content
4 changes: 4 additions & 0 deletions packages/cli/__tests__/commands/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("report commands", () => {
app: {
name: "app",
path: "/tmp/app",
sessionPrefix: "app",
},
},
};
Expand Down Expand Up @@ -97,6 +98,9 @@ describe("report commands", () => {
source: "acknowledge",
actor: "codex",
}),
{
sessionPrefix: "app",
},
);
});

Expand Down
23 changes: 15 additions & 8 deletions packages/cli/src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`)
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/__tests__/agent-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/__tests__/lifecycle-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/__tests__/session-manager/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
ChiragArora31 marked this conversation as resolved.

// After list(), the record on disk must still have no role metadata.
const raw = readMetadataRaw(sessionsDir, "my-app-orchestrator");
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -460,6 +462,7 @@ export function applyAgentReport(
const current = cloneLifecycle(
parseCanonicalLifecycle(existing, {
sessionId,
sessionKind: deriveSessionKindFromMetadata(sessionId, existing, options.sessionPrefix),
status: validateStatus(existing["status"]),
}),
);
Expand Down
5 changes: 1 addition & 4 deletions packages/core/src/lifecycle-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) ??
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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));
Expand Down
20 changes: 16 additions & 4 deletions packages/core/src/recovery/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,12 +31,18 @@ import type { RecoveryAssessment, RecoveryResult, RecoveryContext } from "./type
*/
function buildLifecycleRecoveryPatch(
rawMetadata: Record<string, string>,
sessionId: string,
sessionPrefix: string | undefined,
next: { state: CanonicalSessionLifecycle["session"]["state"]; reason: CanonicalSessionLifecycle["session"]["reason"]; terminatedAt?: string },
): Partial<Record<string, string>> {
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 = {
Expand Down Expand Up @@ -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",
}),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}),
Expand Down
21 changes: 3 additions & 18 deletions packages/core/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -354,13 +355,7 @@ function metadataToSession(
meta: Record<string, string>,
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,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/utils/regex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
16 changes: 16 additions & 0 deletions packages/core/src/utils/session-kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { SessionId, SessionKind } from "../types.js";
import { escapeRegex } from "./regex.js";

export function deriveSessionKindFromMetadata(
sessionId: SessionId,
meta: Record<string, string>,
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";
}
Loading