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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
This commit is contained in:
44
waitfor/README.md
Normal file
44
waitfor/README.md
Normal file
@@ -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 .
|
||||
```
|
||||
3
waitfor/go.mod
Normal file
3
waitfor/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module gitea.brasse-pc.eu/brasse/agent-tools/waitfor
|
||||
|
||||
go 1.24
|
||||
86
waitfor/main.go
Normal file
86
waitfor/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
waitfor/wait/wait.go
Normal file
94
waitfor/wait/wait.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
92
waitfor/wait/wait_test.go
Normal file
92
waitfor/wait/wait_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user