mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12993 from Kilo-Org/fix-agent-manager-multi-project-id
fix(agent-manager): route tool requests by project directory and handle busy sessions
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Route Agent Manager tool-launched sessions to the project that owns the tool event directory, keep sandboxed worktree sessions inside their active worktree, and wait for busy managed sessions before prompting them.
|
||||
@@ -53,8 +53,10 @@ import {
|
||||
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
|
||||
import { createLocalDiff } from "./local-diff"
|
||||
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
||||
import { handleToolEvent } from "./tool-project"
|
||||
import { sandboxSessionMetadata } from "../shared/sandbox-session"
|
||||
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
|
||||
import { createOrchestrationBridge } from "./orchestration-setup"
|
||||
import type { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
|
||||
import { pruneSubagents } from "./prune-subagents"
|
||||
import { startSession } from "./mcp-warmup"
|
||||
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
|
||||
@@ -253,22 +255,20 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.statsPoller = pollers.stats
|
||||
this.prBridge = pollers.pr
|
||||
this.projectPollers = pollers.projects
|
||||
this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, {
|
||||
root: () => this.getRoot(),
|
||||
state: () => this.state,
|
||||
ready: async () => {
|
||||
this.stateReady ??= this.initializeState()
|
||||
await this.stateReady
|
||||
return this.state
|
||||
},
|
||||
stats: () => this.statsPoller.snapshot(),
|
||||
prs: () => this.prBridge.snapshot(),
|
||||
push: () => this.pushState(),
|
||||
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
|
||||
close: async (id) => {
|
||||
await this.onCloseSession(id)
|
||||
this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id })
|
||||
},
|
||||
this.orchestration = createOrchestrationBridge({
|
||||
connectionService: this.connectionService,
|
||||
contexts: this.contexts,
|
||||
projectScope: this.projectScope,
|
||||
getRoot: () => this.getRoot(),
|
||||
getState: () => this.state,
|
||||
getStateReady: () => this.stateReady,
|
||||
initStateReady: () => (this.stateReady = this.initializeState()),
|
||||
getStats: () => this.statsPoller.snapshot(),
|
||||
getPrs: () => this.prBridge.snapshot(),
|
||||
pushState: (ctx) => this.pushState(ctx),
|
||||
hasPanelSession: (id) => this.panelSessions.has(id),
|
||||
closeSession: (id) => this.onCloseSession(id),
|
||||
postSessionClosed: (id) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id }),
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
this.unsubTool = this.connectionService.onEventFiltered(
|
||||
@@ -1083,14 +1083,16 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
private onToolEvent(event: unknown, directory?: string): void {
|
||||
const properties = (event as { properties?: unknown }).properties
|
||||
const req = parseToolRequest(properties)
|
||||
if (!req) return
|
||||
if (directory) {
|
||||
req.directory = directory
|
||||
req.projectId ??= this.contexts.byDirectory(directory)?.id
|
||||
}
|
||||
void this.startToolRequest(req)
|
||||
handleToolEvent(
|
||||
event,
|
||||
directory,
|
||||
{
|
||||
byDirectory: (value) => this.contexts.byDirectory(value),
|
||||
usable: (id) => this.contexts.usable(id),
|
||||
},
|
||||
this.projectScope,
|
||||
(req) => this.startToolRequest(req),
|
||||
)
|
||||
}
|
||||
|
||||
private async startToolRequest(req: ToolRequest): Promise<void> {
|
||||
@@ -1829,19 +1831,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
public async createFromSidebar(baseBranch?: string, branchName?: string): Promise<void> {
|
||||
this.openPanel()
|
||||
const panel = this.panel
|
||||
if (!panel) return
|
||||
if (!(await this.waitForPanelReady(panel))) return
|
||||
if (!this.panel || !(await this.waitForPanelReady(this.panel))) return
|
||||
await this.waitForStateReady("createFromSidebar")
|
||||
await this.onCreateWorktree(baseBranch, branchName)
|
||||
}
|
||||
|
||||
public async openAdvancedWorktree(): Promise<void> {
|
||||
this.openPanel()
|
||||
const panel = this.panel
|
||||
if (!panel) return
|
||||
if (!(await this.waitForPanelActive(panel))) return
|
||||
if (!(await this.waitForPanelReady(panel))) return
|
||||
if (!this.panel || !(await this.waitForPanelActive(this.panel)) || !(await this.waitForPanelReady(this.panel)))
|
||||
return
|
||||
await this.waitForStateReady("openAdvancedWorktree")
|
||||
queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" }))
|
||||
}
|
||||
|
||||
@@ -41,14 +41,15 @@ interface Failure {
|
||||
}
|
||||
|
||||
interface Options {
|
||||
root(): string | undefined
|
||||
ready(): Promise<WorktreeStateManager | undefined>
|
||||
state(): WorktreeStateManager | undefined
|
||||
stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
|
||||
prs(): Map<string, PRStatus>
|
||||
push(): void
|
||||
managed(sessionID: string): boolean
|
||||
close(sessionID: string): Promise<void>
|
||||
root(directory?: string): string | undefined
|
||||
ready(directory?: string): Promise<WorktreeStateManager | undefined>
|
||||
state(directory?: string): WorktreeStateManager | undefined
|
||||
stats(directory?: string): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
|
||||
prs(directory?: string): Map<string, PRStatus>
|
||||
push(directory?: string): void
|
||||
managed(sessionID: string, directory?: string): boolean
|
||||
close(sessionID: string, directory?: string): Promise<void>
|
||||
directories?(): string[]
|
||||
log(...args: unknown[]): void
|
||||
}
|
||||
|
||||
@@ -108,6 +109,7 @@ export class AgentManagerOrchestrationBridge {
|
||||
})
|
||||
})
|
||||
this.unsubscribeDirectories = connection.registerDirectoryProvider(() => {
|
||||
if (this.options.directories) return this.options.directories()
|
||||
const root = this.options.root()
|
||||
const dirs =
|
||||
this.options
|
||||
@@ -181,8 +183,8 @@ export class AgentManagerOrchestrationBridge {
|
||||
}
|
||||
|
||||
private async admit(request: Request, directory: string): Promise<void> {
|
||||
const state = await this.options.ready()
|
||||
const root = this.options.root()
|
||||
const state = await this.options.ready(directory)
|
||||
const root = this.options.root(directory)
|
||||
if (this.disposed || this.settled.has(request.id)) return
|
||||
if (!state || !root) {
|
||||
const accepted = await this.reject(request.id, directory, {
|
||||
@@ -232,7 +234,7 @@ export class AgentManagerOrchestrationBridge {
|
||||
|
||||
private async run(request: Request, origin: Origin, active: Active): Promise<void> {
|
||||
try {
|
||||
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, active))
|
||||
const outcome = this.outcomes.get(request.id) ?? (await this.execute(request, origin, active))
|
||||
if (!outcome || this.disposed || active.cancelled) return
|
||||
this.rememberOutcome(request.id, outcome)
|
||||
const accepted =
|
||||
@@ -248,10 +250,10 @@ export class AgentManagerOrchestrationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(request: Request, active: Active): Promise<Outcome | undefined> {
|
||||
private async execute(request: Request, origin: Origin, active: Active): Promise<Outcome | undefined> {
|
||||
try {
|
||||
const state = await this.options.ready()
|
||||
const root = this.options.root()
|
||||
const state = await this.options.ready(origin.directory)
|
||||
const root = this.options.root(origin.directory)
|
||||
if (!state || !root)
|
||||
throw new OrchestrationError("workspace_unavailable", "Agent Manager requires an open workspace")
|
||||
if (this.disposed || active.cancelled) return
|
||||
@@ -260,7 +262,7 @@ export class AgentManagerOrchestrationBridge {
|
||||
// Git stats are refreshed by the poller independently. A forced refresh
|
||||
// here can spawn one diff/ahead-behind pair per worktree and exceed the
|
||||
// host request timeout before the overview can return its IDs.
|
||||
const stats = await this.options.stats()
|
||||
const stats = await this.options.stats(origin.directory)
|
||||
if (this.disposed || active.cancelled) return
|
||||
const result = await overview({
|
||||
client,
|
||||
@@ -269,7 +271,7 @@ export class AgentManagerOrchestrationBridge {
|
||||
titles: this.titles,
|
||||
filter: request.filter,
|
||||
stats,
|
||||
prs: this.options.prs(),
|
||||
prs: this.options.prs(origin.directory),
|
||||
})
|
||||
return { result: { operation: "overview", overview: result } }
|
||||
}
|
||||
@@ -288,7 +290,7 @@ export class AgentManagerOrchestrationBridge {
|
||||
}
|
||||
if (request.operation === "move") {
|
||||
move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID })
|
||||
this.options.push()
|
||||
this.options.push(origin.directory)
|
||||
if (this.disposed || active.cancelled) return
|
||||
return {
|
||||
result: {
|
||||
@@ -299,10 +301,10 @@ export class AgentManagerOrchestrationBridge {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!this.options.managed(request.targetSessionID)) {
|
||||
if (!this.options.managed(request.targetSessionID, origin.directory)) {
|
||||
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
|
||||
}
|
||||
await this.options.close(request.targetSessionID)
|
||||
await this.options.close(request.targetSessionID, origin.directory)
|
||||
if (this.disposed || active.cancelled) return
|
||||
return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } }
|
||||
} catch (error) {
|
||||
|
||||
@@ -319,6 +319,7 @@ export async function prompt(input: {
|
||||
text: string
|
||||
messageID: string
|
||||
signal?: AbortSignal
|
||||
idleTimeoutMs?: number
|
||||
}): Promise<void> {
|
||||
if (input.signal?.aborted) return
|
||||
const managed = input.state.getSession(input.sessionID)
|
||||
@@ -342,15 +343,7 @@ export async function prompt(input: {
|
||||
if (!(await sameManagedDirectory(response.data.directory, dir))) {
|
||||
throw new OrchestrationError("cross_workspace", "The managed session belongs to a different workspace directory")
|
||||
}
|
||||
const status = await input.client.session.status({ directory: dir })
|
||||
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
|
||||
const activity = status.data?.[input.sessionID]?.type ?? "idle"
|
||||
if (activity !== "idle") {
|
||||
throw new OrchestrationError(
|
||||
"unavailable_session",
|
||||
`The managed session is ${activity}; only idle sessions can be prompted`,
|
||||
)
|
||||
}
|
||||
await waitForIdle(input.client, dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000)
|
||||
if (input.signal?.aborted) return
|
||||
await input.client.session.promptAsync(
|
||||
{
|
||||
@@ -364,6 +357,29 @@ export async function prompt(input: {
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForIdle(
|
||||
client: KiloClient,
|
||||
directory: string,
|
||||
sessionID: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeout: number,
|
||||
start = Date.now(),
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
const status = await client.session.status({ directory })
|
||||
if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read")
|
||||
const activity = status.data?.[sessionID]?.type ?? "idle"
|
||||
if (activity === "idle") return
|
||||
if (Date.now() - start >= timeout) {
|
||||
throw new OrchestrationError(
|
||||
"unavailable_session",
|
||||
`The managed session is still ${activity}; only idle sessions can be prompted`,
|
||||
)
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 250))
|
||||
return waitForIdle(client, directory, sessionID, signal, timeout, start)
|
||||
}
|
||||
|
||||
export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void {
|
||||
const session = input.state.getSession(input.sessionID)
|
||||
if (!session)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
|
||||
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
|
||||
import type { ProjectContexts } from "./project/contexts"
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import type { ProjectScope } from "./project/scope"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
|
||||
import type { PRStatus } from "./types"
|
||||
import { initContextState } from "./project/init"
|
||||
|
||||
export interface OrchestrationBridgeDeps {
|
||||
connectionService: KiloConnectionService
|
||||
contexts: ProjectContexts
|
||||
projectScope: ProjectScope
|
||||
getRoot: () => string | undefined
|
||||
getState: () => WorktreeStateManager | undefined
|
||||
getStateReady: () => Promise<void> | undefined
|
||||
initStateReady: () => Promise<void>
|
||||
getStats: () => Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
|
||||
getPrs: () => Map<string, PRStatus>
|
||||
pushState: (ctx?: ProjectContext) => void
|
||||
hasPanelSession: (id: string) => boolean
|
||||
closeSession: (id: string) => Promise<unknown>
|
||||
postSessionClosed: (id: string) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentManagerOrchestrationBridge {
|
||||
return new AgentManagerOrchestrationBridge(deps.connectionService, {
|
||||
root: (dir) => (dir ? deps.contexts.byDirectory(dir)?.root : undefined) ?? deps.getRoot(),
|
||||
state: (dir) => (dir ? deps.contexts.byDirectory(dir)?.peekState() : undefined) ?? deps.getState(),
|
||||
ready: async (dir) => {
|
||||
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
|
||||
if (ctx && ctx.id !== deps.contexts.active()?.id) {
|
||||
await initContextState(ctx, (...args) => deps.log(...args))
|
||||
return ctx.stateManager()
|
||||
}
|
||||
const ready = deps.getStateReady() ?? deps.initStateReady()
|
||||
await ready
|
||||
return deps.getState()
|
||||
},
|
||||
stats: () => deps.getStats(),
|
||||
prs: () => deps.getPrs(),
|
||||
push: (dir) => {
|
||||
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
|
||||
deps.pushState(ctx)
|
||||
},
|
||||
managed: (id, dir) => {
|
||||
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
|
||||
if (ctx) return ctx.hasLiveSession(id) || !!ctx.peekState()?.getSession(id)
|
||||
return deps.hasPanelSession(id) || !!deps.getState()?.getSession(id)
|
||||
},
|
||||
close: async (id, dir) => {
|
||||
const ctx = dir ? deps.contexts.byDirectory(dir) : undefined
|
||||
if (ctx) {
|
||||
await deps.projectScope.run(ctx, () => deps.closeSession(id))
|
||||
} else {
|
||||
await deps.closeSession(id)
|
||||
}
|
||||
deps.postSessionClosed(id)
|
||||
},
|
||||
directories: () => {
|
||||
const all: string[] = []
|
||||
for (const ctx of deps.contexts.values()) {
|
||||
all.push(ctx.root)
|
||||
for (const wt of ctx.peekState()?.getWorktrees() ?? []) {
|
||||
if (wt.path) all.push(wt.path)
|
||||
}
|
||||
}
|
||||
if (all.length === 0) {
|
||||
const root = deps.getRoot()
|
||||
if (root) all.push(root)
|
||||
}
|
||||
return all
|
||||
},
|
||||
log: (...args) => deps.log(...args),
|
||||
})
|
||||
}
|
||||
@@ -100,6 +100,10 @@ export class ProjectContexts {
|
||||
return this.contexts.get(id)
|
||||
}
|
||||
|
||||
values(): IterableIterator<ProjectContext> {
|
||||
return this.contexts.values()
|
||||
}
|
||||
|
||||
/** The context that owns a directory: its root or one of its worktree paths. */
|
||||
byDirectory(dir: string): ProjectContext | undefined {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ToolRequest } from "./tool-start"
|
||||
import { parseToolRequest } from "./tool-start"
|
||||
|
||||
export function routeToolRequest<T extends { projectId?: string; directory?: string }, C extends { id: string }>(
|
||||
input: T,
|
||||
directory: string | undefined,
|
||||
deps: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
|
||||
): { request: T; owner?: C } {
|
||||
const request = directory ? { ...input, directory } : input
|
||||
const owner =
|
||||
(directory && deps.byDirectory(directory)) ?? (request.projectId ? deps.usable(request.projectId) : undefined)
|
||||
if (!owner) return { request }
|
||||
return { request: { ...request, projectId: owner.id }, owner }
|
||||
}
|
||||
|
||||
export function handleToolEvent<C extends { id: string }>(
|
||||
event: unknown,
|
||||
directory: string | undefined,
|
||||
contexts: { byDirectory: (value: string) => C | undefined; usable: (id: string) => C | undefined },
|
||||
scope: { run: <T>(owner: C, fn: () => Promise<T>) => Promise<T> },
|
||||
start: (req: ToolRequest) => Promise<void>,
|
||||
): void {
|
||||
const properties = (event as { properties?: unknown }).properties
|
||||
const req = parseToolRequest(properties)
|
||||
if (!req) return
|
||||
const routed = routeToolRequest(req, directory, contexts)
|
||||
if (routed.owner) {
|
||||
void scope.run(routed.owner, () => start(routed.request))
|
||||
return
|
||||
}
|
||||
void start(routed.request)
|
||||
}
|
||||
@@ -33,7 +33,9 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function harness() {
|
||||
function harness(
|
||||
overrides?: Partial<Parameters<(typeof AgentManagerOrchestrationBridge.prototype)["constructor"]>[1]>,
|
||||
) {
|
||||
const replies: unknown[] = []
|
||||
const rejections: unknown[] = []
|
||||
const lists = new Map<string, AgentManagerRequest[]>()
|
||||
@@ -49,8 +51,8 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
const push = mock(() => undefined)
|
||||
const client = {
|
||||
session: {
|
||||
get: mock(async () => ({
|
||||
data: { id: "ses_target", directory: dir, title: "Target" } as Session,
|
||||
get: mock(async ({ sessionID, directory }: { sessionID?: string; directory?: string }) => ({
|
||||
data: { id: sessionID ?? "ses_target", directory: directory ?? dir, title: "Target" } as Session,
|
||||
})),
|
||||
status: mock(async () => ({ data: {} })),
|
||||
promptAsync,
|
||||
@@ -100,17 +102,17 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
getClient: () => client,
|
||||
}
|
||||
const bridge = new AgentManagerOrchestrationBridge(connection as never, {
|
||||
root: () => root,
|
||||
ready: async () => state,
|
||||
state: () => state,
|
||||
stats: async () => {
|
||||
root: (dir) => (overrides?.root ? overrides.root(dir) : root),
|
||||
ready: async (dir) => (overrides?.ready ? overrides.ready(dir) : state),
|
||||
state: (dir) => (overrides?.state ? overrides.state(dir) : state),
|
||||
stats: async (dir) => {
|
||||
statsCalls.push(1)
|
||||
return { worktrees: [] }
|
||||
return overrides?.stats ? overrides.stats(dir) : { worktrees: [] }
|
||||
},
|
||||
prs: () => new Map(),
|
||||
push,
|
||||
managed: (id) => managed.has(id),
|
||||
close,
|
||||
prs: (dir) => (overrides?.prs ? overrides.prs(dir) : new Map()),
|
||||
push: (dir) => (overrides?.push ? overrides.push(dir) : push()),
|
||||
managed: (id, dir) => (overrides?.managed ? overrides.managed(id, dir) : managed.has(id)),
|
||||
close: async (id, dir) => (overrides?.close ? overrides.close(id, dir) : close(id, dir)),
|
||||
log: () => undefined,
|
||||
})
|
||||
const request = (value: AgentManagerRequest, directory = root) =>
|
||||
@@ -195,7 +197,7 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
await waitFor(() => test.replies.length === 2)
|
||||
|
||||
expect(test.close).toHaveBeenCalledTimes(1)
|
||||
expect(test.close).toHaveBeenCalledWith("ses_target")
|
||||
expect(test.close).toHaveBeenCalledWith("ses_target", root)
|
||||
expect(test.replies).toEqual([
|
||||
{
|
||||
requestID: "amr_stop",
|
||||
@@ -294,7 +296,7 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
await waitFor(() => test.replies.length === 1)
|
||||
|
||||
expect(state.getSession("ses_live")).toBeUndefined()
|
||||
expect(test.close).toHaveBeenCalledWith("ses_live")
|
||||
expect(test.close).toHaveBeenCalledWith("ses_live", root)
|
||||
expect(test.replies[0]).toEqual({
|
||||
requestID: "amr_stop_live",
|
||||
directory: root,
|
||||
@@ -389,4 +391,39 @@ describe("AgentManagerOrchestrationBridge", () => {
|
||||
expect(test.promptAsync).toHaveBeenCalledTimes(1)
|
||||
test.bridge.dispose()
|
||||
})
|
||||
|
||||
it("handles requests for secondary project directories in multi-project mode", async () => {
|
||||
const secondaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-"))
|
||||
fs.mkdirSync(path.join(secondaryRoot, ".kilo"), { recursive: true })
|
||||
const secondaryState = new WorktreeStateManager(secondaryRoot, () => undefined)
|
||||
secondaryState.addSession("ses_secondary", null)
|
||||
|
||||
const test = harness({
|
||||
root: (d) => (d === secondaryRoot ? secondaryRoot : root),
|
||||
ready: async (d) => (d === secondaryRoot ? secondaryState : state),
|
||||
state: (d) => (d === secondaryRoot ? secondaryState : state),
|
||||
})
|
||||
|
||||
test.request(
|
||||
{
|
||||
id: "amr_secondary",
|
||||
sessionID: "ses_caller",
|
||||
operation: "prompt",
|
||||
targetSessionID: "ses_secondary",
|
||||
prompt: "Hello from secondary",
|
||||
},
|
||||
secondaryRoot,
|
||||
)
|
||||
await waitFor(() => test.replies.length === 1)
|
||||
|
||||
expect(test.promptAsync).toHaveBeenCalledTimes(1)
|
||||
expect(test.replies[0]).toEqual({
|
||||
requestID: "amr_secondary",
|
||||
directory: secondaryRoot,
|
||||
result: { operation: "prompt", sessionID: "ses_secondary", delivered: true },
|
||||
})
|
||||
test.bridge.dispose()
|
||||
await secondaryState.flush()
|
||||
fs.rmSync(secondaryRoot, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,6 +202,25 @@ describe("Agent Manager orchestration domain", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("waits for a busy managed session to become idle before prompting", async () => {
|
||||
const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" })
|
||||
state.addSession("ses_wait", managed.id)
|
||||
let calls = 0
|
||||
const promptAsync = mock(async () => ({ data: undefined }))
|
||||
const client = {
|
||||
session: {
|
||||
get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })),
|
||||
status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })),
|
||||
promptAsync,
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" })
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(2)
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("rejects unknown, stale, cross-workspace, and busy targets", async () => {
|
||||
const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" })
|
||||
state.addSession("ses_target", managed.id)
|
||||
@@ -231,7 +250,15 @@ describe("Agent Manager orchestration domain", () => {
|
||||
data: { ses_target: { type: "busy" } },
|
||||
}))
|
||||
await expect(
|
||||
prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_busy" }),
|
||||
prompt({
|
||||
client,
|
||||
root,
|
||||
state,
|
||||
sessionID: "ses_target",
|
||||
text: "Continue",
|
||||
messageID: "amr_busy",
|
||||
idleTimeoutMs: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "unavailable_session",
|
||||
} satisfies Partial<OrchestrationError>)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { routeToolRequest } from "../../src/agent-manager/tool-project"
|
||||
|
||||
describe("Agent Manager tool project routing", () => {
|
||||
it("routes by the event directory before any explicit project id", () => {
|
||||
const secondary = { id: "prj-secondary" }
|
||||
const request = routeToolRequest({ requestID: "am-1", projectId: "prj-active", mode: "worktree" }, "/secondary", {
|
||||
byDirectory: (dir) => (dir === "/secondary" ? secondary : undefined),
|
||||
usable: () => ({ id: "prj-active" }),
|
||||
})
|
||||
|
||||
expect(request.owner).toBe(secondary)
|
||||
expect(request.request).toEqual({
|
||||
requestID: "am-1",
|
||||
projectId: "prj-secondary",
|
||||
mode: "worktree",
|
||||
directory: "/secondary",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses an explicit usable project when no event directory is available", () => {
|
||||
const project = { id: "prj-secondary" }
|
||||
const request = routeToolRequest({ requestID: "am-2", projectId: "prj-secondary", mode: "local" }, undefined, {
|
||||
byDirectory: () => undefined,
|
||||
usable: (id) => (id === project.id ? project : undefined),
|
||||
})
|
||||
|
||||
expect(request.owner).toBe(project)
|
||||
expect(request.request.projectId).toBe("prj-secondary")
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/AgentManager") {}
|
||||
|
||||
export function layer(timeout: Duration.Input = "10 seconds") {
|
||||
export function layer(timeout: Duration.Input = "60 seconds") {
|
||||
return Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, statSync } from "node:fs"
|
||||
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
@@ -194,6 +194,37 @@ function isolated(ctx: InstanceContext) {
|
||||
return linked(path.resolve(ctx.directory), path.resolve(ctx.worktree))
|
||||
}
|
||||
|
||||
function canonical(dir: string) {
|
||||
try {
|
||||
return realpathSync.native(dir)
|
||||
} catch {
|
||||
return path.resolve(dir)
|
||||
}
|
||||
}
|
||||
|
||||
function ancestor(value: string, target: string) {
|
||||
const relative = path.relative(canonical(value), canonical(target))
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function accessible(dir: string) {
|
||||
try {
|
||||
accessSync(dir, constants.R_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function filterWritable(ctx: InstanceContext, values: readonly string[]) {
|
||||
const list = values.filter(accessible)
|
||||
if (!isolated(ctx)) return list
|
||||
// A nested macOS sandbox cannot reliably canonicalize an inherited writable
|
||||
// ancestor of the linked worktree. The active worktree is already writable;
|
||||
// keep unrelated explicit paths, but do not widen it back to the repository.
|
||||
return list.filter((value) => !ancestor(value, ctx.directory))
|
||||
}
|
||||
|
||||
export function profile(
|
||||
ctx: InstanceContext,
|
||||
mode: Profile["network"]["mode"] = "deny",
|
||||
@@ -215,7 +246,7 @@ export function profile(
|
||||
Global.Path.bin,
|
||||
Global.Path.log,
|
||||
Global.Path.repos,
|
||||
...(extraWritable ?? []),
|
||||
...filterWritable(ctx, extraWritable ?? []),
|
||||
].map(root)
|
||||
return {
|
||||
filesystem: {
|
||||
|
||||
@@ -130,6 +130,17 @@ describe("sandbox policy", () => {
|
||||
expect(actual).not.toContain(dirs.b)
|
||||
})
|
||||
|
||||
test("drops inherited writable ancestors for a managed worktree", async () => {
|
||||
await using tmp = await fixture()
|
||||
const dirs = tmp.extra
|
||||
const policy = profile(context(dirs.a, dirs.main, dirs), "deny", [dirs.main, dirs.approved])
|
||||
const paths = policy.filesystem.allowWrite.map((rule) => rule.path)
|
||||
|
||||
expect(paths).not.toContain(dirs.main)
|
||||
expect(paths).toContain(dirs.approved)
|
||||
expect(paths).toContain(dirs.a)
|
||||
})
|
||||
|
||||
posix("fails closed when a worktree marker cannot be resolved", async () => {
|
||||
await using tmp = await fixture()
|
||||
const dirs = tmp.extra
|
||||
|
||||
Reference in New Issue
Block a user