feat: support distinct worker and orchestrator agents (#439)
* feat: add role-specific agent resolution * test: cover role-specific agent precedence * docs: show role-specific agent config * fix: preserve shared agent config fallbacks * fix: avoid role-config permission clobbering * fix: keep orchestrator launches permissionless * fix: remove stale lifecycle imports
This commit is contained in:
parent
0a41b7dcdb
commit
3deea32bda
|
|
@ -19,6 +19,10 @@ port: 3000
|
|||
defaults:
|
||||
runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b
|
||||
agent: claude-code # claude-code | codex | aider | goose | custom
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
# worker:
|
||||
# agent: codex
|
||||
workspace: worktree # worktree | clone | copy
|
||||
notifiers: [desktop] # desktop | slack | discord | webhook | email
|
||||
|
||||
|
|
@ -59,6 +63,16 @@ projects:
|
|||
# permissions: skip # --dangerously-skip-permissions
|
||||
# model: opus
|
||||
|
||||
# Optional role-specific agent overrides
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
# agentConfig:
|
||||
# model: claude-sonnet-4-5
|
||||
# worker:
|
||||
# agent: codex
|
||||
# agentConfig:
|
||||
# model: gpt-5-codex
|
||||
|
||||
# Inline rules included in every agent prompt for this project
|
||||
# agentRules: |
|
||||
# Always run tests before pushing.
|
||||
|
|
|
|||
|
|
@ -414,6 +414,71 @@ describe("Config Schema Validation", () => {
|
|||
expect(validated.projects.proj1.agentConfig?.model).toBe("worker-model");
|
||||
expect(validated.projects.proj1.agentConfig?.orchestratorModel).toBe("orchestrator-model");
|
||||
});
|
||||
|
||||
it("accepts role-specific agent overrides at defaults and project scope", () => {
|
||||
const config = {
|
||||
defaults: {
|
||||
agent: "claude-code",
|
||||
orchestrator: {
|
||||
agent: "opencode",
|
||||
},
|
||||
worker: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
orchestrator: {
|
||||
agent: "claude-code",
|
||||
agentConfig: {
|
||||
model: "orchestrator-model",
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
agent: "codex",
|
||||
agentConfig: {
|
||||
model: "worker-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfig(config);
|
||||
expect(validated.defaults.orchestrator?.agent).toBe("opencode");
|
||||
expect(validated.defaults.worker?.agent).toBe("codex");
|
||||
expect(validated.projects.proj1.orchestrator?.agent).toBe("claude-code");
|
||||
expect(validated.projects.proj1.orchestrator?.agentConfig?.model).toBe("orchestrator-model");
|
||||
expect(validated.projects.proj1.worker?.agent).toBe("codex");
|
||||
expect(validated.projects.proj1.worker?.agentConfig?.model).toBe("worker-model");
|
||||
});
|
||||
|
||||
it("does not inject default permissions into role-specific agent config", () => {
|
||||
const config = validateConfig({
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
agentConfig: {
|
||||
permissions: "suggest",
|
||||
},
|
||||
worker: {
|
||||
agent: "codex",
|
||||
agentConfig: {
|
||||
model: "worker-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.projects.proj1.agentConfig?.permissions).toBe("suggest");
|
||||
expect(config.projects.proj1.worker?.agentConfig?.permissions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Defaults", () => {
|
||||
|
|
|
|||
|
|
@ -205,6 +205,59 @@ describe("check (single session)", () => {
|
|||
expect(meta!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("uses worker-specific agent fallback when metadata does not persist an agent", async () => {
|
||||
const codexAgent: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
processName: "codex",
|
||||
getActivityState: vi.fn().mockResolvedValue({ state: "active" as ActivityState }),
|
||||
};
|
||||
const registryWithMultipleAgents: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") {
|
||||
if (name === "codex") return codexAgent;
|
||||
if (name === "mock-agent") return mockAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const configWithWorkerAgent: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: "mock-agent",
|
||||
worker: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const session = makeSession({ status: "working", metadata: {} });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config: configWithWorkerAgent,
|
||||
registry: registryWithMultipleAgents,
|
||||
sessionManager: mockSessionManager,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(codexAgent.getActivityState).toHaveBeenCalled();
|
||||
expect(mockAgent.getActivityState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects killed state when runtime is dead", async () => {
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { validateSession } from "../recovery/validator.js";
|
||||
import type { ScannedSession } from "../recovery/scanner.js";
|
||||
import type { Agent, OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js";
|
||||
import { getSessionsDir } from "../paths.js";
|
||||
|
||||
describe("recovery validator", () => {
|
||||
let rootDir = "";
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses role-specific orchestrator agent fallback when metadata is missing agent", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
const projectPath = join(rootDir, "project");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const mockRuntime: Runtime = {
|
||||
name: "tmux",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkspace: Workspace = {
|
||||
name: "worktree",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
list: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mockWorkerAgent: Agent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock-agent",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn(),
|
||||
getActivityState: vi.fn(),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn(),
|
||||
};
|
||||
const mockOrchestratorAgent: Agent = {
|
||||
...mockWorkerAgent,
|
||||
name: "codex",
|
||||
processName: "codex",
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const registry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") {
|
||||
if (name === "codex") return mockOrchestratorAgent;
|
||||
if (name === "mock-agent") return mockWorkerAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "mock-agent",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: projectPath,
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
agent: "mock-agent",
|
||||
orchestrator: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
const scanned: ScannedSession = {
|
||||
sessionId: "app-orchestrator",
|
||||
projectId: "app",
|
||||
project: config.projects.app,
|
||||
sessionsDir: getSessionsDir(config.configPath, projectPath),
|
||||
rawMetadata: {
|
||||
worktree: projectPath,
|
||||
status: "working",
|
||||
role: "orchestrator",
|
||||
runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.agentProcessRunning).toBe(true);
|
||||
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
|
||||
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import { join } from "node:path";
|
|||
import { homedir, tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { validateConfig } from "../config.js";
|
||||
import {
|
||||
writeMetadata,
|
||||
readMetadata,
|
||||
|
|
@ -819,6 +820,60 @@ describe("spawn", () => {
|
|||
expect(meta!["agent"]).toBe("mock-agent");
|
||||
});
|
||||
|
||||
it("uses project worker agent when configured and no spawn override is provided", async () => {
|
||||
const configWithWorkerAgent: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: "mock-agent",
|
||||
worker: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithWorkerAgent,
|
||||
registry: registryWithMultipleAgents,
|
||||
});
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("codex");
|
||||
});
|
||||
|
||||
it("uses defaults worker agent when project agent is not set", async () => {
|
||||
const configWithDefaultWorkerAgent: OrchestratorConfig = {
|
||||
...config,
|
||||
defaults: {
|
||||
...config.defaults,
|
||||
worker: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithDefaultWorkerAgent,
|
||||
registry: registryWithMultipleAgents,
|
||||
});
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("codex");
|
||||
});
|
||||
|
||||
it("readMetadata returns agent field (typed SessionMetadata)", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
|
|
@ -3338,6 +3393,191 @@ describe("spawnOrchestrator", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps orchestrator launch permissionless even when shared config sets permissions", async () => {
|
||||
const configWithSharedPermissions: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agentConfig: {
|
||||
permissions: "suggest",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithSharedPermissions,
|
||||
registry: mockRegistry,
|
||||
});
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permissions: "permissionless",
|
||||
projectConfig: expect.objectContaining({
|
||||
agentConfig: expect.objectContaining({ permissions: "permissionless" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses project orchestrator agent when configured", async () => {
|
||||
const mockCodexAgent: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
processName: "codex",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("codex --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }),
|
||||
};
|
||||
const registryWithMultipleAgents: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") {
|
||||
if (name === "codex") return mockCodexAgent;
|
||||
if (name === "mock-agent") return mockAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const configWithOrchestratorAgent: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: "mock-agent",
|
||||
orchestrator: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithOrchestratorAgent,
|
||||
registry: registryWithMultipleAgents,
|
||||
});
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex");
|
||||
});
|
||||
|
||||
it("uses defaults orchestrator agent when project agent is not set", async () => {
|
||||
const mockCodexAgent: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
processName: "codex",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("codex --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }),
|
||||
};
|
||||
const registryWithMultipleAgents: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "agent") {
|
||||
if (name === "codex") return mockCodexAgent;
|
||||
if (name === "mock-agent") return mockAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const configWithDefaultOrchestratorAgent: OrchestratorConfig = {
|
||||
...config,
|
||||
defaults: {
|
||||
...config.defaults,
|
||||
orchestrator: {
|
||||
agent: "codex",
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithDefaultOrchestratorAgent,
|
||||
registry: registryWithMultipleAgents,
|
||||
});
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex");
|
||||
});
|
||||
|
||||
it("keeps shared worker permissions when role-specific config only overrides model", async () => {
|
||||
const configWithSharedPermissions: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agentConfig: {
|
||||
permissions: "suggest",
|
||||
},
|
||||
worker: {
|
||||
agentConfig: {
|
||||
model: "worker-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validatedConfig = validateConfig(configWithSharedPermissions);
|
||||
validatedConfig.configPath = config.configPath;
|
||||
const sm = createSessionManager({
|
||||
config: validatedConfig,
|
||||
registry: mockRegistry,
|
||||
});
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ permissions: "suggest", model: "worker-model" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses role-specific orchestratorModel when configured", async () => {
|
||||
const configWithRoleOrchestratorModel: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agentConfig: {
|
||||
model: "worker-model",
|
||||
orchestratorModel: "shared-orchestrator-model",
|
||||
},
|
||||
orchestrator: {
|
||||
agentConfig: {
|
||||
orchestratorModel: "role-orchestrator-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config: configWithRoleOrchestratorModel,
|
||||
registry: mockRegistry,
|
||||
});
|
||||
await sm.spawnOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: "role-orchestrator-model" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards configured subagent to orchestrator launch", async () => {
|
||||
const configWithSubagent: OrchestratorConfig = {
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
normalizeAgentPermissionMode,
|
||||
isOrchestratorSession,
|
||||
type AgentPermissionMode,
|
||||
type AgentSpecificConfig,
|
||||
type DefaultPlugins,
|
||||
type ProjectConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export type SessionRole = "orchestrator" | "worker";
|
||||
|
||||
export interface ResolvedAgentSelection {
|
||||
role: SessionRole;
|
||||
agentName: string;
|
||||
agentConfig: AgentSpecificConfig;
|
||||
model?: string;
|
||||
permissions?: AgentPermissionMode;
|
||||
subagent?: string;
|
||||
}
|
||||
|
||||
export function resolveSessionRole(
|
||||
sessionId: string,
|
||||
metadata?: Record<string, string>,
|
||||
): SessionRole {
|
||||
return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker";
|
||||
}
|
||||
|
||||
export function resolveAgentSelection(params: {
|
||||
role: SessionRole;
|
||||
project: ProjectConfig;
|
||||
defaults: DefaultPlugins;
|
||||
persistedAgent?: string;
|
||||
spawnAgentOverride?: string;
|
||||
}): ResolvedAgentSelection {
|
||||
const { role, project, defaults, persistedAgent, spawnAgentOverride } = params;
|
||||
const roleProjectConfig = role === "orchestrator" ? project.orchestrator : project.worker;
|
||||
const roleDefaults = role === "orchestrator" ? defaults.orchestrator : defaults.worker;
|
||||
const sharedConfig = project.agentConfig ?? {};
|
||||
const roleAgentConfig = roleProjectConfig?.agentConfig ?? {};
|
||||
|
||||
const agentName = persistedAgent
|
||||
? persistedAgent
|
||||
: role === "worker"
|
||||
? (spawnAgentOverride ??
|
||||
roleProjectConfig?.agent ??
|
||||
project.agent ??
|
||||
roleDefaults?.agent ??
|
||||
defaults.agent)
|
||||
: (roleProjectConfig?.agent ?? project.agent ?? roleDefaults?.agent ?? defaults.agent);
|
||||
|
||||
const agentConfig: AgentSpecificConfig = {
|
||||
...sharedConfig,
|
||||
};
|
||||
for (const [key, value] of Object.entries(roleAgentConfig)) {
|
||||
if (value !== undefined) {
|
||||
agentConfig[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const model =
|
||||
role === "orchestrator"
|
||||
? (roleAgentConfig.orchestratorModel ??
|
||||
roleAgentConfig.model ??
|
||||
sharedConfig.orchestratorModel ??
|
||||
sharedConfig.model)
|
||||
: (roleAgentConfig.model ?? sharedConfig.model);
|
||||
|
||||
if (model !== undefined) {
|
||||
agentConfig.model = model;
|
||||
}
|
||||
|
||||
const permissions = normalizeAgentPermissionMode(
|
||||
typeof agentConfig.permissions === "string" ? agentConfig.permissions : undefined,
|
||||
);
|
||||
if (permissions !== undefined) {
|
||||
agentConfig.permissions = permissions;
|
||||
}
|
||||
const subagent =
|
||||
typeof agentConfig["subagent"] === "string" ? agentConfig["subagent"] : undefined;
|
||||
|
||||
return {
|
||||
role,
|
||||
agentName,
|
||||
agentConfig,
|
||||
model,
|
||||
permissions,
|
||||
subagent,
|
||||
};
|
||||
}
|
||||
|
|
@ -104,6 +104,30 @@ const AgentSpecificConfigSchema = z
|
|||
})
|
||||
.passthrough();
|
||||
|
||||
const RoleAgentSpecificConfigSchema = z
|
||||
.object({
|
||||
permissions: z
|
||||
.union([z.enum(["permissionless", "default", "auto-edit", "suggest"]), z.literal("skip")])
|
||||
.optional(),
|
||||
model: z.string().optional(),
|
||||
orchestratorModel: z.string().optional(),
|
||||
opencodeSessionId: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RoleAgentDefaultsSchema = z
|
||||
.object({
|
||||
agent: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const RoleAgentConfigSchema = z
|
||||
.object({
|
||||
agent: z.string().optional(),
|
||||
agentConfig: RoleAgentSpecificConfigSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const DecomposerConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
|
|
@ -135,6 +159,8 @@ const ProjectConfigSchema = z.object({
|
|||
symlinks: z.array(z.string()).optional(),
|
||||
postCreate: z.array(z.string()).optional(),
|
||||
agentConfig: AgentSpecificConfigSchema.default({}),
|
||||
orchestrator: RoleAgentConfigSchema,
|
||||
worker: RoleAgentConfigSchema,
|
||||
reactions: z.record(ReactionConfigSchema.partial()).optional(),
|
||||
agentRules: z.string().optional(),
|
||||
agentRulesFile: z.string().optional(),
|
||||
|
|
@ -151,6 +177,8 @@ const DefaultPluginsSchema = z.object({
|
|||
agent: z.string().default("claude-code"),
|
||||
workspace: z.string().default("worktree"),
|
||||
notifiers: z.array(z.string()).default(["composio", "desktop"]),
|
||||
orchestrator: RoleAgentDefaultsSchema,
|
||||
worker: RoleAgentDefaultsSchema,
|
||||
});
|
||||
|
||||
const OrchestratorConfigSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { updateMetadata } from "./metadata.js";
|
||||
import { getSessionsDir } from "./paths.js";
|
||||
import { createCorrelationId, createProjectObserver } from "./observability.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
|
||||
/** Parse a duration string like "10m", "30s", "1h" to milliseconds. */
|
||||
function parseDuration(str: string): number {
|
||||
|
|
@ -202,7 +203,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
const project = config.projects[session.projectId];
|
||||
if (!project) return session.status;
|
||||
|
||||
const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const agentName = resolveAgentSelection({
|
||||
role: resolveSessionRole(session.id, session.metadata),
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: session.metadata["agent"],
|
||||
}).agentName;
|
||||
const agent = registry.get<Agent>("agent", agentName);
|
||||
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type RecoveryAction,
|
||||
type RecoveryConfig,
|
||||
} from "./types.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js";
|
||||
|
||||
export async function validateSession(
|
||||
scanned: ScannedSession,
|
||||
|
|
@ -29,7 +30,12 @@ export async function validateSession(
|
|||
const { sessionId, projectId, project, rawMetadata } = scanned;
|
||||
|
||||
const runtimeName = project.runtime ?? config.defaults.runtime;
|
||||
const agentName = rawMetadata["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const agentName = resolveAgentSelection({
|
||||
role: resolveSessionRole(sessionId, rawMetadata),
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: rawMetadata["agent"],
|
||||
}).agentName;
|
||||
const workspaceName = project.workspace ?? config.defaults.workspace;
|
||||
|
||||
const runtime = registry.get<Runtime>("runtime", runtimeName);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import {
|
|||
} from "./global-pause.js";
|
||||
import { sessionFromMetadata } from "./utils/session-from-metadata.js";
|
||||
import { safeJsonParse } from "./utils/validation.js";
|
||||
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000;
|
||||
|
|
@ -690,12 +691,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
/** Resolve which plugins to use for a project. */
|
||||
function resolvePlugins(project: ProjectConfig, agentOverride?: string) {
|
||||
function resolvePlugins(project: ProjectConfig, agentName?: string) {
|
||||
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
|
||||
const agent = registry.get<Agent>(
|
||||
"agent",
|
||||
agentOverride ?? project.agent ?? config.defaults.agent,
|
||||
);
|
||||
const agent = registry.get<Agent>("agent", agentName ?? project.agent ?? config.defaults.agent);
|
||||
const workspace = registry.get<Workspace>(
|
||||
"workspace",
|
||||
project.workspace ?? config.defaults.workspace,
|
||||
|
|
@ -708,6 +706,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
return { runtime, agent, workspace, tracker, scm };
|
||||
}
|
||||
|
||||
function resolveSelectionForSession(
|
||||
project: ProjectConfig,
|
||||
sessionId: string,
|
||||
metadata: Record<string, string>,
|
||||
) {
|
||||
return resolveAgentSelection({
|
||||
role: resolveSessionRole(sessionId, metadata),
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
persistedAgent: metadata["agent"],
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSessionMapping(
|
||||
session: Session,
|
||||
sessionName: string,
|
||||
|
|
@ -871,22 +882,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
}
|
||||
|
||||
const plugins = resolvePlugins(project);
|
||||
const selection = resolveAgentSelection({
|
||||
role: "worker",
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
spawnAgentOverride: spawnConfig.agent,
|
||||
});
|
||||
const plugins = resolvePlugins(project, selection.agentName);
|
||||
if (!plugins.runtime) {
|
||||
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
|
||||
}
|
||||
|
||||
// Allow --agent override to swap the agent plugin for this session
|
||||
if (spawnConfig.agent) {
|
||||
const overrideAgent = registry.get<Agent>("agent", spawnConfig.agent);
|
||||
if (!overrideAgent) {
|
||||
throw new Error(`Agent plugin '${spawnConfig.agent}' not found`);
|
||||
}
|
||||
plugins.agent = overrideAgent;
|
||||
}
|
||||
|
||||
if (!plugins.agent) {
|
||||
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
|
||||
throw new Error(`Agent plugin '${selection.agentName}' not found`);
|
||||
}
|
||||
|
||||
// Validate issue exists BEFORE creating any resources
|
||||
|
|
@ -1010,25 +1018,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
strategy: opencodeIssueSessionStrategy,
|
||||
})
|
||||
: undefined;
|
||||
const configuredSubagent =
|
||||
typeof project.agentConfig?.["subagent"] === "string"
|
||||
? project.agentConfig["subagent"]
|
||||
: undefined;
|
||||
|
||||
const agentLaunchConfig = {
|
||||
sessionId,
|
||||
projectConfig: {
|
||||
...project,
|
||||
agentConfig: {
|
||||
...(project.agentConfig ?? {}),
|
||||
...selection.agentConfig,
|
||||
...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}),
|
||||
},
|
||||
},
|
||||
issueId: spawnConfig.issueId,
|
||||
prompt: composedPrompt,
|
||||
permissions: project.agentConfig?.permissions,
|
||||
model: project.agentConfig?.model,
|
||||
subagent: spawnConfig.subagent ?? configuredSubagent,
|
||||
permissions: selection.permissions,
|
||||
model: selection.model,
|
||||
subagent: spawnConfig.subagent ?? selection.subagent,
|
||||
};
|
||||
|
||||
let handle: RuntimeHandle;
|
||||
|
|
@ -1095,7 +1098,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
tmuxName, // Store tmux name for mapping
|
||||
issue: spawnConfig.issueId,
|
||||
project: spawnConfig.projectId,
|
||||
agent: plugins.agent.name, // Persist agent name for lifecycle manager
|
||||
agent: selection.agentName, // Persist agent name for lifecycle manager
|
||||
createdAt: new Date().toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
opencodeSessionId: reusedOpenCodeSessionId,
|
||||
|
|
@ -1178,12 +1181,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
}
|
||||
|
||||
const plugins = resolvePlugins(project);
|
||||
const selection = resolveAgentSelection({
|
||||
role: "orchestrator",
|
||||
project,
|
||||
defaults: config.defaults,
|
||||
});
|
||||
const plugins = resolvePlugins(project, selection.agentName);
|
||||
if (!plugins.runtime) {
|
||||
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
|
||||
}
|
||||
if (!plugins.agent) {
|
||||
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
|
||||
throw new Error(`Agent plugin '${selection.agentName}' not found`);
|
||||
}
|
||||
|
||||
const sessionId = `${project.sessionPrefix}-orchestrator`;
|
||||
|
|
@ -1296,11 +1304,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
strategy: "reuse",
|
||||
})
|
||||
: undefined;
|
||||
const configuredSubagent =
|
||||
typeof project.agentConfig?.["subagent"] === "string"
|
||||
? project.agentConfig["subagent"]
|
||||
: undefined;
|
||||
|
||||
if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") {
|
||||
await resolveOpenCodeSessionReuse({
|
||||
sessionsDir,
|
||||
|
|
@ -1317,14 +1320,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
projectConfig: {
|
||||
...project,
|
||||
agentConfig: {
|
||||
...(project.agentConfig ?? {}),
|
||||
...selection.agentConfig,
|
||||
permissions: "permissionless" as const,
|
||||
...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}),
|
||||
},
|
||||
},
|
||||
permissions: "permissionless" as const,
|
||||
model: project.agentConfig?.orchestratorModel ?? project.agentConfig?.model,
|
||||
model: selection.model,
|
||||
systemPromptFile,
|
||||
subagent: configuredSubagent,
|
||||
subagent: selection.subagent,
|
||||
};
|
||||
|
||||
const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
|
||||
|
|
@ -1370,7 +1374,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
role: "orchestrator",
|
||||
tmuxName,
|
||||
project: orchestratorConfig.projectId,
|
||||
agent: plugins.agent.name,
|
||||
agent: selection.agentName,
|
||||
createdAt: new Date().toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
opencodeSessionId: reusableOpenCodeSessionId,
|
||||
|
|
@ -1444,8 +1448,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
|
||||
const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt);
|
||||
const selectedAgentName = raw["agent"];
|
||||
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
|
||||
const selection = resolveSelectionForSession(project, sessionName, raw);
|
||||
const effectiveAgentName = selection.agentName;
|
||||
const plugins = resolvePlugins(project, effectiveAgentName);
|
||||
const sessionListPromise =
|
||||
effectiveAgentName === "opencode"
|
||||
|
|
@ -1507,8 +1511,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt);
|
||||
|
||||
const selectedAgentName = repaired.raw["agent"];
|
||||
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
|
||||
const selection = resolveSelectionForSession(project, sessionId, repaired.raw);
|
||||
const effectiveAgentName = selection.agentName;
|
||||
const plugins = resolvePlugins(project, effectiveAgentName);
|
||||
await ensureHandleAndEnrich(
|
||||
session,
|
||||
|
|
@ -1528,7 +1532,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
async function kill(sessionId: SessionId, options?: { purgeOpenCode?: boolean }): Promise<void> {
|
||||
const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId);
|
||||
|
||||
const cleanupAgent = raw["agent"] ?? project?.agent ?? config.defaults.agent;
|
||||
const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName;
|
||||
|
||||
// Destroy runtime — prefer handle.runtimeName to find the correct plugin
|
||||
if (raw["runtimeHandle"]) {
|
||||
|
|
@ -1710,7 +1714,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
continue;
|
||||
}
|
||||
|
||||
const cleanupAgent = archived["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const cleanupAgent = resolveSelectionForSession(project, archivedId, archived).agentName;
|
||||
const mappedOpenCodeSessionId = asValidOpenCodeSessionId(archived["opencodeSessionId"]);
|
||||
if (cleanupAgent === "opencode" && archived["opencodeCleanedAt"]) {
|
||||
pushSkipped(projectKey, archivedId);
|
||||
|
|
@ -1764,7 +1768,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
);
|
||||
}
|
||||
|
||||
const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const selection = resolveSelectionForSession(project, sessionId, raw);
|
||||
const selectedAgent = selection.agentName;
|
||||
if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) {
|
||||
const discovered = await discoverOpenCodeSessionIdByTitle(
|
||||
sessionId,
|
||||
|
|
@ -1779,7 +1784,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
? safeJsonParse<RuntimeHandle>(raw["runtimeHandle"])
|
||||
: null;
|
||||
const runtimeName = parsedHandle?.runtimeName ?? project.runtime ?? config.defaults.runtime;
|
||||
const agentName = raw["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const agentName = selectedAgent;
|
||||
|
||||
const runtimePlugin = registry.get<Runtime>("runtime", runtimeName);
|
||||
if (!runtimePlugin) {
|
||||
|
|
@ -2053,7 +2058,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`);
|
||||
}
|
||||
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
const plugins = resolvePlugins(
|
||||
project,
|
||||
resolveSelectionForSession(project, sessionId, raw).agentName,
|
||||
);
|
||||
const scm = plugins.scm;
|
||||
if (!scm?.resolvePR || !scm.checkoutPR) {
|
||||
throw new Error(
|
||||
|
|
@ -2140,7 +2148,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
async function remap(sessionId: SessionId, force = false): Promise<string> {
|
||||
const { raw, sessionsDir, project } = requireSessionRecord(sessionId);
|
||||
|
||||
const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const selection = resolveSelectionForSession(project, sessionId, raw);
|
||||
const selectedAgent = selection.agentName;
|
||||
if (selectedAgent !== "opencode") {
|
||||
throw new Error(`Session ${sessionId} is not using the opencode agent`);
|
||||
}
|
||||
|
|
@ -2197,7 +2206,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
throw new SessionNotFoundError(sessionId);
|
||||
}
|
||||
|
||||
const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const selection = resolveSelectionForSession(project, sessionId, raw);
|
||||
const selectedAgent = selection.agentName;
|
||||
if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) {
|
||||
const discovered = await discoverOpenCodeSessionIdByTitle(
|
||||
sessionId,
|
||||
|
|
@ -2217,7 +2227,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// session (status "working", agent exited) would not be detected as terminal
|
||||
// and isRestorable would reject it.
|
||||
const session = metadataToSession(sessionId, raw, projectId);
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
const plugins = resolvePlugins(project, selection.agentName);
|
||||
await enrichSessionWithRuntimeState(session, plugins, true);
|
||||
|
||||
// 3. Validate restorability
|
||||
|
|
@ -2253,7 +2263,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
|
||||
}
|
||||
if (!plugins.agent) {
|
||||
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
|
||||
throw new Error(`Agent plugin '${selection.agentName}' not found`);
|
||||
}
|
||||
|
||||
// 5. Check workspace
|
||||
|
|
@ -2304,28 +2314,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
// 7. Get launch command — try restore command first, fall back to fresh launch
|
||||
let launchCommand: string;
|
||||
const configuredSubagent =
|
||||
typeof project.agentConfig?.["subagent"] === "string"
|
||||
? project.agentConfig["subagent"]
|
||||
: undefined;
|
||||
const agentLaunchConfig = {
|
||||
sessionId,
|
||||
projectConfig: {
|
||||
...project,
|
||||
agentConfig: {
|
||||
...(project.agentConfig ?? {}),
|
||||
...selection.agentConfig,
|
||||
...(selection.role === "orchestrator" ? { permissions: "permissionless" as const } : {}),
|
||||
...(session.metadata?.opencodeSessionId
|
||||
? { opencodeSessionId: session.metadata.opencodeSessionId }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
issueId: session.issueId ?? undefined,
|
||||
permissions: project.agentConfig?.permissions,
|
||||
model:
|
||||
raw["role"] === "orchestrator"
|
||||
? (project.agentConfig?.orchestratorModel ?? project.agentConfig?.model)
|
||||
: project.agentConfig?.model,
|
||||
subagent: configuredSubagent,
|
||||
permissions: selection.role === "orchestrator" ? "permissionless" : selection.permissions,
|
||||
model: selection.model,
|
||||
subagent: selection.subagent,
|
||||
};
|
||||
|
||||
if (plugins.agent.getRestoreCommand) {
|
||||
|
|
|
|||
|
|
@ -919,6 +919,17 @@ export interface DefaultPlugins {
|
|||
agent: string;
|
||||
workspace: string;
|
||||
notifiers: string[];
|
||||
orchestrator?: {
|
||||
agent?: string;
|
||||
};
|
||||
worker?: {
|
||||
agent?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleAgentConfig {
|
||||
agent?: string;
|
||||
agentConfig?: AgentSpecificConfig;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
|
|
@ -961,6 +972,10 @@ export interface ProjectConfig {
|
|||
/** Agent-specific configuration */
|
||||
agentConfig?: AgentSpecificConfig;
|
||||
|
||||
orchestrator?: RoleAgentConfig;
|
||||
|
||||
worker?: RoleAgentConfig;
|
||||
|
||||
/** Per-project reaction overrides */
|
||||
reactions?: Record<string, Partial<ReactionConfig>>;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue