From 27e165b94ea7d8bba3b8fc2255e01f2b153342f1 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sat, 5 Sep 2026 03:22:58 +0900 Subject: [PATCH 01/24] provider/codex: detect turn completion via OSC 9 notification in TUI PTY Codex 0.153.2 emits an OSC 9 notification when a turn completes (agent-turn-complete). Previously, triggerCodex sent Ctrl-C as early as 4 seconds simply because the initial TUI screen rendering went quiet, terminating the process before Codex finished session initialization and submitted the prompt. By passing: -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" we can deterministically detect turn completion even in a focused PTY environment. In addition: - Fallback TERM to xterm-256color if unset or dumb, preventing TUI launch failures under cron or non-interactive daemon environments. - Maintain the 45s timeout fallback as a safety net. - Add regression test using mock codex script without external quotas. - Document the notification flags and updated output in README. --- README.md | 12 ++-- README.zh-CN.md | 10 +-- internal/provider/codex.go | 104 ++++++++++++++++++++++---------- internal/provider/codex_test.go | 76 ++++++++++++++++++++++- 4 files changed, 159 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f9b6bbe..8cc34e6 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,16 @@ elapsed time only: ``` claude → claude --model haiku . claude ✓ pinged (6.6s) -codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok -codex ✓ pinged (13.6s) -spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok -spark ✓ pinged (12.4s) +codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +codex ✓ pinged (6.8s) +spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +spark ✓ pinged (6.5s) ``` +For Codex/Spark, `limitping` automatically appends the `-c tui...` flags to +enable Codex CLI's turn-completion notifications, so it can detect ping success +and exit immediately. + Use `status` or `bg status` for the authoritative 5h/weekly window view after a ping. diff --git a/README.zh-CN.md b/README.zh-CN.md index c64cf41..b3e672a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -213,12 +213,14 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm ``` claude → claude --model haiku . claude ✓ pinged (6.6s) -codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok -codex ✓ pinged (13.6s) -spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok -spark ✓ pinged (12.4s) +codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +codex ✓ pinged (6.8s) +spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +spark ✓ pinged (6.5s) ``` +对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数以启用 Codex CLI 的 turn 结束通知,从而检测 ping 成功并立即退出。 + ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 `status` 示例: diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 7fb0c45..5190ee8 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -36,18 +36,27 @@ const ( codexAPIPath = "/api/codex/usage" codexUserAgent = "limitping" sparkDefaultModel = "gpt-5.3-codex-spark" + codexTurnComplete = "\x1b]9;" + codexTurnScanLimit = 4096 // codexRedeemCooldown throttles the automatic redemption path so a // once-a-minute poll loop cannot re-attempt a refused redemption every cycle. codexRedeemCooldown = 15 * time.Minute - codexTurnMinWait = 4 * time.Second - codexTurnQuiet = 2500 * time.Millisecond - codexTurnMaxWait = 45 * time.Second - codexExitGrace = 5 * time.Second - codexPollInterval = 200 * time.Millisecond + codexTurnMaxWait = 45 * time.Second + codexExitGrace = 5 * time.Second ) +type codexInteractiveTiming struct { + maxWait time.Duration + exitGrace time.Duration +} + +var defaultCodexInteractiveTiming = codexInteractiveTiming{ + maxWait: codexTurnMaxWait, + exitGrace: codexExitGrace, +} + // Codex reads usage via the ChatGPT backend usage endpoint and triggers windows // via the interactive, TTY-backed Codex CLI. Headless `codex exec` can consume // tokens without anchoring the subscription-backed Codex window. @@ -551,6 +560,10 @@ func codexWindowToUsage(w codexWindow) usage.Window { } func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (*TriggerResult, error) { + return triggerCodexWithTiming(ctx, cfg, dryRun, defaultCodexInteractiveTiming) +} + +func triggerCodexWithTiming(ctx context.Context, cfg config.ProviderConfig, dryRun bool, timing codexInteractiveTiming) (*TriggerResult, error) { prompt := cfg.Prompt if prompt == "" { prompt = "ok" @@ -563,6 +576,13 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( args = append(args, "-m", cfg.Model) } args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...) + // A PTY is always treated as focused, so force Codex's turn-complete OSC 9 + // notification and use it as the exact boundary before stopping the TUI. + args = append(args, + "-c", `tui.notifications=["agent-turn-complete"]`, + "-c", `tui.notification_method="osc9"`, + "-c", `tui.notification_condition="always"`, + ) args = append(args, prompt) res := &TriggerResult{Command: "codex " + shellJoin(args)} if dryRun { @@ -570,6 +590,9 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( } cmd := exec.CommandContext(ctx, "codex", args...) + if term := os.Getenv("TERM"); term == "" || term == "dumb" { + cmd.Env = append(cmd.Environ(), "TERM=xterm-256color") + } ptmx, err := pty.Start(cmd) if err != nil { return res, fmt.Errorf("codex interactive failed to start: %w", err) @@ -577,8 +600,9 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( defer ptmx.Close() output := &limitedBuffer{limit: 4096} + markers := newCodexTurnMarkers() go func() { - _, _ = io.Copy(output, ptmx) + _, _ = io.Copy(io.MultiWriter(output, markers), ptmx) }() done := make(chan error, 1) @@ -586,41 +610,55 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( done <- cmd.Wait() }() - if terminal, err := codexAwait(ctx, cmd, ptmx, output, done, codexTurnMaxWait, - func(idle, elapsed time.Duration) bool { - return elapsed >= codexTurnMinWait && idle >= codexTurnQuiet - }); terminal { + if terminal, err := codexAwait(ctx, cmd, ptmx, output, markers.completed, done, timing.maxWait); terminal { return res, err } - return res, codexInteractiveStop(ctx, cmd, ptmx, done, output) + return res, codexInteractiveStop(ctx, cmd, ptmx, done, output, timing.exitGrace) } -func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { - start := time.Now() - deadline := time.After(maxWait) - ticker := time.NewTicker(codexPollInterval) - defer ticker.Stop() - for { - select { - case err := <-done: - return true, codexInteractiveErr(err, output) - case <-ctx.Done(): - return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) - case <-deadline: - return false, nil - case <-ticker.C: - changed := output.changedAt() - if !changed.IsZero() && ready(time.Since(changed), time.Since(start)) { - return false, nil - } - } +type codexTurnMarkers struct { + buf []byte + finished bool + completed chan struct{} +} + +func newCodexTurnMarkers() *codexTurnMarkers { + return &codexTurnMarkers{completed: make(chan struct{})} +} + +func (m *codexTurnMarkers) Write(p []byte) (int, error) { + if m.finished { + return len(p), nil + } + m.buf = append(m.buf, p...) + if start := bytes.Index(m.buf, []byte(codexTurnComplete)); start >= 0 && bytes.IndexByte(m.buf[start:], 0x07) >= 0 { + close(m.completed) + m.finished = true + m.buf = nil + } + if len(m.buf) > codexTurnScanLimit { + m.buf = append(m.buf[:0], m.buf[len(m.buf)-codexTurnScanLimit:]...) + } + return len(p), nil +} + +func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, completed <-chan struct{}, done <-chan error, maxWait time.Duration) (bool, error) { + select { + case <-completed: + return false, nil + case err := <-done: + return true, codexInteractiveErr(err, output) + case <-ctx.Done(): + return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) + case <-time.After(maxWait): + return false, nil } } -func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { - deadline := time.After(codexExitGrace) - ticker := time.NewTicker(codexExitGrace / 2) +func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer, exitGrace time.Duration) error { + deadline := time.After(exitGrace) + ticker := time.NewTicker(exitGrace / 2) defer ticker.Stop() for sent := false; ; { diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index e36a1d6..5a8b8a8 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -322,7 +322,7 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only ok" + want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok" if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } @@ -331,6 +331,78 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { } } +func TestCodexTriggerWaitsForTurnCompleteNotification(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + turnPath := filepath.Join(dir, "turn") + termPath := filepath.Join(dir, "term") + script := `#!/bin/sh +printf '%s\n' "$@" > "$CODEX_TEST_ARGS" +printf '%s' "$TERM" > "$CODEX_TEST_TERM" +printf 'startup screen\n' +sleep 0.08 +printf 'submitted' > "$CODEX_TEST_TURN" +i=0 +while [ "$i" -lt 20 ]; do + printf '.' + sleep 0.01 + i=$((i + 1)) +done +printf '\033]9;turn finished\007' +trap 'exit 0' INT TERM +while :; do + printf '.' + sleep 0.01 +done +` + if err := os.WriteFile(filepath.Join(dir, "codex"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CODEX_TEST_ARGS", argsPath) + t.Setenv("CODEX_TEST_TURN", turnPath) + t.Setenv("CODEX_TEST_TERM", termPath) + t.Setenv("TERM", "dumb") + + timing := codexInteractiveTiming{ + maxWait: time.Second, + exitGrace: 50 * time.Millisecond, + } + started := time.Now() + _, err := triggerCodexWithTiming(context.Background(), config.ProviderConfig{ + Prompt: "ping through pty", + Model: "test-model", + }, false, timing) + if err != nil { + t.Fatalf("trigger: %v", err) + } + if elapsed := time.Since(started); elapsed >= 500*time.Millisecond { + t.Fatalf("trigger took %s, want completion marker to stop it before fallback", elapsed) + } + + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "ping through pty") { + t.Fatalf("arguments = %q, want positional prompt", args) + } + turn, err := os.ReadFile(turnPath) + if err != nil { + t.Fatal(err) + } + if string(turn) != "submitted" { + t.Fatalf("turn marker = %q, want submitted", turn) + } + term, err := os.ReadFile(termPath) + if err != nil { + t.Fatal(err) + } + if string(term) != "xterm-256color" { + t.Fatalf("TERM = %q, want xterm-256color", term) + } +} + func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) { c := NewSpark(config.ProviderConfig{ Prompt: "ok", @@ -345,7 +417,7 @@ func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok" + want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok" if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } From 56ca5dcc9e2965cad5eb7adc2603e09e1bb8de4b Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sat, 5 Sep 2026 03:23:11 +0900 Subject: [PATCH 02/24] config: update default Codex model from gpt-5.4-mini to gpt-5.6-luna In Codex 0.153.2, selecting gpt-5.4-mini displays an interactive model migration dialog on startup, preventing automated turns from starting when relying on the default model without explicit configuration. Update the default Codex model from gpt-5.4-mini to gpt-5.6-luna in the built-in config defaults, template TOML, documentation, and tests. --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- internal/config/config.go | 4 ++-- internal/config/config_test.go | 2 +- internal/provider/codex_test.go | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8cc34e6..7decdaf 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ elapsed time only: ``` claude → claude --model haiku . claude ✓ pinged (6.6s) -codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok codex ✓ pinged (6.8s) spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok spark ✓ pinged (6.5s) @@ -332,7 +332,7 @@ continue_prompt = "continue" # message `continue` injects on 5h recovery; empty [codex] enabled = true prompt = "ok" -model = "gpt-5.4-mini" # cheapest Codex model for triggering +model = "gpt-5.6-luna" # cheapest Codex model for triggering reasoning_effort = "low" # "minimal" is rejected when web_search/image_gen tools are enabled extra_args = [] # extra Codex CLI args; exec-only flags such as --json are ignored align_start = "" diff --git a/README.zh-CN.md b/README.zh-CN.md index b3e672a..4aeb1a1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -213,7 +213,7 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm ``` claude → claude --model haiku . claude ✓ pinged (6.6s) -codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok +codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok codex ✓ pinged (6.8s) spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok spark ✓ pinged (6.5s) @@ -311,7 +311,7 @@ continue_prompt = "continue" # continue 在 5h 恢复时注入的消息;留空 [codex] enabled = true prompt = "ok" -model = "gpt-5.4-mini" # 用于触发的最便宜 Codex 模型 +model = "gpt-5.6-luna" # 用于触发的最便宜 Codex 模型 reasoning_effort = "low" # 启用 web_search/image_gen 工具时,"minimal" 会被拒绝 extra_args = [] # 额外 Codex CLI 参数;--json 等 exec-only 参数会被忽略 align_start = "" diff --git a/internal/config/config.go b/internal/config/config.go index 3cc622c..3756795 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,7 +82,7 @@ func Default() Config { Codex: ProviderConfig{ Enabled: true, Prompt: "ok", - Model: "gpt-5.4-mini", + Model: "gpt-5.6-luna", ReasoningEffort: "low", ContinuePrompt: "continue", }, @@ -209,7 +209,7 @@ enabled = true prompt = "ok" # Cheapest Codex model for triggering (see ~/.codex/models_cache.json for the # list available to your plan). Empty = use the Codex default model. -model = "gpt-5.4-mini" +model = "gpt-5.6-luna" # "low" keeps the ping cheap; "minimal" is rejected when web_search/image_gen # tools are enabled in your Codex config. reasoning_effort = "low" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7fb670d..d07dd5e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -69,7 +69,7 @@ enabled = false t.Fatal("claude.enabled = true, want the file's false to win") } // Untouched fields keep their defaults. - if cfg.Claude.Model != "haiku" || cfg.Codex.Model != "gpt-5.4-mini" || cfg.UsageDisplay != "used" { + if cfg.Claude.Model != "haiku" || cfg.Codex.Model != "gpt-5.6-luna" || cfg.UsageDisplay != "used" { t.Fatalf("defaults not preserved: claude.model=%q codex.model=%q usage_display=%q", cfg.Claude.Model, cfg.Codex.Model, cfg.UsageDisplay) } diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index 5a8b8a8..dd374ec 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -296,7 +296,7 @@ func TestCodexResetCreditsURLFromBase(t *testing.T) { func TestParseCodexBaseURL(t *testing.T) { contents := ` -model = "gpt-5.4-mini" +model = "gpt-5.6-luna" chatgpt_base_url = "https://api.openai.com" ` if got := parseCodexBaseURL(contents); got != "https://api.openai.com" { @@ -307,7 +307,7 @@ chatgpt_base_url = "https://api.openai.com" func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { c := NewCodex(config.ProviderConfig{ Prompt: "ok", - Model: "gpt-5.4-mini", + Model: "gpt-5.6-luna", ReasoningEffort: "low", ExtraArgs: []string{ "--skip-git-repo-check", @@ -322,7 +322,7 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok" + want := "codex -c model_reasoning_effort=low -m gpt-5.6-luna --search --sandbox read-only -c tui.notifications=[\"agent-turn-complete\"] -c tui.notification_method=\"osc9\" -c tui.notification_condition=\"always\" ok" if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } From 8a733a69811837c0deab9ad11ef9018094a5df75 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 10:54:58 +0900 Subject: [PATCH 03/24] Verify Codex and Spark quota windows with persisted observations --- .github/workflows/ci.yml | 13 + README.md | 5 + README.zh-CN.md | 4 + docs/window-verification.md | 107 ++++++ internal/cli/bg.go | 2 + internal/cli/i18n.go | 41 ++- internal/cli/ping.go | 11 +- internal/cli/status.go | 55 ++- internal/cli/verification.go | 60 ++++ internal/cli/verification_test.go | 72 ++++ internal/codexstate/file_unix.go | 26 ++ internal/codexstate/file_windows.go | 36 ++ internal/codexstate/state.go | 342 +++++++++++++++++++ internal/codexstate/state_test.go | 260 ++++++++++++++ internal/provider/codex.go | 149 ++++---- internal/provider/codex_test.go | 5 +- internal/provider/codex_verification.go | 198 +++++++++++ internal/provider/codex_verification_test.go | 154 +++++++++ internal/provider/provider.go | 20 +- internal/scheduler/codex.go | 124 +++++++ internal/scheduler/codex_test.go | 50 +++ internal/scheduler/scheduler.go | 7 + internal/usage/usage.go | 18 +- 23 files changed, 1668 insertions(+), 91 deletions(-) create mode 100644 docs/window-verification.md create mode 100644 internal/cli/verification.go create mode 100644 internal/cli/verification_test.go create mode 100644 internal/codexstate/file_unix.go create mode 100644 internal/codexstate/file_windows.go create mode 100644 internal/codexstate/state.go create mode 100644 internal/codexstate/state_test.go create mode 100644 internal/provider/codex_verification.go create mode 100644 internal/provider/codex_verification_test.go create mode 100644 internal/scheduler/codex.go create mode 100644 internal/scheduler/codex_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef81f3..4331dd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,19 @@ on: pull_request: jobs: + + quota-state-platforms: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test ./internal/codexstate + build: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index f9b6bbe..49c7f3f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,11 @@ provider quota: `limitping ping --dry-run`, `limitping watch --dry-run`, or ## How it works +Codex/Spark distinguish CLI completion from quota-window start, including 0% +usage windows. Manual pings return without waiting a minute; watch performs +follow-up observations and bounded recovery. See [window verification](docs/window-verification.md) +for status/JSON semantics, state files, retry limits and platform limitations. + Two cleanly separated jobs: | Job | Mechanism | Cost | diff --git a/README.zh-CN.md b/README.zh-CN.md index c64cf41..947993d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,6 +4,10 @@ # CCLimitPing (`limitping`) +Codex/Spark 会分别判断 CLI 请求完成与限额窗口启动,包括使用率为 0% 的窗口。 +手动 ping 不会等待一分钟;watch 负责后续检查和有上限的重试。 +状态字段、存储位置和平台限制参见[窗口验证说明](docs/window-verification.md)。 + [English](README.md) | **中文** [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) diff --git a/docs/window-verification.md b/docs/window-verification.md new file mode 100644 index 0000000..755d852 --- /dev/null +++ b/docs/window-verification.md @@ -0,0 +1,107 @@ +# Codex and Spark window verification + +Codex and Spark share an observation algorithm, not quota buckets. Normal Codex +uses `rate_limit`; Spark selects its model from `additional_rate_limits`. +Claude's existing window detection and scheduling are unchanged. + +## Interactive commands + +Every real Codex/Spark ping attempts the read-only quota API before and after the +CLI turn. Each read has a three-second total timeout including retries. This +does not consume model quota. These reads do not fetch reset-credit details. +The command does not wait one minute or start a detached verification process. +`ping all` continues to the next provider after the ordinary trigger and these +bounded reads. Explicit `schedule` commands use the same ping behavior. + +CLI completion and window start are separate results. A turn-completion OSC 9 +notification confirms completion; a timeout, nonzero exit or clean exit without +that notification is an error. A confirmed completed turn returns exit zero even +if the API or state file is unavailable or the window is unconfirmed. A failed +pre-read does not prevent an explicit manual ping. Cancellation returns promptly +without requiring a post-read. + +An unconfirmed result with a saved baseline suggests `limitping status` after +60 seconds. If no baseline could be saved, the first successful status read +collects it; another observation at least 60 seconds later is needed. Already +confirmed windows need no recheck instruction. Status only reads quota and +observes state; it never sends a model request or waits for the comparison interval. + +`status` still reads enabled providers only and accepts no provider selector. +An explicitly pinged disabled provider therefore warns that it will not appear +in status. Other enabled providers retain their existing read timeouts. + +`status --json` retains the legacy `active` boolean (positive usage with a future +reset). Codex/Spark windows additionally expose `start_state`: `started`, +`not_started` or `unknown`, and `verification_due_at` when another sample is +needed. At 0% usage, `active: false` can coexist with `start_state: "started"`. +No new fields are emitted for Claude. Cache failures leave usable quota data +visible and the start state unknown; API failures retain the existing nonzero +status exit behavior. Human output labels unconfirmed reset times as estimates. + +## Observation rules + +Positive usage with a plausible future reset supports a started window. At zero +usage, two compatible samples at least 60 seconds apart can distinguish a fixed +absolute reset from a reset sliding with observation time. A fixed reset supports +started, including when the second observation is hours later. Sliding resets +require a 60–600 second pair and approximately match observation time plus the +reported duration. Timestamp tolerance is five seconds. A fresh pre-send read +can extend recently established sliding evidence; it cannot rely only on old +cached eligibility. + +These are empirical rules, not a documented server-side guarantee. Inconsistent +or missing evidence remains unknown. Account/plan/duration changes, reset +boundaries, backwards observation time and reset-credit redemption invalidate +incompatible evidence. A pre-ping/post-ping pair alone cannot establish failure: +each attempt starts a new post-attempt failure baseline. Compatible previously +confirmed start evidence is retained across a manual ping. + +## Automatic recovery + +Foreground watch and background watch schedule observation-only checks while +start state is uncertain. The target is the five-hour window when present, +otherwise the weekly window. An active weekly window does not cancel recovery +of an unstarted five-hour window. One ping observes both windows in its own +bucket, never the other provider's bucket. + +Automatic sends require fresh not-started evidence, usable state storage and +the existing alignment, activity and weekly/credit guards. After a send, +observation failure causes further reads, not immediate model retries. Confirmed +failure permits retries after at least 1, 5 and then 15 minutes. At most four +automatic attempts are allowed in a rolling hour per account and bucket. +The budget persists across restarts and target changes. Cooldown expires +automatically; fresh verification and all guards are still required to send. +Manual pings bypass that budget without clearing it. + +Background status exposes the target, verification/backoff/cooldown state and +next eligibility time, not a guarantee that a ping will occur then. Ping history +counts each trigger outcome once; later verification events are not extra pings. + +## State and platforms + +State lives in `/state/codex/.json`, with separate +normal-Codex and Spark-model entries. The directory is `$XDG_CONFIG_HOME/limitping` +when set, otherwise the user's home `.config/limitping` on all platforms. +It contains observations, deadlines and attempt metadata, not tokens, raw API +responses, prompts or model output. New directories/files use private permissions +where supported. Same-directory replacement and short OS-backed file locks +protect updates; locks are not held across network or model requests. + +A live per-bucket attempt claim makes a competing manual ping return promptly +with an actionable error. Pending verification alone does not block manual use. +An expired claim requires new observation evidence before automatic retry. +Missing state starts with unknown evidence. Corrupt/incompatible state or an +unwritable directory disables automatic sends; explicit manual pings can proceed +with a warning, without promising duplicate prevention during storage failure. +After repairing permissions or moving a corrupt state file aside, observations +resume. Do not remove healthy state to bypass retry budgets. + +The state implementation supports Linux, macOS and Windows. Native Windows +Codex/Spark PTY triggering remains unsupported by the current PTY dependency; +Windows release targets do not imply working TUI pings. WSL with Linux binaries +uses the Linux PTY implementation. No Windows PTY replacement is part of this change. + +`ping --dry-run` neither fetches quota nor writes state. Watch dry-run retains +its existing quota reads but does not persist verification or attempt state. +Offline tests cover observations, claims, budgets, CLI output and fake PTY +completion; no real quota pings are necessary for these tests. diff --git a/internal/cli/bg.go b/internal/cli/bg.go index 21a0497..e68f1c0 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -386,6 +386,8 @@ func parseBgPingAttempt(line string) (bgPingAttempt, bool) { status = bgPingFailed case strings.Contains(msg, "ping sent, new window started"): status = bgPingSucceeded + case strings.Contains(msg, "ping request completed; checking window"): + status = bgPingSucceeded default: return bgPingAttempt{}, false } diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index fd44eeb..6ecd3be 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -6,10 +6,17 @@ import ( ) type cliText struct { - rootShort string - rootLong string - helpFlag string - usageTemplate string + verifyStarted string + verifyNotStarted string + verifyUnknown string + verifyRecovery string + verifyDisabled string + verifyNoBaseline string + verifyCheckFmt string + rootShort string + rootLong string + helpFlag string + usageTemplate string helpCommandShort string helpCommandLong string @@ -186,9 +193,16 @@ func isChineseLocale() bool { } var enText = cliText{ - rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", - rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", - helpFlag: "help for this command", + verifyStarted: "window started", + verifyNotStarted: "window not started (reset is an estimate)", + verifyUnknown: "window start unconfirmed (reset is an estimate)", + verifyRecovery: "quota recovery / next eligibility", + verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.", + verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.", + verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n", + rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", + rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", + helpFlag: "help for this command", usageTemplate: `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} @@ -444,9 +458,16 @@ Examples: } var zhText = cliText{ - rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", - rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", - helpFlag: "显示此命令的帮助", + verifyStarted: "窗口已启动", + verifyNotStarted: "窗口未启动(重置时间为估计值)", + verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", + verifyRecovery: "窗口恢复 / 下次可尝试时间", + verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。", + verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。", + verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n", + rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", + rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", + helpFlag: "显示此命令的帮助", usageTemplate: `用法:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} diff --git a/internal/cli/ping.go b/internal/cli/ping.go index 31a8717..c2acb90 100644 --- a/internal/cli/ping.go +++ b/internal/cli/ping.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "sync/atomic" "time" "github.com/spf13/cobra" @@ -72,6 +73,9 @@ func runPing(parent context.Context, out io.Writer, text cliText, p provider.Pro defer cancel() start := time.Now() + var stage atomic.Value + stage.Store("") + ctx = provider.WithPingStage(ctx, func(s string) { stage.Store(s) }) type outcome struct { res *provider.TriggerResult err error @@ -100,13 +104,18 @@ func runPing(parent context.Context, out io.Writer, text cliText, p provider.Pro report(out, text, name, start, o.res, o.err) return o.err case <-ticker.C: - fmt.Fprintf(out, text.pingSendingFmt, name, frames[i%len(frames)], elapsed(start)) + label := name + if s := stage.Load().(string); s != "" { + label += " [" + s + "]" + } + fmt.Fprintf(out, text.pingSendingFmt, label, frames[i%len(frames)], elapsed(start)) i++ } } } func report(out io.Writer, text cliText, name string, start time.Time, res *provider.TriggerResult, err error) { + defer reportVerification(out, text, res) if err != nil { fmt.Fprintf(out, text.pingFailedFmt, name, elapsed(start), localizedProviderError(text, err)) return diff --git a/internal/cli/status.go b/internal/cli/status.go index ff84f73..1b5616a 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -48,6 +48,7 @@ func runStatus(ctx context.Context, out, progress io.Writer, text cliText, provi progress = io.Discard } display = normalizeUsageDisplay(display) + diagnostics := progress // In JSON mode keep stdout a single valid document: suppress the // "Fetching..." progress chatter that would otherwise interleave. if jsonOut { @@ -72,6 +73,9 @@ func runStatus(ctx context.Context, out, progress io.Writer, text cliText, provi continue } if jsonOut { + if u.Verification != nil && u.Verification.Warning != "" { + fmt.Fprintln(diagnostics, u.Verification.Warning) + } entries = append(entries, newStatusJSON(u, verbose)) continue } @@ -115,12 +119,14 @@ type statusJSON struct { } type windowJSON struct { - UsedPercent float64 `json:"used_percent"` - RemainingPercent float64 `json:"remaining_percent"` - Active bool `json:"active"` - ResetsAt string `json:"resets_at,omitempty"` - RemainingSeconds int `json:"remaining_seconds"` - WindowSeconds int `json:"window_seconds,omitempty"` + UsedPercent float64 `json:"used_percent"` + RemainingPercent float64 `json:"remaining_percent"` + Active bool `json:"active"` + ResetsAt string `json:"resets_at,omitempty"` + RemainingSeconds int `json:"remaining_seconds"` + WindowSeconds int `json:"window_seconds,omitempty"` + StartState string `json:"start_state,omitempty"` + VerificationDueAt string `json:"verification_due_at,omitempty"` } type creditsJSON struct { @@ -155,6 +161,10 @@ func newStatusJSON(u *usage.Usage, verbose bool) statusJSON { if !u.Weekly.Missing() { s.Weekly = newWindowJSON(u.Weekly) } + if u.Verification != nil { + addStartJSON(s.FiveHour, u.Verification.FiveHour) + addStartJSON(s.Weekly, u.Verification.Weekly) + } if !u.FetchedAt.IsZero() { s.FetchedAt = u.FetchedAt.Format(time.RFC3339) } @@ -174,6 +184,16 @@ func newStatusJSON(u *usage.Usage, verbose bool) statusJSON { return s } +func addStartJSON(w *windowJSON, s usage.StartStatus) { + if w == nil { + return + } + w.StartState = s.State + if !s.DueAt.IsZero() { + w.VerificationDueAt = s.DueAt.Format(time.RFC3339) + } +} + func newWindowJSON(w usage.Window) *windowJSON { j := &windowJSON{ UsedPercent: w.UsedPercent, @@ -218,8 +238,27 @@ func printUsage(out io.Writer, text cliText, u *usage.Usage, verbose bool, displ plan = " (" + plan + ")" } fmt.Fprintf(out, "%s%s\n", u.Provider, plan) - fmt.Fprintf(out, text.statusFiveHourLineFmt, fmtWindow(text, u.FiveHour, display)) - fmt.Fprintf(out, text.statusWeeklyLineFmt, fmtWindow(text, u.Weekly, display)) + five, week := fmtWindow(text, u.FiveHour, display), fmtWindow(text, u.Weekly, display) + if v := u.Verification; v != nil { + if !u.FiveHour.Missing() { + five += " — " + startDescription(text, v.FiveHour) + } + if !u.Weekly.Missing() { + week += " — " + startDescription(text, v.Weekly) + } + } + fmt.Fprintf(out, text.statusFiveHourLineFmt, five) + fmt.Fprintf(out, text.statusWeeklyLineFmt, week) + if v := u.Verification; v != nil { + fmt.Fprintf(out, " %s: %s %s", text.verifyRecovery, v.Target, v.Recovery) + if !v.NextEligible.IsZero() { + fmt.Fprintf(out, " (%s)", fmtClock(text, v.NextEligible)) + } + fmt.Fprintln(out) + if v.Warning != "" { + fmt.Fprintln(out, " "+v.Warning) + } + } if u.Credits != nil && (u.Credits.HasCredits || u.Credits.Unlimited) { if u.Credits.Unlimited { fmt.Fprint(out, text.statusCreditsUnlimited) diff --git a/internal/cli/verification.go b/internal/cli/verification.go new file mode 100644 index 0000000..4c34412 --- /dev/null +++ b/internal/cli/verification.go @@ -0,0 +1,60 @@ +package cli + +import ( + "fmt" + "io" + "time" + + "github.com/wavever/CCLimitPing/internal/codexstate" + "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/usage" +) + +func startDescription(text cliText, s usage.StartStatus) string { + switch s.State { + case codexstate.Started: + return text.verifyStarted + case codexstate.NotStarted: + return text.verifyNotStarted + default: + if !s.DueAt.IsZero() { + d := time.Until(s.DueAt).Round(time.Second) + if d < 0 { + d = 0 + } + return fmt.Sprintf("%s; recheck after %s", text.verifyUnknown, d) + } + return text.verifyUnknown + } +} + +func reportVerification(out io.Writer, text cliText, res *provider.TriggerResult) { + if res == nil || res.Verification == nil { + return + } + v := res.Verification + s := v.FiveHour + if v.Target == "weekly" { + s = v.Weekly + } + fmt.Fprintf(out, " %s: %s\n", v.Target, startDescription(text, s)) + if v.Warning != "" { + fmt.Fprintln(out, " "+v.Warning) + } + if s.State == codexstate.Started { + return + } + if !res.StatusEnabled { + fmt.Fprintln(out, text.verifyDisabled) + return + } + if s.DueAt.IsZero() { + fmt.Fprintln(out, text.verifyNoBaseline) + return + } + d := time.Until(s.DueAt).Round(time.Second) + if d < 0 { + d = 0 + } + fmt.Fprintf(out, text.verifyCheckFmt, d) +} diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go new file mode 100644 index 0000000..1e8ac7e --- /dev/null +++ b/internal/cli/verification_test.go @@ -0,0 +1,72 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/usage" +) + +func TestVerificationGuidance(t *testing.T) { + for _, tc := range []struct { + name, state string + baseline, enabled bool + want string + }{ + {"pending", "unknown", true, true, "after"}, + {"no-baseline", "unknown", false, true, "collect a baseline"}, + {"disabled", "unknown", true, false, "disabled"}, + {"confirmed", "started", false, true, "window started"}, + } { + t.Run(tc.name, func(t *testing.T) { + v := &usage.Verification{Target: "weekly", Weekly: usage.StartStatus{State: tc.state}} + if tc.baseline { + v.Weekly.DueAt = time.Now().Add(time.Minute) + } + var out bytes.Buffer + reportVerification(&out, enText, &provider.TriggerResult{Verification: v, StatusEnabled: tc.enabled}) + if !strings.Contains(out.String(), tc.want) { + t.Fatal(out.String()) + } + if tc.state == "started" && strings.Contains(out.String(), "Run `") { + t.Fatal(out.String()) + } + }) + } +} + +func TestStartJSONDoesNotChangeLegacyActiveOrClaude(t *testing.T) { + u := &usage.Usage{Provider: "codex", Weekly: usage.Window{ResetsAt: time.Now().Add(time.Hour), WindowSeconds: 604800}, + Verification: &usage.Verification{Weekly: usage.StartStatus{State: "started"}}} + j := newStatusJSON(u, false) + if j.Weekly.Active || j.Weekly.StartState != "started" { + t.Fatal(j.Weekly) + } + u.Verification = nil + u.Provider = "claude" + j = newStatusJSON(u, false) + if j.Weekly.StartState != "" { + t.Fatal(j.Weekly) + } +} + +func TestBackgroundVerificationDoesNotCountAsPing(t *testing.T) { + for _, tc := range []struct { + msg string + count bool + }{ + {"ping request completed; checking window", true}, + {"window started after verification", false}, + {"quota read failed: timeout", false}, + {"ping failed: notification timeout; verifying quota before retry", true}, + {"ping sent, new window started", true}, + } { + _, ok := parseBgPingAttempt("2026/09/01 12:00:00 [codex] " + tc.msg) + if ok != tc.count { + t.Fatalf("%s: %v", tc.msg, ok) + } + } +} diff --git a/internal/codexstate/file_unix.go b/internal/codexstate/file_unix.go new file mode 100644 index 0000000..930ad21 --- /dev/null +++ b/internal/codexstate/file_unix.go @@ -0,0 +1,26 @@ +//go:build !windows + +package codexstate + +import ( + "errors" + "golang.org/x/sys/unix" + "os" +) + +func lock(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + f.Close() + if errors.Is(err, unix.EWOULDBLOCK) { + return nil, ErrBusy + } + return nil, err + } + return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN); _ = f.Close() }, nil +} + +func replace(from, to string) error { return os.Rename(from, to) } diff --git a/internal/codexstate/file_windows.go b/internal/codexstate/file_windows.go new file mode 100644 index 0000000..d6e3af4 --- /dev/null +++ b/internal/codexstate/file_windows.go @@ -0,0 +1,36 @@ +package codexstate + +import ( + "errors" + "golang.org/x/sys/windows" + "os" +) + +func lock(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + var o windows.Overlapped + err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &o) + if err != nil { + f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, ErrBusy + } + return nil, err + } + return func() { _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, &o); _ = f.Close() }, nil +} + +func replace(from, to string) error { + a, err := windows.UTF16PtrFromString(from) + if err != nil { + return err + } + b, err := windows.UTF16PtrFromString(to) + if err != nil { + return err + } + return windows.MoveFileEx(a, b, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/codexstate/state.go b/internal/codexstate/state.go new file mode 100644 index 0000000..eb66b5e --- /dev/null +++ b/internal/codexstate/state.go @@ -0,0 +1,342 @@ +// Package codexstate interprets Codex quota observations. It never sends requests. +package codexstate + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/wavever/CCLimitPing/internal/usage" +) + +const ( + Started = "started" + NotStarted = "not_started" + Unknown = "unknown" + Interval = time.Minute + claimDuration = 3*time.Minute + 15*time.Second + tolerance = 5 * time.Second +) + +var ErrBusy = errors.New("another ping or state update is running; try again shortly") +var ErrDeferred = errors.New("automatic ping deferred; quota verification or cooldown is pending") + +type Sample struct { + At time.Time + Reset time.Time + Seconds int + Used float64 +} + +type window struct { + Baseline Sample + Latest Sample + State string + // ConfirmedReset is independent of the post-attempt failure baseline. + ConfirmedReset time.Time +} + +type bucket struct { + Plan string + FiveHour window + Weekly window + LastRead time.Time + AttemptEnd time.Time + ClaimID string + ClaimUntil time.Time + Attempts []time.Time + Retry int + LastAuto time.Time +} + +type diskState struct { + Version int + Buckets map[string]*bucket +} + +type Store struct{ Dir string } + +func sample(w usage.Window, now time.Time) Sample { + return Sample{At: now, Reset: w.ResetsAt, Seconds: w.WindowSeconds, Used: w.UsedPercent} +} + +func near(a, b time.Time) bool { return a.Sub(b).Abs() <= tolerance } +func valid(s Sample) bool { + return s.Seconds > 0 && s.Used >= 0 && s.Used <= 100 && s.Reset.After(s.At) && + s.Reset.Sub(s.At) <= time.Duration(s.Seconds)*time.Second+tolerance +} + +func observe(w *window, s Sample, duringAttempt bool) usage.StartStatus { + if !valid(s) { + *w = window{} + return usage.StartStatus{State: Unknown} + } + old := w.Baseline + if w.Latest.Seconds != s.Seconds || s.At.Before(w.Latest.At) || + (!w.ConfirmedReset.IsZero() && (!w.ConfirmedReset.After(s.At) || !near(w.ConfirmedReset, s.Reset))) { + *w = window{} + old = Sample{} + } + state := Unknown + if s.Used > 0 || (!w.ConfirmedReset.IsZero() && near(w.ConfirmedReset, s.Reset)) { + state = Started + } else if !duringAttempt && w.State == NotStarted && s.At.Sub(w.Latest.At) >= 0 && s.At.Sub(w.Latest.At) <= Interval && + near(s.Reset, s.At.Add(time.Duration(s.Seconds)*time.Second)) && + near(s.Reset, w.Latest.Reset.Add(s.At.Sub(w.Latest.At))) { + // A fresh pre-send read may extend a recently established sliding pair. + // It must not erase that evidence merely because it arrives within 60s. + state = NotStarted + } else if valid(old) && old.Seconds == s.Seconds && old.Reset.After(s.At) && s.At.Sub(old.At) >= Interval { + if near(old.Reset, s.Reset) { + state = Started + } else if !duringAttempt && s.At.Sub(old.At) <= 10*time.Minute && + near(old.Reset, old.At.Add(time.Duration(old.Seconds)*time.Second)) && + near(s.Reset, s.At.Add(time.Duration(s.Seconds)*time.Second)) && + (s.Reset.Sub(old.Reset)-s.At.Sub(old.At)).Abs() <= tolerance { + state = NotStarted + } + } + if state == Started { + w.ConfirmedReset = s.Reset + } + // During a live claim do not collect evidence that could authorize its retry. + if !duringAttempt && (old.At.IsZero() || state != Unknown || !old.Reset.After(s.At) || + s.At.Sub(old.At) > 10*time.Minute) { + w.Baseline = s + } + w.Latest, w.State = s, state + result := usage.StartStatus{State: state} + if state == Unknown && !w.Baseline.At.IsZero() { + result.DueAt = w.Baseline.At.Add(Interval) + if !result.DueAt.After(s.At) { + result.DueAt = s.At.Add(Interval) + } + } + return result +} + +func target(b *bucket) (string, *window) { + if b.FiveHour.Latest.Seconds != 0 { + return "five_hour", &b.FiveHour + } + if b.Weekly.Latest.Seconds != 0 { + return "weekly", &b.Weekly + } + return "", nil +} + +func prune(b *bucket, now time.Time) { + kept := b.Attempts[:0] + for _, at := range b.Attempts { + if at.Add(time.Hour).After(now) { + kept = append(kept, at) + } + } + b.Attempts = kept +} + +func view(b *bucket, now time.Time) *usage.Verification { + v := &usage.Verification{FiveHour: status(b.FiveHour, now), Weekly: status(b.Weekly, now)} + name, w := target(b) + v.Target = name + switch { + case b.ClaimID != "" && b.ClaimUntil.After(now): + v.Recovery, v.NextEligible = "ping_running", b.ClaimUntil + case w == nil: + v.Recovery = "unavailable" + case w.State == Started: + v.Recovery, v.NextEligible = "window_started", w.Latest.Reset + default: + v.Recovery = "verifying" + v.NextEligible = now.Add(Interval) + if w.State == NotStarted { + v.Recovery, v.NextEligible = "ready", now + } + if d := retryDue(b); d.After(v.NextEligible) { + v.Recovery, v.NextEligible = "backoff", d + } + if len(b.Attempts) >= 4 && b.Attempts[0].Add(time.Hour).After(v.NextEligible) { + v.Recovery, v.NextEligible = "cooldown", b.Attempts[0].Add(time.Hour) + } + } + return v +} + +func status(w window, now time.Time) usage.StartStatus { + s := usage.StartStatus{State: w.State} + if s.State == "" { + s.State = Unknown + } + if s.State == Unknown && !w.Baseline.At.IsZero() { + s.DueAt = w.Baseline.At.Add(Interval) + if !s.DueAt.After(now) { + s.DueAt = now.Add(Interval) + } + } + return s +} + +func retryDue(b *bucket) time.Time { + delay := time.Minute + if b.Retry >= 2 { + delay = 5 * time.Minute + } + if b.Retry >= 3 { + delay = 15 * time.Minute + } + due := b.LastAuto.Add(delay) + if b.AttemptEnd.Add(Interval).After(due) { + due = b.AttemptEnd.Add(Interval) + } + return due +} + +func (s Store) Observe(account, key string, u *usage.Usage) (*usage.Verification, error) { + var v *usage.Verification + err := s.update(account, key, func(b *bucket) error { + now := u.FetchedAt + prune(b, now) + if b.Plan != u.Plan || now.Before(b.LastRead) { + b.FiveHour, b.Weekly = window{}, window{} + } + b.Plan, b.LastRead = u.Plan, now + if b.ClaimID != "" && !b.ClaimUntil.After(now) { + // Crash recovery: first observation after an expired claim is a new baseline. + clearEvidence(b) + b.AttemptEnd, b.ClaimID = now, "" + } + live := b.ClaimID != "" + observe(&b.FiveHour, sample(u.FiveHour, now), live) + observe(&b.Weekly, sample(u.Weekly, now), live) + _, w := target(b) + if w != nil && w.State == Started { + b.Retry, b.LastAuto = 0, time.Time{} + } + v = view(b, now) + return nil + }) + return v, err +} + +func clearEvidence(b *bucket) { + for _, w := range []*window{&b.FiveHour, &b.Weekly} { + w.Baseline = Sample{} + if w.ConfirmedReset.IsZero() { + w.State = Unknown + } + } +} + +// Begin is a short transaction, never a network-length lock. observedAt prevents +// an automatic sender from acting on a snapshot superseded by another process. +func (s Store) Begin(account, key string, automatic bool, observedAt, now time.Time) (string, error) { + idBytes := make([]byte, 16) + if _, err := rand.Read(idBytes); err != nil { + return "", err + } + id := hex.EncodeToString(idBytes) + err := s.update(account, key, func(b *bucket) error { + prune(b, now) + if b.ClaimID != "" && b.ClaimUntil.After(now) { + return ErrBusy + } + if automatic { + v := view(b, now) + _, w := target(b) + if b.ClaimID != "" || !b.LastRead.Equal(observedAt) || now.Sub(observedAt) > 3*time.Second || + w == nil || w.State != NotStarted || v.NextEligible.After(now) { + return ErrDeferred + } + b.Attempts = append(b.Attempts, now) + b.LastAuto = now + if b.Retry < 3 { + b.Retry++ + } + } + clearEvidence(b) + b.ClaimID, b.ClaimUntil = id, now.Add(claimDuration) + return nil + }) + return id, err +} + +func (s Store) Finish(account, key, id string, now time.Time, invalidate bool) error { + return s.update(account, key, func(b *bucket) error { + if id != b.ClaimID { + return ErrBusy + } + clearEvidence(b) + if invalidate { + b.FiveHour, b.Weekly = window{}, window{} + } + b.AttemptEnd, b.ClaimID, b.ClaimUntil = now, "", time.Time{} + return nil + }) +} + +func (s Store) Invalidate(account, key string) error { + return s.update(account, key, func(b *bucket) error { b.FiveHour, b.Weekly = window{}, window{}; return nil }) +} + +func (s Store) update(account, key string, fn func(*bucket) error) error { + if account == "" || key == "" { + return errors.New("quota account or bucket identity unavailable") + } + if err := os.MkdirAll(s.Dir, 0700); err != nil { + return err + } + hash := sha256.Sum256([]byte(account)) + path := filepath.Join(s.Dir, hex.EncodeToString(hash[:])+".json") + unlock, err := lock(path + ".lock") + if err != nil { + return err + } + defer unlock() + d := diskState{Version: 1, Buckets: map[string]*bucket{}} + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, &d); err != nil { + return fmt.Errorf("invalid quota state: %w", err) + } + if d.Version != 1 || d.Buckets == nil { + return errors.New("unsupported quota state") + } + } else if !os.IsNotExist(err) { + return err + } + b := d.Buckets[key] + if b == nil { + b = &bucket{} + d.Buckets[key] = b + } + if err := fn(b); err != nil { + return err + } + data, err = json.Marshal(d) + if err != nil { + return err + } + f, err := os.CreateTemp(s.Dir, ".quota-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return replace(f.Name(), path) +} diff --git a/internal/codexstate/state_test.go b/internal/codexstate/state_test.go new file mode 100644 index 0000000..25d6156 --- /dev/null +++ b/internal/codexstate/state_test.go @@ -0,0 +1,260 @@ +package codexstate + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/usage" +) + +var epoch = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + +func quota(at, reset time.Time, used float64, weekly bool) *usage.Usage { + u := &usage.Usage{Plan: "pro", FetchedAt: at} + w := usage.Window{UsedPercent: used, ResetsAt: reset, WindowSeconds: 18000} + if weekly { + w.WindowSeconds = 604800 + u.Weekly = w + } else { + u.FiveHour = w + } + return u +} + +func TestClassify(t *testing.T) { + for _, tc := range []struct { + name string + gap, move time.Duration + used float64 + want string + }{ + {"first-minute", 59 * time.Second, 0, 0, Unknown}, + {"fixed", time.Minute, 0, 0, Started}, + {"fixed-six-minutes", 6 * time.Minute, 0, 0, Started}, + {"fixed-hours", 2 * time.Hour, 0, 0, Started}, + {"sliding", time.Minute, time.Minute, 0, NotStarted}, + {"jitter", time.Minute, time.Second, 0, Started}, + {"ambiguous", time.Minute, 30 * time.Second, 0, Unknown}, + {"stale-sliding", 11 * time.Minute, 11 * time.Minute, 0, Unknown}, + {"positive", time.Second, 0, 1, Started}, + } { + t.Run(tc.name, func(t *testing.T) { + w := &window{} + reset := epoch.Add(5 * time.Hour) + observe(w, Sample{At: epoch, Reset: reset, Seconds: 18000}, false) + got := observe(w, Sample{At: epoch.Add(tc.gap), Reset: reset.Add(tc.move), Seconds: 18000, Used: tc.used}, false) + if got.State != tc.want { + t.Fatalf("got %s want %s", got.State, tc.want) + } + }) + } +} + +func TestPollingAndInvalidation(t *testing.T) { + w := &window{} + reset := epoch.Add(5 * time.Hour) + for i := 0; i <= 60; i += 10 { + v := observe(w, Sample{At: epoch.Add(time.Duration(i) * time.Second), Reset: reset, Seconds: 18000}, false) + if i == 60 && v.State != Started { + t.Fatal(v) + } + } + for _, s := range []Sample{ + {At: epoch.Add(61 * time.Second), Reset: reset, Seconds: 604800}, + {At: epoch.Add(-time.Second), Reset: reset, Seconds: 18000}, + {At: reset.Add(time.Second), Reset: reset, Seconds: 18000}, + } { + copy := *w + if v := observe(©, s, false); v.State != Unknown { + t.Fatal(v) + } + } +} + +func read(t *testing.T, s Store, key string, u *usage.Usage) *usage.Verification { + t.Helper() + v, err := s.Observe("account", key, u) + if err != nil { + t.Fatal(err) + } + return v +} + +func TestPostAttemptBaselineAndExistingStarted(t *testing.T) { + for _, started := range []bool{false, true} { + t.Run(map[bool]string{true: "already-started", false: "new-start"}[started], func(t *testing.T) { + s := Store{Dir: t.TempDir()} + reset := epoch.Add(5 * time.Hour) + read(t, s, "codex", quota(epoch, reset, 0, false)) + if !started { + reset = reset.Add(time.Minute) + } + pre := quota(epoch.Add(time.Minute), reset, 0, false) + read(t, s, "codex", pre) + id, err := s.Begin("account", "codex", false, pre.FetchedAt, pre.FetchedAt) + if err != nil { + t.Fatal(err) + } + end := pre.FetchedAt.Add(5 * time.Second) + if err := s.Finish("account", "codex", id, end, false); err != nil { + t.Fatal(err) + } + if !started { + reset = end.Add(5 * time.Hour) + } + v := read(t, s, "codex", quota(end, reset, 0, false)) + want := Unknown + if started { + want = Started + } + if v.FiveHour.State != want { + t.Fatal(v) + } + v = read(t, s, "codex", quota(end.Add(time.Minute), reset, 0, false)) + if v.FiveHour.State != Started { + t.Fatal(v) + } + }) + } +} + +func TestBucketIdentityAndTarget(t *testing.T) { + s := Store{Dir: t.TempDir()} + for _, key := range []string{"codex", "spark:model"} { + u := quota(epoch, epoch.Add(5*time.Hour), 0, false) + u.Weekly = usage.Window{UsedPercent: 5, ResetsAt: epoch.Add(7 * 24 * time.Hour), WindowSeconds: 604800} + v := read(t, s, key, u) + if v.Target != "five_hour" || v.Recovery == "window_started" { + t.Fatal(v) + } + } + v := read(t, s, "codex", quota(epoch.Add(time.Minute), epoch.Add(5*time.Hour), 0, false)) + if v.FiveHour.State != Started { + t.Fatal(v) + } + v = read(t, s, "spark:model", quota(epoch.Add(time.Minute), epoch.Add(5*time.Hour+time.Minute), 0, false)) + if v.FiveHour.State != NotStarted { + t.Fatal(v) + } + u := quota(epoch, epoch.Add(7*24*time.Hour), 0, true) + v = read(t, s, "weekly-only", u) + if v.Target != "weekly" { + t.Fatal(v) + } + u.FetchedAt = epoch.Add(10 * time.Minute) + v = read(t, s, "weekly-only", u) + if v.Weekly.State != Started { + t.Fatal(v) + } + v, err := s.Observe("other-account", "codex", quota(epoch.Add(time.Minute), epoch.Add(5*time.Hour), 0, false)) + if err != nil || v.FiveHour.State != Unknown { + t.Fatal(v, err) + } + if err := s.Invalidate("account", "codex"); err != nil { + t.Fatal(err) + } + v = read(t, s, "codex", quota(epoch.Add(2*time.Minute), epoch.Add(5*time.Hour), 0, false)) + if v.FiveHour.State != Unknown { + t.Fatal(v) + } +} + +func TestImmediatePreSendReadRetainsSlidingEvidence(t *testing.T) { + s := Store{Dir: t.TempDir()} + for _, d := range []time.Duration{0, time.Minute, time.Minute + time.Second} { + now := epoch.Add(d) + v := read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + if d >= time.Minute && v.FiveHour.State != NotStarted { + t.Fatal(v) + } + } + now := epoch.Add(time.Minute + time.Second) + if _, err := s.Begin("account", "codex", true, now, now); err != nil { + t.Fatal(err) + } +} + +func TestBudgetAndCrashRecovery(t *testing.T) { + s := Store{Dir: t.TempDir()} + now := epoch + // Persistent transitions use fake time, including the backoff schedule. + for i := 0; i < 4; i++ { + read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + now = now.Add(time.Minute) + u := quota(now, now.Add(5*time.Hour), 0, false) + v := read(t, s, "codex", u) + if v.NextEligible.After(now) { + now = v.NextEligible + // Fresh short-interval evidence after a long backoff. + read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + now = now.Add(time.Minute) + u = quota(now, now.Add(5*time.Hour), 0, false) + read(t, s, "codex", u) + } + id, err := s.Begin("account", "codex", true, u.FetchedAt, now) + if err != nil { + t.Fatalf("attempt %d: %v", i, err) + } + if _, err := s.Begin("account", "codex", false, time.Time{}, now); !errors.Is(err, ErrBusy) { + t.Fatal(err) + } + if err := s.Finish("account", "codex", id, now, false); err != nil { + t.Fatal(err) + } + read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + } + now = now.Add(time.Minute) + u := quota(now, now.Add(5*time.Hour), 0, false) + v := read(t, Store{Dir: s.Dir}, "codex", u) + if v.Recovery != "cooldown" { + t.Fatal(v) + } + if _, err := s.Begin("account", "codex", true, u.FetchedAt, now); !errors.Is(err, ErrDeferred) { + t.Fatal(err) + } + now = epoch.Add(2 * time.Hour) + read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + now = now.Add(time.Minute) + u = quota(now, now.Add(5*time.Hour), 0, false) + read(t, s, "codex", u) + _, err := s.Begin("account", "codex", true, now, now) + if err != nil { + t.Fatal(err) + } + now = now.Add(claimDuration + time.Second) + v = read(t, s, "codex", quota(now, now.Add(5*time.Hour), 0, false)) + if v.FiveHour.State != Unknown { + t.Fatal("expired claim reused old evidence", v) + } +} + +func TestLockProcess(t *testing.T) { + if path := os.Getenv("LIMITPING_TEST_LOCK"); path != "" { + _, err := lock(path) + if !errors.Is(err, ErrBusy) { + os.Exit(2) + } + os.Exit(0) + } + path := filepath.Join(t.TempDir(), "test.lock") + unlock, err := lock(path) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestLockProcess$") + cmd.Env = append(os.Environ(), "LIMITPING_TEST_LOCK="+path) + err = cmd.Run() + unlock() + if err != nil { + t.Fatal(err) + } + unlock, err = lock(path) + if err != nil { + t.Fatal(err) + } + unlock() +} diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 7fb0c45..e2a675c 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -41,11 +41,8 @@ const ( // once-a-minute poll loop cannot re-attempt a refused redemption every cycle. codexRedeemCooldown = 15 * time.Minute - codexTurnMinWait = 4 * time.Second - codexTurnQuiet = 2500 * time.Millisecond - codexTurnMaxWait = 45 * time.Second - codexExitGrace = 5 * time.Second - codexPollInterval = 200 * time.Millisecond + codexTurnMaxWait = 45 * time.Second + codexExitGrace = 5 * time.Second ) // Codex reads usage via the ChatGPT backend usage endpoint and triggers windows @@ -73,23 +70,16 @@ func (c *Codex) ActiveTask(ctx context.Context) (string, bool, error) { } func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { - body, r, err := readCodexUsage(ctx, c.auth) - if err != nil { - return nil, err - } - u := codexUsageToUsage(c.Name(), body, r, r.RateLimit) - if credits, err := readCodexResetCredits(ctx, c.auth); err == nil { - u.ResetCredits = credits - } else if r.ResetCredits != nil { - // The detail endpoint is private and may go away; the usage response - // itself now embeds the available count, so keep at least that. - u.ResetCredits = &usage.ResetCredits{AvailableCount: r.ResetCredits.AvailableCount} - } - return u, nil + u, _, err := readVerifiedUsage(ctx, c.Name(), c.cfg, true) + return u, err } func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return triggerCodex(ctx, c.cfg, dryRun) + return pingVerified(ctx, c.Name(), c.cfg, dryRun, false, 0) +} + +func (c *Codex) TriggerAutomatic(ctx context.Context, threshold float64) (*TriggerResult, error) { + return pingVerified(ctx, c.Name(), c.cfg, false, true, threshold) } // RedeemResetCredit spends the next available reset credit right now. Each call @@ -126,8 +116,10 @@ func (c *Codex) consumeResetCredit(ctx context.Context, idempotencyKey string) ( if err != nil { return "", err } - accountID, _ := c.auth.AccountID(ctx) - body, err := fetchWithAuth(ctx, c.auth, func(token string) (*http.Request, error) { + a := auth.NewCodexAuth() + accountID := "" + body, err := fetchWithAuth(ctx, a, func(token string) (*http.Request, error) { + accountID, _ = a.AccountID(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexConsumeURL(), bytes.NewReader(payload)) if err != nil { return nil, err @@ -155,7 +147,13 @@ func (c *Codex) consumeResetCredit(ctx context.Context, idempotencyKey string) ( if r.Code == "" { return "", fmt.Errorf("codex reset credit consume: no outcome in response: %s", truncate(body, 200)) } - return normalizeRedeemOutcome(r.Code), nil + outcome := normalizeRedeemOutcome(r.Code) + if outcome == RedeemReset { + if store, err := quotaStore(); err == nil { + _ = store.Invalidate(accountID, "codex") + } + } + return outcome, nil } // normalizeRedeemOutcome folds the two spellings of the same outcomes into the @@ -216,19 +214,16 @@ func (s *Spark) ActiveTask(ctx context.Context) (string, bool, error) { } func (s *Spark) ReadUsage(ctx context.Context) (*usage.Usage, error) { - body, r, err := readCodexUsage(ctx, s.auth) - if err != nil { - return nil, err - } - rateLimit, err := sparkRateLimitFromResponse(r, s.cfg.Model) - if err != nil { - return nil, err - } - return codexUsageToUsage(s.Name(), body, r, rateLimit), nil + u, _, err := readVerifiedUsage(ctx, s.Name(), s.cfg, false) + return u, err } func (s *Spark) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return triggerCodex(ctx, s.cfg, dryRun) + return pingVerified(ctx, s.Name(), s.cfg, dryRun, false, 0) +} + +func (s *Spark) TriggerAutomatic(ctx context.Context, threshold float64) (*TriggerResult, error) { + return pingVerified(ctx, s.Name(), s.cfg, false, true, threshold) } func codexActiveTask(_ context.Context) (string, bool, error) { @@ -299,8 +294,8 @@ type codexResetCredit struct { func readCodexUsage(ctx context.Context, auth *auth.CodexAuth) ([]byte, codexUsageResp, error) { var r codexUsageResp - accountID, _ := auth.AccountID(ctx) body, err := fetchWithAuth(ctx, auth, func(token string) (*http.Request, error) { + accountID, _ := auth.AccountID(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL(), nil) if err != nil { return nil, err @@ -325,8 +320,8 @@ func readCodexUsage(ctx context.Context, auth *auth.CodexAuth) ([]byte, codexUsa func readCodexResetCredits(ctx context.Context, auth *auth.CodexAuth) (*usage.ResetCredits, error) { var r codexResetCreditsResp - accountID, _ := auth.AccountID(ctx) body, err := fetchWithAuth(ctx, auth, func(token string) (*http.Request, error) { + accountID, _ := auth.AccountID(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexResetCreditsURL(), nil) if err != nil { return nil, err @@ -551,6 +546,10 @@ func codexWindowToUsage(w codexWindow) usage.Window { } func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (*TriggerResult, error) { + return triggerCodexWithWait(ctx, cfg, dryRun, codexTurnMaxWait, codexExitGrace) +} + +func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun bool, maxWait, exitGrace time.Duration) (*TriggerResult, error) { prompt := cfg.Prompt if prompt == "" { prompt = "ok" @@ -563,6 +562,8 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( args = append(args, "-m", cfg.Model) } args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...) + args = append(args, "-c", `tui.notifications=["agent-turn-complete"]`, + "-c", `tui.notification_method="osc9"`, "-c", `tui.notification_condition="always"`) args = append(args, prompt) res := &TriggerResult{Command: "codex " + shellJoin(args)} if dryRun { @@ -570,6 +571,9 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( } cmd := exec.CommandContext(ctx, "codex", args...) + if term := os.Getenv("TERM"); term == "" || term == "dumb" { + cmd.Env = append(cmd.Environ(), "TERM=xterm-256color") + } ptmx, err := pty.Start(cmd) if err != nil { return res, fmt.Errorf("codex interactive failed to start: %w", err) @@ -577,8 +581,11 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( defer ptmx.Close() output := &limitedBuffer{limit: 4096} + marker := &completionMarker{done: make(chan struct{})} + readDone := make(chan struct{}) go func() { - _, _ = io.Copy(output, ptmx) + defer close(readDone) + _, _ = io.Copy(io.MultiWriter(output, marker), ptmx) }() done := make(chan error, 1) @@ -586,41 +593,57 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( done <- cmd.Wait() }() - if terminal, err := codexAwait(ctx, cmd, ptmx, output, done, codexTurnMaxWait, - func(idle, elapsed time.Duration) bool { - return elapsed >= codexTurnMinWait && idle >= codexTurnQuiet - }); terminal { - return res, err + select { + case <-marker.done: + return res, codexInteractiveStop(ctx, cmd, ptmx, done, output, exitGrace) + case err := <-done: + // Wait for the PTY tail: process exit can race with reading its marker. + select { + case <-readDone: + case <-time.After(100 * time.Millisecond): + } + if err != nil { + return res, codexInteractiveErr(err, output) + } + select { + case <-marker.done: + return res, nil + default: + } + return res, fmt.Errorf("codex exited without a turn-completion notification; request completion unconfirmed") + case <-ctx.Done(): + return res, codexInteractiveCancel(ctx, cmd, ptmx, done, output) + case <-time.After(maxWait): + _ = codexInteractiveStop(ctx, cmd, ptmx, done, output, exitGrace) + return res, fmt.Errorf("codex turn-completion notification timed out after %s", maxWait) } +} - return res, codexInteractiveStop(ctx, cmd, ptmx, done, output) +type completionMarker struct { + buf []byte + done chan struct{} + finished bool } -func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { - start := time.Now() - deadline := time.After(maxWait) - ticker := time.NewTicker(codexPollInterval) - defer ticker.Stop() - for { - select { - case err := <-done: - return true, codexInteractiveErr(err, output) - case <-ctx.Done(): - return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) - case <-deadline: - return false, nil - case <-ticker.C: - changed := output.changedAt() - if !changed.IsZero() && ready(time.Since(changed), time.Since(start)) { - return false, nil - } - } +func (m *completionMarker) Write(p []byte) (int, error) { + if m.finished { + return len(p), nil + } + m.buf = append(m.buf, p...) + if i := bytes.Index(m.buf, []byte("\x1b]9;")); i >= 0 && bytes.IndexByte(m.buf[i:], 7) >= 0 { + m.finished = true + close(m.done) + m.buf = nil + } + if len(m.buf) > 4096 { + m.buf = append(m.buf[:0], m.buf[len(m.buf)-4096:]...) } + return len(p), nil } -func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { - deadline := time.After(codexExitGrace) - ticker := time.NewTicker(codexExitGrace / 2) +func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer, grace time.Duration) error { + deadline := time.After(grace) + ticker := time.NewTicker(grace / 2) defer ticker.Stop() for sent := false; ; { diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index e36a1d6..885fee9 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -22,6 +22,7 @@ func fakeCodexHome(t *testing.T) { t.Helper() home := t.TempDir() t.Setenv("CODEX_HOME", home) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) authJSON := `{"tokens":{"access_token":"access-token","refresh_token":"refresh-token","account_id":"account-123"}}` if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(authJSON), 0o600); err != nil { t.Fatal(err) @@ -322,7 +323,7 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only ok" + want := `codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok` if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } @@ -345,7 +346,7 @@ func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok" + want := `codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok` if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go new file mode 100644 index 0000000..5a7828b --- /dev/null +++ b/internal/provider/codex_verification.go @@ -0,0 +1,198 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "time" + + "github.com/wavever/CCLimitPing/internal/auth" + "github.com/wavever/CCLimitPing/internal/codexstate" + "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/usage" +) + +const quotaReadBudget = 3 * time.Second + +type noQuotaStateKey struct{} +type pingStageKey struct{} + +// WithPingStage supplies optional live CLI progress without coupling providers +// to terminal output. The callback must be safe to call from a worker goroutine. +func WithPingStage(ctx context.Context, f func(string)) context.Context { + return context.WithValue(ctx, pingStageKey{}, f) +} +func pingStage(ctx context.Context, stage string) { + if f, ok := ctx.Value(pingStageKey{}).(func(string)); ok { + f(stage) + } +} + +// WithoutQuotaState preserves watch --dry-run's read-only storage behavior. +func WithoutQuotaState(ctx context.Context) context.Context { + return context.WithValue(ctx, noQuotaStateKey{}, true) +} + +func quotaStore() (codexstate.Store, error) { + dir, err := config.Dir() + return codexstate.Store{Dir: filepath.Join(dir, "state", "codex")}, err +} + +func quotaBucket(name string, cfg config.ProviderConfig) string { + if name == "spark" { + return "spark:" + normalizeCodexLimitName(cfg.Model) + } + return "codex" +} + +func currentCodexAccount(ctx context.Context) (string, error) { + a := auth.NewCodexAuth() + id, err := a.AccountID(ctx) + if err == nil && id == "" { + err = errors.New("Codex account identity unavailable") + } + return id, err +} + +func unknownVerification(warning string) *usage.Verification { + return &usage.Verification{FiveHour: usage.StartStatus{State: codexstate.Unknown}, + Weekly: usage.StartStatus{State: codexstate.Unknown}, Recovery: "verifying", Warning: warning} +} + +// Each observation owns its credential snapshot; a watcher cannot retain a +// previous login indefinitely. Authentication retries update that same snapshot. +func readVerifiedUsage(ctx context.Context, name string, cfg config.ProviderConfig, details bool) (*usage.Usage, string, error) { + a := auth.NewCodexAuth() + body, r, err := readCodexUsage(ctx, a) + if err != nil { + return nil, "", err + } + account, err := a.AccountID(ctx) + if err != nil { + return nil, "", err + } + rl := r.RateLimit + if name == "spark" { + rl, err = sparkRateLimitFromResponse(r, cfg.Model) + if err != nil { + return nil, account, err + } + } + u := codexUsageToUsage(name, body, r, rl) + if r.ResetCredits != nil { + u.ResetCredits = &usage.ResetCredits{AvailableCount: r.ResetCredits.AvailableCount} + } + store, err := quotaStore() + if ctx.Value(noQuotaStateKey{}) == true { + u.Verification = unknownVerification("") + } else if err == nil { + u.Verification, err = store.Observe(account, quotaBucket(name, cfg), u) + } + if err != nil { + u.Verification = unknownVerification("quota state unavailable: " + err.Error()) + } + if details { + if credits, err := readCodexResetCredits(ctx, a); err == nil { + if id, _ := a.AccountID(ctx); id == account { + u.ResetCredits = credits + } + } + } + return u, account, nil +} + +func boundedQuota(ctx context.Context, name string, cfg config.ProviderConfig) (*usage.Usage, string, error) { + readCtx, cancel := context.WithTimeout(ctx, quotaReadBudget) + defer cancel() + return readVerifiedUsage(readCtx, name, cfg, false) +} + +func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, dry, automatic bool, threshold float64) (*TriggerResult, error) { + if dry { + return triggerCodex(ctx, cfg, true) + } + pingStage(ctx, "checking quota before ping") + pre, account, readErr := boundedQuota(ctx, name, cfg) + if ctx.Err() != nil { + return nil, ctx.Err() + } + warning := "" + if readErr != nil { + warning = "pre-ping quota read failed: " + readErr.Error() + } + if pre != nil && pre.Verification.Warning != "" { + warning = pre.Verification.Warning + } + if automatic && (readErr != nil || warning != "") { + return nil, fmt.Errorf("automatic pre-ping check unavailable: %s", warning) + } + identity, identityErr := currentCodexAccount(ctx) + if identityErr == nil && (account == "" || readErr != nil) { + account = identity + } + if identityErr != nil || identity != account { + if automatic { + return nil, codexstate.ErrDeferred + } + warning = "account changed or unavailable; window verification unavailable" + account = "" + } + if automatic { + if pre.WeeklyExhausted(threshold) { + return nil, codexstate.ErrDeferred + } + if _, active, err := codexActiveTask(ctx); err != nil || active { + return nil, codexstate.ErrDeferred + } + } + store, storeErr := quotaStore() + key := quotaBucket(name, cfg) + claim := "" + if storeErr == nil && account != "" { + var observed time.Time + if pre != nil { + observed = pre.FetchedAt + } + claim, storeErr = store.Begin(account, key, automatic, observed, time.Now()) + } + if storeErr != nil { + if automatic || errors.Is(storeErr, codexstate.ErrBusy) || errors.Is(storeErr, codexstate.ErrDeferred) { + return nil, storeErr + } + warning = "quota coordination unavailable: " + storeErr.Error() + claim = "" + } + // The PTY deadline and claim deadline must describe the same bounded operation. + pingStage(ctx, "sending ping") + triggerCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + res, triggerErr := triggerCodex(triggerCtx, cfg, false) + cancel() + if res == nil { + res = &TriggerResult{} + } + res.StatusEnabled = cfg.Enabled + after, afterErr := currentCodexAccount(ctx) + changed := account == "" || afterErr != nil || after != account + if claim != "" { + if err := store.Finish(account, key, claim, time.Now(), changed); err != nil { + warning = "quota state update failed: " + err.Error() + } + } + res.Verification = unknownVerification(warning) + if ctx.Err() == nil { + pingStage(ctx, "checking quota after ping") + post, postAccount, err := boundedQuota(ctx, name, cfg) + if err != nil { + res.Verification.Warning = "post-ping quota read failed: " + err.Error() + } else if !changed && postAccount == account { + res.Verification = post.Verification + if warning != "" { + res.Verification.Warning = warning + } + } else { + res.Verification.Warning = "account changed; ping result cannot be attributed to this quota" + } + } + return res, triggerErr +} diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go new file mode 100644 index 0000000..3ab36ba --- /dev/null +++ b/internal/provider/codex_verification_test.go @@ -0,0 +1,154 @@ +package provider + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/codexstate" + "github.com/wavever/CCLimitPing/internal/config" +) + +func fakeCodexCLI(t *testing.T, script string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("native Windows PTY is unsupported") + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "codex"), []byte("#!/bin/sh\n"+script+"\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func quotaResponse(reset int64) string { + return fmt.Sprintf(`{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":%d}}}`, reset) +} + +func TestVerifiedPingReadsBeforeAndAfterWithoutWaitingMinute(t *testing.T) { + fakeCodexHome(t) + fakeCodexCLI(t, `printf '\033]9;done\007'`) + old := usageHTTPClient + defer func() { usageHTTPClient = old }() + reads := 0 + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/usage") { + t.Fatalf("unexpected detail read %s", req.URL.Path) + } + reads++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(quotaResponse(time.Now().Add(7 * 24 * time.Hour).Unix()))), Header: make(http.Header)}, nil + })} + start := time.Now() + res, err := NewCodex(config.ProviderConfig{Enabled: true}).Trigger(context.Background(), false) + if err != nil { + t.Fatal(err) + } + if reads != 2 { + t.Fatalf("reads=%d", reads) + } + if time.Since(start) > 5*time.Second { + t.Fatal("manual ping blocked") + } + if res.Verification == nil || res.Verification.Weekly.State != codexstate.Unknown || res.Verification.Weekly.DueAt.IsZero() { + t.Fatalf("%+v", res.Verification) + } +} + +func TestFailedTriggerStillReadsQuotaAfterwards(t *testing.T) { + fakeCodexHome(t) + fakeCodexCLI(t, "exit 1") + old := usageHTTPClient + defer func() { usageHTTPClient = old }() + reads := 0 + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + reads++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(quotaResponse(time.Now().Add(7 * 24 * time.Hour).Unix()))), Header: make(http.Header)}, nil + })} + res, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false) + if err == nil || reads != 2 || res.Verification == nil { + t.Fatal(err, reads, res) + } +} + +func TestMarkerAndTimeoutOutcomes(t *testing.T) { + for _, tc := range []struct { + name, script string + success bool + }{ + {"marker", `printf '\033]9;done\007'`, true}, + {"clean-no-marker", "exit 0", false}, + {"timeout", "sleep 1", false}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeCodexCLI(t, tc.script) + _, err := triggerCodexWithWait(context.Background(), config.ProviderConfig{}, false, 50*time.Millisecond, 20*time.Millisecond) + if (err == nil) != tc.success { + t.Fatal(err) + } + }) + } + m := &completionMarker{done: make(chan struct{})} + for _, p := range []string{"noise\x1b]", "9;", "done", "\a"} { + _, _ = m.Write([]byte(p)) + } + select { + case <-m.done: + default: + t.Fatal("fragmented marker missed") + } +} + +func TestAccountSwitchReloadsIdentity(t *testing.T) { + fakeCodexHome(t) + old := usageHTTPClient + defer func() { usageHTTPClient = old }() + expected := "account-123" + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("ChatGPT-Account-Id") != expected { + t.Fatalf("stale account header: %s", req.Header.Get("ChatGPT-Account-Id")) + } + body := quotaResponse(time.Now().Add(7 * 24 * time.Hour).Unix()) + if strings.HasSuffix(req.URL.Path, "reset-credits") { + body = `{"available_count":0}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + c := NewCodex(config.ProviderConfig{}) + if _, err := c.ReadUsage(context.Background()); err != nil { + t.Fatal(err) + } + expected = "account-new" + if err := os.WriteFile(filepath.Join(os.Getenv("CODEX_HOME"), "auth.json"), []byte(`{"tokens":{"access_token":"different-valid-token","account_id":"account-new"}}`), 0600); err != nil { + t.Fatal(err) + } + u, err := c.ReadUsage(context.Background()) + if err != nil { + t.Fatal(err) + } + if u.Verification.Weekly.State != codexstate.Unknown { + t.Fatal(u.Verification) + } +} + +func TestDryRunNeverReadsOrWritesState(t *testing.T) { + t.Setenv("CODEX_HOME", t.TempDir()) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + old := usageHTTPClient + defer func() { usageHTTPClient = old }() + usageHTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { t.Fatal("dry run read usage"); return nil, nil })} + if _, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), true); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Fatal(entries) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 24f6047..750232c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -87,12 +87,20 @@ type ResetCreditRedeemer interface { // consumed (parsed from the CLI's machine-readable output). CostUSD is 0 when // the provider doesn't report a cost (e.g. Codex). type TriggerResult struct { - Command string - HasUsage bool - InputTokens int - OutputTokens int - TotalTokens int - CostUSD float64 + Command string + HasUsage bool + InputTokens int + OutputTokens int + TotalTokens int + CostUSD float64 + Verification *usage.Verification + StatusEnabled bool +} + +// VerifiedTrigger is implemented only by Codex-backed providers. Automatic +// requests require fresh, bucket-specific start evidence before sending. +type VerifiedTrigger interface { + TriggerAutomatic(context.Context, float64) (*TriggerResult, error) } // UsageHTTPError preserves usage endpoint HTTP failures so callers can make diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go new file mode 100644 index 0000000..41261d4 --- /dev/null +++ b/internal/scheduler/codex.go @@ -0,0 +1,124 @@ +package scheduler + +import ( + "context" + "errors" + "time" + + "github.com/wavever/CCLimitPing/internal/codexstate" + "github.com/wavever/CCLimitPing/internal/provider" +) + +// runVerifiedTarget is shared by Codex and Spark, never by Claude. A successful +// transport does not advance a quota schedule without observation evidence. +func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider.VerifiedTrigger) { + name := t.Provider.Name() + backoff := minBackoff + aligned := t.AlignStart.IsZero() + wait := func(reason string, d time.Duration) bool { + if d <= 0 { + d = time.Second + } + s.live.set(name, reason, time.Now().Add(d)) + return sleepCtx(ctx, d) + } + for ctx.Err() == nil { + s.live.set(name, "checking quota…", time.Time{}) + rctx, cancel := context.WithTimeout(ctx, readTimeout) + u, err := t.Provider.ReadUsage(rctx) + cancel() + if err != nil { + d := backoff + var httpErr *provider.UsageHTTPError + if errors.As(err, &httpErr) && !httpErr.RetryAfter.IsZero() { + d = usageRateLimitWait(httpErr.RetryAfter, time.Now()) + } else if errors.As(err, &httpErr) && httpErr.StatusCode == 429 { + d = rateLimitPause + } + s.log.Printf("[%s] quota read failed: %v (retry in %s)", name, err, d) + if !wait("quota read failed", d) { + return + } + backoff = nextBackoff(backoff) + continue + } + backoff = minBackoff + if s.redeemExpiringCredit(ctx, t, u) { + continue + } + if s.weeklyExhausted(u) { + d := u.Weekly.Remaining() + s.cfg.ResetBuffer.Duration + if d > 5*time.Minute { + d = 5 * time.Minute + } + if !wait("weekly limit reached", d) { + return + } + continue + } + v := u.Verification + if v == nil || v.Warning != "" { + if v != nil { + s.log.Printf("[%s] %s; automatic ping deferred", name, v.Warning) + } + if !wait("quota state unavailable", codexstate.Interval) { + return + } + continue + } + if !aligned { + aligned = true + if d := time.Until(t.AlignStart); d > 0 { + if !wait("waiting for align_start", d) { + return + } + continue // re-read after alignment; user activity may have started it + } + } + if v.Recovery == "window_started" { + d := time.Until(v.NextEligible) + s.cfg.ResetBuffer.Duration + if d > 5*time.Minute { + d = 5 * time.Minute + } + if !wait(v.Target+" started", d) { + return + } + continue + } + if v.Recovery != "ready" || v.NextEligible.After(time.Now()) { + d := time.Until(v.NextEligible) + if d <= 0 || d > codexstate.Interval { + d = codexstate.Interval + } + if !wait(v.Target+" "+v.Recovery, d) { + return + } + continue + } + if desc, active, err := activeProviderTask(ctx, t.Provider); err != nil || active { + if !wait(desc+" active or activity unavailable", activeTaskPoll) { + return + } + continue + } + s.live.set(name, "checking and sending ping…", time.Time{}) + res, err := p.TriggerAutomatic(ctx, s.cfg.WeeklyThreshold) + if errors.Is(err, codexstate.ErrBusy) || errors.Is(err, codexstate.ErrDeferred) { + if !wait("ping deferred", codexstate.Interval) { + return + } + continue + } + if err != nil { + s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) + } else { + s.log.Printf("[%s] ping request completed; checking window%s", name, triggerCost(res)) + } + if res != nil && res.Verification != nil && res.Verification.Warning != "" { + s.log.Printf("[%s] %s", name, res.Verification.Warning) + } + if !wait("verifying quota", codexstate.Interval) { + return + } + } +} diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go new file mode 100644 index 0000000..22262fa --- /dev/null +++ b/internal/scheduler/codex_test.go @@ -0,0 +1,50 @@ +package scheduler + +import ( + "context" + "io" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/usage" +) + +type verifiedStub struct{ stubProvider } + +func (p *verifiedStub) TriggerAutomatic(ctx context.Context, _ float64) (*provider.TriggerResult, error) { + return p.Trigger(ctx, false) +} + +func TestVerifiedSchedulerGates(t *testing.T) { + for _, tc := range []struct { + name, recovery string + warn, active bool + weekly float64 + want int + }{ + {"unknown", "verifying", false, false, 0, 0}, + {"ready", "ready", false, false, 0, 1}, + {"cooldown", "cooldown", false, false, 0, 0}, + {"storage-error", "ready", true, false, 0, 0}, + {"active-user", "ready", false, true, 0, 0}, + {"weekly-guard", "ready", false, false, 100, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + v := &usage.Verification{Target: "five_hour", Recovery: tc.recovery} + if tc.warn { + v.Warning = "cannot write state" + } + p := &verifiedStub{stubProvider: stubProvider{active: tc.active, usage: &usage.Usage{ + Weekly: usage.Window{UsedPercent: tc.weekly, ResetsAt: time.Now().Add(time.Hour)}, Verification: v}}} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + s := New(testConfig(), []Target{{Provider: p}}, false, false, io.Discard) + s.Run(ctx) + _, n := p.counts() + if n != tc.want { + t.Fatalf("triggers=%d", n) + } + }) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 6b6de14..63f7258 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -100,6 +100,10 @@ func (s *Scheduler) Run(ctx context.Context) { } func (s *Scheduler) runTarget(ctx context.Context, t Target) { + if p, ok := t.Provider.(provider.VerifiedTrigger); ok && !s.dryRun { + s.runVerifiedTarget(ctx, t, p) + return + } name := t.Provider.Name() backoff := minBackoff aligned := t.AlignStart.IsZero() // whether the align gate has been passed @@ -112,6 +116,9 @@ func (s *Scheduler) runTarget(ctx context.Context, t Target) { s.live.set(name, "checking usage…", time.Time{}) rctx, cancel := context.WithTimeout(ctx, readTimeout) + if s.dryRun { + rctx = provider.WithoutQuotaState(rctx) + } u, err := t.Provider.ReadUsage(rctx) cancel() if err != nil { diff --git a/internal/usage/usage.go b/internal/usage/usage.go index aafd8f2..693cb0b 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -80,7 +80,23 @@ type Usage struct { ResetCredits *ResetCredits LimitReached bool FetchedAt time.Time - Raw []byte // raw JSON body, for `status -v` + Raw []byte // raw JSON body, for `status -v` + Verification *Verification // Codex-backed providers only; not a shared window predicate +} + +// Verification separates a completed CLI request from an observed quota window. +type Verification struct { + FiveHour StartStatus + Weekly StartStatus + Target string + Recovery string + NextEligible time.Time + Warning string +} + +type StartStatus struct { + State string + DueAt time.Time } // Reset-credit auto-redeem policy. A credit that lapses unused is worth From 1c4efa054a4de498b750c28c67f861931af70781 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 11:01:16 +0900 Subject: [PATCH 04/24] Harden verification anchors and account-bound automatic recovery --- docs/window-verification.md | 6 +- internal/codexstate/state.go | 2 +- internal/codexstate/state_test.go | 63 ++++++++++++++++++++ internal/provider/codex.go | 24 +++++--- internal/provider/codex_test.go | 2 +- internal/provider/codex_verification.go | 4 ++ internal/provider/codex_verification_test.go | 40 +++++++++++++ internal/scheduler/codex.go | 15 +++++ internal/usage/usage.go | 1 + 9 files changed, 144 insertions(+), 13 deletions(-) diff --git a/docs/window-verification.md b/docs/window-verification.md index 755d852..d447e72 100644 --- a/docs/window-verification.md +++ b/docs/window-verification.md @@ -87,8 +87,10 @@ responses, prompts or model output. New directories/files use private permission where supported. Same-directory replacement and short OS-backed file locks protect updates; locks are not held across network or model requests. -A live per-bucket attempt claim makes a competing manual ping return promptly -with an actionable error. Pending verification alone does not block manual use. +A live per-bucket attempt claim or a contended short state transaction makes a +competing manual ping return promptly with actionable retry advice. Contention +does not bypass duplicate protection: another process may be reserving a send. +Pending verification alone does not block manual use. An expired claim requires new observation evidence before automatic retry. Missing state starts with unknown evidence. Corrupt/incompatible state or an unwritable directory disables automatic sends; explicit manual pings can proceed diff --git a/internal/codexstate/state.go b/internal/codexstate/state.go index eb66b5e..149d3a3 100644 --- a/internal/codexstate/state.go +++ b/internal/codexstate/state.go @@ -102,7 +102,7 @@ func observe(w *window, s Sample, duringAttempt bool) usage.StartStatus { state = NotStarted } } - if state == Started { + if state == Started && w.ConfirmedReset.IsZero() { w.ConfirmedReset = s.Reset } // During a live claim do not collect evidence that could authorize its retry. diff --git a/internal/codexstate/state_test.go b/internal/codexstate/state_test.go index 25d6156..a49f4f0 100644 --- a/internal/codexstate/state_test.go +++ b/internal/codexstate/state_test.go @@ -178,6 +178,19 @@ func TestImmediatePreSendReadRetainsSlidingEvidence(t *testing.T) { } } +func TestJitterCannotAccumulateIntoSlidingReset(t *testing.T) { + w := &window{} + reset := epoch.Add(5 * time.Hour) + observe(w, Sample{At: epoch, Reset: reset, Seconds: 18000}, false) + observe(w, Sample{At: epoch.Add(time.Minute), Reset: reset, Seconds: 18000}, false) + for i := 1; i <= 10; i++ { + v := observe(w, Sample{At: epoch.Add(time.Minute + time.Duration(i)*time.Second), Reset: reset.Add(time.Duration(i) * time.Second), Seconds: 18000}, false) + if i > 5 && v.State == Started { + t.Fatal("jitter accumulated beyond original anchor", v) + } + } +} + func TestBudgetAndCrashRecovery(t *testing.T) { s := Store{Dir: t.TempDir()} now := epoch @@ -258,3 +271,53 @@ func TestLockProcess(t *testing.T) { } unlock() } + +func TestLockReleasedAfterProcessCrash(t *testing.T) { + if path := os.Getenv("LIMITPING_TEST_CRASH_LOCK"); path != "" { + _, err := lock(path) + if err != nil { + os.Exit(2) + } + if err := os.WriteFile(path+".ready", []byte("ready"), 0600); err != nil { + os.Exit(3) + } + select {} + } + path := filepath.Join(t.TempDir(), "crash.lock") + cmd := exec.Command(os.Args[0], "-test.run=^TestLockReleasedAfterProcessCrash$") + cmd.Env = append(os.Environ(), "LIMITPING_TEST_CRASH_LOCK="+path) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(path + ".ready"); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("child did not acquire lock") + } + time.Sleep(10 * time.Millisecond) + } + _ = cmd.Process.Kill() + _ = cmd.Wait() + unlock, err := lock(path) + if err != nil { + t.Fatal(err) + } + unlock() +} + +func TestAutomaticBudgetsAreBucketSpecific(t *testing.T) { + s := Store{Dir: t.TempDir()} + if err := s.update("account", "codex", func(b *bucket) error { b.Attempts = []time.Time{epoch, epoch, epoch, epoch}; return nil }); err != nil { + t.Fatal(err) + } + read(t, s, "spark:model", quota(epoch, epoch.Add(5*time.Hour), 0, false)) + now := epoch.Add(time.Minute) + read(t, s, "spark:model", quota(now, now.Add(5*time.Hour), 0, false)) + if _, err := s.Begin("account", "spark:model", true, now, now); err != nil { + t.Fatal("Codex budget blocked Spark", err) + } +} diff --git a/internal/provider/codex.go b/internal/provider/codex.go index e2a675c..6417fc3 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -49,8 +49,7 @@ const ( // via the interactive, TTY-backed Codex CLI. Headless `codex exec` can consume // tokens without anchoring the subscription-backed Codex window. type Codex struct { - cfg config.ProviderConfig - auth *auth.CodexAuth + cfg config.ProviderConfig redeemMu sync.Mutex lastRedeem time.Time // last automatic redemption attempt, for the cooldown @@ -58,8 +57,7 @@ type Codex struct { func NewCodex(cfg config.ProviderConfig) *Codex { return &Codex{ - cfg: cfg, - auth: auth.NewCodexAuth(), + cfg: cfg, } } @@ -97,6 +95,9 @@ func (c *Codex) AutoRedeemResetCredit(ctx context.Context, u *usage.Usage) (stri if !ok { return "", nil } + if u.QuotaAccount == "" { + return "", fmt.Errorf("automatic reset credit requires an identified quota observation") + } c.redeemMu.Lock() if time.Since(c.lastRedeem) < codexRedeemCooldown { c.redeemMu.Unlock() @@ -104,7 +105,7 @@ func (c *Codex) AutoRedeemResetCredit(ctx context.Context, u *usage.Usage) (stri } c.lastRedeem = time.Now() c.redeemMu.Unlock() - return c.consumeResetCredit(ctx, creditIdempotencyKey(credit)) + return c.consumeResetCreditFor(ctx, creditIdempotencyKey(credit), u.QuotaAccount) } // consumeResetCredit redeems one banked reset credit. The credit id is @@ -112,6 +113,10 @@ func (c *Codex) AutoRedeemResetCredit(ctx context.Context, u *usage.Usage) (stri // same one the policy targets — so we don't depend on an id field this private // endpoint doesn't document. func (c *Codex) consumeResetCredit(ctx context.Context, idempotencyKey string) (string, error) { + return c.consumeResetCreditFor(ctx, idempotencyKey, "") +} + +func (c *Codex) consumeResetCreditFor(ctx context.Context, idempotencyKey, expectedAccount string) (string, error) { payload, err := json.Marshal(map[string]string{"idempotency_key": idempotencyKey}) if err != nil { return "", err @@ -120,6 +125,9 @@ func (c *Codex) consumeResetCredit(ctx context.Context, idempotencyKey string) ( accountID := "" body, err := fetchWithAuth(ctx, a, func(token string) (*http.Request, error) { accountID, _ = a.AccountID(ctx) + if expectedAccount != "" && accountID != expectedAccount { + return nil, fmt.Errorf("Codex account changed; automatic reset credit cancelled") + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexConsumeURL(), bytes.NewReader(payload)) if err != nil { return nil, err @@ -191,8 +199,7 @@ func creditIdempotencyKey(c usage.ResetCredit) string { // Spark is a separate provider backed by Codex auth and CLI transport. // Its usage window is the Spark-specific entry inside the Codex usage payload. type Spark struct { - cfg config.ProviderConfig - auth *auth.CodexAuth + cfg config.ProviderConfig } // NewSpark returns the Spark provider. It shares Codex credentials and the @@ -202,8 +209,7 @@ func NewSpark(cfg config.ProviderConfig) *Spark { cfg.Model = sparkDefaultModel } return &Spark{ - cfg: cfg, - auth: auth.NewCodexAuth(), + cfg: cfg, } } diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index 885fee9..4743e4c 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -477,7 +477,7 @@ func TestCodexAutoRedeemSkipsUntilExpiryAndThenThrottles(t *testing.T) { t.Fatalf("requests = %d, want 0", requests) } - expiring := &usage.Usage{ResetCredits: &usage.ResetCredits{Credits: []usage.ResetCredit{ + expiring := &usage.Usage{QuotaAccount: "account-123", ResetCredits: &usage.ResetCredits{Credits: []usage.ResetCredit{ {Status: "available", ExpiresAt: time.Now().Add(30 * time.Minute)}, }}} if outcome, err := c.AutoRedeemResetCredit(context.Background(), expiring); outcome != RedeemNothingToReset || err != nil { diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go index 5a7828b..d0c5fc8 100644 --- a/internal/provider/codex_verification.go +++ b/internal/provider/codex_verification.go @@ -80,6 +80,7 @@ func readVerifiedUsage(ctx context.Context, name string, cfg config.ProviderConf } } u := codexUsageToUsage(name, body, r, rl) + u.QuotaAccount = account if r.ResetCredits != nil { u.ResetCredits = &usage.ResetCredits{AvailableCount: r.ResetCredits.AvailableCount} } @@ -125,6 +126,9 @@ func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, d warning = pre.Verification.Warning } if automatic && (readErr != nil || warning != "") { + if readErr != nil { + return nil, fmt.Errorf("automatic pre-ping check unavailable: %w", readErr) + } return nil, fmt.Errorf("automatic pre-ping check unavailable: %s", warning) } identity, identityErr := currentCodexAccount(ctx) diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index 3ab36ba..51f1d9d 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -14,6 +14,7 @@ import ( "github.com/wavever/CCLimitPing/internal/codexstate" "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/usage" ) func fakeCodexCLI(t *testing.T, script string) { @@ -152,3 +153,42 @@ func TestDryRunNeverReadsOrWritesState(t *testing.T) { t.Fatal(entries) } } + +func TestAutoRedeemRejectsChangedObservationAccount(t *testing.T) { + fakeCodexHome(t) + requests := 0 + useTransport(t, func(req *http.Request) (*http.Response, error) { + requests++ + t.Fatal("must not spend another account's credit") + return nil, nil + }) + u := &usage.Usage{QuotaAccount: "previous-account", ResetCredits: &usage.ResetCredits{Credits: []usage.ResetCredit{ + {Status: "available", ExpiresAt: time.Now().Add(30 * time.Minute)}, + }}} + _, err := NewCodex(config.ProviderConfig{}).AutoRedeemResetCredit(context.Background(), u) + if err == nil || requests != 0 { + t.Fatal(err, requests) + } +} + +func TestAutoRedeemRechecksIdentityOnAuthenticationRetry(t *testing.T) { + fakeCodexHome(t) + requests := 0 + useTransport(t, func(req *http.Request) (*http.Response, error) { + requests++ + if requests > 1 { + t.Fatal("retried redemption against changed account") + } + if err := os.WriteFile(filepath.Join(os.Getenv("CODEX_HOME"), "auth.json"), []byte(`{"tokens":{"access_token":"new-token","account_id":"new-account"}}`), 0600); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil + }) + u := &usage.Usage{QuotaAccount: "account-123", ResetCredits: &usage.ResetCredits{Credits: []usage.ResetCredit{ + {Status: "available", ExpiresAt: time.Now().Add(30 * time.Minute)}, + }}} + _, err := NewCodex(config.ProviderConfig{}).AutoRedeemResetCredit(context.Background(), u) + if err == nil || requests != 1 { + t.Fatal(err, requests) + } +} diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 41261d4..5957ea9 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -109,10 +109,25 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } continue } + if err != nil && res == nil { + // No trigger occurred: do not manufacture a failed ping-history entry. + d := minBackoff + var httpErr *provider.UsageHTTPError + if errors.As(err, &httpErr) && (!httpErr.RetryAfter.IsZero() || httpErr.StatusCode == 429) { + d = usageRateLimitWait(httpErr.RetryAfter, time.Now()) + } + s.log.Printf("[%s] pre-ping check failed: %v; observing again in %s", name, err, d) + if !wait("pre-ping check unavailable", d) { + return + } + continue + } if err != nil { s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) + s.notify(name+": ping failed", "Verifying quota before another attempt") } else { s.log.Printf("[%s] ping request completed; checking window%s", name, triggerCost(res)) + s.notify(name+": request completed", "Quota window verification is separate from request completion") } if res != nil && res.Verification != nil && res.Verification.Warning != "" { s.log.Printf("[%s] %s", name, res.Verification.Warning) diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 693cb0b..f94c0ad 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -82,6 +82,7 @@ type Usage struct { FetchedAt time.Time Raw []byte // raw JSON body, for `status -v` Verification *Verification // Codex-backed providers only; not a shared window predicate + QuotaAccount string // identity of the quota request; never part of status JSON } // Verification separates a completed CLI request from an observed quota window. From e9d6ebeaac6d5b100bd975743652daa9b8f1eae0 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 11:02:53 +0900 Subject: [PATCH 05/24] Keep lock-crash fixture alive until termination --- internal/codexstate/state_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/codexstate/state_test.go b/internal/codexstate/state_test.go index a49f4f0..8dfe13e 100644 --- a/internal/codexstate/state_test.go +++ b/internal/codexstate/state_test.go @@ -281,7 +281,9 @@ func TestLockReleasedAfterProcessCrash(t *testing.T) { if err := os.WriteFile(path+".ready", []byte("ready"), 0600); err != nil { os.Exit(3) } - select {} + for { + time.Sleep(time.Hour) + } } path := filepath.Join(t.TempDir(), "crash.lock") cmd := exec.Command(os.Args[0], "-test.run=^TestLockReleasedAfterProcessCrash$") From 1e44e0a0ba74be1fa58670ab154dd5f6ae22ac6d Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 13:30:03 +0900 Subject: [PATCH 06/24] Honor reset buffer against known quota rollover boundaries --- docs/window-verification.md | 8 +++- internal/codexstate/state.go | 22 +++++++-- internal/codexstate/state_test.go | 62 +++++++++++++++++++++++++ internal/provider/codex.go | 12 ++--- internal/provider/codex_verification.go | 3 +- internal/provider/provider.go | 2 +- internal/scheduler/codex.go | 18 +++++-- internal/scheduler/codex_test.go | 30 +++++++++++- internal/usage/usage.go | 13 +++--- 9 files changed, 147 insertions(+), 23 deletions(-) diff --git a/docs/window-verification.md b/docs/window-verification.md index d447e72..3da27f6 100644 --- a/docs/window-verification.md +++ b/docs/window-verification.md @@ -65,7 +65,13 @@ of an unstarted five-hour window. One ping observes both windows in its own bucket, never the other provider's bucket. Automatic sends require fresh not-started evidence, usable state storage and -the existing alignment, activity and weekly/credit guards. After a send, +the existing alignment, activity and weekly/credit guards. `reset_buffer` +(default 10 seconds) is a watcher safety margin after a known previous window's +reset boundary, not an extra delay after verification. The boundary is persisted +per window; observation time counts toward the buffer. For example, a ten-minute +buffer leaves nine minutes after a one-minute verification. Polling never moves +that deadline. When the previous reset boundary is unknown, no buffer delay is +added; explicit manual pings also bypass it. After a send, observation failure causes further reads, not immediate model retries. Confirmed failure permits retries after at least 1, 5 and then 15 minutes. At most four automatic attempts are allowed in a rolling hour per account and bucket. diff --git a/internal/codexstate/state.go b/internal/codexstate/state.go index 149d3a3..4e5de31 100644 --- a/internal/codexstate/state.go +++ b/internal/codexstate/state.go @@ -40,6 +40,7 @@ type window struct { State string // ConfirmedReset is independent of the post-attempt failure baseline. ConfirmedReset time.Time + PreviousReset time.Time } type bucket struct { @@ -60,7 +61,10 @@ type diskState struct { Buckets map[string]*bucket } -type Store struct{ Dir string } +type Store struct { + Dir string + ResetBuffer time.Duration // automatic sends only; never shifts an observation +} func sample(w usage.Window, now time.Time) Sample { return Sample{At: now, Reset: w.ResetsAt, Seconds: w.WindowSeconds, Used: w.UsedPercent} @@ -73,14 +77,20 @@ func valid(s Sample) bool { } func observe(w *window, s Sample, duringAttempt bool) usage.StartStatus { + previousReset := w.PreviousReset + if w.Latest.Seconds != s.Seconds || s.At.Before(w.Latest.At) { + previousReset = time.Time{} + } else if !w.ConfirmedReset.IsZero() && !w.ConfirmedReset.After(s.At) { + previousReset = w.ConfirmedReset + } if !valid(s) { - *w = window{} + *w = window{PreviousReset: previousReset, Latest: s} return usage.StartStatus{State: Unknown} } old := w.Baseline if w.Latest.Seconds != s.Seconds || s.At.Before(w.Latest.At) || (!w.ConfirmedReset.IsZero() && (!w.ConfirmedReset.After(s.At) || !near(w.ConfirmedReset, s.Reset))) { - *w = window{} + *w = window{PreviousReset: previousReset} old = Sample{} } state := Unknown @@ -145,6 +155,9 @@ func view(b *bucket, now time.Time) *usage.Verification { v := &usage.Verification{FiveHour: status(b.FiveHour, now), Weekly: status(b.Weekly, now)} name, w := target(b) v.Target = name + if w != nil { + v.PreviousReset = w.PreviousReset + } switch { case b.ClaimID != "" && b.ClaimUntil.After(now): v.Recovery, v.NextEligible = "ping_running", b.ClaimUntil @@ -250,7 +263,8 @@ func (s Store) Begin(account, key string, automatic bool, observedAt, now time.T v := view(b, now) _, w := target(b) if b.ClaimID != "" || !b.LastRead.Equal(observedAt) || now.Sub(observedAt) > 3*time.Second || - w == nil || w.State != NotStarted || v.NextEligible.After(now) { + w == nil || w.State != NotStarted || v.NextEligible.After(now) || + (!v.PreviousReset.IsZero() && v.PreviousReset.Add(s.ResetBuffer).After(now)) { return ErrDeferred } b.Attempts = append(b.Attempts, now) diff --git a/internal/codexstate/state_test.go b/internal/codexstate/state_test.go index 8dfe13e..f2ec954 100644 --- a/internal/codexstate/state_test.go +++ b/internal/codexstate/state_test.go @@ -2,6 +2,7 @@ package codexstate import ( "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -323,3 +324,64 @@ func TestAutomaticBudgetsAreBucketSpecific(t *testing.T) { t.Fatal("Codex budget blocked Spark", err) } } + +func TestResetBufferUsesPersistedPreviousBoundary(t *testing.T) { + for _, weekly := range []bool{false, true} { + for _, tc := range []struct { + name string + buffer time.Duration + known, manual bool + blocked bool + }{ + {"ten-minute-buffer", 10 * time.Minute, true, false, true}, + {"verification-covers-buffer", 35 * time.Second, true, false, false}, + {"unknown-reset", 10 * time.Minute, false, false, false}, + {"manual-bypass", 10 * time.Minute, true, true, false}, + } { + t.Run(fmt.Sprintf("%s/weekly=%t", tc.name, weekly), func(t *testing.T) { + dir := t.TempDir() + s := Store{Dir: dir, ResetBuffer: tc.buffer} + length := 5 * time.Hour + if weekly { + length = 7 * 24 * time.Hour + } + reset := epoch.Add(time.Minute) + if tc.known { + read(t, s, "codex", quota(epoch, reset, 1, weekly)) + // The expired snapshot itself must not erase the known boundary. + read(t, s, "codex", quota(reset, reset, 0, weekly)) + } + for _, d := range []time.Duration{time.Second, 61 * time.Second} { + now := reset.Add(d) + v := read(t, Store{Dir: dir}, "codex", quota(now, now.Add(length), 0, weekly)) + if tc.known && !v.PreviousReset.Equal(reset) { + t.Fatalf("boundary lost on restart: %v", v.PreviousReset) + } + if !tc.known && !v.PreviousReset.IsZero() { + t.Fatal("invented boundary") + } + } + now := reset.Add(61 * time.Second) + _, err := s.Begin("account", "codex", !tc.manual, now, now) + if tc.blocked { + if !errors.Is(err, ErrDeferred) { + t.Fatalf("early send: %v", err) + } + // Repeated moving resets do not move the buffer deadline. + for d := 2 * time.Minute; d <= 10*time.Minute; d += time.Minute { + now = reset.Add(d) + v := read(t, Store{Dir: dir}, "codex", quota(now, now.Add(length), 0, weekly)) + if !v.PreviousReset.Equal(reset) { + t.Fatal(v.PreviousReset) + } + } + if _, err := s.Begin("account", "codex", true, now, now); err != nil { + t.Fatalf("buffer counted twice: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + }) + } + } +} diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 6417fc3..6cc2b5b 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -73,11 +73,11 @@ func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { } func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return pingVerified(ctx, c.Name(), c.cfg, dryRun, false, 0) + return pingVerified(ctx, c.Name(), c.cfg, dryRun, false, 0, 0) } -func (c *Codex) TriggerAutomatic(ctx context.Context, threshold float64) (*TriggerResult, error) { - return pingVerified(ctx, c.Name(), c.cfg, false, true, threshold) +func (c *Codex) TriggerAutomatic(ctx context.Context, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { + return pingVerified(ctx, c.Name(), c.cfg, false, true, threshold, resetBuffer) } // RedeemResetCredit spends the next available reset credit right now. Each call @@ -225,11 +225,11 @@ func (s *Spark) ReadUsage(ctx context.Context) (*usage.Usage, error) { } func (s *Spark) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return pingVerified(ctx, s.Name(), s.cfg, dryRun, false, 0) + return pingVerified(ctx, s.Name(), s.cfg, dryRun, false, 0, 0) } -func (s *Spark) TriggerAutomatic(ctx context.Context, threshold float64) (*TriggerResult, error) { - return pingVerified(ctx, s.Name(), s.cfg, false, true, threshold) +func (s *Spark) TriggerAutomatic(ctx context.Context, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { + return pingVerified(ctx, s.Name(), s.cfg, false, true, threshold, resetBuffer) } func codexActiveTask(_ context.Context) (string, bool, error) { diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go index d0c5fc8..59ff656 100644 --- a/internal/provider/codex_verification.go +++ b/internal/provider/codex_verification.go @@ -109,7 +109,7 @@ func boundedQuota(ctx context.Context, name string, cfg config.ProviderConfig) ( return readVerifiedUsage(readCtx, name, cfg, false) } -func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, dry, automatic bool, threshold float64) (*TriggerResult, error) { +func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, dry, automatic bool, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { if dry { return triggerCodex(ctx, cfg, true) } @@ -151,6 +151,7 @@ func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, d } } store, storeErr := quotaStore() + store.ResetBuffer = resetBuffer key := quotaBucket(name, cfg) claim := "" if storeErr == nil && account != "" { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 750232c..1ba503e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -100,7 +100,7 @@ type TriggerResult struct { // VerifiedTrigger is implemented only by Codex-backed providers. Automatic // requests require fresh, bucket-specific start evidence before sending. type VerifiedTrigger interface { - TriggerAutomatic(context.Context, float64) (*TriggerResult, error) + TriggerAutomatic(context.Context, float64, time.Duration) (*TriggerResult, error) } // UsageHTTPError preserves usage endpoint HTTP failures so callers can make diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 5957ea9..ffaf702 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -47,7 +47,7 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. continue } if s.weeklyExhausted(u) { - d := u.Weekly.Remaining() + s.cfg.ResetBuffer.Duration + d := u.Weekly.Remaining() if d > 5*time.Minute { d = 5 * time.Minute } @@ -76,7 +76,8 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } } if v.Recovery == "window_started" { - d := time.Until(v.NextEligible) + s.cfg.ResetBuffer.Duration + // Observe at rollover; the verification interval counts toward the buffer. + d := time.Until(v.NextEligible) if d > 5*time.Minute { d = 5 * time.Minute } @@ -95,6 +96,17 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } continue } + if !v.PreviousReset.IsZero() { + if d := time.Until(v.PreviousReset.Add(s.cfg.ResetBuffer.Duration)); d > 0 { + if d > 5*time.Minute { + d = 5 * time.Minute + } + if !wait("waiting for reset_buffer", d) { + return + } + continue // polling and verification count toward the same fixed deadline + } + } if desc, active, err := activeProviderTask(ctx, t.Provider); err != nil || active { if !wait(desc+" active or activity unavailable", activeTaskPoll) { return @@ -102,7 +114,7 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. continue } s.live.set(name, "checking and sending ping…", time.Time{}) - res, err := p.TriggerAutomatic(ctx, s.cfg.WeeklyThreshold) + res, err := p.TriggerAutomatic(ctx, s.cfg.WeeklyThreshold, s.cfg.ResetBuffer.Duration) if errors.Is(err, codexstate.ErrBusy) || errors.Is(err, codexstate.ErrDeferred) { if !wait("ping deferred", codexstate.Interval) { return diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go index 22262fa..162438d 100644 --- a/internal/scheduler/codex_test.go +++ b/internal/scheduler/codex_test.go @@ -12,7 +12,7 @@ import ( type verifiedStub struct{ stubProvider } -func (p *verifiedStub) TriggerAutomatic(ctx context.Context, _ float64) (*provider.TriggerResult, error) { +func (p *verifiedStub) TriggerAutomatic(ctx context.Context, _ float64, _ time.Duration) (*provider.TriggerResult, error) { return p.Trigger(ctx, false) } @@ -48,3 +48,31 @@ func TestVerifiedSchedulerGates(t *testing.T) { }) } } + +func TestVerifiedSchedulerHonorsResetBuffer(t *testing.T) { + for _, tc := range []struct { + name string + previousReset time.Time + buffer time.Duration + want int + }{ + {"known-boundary", time.Now().Add(-time.Minute), 10 * time.Minute, 0}, + {"short-buffer", time.Now().Add(-time.Minute), 35 * time.Second, 1}, + {"unknown-boundary", time.Time{}, 10 * time.Minute, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + p := &verifiedStub{stubProvider: stubProvider{usage: &usage.Usage{Verification: &usage.Verification{ + Target: "weekly", Recovery: "ready", PreviousReset: tc.previousReset, + }}}} + cfg := testConfig() + cfg.ResetBuffer.Duration = tc.buffer + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + New(cfg, []Target{{Provider: p}}, false, false, io.Discard).Run(ctx) + _, n := p.counts() + if n != tc.want { + t.Fatalf("triggers=%d want %d", n, tc.want) + } + }) + } +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go index f94c0ad..0150859 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -87,12 +87,13 @@ type Usage struct { // Verification separates a completed CLI request from an observed quota window. type Verification struct { - FiveHour StartStatus - Weekly StartStatus - Target string - Recovery string - NextEligible time.Time - Warning string + FiveHour StartStatus + Weekly StartStatus + Target string + Recovery string + NextEligible time.Time + PreviousReset time.Time // known reset boundary of the target's previous window + Warning string } type StartStatus struct { From 3fc6a8bc8cacd4822843b2fdd90450bdedc230c1 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 13:50:23 +0900 Subject: [PATCH 07/24] Separate quota verification from TUI completion handling --- docs/window-verification.md | 14 +-- internal/cli/bg.go | 2 + internal/cli/i18n.go | 37 ++++---- internal/cli/ping.go | 6 +- internal/cli/verification_test.go | 12 +++ internal/provider/codex.go | 93 +++++++------------- internal/provider/codex_test.go | 4 +- internal/provider/codex_verification_test.go | 30 +------ internal/scheduler/codex.go | 4 +- 9 files changed, 86 insertions(+), 116 deletions(-) diff --git a/docs/window-verification.md b/docs/window-verification.md index 3da27f6..96344b5 100644 --- a/docs/window-verification.md +++ b/docs/window-verification.md @@ -13,10 +13,12 @@ The command does not wait one minute or start a detached verification process. `ping all` continues to the next provider after the ordinary trigger and these bounded reads. Explicit `schedule` commands use the same ping behavior. -CLI completion and window start are separate results. A turn-completion OSC 9 -notification confirms completion; a timeout, nonzero exit or clean exit without -that notification is an error. A confirmed completed turn returns exit zero even -if the API or state file is unavailable or the window is unconfirmed. A failed +The existing CLI trigger result and window start are separate results. This +feature does not change the TUI transport or interpret its completion markers. +A trigger returning without error is reported as such, not as verified turn +completion or proof that a window started. It retains exit zero even if the API +or state file is unavailable or the window is unconfirmed. Trigger errors retain +their existing nonzero exit behavior. A failed pre-read does not prevent an explicit manual ping. Cancellation returns promptly without requiring a post-read. @@ -111,5 +113,5 @@ uses the Linux PTY implementation. No Windows PTY replacement is part of this ch `ping --dry-run` neither fetches quota nor writes state. Watch dry-run retains its existing quota reads but does not persist verification or attempt state. -Offline tests cover observations, claims, budgets, CLI output and fake PTY -completion; no real quota pings are necessary for these tests. +Offline tests cover observations, claims, budgets, CLI output and a fake CLI +trigger; no real quota pings are necessary for these tests. diff --git a/internal/cli/bg.go b/internal/cli/bg.go index e68f1c0..0614a90 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -388,6 +388,8 @@ func parseBgPingAttempt(line string) (bgPingAttempt, bool) { status = bgPingSucceeded case strings.Contains(msg, "ping request completed; checking window"): status = bgPingSucceeded + case strings.Contains(msg, "ping trigger returned; checking window"): + status = bgPingSucceeded default: return bgPingAttempt{}, false } diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index 6ecd3be..fdc083b 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -63,13 +63,14 @@ type cliText struct { statusNowWord string statusWeekdays [7]string // Sunday first; zero value = Go's "Mon" names - pingShort string - pingLong string - pingDryRunFlag string - pingWouldRunFmt string // provider, command - pingSendingFmt string // provider, spinner frame, elapsed - pingFailedFmt string // provider, elapsed, error - pingSuccessFmt string // provider, elapsed, usage suffix + pingShort string + pingLong string + pingDryRunFlag string + pingWouldRunFmt string // provider, command + pingSendingFmt string // provider, spinner frame, elapsed + pingFailedFmt string // provider, elapsed, error + pingSuccessFmt string // provider, elapsed, usage suffix + pingTriggerReturnedFmt string watchShort string watchLong string @@ -286,11 +287,12 @@ Examples: limitping ping limitping p claude limitping ping codex --dry-run`, - pingDryRunFlag: "print the command without sending", - pingWouldRunFmt: "%-7s would run: %s\n", - pingSendingFmt: "\r%-7s %c sending… %s", - pingFailedFmt: "%-7s ✗ failed after %s: %v\n", - pingSuccessFmt: "%-7s ✓ pinged (%s%s)\n", + pingDryRunFlag: "print the command without sending", + pingWouldRunFmt: "%-7s would run: %s\n", + pingSendingFmt: "\r%-7s %c sending… %s", + pingFailedFmt: "%-7s ✗ failed after %s: %v\n", + pingSuccessFmt: "%-7s ✓ pinged (%s%s)\n", + pingTriggerReturnedFmt: "%-7s CLI trigger returned without error (%s%s); turn completion is not verified\n", watchShort: "Run the foreground daemon and ping each provider when its 5h window resets", watchLong: `Run the foreground daemon. When a provider's 5h window resets, limitping sends the minimal message to start the next window. @@ -553,11 +555,12 @@ var zhText = cliText{ limitping ping limitping p claude limitping ping codex --dry-run`, - pingDryRunFlag: "只打印将执行的命令,不真正发送", - pingWouldRunFmt: "%-7s 将执行: %s\n", - pingSendingFmt: "\r%-7s %c 发送中… %s", - pingFailedFmt: "%-7s ✗ 失败 (耗时 %s): %v\n", - pingSuccessFmt: "%-7s ✓ 已 ping (%s%s)\n", + pingDryRunFlag: "只打印将执行的命令,不真正发送", + pingWouldRunFmt: "%-7s 将执行: %s\n", + pingSendingFmt: "\r%-7s %c 发送中… %s", + pingFailedFmt: "%-7s ✗ 失败 (耗时 %s): %v\n", + pingSuccessFmt: "%-7s ✓ 已 ping (%s%s)\n", + pingTriggerReturnedFmt: "%-7s CLI 触发已返回且未报错(%s%s);尚未验证轮次完成\n", watchShort: "以前台守护方式运行,并在每个 Provider 的 5h 窗口重置时自动 ping", watchLong: `以前台守护方式运行。某个 Provider 的 5h 窗口重置后,limitping 会发送最小消息来开启下一个窗口。 diff --git a/internal/cli/ping.go b/internal/cli/ping.go index c2acb90..08c944b 100644 --- a/internal/cli/ping.go +++ b/internal/cli/ping.go @@ -120,7 +120,11 @@ func report(out io.Writer, text cliText, name string, start time.Time, res *prov fmt.Fprintf(out, text.pingFailedFmt, name, elapsed(start), localizedProviderError(text, err)) return } - fmt.Fprintf(out, text.pingSuccessFmt, name, elapsed(start), usageSuffix(res)) + format := text.pingSuccessFmt + if res != nil && res.Verification != nil { + format = text.pingTriggerReturnedFmt + } + fmt.Fprintf(out, format, name, elapsed(start), usageSuffix(res)) } // usageSuffix renders the token/cost tail, e.g. ", 32,934 tok, $0.0110". diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index 1e8ac7e..ca37e1d 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -59,6 +59,7 @@ func TestBackgroundVerificationDoesNotCountAsPing(t *testing.T) { count bool }{ {"ping request completed; checking window", true}, + {"ping trigger returned; checking window", true}, {"window started after verification", false}, {"quota read failed: timeout", false}, {"ping failed: notification timeout; verifying quota before retry", true}, @@ -70,3 +71,14 @@ func TestBackgroundVerificationDoesNotCountAsPing(t *testing.T) { } } } + +func TestReturnedTriggerDoesNotClaimTurnCompletion(t *testing.T) { + var out bytes.Buffer + report(&out, enText, "codex", time.Now(), &provider.TriggerResult{ + Verification: &usage.Verification{Target: "weekly", Weekly: usage.StartStatus{State: "unknown"}}, + }, nil) + if !strings.Contains(out.String(), "CLI trigger returned without error") || + !strings.Contains(out.String(), "turn completion is not verified") { + t.Fatal(out.String()) + } +} diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 6cc2b5b..1551f2f 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -41,8 +41,11 @@ const ( // once-a-minute poll loop cannot re-attempt a refused redemption every cycle. codexRedeemCooldown = 15 * time.Minute - codexTurnMaxWait = 45 * time.Second - codexExitGrace = 5 * time.Second + codexTurnMinWait = 4 * time.Second + codexTurnQuiet = 2500 * time.Millisecond + codexTurnMaxWait = 45 * time.Second + codexExitGrace = 5 * time.Second + codexPollInterval = 200 * time.Millisecond ) // Codex reads usage via the ChatGPT backend usage endpoint and triggers windows @@ -552,10 +555,6 @@ func codexWindowToUsage(w codexWindow) usage.Window { } func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) (*TriggerResult, error) { - return triggerCodexWithWait(ctx, cfg, dryRun, codexTurnMaxWait, codexExitGrace) -} - -func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun bool, maxWait, exitGrace time.Duration) (*TriggerResult, error) { prompt := cfg.Prompt if prompt == "" { prompt = "ok" @@ -568,8 +567,6 @@ func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun args = append(args, "-m", cfg.Model) } args = append(args, codexInteractiveArgs(cfg.ExtraArgs)...) - args = append(args, "-c", `tui.notifications=["agent-turn-complete"]`, - "-c", `tui.notification_method="osc9"`, "-c", `tui.notification_condition="always"`) args = append(args, prompt) res := &TriggerResult{Command: "codex " + shellJoin(args)} if dryRun { @@ -577,9 +574,6 @@ func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun } cmd := exec.CommandContext(ctx, "codex", args...) - if term := os.Getenv("TERM"); term == "" || term == "dumb" { - cmd.Env = append(cmd.Environ(), "TERM=xterm-256color") - } ptmx, err := pty.Start(cmd) if err != nil { return res, fmt.Errorf("codex interactive failed to start: %w", err) @@ -587,11 +581,8 @@ func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun defer ptmx.Close() output := &limitedBuffer{limit: 4096} - marker := &completionMarker{done: make(chan struct{})} - readDone := make(chan struct{}) go func() { - defer close(readDone) - _, _ = io.Copy(io.MultiWriter(output, marker), ptmx) + _, _ = io.Copy(output, ptmx) }() done := make(chan error, 1) @@ -599,57 +590,41 @@ func triggerCodexWithWait(ctx context.Context, cfg config.ProviderConfig, dryRun done <- cmd.Wait() }() - select { - case <-marker.done: - return res, codexInteractiveStop(ctx, cmd, ptmx, done, output, exitGrace) - case err := <-done: - // Wait for the PTY tail: process exit can race with reading its marker. - select { - case <-readDone: - case <-time.After(100 * time.Millisecond): - } - if err != nil { - return res, codexInteractiveErr(err, output) - } - select { - case <-marker.done: - return res, nil - default: - } - return res, fmt.Errorf("codex exited without a turn-completion notification; request completion unconfirmed") - case <-ctx.Done(): - return res, codexInteractiveCancel(ctx, cmd, ptmx, done, output) - case <-time.After(maxWait): - _ = codexInteractiveStop(ctx, cmd, ptmx, done, output, exitGrace) - return res, fmt.Errorf("codex turn-completion notification timed out after %s", maxWait) + if terminal, err := codexAwait(ctx, cmd, ptmx, output, done, codexTurnMaxWait, + func(idle, elapsed time.Duration) bool { + return elapsed >= codexTurnMinWait && idle >= codexTurnQuiet + }); terminal { + return res, err } -} -type completionMarker struct { - buf []byte - done chan struct{} - finished bool + return res, codexInteractiveStop(ctx, cmd, ptmx, done, output) } -func (m *completionMarker) Write(p []byte) (int, error) { - if m.finished { - return len(p), nil - } - m.buf = append(m.buf, p...) - if i := bytes.Index(m.buf, []byte("\x1b]9;")); i >= 0 && bytes.IndexByte(m.buf[i:], 7) >= 0 { - m.finished = true - close(m.done) - m.buf = nil - } - if len(m.buf) > 4096 { - m.buf = append(m.buf[:0], m.buf[len(m.buf)-4096:]...) +func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { + start := time.Now() + deadline := time.After(maxWait) + ticker := time.NewTicker(codexPollInterval) + defer ticker.Stop() + for { + select { + case err := <-done: + return true, codexInteractiveErr(err, output) + case <-ctx.Done(): + return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) + case <-deadline: + return false, nil + case <-ticker.C: + changed := output.changedAt() + if !changed.IsZero() && ready(time.Since(changed), time.Since(start)) { + return false, nil + } + } } - return len(p), nil } -func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer, grace time.Duration) error { - deadline := time.After(grace) - ticker := time.NewTicker(grace / 2) +func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { + deadline := time.After(codexExitGrace) + ticker := time.NewTicker(codexExitGrace / 2) defer ticker.Stop() for sent := false; ; { diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index 4743e4c..5ab928b 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -323,7 +323,7 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := `codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok` + want := "codex -c model_reasoning_effort=low -m gpt-5.4-mini --search --sandbox read-only ok" if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } @@ -346,7 +346,7 @@ func TestSparkTriggerDryRunUsesSparkModel(t *testing.T) { if err != nil { t.Fatalf("dry-run trigger: %v", err) } - want := `codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok` + want := "codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok" if res.Command != want { t.Fatalf("command = %q, want %q", res.Command, want) } diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index 51f1d9d..d17af60 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -35,7 +35,7 @@ func quotaResponse(reset int64) string { func TestVerifiedPingReadsBeforeAndAfterWithoutWaitingMinute(t *testing.T) { fakeCodexHome(t) - fakeCodexCLI(t, `printf '\033]9;done\007'`) + fakeCodexCLI(t, "exit 0") old := usageHTTPClient defer func() { usageHTTPClient = old }() reads := 0 @@ -78,34 +78,6 @@ func TestFailedTriggerStillReadsQuotaAfterwards(t *testing.T) { } } -func TestMarkerAndTimeoutOutcomes(t *testing.T) { - for _, tc := range []struct { - name, script string - success bool - }{ - {"marker", `printf '\033]9;done\007'`, true}, - {"clean-no-marker", "exit 0", false}, - {"timeout", "sleep 1", false}, - } { - t.Run(tc.name, func(t *testing.T) { - fakeCodexCLI(t, tc.script) - _, err := triggerCodexWithWait(context.Background(), config.ProviderConfig{}, false, 50*time.Millisecond, 20*time.Millisecond) - if (err == nil) != tc.success { - t.Fatal(err) - } - }) - } - m := &completionMarker{done: make(chan struct{})} - for _, p := range []string{"noise\x1b]", "9;", "done", "\a"} { - _, _ = m.Write([]byte(p)) - } - select { - case <-m.done: - default: - t.Fatal("fragmented marker missed") - } -} - func TestAccountSwitchReloadsIdentity(t *testing.T) { fakeCodexHome(t) old := usageHTTPClient diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index ffaf702..a02fe40 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -138,8 +138,8 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) s.notify(name+": ping failed", "Verifying quota before another attempt") } else { - s.log.Printf("[%s] ping request completed; checking window%s", name, triggerCost(res)) - s.notify(name+": request completed", "Quota window verification is separate from request completion") + s.log.Printf("[%s] ping trigger returned; checking window%s", name, triggerCost(res)) + s.notify(name+": CLI trigger returned", "Turn completion is unverified; checking quota separately") } if res != nil && res.Verification != nil && res.Verification.Warning != "" { s.log.Printf("[%s] %s", name, res.Verification.Warning) From 88326897c0df5196be1f8b6fc4db8305378fb8e3 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 13:55:58 +0900 Subject: [PATCH 08/24] Require Codex turn-completion evidence for successful trigger reporting --- docs/window-verification.md | 18 +++-- internal/cli/bg.go | 2 + internal/cli/i18n.go | 3 + internal/cli/ping.go | 3 + internal/cli/verification_test.go | 15 ++++ internal/provider/codex.go | 46 ++++++++++-- internal/provider/codex_completion_test.go | 78 ++++++++++++++++++++ internal/provider/codex_verification_test.go | 5 +- internal/provider/provider.go | 1 + internal/scheduler/codex.go | 9 ++- 10 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 internal/provider/codex_completion_test.go diff --git a/docs/window-verification.md b/docs/window-verification.md index 96344b5..3838b79 100644 --- a/docs/window-verification.md +++ b/docs/window-verification.md @@ -13,12 +13,18 @@ The command does not wait one minute or start a detached verification process. `ping all` continues to the next provider after the ordinary trigger and these bounded reads. Explicit `schedule` commands use the same ping behavior. -The existing CLI trigger result and window start are separate results. This -feature does not change the TUI transport or interpret its completion markers. -A trigger returning without error is reported as such, not as verified turn -completion or proof that a window started. It retains exit zero even if the API -or state file is unavailable or the window is unconfirmed. Trigger errors retain -their existing nonzero exit behavior. A failed +CLI turn completion and window start are separate results. The Codex/Spark TUI's +OSC 9 turn-completion notification provides positive completion evidence. A clean +exit without that marker is unconfirmed and returns nonzero, as do timeout, +interruption and trigger errors. Process-exit races allow a bounded PTY-tail drain +before deciding whether the marker was absent. The notification configuration and +scanner are shared with the TUI implementation, not reimplemented by quota logic. + +A confirmed completed turn retains exit zero even if the API or state file is +unavailable or the window is unconfirmed. Human output and background history +describe turn completion separately from quota start; a timeout remains a failed +CLI attempt even if a later API observation finds a started window. These rules +do not change Claude's result handling. A failed pre-read does not prevent an explicit manual ping. Cancellation returns promptly without requiring a post-read. diff --git a/internal/cli/bg.go b/internal/cli/bg.go index 0614a90..0291757 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -390,6 +390,8 @@ func parseBgPingAttempt(line string) (bgPingAttempt, bool) { status = bgPingSucceeded case strings.Contains(msg, "ping trigger returned; checking window"): status = bgPingSucceeded + case strings.Contains(msg, "ping turn completed; checking window"): + status = bgPingSucceeded default: return bgPingAttempt{}, false } diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index fdc083b..7478823 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -71,6 +71,7 @@ type cliText struct { pingFailedFmt string // provider, elapsed, error pingSuccessFmt string // provider, elapsed, usage suffix pingTriggerReturnedFmt string + pingTurnCompletedFmt string watchShort string watchLong string @@ -293,6 +294,7 @@ Examples: pingFailedFmt: "%-7s ✗ failed after %s: %v\n", pingSuccessFmt: "%-7s ✓ pinged (%s%s)\n", pingTriggerReturnedFmt: "%-7s CLI trigger returned without error (%s%s); turn completion is not verified\n", + pingTurnCompletedFmt: "%-7s ✓ turn completed (%s%s); quota start is checked separately\n", watchShort: "Run the foreground daemon and ping each provider when its 5h window resets", watchLong: `Run the foreground daemon. When a provider's 5h window resets, limitping sends the minimal message to start the next window. @@ -561,6 +563,7 @@ var zhText = cliText{ pingFailedFmt: "%-7s ✗ 失败 (耗时 %s): %v\n", pingSuccessFmt: "%-7s ✓ 已 ping (%s%s)\n", pingTriggerReturnedFmt: "%-7s CLI 触发已返回且未报错(%s%s);尚未验证轮次完成\n", + pingTurnCompletedFmt: "%-7s ✓ 轮次已完成(%s%s);限额窗口启动单独检查\n", watchShort: "以前台守护方式运行,并在每个 Provider 的 5h 窗口重置时自动 ping", watchLong: `以前台守护方式运行。某个 Provider 的 5h 窗口重置后,limitping 会发送最小消息来开启下一个窗口。 diff --git a/internal/cli/ping.go b/internal/cli/ping.go index 08c944b..767f869 100644 --- a/internal/cli/ping.go +++ b/internal/cli/ping.go @@ -123,6 +123,9 @@ func report(out io.Writer, text cliText, name string, start time.Time, res *prov format := text.pingSuccessFmt if res != nil && res.Verification != nil { format = text.pingTriggerReturnedFmt + if res.TurnCompleted { + format = text.pingTurnCompletedFmt + } } fmt.Fprintf(out, format, name, elapsed(start), usageSuffix(res)) } diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index ca37e1d..6a076c9 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -60,6 +60,7 @@ func TestBackgroundVerificationDoesNotCountAsPing(t *testing.T) { }{ {"ping request completed; checking window", true}, {"ping trigger returned; checking window", true}, + {"ping turn completed; checking window", true}, {"window started after verification", false}, {"quota read failed: timeout", false}, {"ping failed: notification timeout; verifying quota before retry", true}, @@ -82,3 +83,17 @@ func TestReturnedTriggerDoesNotClaimTurnCompletion(t *testing.T) { t.Fatal(out.String()) } } + +func TestConfirmedTurnDoesNotClaimWindowStarted(t *testing.T) { + var out bytes.Buffer + report(&out, enText, "codex", time.Now(), &provider.TriggerResult{ + TurnCompleted: true, + Verification: &usage.Verification{Target: "weekly", Weekly: usage.StartStatus{State: "unknown"}}, + }, nil) + if !strings.Contains(out.String(), "turn completed") || !strings.Contains(out.String(), "window start unconfirmed") { + t.Fatal(out.String()) + } + if strings.Contains(out.String(), "window started") { + t.Fatal(out.String()) + } +} diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 8adc402..c1fda2f 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -605,7 +605,9 @@ func triggerCodexWithTiming(ctx context.Context, cfg config.ProviderConfig, dryR output := &limitedBuffer{limit: 4096} markers := newCodexTurnMarkers() + readDone := make(chan struct{}) go func() { + defer close(readDone) _, _ = io.Copy(io.MultiWriter(output, markers), ptmx) }() @@ -614,11 +616,22 @@ func triggerCodexWithTiming(ctx context.Context, cfg config.ProviderConfig, dryR done <- cmd.Wait() }() - if terminal, err := codexAwait(ctx, cmd, ptmx, output, markers.completed, done, timing.maxWait); terminal { + terminal, completed, err := codexAwait(ctx, cmd, ptmx, output, markers.completed, readDone, done, timing.maxWait) + res.TurnCompleted = completed + if terminal { + if ctx.Err() != nil { + err = ctx.Err() + } return res, err } - - return res, codexInteractiveStop(ctx, cmd, ptmx, done, output, timing.exitGrace) + stopErr := codexInteractiveStop(ctx, cmd, ptmx, done, output, timing.exitGrace) + if ctx.Err() != nil { + return res, ctx.Err() + } + if err != nil { + return res, err + } + return res, stopErr } type codexTurnMarkers struct { @@ -647,16 +660,33 @@ func (m *codexTurnMarkers) Write(p []byte) (int, error) { return len(p), nil } -func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, completed <-chan struct{}, done <-chan error, maxWait time.Duration) (bool, error) { +func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, completed, readDone <-chan struct{}, done <-chan error, maxWait time.Duration) (terminal, turnCompleted bool, err error) { select { case <-completed: - return false, nil + return false, true, nil case err := <-done: - return true, codexInteractiveErr(err, output) + // Process exit can win the select before the PTY reader sees its tail. + select { + case <-readDone: + case <-ctx.Done(): + case <-time.After(100 * time.Millisecond): + } + select { + case <-completed: + turnCompleted = true + default: + } + if err != nil { + return true, turnCompleted, codexInteractiveErr(err, output) + } + if !turnCompleted { + return true, false, fmt.Errorf("codex exited without a turn-completion notification; completion unconfirmed") + } + return true, true, nil case <-ctx.Done(): - return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) + return true, false, codexInteractiveCancel(ctx, cmd, ptmx, done, output) case <-time.After(maxWait): - return false, nil + return false, false, fmt.Errorf("codex turn-completion notification timed out after %s", maxWait) } } diff --git a/internal/provider/codex_completion_test.go b/internal/provider/codex_completion_test.go new file mode 100644 index 0000000..c119220 --- /dev/null +++ b/internal/provider/codex_completion_test.go @@ -0,0 +1,78 @@ +package provider + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/config" +) + +func TestCodexCompletionOutcomes(t *testing.T) { + for _, tc := range []struct { + name, script, wantError string + completed bool + }{ + {"completed", `printf '\033]9;done\007'`, "", true}, + {"clean-without-marker", "exit 0", "completion unconfirmed", false}, + {"process-error", "exit 1", "interactive failed", false}, + {"timeout", "exec sleep 5", "timed out", false}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeCodexCLI(t, tc.script) + res, err := triggerCodexWithTiming(context.Background(), config.ProviderConfig{}, false, + codexInteractiveTiming{maxWait: 100 * time.Millisecond, exitGrace: 20 * time.Millisecond}) + if res.TurnCompleted != tc.completed { + t.Fatalf("completed=%v", res.TurnCompleted) + } + if tc.wantError == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatal(err) + } + }) + } +} + +func TestCodexCompletionCancellation(t *testing.T) { + fakeCodexCLI(t, "exec sleep 5") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + res, err := triggerCodexWithTiming(ctx, config.ProviderConfig{}, false, + codexInteractiveTiming{maxWait: time.Second, exitGrace: 20 * time.Millisecond}) + if err == nil || res.TurnCompleted { + t.Fatal(res, err) + } +} + +func TestCodexAwaitDrainsCompletionAfterProcessExit(t *testing.T) { + markers := newCodexTurnMarkers() + readDone := make(chan struct{}) + done := make(chan error, 1) + done <- nil + go func() { + time.Sleep(5 * time.Millisecond) + _, _ = markers.Write([]byte("\x1b]9;complete\x07")) + close(readDone) + }() + terminal, completed, err := codexAwait(context.Background(), nil, nil, &limitedBuffer{limit: 4096}, + markers.completed, readDone, done, time.Second) + if !terminal || !completed || err != nil { + t.Fatal(terminal, completed, err) + } +} + +func TestCodexCompletionMarkerFragments(t *testing.T) { + m := newCodexTurnMarkers() + for _, fragment := range []string{"noise\x1b]", "9;", "finished", "\a"} { + _, _ = m.Write([]byte(fragment)) + } + select { + case <-m.completed: + default: + t.Fatal("fragmented notification not detected") + } +} diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index d17af60..5ef8cd1 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -35,7 +35,7 @@ func quotaResponse(reset int64) string { func TestVerifiedPingReadsBeforeAndAfterWithoutWaitingMinute(t *testing.T) { fakeCodexHome(t) - fakeCodexCLI(t, "exit 0") + fakeCodexCLI(t, `printf '\033]9;done\007'`) old := usageHTTPClient defer func() { usageHTTPClient = old }() reads := 0 @@ -54,6 +54,9 @@ func TestVerifiedPingReadsBeforeAndAfterWithoutWaitingMinute(t *testing.T) { if reads != 2 { t.Fatalf("reads=%d", reads) } + if !res.TurnCompleted { + t.Fatal("missing completion evidence") + } if time.Since(start) > 5*time.Second { t.Fatal("manual ping blocked") } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1ba503e..c2e61f7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -87,6 +87,7 @@ type ResetCreditRedeemer interface { // consumed (parsed from the CLI's machine-readable output). CostUSD is 0 when // the provider doesn't report a cost (e.g. Codex). type TriggerResult struct { + TurnCompleted bool // positive completion evidence; an error-free exit alone is insufficient Command string HasUsage bool InputTokens int diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index a02fe40..c77835c 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -138,8 +138,13 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) s.notify(name+": ping failed", "Verifying quota before another attempt") } else { - s.log.Printf("[%s] ping trigger returned; checking window%s", name, triggerCost(res)) - s.notify(name+": CLI trigger returned", "Turn completion is unverified; checking quota separately") + if res != nil && res.TurnCompleted { + s.log.Printf("[%s] ping turn completed; checking window%s", name, triggerCost(res)) + s.notify(name+": turn completed", "Quota window start is checked separately") + } else { + s.log.Printf("[%s] ping trigger returned; checking window%s", name, triggerCost(res)) + s.notify(name+": CLI trigger returned", "Turn completion is unverified; checking quota separately") + } } if res != nil && res.Verification != nil && res.Verification.Warning != "" { s.log.Printf("[%s] %s", name, res.Verification.Warning) From bcca35cb74d6c1061a70023d5b4a7610c74983c3 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 14:03:29 +0900 Subject: [PATCH 09/24] Preserve process failures after Codex completion notification --- README.md | 15 ++++---- README.zh-CN.md | 12 +++--- internal/provider/codex.go | 38 +++++++++++++++++-- internal/provider/codex_completion_test.go | 44 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 700c6ee..c3762b6 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ after the terminal closes. ``` claude ✓ pinged (6.6s) -codex ✓ pinged (13.6s) -spark ✓ pinged (12.4s) +codex ✓ turn completed (13.6s); quota start is checked separately +spark ✓ turn completed (12.4s); quota start is checked separately ``` ## Highlights @@ -231,20 +231,21 @@ Short aliases are also available for config commands: `limitping c i` for `ping` shows the exact command and a live timer (a spinner on a terminal). Current Claude/Codex/Spark interactive trigger sessions do not expose reliable machine-readable per-ping token or cost data, so success output normally shows -elapsed time only: +elapsed time. Codex/Spark report turn completion separately from quota-window +verification (verification lines omitted here): ``` claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok -codex ✓ pinged (6.8s) +codex ✓ turn completed (6.8s); quota start is checked separately spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok -spark ✓ pinged (6.5s) +spark ✓ turn completed (6.5s); quota start is checked separately ``` For Codex/Spark, `limitping` automatically appends the `-c tui...` flags to -enable Codex CLI's turn-completion notifications, so it can detect ping success -and exit immediately. +enable Codex CLI's turn-completion notifications, so it can detect turn completion +and exit immediately. The quota API separately verifies window activation. Use `status` or `bg status` for the authoritative 5h/weekly window view after a ping. diff --git a/README.zh-CN.md b/README.zh-CN.md index bc0e3fb..4ed516c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,8 +27,8 @@ Claude Code、Codex 和 Spark 的订阅限额按 **5 小时滚动窗口**(外加 ``` claude ✓ pinged (6.6s) -codex ✓ pinged (13.6s) -spark ✓ pinged (12.4s) +codex ✓ turn completed (13.6s); quota start is checked separately +spark ✓ turn completed (12.4s); quota start is checked separately ``` ## 亮点 @@ -212,18 +212,18 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm | `uninstall` | `rm`、`remove` | `ping` 会显示具体命令和实时计时(终端下是 spinner)。当前 Claude/Codex/Spark 都用交互式 -触发,CLI 不提供可靠的逐次 machine-readable token/费用数据,所以成功输出通常只显示耗时: +触发,CLI 不提供可靠的逐次 machine-readable token/费用数据,所以输出会显示耗时。Codex/Spark 会区分轮次完成和限额窗口启动确认(此处省略窗口确认行): ``` claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.6-luna -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok -codex ✓ pinged (6.8s) +codex ✓ turn completed (6.8s); quota start is checked separately spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.notifications=["agent-turn-complete"] -c tui.notification_method="osc9" -c tui.notification_condition="always" ok -spark ✓ pinged (6.5s) +spark ✓ turn completed (6.5s); quota start is checked separately ``` -对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数以启用 Codex CLI 的 turn 结束通知,从而检测 ping 成功并立即退出。 +对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数以启用 Codex CLI 的 turn 结束通知,从而检测轮次完成并立即退出。限额窗口是否启动由限额 API 单独确认。 ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 diff --git a/internal/provider/codex.go b/internal/provider/codex.go index c1fda2f..6e992ee 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,6 +17,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "time" "unicode" @@ -691,6 +693,12 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit } func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer, exitGrace time.Duration) error { + // Preserve an exit already observed before requesting intentional shutdown. + select { + case err := <-done: + return codexInteractiveErr(err, output) + default: + } deadline := time.After(exitGrace) ticker := time.NewTicker(exitGrace / 2) defer ticker.Stop() @@ -701,18 +709,26 @@ func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, don sent = true } select { - case <-done: - return nil + case err := <-done: + return codexShutdownErr(err, output) case <-ctx.Done(): return codexInteractiveCancel(ctx, cmd, ptmx, done, output) case <-ticker.C: _, _ = ptmx.Write([]byte{0x03}) case <-deadline: + killed := false if cmd.Process != nil { - _ = cmd.Process.Kill() + killed = cmd.Process.Kill() == nil } select { - case <-done: + case err := <-done: + var exitErr *exec.ExitError + if killed && errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() && status.Signal() == syscall.SIGKILL { + return nil + } + } + return codexShutdownErr(err, output) case <-time.After(time.Second): } return nil @@ -720,6 +736,20 @@ func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, don } } +func codexShutdownErr(err error, output *limitedBuffer) error { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // Ctrl-C is expected after completion; other failures remain failures. + if exitErr.ExitCode() == 130 { + return nil + } + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() && status.Signal() == syscall.SIGINT { + return nil + } + } + return codexInteractiveErr(err, output) +} + func codexInteractiveErr(err error, output *limitedBuffer) error { if err == nil { return nil diff --git a/internal/provider/codex_completion_test.go b/internal/provider/codex_completion_test.go index c119220..4248c57 100644 --- a/internal/provider/codex_completion_test.go +++ b/internal/provider/codex_completion_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "os/exec" "strings" "testing" "time" @@ -17,6 +18,7 @@ func TestCodexCompletionOutcomes(t *testing.T) { {"completed", `printf '\033]9;done\007'`, "", true}, {"clean-without-marker", "exit 0", "completion unconfirmed", false}, {"process-error", "exit 1", "interactive failed", false}, + {"completed-process-error", `printf '\033]9;done\007'; exit 1`, "interactive failed", true}, {"timeout", "exec sleep 5", "timed out", false}, } { t.Run(tc.name, func(t *testing.T) { @@ -37,6 +39,48 @@ func TestCodexCompletionOutcomes(t *testing.T) { } } +func TestCodexCompletionAndFailureReadyTogether(t *testing.T) { + exitErr := exec.Command("sh", "-c", "exit 1").Run() + if exitErr == nil { + t.Fatal("expected subprocess failure") + } + for i := 0; i < 100; i++ { + completed, readDone := make(chan struct{}), make(chan struct{}) + close(completed) + close(readDone) + done := make(chan error, 1) + done <- exitErr + output := &limitedBuffer{limit: 4096} + terminal, confirmed, err := codexAwait(context.Background(), nil, nil, output, completed, readDone, done, time.Second) + if !terminal { + err = codexInteractiveStop(context.Background(), nil, nil, done, output, time.Second) + } + if !confirmed || err == nil || !strings.Contains(err.Error(), "interactive failed") { + t.Fatal(terminal, confirmed, err) + } + } +} + +func TestCodexShutdownExitStatus(t *testing.T) { + for _, tc := range []struct { + script string + failed bool + }{ + {"exit 0", false}, + {"exit 1", true}, + {"exit 130", false}, + {"kill -INT $$", false}, + {"kill -TERM $$", true}, + } { + t.Run(tc.script, func(t *testing.T) { + err := exec.Command("sh", "-c", tc.script).Run() + if got := codexShutdownErr(err, &limitedBuffer{limit: 4096}); (got != nil) != tc.failed { + t.Fatal(got) + } + }) + } +} + func TestCodexCompletionCancellation(t *testing.T) { fakeCodexCLI(t, "exec sleep 5") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) From 0e75e18db93fc6aa85435abd59c1c4478084bf23 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Sun, 6 Sep 2026 14:04:35 +0900 Subject: [PATCH 10/24] Guard Unix shutdown fixtures on native Windows --- internal/provider/codex_completion_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/provider/codex_completion_test.go b/internal/provider/codex_completion_test.go index 4248c57..7d6a883 100644 --- a/internal/provider/codex_completion_test.go +++ b/internal/provider/codex_completion_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "os/exec" + "runtime" "strings" "testing" "time" @@ -40,6 +41,9 @@ func TestCodexCompletionOutcomes(t *testing.T) { } func TestCodexCompletionAndFailureReadyTogether(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires Unix subprocess exit semantics") + } exitErr := exec.Command("sh", "-c", "exit 1").Run() if exitErr == nil { t.Fatal("expected subprocess failure") @@ -62,6 +66,9 @@ func TestCodexCompletionAndFailureReadyTogether(t *testing.T) { } func TestCodexShutdownExitStatus(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires Unix subprocess signal semantics") + } for _, tc := range []struct { script string failed bool From 7dfabbf049231905bd2da668fa2191c2732fabaa Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 19:35:45 +0900 Subject: [PATCH 11/24] Explain Codex reset verification briefly in README --- README.md | 27 ++++--- README.zh-CN.md | 25 +++++-- docs/window-verification.md | 117 ------------------------------ internal/cli/bg.go | 2 - internal/cli/verification_test.go | 2 +- 5 files changed, 36 insertions(+), 137 deletions(-) delete mode 100644 docs/window-verification.md diff --git a/README.md b/README.md index 49c7f3f..31f953f 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ after the terminal closes. ``` claude ✓ pinged (6.6s) -codex ✓ pinged (13.6s) -spark ✓ pinged (12.4s) +codex CLI trigger returned without error (13.6s); turn completion is not verified +spark CLI trigger returned without error (12.4s); turn completion is not verified ``` ## Highlights @@ -76,11 +76,6 @@ provider quota: `limitping ping --dry-run`, `limitping watch --dry-run`, or ## How it works -Codex/Spark distinguish CLI completion from quota-window start, including 0% -usage windows. Manual pings return without waiting a minute; watch performs -follow-up observations and bounded recovery. See [window verification](docs/window-verification.md) -for status/JSON semantics, state files, retry limits and platform limitations. - Two cleanly separated jobs: | Job | Mechanism | Cost | @@ -117,6 +112,20 @@ and pings as soon as the window resets. Claude/Codex tokens are reused from the official tools (no separate login) and refreshed on 401. Spark reuses the Codex token. +### Codex/Spark window verification + +At 0% usage, one quota API response cannot always tell whether a window has started. +For example, compare these five-hour reset times: + +| Pattern (both show 0% used) | Read at 10:00 | Read at 10:01 | +| --- | --- | --- | +| Started: reset stays fixed | 15:00 | 15:00 | +| Not started: reset slides forward | 15:00 | 15:01 | + +To distinguish these patterns, limitping compares reads at least one minute apart. +Inconclusive results stay unconfirmed. `ping` returns without waiting that minute +and suggests a later `status` check; `watch`/`bg` rechecks automatically. + ## Install `limitping` ships as a single self-contained binary — **no Go required**. @@ -237,9 +246,9 @@ elapsed time only: claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok -codex ✓ pinged (13.6s) +codex CLI trigger returned without error (13.6s); turn completion is not verified spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok -spark ✓ pinged (12.4s) +spark CLI trigger returned without error (12.4s); turn completion is not verified ``` Use `status` or `bg status` for the authoritative 5h/weekly window view after a diff --git a/README.zh-CN.md b/README.zh-CN.md index 947993d..5a8d2e9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,10 +4,6 @@ # CCLimitPing (`limitping`) -Codex/Spark 会分别判断 CLI 请求完成与限额窗口启动,包括使用率为 0% 的窗口。 -手动 ping 不会等待一分钟;watch 负责后续检查和有上限的重试。 -状态字段、存储位置和平台限制参见[窗口验证说明](docs/window-verification.md)。 - [English](README.md) | **中文** [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) @@ -27,8 +23,8 @@ Claude Code、Codex 和 Spark 的订阅限额按 **5 小时滚动窗口**(外加 ``` claude ✓ pinged (6.6s) -codex ✓ pinged (13.6s) -spark ✓ pinged (12.4s) +codex CLI trigger returned without error (13.6s); turn completion is not verified +spark CLI trigger returned without error (12.4s); turn completion is not verified ``` ## 亮点 @@ -101,6 +97,19 @@ limitping bg logs -f Claude/Codex 的 token 直接复用官方工具(无需另外登录),遇到 401 会自动刷新。Spark 复用 Codex token。 +### Codex/Spark 窗口验证 + +用量为 0% 时,单次限额 API 响应不一定能判断窗口是否已启动。 +以下以五小时窗口的重置时间为例: + +| 情况(用量均为 0%) | 10:00 查询 | 10:01 查询 | +| --- | --- | --- | +| 已启动:重置时间固定 | 15:00 | 15:00 | +| 未启动:重置时间向后滑动 | 15:00 | 15:01 | + +因此,limitping 会比较至少间隔一分钟的查询结果;证据不足时仍显示未确认。 +`ping` 不会等待这一分钟,而是提示稍后运行 `status`;`watch`/`bg` 会自动复查。 + ## 安装 `limitping` 是一个自包含的单文件二进制——**普通用户无需安装 Go**。 @@ -218,9 +227,9 @@ limitping uninstall # 删除 limitping 以及配置/缓存(简称: rm claude → claude --model haiku . claude ✓ pinged (6.6s) codex → codex -c model_reasoning_effort=low -m gpt-5.4-mini ok -codex ✓ pinged (13.6s) +codex CLI trigger returned without error (13.6s); turn completion is not verified spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark ok -spark ✓ pinged (12.4s) +spark CLI trigger returned without error (12.4s); turn completion is not verified ``` ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 diff --git a/docs/window-verification.md b/docs/window-verification.md deleted file mode 100644 index 96344b5..0000000 --- a/docs/window-verification.md +++ /dev/null @@ -1,117 +0,0 @@ -# Codex and Spark window verification - -Codex and Spark share an observation algorithm, not quota buckets. Normal Codex -uses `rate_limit`; Spark selects its model from `additional_rate_limits`. -Claude's existing window detection and scheduling are unchanged. - -## Interactive commands - -Every real Codex/Spark ping attempts the read-only quota API before and after the -CLI turn. Each read has a three-second total timeout including retries. This -does not consume model quota. These reads do not fetch reset-credit details. -The command does not wait one minute or start a detached verification process. -`ping all` continues to the next provider after the ordinary trigger and these -bounded reads. Explicit `schedule` commands use the same ping behavior. - -The existing CLI trigger result and window start are separate results. This -feature does not change the TUI transport or interpret its completion markers. -A trigger returning without error is reported as such, not as verified turn -completion or proof that a window started. It retains exit zero even if the API -or state file is unavailable or the window is unconfirmed. Trigger errors retain -their existing nonzero exit behavior. A failed -pre-read does not prevent an explicit manual ping. Cancellation returns promptly -without requiring a post-read. - -An unconfirmed result with a saved baseline suggests `limitping status` after -60 seconds. If no baseline could be saved, the first successful status read -collects it; another observation at least 60 seconds later is needed. Already -confirmed windows need no recheck instruction. Status only reads quota and -observes state; it never sends a model request or waits for the comparison interval. - -`status` still reads enabled providers only and accepts no provider selector. -An explicitly pinged disabled provider therefore warns that it will not appear -in status. Other enabled providers retain their existing read timeouts. - -`status --json` retains the legacy `active` boolean (positive usage with a future -reset). Codex/Spark windows additionally expose `start_state`: `started`, -`not_started` or `unknown`, and `verification_due_at` when another sample is -needed. At 0% usage, `active: false` can coexist with `start_state: "started"`. -No new fields are emitted for Claude. Cache failures leave usable quota data -visible and the start state unknown; API failures retain the existing nonzero -status exit behavior. Human output labels unconfirmed reset times as estimates. - -## Observation rules - -Positive usage with a plausible future reset supports a started window. At zero -usage, two compatible samples at least 60 seconds apart can distinguish a fixed -absolute reset from a reset sliding with observation time. A fixed reset supports -started, including when the second observation is hours later. Sliding resets -require a 60–600 second pair and approximately match observation time plus the -reported duration. Timestamp tolerance is five seconds. A fresh pre-send read -can extend recently established sliding evidence; it cannot rely only on old -cached eligibility. - -These are empirical rules, not a documented server-side guarantee. Inconsistent -or missing evidence remains unknown. Account/plan/duration changes, reset -boundaries, backwards observation time and reset-credit redemption invalidate -incompatible evidence. A pre-ping/post-ping pair alone cannot establish failure: -each attempt starts a new post-attempt failure baseline. Compatible previously -confirmed start evidence is retained across a manual ping. - -## Automatic recovery - -Foreground watch and background watch schedule observation-only checks while -start state is uncertain. The target is the five-hour window when present, -otherwise the weekly window. An active weekly window does not cancel recovery -of an unstarted five-hour window. One ping observes both windows in its own -bucket, never the other provider's bucket. - -Automatic sends require fresh not-started evidence, usable state storage and -the existing alignment, activity and weekly/credit guards. `reset_buffer` -(default 10 seconds) is a watcher safety margin after a known previous window's -reset boundary, not an extra delay after verification. The boundary is persisted -per window; observation time counts toward the buffer. For example, a ten-minute -buffer leaves nine minutes after a one-minute verification. Polling never moves -that deadline. When the previous reset boundary is unknown, no buffer delay is -added; explicit manual pings also bypass it. After a send, -observation failure causes further reads, not immediate model retries. Confirmed -failure permits retries after at least 1, 5 and then 15 minutes. At most four -automatic attempts are allowed in a rolling hour per account and bucket. -The budget persists across restarts and target changes. Cooldown expires -automatically; fresh verification and all guards are still required to send. -Manual pings bypass that budget without clearing it. - -Background status exposes the target, verification/backoff/cooldown state and -next eligibility time, not a guarantee that a ping will occur then. Ping history -counts each trigger outcome once; later verification events are not extra pings. - -## State and platforms - -State lives in `/state/codex/.json`, with separate -normal-Codex and Spark-model entries. The directory is `$XDG_CONFIG_HOME/limitping` -when set, otherwise the user's home `.config/limitping` on all platforms. -It contains observations, deadlines and attempt metadata, not tokens, raw API -responses, prompts or model output. New directories/files use private permissions -where supported. Same-directory replacement and short OS-backed file locks -protect updates; locks are not held across network or model requests. - -A live per-bucket attempt claim or a contended short state transaction makes a -competing manual ping return promptly with actionable retry advice. Contention -does not bypass duplicate protection: another process may be reserving a send. -Pending verification alone does not block manual use. -An expired claim requires new observation evidence before automatic retry. -Missing state starts with unknown evidence. Corrupt/incompatible state or an -unwritable directory disables automatic sends; explicit manual pings can proceed -with a warning, without promising duplicate prevention during storage failure. -After repairing permissions or moving a corrupt state file aside, observations -resume. Do not remove healthy state to bypass retry budgets. - -The state implementation supports Linux, macOS and Windows. Native Windows -Codex/Spark PTY triggering remains unsupported by the current PTY dependency; -Windows release targets do not imply working TUI pings. WSL with Linux binaries -uses the Linux PTY implementation. No Windows PTY replacement is part of this change. - -`ping --dry-run` neither fetches quota nor writes state. Watch dry-run retains -its existing quota reads but does not persist verification or attempt state. -Offline tests cover observations, claims, budgets, CLI output and a fake CLI -trigger; no real quota pings are necessary for these tests. diff --git a/internal/cli/bg.go b/internal/cli/bg.go index 0614a90..4d0812d 100644 --- a/internal/cli/bg.go +++ b/internal/cli/bg.go @@ -386,8 +386,6 @@ func parseBgPingAttempt(line string) (bgPingAttempt, bool) { status = bgPingFailed case strings.Contains(msg, "ping sent, new window started"): status = bgPingSucceeded - case strings.Contains(msg, "ping request completed; checking window"): - status = bgPingSucceeded case strings.Contains(msg, "ping trigger returned; checking window"): status = bgPingSucceeded default: diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index ca37e1d..de36487 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -58,7 +58,7 @@ func TestBackgroundVerificationDoesNotCountAsPing(t *testing.T) { msg string count bool }{ - {"ping request completed; checking window", true}, + {"ping request completed; checking window", false}, {"ping trigger returned; checking window", true}, {"window started after verification", false}, {"quota read failed: timeout", false}, From ad0bfd796a62b426c94fe4a96c3aade0774ed1c7 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 19:50:52 +0900 Subject: [PATCH 12/24] Clarify TUI completion scope and guard Unix PTY test --- README.md | 4 ++-- README.zh-CN.md | 3 ++- internal/provider/codex_test.go | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7decdaf..fe30d61 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,8 @@ spark ✓ pinged (6.5s) ``` For Codex/Spark, `limitping` automatically appends the `-c tui...` flags to -enable Codex CLI's turn-completion notifications, so it can detect ping success -and exit immediately. +enable turn-completion notifications and stop the TUI when one is received. +A 45-second safety timeout remains; this does not verify quota-window activation. Use `status` or `bg status` for the authoritative 5h/weekly window view after a ping. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4aeb1a1..243592d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -219,7 +219,8 @@ spark → codex -c model_reasoning_effort=low -m gpt-5.3-codex-spark -c tui.no spark ✓ pinged (6.5s) ``` -对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数以启用 Codex CLI 的 turn 结束通知,从而检测 ping 成功并立即退出。 +对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数,收到轮次完成通知后停止 TUI。 +仍保留 45 秒安全超时;这不代表已确认限额窗口启动。 ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index dd374ec..d67df21 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" "time" @@ -296,7 +297,7 @@ func TestCodexResetCreditsURLFromBase(t *testing.T) { func TestParseCodexBaseURL(t *testing.T) { contents := ` -model = "gpt-5.6-luna" +model = "gpt-5.4-mini" chatgpt_base_url = "https://api.openai.com" ` if got := parseCodexBaseURL(contents); got != "https://api.openai.com" { @@ -332,6 +333,9 @@ func TestCodexTriggerDryRunUsesInteractiveCommand(t *testing.T) { } func TestCodexTriggerWaitsForTurnCompleteNotification(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires Unix PTY support") + } dir := t.TempDir() argsPath := filepath.Join(dir, "args") turnPath := filepath.Join(dir, "turn") From 1e13b5db21f487cd67bf5e56db05a2c7fbc0be65 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 20:16:24 +0900 Subject: [PATCH 13/24] Test Codex PTY completion detection on macOS CI --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef81f3..0972b7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: pull_request: jobs: + codex-pty-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Detect Codex completion notification through a PTY + run: go test -race -count=1 -v ./internal/provider -run '^TestCodexTriggerWaitsForTurnCompleteNotification$' + build: runs-on: ubuntu-latest steps: From 3a9fd47c74483d8c85471529f517eb009470deca Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 20:18:29 +0900 Subject: [PATCH 14/24] Allow macOS CI scheduling overhead in PTY fixture --- internal/provider/codex_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/provider/codex_test.go b/internal/provider/codex_test.go index d67df21..97acfe6 100644 --- a/internal/provider/codex_test.go +++ b/internal/provider/codex_test.go @@ -369,7 +369,7 @@ done t.Setenv("TERM", "dumb") timing := codexInteractiveTiming{ - maxWait: time.Second, + maxWait: 10 * time.Second, exitGrace: 50 * time.Millisecond, } started := time.Now() @@ -380,7 +380,7 @@ done if err != nil { t.Fatalf("trigger: %v", err) } - if elapsed := time.Since(started); elapsed >= 500*time.Millisecond { + if elapsed := time.Since(started); elapsed >= 5*time.Second { t.Fatalf("trigger took %s, want completion marker to stop it before fallback", elapsed) } From 149bf60a07907129209e70341b6376ea86032c7c Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 21:48:45 +0900 Subject: [PATCH 15/24] Share fail-fast Codex ping path and keep retry policy in watcher --- README.md | 7 ++ README.zh-CN.md | 6 ++ internal/provider/codex.go | 15 +++-- internal/provider/codex_verification.go | 55 +++++---------- internal/provider/codex_verification_test.go | 67 +++++++++++++++++++ internal/provider/provider.go | 13 ++-- internal/scheduler/codex.go | 70 +++++++++++++++----- internal/scheduler/codex_test.go | 42 +++++++++++- 8 files changed, 211 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 31f953f..5ac3f12 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ To distinguish these patterns, limitping compares reads at least one minute apar Inconclusive results stay unconfirmed. `ping` returns without waiting that minute and suggests a later `status` check; `watch`/`bg` rechecks automatically. +If the pre-ping quota check fails, both manual and automatic pings report the +reason and stop without sending. Authentication reload/refresh on HTTP 401 is +still attempted. Only the watcher waits before retrying: authentication/permission +failures back off from 30 seconds to one hour; other read failures cap at ten +minutes, with `Retry-After` respected. Restart the watcher to retry immediately +after fixing access. Manual pings do not wait through this backoff. + ## Install `limitping` ships as a single self-contained binary — **no Go required**. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5a8d2e9..fe7e48c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,6 +110,12 @@ Codex token。 因此,limitping 会比较至少间隔一分钟的查询结果;证据不足时仍显示未确认。 `ping` 不会等待这一分钟,而是提示稍后运行 `status`;`watch`/`bg` 会自动复查。 +发送前的限额检查失败时,手动和自动 ping 都会说明原因并停止,不发送请求。 +HTTP 401 仍会尝试重新加载或刷新凭据。只有 watcher 负责重试等待: +认证或权限错误从 30 秒退避至最多一小时,其他读取错误最多十分钟, +并遵守 `Retry-After`。修复访问权限后可重启 watcher 立即重试; +手动 ping 不等待这些退避间隔。 + ## 安装 `limitping` 是一个自包含的单文件二进制——**普通用户无需安装 Go**。 diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 1551f2f..818cf45 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -76,11 +76,11 @@ func (c *Codex) ReadUsage(ctx context.Context) (*usage.Usage, error) { } func (c *Codex) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return pingVerified(ctx, c.Name(), c.cfg, dryRun, false, 0, 0) + return pingVerified(ctx, c.Name(), c.cfg, dryRun, nil) } -func (c *Codex) TriggerAutomatic(ctx context.Context, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { - return pingVerified(ctx, c.Name(), c.cfg, false, true, threshold, resetBuffer) +func (c *Codex) TriggerWithReservation(ctx context.Context, reserve PingReservation) (*TriggerResult, error) { + return pingVerified(ctx, c.Name(), c.cfg, false, reserve) } // RedeemResetCredit spends the next available reset credit right now. Each call @@ -228,11 +228,11 @@ func (s *Spark) ReadUsage(ctx context.Context) (*usage.Usage, error) { } func (s *Spark) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, error) { - return pingVerified(ctx, s.Name(), s.cfg, dryRun, false, 0, 0) + return pingVerified(ctx, s.Name(), s.cfg, dryRun, nil) } -func (s *Spark) TriggerAutomatic(ctx context.Context, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { - return pingVerified(ctx, s.Name(), s.cfg, false, true, threshold, resetBuffer) +func (s *Spark) TriggerWithReservation(ctx context.Context, reserve PingReservation) (*TriggerResult, error) { + return pingVerified(ctx, s.Name(), s.cfg, false, reserve) } func codexActiveTask(_ context.Context) (string, bool, error) { @@ -303,6 +303,9 @@ type codexResetCredit struct { func readCodexUsage(ctx context.Context, auth *auth.CodexAuth) ([]byte, codexUsageResp, error) { var r codexUsageResp + if _, err := auth.Token(ctx); err != nil { + return nil, r, &AuthenticationError{Err: err} + } body, err := fetchWithAuth(ctx, auth, func(token string) (*http.Request, error) { accountID, _ := auth.AccountID(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL(), nil) diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go index 59ff656..37c592e 100644 --- a/internal/provider/codex_verification.go +++ b/internal/provider/codex_verification.go @@ -15,6 +15,10 @@ import ( const quotaReadBudget = 3 * time.Second +// PingReservation lets the watcher gate and reserve a send using fresh quota data. +// It returns a state claim, which the common ping path finishes after the CLI exits. +type PingReservation func(codexstate.Store, string, string, *usage.Usage) (string, error) + type noQuotaStateKey struct{} type pingStageKey struct{} @@ -109,7 +113,7 @@ func boundedQuota(ctx context.Context, name string, cfg config.ProviderConfig) ( return readVerifiedUsage(readCtx, name, cfg, false) } -func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, dry, automatic bool, threshold float64, resetBuffer time.Duration) (*TriggerResult, error) { +func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, dry bool, reserve PingReservation) (*TriggerResult, error) { if dry { return triggerCodex(ctx, cfg, true) } @@ -118,55 +122,32 @@ func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, d if ctx.Err() != nil { return nil, ctx.Err() } - warning := "" if readErr != nil { - warning = "pre-ping quota read failed: " + readErr.Error() - } - if pre != nil && pre.Verification.Warning != "" { - warning = pre.Verification.Warning - } - if automatic && (readErr != nil || warning != "") { - if readErr != nil { - return nil, fmt.Errorf("automatic pre-ping check unavailable: %w", readErr) - } - return nil, fmt.Errorf("automatic pre-ping check unavailable: %s", warning) + return nil, fmt.Errorf("ping not sent: quota precheck failed: %w", readErr) } + warning := pre.Verification.Warning identity, identityErr := currentCodexAccount(ctx) - if identityErr == nil && (account == "" || readErr != nil) { - account = identity + if identityErr != nil { + return nil, fmt.Errorf("ping not sent: account check failed: %w", identityErr) } - if identityErr != nil || identity != account { - if automatic { - return nil, codexstate.ErrDeferred - } - warning = "account changed or unavailable; window verification unavailable" - account = "" - } - if automatic { - if pre.WeeklyExhausted(threshold) { - return nil, codexstate.ErrDeferred - } - if _, active, err := codexActiveTask(ctx); err != nil || active { - return nil, codexstate.ErrDeferred - } + if identity != account { + return nil, fmt.Errorf("ping not sent: Codex account changed during quota precheck; retry") } store, storeErr := quotaStore() - store.ResetBuffer = resetBuffer key := quotaBucket(name, cfg) claim := "" - if storeErr == nil && account != "" { - var observed time.Time - if pre != nil { - observed = pre.FetchedAt + if storeErr == nil { + if reserve != nil { + claim, storeErr = reserve(store, account, key, pre) + } else { + claim, storeErr = store.Begin(account, key, false, pre.FetchedAt, time.Now()) } - claim, storeErr = store.Begin(account, key, automatic, observed, time.Now()) } if storeErr != nil { - if automatic || errors.Is(storeErr, codexstate.ErrBusy) || errors.Is(storeErr, codexstate.ErrDeferred) { - return nil, storeErr + if reserve != nil || errors.Is(storeErr, codexstate.ErrBusy) || errors.Is(storeErr, codexstate.ErrDeferred) { + return nil, fmt.Errorf("ping not sent: %w", storeErr) } warning = "quota coordination unavailable: " + storeErr.Error() - claim = "" } // The PTY deadline and claim deadline must describe the same bounded operation. pingStage(ctx, "sending ping") diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index d17af60..93feaa1 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "io" "net/http" @@ -126,6 +127,72 @@ func TestDryRunNeverReadsOrWritesState(t *testing.T) { } } +func TestQuotaPrecheckFailureNeverSends(t *testing.T) { + for _, automatic := range []bool{false, true} { + t.Run(fmt.Sprint(automatic), func(t *testing.T) { + fakeCodexHome(t) + marker := filepath.Join(t.TempDir(), "sent") + t.Setenv("TEST_SENT", marker) + fakeCodexCLI(t, `touch "$TEST_SENT"`) + reads := 0 + useTransport(t, func(*http.Request) (*http.Response, error) { + reads++ + return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil + }) + var reserve PingReservation + if automatic { + reserve = func(codexstate.Store, string, string, *usage.Usage) (string, error) { + t.Fatal("failed precheck must not reserve a ping") + return "", nil + } + } + start := time.Now() + res, err := pingVerified(context.Background(), "codex", config.ProviderConfig{}, false, reserve) + var httpErr *UsageHTTPError + if res != nil || !errors.As(err, &httpErr) || httpErr.StatusCode != 403 || !strings.Contains(err.Error(), "ping not sent: quota precheck failed") { + t.Fatal(res, err) + } + if reads != 1 || time.Since(start) > 5*time.Second { + t.Fatal("precheck retried or waited", reads) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("CLI was executed", err) + } + }) + } +} + +func TestMissingCredentialsIsAuthenticationFailure(t *testing.T) { + t.Setenv("CODEX_HOME", t.TempDir()) + res, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false) + var authErr *AuthenticationError + if res != nil || !errors.As(err, &authErr) || !strings.Contains(err.Error(), "ping not sent") { + t.Fatal(res, err) + } +} + +func TestPrecheckReloadsCredentialsOn401(t *testing.T) { + fakeCodexHome(t) + fakeCodexCLI(t, `printf '\033]9;done\007'`) + reads := 0 + useTransport(t, func(req *http.Request) (*http.Response, error) { + reads++ + if reads == 1 { + if err := os.WriteFile(filepath.Join(os.Getenv("CODEX_HOME"), "auth.json"), []byte(`{"tokens":{"access_token":"new-token","account_id":"account-123"}}`), 0600); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil + } + if req.Header.Get("Authorization") != "Bearer new-token" { + t.Fatal("stale token") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(quotaResponse(time.Now().Add(7 * 24 * time.Hour).Unix()))), Header: make(http.Header)}, nil + }) + if _, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false); err != nil || reads != 3 { + t.Fatal(err, reads) + } +} + func TestAutoRedeemRejectsChangedObservationAccount(t *testing.T) { fakeCodexHome(t) requests := 0 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1ba503e..77e4284 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -97,12 +97,17 @@ type TriggerResult struct { StatusEnabled bool } -// VerifiedTrigger is implemented only by Codex-backed providers. Automatic -// requests require fresh, bucket-specific start evidence before sending. +// VerifiedTrigger uses the same ping path with a watcher-owned pre-send reservation. type VerifiedTrigger interface { - TriggerAutomatic(context.Context, float64, time.Duration) (*TriggerResult, error) + TriggerWithReservation(context.Context, PingReservation) (*TriggerResult, error) } +// AuthenticationError preserves credential failures for watcher retry policy. +type AuthenticationError struct{ Err error } + +func (e *AuthenticationError) Error() string { return e.Err.Error() } +func (e *AuthenticationError) Unwrap() error { return e.Err } + // UsageHTTPError preserves usage endpoint HTTP failures so callers can make // status-aware scheduling decisions instead of treating every failure alike. type UsageHTTPError struct { @@ -150,7 +155,7 @@ func fetchWithAuth(ctx context.Context, src tokenSource, buildReq func(token str if status == http.StatusUnauthorized { t, rerr := src.Refresh(ctx) if rerr != nil { - return nil, fmt.Errorf("unauthorized and refresh failed: %w", rerr) + return nil, &AuthenticationError{Err: fmt.Errorf("unauthorized and refresh failed: %w", rerr)} } token = t if body, status, header, err = doGet(ctx, token, buildReq); err != nil { diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index a02fe40..7511c02 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -3,17 +3,19 @@ package scheduler import ( "context" "errors" + "fmt" "time" "github.com/wavever/CCLimitPing/internal/codexstate" "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/usage" ) // runVerifiedTarget is shared by Codex and Spark, never by Claude. A successful // transport does not advance a quota schedule without observation evidence. func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider.VerifiedTrigger) { name := t.Provider.Name() - backoff := minBackoff + var reads, prechecks quotaRetry aligned := t.AlignStart.IsZero() wait := func(reason string, d time.Duration) bool { if d <= 0 { @@ -28,21 +30,14 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. u, err := t.Provider.ReadUsage(rctx) cancel() if err != nil { - d := backoff - var httpErr *provider.UsageHTTPError - if errors.As(err, &httpErr) && !httpErr.RetryAfter.IsZero() { - d = usageRateLimitWait(httpErr.RetryAfter, time.Now()) - } else if errors.As(err, &httpErr) && httpErr.StatusCode == 429 { - d = rateLimitPause - } + d := reads.next(err, time.Now()) s.log.Printf("[%s] quota read failed: %v (retry in %s)", name, err, d) if !wait("quota read failed", d) { return } - backoff = nextBackoff(backoff) continue } - backoff = minBackoff + reads = quotaRetry{} if s.redeemExpiringCredit(ctx, t, u) { continue } @@ -76,6 +71,7 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } } if v.Recovery == "window_started" { + prechecks = quotaRetry{} // Observe at rollover; the verification interval counts toward the buffer. d := time.Until(v.NextEligible) if d > 5*time.Minute { @@ -114,8 +110,21 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. continue } s.live.set(name, "checking and sending ping…", time.Time{}) - res, err := p.TriggerAutomatic(ctx, s.cfg.WeeklyThreshold, s.cfg.ResetBuffer.Duration) + res, err := p.TriggerWithReservation(ctx, func(store codexstate.Store, account, key string, pre *usage.Usage) (string, error) { + if pre.Verification == nil || pre.Verification.Warning != "" { + return "", fmt.Errorf("quota state unavailable") + } + if pre.WeeklyExhausted(s.cfg.WeeklyThreshold) { + return "", codexstate.ErrDeferred + } + if _, active, err := activeProviderTask(ctx, t.Provider); err != nil || active { + return "", codexstate.ErrDeferred + } + store.ResetBuffer = s.cfg.ResetBuffer.Duration + return store.Begin(account, key, true, pre.FetchedAt, time.Now()) + }) if errors.Is(err, codexstate.ErrBusy) || errors.Is(err, codexstate.ErrDeferred) { + prechecks = quotaRetry{} if !wait("ping deferred", codexstate.Interval) { return } @@ -123,17 +132,14 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } if err != nil && res == nil { // No trigger occurred: do not manufacture a failed ping-history entry. - d := minBackoff - var httpErr *provider.UsageHTTPError - if errors.As(err, &httpErr) && (!httpErr.RetryAfter.IsZero() || httpErr.StatusCode == 429) { - d = usageRateLimitWait(httpErr.RetryAfter, time.Now()) - } + d := prechecks.next(err, time.Now()) s.log.Printf("[%s] pre-ping check failed: %v; observing again in %s", name, err, d) if !wait("pre-ping check unavailable", d) { return } continue } + prechecks = quotaRetry{} if err != nil { s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) s.notify(name+": ping failed", "Verifying quota before another attempt") @@ -149,3 +155,35 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } } } + +type quotaRetry struct { + delay time.Duration + auth bool +} + +func (r *quotaRetry) next(err error, now time.Time) time.Duration { + var httpErr *provider.UsageHTTPError + var authErr *provider.AuthenticationError + auth := errors.As(err, &authErr) + if errors.As(err, &httpErr) { + auth = auth || httpErr.StatusCode == 401 || httpErr.StatusCode == 403 + if !httpErr.RetryAfter.IsZero() || httpErr.StatusCode == 429 { + *r = quotaRetry{} + return usageRateLimitWait(httpErr.RetryAfter, now) + } + } + if r.delay == 0 || r.auth != auth { + r.delay = minBackoff + } else { + r.delay *= 2 + } + r.auth = auth + cap := maxBackoff + if auth { + cap = time.Hour + } + if r.delay > cap { + r.delay = cap + } + return r.delay +} diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go index 162438d..a3a747b 100644 --- a/internal/scheduler/codex_test.go +++ b/internal/scheduler/codex_test.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "errors" "io" "testing" "time" @@ -10,9 +11,48 @@ import ( "github.com/wavever/CCLimitPing/internal/usage" ) +func TestQuotaRetryPolicy(t *testing.T) { + for _, tc := range []struct { + name string + err error + cap time.Duration + }{ + {"unauthorized", &provider.UsageHTTPError{StatusCode: 401}, time.Hour}, + {"forbidden", &provider.UsageHTTPError{StatusCode: 403}, time.Hour}, + {"credentials", &provider.AuthenticationError{Err: errors.New("missing credentials")}, time.Hour}, + {"server", &provider.UsageHTTPError{StatusCode: 503}, 10 * time.Minute}, + {"network", errors.New("connection failed"), 10 * time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + var retry quotaRetry + want := 30 * time.Second + for i := 0; i < 12; i++ { + if got := retry.next(tc.err, time.Now()); got != want { + t.Fatalf("step %d: %s, want %s", i, got, want) + } + want *= 2 + if want > tc.cap { + want = tc.cap + } + } + }) + } + now := time.Now() + var retry quotaRetry + if got := retry.next(&provider.UsageHTTPError{StatusCode: 429, RetryAfter: now.Add(20 * time.Minute)}, now); got != 20*time.Minute { + t.Fatal(got) + } + if got := retry.next(&provider.UsageHTTPError{StatusCode: 429}, now); got != 5*time.Minute { + t.Fatal(got) + } + if got := retry.next(&provider.UsageHTTPError{StatusCode: 403}, now); got != 30*time.Second { + t.Fatal(got) + } +} + type verifiedStub struct{ stubProvider } -func (p *verifiedStub) TriggerAutomatic(ctx context.Context, _ float64, _ time.Duration) (*provider.TriggerResult, error) { +func (p *verifiedStub) TriggerWithReservation(ctx context.Context, _ provider.PingReservation) (*provider.TriggerResult, error) { return p.Trigger(ctx, false) } From 081ff17393caf5f3d59e494913bcd62641ad25e7 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 21:50:37 +0900 Subject: [PATCH 16/24] Leave transient quota retries to watcher and test fail-fast reads --- internal/provider/codex.go | 2 + internal/provider/codex_verification_test.go | 93 +++++++++++++------- internal/provider/provider.go | 4 +- 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 818cf45..9f11637 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -302,6 +302,8 @@ type codexResetCredit struct { } func readCodexUsage(ctx context.Context, auth *auth.CodexAuth) ([]byte, codexUsageResp, error) { + // Codex quota retries belong to the watcher. Immediate 401 recovery remains. + ctx = context.WithValue(ctx, noUsageRetryKey{}, true) var r codexUsageResp if _, err := auth.Token(ctx); err != nil { return nil, r, &AuthenticationError{Err: err} diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index 93feaa1..20057b7 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -128,37 +128,70 @@ func TestDryRunNeverReadsOrWritesState(t *testing.T) { } func TestQuotaPrecheckFailureNeverSends(t *testing.T) { - for _, automatic := range []bool{false, true} { - t.Run(fmt.Sprint(automatic), func(t *testing.T) { - fakeCodexHome(t) - marker := filepath.Join(t.TempDir(), "sent") - t.Setenv("TEST_SENT", marker) - fakeCodexCLI(t, `touch "$TEST_SENT"`) - reads := 0 - useTransport(t, func(*http.Request) (*http.Response, error) { - reads++ - return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil - }) - var reserve PingReservation - if automatic { - reserve = func(codexstate.Store, string, string, *usage.Usage) (string, error) { - t.Fatal("failed precheck must not reserve a ping") - return "", nil + for _, status := range []int{403, 503} { + for _, automatic := range []bool{false, true} { + t.Run(fmt.Sprint(status, automatic), func(t *testing.T) { + fakeCodexHome(t) + marker := filepath.Join(t.TempDir(), "sent") + t.Setenv("TEST_SENT", marker) + fakeCodexCLI(t, `touch "$TEST_SENT"`) + reads := 0 + useTransport(t, func(*http.Request) (*http.Response, error) { + reads++ + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil + }) + var reserve PingReservation + if automatic { + reserve = func(codexstate.Store, string, string, *usage.Usage) (string, error) { + t.Fatal("failed precheck must not reserve a ping") + return "", nil + } } - } - start := time.Now() - res, err := pingVerified(context.Background(), "codex", config.ProviderConfig{}, false, reserve) - var httpErr *UsageHTTPError - if res != nil || !errors.As(err, &httpErr) || httpErr.StatusCode != 403 || !strings.Contains(err.Error(), "ping not sent: quota precheck failed") { - t.Fatal(res, err) - } - if reads != 1 || time.Since(start) > 5*time.Second { - t.Fatal("precheck retried or waited", reads) - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("CLI was executed", err) - } - }) + start := time.Now() + res, err := pingVerified(context.Background(), "codex", config.ProviderConfig{}, false, reserve) + var httpErr *UsageHTTPError + if res != nil || !errors.As(err, &httpErr) || httpErr.StatusCode != status || !strings.Contains(err.Error(), "ping not sent: quota precheck failed") { + t.Fatal(res, err) + } + if reads != 1 || time.Since(start) > 5*time.Second { + t.Fatal("precheck retried or waited", reads) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("CLI was executed", err) + } + }) + } + } +} + +func TestQuotaNetworkFailureDoesNotRetry(t *testing.T) { + fakeCodexHome(t) + reads := 0 + useTransport(t, func(*http.Request) (*http.Response, error) { + reads++ + return nil, io.ErrUnexpectedEOF + }) + res, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false) + if res != nil || !errors.Is(err, io.ErrUnexpectedEOF) || reads != 1 { + t.Fatal(res, err, reads) + } +} + +func TestPostcheckFailureReportsSentWithoutRetry(t *testing.T) { + fakeCodexHome(t) + fakeCodexCLI(t, `printf '\033]9;done\007'`) + reads := 0 + useTransport(t, func(*http.Request) (*http.Response, error) { + reads++ + status, body := 200, quotaResponse(time.Now().Add(7*24*time.Hour).Unix()) + if reads > 1 { + status, body = 503, "{}" + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + res, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false) + if err != nil || reads != 2 || res == nil || !strings.Contains(res.Verification.Warning, "post-ping quota read failed") { + t.Fatal(res, err, reads) } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 77e4284..74dd697 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -220,8 +220,10 @@ func doGet(ctx context.Context, token string, buildReq func(token string) (*http return lastBody, lastStatus, lastHeader, lastErr } +type noUsageRetryKey struct{} + func shouldRetryUsageGET(ctx context.Context, attempt, status int, err error) bool { - if attempt >= usageGETAttempts || ctx.Err() != nil { + if ctx.Value(noUsageRetryKey{}) == true || attempt >= usageGETAttempts || ctx.Err() != nil { return false } if err != nil { From d059d1532a8beb17cfe00a7c9ba6aec93b7761c7 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 21:51:06 +0900 Subject: [PATCH 17/24] Cover Retry-After propagation and explicit unsent CLI output --- internal/cli/verification_test.go | 10 ++++++++++ internal/provider/codex_verification_test.go | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index de36487..8694da4 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "fmt" "strings" "testing" "time" @@ -10,6 +11,15 @@ import ( "github.com/wavever/CCLimitPing/internal/usage" ) +func TestPrecheckFailureOutputSaysNotSent(t *testing.T) { + var out bytes.Buffer + err := fmt.Errorf("ping not sent: quota precheck failed: %w", &provider.UsageHTTPError{StatusCode: 403}) + report(&out, enText, "codex", time.Now(), nil, err) + if got := out.String(); !strings.Contains(got, "ping not sent: quota precheck failed") || !strings.Contains(got, "403") || strings.Contains(got, "✓") { + t.Fatal(got) + } +} + func TestVerificationGuidance(t *testing.T) { for _, tc := range []struct { name, state string diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index 20057b7..c873356 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -138,7 +138,7 @@ func TestQuotaPrecheckFailureNeverSends(t *testing.T) { reads := 0 useTransport(t, func(*http.Request) (*http.Response, error) { reads++ - return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("{}")), Header: http.Header{"Retry-After": []string{"120"}}}, nil }) var reserve PingReservation if automatic { @@ -156,6 +156,9 @@ func TestQuotaPrecheckFailureNeverSends(t *testing.T) { if reads != 1 || time.Since(start) > 5*time.Second { t.Fatal("precheck retried or waited", reads) } + if !httpErr.RetryAfter.After(start.Add(time.Minute)) { + t.Fatal("Retry-After was lost") + } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatal("CLI was executed", err) } From f84bfa297c4d8304b67900f625f24c0ac0a9199d Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 22:02:43 +0900 Subject: [PATCH 18/24] Suggest retrying busy pings in about a minute --- internal/cli/verification_test.go | 10 ++++++++++ internal/codexstate/state.go | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index 8694da4..8efe849 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -7,10 +7,20 @@ import ( "testing" "time" + "github.com/wavever/CCLimitPing/internal/codexstate" "github.com/wavever/CCLimitPing/internal/provider" "github.com/wavever/CCLimitPing/internal/usage" ) +func TestBusyPingOutputSuggestsRetry(t *testing.T) { + var out bytes.Buffer + report(&out, enText, "codex", time.Now(), nil, fmt.Errorf("ping not sent: %w", codexstate.ErrBusy)) + want := "ping not sent: another ping or quota-state update is in progress.\nPlease try again in about a minute." + if got := out.String(); !strings.Contains(got, want) || strings.Contains(got, "✓") { + t.Fatal(got) + } +} + func TestPrecheckFailureOutputSaysNotSent(t *testing.T) { var out bytes.Buffer err := fmt.Errorf("ping not sent: quota precheck failed: %w", &provider.UsageHTTPError{StatusCode: 403}) diff --git a/internal/codexstate/state.go b/internal/codexstate/state.go index 4e5de31..ceab2a1 100644 --- a/internal/codexstate/state.go +++ b/internal/codexstate/state.go @@ -24,7 +24,7 @@ const ( tolerance = 5 * time.Second ) -var ErrBusy = errors.New("another ping or state update is running; try again shortly") +var ErrBusy = errors.New("another ping or quota-state update is in progress.\nPlease try again in about a minute.") var ErrDeferred = errors.New("automatic ping deferred; quota verification or cooldown is pending") type Sample struct { From 2eda9cb9bf85b7c4ee399832e9f0bd8ba2ce5048 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 22:16:00 +0900 Subject: [PATCH 19/24] Honor quota retry metadata after a completed ping --- internal/provider/codex_verification.go | 1 + internal/provider/codex_verification_test.go | 6 ++- internal/provider/provider.go | 1 + internal/scheduler/codex.go | 6 ++- internal/scheduler/codex_test.go | 47 +++++++++++++++++++- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go index 37c592e..ab70396 100644 --- a/internal/provider/codex_verification.go +++ b/internal/provider/codex_verification.go @@ -170,6 +170,7 @@ func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, d pingStage(ctx, "checking quota after ping") post, postAccount, err := boundedQuota(ctx, name, cfg) if err != nil { + res.PostcheckErr = err res.Verification.Warning = "post-ping quota read failed: " + err.Error() } else if !changed && postAccount == account { res.Verification = post.Verification diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index c873356..c41d2eb 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -190,12 +190,16 @@ func TestPostcheckFailureReportsSentWithoutRetry(t *testing.T) { if reads > 1 { status, body = 503, "{}" } - return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Retry-After": []string{"1200"}}}, nil }) res, err := NewCodex(config.ProviderConfig{}).Trigger(context.Background(), false) if err != nil || reads != 2 || res == nil || !strings.Contains(res.Verification.Warning, "post-ping quota read failed") { t.Fatal(res, err, reads) } + var postErr *UsageHTTPError + if !errors.As(res.PostcheckErr, &postErr) || postErr.StatusCode != 503 || !postErr.RetryAfter.After(time.Now().Add(19*time.Minute)) { + t.Fatal("postcheck retry metadata lost", res.PostcheckErr) + } } func TestMissingCredentialsIsAuthenticationFailure(t *testing.T) { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 74dd697..e8fe223 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -94,6 +94,7 @@ type TriggerResult struct { TotalTokens int CostUSD float64 Verification *usage.Verification + PostcheckErr error // quota-read failure, independent of the CLI outcome StatusEnabled bool } diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 7511c02..7c4ffe1 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -150,7 +150,11 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. if res != nil && res.Verification != nil && res.Verification.Warning != "" { s.log.Printf("[%s] %s", name, res.Verification.Warning) } - if !wait("verifying quota", codexstate.Interval) { + delay := codexstate.Interval + if res != nil && res.PostcheckErr != nil { + delay = reads.next(res.PostcheckErr, time.Now()) + } + if !wait("verifying quota", delay) { return } } diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go index a3a747b..ed76758 100644 --- a/internal/scheduler/codex_test.go +++ b/internal/scheduler/codex_test.go @@ -50,10 +50,53 @@ func TestQuotaRetryPolicy(t *testing.T) { } } -type verifiedStub struct{ stubProvider } +type verifiedStub struct { + stubProvider + postcheckErr error +} func (p *verifiedStub) TriggerWithReservation(ctx context.Context, _ provider.PingReservation) (*provider.TriggerResult, error) { - return p.Trigger(ctx, false) + res, err := p.Trigger(ctx, false) + if res != nil { + res.PostcheckErr = p.postcheckErr + } + return res, err +} + +func TestPostcheckFailureSchedulesQuotaRetry(t *testing.T) { + for _, tc := range []struct { + name string + status int + retryAfter, want time.Duration + }{ + {"rate-limit-deadline", 429, 20 * time.Minute, 20 * time.Minute}, + {"server-deadline", 503, time.Hour, time.Hour}, + {"rate-limit-fallback", 429, 0, 5 * time.Minute}, + {"permission-backoff", 403, 0, 30 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + start := time.Now() + err := &provider.UsageHTTPError{StatusCode: tc.status} + if tc.retryAfter > 0 { + err.RetryAfter = start.Add(tc.retryAfter) + } + p := &verifiedStub{stubProvider: stubProvider{usage: &usage.Usage{ + Verification: &usage.Verification{Target: "weekly", Recovery: "ready"}, + }}, postcheckErr: err} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + s := New(testConfig(), []Target{{Provider: p}}, false, false, io.Discard) + s.live.enabled = true + s.runVerifiedTarget(ctx, Target{Provider: p}, p) + item := s.live.items[p.Name()] + if item.state != "verifying quota" || item.deadline.Sub(start.Add(tc.want)).Abs() > time.Second { + t.Fatalf("next read: %+v, want delay %s", item, tc.want) + } + if reads, sends := p.counts(); reads != 1 || sends != 1 { + t.Fatal(reads, sends) + } + }) + } } func TestVerifiedSchedulerGates(t *testing.T) { From 6157158572c64e41c976f7f01e712ba4b99fe46c Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 22:41:41 +0900 Subject: [PATCH 20/24] Clarify quota recovery guidance in status output --- internal/cli/i18n.go | 74 ++++++++++++++++------------ internal/cli/status.go | 15 ++++-- internal/cli/status_recovery_test.go | 52 +++++++++++++++++++ 3 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 internal/cli/status_recovery_test.go diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index fdc083b..4e14b7f 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -6,17 +6,21 @@ import ( ) type cliText struct { - verifyStarted string - verifyNotStarted string - verifyUnknown string - verifyRecovery string - verifyDisabled string - verifyNoBaseline string - verifyCheckFmt string - rootShort string - rootLong string - helpFlag string - usageTemplate string + verifyStarted string + verifyNotStarted string + verifyUnknown string + verifyStatusCheck string + verifyRetryFmt string + verifyRunning string + verifyReady string + verifyUnavailable string + verifyDisabled string + verifyNoBaseline string + verifyCheckFmt string + rootShort string + rootLong string + helpFlag string + usageTemplate string helpCommandShort string helpCommandLong string @@ -194,16 +198,20 @@ func isChineseLocale() bool { } var enText = cliText{ - verifyStarted: "window started", - verifyNotStarted: "window not started (reset is an estimate)", - verifyUnknown: "window start unconfirmed (reset is an estimate)", - verifyRecovery: "quota recovery / next eligibility", - verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.", - verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.", - verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n", - rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", - rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", - helpFlag: "help for this command", + verifyStarted: "window started", + verifyNotStarted: "window not started (reset is an estimate)", + verifyUnknown: "window start unconfirmed (reset is an estimate)", + verifyStatusCheck: "Window start unconfirmed; run `limitping status` again in about a minute.", + verifyRetryFmt: "Automatic ping retry eligible at %s.", + verifyRunning: "Ping in progress.", + verifyReady: "Window not started; eligible for automatic ping, subject to watcher checks.", + verifyUnavailable: "Quota-window state unavailable; run `limitping status` again in about a minute.", + verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.", + verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.", + verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n", + rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", + rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", + helpFlag: "help for this command", usageTemplate: `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} @@ -460,16 +468,20 @@ Examples: } var zhText = cliText{ - verifyStarted: "窗口已启动", - verifyNotStarted: "窗口未启动(重置时间为估计值)", - verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", - verifyRecovery: "窗口恢复 / 下次可尝试时间", - verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。", - verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。", - verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n", - rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", - rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", - helpFlag: "显示此命令的帮助", + verifyStarted: "窗口已启动", + verifyNotStarted: "窗口未启动(重置时间为估计值)", + verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", + verifyStatusCheck: "窗口启动尚未确认;请约一分钟后再次运行 `limitping status`。", + verifyRetryFmt: "自动 ping 最早可于 %s 重试。", + verifyRunning: "Ping 正在进行。", + verifyReady: "窗口未启动;可尝试自动 ping,仍需通过监视器检查。", + verifyUnavailable: "窗口状态不可用;请约一分钟后再次运行 `limitping status`。", + verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。", + verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。", + verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n", + rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", + rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", + helpFlag: "显示此命令的帮助", usageTemplate: `用法:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} diff --git a/internal/cli/status.go b/internal/cli/status.go index 1b5616a..9e921f7 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -250,11 +250,18 @@ func printUsage(out io.Writer, text cliText, u *usage.Usage, verbose bool, displ fmt.Fprintf(out, text.statusFiveHourLineFmt, five) fmt.Fprintf(out, text.statusWeeklyLineFmt, week) if v := u.Verification; v != nil { - fmt.Fprintf(out, " %s: %s %s", text.verifyRecovery, v.Target, v.Recovery) - if !v.NextEligible.IsZero() { - fmt.Fprintf(out, " (%s)", fmtClock(text, v.NextEligible)) + switch v.Recovery { + case "verifying": + fmt.Fprintln(out, " "+text.verifyStatusCheck) + case "backoff", "cooldown": + fmt.Fprintf(out, " "+text.verifyRetryFmt+"\n", fmtClock(text, v.NextEligible)) + case "ping_running": + fmt.Fprintln(out, " "+text.verifyRunning) + case "ready": + fmt.Fprintln(out, " "+text.verifyReady) + case "unavailable": + fmt.Fprintln(out, " "+text.verifyUnavailable) } - fmt.Fprintln(out) if v.Warning != "" { fmt.Fprintln(out, " "+v.Warning) } diff --git a/internal/cli/status_recovery_test.go b/internal/cli/status_recovery_test.go new file mode 100644 index 0000000..7c4c312 --- /dev/null +++ b/internal/cli/status_recovery_test.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/usage" +) + +func TestStatusRecoveryGuidance(t *testing.T) { + for _, text := range []cliText{enText, zhText} { + for _, tc := range []struct { + state string + want string + }{ + {"window_started", ""}, + {"verifying", text.verifyStatusCheck}, + {"backoff", fmtClock(text, time.Date(2026, 9, 7, 12, 4, 0, 0, time.UTC))}, + {"cooldown", fmtClock(text, time.Date(2026, 9, 7, 12, 4, 0, 0, time.UTC))}, + {"ping_running", text.verifyRunning}, + {"ready", text.verifyReady}, + {"unavailable", text.verifyUnavailable}, + } { + t.Run(tc.state+"/"+text.verifyStarted, func(t *testing.T) { + u := &usage.Usage{Provider: "codex"} + var plain bytes.Buffer + printUsage(&plain, text, u, false, "") + u.Verification = &usage.Verification{ + Recovery: tc.state, + NextEligible: time.Date(2026, 9, 7, 12, 4, 0, 0, time.UTC), + } + var out bytes.Buffer + printUsage(&out, text, u, false, "") + if tc.want == "" { + if out.String() != plain.String() { + t.Fatal(out.String()) + } + } else if !strings.Contains(out.String(), tc.want) { + t.Fatal(out.String()) + } + u.Verification.Warning = "observation warning" + out.Reset() + printUsage(&out, text, u, false, "") + if !strings.Contains(out.String(), "observation warning") { + t.Fatal(out.String()) + } + }) + } + } +} From b1027757b4c537c6d9ae018567d38f6b22a667dd Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 23:08:28 +0900 Subject: [PATCH 21/24] Add startup diagnostics for unconfirmed Codex completion --- README.md | 3 ++ README.zh-CN.md | 1 + internal/cli/i18n.go | 3 ++ internal/cli/ping.go | 5 +++ internal/cli/verification_test.go | 29 +++++++++++++++ internal/provider/codex.go | 4 +- internal/provider/codex_completion_test.go | 5 +++ internal/provider/provider.go | 5 +++ internal/scheduler/codex.go | 15 ++++++++ internal/scheduler/codex_hint_test.go | 43 ++++++++++++++++++++++ 10 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 internal/scheduler/codex_hint_test.go diff --git a/README.md b/README.md index 4004166..2ff7188 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,9 @@ enable turn-completion notifications and stop the TUI when one is received. A 45-second safety timeout remains; this does not verify quota-window activation. Without a completion notification, the attempt returns a nonzero exit status, including on timeout or clean process exit; process failures also remain errors. +Timeouts and clean exits without a completion notification include a hint to +rerun the command in a terminal with the same `CODEX_HOME` and check for startup +confirmation dialogs. Watcher logs also include the command and `CODEX_HOME` setting. Use `status` or `bg status` for the authoritative 5h/weekly window view after a ping. diff --git a/README.zh-CN.md b/README.zh-CN.md index b928aec..43da3a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -241,6 +241,7 @@ spark ✓ turn completed (6.5s); quota start is checked separately 对于 Codex/Spark,`limitping` 会自动追加 `-c tui...` 参数,收到轮次完成通知后停止 TUI。 仍保留 45 秒安全超时;这不代表已确认限额窗口启动。 未收到完成通知时,即使进程正常退出,也会返回非零退出码;超时及进程错误同样按失败处理。 +完成通知超时或正常退出但未收到通知时,会提示使用相同的 `CODEX_HOME` 在终端重跑命令,检查启动确认对话框。监视器日志还会记录命令和 `CODEX_HOME` 设置。 ping 后请用 `status` 或 `bg status` 查看权威的 5h/周窗口状态。 diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index e119d44..efeced5 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -6,6 +6,7 @@ import ( ) type cliText struct { + pingStartupHint string verifyStarted string verifyNotStarted string verifyUnknown string @@ -199,6 +200,7 @@ func isChineseLocale() bool { } var enText = cliText{ + pingStartupHint: " Hint: Codex may be waiting for a startup confirmation.\n Run the command shown above in a terminal and check for confirmation dialogs.\n Use the same CODEX_HOME as limitping.", verifyStarted: "window started", verifyNotStarted: "window not started (reset is an estimate)", verifyUnknown: "window start unconfirmed (reset is an estimate)", @@ -470,6 +472,7 @@ Examples: } var zhText = cliText{ + pingStartupHint: " 提示:Codex 可能正在等待启动确认。\n 请在终端运行上方命令,检查是否出现确认对话框。\n 使用与 limitping 相同的 CODEX_HOME。", verifyStarted: "窗口已启动", verifyNotStarted: "窗口未启动(重置时间为估计值)", verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", diff --git a/internal/cli/ping.go b/internal/cli/ping.go index 767f869..c9b3c1a 100644 --- a/internal/cli/ping.go +++ b/internal/cli/ping.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "io" "os" @@ -118,6 +119,10 @@ func report(out io.Writer, text cliText, name string, start time.Time, res *prov defer reportVerification(out, text, res) if err != nil { fmt.Fprintf(out, text.pingFailedFmt, name, elapsed(start), localizedProviderError(text, err)) + var completionErr *provider.CodexCompletionError + if errors.As(err, &completionErr) { + fmt.Fprintln(out, text.pingStartupHint) + } return } format := text.pingSuccessFmt diff --git a/internal/cli/verification_test.go b/internal/cli/verification_test.go index 63915d4..e9a8721 100644 --- a/internal/cli/verification_test.go +++ b/internal/cli/verification_test.go @@ -21,6 +21,35 @@ func TestBusyPingOutputSuggestsRetry(t *testing.T) { } } +func TestPingStartupHint(t *testing.T) { + for _, text := range []cliText{enText, zhText} { + for _, name := range []string{"codex", "spark"} { + for _, tc := range []struct { + err error + hint bool + }{ + {fmt.Errorf("wrapped: %w", &provider.CodexCompletionError{Reason: "completion unconfirmed"}), true}, + {&provider.UsageHTTPError{StatusCode: 401}, false}, + {fmt.Errorf("process failed"), false}, + {codexstate.ErrBusy, false}, + } { + var out bytes.Buffer + res := &provider.TriggerResult{Verification: &usage.Verification{ + Target: "weekly", Weekly: usage.StartStatus{State: "started"}, + }} + report(&out, text, name, time.Now(), res, tc.err) + got := out.String() + if strings.Contains(got, text.pingStartupHint) != tc.hint { + t.Fatal(got) + } + if tc.hint && strings.Index(got, text.pingStartupHint) > strings.Index(got, "weekly:") { + t.Fatal(got) + } + } + } + } +} + func TestPrecheckFailureOutputSaysNotSent(t *testing.T) { var out bytes.Buffer err := fmt.Errorf("ping not sent: quota precheck failed: %w", &provider.UsageHTTPError{StatusCode: 403}) diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 2d38460..24001a0 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -687,13 +687,13 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit return true, turnCompleted, codexInteractiveErr(err, output) } if !turnCompleted { - return true, false, fmt.Errorf("codex exited without a turn-completion notification; completion unconfirmed") + return true, false, &CodexCompletionError{Reason: "codex exited without a turn-completion notification; completion unconfirmed"} } return true, true, nil case <-ctx.Done(): return true, false, codexInteractiveCancel(ctx, cmd, ptmx, done, output) case <-time.After(maxWait): - return false, false, fmt.Errorf("codex turn-completion notification timed out after %s", maxWait) + return false, false, &CodexCompletionError{Reason: fmt.Sprintf("codex turn-completion notification timed out after %s", maxWait)} } } diff --git a/internal/provider/codex_completion_test.go b/internal/provider/codex_completion_test.go index 7d6a883..9c1cb22 100644 --- a/internal/provider/codex_completion_test.go +++ b/internal/provider/codex_completion_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "os/exec" "runtime" "strings" @@ -29,6 +30,10 @@ func TestCodexCompletionOutcomes(t *testing.T) { if res.TurnCompleted != tc.completed { t.Fatalf("completed=%v", res.TurnCompleted) } + var completionErr *CodexCompletionError + if got, want := errors.As(err, &completionErr), tc.name == "timeout" || tc.name == "clean-without-marker"; got != want { + t.Fatalf("startup diagnostic classification=%v, want %v: %v", got, want, err) + } if tc.wantError == "" { if err != nil { t.Fatal(err) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1afa433..75aa8ed 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -83,6 +83,11 @@ type ResetCreditRedeemer interface { AutoRedeemResetCredit(ctx context.Context, u *usage.Usage) (outcome string, err error) } +// CodexCompletionError means the TUI ended or timed out without completion evidence. +type CodexCompletionError struct{ Reason string } + +func (e *CodexCompletionError) Error() string { return e.Reason } + // TriggerResult reports what a Trigger did, including the token usage the ping // consumed (parsed from the CLI's machine-readable output). CostUSD is 0 when // the provider doesn't report a cost (e.g. Codex). diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 2a2b39e..31e296a 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "time" "github.com/wavever/CCLimitPing/internal/codexstate" @@ -142,6 +143,7 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. prechecks = quotaRetry{} if err != nil { s.log.Printf("[%s] ping failed: %v; verifying quota before retry", name, err) + s.logCodexStartupHint(name, res, err) s.notify(name+": ping failed", "Verifying quota before another attempt") } else { if res != nil && res.TurnCompleted { @@ -165,6 +167,19 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } } +func (s *Scheduler) logCodexStartupHint(name string, res *provider.TriggerResult, err error) { + var completionErr *provider.CodexCompletionError + if !errors.As(err, &completionErr) || res == nil { + return + } + s.log.Printf("[%s] Hint: Codex may be waiting for a startup confirmation. Run this command in a terminal and check for confirmation dialogs: %s", name, res.Command) + if home := os.Getenv("CODEX_HOME"); home != "" { + s.log.Printf("[%s] Use the same CODEX_HOME=%q as this watcher.", name, home) + } else { + s.log.Printf("[%s] CODEX_HOME is unset for this watcher (defaults to ~/.codex). Use the same setting.", name) + } +} + type quotaRetry struct { delay time.Duration auth bool diff --git a/internal/scheduler/codex_hint_test.go b/internal/scheduler/codex_hint_test.go new file mode 100644 index 0000000..3730f92 --- /dev/null +++ b/internal/scheduler/codex_hint_test.go @@ -0,0 +1,43 @@ +package scheduler + +import ( + "bytes" + "errors" + "fmt" + "log" + "strings" + "testing" + + "github.com/wavever/CCLimitPing/internal/provider" +) + +func TestCodexStartupHintLog(t *testing.T) { + for _, home := range []string{"", "/tmp/codex home"} { + t.Run(home, func(t *testing.T) { + t.Setenv("CODEX_HOME", home) + var out bytes.Buffer + s := &Scheduler{log: log.New(&out, "", 0)} + res := &provider.TriggerResult{Command: "codex -C /tmp/ping-repo ok"} + err := fmt.Errorf("wrapped: %w", &provider.CodexCompletionError{Reason: "timeout"}) + s.logCodexStartupHint("spark", res, err) + got := out.String() + if !strings.Contains(got, res.Command) || !strings.Contains(got, "confirmation dialogs") || + !strings.Contains(got, "CODEX_HOME") || !strings.Contains(got, "[spark]") { + t.Fatal(got) + } + if home != "" && !strings.Contains(got, fmt.Sprintf("%q", home)) { + t.Fatal(got) + } + if home == "" && !strings.Contains(got, "unset") { + t.Fatal(got) + } + out.Reset() + s.logCodexStartupHint("codex", res, errors.New("process failed")) + s.logCodexStartupHint("codex", res, &provider.UsageHTTPError{StatusCode: 403}) + s.logCodexStartupHint("codex", nil, err) + if out.Len() != 0 { + t.Fatal(out.String()) + } + }) + } +} From b4cb7761985e3373437302ccb66633a0ec3ef432 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Mon, 7 Sep 2026 23:14:57 +0900 Subject: [PATCH 22/24] Distinguish quota windows active before a manual ping --- internal/cli/i18n.go | 92 +++++++++++--------- internal/cli/ping_quota_state_test.go | 46 ++++++++++ internal/cli/verification.go | 12 ++- internal/provider/codex_verification.go | 1 + internal/provider/codex_verification_test.go | 3 + internal/provider/provider.go | 19 ++-- 6 files changed, 120 insertions(+), 53 deletions(-) create mode 100644 internal/cli/ping_quota_state_test.go diff --git a/internal/cli/i18n.go b/internal/cli/i18n.go index 4e14b7f..3ab6496 100644 --- a/internal/cli/i18n.go +++ b/internal/cli/i18n.go @@ -6,21 +6,23 @@ import ( ) type cliText struct { - verifyStarted string - verifyNotStarted string - verifyUnknown string - verifyStatusCheck string - verifyRetryFmt string - verifyRunning string - verifyReady string - verifyUnavailable string - verifyDisabled string - verifyNoBaseline string - verifyCheckFmt string - rootShort string - rootLong string - helpFlag string - usageTemplate string + verifyAlreadyActive string + verifyQuotaStateFmt string + verifyStarted string + verifyNotStarted string + verifyUnknown string + verifyStatusCheck string + verifyRetryFmt string + verifyRunning string + verifyReady string + verifyUnavailable string + verifyDisabled string + verifyNoBaseline string + verifyCheckFmt string + rootShort string + rootLong string + helpFlag string + usageTemplate string helpCommandShort string helpCommandLong string @@ -198,20 +200,22 @@ func isChineseLocale() bool { } var enText = cliText{ - verifyStarted: "window started", - verifyNotStarted: "window not started (reset is an estimate)", - verifyUnknown: "window start unconfirmed (reset is an estimate)", - verifyStatusCheck: "Window start unconfirmed; run `limitping status` again in about a minute.", - verifyRetryFmt: "Automatic ping retry eligible at %s.", - verifyRunning: "Ping in progress.", - verifyReady: "Window not started; eligible for automatic ping, subject to watcher checks.", - verifyUnavailable: "Quota-window state unavailable; run `limitping status` again in about a minute.", - verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.", - verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.", - verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n", - rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", - rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", - helpFlag: "help for this command", + verifyAlreadyActive: "already active before ping", + verifyQuotaStateFmt: " %s quota state: %s\n", + verifyStarted: "window started", + verifyNotStarted: "window not started (reset is an estimate)", + verifyUnknown: "window start unconfirmed (reset is an estimate)", + verifyStatusCheck: "Window start unconfirmed; run `limitping status` again in about a minute.", + verifyRetryFmt: "Automatic ping retry eligible at %s.", + verifyRunning: "Ping in progress.", + verifyReady: "Window not started; eligible for automatic ping, subject to watcher checks.", + verifyUnavailable: "Quota-window state unavailable; run `limitping status` again in about a minute.", + verifyDisabled: " This provider is disabled in config and will not appear in `limitping status`.", + verifyNoBaseline: " Run `limitping status` to collect a baseline, then check again after 60s.", + verifyCheckFmt: " Run `limitping status` after %s to recheck (no background check was scheduled by this command).\n", + rootShort: "Keep Claude Code / Codex / Spark rate-limit windows back-to-back", + rootLong: "limitping pings your AI coding provider the moment its 5h rate-limit window resets, so the next window starts immediately and stays aligned. Usage is read via zero-quota endpoints; pings go through the official CLIs.", + helpFlag: "help for this command", usageTemplate: `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} @@ -468,20 +472,22 @@ Examples: } var zhText = cliText{ - verifyStarted: "窗口已启动", - verifyNotStarted: "窗口未启动(重置时间为估计值)", - verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", - verifyStatusCheck: "窗口启动尚未确认;请约一分钟后再次运行 `limitping status`。", - verifyRetryFmt: "自动 ping 最早可于 %s 重试。", - verifyRunning: "Ping 正在进行。", - verifyReady: "窗口未启动;可尝试自动 ping,仍需通过监视器检查。", - verifyUnavailable: "窗口状态不可用;请约一分钟后再次运行 `limitping status`。", - verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。", - verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。", - verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n", - rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", - rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", - helpFlag: "显示此命令的帮助", + verifyAlreadyActive: "ping 前已启动", + verifyQuotaStateFmt: " %s 限额状态:%s\n", + verifyStarted: "窗口已启动", + verifyNotStarted: "窗口未启动(重置时间为估计值)", + verifyUnknown: "窗口启动尚未确认(重置时间为估计值)", + verifyStatusCheck: "窗口启动尚未确认;请约一分钟后再次运行 `limitping status`。", + verifyRetryFmt: "自动 ping 最早可于 %s 重试。", + verifyRunning: "Ping 正在进行。", + verifyReady: "窗口未启动;可尝试自动 ping,仍需通过监视器检查。", + verifyUnavailable: "窗口状态不可用;请约一分钟后再次运行 `limitping status`。", + verifyDisabled: " 此服务在配置中已禁用,不会出现在 `limitping status` 中。", + verifyNoBaseline: " 运行 `limitping status` 采集基准,60 秒后再次检查。", + verifyCheckFmt: " %s 后运行 `limitping status` 再次检查(本命令未安排后台检查)。\n", + rootShort: "让 Claude Code / Codex / Spark 的限额窗口自动接龙", + rootLong: "limitping 会在 AI 编程 Provider 的 5h 限额窗口重置时立即发送 ping,让下一个窗口马上开始并保持对齐。用量读取走零消耗接口;ping 通过官方 CLI 发送。", + helpFlag: "显示此命令的帮助", usageTemplate: `用法:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} diff --git a/internal/cli/ping_quota_state_test.go b/internal/cli/ping_quota_state_test.go new file mode 100644 index 0000000..f3e6bef --- /dev/null +++ b/internal/cli/ping_quota_state_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "github.com/wavever/CCLimitPing/internal/provider" + "github.com/wavever/CCLimitPing/internal/usage" +) + +func TestPingAlreadyActiveBeforePing(t *testing.T) { + for _, target := range []string{"weekly", "five_hour"} { + for _, before := range []string{"", "unknown", "not_started", "started"} { + for _, after := range []string{"unknown", "not_started", "started"} { + for _, failed := range []bool{false, true} { + res := &provider.TriggerResult{StatusEnabled: true, + Verification: &usage.Verification{Target: target, + FiveHour: usage.StartStatus{State: after}, Weekly: usage.StartStatus{State: after}}, + } + if before != "" { + res.PreVerification = &usage.Verification{Target: target, + FiveHour: usage.StartStatus{State: before}, Weekly: usage.StartStatus{State: before}} + } + var err error + if failed { + err = fmt.Errorf("CLI failed") + } + for _, text := range []cliText{enText, zhText} { + var out bytes.Buffer + report(&out, text, "codex", time.Now(), res, err) + want := before == "started" && after == "started" + if strings.Contains(out.String(), text.verifyAlreadyActive) != want { + t.Fatalf("%s/%s/%s/failed=%v: %s", target, before, after, failed, out.String()) + } + if want && !strings.Contains(out.String(), fmt.Sprintf(text.verifyQuotaStateFmt, target, text.verifyAlreadyActive)) { + t.Fatal(out.String()) + } + } + } + } + } + } +} diff --git a/internal/cli/verification.go b/internal/cli/verification.go index 4c34412..64f938e 100644 --- a/internal/cli/verification.go +++ b/internal/cli/verification.go @@ -37,7 +37,17 @@ func reportVerification(out io.Writer, text cliText, res *provider.TriggerResult if v.Target == "weekly" { s = v.Weekly } - fmt.Fprintf(out, " %s: %s\n", v.Target, startDescription(text, s)) + description := startDescription(text, s) + if pre := res.PreVerification; pre != nil && pre.Target == v.Target && s.State == codexstate.Started { + before := pre.FiveHour + if v.Target == "weekly" { + before = pre.Weekly + } + if before.State == codexstate.Started { + description = text.verifyAlreadyActive + } + } + fmt.Fprintf(out, text.verifyQuotaStateFmt, v.Target, description) if v.Warning != "" { fmt.Fprintln(out, " "+v.Warning) } diff --git a/internal/provider/codex_verification.go b/internal/provider/codex_verification.go index ab70396..1889a8d 100644 --- a/internal/provider/codex_verification.go +++ b/internal/provider/codex_verification.go @@ -158,6 +158,7 @@ func pingVerified(ctx context.Context, name string, cfg config.ProviderConfig, d res = &TriggerResult{} } res.StatusEnabled = cfg.Enabled + res.PreVerification = pre.Verification after, afterErr := currentCodexAccount(ctx) changed := account == "" || afterErr != nil || after != account if claim != "" { diff --git a/internal/provider/codex_verification_test.go b/internal/provider/codex_verification_test.go index c41d2eb..6a05682 100644 --- a/internal/provider/codex_verification_test.go +++ b/internal/provider/codex_verification_test.go @@ -197,6 +197,9 @@ func TestPostcheckFailureReportsSentWithoutRetry(t *testing.T) { t.Fatal(res, err, reads) } var postErr *UsageHTTPError + if res.PreVerification == nil || res.PreVerification == res.Verification { + t.Fatal("pre-ping verification was not retained separately") + } if !errors.As(res.PostcheckErr, &postErr) || postErr.StatusCode != 503 || !postErr.RetryAfter.After(time.Now().Add(19*time.Minute)) { t.Fatal("postcheck retry metadata lost", res.PostcheckErr) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e8fe223..09100b9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -87,15 +87,16 @@ type ResetCreditRedeemer interface { // consumed (parsed from the CLI's machine-readable output). CostUSD is 0 when // the provider doesn't report a cost (e.g. Codex). type TriggerResult struct { - Command string - HasUsage bool - InputTokens int - OutputTokens int - TotalTokens int - CostUSD float64 - Verification *usage.Verification - PostcheckErr error // quota-read failure, independent of the CLI outcome - StatusEnabled bool + Command string + HasUsage bool + InputTokens int + OutputTokens int + TotalTokens int + CostUSD float64 + Verification *usage.Verification + PreVerification *usage.Verification + PostcheckErr error // quota-read failure, independent of the CLI outcome + StatusEnabled bool } // VerifiedTrigger uses the same ping path with a watcher-owned pre-send reservation. From 551ce903bd1846a611d5fca86bc2b7ac01796317 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Tue, 8 Sep 2026 00:22:08 +0900 Subject: [PATCH 23/24] Throttle quota polling for stale weekly resets --- internal/scheduler/codex.go | 4 ++++ internal/scheduler/codex_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 7c4ffe1..099ed99 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -43,6 +43,10 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. } if s.weeklyExhausted(u) { d := u.Weekly.Remaining() + if d <= 0 { + // A stale or missing reset must not turn quota reads into a tight loop. + d = time.Minute + } if d > 5*time.Minute { d = 5 * time.Minute } diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go index ed76758..d76e5a0 100644 --- a/internal/scheduler/codex_test.go +++ b/internal/scheduler/codex_test.go @@ -132,6 +132,38 @@ func TestVerifiedSchedulerGates(t *testing.T) { } } +func TestVerifiedWeeklyExhaustionPolling(t *testing.T) { + for _, tc := range []struct { + name string + reset time.Time + want time.Duration + }{ + {"missing-reset", time.Time{}, time.Minute}, + {"stale-reset", time.Now().Add(-time.Minute), time.Minute}, + {"near-reset", time.Now().Add(30 * time.Second), 30 * time.Second}, + {"distant-reset", time.Now().Add(time.Hour), 5 * time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + p := &verifiedStub{stubProvider: stubProvider{usage: &usage.Usage{ + Weekly: usage.Window{UsedPercent: 100, ResetsAt: tc.reset}, + }}} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + s := New(testConfig(), []Target{{Provider: p}}, false, false, io.Discard) + s.live.enabled = true + start := time.Now() + s.runVerifiedTarget(ctx, Target{Provider: p}, p) + item := s.live.items[p.Name()] + if item.state != "weekly limit reached" || item.deadline.Sub(start.Add(tc.want)).Abs() > time.Second { + t.Fatalf("next read: %+v, want delay %s", item, tc.want) + } + if reads, sends := p.counts(); reads != 1 || sends != 0 { + t.Fatalf("reads=%d sends=%d", reads, sends) + } + }) + } +} + func TestVerifiedSchedulerHonorsResetBuffer(t *testing.T) { for _, tc := range []struct { name string From 5b75cac5f5c795d3202face9fdebfc2ecb084868 Mon Sep 17 00:00:00 2001 From: Motonari Tsuzuki Date: Tue, 8 Sep 2026 00:24:37 +0900 Subject: [PATCH 24/24] Use five-minute polling for unavailable weekly resets --- internal/scheduler/codex.go | 2 +- internal/scheduler/codex_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/scheduler/codex.go b/internal/scheduler/codex.go index 099ed99..bc17e96 100644 --- a/internal/scheduler/codex.go +++ b/internal/scheduler/codex.go @@ -45,7 +45,7 @@ func (s *Scheduler) runVerifiedTarget(ctx context.Context, t Target, p provider. d := u.Weekly.Remaining() if d <= 0 { // A stale or missing reset must not turn quota reads into a tight loop. - d = time.Minute + d = 5 * time.Minute } if d > 5*time.Minute { d = 5 * time.Minute diff --git a/internal/scheduler/codex_test.go b/internal/scheduler/codex_test.go index d76e5a0..36b9bb0 100644 --- a/internal/scheduler/codex_test.go +++ b/internal/scheduler/codex_test.go @@ -138,8 +138,8 @@ func TestVerifiedWeeklyExhaustionPolling(t *testing.T) { reset time.Time want time.Duration }{ - {"missing-reset", time.Time{}, time.Minute}, - {"stale-reset", time.Now().Add(-time.Minute), time.Minute}, + {"missing-reset", time.Time{}, 5 * time.Minute}, + {"stale-reset", time.Now().Add(-time.Minute), 5 * time.Minute}, {"near-reset", time.Now().Add(30 * time.Second), 30 * time.Second}, {"distant-reset", time.Now().Add(time.Hour), 5 * time.Minute}, } {