fix(cli): fail headless subagent permission asks instead of hanging

This commit is contained in:
marius-kilocode
2026-07-03 17:39:52 +02:00
parent c78d44a143
commit fda4e1756b
5 changed files with 197 additions and 4 deletions
+17 -3
View File
@@ -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 },
),
)