Fix claude-hook stop teardown races (#1954)

* Add regression test for claude-hook stop teardown errors

* Handle claude-hook stop teardown races
This commit is contained in:
Austin Wang 2026-03-22 17:34:33 -07:00 committed by GitHub
parent c9616c59e9
commit e8cf83ca57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 352 additions and 77 deletions

View file

@ -10173,15 +10173,18 @@ struct CMUXCLI {
"has_surface_flag": optionValue(hookArgs, name: "--surface") != nil
]
)
let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client)
let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client)
switch subcommand {
case "session-start", "active":
telemetry.breadcrumb("claude-hook.session-start")
let workspaceId = fallbackWorkspaceId
let surfaceId = try resolveSurfaceIdForClaudeHook(
surfaceArg,
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: nil,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: nil,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
@ -10217,63 +10220,71 @@ struct CMUXCLI {
case "stop", "idle":
telemetry.breadcrumb("claude-hook.stop")
// Turn ended. Don't consume session or clear PID Claude is still alive.
// Notification hook handles user-facing notifications; SessionEnd handles cleanup.
var workspaceId = fallbackWorkspaceId
var surfaceId = surfaceArg
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
surfaceId = mapped.surfaceId
}
// Update session with transcript summary and send completion notification.
let completion = summarizeClaudeHookStop(
parsedInput: parsedInput,
sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? ""))
)
if let sessionId = parsedInput.sessionId, let completion {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId ?? "",
cwd: parsedInput.cwd,
lastSubtitle: completion.subtitle,
lastBody: completion.body
do {
// Turn ended. Don't consume session or clear PID Claude is still alive.
// Notification hook handles user-facing notifications; SessionEnd handles cleanup.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
}
if let completion {
let resolvedSurface = try resolveSurfaceIdForClaudeHook(
surfaceId,
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client)
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("OK")
// Update session with transcript summary and send completion notification.
let completion = summarizeClaudeHookStop(
parsedInput: parsedInput,
sessionRecord: mappedSession
)
if let sessionId = parsedInput.sessionId, let completion {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
lastSubtitle: completion.subtitle,
lastBody: completion.body
)
}
if let completion {
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
}
try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("OK")
} catch {
if shouldIgnoreClaudeHookTeardownError(error) {
telemetry.breadcrumb("claude-hook.stop.ignored", data: ["error": String(describing: error)])
print("OK")
return
}
throw error
}
case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit")
var workspaceId = fallbackWorkspaceId
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
}
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
_ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
try setClaudeStatus(
client: client,
@ -10288,23 +10299,21 @@ struct CMUXCLI {
telemetry.breadcrumb("claude-hook.notification")
var summary = summarizeClaudeHookNotification(rawInput: rawInput)
var workspaceId = fallbackWorkspaceId
var preferredSurface = surfaceArg
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
preferredSurface = mapped.surfaceId
// If PreToolUse saved a richer message (e.g. from AskUserQuestion),
// use it instead of the generic notification text.
if let savedBody = mapped.lastBody, !savedBody.isEmpty,
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
summary = (subtitle: mapped.lastSubtitle ?? summary.subtitle, body: savedBody)
}
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
if let mappedSession,
let savedBody = mappedSession.lastBody, !savedBody.isEmpty,
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody)
}
let surfaceId = try resolveSurfaceIdForClaudeHook(
preferredSurface,
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
@ -10341,6 +10350,21 @@ struct CMUXCLI {
// Only clear when we are the primary cleanup path (Stop didn't fire first).
// If Stop already consumed the session, consumedSession is nil and we skip
// to avoid wiping the completion notification that Stop just delivered.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let fallbackSurfaceId: String? = {
guard let fallbackWorkspaceId else { return nil }
return try? resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: fallbackWorkspaceId,
client: client
)
}()
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
@ -10358,14 +10382,13 @@ struct CMUXCLI {
telemetry.breadcrumb("claude-hook.pre-tool-use")
// Clears "Needs input" status and notification when Claude resumes work
// (e.g. after permission grant). Runs async so it doesn't block tool execution.
var workspaceId = fallbackWorkspaceId
var claudePid: Int? = nil
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
claudePid = mapped.pid
}
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let claudePid = mappedSession?.pid
// AskUserQuestion means Claude is about to ask the user something.
// Save question text in session so the Notification handler can use it
@ -10442,6 +10465,70 @@ struct CMUXCLI {
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)")
}
private func resolvePreferredWorkspaceIdForClaudeHook(
preferred: String?,
fallback: String?,
client: SocketClient
) throws -> String {
if let preferred = nonEmptyClaudeHookIdentifier(preferred) {
if isUUID(preferred) {
return preferred
}
return try resolveWorkspaceIdForClaudeHook(preferred, client: client)
}
if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) {
return fallback
}
return try resolveWorkspaceIdForClaudeHook(fallback, client: client)
}
private func resolvePreferredSurfaceIdForClaudeHook(
preferred: String?,
fallback: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let preferred = nonEmptyClaudeHookIdentifier(preferred) {
if isUUID(preferred) {
return preferred
}
return try resolveSurfaceIdForClaudeHook(preferred, workspaceId: workspaceId, client: client)
}
if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) {
return fallback
}
return try resolveSurfaceIdForClaudeHook(fallback, workspaceId: workspaceId, client: client)
}
private func nonEmptyClaudeHookIdentifier(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private func shouldIgnoreClaudeHookTeardownError(_ error: Error) -> Bool {
let message = String(describing: error).lowercased()
let benignFragments = [
"tabmanager not available",
"no workspace selected",
"workspace not found",
"workspace ref not found",
"workspace index not found",
"surface not found",
"surface ref not found",
"surface index not found",
"unable to resolve surface id",
"panel not found",
"tab not found",
"failed to write to socket",
"socket read error",
"not connected"
]
return benignFragments.contains { message.contains($0) }
}
private func describeAskUserQuestion(_ object: [String: Any]?) -> String? {
guard let object,
let input = object["tool_input"] as? [String: Any],