From 9ecccd5a46bc2ff914a8b4294b8e4f18c9515549 Mon Sep 17 00:00:00 2001 From: jackiexiao <707610215@qq.com> Date: Thu, 16 Jul 2026 17:33:48 +0800 Subject: [PATCH] fix: monitor current Codex capacity events --- Resources/Info.plist | 4 +- Sources/Agent.swift | 149 ++++++++++++++++++++++++++++++++++++- docs/how-it-works.md | 4 +- docs/how-it-works.zh-CN.md | 4 +- docs/releasing.md | 10 +-- scripts/package-release.sh | 2 +- 6 files changed, 159 insertions(+), 14 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index ae271ca..16f0ea0 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.8.0 + 0.8.1 CFBundleVersion - 12 + 13 LSMinimumSystemVersion 13.0 LSApplicationCategoryType diff --git a/Sources/Agent.swift b/Sources/Agent.swift index 07ee288..cb97836 100644 --- a/Sources/Agent.swift +++ b/Sources/Agent.swift @@ -2,6 +2,7 @@ import AppKit import ApplicationServices import CryptoKit import Foundation +import SQLite3 let capacityMessage = "Selected model is at capacity. Please try a different model." let retryPromptEnglish = "Continue the unfinished task from the failed turn. Do not repeat completed work." @@ -58,6 +59,8 @@ private struct RetryState: Codable { private struct PersistedState: Codable { var cursor = CursorState(fileID: nil, offset: 0) + var databaseFileID: UInt64? + var databaseCursor: Int64? var retries: [String: RetryState] = [:] } @@ -80,6 +83,26 @@ func parseCapacityFailureLine(_ line: String) -> CapacityFailure? { return CapacityFailure(threadID: threadID, timestamp: timestamp) } +func parseCapacityFailureRecord(_ body: String, threadID: String?, timestamp: Date) -> CapacityFailure? { + guard body.contains("Turn error: \(capacityMessage)") else { return nil } + + if let threadID, threadID.range(of: #"^[0-9a-fA-F-]{36}$"#, options: .regularExpression) != nil { + return CapacityFailure(threadID: threadID, timestamp: timestamp) + } + + let patterns = [#"thread\.id=[0-9a-fA-F-]{36}"#, #"thread_id=[0-9a-fA-F-]{36}"#] + for pattern in patterns { + guard let expression = try? NSRegularExpression(pattern: pattern) else { continue } + let range = NSRange(body.startIndex.. cursor else { return } + let failures = databaseCapacityFailures(database, after: cursor, through: highWatermark) + state.databaseCursor = highWatermark + + let now = Date() + if !failures.isEmpty || now.timeIntervalSince(lastDatabaseCursorSaveAt) >= 60 { + lastDatabaseCursorSaveAt = now + saveState() + } + for failure in failures { + handle(failure) + } + } + + private func currentLogsDatabase() -> URL? { + guard let urls = try? fileManager.contentsOfDirectory( + at: codexHome, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return nil } + return urls.compactMap { url -> (URL, Int)? in + let name = url.lastPathComponent + guard name.hasPrefix("logs_"), name.hasSuffix(".sqlite"), + let version = Int(name.dropFirst(5).dropLast(7)) else { return nil } + return (url, version) + }.max(by: { $0.1 < $1.1 })?.0 + } + + private func openReadOnlyDatabase(at url: URL) -> OpaquePointer? { + var database: OpaquePointer? + let result = sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) + guard result == SQLITE_OK, let database else { + if let database { sqlite3_close(database) } + return nil + } + sqlite3_busy_timeout(database, 150) + return database + } + + private func databaseHighWatermark(_ database: OpaquePointer) -> Int64? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "SELECT COALESCE(MAX(id), 0) FROM logs", -1, &statement, nil) == SQLITE_OK, + let statement else { return nil } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return sqlite3_column_int64(statement, 0) + } + + private func databaseCapacityFailures( + _ database: OpaquePointer, + after cursor: Int64, + through highWatermark: Int64 + ) -> [CapacityFailure] { + let sql = """ + SELECT ts, thread_id, feedback_log_body + FROM logs + WHERE id > ? AND id <= ? AND target = 'codex_core::session::turn' + ORDER BY id ASC + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { return [] } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, cursor) + sqlite3_bind_int64(statement, 2, highWatermark) + + var failures: [CapacityFailure] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let bodyPointer = sqlite3_column_text(statement, 2) else { continue } + let body = String(cString: bodyPointer) + let threadID = sqlite3_column_text(statement, 1).map { String(cString: $0) } + let timestamp = Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 0))) + if let failure = parseCapacityFailureRecord(body, threadID: threadID, timestamp: timestamp) { + failures.append(failure) + } + } + return failures + } + private func consume(_ data: Data) { guard let text = String(data: data, encoding: .utf8) else { return } let combined = partialLine + text @@ -292,6 +422,12 @@ final class AutoRetryAgent { return } + if let existing = state.retries[failure.threadID], + abs(failure.timestamp.timeIntervalSince(existing.lastErrorAt)) < 1 { + logger.write("ignored duplicate capacity failure for \(failure.threadID)") + return + } + var retryState = state.retries[failure.threadID] ?? RetryState(attempts: 0, lastErrorAt: failure.timestamp, generation: 0) if failure.timestamp.timeIntervalSince(retryState.lastErrorAt) > attemptResetInterval { retryState.attempts = 0 @@ -595,6 +731,11 @@ final class AutoRetryAgent { func runSelfTest() -> Int32 { let sample = "2026-07-13T12:00:00.123456Z INFO session_loop{thread_id=019f59b0-c8ec-7cf1-88be-4e6247938d01}: Turn error: \(capacityMessage)" let failure = parseCapacityFailureLine(sample) + let databaseFailure = parseCapacityFailureRecord( + "turn{thread.id=019f6017-6542-7c50-a6b9-b87406def81a}:run_turn: Turn error: \(capacityMessage)", + threadID: nil, + timestamp: Date(timeIntervalSince1970: 100) + ) let activity = #"{"type":"event_msg","payload":{"type":"user_message"}}"# let legacyConfig = #"{"language":"zh"}"#.data(using: .utf8)! let decodedConfig = try? JSONDecoder().decode(AgentConfig.self, from: legacyConfig) @@ -650,6 +791,10 @@ func runSelfTest() -> Int32 { let checksumData = Data("codex-helper".utf8) let checksum = SHA256.hash(data: checksumData).map { String(format: "%02x", $0) }.joined() guard failure?.threadID == "019f59b0-c8ec-7cf1-88be-4e6247938d01", + databaseFailure == CapacityFailure( + threadID: "019f6017-6542-7c50-a6b9-b87406def81a", + timestamp: Date(timeIntervalSince1970: 100) + ), containsNewTurnActivity(activity), decodedConfig == AgentConfig(language: "zh"), decodedHiddenQuotaConfig?.showQuotaInMenuBar == false, diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 75dd132..6c17c2f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -20,7 +20,7 @@ flowchart LR ## Detection -The native Swift agent tails `~/.codex/log/codex-tui.log`. It looks for the exact capacity message and extracts the UUID from `thread_id=...`. The first launch starts at end-of-file, so old failures are not replayed. +The native Swift agent reads new `codex_core::session::turn` records from Codex Desktop's current `~/.codex/logs_*.sqlite` event database. It looks for the exact capacity message and uses the record's task UUID. The first launch starts at the current database high-water mark, so old failures are not replayed. The legacy `~/.codex/log/codex-tui.log` tail remains as a compatibility fallback for older Codex builds. The task ID must also exist in `~/.codex/session_index.jsonl`. This deliberately excludes hidden subagent sessions. @@ -61,7 +61,7 @@ The updater checks `makerjackie/codex-helper` GitHub Releases at most once per d - No backend service or telemetry. Network requests go directly to official OpenAI feeds and the project's GitHub Releases API. - No project files are read. - No conversation content is retained. -- Persistent state contains log cursor offsets, task IDs, timestamps, and retry counters. +- Persistent state contains event cursors, task IDs, timestamps, and retry counters. - Accessibility permission is used only to synthesize retry keystrokes targeted to the Codex process, after confirming Codex is frontmost. Launch, quota refresh, and automated tests never prompt for permission; only an explicit user action does. - GitHub Release builds use Developer ID signing, hardened runtime, Apple notarization, and stapled tickets. Source builds installed with `install.sh` use local ad-hoc signing. diff --git a/docs/how-it-works.zh-CN.md b/docs/how-it-works.zh-CN.md index a94d7ea..958d593 100644 --- a/docs/how-it-works.zh-CN.md +++ b/docs/how-it-works.zh-CN.md @@ -20,7 +20,7 @@ flowchart LR ## 错误检测 -原生 Swift 助手持续读取 `~/.codex/log/codex-tui.log` 的新增内容,匹配精确的容量错误,并从 `thread_id=...` 提取 UUID。首次启动会从文件末尾开始,因此不会重放历史错误。 +原生 Swift 助手会读取 Codex Desktop 当前 `~/.codex/logs_*.sqlite` 事件数据库中新产生的 `codex_core::session::turn` 记录,匹配精确的容量错误,并使用记录中的任务 UUID。首次启动会从数据库当前的最新位置开始,因此不会重放历史错误;旧版 Codex 使用的 `~/.codex/log/codex-tui.log` 仍作为兼容后备来源。 任务 ID 还必须存在于 `~/.codex/session_index.jsonl`,从而主动排除隐藏的子代理会话。 @@ -61,7 +61,7 @@ Codex Helper 会先验证随 App 提供的 `codex` 可执行文件属于 OpenAI - 没有后端服务或遥测;网络请求只会直接访问 OpenAI 官方公开 RSS 和项目 GitHub Releases API。 - 不读取项目文件。 - 不保存对话内容。 -- 持久化状态只包含日志游标、任务 ID、时间戳和重试次数。 +- 持久化状态只包含事件游标、任务 ID、时间戳和重试次数。 - 辅助功能权限只用于向 Codex 进程定向发送重试键盘操作,并且发送前会确认 Codex 位于前台。App 启动、额度刷新与自动化测试都不会主动弹出授权请求;只有用户点击授权入口时才请求。 - GitHub Release 版本使用 Developer ID 签名、Hardened Runtime、Apple 公证和 stapled ticket;通过 `install.sh` 构建的源码版本使用本机 ad-hoc 签名。 diff --git a/docs/releasing.md b/docs/releasing.md index 146d888..a7e8da7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ Requirements: ```bash SIGNING_IDENTITY="Developer ID Application: Company Name (TEAMID)" \ -VERSION="0.8.0" \ +VERSION="0.8.1" \ ./scripts/package-release.sh ``` @@ -20,9 +20,9 @@ The script verifies the app signature, submits and staples the app, builds and s Publish the resulting DMG and checksum: ```bash -gh release create v0.8.0 \ - dist/Codex-Helper-0.8.0.dmg \ - dist/Codex-Helper-0.8.0.dmg.sha256 \ - --title "Codex Helper v0.8.0" \ +gh release create v0.8.1 \ + dist/Codex-Helper-0.8.1.dmg \ + dist/Codex-Helper-0.8.1.dmg.sha256 \ + --title "Codex Helper v0.8.1" \ --generate-notes ``` diff --git a/scripts/package-release.sh b/scripts/package-release.sh index bfc8105..fc5b5aa 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="${0:A:h:h}" -VERSION="${VERSION:-0.8.0}" +VERSION="${VERSION:-0.8.1}" DIST_DIR="${DIST_DIR:-$ROOT/dist}" BUILD_DIR="$ROOT/.build/notarized" APP_PATH="$BUILD_DIR/Codex Helper.app"