Skip to content

Commit 1d960fe

Browse files
fix: protect orchestrators from session cleanup (#453)
* fix: harden orchestrator cleanup selection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: suppress orchestrators in cleanup command output Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: remove unreachable cleanup guard branch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 4e93bc1 commit 1d960fe

File tree

6 files changed

+283
-11
lines changed

6 files changed

+283
-11
lines changed

packages/cli/__tests__/commands/session.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,77 @@ describe("session cleanup", () => {
565565
expect(output).toContain("Cleaned: app-2");
566566
});
567567

568+
it("suppresses orchestrator cleanup output while preserving worker cleanup output", async () => {
569+
mockSessionManager.cleanup.mockResolvedValue({
570+
killed: ["app-orchestrator", "app-2"],
571+
skipped: [],
572+
errors: [{ sessionId: "app-orchestrator", error: "should never surface" }],
573+
} satisfies CleanupResult);
574+
575+
await program.parseAsync(["node", "test", "session", "cleanup"]);
576+
577+
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
578+
const errOutput = vi
579+
.mocked(console.error)
580+
.mock.calls.map((c) => String(c[0]))
581+
.join("\n");
582+
583+
expect(output).toContain("Cleaned: app-2");
584+
expect(output).not.toContain("app-orchestrator");
585+
expect(output).toContain("Cleanup complete. 1 sessions cleaned");
586+
expect(errOutput).not.toContain("app-orchestrator");
587+
});
588+
589+
it("treats orchestrator-only cleanup results as no-op output", async () => {
590+
mockSessionManager.cleanup.mockResolvedValue({
591+
killed: ["app-orchestrator"],
592+
skipped: [],
593+
errors: [],
594+
} satisfies CleanupResult);
595+
596+
await program.parseAsync(["node", "test", "session", "cleanup"]);
597+
598+
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
599+
expect(output).toContain("No sessions to clean up");
600+
expect(output).not.toContain("app-orchestrator");
601+
});
602+
603+
it("suppresses orchestrators in cleanup dry-run output", async () => {
604+
mockSessionManager.cleanup.mockResolvedValue({
605+
killed: ["app-orchestrator", "app-3"],
606+
skipped: [],
607+
errors: [],
608+
} satisfies CleanupResult);
609+
610+
await program.parseAsync(["node", "test", "session", "cleanup", "--dry-run"]);
611+
612+
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
613+
expect(output).toContain("Would kill app-3");
614+
expect(output).not.toContain("app-orchestrator");
615+
expect(output).toContain("1 session would be cleaned");
616+
});
617+
618+
it("suppresses project-prefixed orchestrator cleanup results", async () => {
619+
mockSessionManager.cleanup.mockResolvedValue({
620+
killed: ["my-app:app-orchestrator", "my-app:app-4"],
621+
skipped: [],
622+
errors: [{ sessionId: "my-app:app-orchestrator", error: "should never surface" }],
623+
} satisfies CleanupResult);
624+
625+
await program.parseAsync(["node", "test", "session", "cleanup"]);
626+
627+
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
628+
const errOutput = vi
629+
.mocked(console.error)
630+
.mock.calls.map((c) => String(c[0]))
631+
.join("\n");
632+
633+
expect(output).toContain("Cleaned: my-app:app-4");
634+
expect(output).not.toContain("my-app:app-orchestrator");
635+
expect(output).toContain("Cleanup complete. 1 sessions cleaned");
636+
expect(errOutput).not.toContain("my-app:app-orchestrator");
637+
});
638+
568639
it("skips sessions without metadata", async () => {
569640
// No metadata files exist — list returns empty, cleanup returns empty
570641
mockSessionManager.cleanup.mockResolvedValue({

packages/cli/__tests__/lib/session-utils.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect } from "vitest";
2-
import { escapeRegex, matchesPrefix, findProjectForSession } from "../../src/lib/session-utils.js";
2+
import {
3+
escapeRegex,
4+
matchesPrefix,
5+
findProjectForSession,
6+
isOrchestratorSessionName,
7+
} from "../../src/lib/session-utils.js";
38
import type { OrchestratorConfig } from "@composio/ao-core";
49

510
describe("escapeRegex", () => {
@@ -103,3 +108,42 @@ describe("findProjectForSession", () => {
103108
expect(findProjectForSession(config, "b-2")).toBe("beta");
104109
});
105110
});
111+
112+
describe("isOrchestratorSessionName", () => {
113+
const makeConfig = (projects: Record<string, { sessionPrefix?: string }>): OrchestratorConfig =>
114+
({
115+
dataDir: "/tmp",
116+
worktreeDir: "/tmp/wt",
117+
port: 3000,
118+
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
119+
projects: Object.fromEntries(
120+
Object.entries(projects).map(([id, p]) => [
121+
id,
122+
{ name: id, repo: "", path: "", defaultBranch: "main", ...p },
123+
]),
124+
),
125+
notifiers: {},
126+
notificationRouting: {},
127+
reactions: {},
128+
}) as OrchestratorConfig;
129+
130+
it("matches the canonical orchestrator ID for a known project", () => {
131+
const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
132+
expect(isOrchestratorSessionName(config, "app-orchestrator", "my-app")).toBe(true);
133+
});
134+
135+
it("matches orchestrator IDs across configured projects without an explicit project", () => {
136+
const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
137+
expect(isOrchestratorSessionName(config, "app-orchestrator")).toBe(true);
138+
});
139+
140+
it("falls back to suffix detection for legacy orchestrator names", () => {
141+
const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
142+
expect(isOrchestratorSessionName(config, "legacy-orchestrator")).toBe(true);
143+
});
144+
145+
it("does not classify worker session IDs as orchestrators", () => {
146+
const config = makeConfig({ "my-app": { sessionPrefix: "app" } });
147+
expect(isOrchestratorSessionName(config, "app-12", "my-app")).toBe(false);
148+
});
149+
});

packages/cli/src/commands/session.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@c
55
import { git, getTmuxActivity, tmux } from "../lib/shell.js";
66
import { formatAge } from "../lib/format.js";
77
import { getSessionManager } from "../lib/create-session-manager.js";
8+
import { isOrchestratorSessionName } from "../lib/session-utils.js";
89

910
export function registerSession(program: Command): void {
1011
const session = program
@@ -145,10 +146,31 @@ export function registerSession(program: Command): void {
145146

146147
const sm = await getSessionManager(config);
147148

149+
const filterCleanupIds = (ids: string[]): string[] =>
150+
ids.filter((entry) => {
151+
const separator = entry.indexOf(":");
152+
const entryProjectId = separator === -1 ? opts.project : entry.slice(0, separator);
153+
const sessionId = separator === -1 ? entry : entry.slice(separator + 1);
154+
return !isOrchestratorSessionName(config, sessionId, entryProjectId);
155+
});
156+
157+
const filterCleanupErrors = (errors: Array<{ sessionId: string; error: string }>) =>
158+
errors.filter(({ sessionId }) => {
159+
const separator = sessionId.indexOf(":");
160+
const entryProjectId = separator === -1 ? opts.project : sessionId.slice(0, separator);
161+
const normalizedSessionId = separator === -1 ? sessionId : sessionId.slice(separator + 1);
162+
return !isOrchestratorSessionName(config, normalizedSessionId, entryProjectId);
163+
});
164+
148165
if (opts.dryRun) {
149166
// Dry-run delegates to sm.cleanup() with dryRun flag so it uses the
150167
// same live checks (PR state, runtime alive, tracker) as actual cleanup.
151-
const result = await sm.cleanup(opts.project, { dryRun: true });
168+
const rawResult = await sm.cleanup(opts.project, { dryRun: true });
169+
const result = {
170+
...rawResult,
171+
killed: filterCleanupIds(rawResult.killed),
172+
errors: filterCleanupErrors(rawResult.errors),
173+
};
152174

153175
if (result.errors.length > 0) {
154176
for (const { sessionId, error } of result.errors) {
@@ -171,7 +193,12 @@ export function registerSession(program: Command): void {
171193
}
172194
}
173195
} else {
174-
const result = await sm.cleanup(opts.project);
196+
const rawResult = await sm.cleanup(opts.project);
197+
const result = {
198+
...rawResult,
199+
killed: filterCleanupIds(rawResult.killed),
200+
errors: filterCleanupErrors(rawResult.errors),
201+
};
175202

176203
if (result.killed.length === 0 && result.errors.length === 0) {
177204
console.log(chalk.dim(" No sessions to clean up."));

packages/cli/src/lib/session-utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,37 @@ export function findProjectForSession(
1414
config: OrchestratorConfig,
1515
sessionName: string,
1616
): string | null {
17-
for (const [id, project] of Object.entries(config.projects)) {
17+
for (const [id, project] of Object.entries(config.projects) as Array<
18+
[string, OrchestratorConfig["projects"][string]]
19+
>) {
1820
const prefix = project.sessionPrefix || id;
1921
if (matchesPrefix(sessionName, prefix)) {
2022
return id;
2123
}
2224
}
2325
return null;
2426
}
27+
28+
export function isOrchestratorSessionName(
29+
config: OrchestratorConfig,
30+
sessionName: string,
31+
projectId?: string,
32+
): boolean {
33+
if (projectId) {
34+
const project = config.projects[projectId];
35+
if (project && sessionName === `${project.sessionPrefix || projectId}-orchestrator`) {
36+
return true;
37+
}
38+
}
39+
40+
for (const [id, project] of Object.entries(config.projects) as Array<
41+
[string, OrchestratorConfig["projects"][string]]
42+
>) {
43+
const prefix = project.sessionPrefix || id;
44+
if (sessionName === `${prefix}-orchestrator`) {
45+
return true;
46+
}
47+
}
48+
49+
return sessionName.endsWith("-orchestrator");
50+
}

packages/core/src/__tests__/session-manager.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2289,6 +2289,101 @@ describe("cleanup", () => {
22892289
expect(result.skipped).toContain("app-orchestrator");
22902290
});
22912291

2292+
it("never cleans the canonical orchestrator session even with stale worker-like metadata", async () => {
2293+
const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log");
2294+
const mockBin = installMockOpencode("[]", deleteLogPath);
2295+
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
2296+
2297+
const deadRuntime: Runtime = {
2298+
...mockRuntime,
2299+
isAlive: vi.fn().mockResolvedValue(false),
2300+
};
2301+
const mockSCM: SCM = {
2302+
name: "mock-scm",
2303+
detectPR: vi.fn(),
2304+
getPRState: vi.fn().mockResolvedValue("merged"),
2305+
mergePR: vi.fn(),
2306+
closePR: vi.fn(),
2307+
getCIChecks: vi.fn(),
2308+
getCISummary: vi.fn(),
2309+
getReviews: vi.fn(),
2310+
getReviewDecision: vi.fn(),
2311+
getPendingComments: vi.fn(),
2312+
getAutomatedComments: vi.fn(),
2313+
getMergeability: vi.fn(),
2314+
};
2315+
const mockTracker: Tracker = {
2316+
name: "mock-tracker",
2317+
getIssue: vi.fn().mockResolvedValue({
2318+
id: "INT-42",
2319+
title: "Issue",
2320+
description: "",
2321+
url: "https://example.com/INT-42",
2322+
state: "closed",
2323+
labels: [],
2324+
}),
2325+
isCompleted: vi.fn().mockResolvedValue(true),
2326+
issueUrl: vi.fn().mockReturnValue("https://example.com/INT-42"),
2327+
branchName: vi.fn().mockReturnValue("feat/INT-42"),
2328+
generatePrompt: vi.fn().mockResolvedValue(""),
2329+
};
2330+
const registryWithSignals: PluginRegistry = {
2331+
...mockRegistry,
2332+
get: vi.fn().mockImplementation((slot: string) => {
2333+
if (slot === "runtime") return deadRuntime;
2334+
if (slot === "agent") return mockAgent;
2335+
if (slot === "workspace") return mockWorkspace;
2336+
if (slot === "scm") return mockSCM;
2337+
if (slot === "tracker") return mockTracker;
2338+
return null;
2339+
}),
2340+
};
2341+
2342+
writeMetadata(sessionsDir, "app-orchestrator", {
2343+
worktree: "/tmp",
2344+
branch: "main",
2345+
status: "ci_failed",
2346+
project: "my-app",
2347+
issue: "INT-42",
2348+
pr: "https://github.com/org/repo/pull/10",
2349+
agent: "opencode",
2350+
opencodeSessionId: "ses_orchestrator_active",
2351+
runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")),
2352+
});
2353+
2354+
const sm = createSessionManager({ config, registry: registryWithSignals });
2355+
const result = await sm.cleanup();
2356+
2357+
expect(result.killed).not.toContain("app-orchestrator");
2358+
expect(result.skipped).toContain("app-orchestrator");
2359+
expect(existsSync(deleteLogPath)).toBe(false);
2360+
});
2361+
2362+
it("never cleans archived orchestrator mappings even when metadata looks stale", async () => {
2363+
const deleteLogPath = join(tmpDir, "opencode-delete-archived-orchestrator.log");
2364+
const mockBin = installMockOpencode("[]", deleteLogPath);
2365+
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
2366+
2367+
writeMetadata(sessionsDir, "app-orchestrator", {
2368+
worktree: "/tmp",
2369+
branch: "main",
2370+
status: "killed",
2371+
project: "my-app",
2372+
agent: "opencode",
2373+
opencodeSessionId: "ses_orchestrator_archived",
2374+
pr: "https://github.com/org/repo/pull/88",
2375+
runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")),
2376+
});
2377+
deleteMetadata(sessionsDir, "app-orchestrator", true);
2378+
2379+
const sm = createSessionManager({ config, registry: mockRegistry });
2380+
const result = await sm.cleanup();
2381+
2382+
expect(result.killed).not.toContain("app-orchestrator");
2383+
expect(result.skipped).toContain("app-orchestrator");
2384+
expect(existsSync(deleteLogPath)).toBe(false);
2385+
});
2386+
22922387
it("kills sessions with dead runtimes", async () => {
22932388
const deadRuntime: Runtime = {
22942389
...mockRuntime,

packages/core/src/session-manager.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,18 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
363363
return raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator");
364364
}
365365

366+
function isCleanupProtectedSession(
367+
project: ProjectConfig,
368+
sessionId: string,
369+
metadata?: Record<string, string> | null,
370+
): boolean {
371+
const canonicalOrchestratorId = `${project.sessionPrefix}-orchestrator`;
372+
return (
373+
sessionId === canonicalOrchestratorId ||
374+
isOrchestratorSession({ id: sessionId, metadata: metadata ?? undefined })
375+
);
376+
}
377+
366378
function applyMetadataUpdatesToRaw(
367379
raw: Record<string, string>,
368380
updates: Partial<Record<string, string>>,
@@ -1643,16 +1655,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
16431655

16441656
for (const session of sessions) {
16451657
try {
1646-
// Never clean up orchestrator sessions — they manage the lifecycle.
1647-
// Check explicit role metadata first, fall back to naming convention
1648-
// for pre-existing sessions spawned before the role field was added.
1649-
if (isOrchestratorSession(session)) {
1658+
const project = config.projects[session.projectId];
1659+
if (!project) {
16501660
pushSkipped(session.projectId, session.id);
16511661
continue;
16521662
}
16531663

1654-
const project = config.projects[session.projectId];
1655-
if (!project) {
1664+
if (isCleanupProtectedSession(project, session.id, session.metadata)) {
16561665
pushSkipped(session.projectId, session.id);
16571666
continue;
16581667
}
@@ -1718,7 +1727,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
17181727
const archived = readArchivedMetadataRaw(sessionsDir, archivedId);
17191728
if (!archived) continue;
17201729

1721-
if (isOrchestratorSession({ id: archivedId, metadata: archived })) {
1730+
if (isCleanupProtectedSession(project, archivedId, archived)) {
17221731
pushSkipped(projectKey, archivedId);
17231732
continue;
17241733
}

0 commit comments

Comments
 (0)