Skip to content

feat(ux): add background update check with 24h cache#80

Merged
jo-hnny merged 1 commit into
TencentCloudAgentRuntime:mainfrom
fredaluliu:feature/update-check
Jul 21, 2026
Merged

feat(ux): add background update check with 24h cache#80
jo-hnny merged 1 commit into
TencentCloudAgentRuntime:mainfrom
fredaluliu:feature/update-check

Conversation

@fredaluliu

Copy link
Copy Markdown
Contributor

Summary

Add a non-blocking background version check inspired by AgentCore CLI's update-notifier.ts. Every agr invocation triggers a background goroutine that checks for newer versions. A notice is printed to stderr after the command finishes — it never blocks the actual command.

Features

Feature Implementation
Background check Goroutine started before command execution
24h file cache ~/.agr/update-check.json with {"last_check": unix_ts, "latest": "v0.7.0"}
Semver comparison Handles pre-release, v-prefix, "dev" versions
agr update command Explicit check with text/JSON output
agr update --check Same as above (alias)
Respects --non-interactive No notice
Respects -o json No notice (stdout stays pure)
Skips self agr update/agr version/agr help don't trigger background check
Version source https://dl.tencentags.com/agr-cli/latest/VERSION (already exists)

Expected Behavior

$ agr instance list
... normal output ...

Update available: v0.6.2 → v0.7.0
Run: curl -fsSL https://dl.tencentags.com/agr-cli/latest/install.sh | sh
$ agr update
Current: v0.6.2
Latest:  v0.7.0

Update available: v0.6.2 → v0.7.0
Run: curl -fsSL https://dl.tencentags.com/agr-cli/latest/install.sh | sh

Design Constraints Met

  • Never blocks the real command (goroutine + channel)
  • Respects --non-interactive
  • Respects -o json
  • Cache file at ~/.agr/update-check.json
  • Checks https://dl.tencentags.com/agr-cli/latest/VERSION
  • Handles pre-release versions correctly
  • agr update itself skips the background check (no infinite loop)
  • 5s HTTP timeout (won't hang on slow networks)

Files

New

  • internal/updatecheck/updatecheck.go — Core: Start(), PrintNotice(), CompareVersions(), FetchLatestVersion(), cache I/O
  • internal/updatecheck/updatecheck_test.go — 11 unit tests
  • internal/cli/update.goagr update command

Modified

  • internal/cli/root.go — Integrate background check in Execute()

Test Results

ok  github.com/TencentCloudAgentRuntime/ags-cli/internal/updatecheck  0.779s (11 tests)
ok  github.com/TencentCloudAgentRuntime/ags-cli/internal/cli          1.874s

Closes #76

@fredaluliu
fredaluliu force-pushed the feature/update-check branch 3 times, most recently from 212bb71 to a93df63 Compare July 16, 2026 04:02

@KayIter KayIter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR as a CLI behavior change. The update-check implementation is nicely isolated, but I found two issues worth addressing before merge.

Required

The background update check can still block normal command exit.

// Fire off non-blocking background update check (skip for version/update
// commands, non-interactive mode, and JSON output).
var updateCh <-chan *updatecheck.Result
if shouldRunUpdateCheck(os.Args[1:]) {
updateCh = updatecheck.Start(Version)
}
if cmd, err := rootCmd.ExecuteC(); err != nil {
renderExecuteError(cmd, err)
}
// Print update notice after command finishes (never blocks the command).
if updateCh != nil && !nonInteractive && !isJSON() {
updatecheck.PrintNotice(ios.ErrOut, updateCh)

Execute() starts updatecheck.Start() before running the command, but after the command completes it calls updatecheck.PrintNotice(). PrintNotice() does a blocking receive from the channel:

// PrintNotice waits for the background check to finish and prints an update
// notice to w (typically stderr) if a newer version is available.
func PrintNotice(w io.Writer, ch <-chan *Result) {
if ch == nil {
return
}
result, ok := <-ch
if !ok || result == nil || !result.UpdateAvailable {
return
}
fmt.Fprintf(w, "\nUpdate available: %s → %s\n", result.CurrentVersion, result.LatestVersion)
fmt.Fprintf(w, "Run: curl -fsSL %s | sh\n", InstallURL)

So on a cold/expired cache, if the VERSION endpoint is slow or unreachable, every ordinary interactive command can pause at exit until the HTTP request finishes, up to the current httpTimeout of 5 seconds. That contradicts the “never blocks the command” comment and makes a background UX feature visible as latency.

I reproduced this locally with an httptest server that sleeps 200ms before returning a version; PrintNotice blocked for ~201ms, matching the server delay.

Suggested fix: do not wait unconditionally in the foreground path. Use a non-blocking select and only print when the result is already ready, or wait with a very small budget, or only print cached results from a previous check and let the fresh network check update cache for the next invocation.

Suggested

The new root tests for agr update depend on the real public VERSION endpoint.

It("checks for updates in text mode", func() {
result := cli.Run(context.Background(), "update")
result.ExpectSuccess()
Expect(result.Stdout).To(ContainSubstring("Current:"))
Expect(result.Stdout).To(ContainSubstring("Latest:"))
})
It("checks for updates with --check flag", func() {
result := cli.Run(context.Background(), "update", "--check")
result.ExpectSuccess()
Expect(result.Stdout).To(ContainSubstring("Current:"))
})
It("returns JSON envelope with update data", func() {
result := cli.Run(context.Background(), "--output", "json", "update")
result.ExpectSuccess()
env := result.Envelope()
Expect(env.Command).To(Equal("update"))
Expect(env.Status).To(Equal("succeeded"))
Expect(env.Data).To(HaveKey("current"))
Expect(env.Data).To(HaveKey("latest"))
Expect(env.Data).To(HaveKey("update_available"))

agr update calls updatecheck.FetchLatestVersion() directly:

func updateFn(cmd *cobra.Command, args []string) (*CmdResult, error) {
latest, err := updatecheck.FetchLatestVersion()
if err != nil {

That makes the default root test suite depend on https://dl.tencentags.com/agr-cli/latest/VERSION and external network availability. If that service is slow, unavailable, or blocked in CI, these tests will fail even when the CLI code is fine.

Suggested fix: add a test-only/env URL override and point the root tests at an httptest server, or move the real endpoint check into an explicit live/integration suite.

Validation I ran locally:

go test ./internal/updatecheck ./internal/cli ./tests/root

Those packages pass; the first issue is a behavior/latency gap rather than a currently failing unit test.

@fredaluliu
fredaluliu force-pushed the feature/update-check branch from a93df63 to 7f005c8 Compare July 17, 2026 07:39
@fredaluliu

Copy link
Copy Markdown
Contributor Author

Two follow-up fixes for the update-notice timing/exit-path issues raised in review:

1. Warm-cache notice dropped on fast commands

Start() always spawned a goroutine, so on a warm cache the result wasn't ready before Start returned. For microsecond-fast commands (e.g. agr status), PrintNotice's non-blocking select ran before the goroutine was scheduled and hit the default branch — the notice was silently dropped even with a fresh warm cache.

Fix: Start() reads the cache synchronously; on a hit it places the result on the channel before returning. Only a cold/stale cache spawns the background fetch goroutine, which stays non-blocking (cold cache still doesn't block — verified 0.007s exit).

2. Error path bypassed PrintNotice via os.Exit

renderExecuteError() calls os.Exit on every error path, so the PrintNotice call after it in Execute() was unreachable. Any command returning an error (e.g. agr instance list without credentials) never showed an update notice.

Fix: updateCh is threaded into renderExecuteError, and a printUpdateNotice helper prints the notice before each os.Exit. The helper honors the same suppression rules (nil channel / non-interactive / JSON), so JSON output stays pure and non-interactive stays quiet.

E2E verification (real binary, warm cache latest=v9.9.9)

Scenario Before After
agr status (fast success) no notice ✅ prints
agr instance list (error, no creds) no notice (os.Exit bypass) ✅ prints
--non-interactive suppressed suppressed
-o json suppressed suppressed (JSON unpolluted)

Regression tests added: warm-cache result must be ready immediately; cold-cache must not block.

@fredaluliu
fredaluliu force-pushed the feature/update-check branch from 5e992b4 to ebcca50 Compare July 17, 2026 10:21

@KayIter KayIter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

还有一个参数解析上的残留问题需要处理。

[P2] shouldRunUpdateCheck 只检查 args[0] 来判断是否跳过后台 update check,但全局 flags 可以出现在子命令前面。比如 agr --config custom.toml updateargs[0]--config,不会命中 update 的 skip 规则,于是会启动后台检查;update 命令本身随后又会执行前台 FetchLatestVersion,warm cache 下还可能额外打印一段 background update notice。类似地,agr -o json status 会在 JSON 模式下仍启动后台检查,只是最后不打印。

我用临时回归测试复现过:

if shouldRunUpdateCheck([]string{"--config", "custom.toml", "update"}) {
    t.Fatal("expected update check to be skipped for update command after global flag")
}
applyRawGlobalArgs([]string{"-o", "json", "status"})
if shouldRunUpdateCheck([]string{"-o", "json", "status"}) {
    t.Fatal("expected update check to be skipped for JSON output")
}

建议先剥离/跳过全局 flags 后再判断真实 command token,或者复用现有的 raw args token extraction / effective output 判断,让注释里的 skip 规则真正生效。

本地验证:

  • go test ./internal/cli ./tests/root 通过
  • go test ./internal/updatecheck 在允许 httptest 监听本地端口后通过

@fredaluliu
fredaluliu force-pushed the feature/update-check branch 3 times, most recently from 0296fd3 to 9c62ee7 Compare July 21, 2026 04:12
Add non-blocking background version check for agr CLI, inspired by
AgentCore CLI's update-notifier.ts. On every invocation a goroutine
checks for new versions; the notice is printed to stderr after the
command finishes without blocking execution.

Key features:
- Background goroutine + non-blocking select (never delays command exit)
- 24h file cache (~/.agr/update-check.json) to avoid redundant HTTP calls
- Semver comparison handling v-prefix, pre-release, and dev versions
- 'agr update' explicit command with text/JSON output
- Respects --non-interactive and -o json (suppresses notice)
- Skips for version/help/update commands and --version/-v/--help/-h flags
- extractCommandToken reuses extractCommandTokens for consistent flag parsing
- Only scans for version/help flags when no subcommand found (avoids
  false positives from subcommand arguments)
- FetchLatestVersion uses io.ReadAll+LimitReader for reliability
- _TEST_VERSION_URL env var and CLI.ExtraEnv for offline E2E testing
- Unit tests for shouldRunUpdateCheck/extractCommandToken (16 cases)
- Offline E2E tests using httptest mock server

Closes TencentCloudAgentRuntime#76
@fredaluliu
fredaluliu force-pushed the feature/update-check branch from 9c62ee7 to fbde5df Compare July 21, 2026 04:14

@KayIter KayIter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复查了最新提交 fbde5df,之前 request changes 的点已经修掉了。

这版 shouldRunUpdateCheck 不再只看 args[0],而是通过 extractCommandToken 识别真实子命令;--config ... update-o json status 这类全局 flag 在前的场景也有了对应处理/覆盖。JSON 模式下也会直接跳过后台 update check,避免不必要的网络 I/O。

本地验证通过:

  • go test ./internal/cli ./internal/updatecheck ./tests/root

LGTM.

@jo-hnny
jo-hnny merged commit 0c4034e into TencentCloudAgentRuntime:main Jul 21, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ux): add background update check with 24h cache

3 participants