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:
parent
c9616c59e9
commit
e8cf83ca57
2 changed files with 352 additions and 77 deletions
241
CLI/cmux.swift
241
CLI/cmux.swift
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue