From 6c8cae885b1016c284ca0028ec0c1d644d573573 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 00:39:58 +0200 Subject: [PATCH] waitfor: block until a shell condition holds --cmd exits 0 (+ optional --matches regex) or --timeout; exit 0/3/1, last output always printed, --then hook composes with notifyr. Injectable runner for tests + real-shell integration test. Spec: doc/tool-parity.md 3.2. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9 --- waitfor/README.md | 44 ++++++++++++++++++ waitfor/go.mod | 3 ++ waitfor/main.go | 86 +++++++++++++++++++++++++++++++++++ waitfor/wait/wait.go | 94 +++++++++++++++++++++++++++++++++++++++ waitfor/wait/wait_test.go | 92 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 waitfor/README.md create mode 100644 waitfor/go.mod create mode 100644 waitfor/main.go create mode 100644 waitfor/wait/wait.go create mode 100644 waitfor/wait/wait_test.go diff --git a/waitfor/README.md b/waitfor/README.md new file mode 100644 index 0000000..0a143ee --- /dev/null +++ b/waitfor/README.md @@ -0,0 +1,44 @@ +# waitfor — block until a condition holds + +Replaces the hand-rolled `while ! curl …; do sleep 5; done` loops that +each need a fresh approval in an agent session with **one stable +command prefix**. The agent makes one blocking call instead of burning +turns polling. Closes the `Monitor` gap from +[`doc/tool-parity.md`](../doc/tool-parity.md) §3.2. + +## Usage + +``` +waitfor --cmd "shell command" [--matches regex] [--interval 30s] + [--timeout 20m] [--then "shell command"] [--verbose] +``` + +The condition holds when `--cmd` exits 0 **and**, if `--matches` is +given, its combined output matches the regex. The first attempt runs +immediately; then every `--interval` until `--timeout`. + +| Exit | Meaning | +|------|---------| +| 0 | condition met (`--then` runs afterwards, if given) | +| 3 | timeout — last output still printed so the caller sees the state | +| 1 | usage/config error (empty `--cmd`, bad regex) | + +Examples: + +```bash +# wait until Gitea answers again +waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m + +# wait until a container is listed (read-only Pi5 wrapper) +waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m + +# wait for a file, then notify the phone (composes with notifyr) +waitfor --cmd "test -f /tmp/report.html" --then 'notifyr send --msg "report is ready"' +``` + +## Build & test + +```bash +go test ./... +go build -o build/waitfor . +``` diff --git a/waitfor/go.mod b/waitfor/go.mod new file mode 100644 index 0000000..850cbe0 --- /dev/null +++ b/waitfor/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/waitfor + +go 1.24 diff --git a/waitfor/main.go b/waitfor/main.go new file mode 100644 index 0000000..14bdb8f --- /dev/null +++ b/waitfor/main.go @@ -0,0 +1,86 @@ +// waitfor blocks until a shell condition holds — the agent-friendly +// replacement for hand-rolled poll loops that each need a fresh +// command approval. See doc/tool-parity.md §3.2. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "gitea.brasse-pc.eu/brasse/agent-tools/waitfor/wait" +) + +var version = "dev" + +const usage = `waitfor - block until a condition holds + +Usage: + waitfor --cmd "shell command" [--matches regex] [--interval 30s] + [--timeout 20m] [--then "shell command"] [--verbose] + waitfor version + +The condition holds when --cmd exits 0 and (if given) its combined +output matches --matches. The first attempt runs immediately. + +Exit codes: 0 condition met, 3 timeout, 1 error. The last command +output is printed either way, so the caller always sees the state. + +Examples: + waitfor --cmd "curl -sf https://gitea.brasse-pc.eu/api/healthz" --interval 30s --timeout 20m + waitfor --cmd "ssh pi5-claude sudo claude-docker ps" --matches gitea --timeout 10m + waitfor --cmd "test -f /tmp/done" --then 'notifyr send --msg "done!"' +` + +func main() { + if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") { + fmt.Println("waitfor", version) + return + } + if len(os.Args) >= 2 && (os.Args[1] == "help" || os.Args[1] == "--help" || os.Args[1] == "-h") { + fmt.Print(usage) + return + } + fs := flag.NewFlagSet("waitfor", flag.ExitOnError) + cmd := fs.String("cmd", "", "shell command to poll (required)") + matches := fs.String("matches", "", "regex the output must match") + interval := fs.Duration("interval", 10*time.Second, "time between attempts") + timeout := fs.Duration("timeout", 10*time.Minute, "total time budget") + then := fs.String("then", "", "shell command to run when the condition is met") + verbose := fs.Bool("verbose", false, "log every attempt to stderr") + fs.Usage = func() { fmt.Fprint(os.Stderr, usage) } + fs.Parse(os.Args[1:]) + if *cmd == "" { + fmt.Fprint(os.Stderr, usage) + os.Exit(1) + } + + res, err := wait.Wait(wait.Options{ + Cmd: *cmd, Matches: *matches, Interval: *interval, Timeout: *timeout, Verbose: *verbose, + }, wait.ShellRunner, func(s string) { fmt.Fprintln(os.Stderr, "waitfor: "+s) }) + if err != nil { + fmt.Fprintln(os.Stderr, "waitfor:", err) + os.Exit(1) + } + + if out := strings.TrimRight(res.LastOut, "\n"); out != "" { + fmt.Println(out) + } + if !res.Met { + fmt.Fprintf(os.Stderr, "waitfor: timeout after %s (%d attempts, last exit %d)\n", + res.Elapsed.Round(time.Second), res.Attempts, res.LastExit) + os.Exit(3) + } + fmt.Fprintf(os.Stderr, "waitfor: condition met after %s (%d attempts)\n", + res.Elapsed.Round(time.Millisecond), res.Attempts) + if *then != "" { + t := exec.Command("sh", "-c", *then) + t.Stdout, t.Stderr = os.Stdout, os.Stderr + if err := t.Run(); err != nil { + fmt.Fprintf(os.Stderr, "waitfor: --then command failed: %v\n", err) + } + } +} diff --git a/waitfor/wait/wait.go b/waitfor/wait/wait.go new file mode 100644 index 0000000..a0f3204 --- /dev/null +++ b/waitfor/wait/wait.go @@ -0,0 +1,94 @@ +// Package wait blocks until a shell condition holds: run a command +// every interval until it exits 0 (and, optionally, its output matches +// a regex) or a timeout expires. One blocking call instead of an +// agent burning turns on poll loops. +package wait + +import ( + "fmt" + "os/exec" + "regexp" + "time" +) + +type Options struct { + Cmd string // shell command to run each attempt (required) + Matches string // optional regex the output must match + Interval time.Duration // between attempts + Timeout time.Duration // total budget + Verbose bool // progress line per attempt to the log func +} + +type Result struct { + Met bool + Attempts int + Elapsed time.Duration + LastOut string + LastExit int +} + +// Runner executes a shell command, returning combined output and exit +// code. Separated out so tests can fake it. +type Runner func(cmd string) (string, int) + +// ShellRunner runs via sh -c with combined stdout+stderr. +func ShellRunner(cmd string) (string, int) { + c := exec.Command("sh", "-c", cmd) + out, err := c.CombinedOutput() + code := 0 + if err != nil { + code = 1 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + } + return string(out), code +} + +// Wait polls until the condition holds or the timeout expires. The +// first attempt runs immediately. log receives progress lines when +// Verbose is set (pass nil otherwise). +func Wait(opts Options, run Runner, log func(string)) (Result, error) { + if opts.Cmd == "" { + return Result{}, fmt.Errorf("no command given") + } + var re *regexp.Regexp + if opts.Matches != "" { + var err error + re, err = regexp.Compile(opts.Matches) + if err != nil { + return Result{}, fmt.Errorf("bad --matches regex: %v", err) + } + } + if opts.Interval <= 0 { + opts.Interval = 10 * time.Second + } + if opts.Timeout <= 0 { + opts.Timeout = 10 * time.Minute + } + start := time.Now() + res := Result{} + for { + res.Attempts++ + out, code := run(opts.Cmd) + res.LastOut, res.LastExit = out, code + met := code == 0 && (re == nil || re.MatchString(out)) + if opts.Verbose && log != nil { + state := "not yet" + if met { + state = "met" + } + log(fmt.Sprintf("attempt %d: exit %d, condition %s", res.Attempts, code, state)) + } + if met { + res.Met = true + res.Elapsed = time.Since(start) + return res, nil + } + if time.Since(start)+opts.Interval > opts.Timeout { + res.Elapsed = time.Since(start) + return res, nil + } + time.Sleep(opts.Interval) + } +} diff --git a/waitfor/wait/wait_test.go b/waitfor/wait/wait_test.go new file mode 100644 index 0000000..b7cbcd6 --- /dev/null +++ b/waitfor/wait/wait_test.go @@ -0,0 +1,92 @@ +package wait + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestMetOnExitZero(t *testing.T) { + calls := 0 + run := func(cmd string) (string, int) { + calls++ + if calls < 3 { + return "not ready", 1 + } + return "ready", 0 + } + res, err := Wait(Options{Cmd: "x", Interval: time.Millisecond, Timeout: time.Second}, run, nil) + if err != nil { + t.Fatal(err) + } + if !res.Met || res.Attempts != 3 || res.LastOut != "ready" { + t.Errorf("unexpected result: %+v", res) + } +} + +func TestMatchesRequiredOnTopOfExitZero(t *testing.T) { + calls := 0 + run := func(cmd string) (string, int) { + calls++ + if calls == 1 { + return "gitea starting", 0 // exit 0 but no match yet + } + return "gitea healthy", 0 + } + res, err := Wait(Options{Cmd: "x", Matches: "healthy", Interval: time.Millisecond, Timeout: time.Second}, run, nil) + if err != nil { + t.Fatal(err) + } + if !res.Met || res.Attempts != 2 { + t.Errorf("match should gate success: %+v", res) + } +} + +func TestTimeoutReportsLastOutput(t *testing.T) { + run := func(cmd string) (string, int) { return "still broken", 7 } + res, err := Wait(Options{Cmd: "x", Interval: 5 * time.Millisecond, Timeout: 20 * time.Millisecond}, run, nil) + if err != nil { + t.Fatal(err) + } + if res.Met { + t.Error("should have timed out") + } + if res.LastOut != "still broken" || res.LastExit != 7 { + t.Errorf("last output lost: %+v", res) + } + if res.Attempts < 1 { + t.Error("should have tried at least once") + } +} + +func TestBadRegexRejected(t *testing.T) { + if _, err := Wait(Options{Cmd: "x", Matches: "("}, nil, nil); err == nil { + t.Error("bad regex accepted") + } +} + +func TestEmptyCommandRejected(t *testing.T) { + if _, err := Wait(Options{}, nil, nil); err == nil { + t.Error("empty command accepted") + } +} + +// Integration: real shell, waiting for a file to appear (the exact +// case from the agy live test in doc/tool-parity.md). +func TestShellRunnerFileAppears(t *testing.T) { + path := filepath.Join(t.TempDir(), "flag") + go func() { + time.Sleep(30 * time.Millisecond) + os.WriteFile(path, []byte("x"), 0o644) + }() + res, err := Wait(Options{ + Cmd: "test -f " + path, Interval: 10 * time.Millisecond, Timeout: 2 * time.Second, + }, ShellRunner, nil) + if err != nil { + t.Fatal(err) + } + if !res.Met { + t.Errorf("file never seen: %+v", res) + } +}