mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(cli): fail headless subagent permission asks instead of hanging
This commit is contained in:
@@ -33,6 +33,7 @@ import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.
|
||||
import { event as normalizeEvent } from "./run/event"
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
|
||||
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
|
||||
import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change
|
||||
|
||||
const runtimeTask = import("./run/runtime")
|
||||
@@ -700,7 +701,10 @@ export const RunCommand = effectCmd({
|
||||
process.exit(1)
|
||||
}
|
||||
const sessionID = sess.id
|
||||
const auto = KiloRunAuto.create(sessionID) // kilocode_change
|
||||
// kilocode_change start - track Task children; plain headless runs deny subagent asks instead of hanging (#11903)
|
||||
const auto = KiloRunAuto.create(sessionID)
|
||||
if (!args.attach && !args.auto && !args["dangerously-skip-permissions"]) KiloHeadless.mark(sessionID)
|
||||
// kilocode_change end
|
||||
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
if (args.format === "json") {
|
||||
@@ -746,8 +750,8 @@ export const RunCommand = effectCmd({
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
// kilocode_change start - track Task child sessions for --auto permission replies
|
||||
if (args.auto) KiloRunAuto.track(auto, part)
|
||||
// kilocode_change start - track Task child sessions for --auto and --dangerously-skip-permissions replies
|
||||
if (args.auto || args["dangerously-skip-permissions"]) KiloRunAuto.track(auto, part)
|
||||
// kilocode_change end
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
@@ -851,6 +855,16 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - approve tracked Task child asks too, so subagents don't hang (#11903)
|
||||
if (args["dangerously-skip-permissions"] && KiloRunAuto.allowed(auto, permission.sessionID)) {
|
||||
await client.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
if (args["dangerously-skip-permissions"]) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
|
||||
/**
|
||||
* Headless roots (#11903).
|
||||
*
|
||||
* Root sessions driven by a client that cannot answer subagent permission
|
||||
* prompts (plain `kilo run`). Permission asks originating from their child
|
||||
* sessions must fail with DeniedError instead of blocking forever on a reply
|
||||
* that never comes. Interactive clients (TUI, extension) never mark sessions
|
||||
* here, so their subagent prompts stay answerable.
|
||||
*/
|
||||
export namespace KiloHeadless {
|
||||
const roots = new Set<string>()
|
||||
|
||||
export function mark(id: string) {
|
||||
roots.add(id)
|
||||
}
|
||||
|
||||
export function clear(id: string) {
|
||||
roots.delete(id)
|
||||
}
|
||||
|
||||
/** True when `id` is a subagent session whose root run has no attached human. */
|
||||
export function denies(id: string): boolean {
|
||||
if (roots.size === 0) return false
|
||||
if (roots.has(id)) return false
|
||||
for (let parent = lookup(id); parent; parent = lookup(parent)) {
|
||||
if (roots.has(parent)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function lookup(id: string) {
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select({ parent: SessionTable.parent_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, id as SessionID))
|
||||
.get(),
|
||||
)
|
||||
return row?.parent ?? undefined
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionID } from "./schema"
|
||||
// kilocode_change start
|
||||
import { ConfigProtection } from "@/kilocode/permission/config-paths"
|
||||
import { KiloHeadless } from "@/kilocode/permission/headless"
|
||||
import { drainCovered } from "@/kilocode/permission/drain"
|
||||
import { ReadPermission } from "@/kilocode/permission/read"
|
||||
import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory"
|
||||
@@ -276,6 +277,12 @@ export const layer = Layer.effect(
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
// kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903)
|
||||
if (KiloHeadless.denies(request.sessionID)) {
|
||||
return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) })
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
|
||||
@@ -44,8 +44,10 @@ import { SyncEvent } from "../../src/sync"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { KiloHeadless } from "../../src/kilocode/permission/headless"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -290,3 +292,123 @@ it.live("active tool calls use permissions changed after model streaming starts"
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const worker = (mode: "subagent" | "all"): AgentSvc.Info => ({
|
||||
name: "worker",
|
||||
mode,
|
||||
permission: Permission.fromConfig({ bash: "ask" }),
|
||||
options: {},
|
||||
})
|
||||
|
||||
const bash = (sessionID: Session.Info["id"]) => ({
|
||||
sessionID,
|
||||
permission: "bash",
|
||||
patterns: ["echo 1"],
|
||||
always: ["echo 1"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
// Reproduces #11903: a sync subagent hitting an "ask" rule in a headless run
|
||||
// used to block forever on a permission prompt no client would ever answer.
|
||||
it.live("headless run: subagent permission asks fail instead of waiting forever", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* () {
|
||||
const permission = yield* Permission.Service
|
||||
const sessions = yield* Session.Service
|
||||
const root = yield* sessions.create({ title: "Root" })
|
||||
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })
|
||||
KiloHeadless.mark(root.id)
|
||||
|
||||
// mode "all" agents are valid subagents too; the deny must not key off agent mode
|
||||
const agent = worker("all")
|
||||
const err = yield* awaitWithTimeout(
|
||||
KiloSessionPrompt.askPermission({
|
||||
permission,
|
||||
agents: { get: () => Effect.succeed(agent) },
|
||||
sessions,
|
||||
agent,
|
||||
session: child,
|
||||
request: bash(child.id),
|
||||
}).pipe(Effect.flip),
|
||||
"subagent permission ask queued waiting for a human reply instead of failing",
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(Permission.DeniedError)
|
||||
expect(yield* permission.list()).toEqual([])
|
||||
expect(KiloHeadless.denies(child.id)).toBe(true)
|
||||
expect(KiloHeadless.denies(root.id)).toBe(false)
|
||||
|
||||
KiloHeadless.clear(root.id)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("interactive run: subagent permission asks still queue for a human reply", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* () {
|
||||
const permission = yield* Permission.Service
|
||||
const sessions = yield* Session.Service
|
||||
const root = yield* sessions.create({ title: "Root" })
|
||||
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })
|
||||
|
||||
const agent = worker("subagent")
|
||||
const fiber = yield* KiloSessionPrompt.askPermission({
|
||||
permission,
|
||||
agents: { get: () => Effect.succeed(agent) },
|
||||
sessions,
|
||||
agent,
|
||||
session: child,
|
||||
request: bash(child.id),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
const list = yield* permission.list()
|
||||
return list.find((item) => item.sessionID === child.id)
|
||||
}),
|
||||
"subagent permission ask was never surfaced",
|
||||
)
|
||||
yield* permission.reply({ requestID: pending.id, reply: "reject" })
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headless run: root session permission asks still queue (only subagents fail)", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* () {
|
||||
const permission = yield* Permission.Service
|
||||
const sessions = yield* Session.Service
|
||||
const root = yield* sessions.create({ title: "Root" })
|
||||
KiloHeadless.mark(root.id)
|
||||
|
||||
const agent = { ...worker("subagent"), mode: "primary" as const }
|
||||
const fiber = yield* KiloSessionPrompt.askPermission({
|
||||
permission,
|
||||
agents: { get: () => Effect.succeed(agent) },
|
||||
sessions,
|
||||
agent,
|
||||
session: root,
|
||||
request: bash(root.id),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
const list = yield* permission.list()
|
||||
return list.find((item) => item.sessionID === root.id)
|
||||
}),
|
||||
"root permission ask was never surfaced",
|
||||
)
|
||||
yield* permission.reply({ requestID: pending.id, reply: "reject" })
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
KiloHeadless.clear(root.id)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user