Skip to content

Commit bd3dc5e

Browse files
committed
merge(local): Phase B — auto-reject questions/permissions for stuck subagents
Phase B adds RunEvents handler that subscribes to Question.Event.Asked and Permission.Event.Asked and auto-rejects (or auto-approves in skipPermissions mode) events targeting descendant sessions of the root run. Prevents subagent livelock from stalling the root session. - B.1 (45566e4): RED test + branch - B.2a (3464886 + 64e0b68): handler module + 8 tests + defect narrowing - B.2b (1813c42): wire into run.ts non-attach path, remove double-reply Diamond review: general (spec) + codex-5.3 (quality), both APPROVE. Plan: docs/superpowers/plans/2026-04-18-subagent-hang-hardening.md lines 810-1207
2 parents 3af65e2 + 1813c42 commit bd3dc5e

3 files changed

Lines changed: 592 additions & 104 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Effect, Option } from "effect"
2+
import { Bus } from "@/bus"
3+
import { Permission } from "@/permission"
4+
import { Question } from "@/question"
5+
import { Session } from "@/session"
6+
import { NotFoundError } from "@/storage"
7+
import { SessionID } from "@/session/schema"
8+
import { Log } from "@/util"
9+
10+
const log = Log.create({ service: "run-events" })
11+
12+
export const LIVELOCK_WARN_THRESHOLD = 5
13+
export const MAX_LINEAGE_DEPTH = 32
14+
15+
export interface Config {
16+
rootSessionID: SessionID
17+
skipPermissions: boolean
18+
jsonMode: boolean
19+
}
20+
21+
export interface Stats {
22+
autoRejectedQuestions: number
23+
autoRejectedPermissions: number
24+
livelockWarned: boolean
25+
}
26+
27+
export interface Handle {
28+
readonly stats: Stats
29+
readonly unsubscribe: () => void
30+
}
31+
32+
export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
33+
const question = yield* Question.Service
34+
const permission = yield* Permission.Service
35+
const bus = yield* Bus.Service
36+
const session = yield* Session.Service
37+
38+
const stats: Stats = {
39+
autoRejectedQuestions: 0,
40+
autoRejectedPermissions: 0,
41+
livelockWarned: false,
42+
}
43+
44+
const descendants = new Set<SessionID>([config.rootSessionID])
45+
46+
const emit = (type: string, data: Record<string, unknown>) => {
47+
if (!config.jsonMode) return
48+
process.stdout.write(
49+
JSON.stringify({ type, timestamp: Date.now(), sessionID: config.rootSessionID, ...data }) + "\n",
50+
)
51+
}
52+
53+
const isDescendant = Effect.fn("RunEvents.isDescendant")(function* (sid: SessionID) {
54+
if (descendants.has(sid)) return true
55+
let cur: SessionID | undefined = sid
56+
const chain: SessionID[] = []
57+
let depth = 0
58+
while (cur !== undefined && !descendants.has(cur) && depth < MAX_LINEAGE_DEPTH) {
59+
chain.push(cur)
60+
depth++
61+
const lookup: Option.Option<Session.Info> = yield* session.get(cur).pipe(
62+
Effect.option,
63+
Effect.catchDefect((defect) => {
64+
if (!NotFoundError.isInstance(defect)) return Effect.die(defect)
65+
return Effect.succeed(Option.none<Session.Info>())
66+
}),
67+
)
68+
if (Option.isNone(lookup)) break
69+
cur = lookup.value.parentID ?? undefined
70+
}
71+
if (cur === undefined || !descendants.has(cur)) return false
72+
chain.forEach((item) => descendants.add(item))
73+
return true
74+
})
75+
76+
const bump = (kind: "question" | "permission", sid: SessionID) => {
77+
if (kind === "question") stats.autoRejectedQuestions++
78+
else stats.autoRejectedPermissions++
79+
const total = stats.autoRejectedQuestions + stats.autoRejectedPermissions
80+
emit("auto-reject", { kind, autoRejectSessionID: sid, totalAutoRejects: total })
81+
if (!stats.livelockWarned && total > LIVELOCK_WARN_THRESHOLD) {
82+
stats.livelockWarned = true
83+
log.warn("possible subagent livelock: >5 auto-rejects in a single run", {
84+
rootSessionID: config.rootSessionID,
85+
})
86+
}
87+
}
88+
89+
const unsubQuestion = yield* bus.subscribeCallback(Question.Event.Asked, (evt) =>
90+
Effect.runPromise(
91+
Effect.gen(function* () {
92+
const mine = yield* isDescendant(evt.properties.sessionID)
93+
if (!mine) return
94+
bump("question", evt.properties.sessionID)
95+
yield* question.reject(evt.properties.id)
96+
}),
97+
),
98+
)
99+
100+
const unsubPermission = yield* bus.subscribeCallback(Permission.Event.Asked, (evt) =>
101+
Effect.runPromise(
102+
Effect.gen(function* () {
103+
const mine = yield* isDescendant(evt.properties.sessionID)
104+
if (!mine) return
105+
if (config.skipPermissions) {
106+
yield* permission.reply({ requestID: evt.properties.id, reply: "once" })
107+
return
108+
}
109+
bump("permission", evt.properties.sessionID)
110+
yield* permission.reply({ requestID: evt.properties.id, reply: "reject" })
111+
}),
112+
),
113+
)
114+
115+
const unsubscribe = () => {
116+
unsubQuestion()
117+
unsubPermission()
118+
}
119+
120+
return { stats, unsubscribe } satisfies Handle
121+
})
122+
123+
export * as RunEvents from "./run-events"

0 commit comments

Comments
 (0)