Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ CCLimitPing;如果只需要一个可嵌入现有 cron/plist/监控体系的 Co
- `--json` 输出机器可读快照。
- 从 `codex debug models` 动态选择最低优先级的可见模型。
- `--dry-run` 在执行模型调用或推送指标前预览操作。
- `--if-5h-full` 只在 5 小时额度恢复到 100% 时执行 ping;指标仍正常上报。
- `--if-5h-full` 只在 5 小时额度恢复到 100% 时执行 ping;此时默认还会结合已配置的 cron 目标做相位对齐:仅在满额且到达对齐时间后才 ping(指标仍正常上报)。
- `--without-align` 关闭相位对齐,只保留满额判断(满额即 ping);仅在配合 `--if-5h-full` 时有意义。不带任何 flag 时 `ping` 立即执行,与对齐无关。
- `align` 子命令管理多条 cron 目标(每条带唯一 id 和自己的延时预算 max-delay,可按 id 单独删除),并预览未来的 ping/刷新时间。
- 将用量、重置券数量和最近成功 ping 时间推送到 Pushgateway。
- 成功 ping 时间持久化,后续上报不会被 `ping_completed=0` 覆盖。

Expand Down Expand Up @@ -104,6 +106,10 @@ limitping update
limitping ping --dry-run
limitping ping
limitping ping --if-5h-full
limitping ping --if-5h-full --without-align
limitping align add "0 0 * * *"
limitping align list
limitping align preview --count 5
limitping ping --push-metric https://pushgateway.example.com
limitping status --push-metric https://pushgateway.example.com
```
Expand All @@ -117,7 +123,62 @@ codex exec -m <model> -c model_reasoning_effort=low ping

`--if-5h-full` 要求用量响应包含 5 小时窗口,并且 `used_percent == 0`。不满足时命令正常
退出;如果同时设置了 `--push-metric`,仍会推送最新用量,并将本次
`limitping_ping_completed` 记为 `0`。
`limitping_ping_completed` 记为 `0`。满足满额条件后,默认还会做相位对齐(见下一节),
只有到达对齐时间才真正 ping;加 `--without-align` 可关闭对齐,恢复“满额即 ping”。

## 刷新时间对齐(`--if-5h-full` 默认开启)

如果你希望 5h 窗口的刷新时刻尽量落在某个固定时间(例如每晚 00:00,好让 23:00 开始
工作、用完一轮后 00:00 刚好刷新到),可以用 `align` 配置一组 cron 目标。之后
`limitping ping --if-5h-full` 会默认做“尽力而为”的相位对齐(无需额外 flag)。

配合每分钟运行一次的 cron(或 LaunchAgent):平时满额即 ping、每 5h 正常链式;临近
目标时间时,会在延时预算内延后 ping,使某次窗口的重置刚好落在目标时间附近。若只想保留
满额判断而不对齐,加 `--without-align`。

```sh
# 声明目标:每晚 00:00(多条时,每轮取最近的一个目标)
limitping align add "0 0 * * *"
limitping align add "0 12 * * *"

# 延时预算(max-delay)绑定在每个目标上:add 时用 --max-delay 设置该目标自己的预算;
# 缺省 90m。重复 add 同一个 cron 且带 --max-delay 时,只更新该目标的预算、不新增。
limitping align add "0 0 * * *" --max-delay 90m

# 查看当前配置(每个目标一行:id、cron、该目标的延时预算 max-delay)
limitping align list

# 按 id(或唯一前缀)删除单个目标;clear 清空全部
limitping align delete <id>
limitping align clear

# 预览未来 5 次计划 ping 与对应刷新时间
limitping align preview --count 5

# 实际运行:--if-5h-full 默认对齐;--without-align 关闭对齐(满额即 ping)
limitping ping --if-5h-full
limitping ping --if-5h-full --without-align
```

工作原理与取舍:

- 目标刷新时刻 `T` 取所有 cron 中最近的一次触发;理想 ping 锚点为 `A* = T − 5h`,
在此锚定可让窗口在 `T` 重置。
- 离锚点还远(超过该目标自己的延时预算)时,按原始行为立即 ping,保持窗口新鲜;临近锚点
时在预算内延后到贴近 `A*` 再 ping。判定只依赖当前时间与 cron,无额外状态。
- 因为窗口是滚动 5h,而 `24 ÷ 5` 除不尽,纯 5h 链无法每天精确命中同一墙钟时间;且相位
只能“往后拖、不能往前提”。延时预算(每个目标各自设置,缺省 90 分钟)决定精度与窗口空转
之间的平衡。对齐是“大致落在目标时间附近”,通常有分钟级到 1~2 小时的偏差。

配置保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/limitping/config.json`,延时预算随每个目标落盘:

```json
{
"targets": [
{ "id": "ca5ca66014b8", "cron": "0 0 * * *", "max_delay_minutes": 90 }
]
}
```

## 预触发 5h 窗口能少等多久?

Expand Down Expand Up @@ -194,6 +255,7 @@ $T/2=2.5$ 小时,也就是约 **50%**。这也是“随机时刻到达”直
| `limitping_reset_credit_expiration_timestamp_seconds` | 最早到期的可用重置券时间戳 |
| `limitping_ping_completed` | 本次是否完成 ping(0/1) |
| `limitping_last_successful_ping_timestamp_seconds` | 最近一次成功 ping 的时间戳 |
| `limitping_planned_ping_timestamp_seconds` | 未来计划 ping 时间戳(`index="1..5"`,1 为最近,需配置对齐目标并以 `--if-5h-full` 运行) |
| `metrics_pusher_collector_success` | 本次采集是否成功 |
| `metrics_pusher_last_run_timestamp_seconds` | 最近采集开始时间 |

Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module github.com/ShawnKung/limitping

go 1.22

require github.com/spf13/cobra v1.10.2
require (
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/cobra v1.10.2
)

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
Expand Down
262 changes: 262 additions & 0 deletions internal/align/align_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package align

import (
"fmt"
"path/filepath"
"testing"
"time"
)

func mustPlanner(t *testing.T, crons []string, maxDelayMinutes int) *Planner {
t.Helper()
planner, err := NewPlanner(&Config{Targets: targetsFromCrons(crons, maxDelayMinutes)})
if err != nil {
t.Fatalf("NewPlanner: %v", err)
}
return planner
}

func targetsFromCrons(crons []string, maxDelayMinutes int) []Target {
targets := make([]Target, 0, len(crons))
for i, expr := range crons {
targets = append(targets, Target{ID: fmt.Sprintf("test%08d", i), Cron: expr, MaxDelayMinutes: maxDelayMinutes})
}
return targets
}

func TestDecideNoTargetsAlwaysPings(t *testing.T) {
planner := mustPlanner(t, nil, 0)
decision := planner.Decide(time.Now())
if decision.Action != ActionPing || decision.HasTarget {
t.Fatalf("expected unconditional ping, got %+v", decision)
}
}

func TestDecideFarFromTargetPingsImmediately(t *testing.T) {
// 目标每晚 00:00;现在 08:00,锚点应为 19:00,距今 11h,远超预算 → 立即 ping。
planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes)
now := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local)
decision := planner.Decide(now)
if decision.Action != ActionPing {
t.Fatalf("expected ping when far from anchor, got %+v", decision)
}
if !decision.HasTarget {
t.Fatalf("expected a resolved target")
}
}

func TestDecideHoldsWithinBudget(t *testing.T) {
// 目标 00:00,锚点 19:00。现在 18:30,距锚点 30min,在 90min 预算内 → hold。
planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes)
now := time.Date(2026, 9, 7, 18, 30, 0, 0, time.Local)
decision := planner.Decide(now)
if decision.Action != ActionHold {
t.Fatalf("expected hold, got %+v", decision)
}
wantAnchor := time.Date(2026, 9, 7, 19, 0, 0, 0, time.Local)
if !decision.Anchor.Equal(wantAnchor) {
t.Fatalf("anchor = %s, want %s", decision.Anchor, wantAnchor)
}
}

func TestDecidePingsAtAnchor(t *testing.T) {
// 现在正好 19:00,落在锚点容差内 → ping。
planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes)
now := time.Date(2026, 9, 7, 19, 0, 0, 0, time.Local)
decision := planner.Decide(now)
if decision.Action != ActionPing {
t.Fatalf("expected ping at anchor, got %+v", decision)
}
wantTarget := time.Date(2026, 9, 8, 0, 0, 0, 0, time.Local)
if !decision.Target.Equal(wantTarget) {
t.Fatalf("target = %s, want %s", decision.Target, wantTarget)
}
}

func TestDecidePicksNearestOfMultipleCrons(t *testing.T) {
// 两个目标 00:00 与 12:00。现在 06:40,最近可达锚点是 12:00 的锚点 07:00。
planner := mustPlanner(t, []string{"0 0 * * *", "0 12 * * *"}, DefaultMaxDelayMinutes)
now := time.Date(2026, 9, 7, 6, 40, 0, 0, time.Local)
decision := planner.Decide(now)
wantTarget := time.Date(2026, 9, 7, 12, 0, 0, 0, time.Local)
if !decision.Target.Equal(wantTarget) {
t.Fatalf("target = %s, want %s", decision.Target, wantTarget)
}
if decision.Action != ActionHold {
t.Fatalf("expected hold near noon anchor, got %+v", decision)
}
}

func TestDecideUsesPerTargetMaxDelay(t *testing.T) {
// 目标 00:00 锚点 19:00。现在 18:30,距锚点 30min。
// 若该目标预算只有 15min(<30),应立即 ping 而非 hold。
planner, err := NewPlanner(&Config{Targets: []Target{
{ID: "aaaa", Cron: "0 0 * * *", MaxDelayMinutes: 15},
}})
if err != nil {
t.Fatalf("NewPlanner: %v", err)
}
now := time.Date(2026, 9, 7, 18, 30, 0, 0, time.Local)
decision := planner.Decide(now)
if decision.Action != ActionPing {
t.Fatalf("expected ping when delay exceeds this target budget, got %+v", decision)
}
if decision.MaxDelay != 15*time.Minute {
t.Fatalf("expected decision to carry per-target max delay 15m, got %s", decision.MaxDelay)
}
}

func TestNextPingTimesConvergeThenAlign(t *testing.T) {
// 从 08:00 起链式:前几次每 5h,临近后把某次拖到 19:00,使刷新落在 00:00。
planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes)
start := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local)
plans := planner.NextPingTimes(start, 5)
if len(plans) != 5 {
t.Fatalf("want 5 plans, got %d", len(plans))
}
// 每次刷新必须等于对应 ping + 5h。
for _, plan := range plans {
if !plan.RefreshAt.Equal(plan.PingAt.Add(WindowLength)) {
t.Fatalf("refresh != ping+5h: %+v", plan)
}
}
// 存在某次刷新命中目标 00:00(允许容差)。
hitMidnight := false
for _, plan := range plans {
if plan.RefreshAt.Hour() == 0 && plan.RefreshAt.Minute() == 0 {
hitMidnight = true
}
}
if !hitMidnight {
t.Fatalf("expected a refresh aligned to 00:00, plans=%v", plans)
}
}

func TestUpcomingUsesRunningWindow(t *testing.T) {
planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes)
now := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local)
lastPing := now.Add(-2 * time.Hour) // 窗口还剩 3h
plans := planner.Upcoming(now, &lastPing, 3)
firstFull := lastPing.Add(WindowLength)
if plans[0].PingAt.Before(firstFull) {
t.Fatalf("first ping %s should not precede next full %s", plans[0].PingAt, firstFull)
}
}

func TestParseCronRejectsInvalid(t *testing.T) {
if _, err := ParseCron("not a cron"); err == nil {
t.Fatal("expected error for invalid cron")
}
if _, err := ParseCron(""); err == nil {
t.Fatal("expected error for empty cron")
}
}

func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
cfg := &Config{}
first, _, err := cfg.Add("0 0 * * *", 120)
if err != nil {
t.Fatalf("Add: %v", err)
}
if _, _, err := cfg.Add("30 12 * * 1", 0); err != nil {
t.Fatalf("Add: %v", err)
}
if err := Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(loaded.Targets) != 2 {
t.Fatalf("round trip mismatch: %+v", loaded)
}
if loaded.Targets[0].ID != first.ID || loaded.Targets[0].Cron != "0 0 * * *" || loaded.Targets[0].MaxDelayMinutes != 120 {
t.Fatalf("id/cron/max-delay not persisted: %+v", loaded.Targets[0])
}
// 第二个目标未指定预算,落盘为 0,读回时以默认值解释。
if loaded.Targets[1].MaxDelayMinutes != 0 || loaded.Targets[1].MaxDelay() != DefaultMaxDelayMinutes*time.Minute {
t.Fatalf("default max-delay wrong: %+v -> %s", loaded.Targets[1], loaded.Targets[1].MaxDelay())
}
if path, _ := ConfigPath(); path != filepath.Join(dir, "limitping", configFilename) {
t.Fatalf("unexpected config path %s", path)
}
}

func TestLoadMissingReturnsDefault(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Targets) != 0 {
t.Fatalf("expected no targets, got %v", cfg.Targets)
}
if (Target{}).MaxDelay() != DefaultMaxDelayMinutes*time.Minute {
t.Fatalf("expected default max delay, got %s", (Target{}).MaxDelay())
}
}

func TestSaveRejectsInvalidCron(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(&Config{Targets: []Target{{ID: "abc", Cron: "bogus"}}}); err == nil {
t.Fatal("expected save to reject invalid cron")
}
}

func TestAddAssignsUniqueIDsAndUpdatesDelayOnDup(t *testing.T) {
cfg := &Config{}
a, created, err := cfg.Add("0 0 * * *", 45)
if err != nil || !created {
t.Fatalf("Add: %v created=%v", err, created)
}
b, created, err := cfg.Add("0 12 * * *", 0)
if err != nil || !created {
t.Fatalf("Add: %v created=%v", err, created)
}
if a.ID == "" || b.ID == "" || a.ID == b.ID {
t.Fatalf("expected distinct non-empty ids, got %q and %q", a.ID, b.ID)
}
if len(a.ID) != idLength {
t.Fatalf("id length = %d, want %d", len(a.ID), idLength)
}
if a.MaxDelayMinutes != 45 {
t.Fatalf("expected per-target max delay 45, got %d", a.MaxDelayMinutes)
}
// 重复 cron 且给了新预算:不新增目标,只更新其预算。
dup, created, err := cfg.Add("0 0 * * *", 30)
if err != nil || created {
t.Fatalf("Add dup: %v created=%v", err, created)
}
if dup.ID != a.ID || len(cfg.Targets) != 2 || cfg.Targets[0].MaxDelayMinutes != 30 {
t.Fatalf("duplicate cron should reuse id and update delay: dup=%+v targets=%d", dup, len(cfg.Targets))
}
}

func TestDeleteByIDAndPrefix(t *testing.T) {
cfg := &Config{}
a, _, _ := cfg.Add("0 0 * * *", 0)
b, _, _ := cfg.Add("0 12 * * *", 0)

removed, err := cfg.Delete(a.ID)
if err != nil {
t.Fatalf("Delete by full id: %v", err)
}
if removed.ID != a.ID || len(cfg.Targets) != 1 {
t.Fatalf("full-id delete failed: removed=%+v remaining=%d", removed, len(cfg.Targets))
}

removed, err = cfg.Delete(b.ID[:4])
if err != nil {
t.Fatalf("Delete by prefix: %v", err)
}
if removed.ID != b.ID || len(cfg.Targets) != 0 {
t.Fatalf("prefix delete failed: removed=%+v remaining=%d", removed, len(cfg.Targets))
}

if _, err := cfg.Delete("deadbeef"); err == nil {
t.Fatal("expected error deleting unknown id")
}
}
Loading
Loading