From fe3febbfd200d643af815d481ce63dec9ebe3ba2 Mon Sep 17 00:00:00 2001 From: wuguangan Date: Fri, 7 Aug 2026 18:10:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Stop=20=E6=8B=A6=E6=88=AA=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20=E2=80=94=E2=80=94=20=E6=94=B6=E5=B7=A5=E6=97=B6?= =?UTF-8?q?=E6=8B=BF=E6=B8=85=E5=8D=95=E9=80=BC=E5=AE=83=E9=80=90=E6=9D=A1?= =?UTF-8?q?=E6=8A=A5=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop 从「非阻塞、输出被忽略」改成阻塞式,有待验收项时输出 {"decision":"block","reason":<清单>} 让 Claude 不停下来,先逐条核对。 下一轮的 lastAssistantMessage 里的自查 JSON 被收回来,把 open 推到 claimed。 Stop 的超时阶梯和审批那条**完全分开**,方向也相反: 3s HookCoordinator / 5s hubctl / 10s settings.json,超时一律 = 不拦。 审批超时 = 拒绝(安全刹车);这里超时 = 放行。写反的话 Hub 一出问题 所有会话就再也收不了工,比"少提醒一次验收"严重得多。实测 hook 本身 6-11ms,Hub 在不在都一样。 查出来三个真问题,都不是靠读代码发现的: 1. 服务端从不给 stop 回写应答。我改了 hubctl 让它等,却没动 HubSocketServer 里写死的 guard kind == .preToolUse —— 拦截一次都没生效 过,而且不报错、不超时、10ms 就返回,表现得和"清单里没有待办" 一模一样。端到端测了三轮才找到。修法不是补一个 if,是加 Kind.expectsDecision 让两边共用同一个判断。 这和上次「grep activeScreen() 漏了一个调用点」是同一类错误: 改了协议的一端,假设另一端会跟上。 2. SIGPIPE 会杀掉 Hub。服务端开始给 stop 回写之后,遇到不等应答就关连接 的客户端(旧版 hubctl、被 Ctrl-C 的 hook),那次 write 直接终止进程 —— 不是报错,是进程没了。测试崩在 signal 13 上才暴露。 先加了 per-socket 的 SO_NOSIGPIPE(看起来更讲究),结果没生效, 照样崩;改成进程级 signal(SIGPIPE, SIG_IGN) 才好。 更讲究的写法如果不生效,就只是更讲究的 bug。 3. stop 事件不能去重。缓存决策只有在决策是事件的纯函数时才成立, 而 stop 的结论取决于会变的上膛状态:同样内容的两条 stop, 一条在用户说话前、一条在之后,结论截然相反。实测被这个坑到过。 4. {"done":1} 被判成做完了。JSONSerialization 把数字 1 桥接成 NSNumber, as? Bool 对它成立 —— 模型只要把 done 写成数字就能绕过"含糊按没做算"。 改用 CFBooleanGetTypeID 严格判定。 端到端实测(真实 hubctl 二进制):未上膛不拦、上膛后拦下 13 条要点、 连着两次收工都放行、Hub 存活、自查回答(带散文和围栏)正确回收成 claimed 且 hasProof 仍为 false。 346 个测试。 Co-Authored-By: Claude Opus 5 (1M context) --- HubKit/Sources/HubIPC/HookEvent.swift | 36 ++++++ HubKit/Sources/HubIPC/HubSocketServer.swift | 15 ++- HubKit/Sources/HubIPC/Timeouts.swift | 27 ++++ HubKit/Sources/HubIPC/UnixSocket.swift | 37 ++++++ HubKit/Sources/HubUI/HookCoordinator.swift | 117 +++++++++++++++++- HubKit/Sources/HubUI/HookDedup.swift | 15 ++- HubKit/Sources/hubctl/main.swift | 19 ++- .../HubIPCTests/SocketRoundTripTests.swift | 8 ++ .../HubIPCTests/StopHookOutputTests.swift | 108 ++++++++++++++++ .../AcceptanceClaimParsingTests.swift | 98 +++++++++++++++ HubKit/Tests/HubUITests/HookDedupTests.swift | 25 +++- scripts/ensure-project-config.sh | 28 +++-- scripts/setup-swift-hooks.sh | 11 +- 13 files changed, 518 insertions(+), 26 deletions(-) create mode 100644 HubKit/Tests/HubIPCTests/StopHookOutputTests.swift create mode 100644 HubKit/Tests/HubUITests/AcceptanceClaimParsingTests.swift diff --git a/HubKit/Sources/HubIPC/HookEvent.swift b/HubKit/Sources/HubIPC/HookEvent.swift index 1876eba..dc0393d 100644 --- a/HubKit/Sources/HubIPC/HookEvent.swift +++ b/HubKit/Sources/HubIPC/HookEvent.swift @@ -42,6 +42,24 @@ public struct HookEvent: Codable, Sendable { /// 压缩正是"遗忘"最集中发生的时刻 —— Claude 记不住的东西, /// Hub 的清单记得住。 case preCompact + + /// 这类事件的发起方在等一个决策,服务端必须回写。 + /// + /// **hubctl 和 HubSocketServer 必须都用这一个属性判断,别各写各的 `if`。** + /// + /// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答), + /// 服务端那边的 `guard kind == .preToolUse` 原封不动 —— 于是 hubctl 等到的 + /// 永远是连接关闭,拦截**一次都没生效过**,而且不报错、不超时、 + /// 表现得和"清单里没有待办"一模一样。查了三轮才找到。 + /// + /// 两边共用一个判断之后,这种分歧在结构上就不成立了。 + public var expectsDecision: Bool { + switch self { + case .preToolUse, .stop: return true + case .notification, .sessionEnd, .userPromptSubmit, + .sessionStart, .postToolUse, .subagentStop, .preCompact: return false + } + } } public let kind: Kind @@ -188,6 +206,24 @@ public struct HookDecision: Codable, Sendable { guard let data = try? JSONSerialization.data(withJSONObject: output) else { return nil } return String(data: data, encoding: .utf8) } + + /// Stop hook 应答的 stdout 内容。 + /// + /// `{"decision":"block","reason":...}` 会让 Claude **不停下来**, + /// 并把 reason 当成新的输入继续跑一轮 —— 验收清单就是靠这个塞回去的。 + /// + /// nil = 什么都不输出 = 正常收工。**这是绝大多数情况**,也是所有异常 + /// 情况的落点:Hub 没运行、桥接超时、清单为空、没上膛,全都走这里。 + /// + /// 注意和 `hookOutputJSON()` 的格式**完全不同**(那个是 + /// hookSpecificOutput + permissionDecision),别想着合并成一个。 + public func stopOutputJSON() -> String? { + guard verdict == .deny, let reason, !reason.isEmpty else { return nil } + guard let data = try? JSONSerialization.data( + withJSONObject: ["decision": "block", "reason": reason] + ) else { return nil } + return String(data: data, encoding: .utf8) + } } /// socket 路径。 diff --git a/HubKit/Sources/HubIPC/HubSocketServer.swift b/HubKit/Sources/HubIPC/HubSocketServer.swift index 8aeea98..5c006a3 100644 --- a/HubKit/Sources/HubIPC/HubSocketServer.swift +++ b/HubKit/Sources/HubIPC/HubSocketServer.swift @@ -30,6 +30,11 @@ public final class HubSocketServer: @unchecked Sendable { } public func start() throws { + // 客户端可能在我们回写决策之前就关掉连接(不等应答的 hook、 + // 被 Ctrl-C 掉的 hubctl、旧版二进制)。不忽略 SIGPIPE 的话, + // 那一次 write 会**直接杀掉 Claude Hub** —— 不是报错,是进程没了。 + UnixSocket.ignoreSIGPIPEProcessWide() + let fd = try UnixSocket.listen(at: path) listenFd = fd @@ -50,6 +55,9 @@ public final class HubSocketServer: @unchecked Sendable { private func acceptOne() { let clientFd = accept(listenFd, nil, nil) guard clientFd >= 0 else { return } + // 客户端可能在我们回写之前就关掉连接(不等应答的 hook、被 Ctrl-C 掉的 + // hubctl)。不关掉 SIGPIPE 的话那一次 write 会**直接杀掉 Claude Hub**。 + UnixSocket.suppressSIGPIPE(fd: clientFd) workQueue.async { [handler] in defer { close(clientFd) } @@ -63,7 +71,8 @@ public final class HubSocketServer: @unchecked Sendable { let decision = handler(event) - guard event.kind == .preToolUse else { return } + // 用 Kind.expectsDecision,别在这里写死类型 —— 见那个属性上的事故说明。 + guard event.kind.expectsDecision else { return } let payload = decision ?? .allow if let encoded = try? JSONEncoder().encode(payload), let text = String(data: encoded, encoding: .utf8) { @@ -102,6 +111,10 @@ public enum HubSocketClient { waitForDecision: Bool, timeout: TimeInterval ) -> HookSendResult { + // Hub 可能正在退出,服务端先关了。被 SIGPIPE 杀掉的 hook 等同于 + // 没有输出 —— 对审批链路来说那就是放行,正是 fail-open 的反面。 + UnixSocket.ignoreSIGPIPEProcessWide() + guard let data = try? JSONEncoder().encode(event), let line = String(data: data, encoding: .utf8) else { return .unreachable } diff --git a/HubKit/Sources/HubIPC/Timeouts.swift b/HubKit/Sources/HubIPC/Timeouts.swift index 70e939b..14576a6 100644 --- a/HubKit/Sources/HubIPC/Timeouts.swift +++ b/HubKit/Sources/HubIPC/Timeouts.swift @@ -44,4 +44,31 @@ public enum HookTimeouts { /// 必须大于 `clientRead`,否则 hubctl 还没来得及输出拒绝就被杀了, /// 而被杀的 hook 等同于没有输出 —— 也就是放行。 public static let hookProcess: Int = 90 + + // MARK: - Stop 的阶梯(独立的一组,和上面那条不共用) + // + // Stop 变成阻塞式之后,**每一次收工都要等 Hub 应答**。挂在上面那条 + // 75 秒的阶梯上是不能接受的:Hub 卡住时用户每说一句话就要干等 75 秒。 + // + // 更重要的是**方向相反**。审批链路上超时 = 拒绝(安全刹车); + // Stop 链路上超时 = **不拦**。写反的话,Hub 一出问题所有会话就再也 + // 收不了工 —— 那是比"少提醒一次验收"严重得多的故障。 + // + // ``` + // 3s HookCoordinator 等 MainActor 算出该不该拦 → 到点就不拦 + // 5s hubctl 读 socket 等服务端应答 → 到点什么都不输出 = 正常收工 + // 10s settings.json Claude 杀掉 hubctl 的上限 + // ``` + // + // 三个数都小得多,因为这里不需要等人 —— 该不该拦是查内存里的清单, + // 毫秒级就有答案。留 3 秒纯粹是给 MainActor 排队的余量。 + + /// 服务端等 MainActor 给出"拦不拦"。到点 = 不拦。 + public static let stopBridge: TimeInterval = 3 + + /// hubctl 等服务端应答。到点 = 什么都不输出 = 正常收工。 + public static let stopRead: TimeInterval = 5 + + /// 写进 settings.json 的 Stop hook 超时。 + public static let stopHookProcess: Int = 10 } diff --git a/HubKit/Sources/HubIPC/UnixSocket.swift b/HubKit/Sources/HubIPC/UnixSocket.swift index fd1180c..17278fa 100644 --- a/HubKit/Sources/HubIPC/UnixSocket.swift +++ b/HubKit/Sources/HubIPC/UnixSocket.swift @@ -88,6 +88,9 @@ enum UnixSocket { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw SocketError.createFailed(errno) } + // 服务端也可能先关(Hub 正在退出)。同样不能让写失败变成杀掉 hubctl —— + // 被信号杀掉的 hook 等同于没有输出,而那正是 fail-open 的反面。 + suppressSIGPIPE(fd: fd) let connected = withAddress(&addr) { Darwin.connect(fd, $0, $1) } guard connected == 0 else { @@ -131,6 +134,40 @@ enum UnixSocket { /// 写一行。 @discardableResult + /// 关掉这个 fd 的 SIGPIPE。 + /// + /// **不加这一句,往一个对端已关闭的 socket 写数据会直接杀掉整个进程。** + /// 默认行为是发 SIGPIPE,而 SIGPIPE 的默认处置是终止 —— 不是返回错误码, + /// 是进程没了。 + /// + /// 这不是理论风险,是实测撞到的:Stop 改成需要应答之后,服务端会给 stop + /// 回写决策;而不等应答的客户端(旧版 hubctl、被 Ctrl-C 掉的 hook) + /// 早就把连接关了。于是每一次这样的收工都会**杀掉 Claude Hub**。 + /// 测试套件是直接崩在 signal 13 上才暴露的。 + /// + /// 用 per-socket 的 `SO_NOSIGPIPE` 而不是全局 `signal(SIGPIPE, SIG_IGN)`: + /// 全局改会影响进程里所有别的代码(包括 Swift 运行时和第三方库), + /// 而这里只需要让这几个 socket 上的写失败退化成返回 -1 + EPIPE。 + static func suppressSIGPIPE(fd: Int32) { + var on: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + } + + /// 进程级忽略 SIGPIPE。**必须调,per-socket 的那个不够。** + /// + /// 一开始只加了 `SO_NOSIGPIPE`(per-socket,影响面更小,看起来更讲究), + /// 结果测试照样崩在 signal 13 上 —— 那个 setsockopt 的返回值没人检查, + /// 而它显然没起作用。这是"选了看起来更干净的方案,却没验证它真的有效" + /// 的典型:更讲究的写法如果不生效,就只是更讲究的 bug。 + /// + /// 忽略之后写失败退化成返回 -1 + EPIPE,由 `writeLine` 正常返回 false。 + /// 对网络服务来说这是标准做法,不是权宜之计。 + /// + /// 幂等,多次调用无害。 + static func ignoreSIGPIPEProcessWide() { + signal(SIGPIPE, SIG_IGN) + } + static func writeLine(fd: Int32, _ line: String) -> Bool { var data = Array((line + "\n").utf8) var offset = 0 diff --git a/HubKit/Sources/HubUI/HookCoordinator.swift b/HubKit/Sources/HubUI/HookCoordinator.swift index a2ee543..71053f8 100644 --- a/HubKit/Sources/HubUI/HookCoordinator.swift +++ b/HubKit/Sources/HubUI/HookCoordinator.swift @@ -108,8 +108,7 @@ public final class HookCoordinator { private nonisolated func dispatch(_ event: HookEvent) -> HookDecision { switch event.kind { case .stop: - Task { @MainActor in self.handleStop(event) } - return .allow + return handleStopSynchronously(event) case .notification: Task { @MainActor in self.handleNotification(event) } @@ -142,10 +141,47 @@ public final class HookCoordinator { } } + /// 收工。**阻塞式** —— hubctl 在等"要不要把验收清单塞回去"。 + /// + /// ## 兜底方向和审批链路相反,这是本方法最要紧的一条 + /// + /// `handlePreToolUseSynchronously` 超时兜底是 **deny**(安全刹车)。 + /// 这里超时兜底必须是 **allow = 不拦**。写反的话 Hub 一出问题, + /// 所有会话就再也收不了工 —— 比"少提醒一次验收"严重得多。 + /// + /// 3 秒足够:该不该拦是查内存里的清单,毫秒级就有答案, + /// 这 3 秒纯粹是给 MainActor 排队留的余量。 + private nonisolated func handleStopSynchronously(_ event: HookEvent) -> HookDecision { + let semaphore = DispatchSemaphore(value: 0) + // 同 handlePreToolUseSynchronously:只有下面的 Task 写、wait 之后才读。 + nonisolated(unsafe) var result = HookDecision.allow + + Task { @MainActor in + result = self.handleStop(event) + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + HookTimeouts.stopBridge) == .timedOut { + HubLog.ipc.error("收工桥接超时 —— 放行(绝不能因为 Hub 慢就让会话停不下来)") + return .allow + } + return result + } + @MainActor - private func handleStop(_ event: HookEvent) { + private func handleStop(_ event: HookEvent) -> HookDecision { store.refresh() + let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects) + + // 先收自查回答,再决定拦不拦。 + // + // 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的 + // lastAssistantMessage 里,先收下来,这一轮的清单才是最新的。 + collectClaims(from: event, projectPath: path) + + let decision = interceptDecision(for: event, projectPath: path) + let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName let session = store.sessions.first { $0.sessionId == event.sessionId } let title = session?.name ?? project @@ -154,6 +190,13 @@ public final class HookCoordinator { // ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。 let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复" + // 被拦下来的这一轮不是"收工",别发完成通知 —— Claude 马上还要再跑一轮, + // 现在弹「✅ 完成了」是在骗用户。 + guard decision.verdict != .deny else { + HubLog.app.notice("验收守望:拦下 \(title, privacy: .public) 的收工,要求逐条核对") + return decision + } + notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId) if allowIntrusion(for: event.sessionId) { @@ -164,6 +207,74 @@ public final class HookCoordinator { ) ) } + return decision + } + + /// 该不该拦这一次收工。 + @MainActor + private func interceptDecision(for event: HookEvent, projectPath: String) -> HookDecision { + // Claude 已经因为 Stop hook 在续跑了 —— 绝不能再拦。 + // + // 这是**第二道**。真正的保证是下面 disarmAndShouldIntercept 里的上膛机制, + // 因为这个字段在本机没验证过,不同 CLI 版本给不给都不确定。 + guard event.stopHookActive != true else { return .allow } + + guard acceptance.disarmAndShouldIntercept( + sessionId: event.sessionId, projectPath: projectPath + ) else { return .allow } + + guard let text = acceptance.injectionText(for: projectPath) else { return .allow } + + return HookDecision(verdict: .deny, reason: text) + } + + /// 收下 Claude 上一轮的逐条自查回答。 + /// + /// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事), + /// 或者干脆没按格式答。这两种情况都退回旁路复核,不该报错也不该清状态。 + @MainActor + private func collectClaims(from event: HookEvent, projectPath: String) { + guard let message = event.lastAssistantMessage, + let claims = Self.parseClaims(message), !claims.isEmpty + else { return } + acceptance.applyClaims(claims, in: projectPath) + HubLog.app.notice("验收守望:收下 \(claims.count, privacy: .public) 条自查回答") + } + + /// 从 Claude 的回复里挖出自查 JSON。 + /// + /// 走 `ModelOutput.extractJSONObject` 而不是直接解析:模型在 JSON 前后 + /// 各写一段话是常态("我核对了一遍:{…} 需要我继续吗?"), + /// 而这里它是在一段长对话的末尾被要求输出 JSON 的,更容易加客套话。 + nonisolated static func parseClaims(_ message: String) -> [AcceptanceClaim]? { + guard let object = ModelOutput.extractJSONObject(message), + let raw = object["items"] as? [[String: Any]] + else { return nil } + + return raw.compactMap { item in + guard let id = item["id"] as? String, !id.isEmpty else { return nil } + return AcceptanceClaim( + id: id, + // done 缺失或不是**真正的布尔**时按没做算。方向是刻意的: + // 把含糊不清当成"做完了",等于给虚报开了个免检通道。 + done: Self.strictlyTrue(item["done"]), + evidence: (item["evidence"] as? String) ?? "" + ) + } + } + + /// 只有 JSON 里字面写的 `true` 才算数。 + /// + /// **不能用 `as? Bool`。** JSONSerialization 把 `true` 和 `1` 都解成 NSNumber, + /// 而 NSNumber 到 Bool 的桥接对 `1` 是成立的 —— 于是 `{"done":1}` 会被判成 + /// 做完了。测试抓到过:模型只要把 done 写成数字就能绕过"含糊按没做算"。 + /// + /// CFBoolean 和 CFNumber 是不同的 CF 类型,按类型 ID 判才分得开。 + nonisolated static func strictlyTrue(_ value: Any?) -> Bool { + guard let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { return false } + return number.boolValue } /// 用户提交了一句话。 diff --git a/HubKit/Sources/HubUI/HookDedup.swift b/HubKit/Sources/HubUI/HookDedup.swift index 6e2327e..feabf41 100644 --- a/HubKit/Sources/HubUI/HookDedup.swift +++ b/HubKit/Sources/HubUI/HookDedup.swift @@ -58,7 +58,20 @@ final class HookDedup: @unchecked Sendable { // 空输入退化成空串是安全的:5 秒内两条空输入吞掉一条没有任何损失。 discriminator = event.promptText ?? "" case .stop: - discriminator = event.lastAssistantMessage ?? event.stopReason ?? "" + // **刻意不去重。** + // + // 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足 + // (同一次工具调用永远同一个结论),stop 不满足 —— 它取决于会变的 + // 上膛状态:同样内容的两条 stop,一条在用户说话前、一条在之后, + // 结论截然相反。 + // + // 实测踩到过:先发一条未上膛的 stop(不拦),再上膛、再发一条内容 + // 一模一样的 stop —— 第二条被当成副本,复用了"不拦",拦截静默失效。 + // + // 不去重的代价只是重复 stop 会多发一条通知,而重复 stop 本来就 + // 几乎不会发生(相同命令 Claude Code 自己会去重)。上膛机制自身 + // 也是安全的:第一条消费掉膛,第二条自然看到未上膛。 + discriminator = nil case .notification: discriminator = event.message ?? event.notificationType ?? "" case .sessionStart, .sessionEnd, .subagentStop, .preCompact: diff --git a/HubKit/Sources/hubctl/main.swift b/HubKit/Sources/hubctl/main.swift index 6db73a3..5b6a4f9 100644 --- a/HubKit/Sources/hubctl/main.swift +++ b/HubKit/Sources/hubctl/main.swift @@ -146,10 +146,25 @@ private func runHook(_ name: String) -> Int32 { let result = HubSocketClient.send( event, - waitForDecision: kind == .preToolUse, - timeout: approvalTimeout + // 和服务端共用同一个判断,见 Kind.expectsDecision 上的事故说明。 + waitForDecision: kind.expectsDecision, + // Stop 走独立的短阶梯:它挡在**每一次收工**前面, + // 用 75 秒会让 Hub 一卡住用户就永远收不了工。 + timeout: kind == .stop ? HookTimeouts.stopRead : approvalTimeout ) + // Stop 的应答和 PreToolUse **方向相反**,单独处理。 + // + // PreToolUse 的 .noResponse 要保守拒绝(服务端认为该拦,只是没答上来); + // Stop 的一切异常都必须什么都不输出 = 正常收工。写反的话 Hub 一出问题 + // 所有会话都再也停不下来 —— 比"少提醒一次验收"严重得多。 + if kind == .stop { + if case .decided(let decision) = result, let text = decision.stopOutputJSON() { + print(text) + } + return 0 + } + guard kind == .preToolUse else { return 0 } // 排障开关:HUB_DEBUG=1 时把收到的结果打到 stderr。 diff --git a/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift b/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift index 5f13426..15b96be 100644 --- a/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift +++ b/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift @@ -80,6 +80,14 @@ final class SocketRoundTripTests: XCTestCase { } } + /// 顺带也是 **SIGPIPE 的回归护栏**,别删。 + /// + /// 它模拟的是"客户端不等应答就关连接"——旧版 hubctl、被 Ctrl-C 掉的 hook + /// 都是这个形态。Stop 改成需要应答之后,服务端会往这个已关闭的连接写决策, + /// 而没有忽略 SIGPIPE 的话那一次 write 会**直接杀掉整个进程**。 + /// + /// 当时的表现就是这条测试崩在 signal 13 上(不是断言失败,是进程没了)。 + /// 生产环境里对应的是"新 Hub + 旧 hubctl → 每次收工 Claude Hub 都被杀掉"。 func testFireAndForgetEventReachesServer() throws { let received = XCTestExpectation(description: "服务端收到事件") nonisolated(unsafe) var seen: HookEvent? diff --git a/HubKit/Tests/HubIPCTests/StopHookOutputTests.swift b/HubKit/Tests/HubIPCTests/StopHookOutputTests.swift new file mode 100644 index 0000000..f3468c0 --- /dev/null +++ b/HubKit/Tests/HubIPCTests/StopHookOutputTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import HubIPC + +/// Stop hook 的应答格式和超时阶梯。 +/// +/// Stop 从「非阻塞、输出被忽略」改成了「阻塞、输出决定 Claude 停不停」, +/// 这是全案侵入性最大的改动 —— 它挡在**每一次收工**前面。 +final class StopHookOutputTests: XCTestCase { + + // MARK: - 输出格式 + + /// 绝大多数情况都走这条:什么都不输出 = 正常收工。 + func testAllowPrintsNothing() { + XCTAssertNil(HookDecision.allow.stopOutputJSON()) + } + + func testDenyBlocksWithTheReason() throws { + let decision = HookDecision(verdict: .deny, reason: "还有 3 项没核对") + + let json = try XCTUnwrap(decision.stopOutputJSON()) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + + XCTAssertEqual(object["decision"] as? String, "block") + XCTAssertEqual(object["reason"] as? String, "还有 3 项没核对") + } + + /// 没有理由的拦截等于让 Claude 空转一轮 —— 它收不到任何指示, + /// 只能再说一遍"做完了",然后又被拦。宁可不拦。 + func testDenyWithoutAReasonDoesNotBlock() { + XCTAssertNil(HookDecision(verdict: .deny, reason: nil).stopOutputJSON()) + XCTAssertNil(HookDecision(verdict: .deny, reason: "").stopOutputJSON()) + } + + /// `ask` 是 PreToolUse 的语义,Stop 这条链路上没有对应概念 —— 不该输出。 + func testAskPrintsNothingOnTheStopChannel() { + XCTAssertNil(HookDecision(verdict: .ask, reason: "随便").stopOutputJSON()) + } + + /// Stop 和 PreToolUse 的输出格式**完全不同**,别想着合并成一个方法。 + /// + /// PreToolUse 是 `hookSpecificOutput.permissionDecision`, + /// Stop 是顶层的 `decision` / `reason`。用错格式的后果是静默失效: + /// Claude 认不出来就当没有输出,也就是照常收工。 + func testTheTwoChannelsUseDifferentShapes() throws { + let decision = HookDecision(verdict: .deny, reason: "拦一下") + + let stop = try XCTUnwrap(decision.stopOutputJSON()) + let pre = try XCTUnwrap(decision.hookOutputJSON()) + + XCTAssertTrue(stop.contains("\"decision\"")) + XCTAssertFalse(stop.contains("hookSpecificOutput")) + XCTAssertTrue(pre.contains("hookSpecificOutput")) + XCTAssertFalse(pre.contains("\"decision\":\"block\"")) + } + + // MARK: - 客户端和服务端必须对「谁在等应答」有共识 + + /// **阻塞类事件的集合只能有一个定义。** + /// + /// 这条来自一次真实事故:把 Stop 改成阻塞式时只动了 hubctl(让它等应答), + /// 服务端 `HubSocketServer` 里那句 `guard kind == .preToolUse` 原封不动 —— + /// 于是 hubctl 等到的永远是连接关闭。 + /// + /// 最难受的是它的**症状**:不报错、不超时、hook 10ms 就返回, + /// 表现得和"清单里没有待办所以不用拦"一模一样。端到端测了三轮才找到。 + /// + /// 现在两边都读 `Kind.expectsDecision`,分歧在结构上不成立。 + /// 这条测试钉住的是这个集合本身。 + func testOnlyBlockingKindsExpectADecision() { + XCTAssertTrue(HookEvent.Kind.preToolUse.expectsDecision) + XCTAssertTrue(HookEvent.Kind.stop.expectsDecision, "Stop 是阻塞的,服务端必须回写") + + for kind in [HookEvent.Kind.notification, .sessionEnd, .userPromptSubmit, + .sessionStart, .postToolUse, .subagentStop, .preCompact] { + XCTAssertFalse( + kind.expectsDecision, + "\(kind.rawValue) 不该等应答 —— 等了会让 hubctl 白白挂在 socket 上" + ) + } + } + + // MARK: - 超时阶梯 + + /// **内层必须先到期。** + /// + /// 这条守的是一次真实事故的镜像:审批那边曾经把服务端和客户端的超时设成 + /// 一样大,结果 hubctl 先判"没收到应答"按 fail-open 放行了一条 + /// `git push --force`,而服务端其实已经决定拒绝。 + /// + /// Stop 这条链路方向相反(超时 = 不拦),但阶梯必须同样严格 —— + /// 顺序错了会出现"服务端说要拦、客户端已经超时放过"的窗口, + /// 表现为拦截时灵时不灵,极难排查。 + func testTheStopLadderIsStrictlyIncreasing() { + XCTAssertLessThan(HookTimeouts.stopBridge, HookTimeouts.stopRead) + XCTAssertLessThan(HookTimeouts.stopRead, TimeInterval(HookTimeouts.stopHookProcess)) + } + + /// Stop 的阶梯必须**远短于**审批阶梯。 + /// + /// 它挡在每一次收工前面。挂到审批那条 75 秒的阶梯上,Hub 一卡住 + /// 用户每说一句话就要干等 75 秒 —— 那比这个功能本身值钱得多。 + func testTheStopLadderIsMuchShorterThanTheApprovalLadder() { + XCTAssertLessThan(HookTimeouts.stopRead, HookTimeouts.clientRead / 5) + XCTAssertLessThanOrEqual(HookTimeouts.stopHookProcess, 15) + } +} diff --git a/HubKit/Tests/HubUITests/AcceptanceClaimParsingTests.swift b/HubKit/Tests/HubUITests/AcceptanceClaimParsingTests.swift new file mode 100644 index 0000000..1a88dd5 --- /dev/null +++ b/HubKit/Tests/HubUITests/AcceptanceClaimParsingTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import HubUI + +/// 解析 Claude 的逐条自查回答。 +/// +/// 这段解析的输入是**一段长对话末尾的自由文本**,不是干净的 API 返回。 +/// 模型在这个位置加客套话、改格式、只答一半都是常态。解析失败是静默的 +/// (表现为"清单一直没进展"),所以每种畸形都要单独钉住。 +final class AcceptanceClaimParsingTests: XCTestCase { + + private func parse(_ text: String) -> [AcceptanceClaim]? { + HookCoordinator.parseClaims(text) + } + + // MARK: - 正常与包装 + + func testParsesBareJSON() { + let claims = parse(#"{"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]}"#) + + XCTAssertEqual(claims?.count, 1) + XCTAssertEqual(claims?.first?.id, "a1") + XCTAssertEqual(claims?.first?.done, true) + XCTAssertEqual(claims?.first?.evidence, "改了 x.swift") + } + + func testStripsCodeFence() { + let text = """ + ```json + {"items":[{"id":"a1","done":true,"evidence":"改了 x.swift"}]} + ``` + """ + XCTAssertEqual(parse(text)?.count, 1) + } + + /// 模型在 JSON 前后写一段话是常态,尤其是在长对话末尾。 + func testToleratesProseAroundTheJSON() { + let text = """ + 我逐条核对了一遍: + + ```json + {"items":[{"id":"a1","done":false,"evidence":"这条没动"}]} + ``` + + 要我现在把没做的补上吗? + """ + XCTAssertEqual(parse(text)?.first?.done, false) + } + + // MARK: - 含糊一律按「没做」算 + + /// **`done` 缺失时必须按没做算。** + /// + /// 方向是刻意的:把含糊不清当成"做完了",等于给虚报开了个免检通道 —— + /// 模型只要省略这个字段就能让一条要点被标记成已完成。 + /// 而这个功能存在的全部理由就是不给虚报留口子。 + func testMissingDoneCountsAsNotDone() { + XCTAssertEqual(parse(#"{"items":[{"id":"a1","evidence":"呃"}]}"#)?.first?.done, false) + } + + /// `done` 不是布尔(字符串 "true"、数字 1)同样按没做算。 + func testNonBooleanDoneCountsAsNotDone() { + XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":"true"}]}"#)?.first?.done, false) + XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":1}]}"#)?.first?.done, false) + } + + // MARK: - 畸形输入不能崩也不能误判 + + /// 没有 id 的条目对不上号,只能丢掉 —— 但不能连累同一批里正常的那些。 + func testSkipsEntriesWithoutAnID() { + let claims = parse(#"{"items":[{"done":true},{"id":"","done":true},{"id":"ok","done":true}]}"#) + + XCTAssertEqual(claims?.map(\.id), ["ok"]) + } + + /// 完全不是 JSON —— 它多半是直接去补做遗漏项了(那其实是好事)。 + /// 返回 nil 让调用方什么都别做,退回旁路复核。 + func testProseOnlyReplyYieldsNil() { + XCTAssertNil(parse("好的,我把漏掉的两条补上了,现在都跑通了。")) + } + + func testMissingItemsKeyYieldsNil() { + XCTAssertNil(parse(#"{"points":[{"id":"a1"}]}"#)) + } + + func testEmptyReplyYieldsNil() { + XCTAssertNil(parse("")) + } + + /// 空数组是"跑通了但一条都没答" —— 和解析失败不一样, + /// 但调用方对两者都是什么都不做,所以这里只要求不崩。 + func testEmptyItemsArrayIsHandled() { + XCTAssertEqual(parse(#"{"items":[]}"#)?.isEmpty, true) + } + + func testMissingEvidenceBecomesEmptyString() { + XCTAssertEqual(parse(#"{"items":[{"id":"a1","done":true}]}"#)?.first?.evidence, "") + } +} diff --git a/HubKit/Tests/HubUITests/HookDedupTests.swift b/HubKit/Tests/HubUITests/HookDedupTests.swift index f7507be..1c160cf 100644 --- a/HubKit/Tests/HubUITests/HookDedupTests.swift +++ b/HubKit/Tests/HubUITests/HookDedupTests.swift @@ -62,8 +62,8 @@ final class HookDedupTests: XCTestCase { func testDifferentSessionsAreNotDuplicates() throws { let dedup = HookDedup() - let a = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "a", lastMessage: "好了"))) - let b = try XCTUnwrap(HookDedup.key(for: event(.stop, session: "b", lastMessage: "好了"))) + let a = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "a"))) + let b = try XCTUnwrap(HookDedup.key(for: event(.sessionEnd, session: "b"))) XCTAssertNotEqual(a, b) guard case .first = dedup.begin(a, waitForDecision: false) else { @@ -74,6 +74,23 @@ final class HookDedupTests: XCTestCase { } } + /// **收工事件刻意不去重。** + /// + /// 缓存决策只有在决策是事件的**纯函数**时才成立。preToolUse 满足 + /// (同一次工具调用永远同一个结论);stop 不满足 —— 它取决于会变的上膛状态。 + /// + /// 这条来自一次真实的端到端失败:先发一条未上膛的 stop(不拦), + /// 再上膛、再发一条内容一模一样的 stop —— 第二条被当成副本, + /// 复用了"不拦"的结论,**拦截静默失效**。 + /// + /// 把 discriminator 改回 `lastAssistantMessage`,这条必须变红。 + func testStopIsDeliberatelyNotDeduplicated() { + XCTAssertNil( + HookDedup.key(for: event(.stop, lastMessage: "做完了")), + "stop 的结论依赖上膛状态,不是事件的纯函数,不能缓存" + ) + } + /// 时间窗外的同样内容不算重复。 /// /// 用户隔一会儿又发一遍「继续」是正常的,那是**两次**真实输入, @@ -165,8 +182,8 @@ final class HookDedupTests: XCTestCase { /// 算不出来(返回 nil)意味着那一类完全不去重 —— 加新 kind 时最容易漏的 /// 就是这里,而漏了不会报错,只会在某天变成"通知弹两遍"。 func testEveryNonToolKindProducesAKey() { - let toolKinds: Set = [.preToolUse, .postToolUse] - for kind in HookChannelMonitor.expected where !toolKinds.contains(kind) { + let exempt: Set = [.preToolUse, .postToolUse, .stop] + for kind in HookChannelMonitor.expected where !exempt.contains(kind) { XCTAssertNotNil( HookDedup.key(for: event(kind)), "\(kind.rawValue) 算不出去重 key —— 加新 kind 时漏了 HookDedup.key" diff --git a/scripts/ensure-project-config.sh b/scripts/ensure-project-config.sh index 7c69f86..7754c3f 100755 --- a/scripts/ensure-project-config.sh +++ b/scripts/ensure-project-config.sh @@ -82,30 +82,34 @@ if settings.get("autoMemoryDirectory") != "./.claude/memory": changed.append("补上 autoMemoryDirectory") # ── hook ────────────────────────────────────────────────────── +# (事件, hubctl 子命令, 阻塞时的 timeout;None = 非阻塞) +# +# Stop 的 10 秒和 PreToolUse 的 90 秒必须和 HubIPC/Timeouts.swift 里的阶梯一致。 +# Stop 挡在每一次收工前面,用 90 秒会让 Hub 一卡住用户就干等。 HOOKS = [ - ("SessionStart", "sessionstart", False), - ("UserPromptSubmit", "userprompt", False), - ("PreToolUse", "pretool", True), - ("PostToolUse", "posttool", False), - ("Notification", "notification", False), - ("Stop", "stop", False), - ("SubagentStop", "subagentstop", False), - ("PreCompact", "precompact", False), - ("SessionEnd", "sessionend", False), + ("SessionStart", "sessionstart", None), + ("UserPromptSubmit", "userprompt", None), + ("PreToolUse", "pretool", 90), + ("PostToolUse", "posttool", None), + ("Notification", "notification", None), + ("Stop", "stop", 10), + ("SubagentStop", "subagentstop", None), + ("PreCompact", "precompact", None), + ("SessionEnd", "sessionend", None), ] if with_hooks: hooks = settings.setdefault("hooks", {}) fixed = 0 - for event, argument, blocking in HOOKS: + for event, argument, timeout in HOOKS: command = f"{hubctl} hook {argument}" entries = [ entry for entry in (hooks.get(event) or []) if not any("hubctl hook" in h.get("command", "") for h in entry.get("hooks", [])) ] hook = {"type": "command", "command": command} - if blocking: - hook["timeout"] = 90 + if timeout is not None: + hook["timeout"] = timeout else: hook["async"] = True entries.append({"hooks": [hook]}) diff --git a/scripts/setup-swift-hooks.sh b/scripts/setup-swift-hooks.sh index 906a865..8596e31 100755 --- a/scripts/setup-swift-hooks.sh +++ b/scripts/setup-swift-hooks.sh @@ -79,7 +79,7 @@ def strip_legacy(entries): kept.append(entry) return kept -def install(event, argument, *, blocking): +def install(event, argument, *, blocking, timeout=90): entries = strip_legacy(hooks.get(event)) hook = {"type": "command", "command": f"{hubctl} hook {argument}"} if blocking: @@ -88,7 +88,7 @@ def install(event, argument, *, blocking): # 90 秒是超时阶梯(HookTimeouts)的最外层,必须大于 hubctl 的 75 秒读超时: # 如果 Claude 在 hubctl 输出拒绝之前就把它杀掉,那等同于没有输出, # 也就是**放行** —— 正好是安全默认值的反面。 - hook["timeout"] = 90 + hook["timeout"] = timeout else: # async: true 很关键 —— 旧配置是 false 且 hook 里同步 afplay, # 导致每轮回复结束都被阻塞。 @@ -110,7 +110,12 @@ install("UserPromptSubmit", "userprompt", blocking=False) install("PreToolUse", "pretool", blocking=True) install("PostToolUse", "posttool", blocking=False) install("Notification", "notification", blocking=False) -install("Stop", "stop", blocking=False) +# Stop 是阻塞的:Hub 要在这里判断该不该把验收清单塞回去。 +# +# 10 秒而不是 90 —— 它挡在**每一次收工**前面,Hub 一卡住用户就干等。 +# 判断本身是查内存,毫秒级;这 10 秒纯粹是异常时的上界。 +# 超时被杀掉等同于没有输出,也就是正常收工,方向是安全的。 +install("Stop", "stop", blocking=True, timeout=10) install("SubagentStop", "subagentstop", blocking=False) install("PreCompact", "precompact", blocking=False) install("SessionEnd", "sessionend", blocking=False)