diff --git a/AGENTS.md b/AGENTS.md index e281fc35..7272fd81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state Owns persistent filesystem and process I/O for checkouts; `crq` supplies only configured roots and a current-token resolver. - `internal/state/` — persisted schema v4: one `Round` per PR, one global +- `internal/state/` — persisted schema v3: one `Round` per PR, one global `FireSlot`, the CodeRabbit `AccountQuota`, an `Archive` ring. Round transition methods, durable tombstones for tidied trigger comments, the CAS store, and dashboard rendering. `Round.CoBots` holds per- diff --git a/README.md b/README.md index 39b266aa..ed4da931 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,9 @@ crq feedback # current normalized findings as JSON, WITHOUT trigger crq threads # every unresolved thread, outdated included crq resolve [...] # resolve addressed review threads crq decline [...] --reason "" [--resolve] # record why a finding is declined +crq drain install # ⭐ unattended: watch every PR and fix what needs fixing +crq watch # what the drain runs: drive open PRs through crq next, one JSON + # line each (--dispatch starts a fix session; --once for cron) crq hold --reason "" # persistently stop reviews for a PR crq unhold # resume reviews for a held PR crq hold # list held PRs @@ -486,7 +489,7 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_SCOPE` | owner of `CRQ_REPO` | which owners/orgs share this quota (comma-separated) | | `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state | | `CRQ_REPOS` | _(all in scope)_ | `autoreview` allowlist — only these `owner/name` repos (comma-separated) | -| `CRQ_EXCLUDE` | _(none)_ | `autoreview` denylist — never these `owner/name` repos (comma-separated) | +| `CRQ_EXCLUDE` | _(none)_ | denylist — crq never reviews, watches or fixes these `owner/name` repos (comma-separated) | | `CRQ_AUTOREVIEW_SKIP_AUTHORS` | `dependabot[bot]` | PR authors `autoreview` never enqueues (comma-separated; case and `[bot]` suffix don't matter) — set to empty to auto-review bot PRs too; manual `crq review` is unaffected | | `CRQ_AUTOREVIEW_SKIP_MARKER` | `` | exact PR-body marker that suppresses fleet auto-review; set empty to disable; manual `crq loop` is unaffected | | `CRQ_TIDY` | `0` | set to `1` to delete crq's own spent review-trigger comments as rounds progress (`crq tidy` by hand is unaffected) | diff --git a/cmd/crq/doctor_installs_test.go b/cmd/crq/doctor_installs_test.go new file mode 100644 index 00000000..34deed88 --- /dev/null +++ b/cmd/crq/doctor_installs_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// One stale crq erased every dispatch claim in the fleet, and nothing said so. +// doctor cannot stop it — the binary that does the damage is older than any +// mechanism meant to catch it — but it can name it. +func TestOtherInstallsNamesADifferentBuild(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + mine, err := os.ReadFile(self) + if err != nil { + t.Fatal(err) + } + + stale := t.TempDir() + same := t.TempDir() + if err := os.WriteFile(filepath.Join(stale, "crq"), []byte("an older build"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(same, "crq"), mine, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", stale+string(os.PathListSeparator)+same) + t.Setenv("GOPATH", t.TempDir()) // no crq there: an empty directory is not a finding + + found := otherInstalls() + if len(found) != 2 { + t.Fatalf("found %d installs, want the two planted ones: %+v", len(found), found) + } + byPath := map[string]otherInstall{} + for _, in := range found { + byPath[in.Path] = in + } + if in, ok := byPath[filepath.Join(stale, "crq")]; !ok || in.SameBuild { + t.Errorf("the stale build was not reported as different: %+v", in) + } + if in, ok := byPath[filepath.Join(same, "crq")]; !ok || !in.SameBuild { + t.Errorf("a copy of this build was reported as different: %+v", in) + } +} + +// A directory on PATH that holds no crq, or holds something unexecutable, is +// not a second install. +func TestOtherInstallsIgnoresWhatItCannotRun(t *testing.T) { + empty := t.TempDir() + unexec := t.TempDir() + if err := os.WriteFile(filepath.Join(unexec, "crq"), []byte("notes"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", empty+string(os.PathListSeparator)+unexec) + t.Setenv("GOPATH", t.TempDir()) + if found := otherInstalls(); len(found) != 0 { + t.Errorf("reported %+v, want nothing", found) + } +} diff --git a/cmd/crq/main.go b/cmd/crq/main.go index b10b9880..0edf24d4 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -2,14 +2,20 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" "fmt" + "io" "os" "os/exec" + "os/signal" + "path/filepath" "strconv" "strings" + "syscall" "time" "github.com/kristofferR/coderabbit-queue/internal/crq" @@ -23,7 +29,9 @@ func (stderrLogger) Printf(format string, args ...any) { } func main() { - code := run(context.Background(), os.Args[1:]) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + code := run(ctx, os.Args[1:]) + stop() os.Exit(code) } @@ -56,6 +64,27 @@ func run(ctx context.Context, args []string) int { return 1 case "preflight": return preflight(ctx, args[1:]) + case "drain": + // A dry run is documented as a PREVIEW: it writes nothing and reads no + // GitHub state. Deciding it here, before the authenticated client is + // built, is what lets somebody inspect the setup before finishing it — + // otherwise the one command for looking at the plan was itself another + // thing to set up first. A real install still goes through the + // authenticated path below. + if opts, perr := parseDrainArgs(args[1:]); perr == nil && opts.dryRun { + cfg, cerr := crq.LoadConfig() + if cerr != nil { + fatal(cerr) + return 1 + } + plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + } } cfg, err := crq.LoadConfig() @@ -247,6 +276,122 @@ func run(ctx context.Context, args []string) int { } printJSON(result) return 0 + case "drain": + switch sub := drainSubcommand(args[1:]); sub { + case "?": + fatal(fmt.Errorf("unknown drain subcommand %q (try: crq drain, crq drain on|off|default , crq drain install)", args[1])) + return 1 + case "", "list": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + settings, derr := service.DrainSettings(ctx) + if derr != nil { + fatal(derr) + return 1 + } + printJSON(settings) + return 0 + case "on", "off", "default": + rest, reason, ok := parseDrainReason(args[2:]) + if !ok || len(rest) != 1 { + fatal(errors.New(`usage: crq drain on|off|default [--reason ""]`)) + return 1 + } + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + if sub == "default" { + cleared, cerr := service.ClearDrainEnabled(ctx, rest[0]) + if cerr != nil { + fatal(cerr) + return 1 + } + printJSON(map[string]any{"repo": crq.NormalizeRepo(rest[0]), "cleared": cleared, "enabled": true, "default": true}) + return 0 + } + setting, serr := service.SetDrainEnabled(ctx, rest[0], sub == "on", reason) + if serr != nil { + fatal(serr) + return 1 + } + printJSON(setting) + return 0 + } + opts, perr := parseDrainArgs(args[1:]) + if perr != nil { + fatal(perr) + return 1 + } + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + plan, ierr := service.InstallDrain(ctx, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, opts.dryRun) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + case "watch": + fs := flag.NewFlagSet("watch", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + // Split on "--" BEFORE parsing: FlagSet.Parse consumes the terminator, so + // looking for it in fs.Args() afterwards never finds it and the fix + // command is silently read as a list of repositories. + flagArgs, command := args[1:], []string(nil) + for i, arg := range flagArgs { + if arg == "--" { + command = flagArgs[i+1:] + // Three-index slice: without capping capacity, the flag half + // still reaches into the command half, and anything that appends + // to a slice derived from it — fs.Args(), which is exactly what + // the repository list becomes — writes over the command. + flagArgs = flagArgs[:i:i] + break + } + } + // Dispatch is the default. Keep both the standard boolean form + // --dispatch=false and the more explicit --no-dispatch observer alias. + dispatch := fs.Bool("dispatch", true, "start a fix session for a PR that needs one (default)") + noDispatch := fs.Bool("no-dispatch", false, "observe only: report what each PR needs and fix nothing") + once := fs.Bool("once", false, "run one pass and exit") + interval := fs.Duration("interval", 0, "time between passes") + attempts := fs.Int("max-attempts", 0, "dispatches allowed per head") + concurrency := fs.Int("concurrency", 0, "cap on concurrent fix sessions (0 = no cap)") + if err := fs.Parse(flagArgs); err != nil { + return 1 + } + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + opts := crq.WatchOptions{ + Once: *once, + Interval: *interval, MaxAttempts: *attempts, + Command: command, + Dispatch: watchDispatchOption(*dispatch, *noDispatch), + } + // Only when it was actually passed: `--concurrency 0` means "no cap" and + // has to override a configured one, which an unset flag must not do. + fs.Visit(func(f *flag.Flag) { + if f.Name == "concurrency" { + opts.Concurrency = concurrency + } + }) + opts.Repos = fs.Args() + enc := json.NewEncoder(os.Stdout) + werr := service.Watch(ctx, opts, func(e crq.WatchEvent) error { + return enc.Encode(e) + }) + if werr != nil && !errors.Is(werr, context.Canceled) { + fatal(werr) + return 1 + } + return 0 case "hold", "unhold": if args[0] == "hold" && len(args[1:]) == 0 { if err := cfg.RequireState(); err != nil { @@ -381,6 +526,14 @@ func run(ctx context.Context, args []string) int { } } +func watchDispatchOption(dispatch, noDispatch bool) *bool { + if dispatch && !noDispatch { + return nil + } + off := false + return &off +} + func debug(ctx context.Context, service *crq.Service, store crq.StateStore, cfg crq.Config, args []string) int { if len(args) == 0 { fatal(errors.New("usage: crq debug ")) @@ -473,6 +626,12 @@ USAGE crq decline [...] --reason "" [--keep-open] reply on a thread to record why a finding is declined (resolves it; --keep-open leaves it open) + crq drain install [--agent ] [--dry-run] [...] + crq drain [on|off|default ] which repositories crq may fix (on by default) + install and start the unattended review drain + crq watch [--no-dispatch] [--once] [...] [-- ] + drive open PRs through crq next; --dispatch starts a + session to fix the ones that need it crq hold --reason "" stop crq reviewing a PR (crq hold lists them) crq unhold put it back in the queue @@ -637,6 +796,81 @@ replies contesting the decline, crq re-surfaces that reply as its own finding. Pass --keep-open to leave it unresolved anyway (an on-the-record disagreement you intend to keep working). Thread IDs come from .findings[].thread_id. +`) + case "drain": + fmt.Print(`crq drain install [--agent ] [--dry-run] [...] +crq drain (which repositories crq may fix) +crq drain off [--reason ""] +crq drain on +crq drain default (back to the fleet default, which is on) + +Install and start the unattended review drain: crq watches every open PR and +starts a fix session for the ones that need one. + +Draining is ON for every repository in scope. That is the point of watching: a +pull request nobody fixes is a queue that reports work and does none. Turn it off +where you do not want crq writing code — a release branch, a repository you are +hand-tuning — and watching continues there regardless, so reviews still arrive +for a person to act on. The setting lives in the state ref, next to the reviewer +overrides, because the daemon has no checkout of what it watches. + +It writes the fix prompt, a wrapper, and a service definition for this platform +(a systemd user unit, or a launchd agent on macOS), turns on whatever that +platform needs to survive a logout, and starts it. --dry-run prints the paths and +commands without touching anything. + +The agent defaults to "claude" on PATH; --agent takes another path to it, or a +wrapper around it. The installed service runs the agent with Claude Code's own +flags (-p, --permission-mode, --output-format), so an agent with a different CLI +needs its own wrapper script and "crq watch -- ...", which runs +whatever argv you give it. --agent must name something runnable; a name that +cannot be resolved is refused here rather than at the first dispatch. + +It is run with permissions bypassed because it is unattended — an interactive +prompt for "go test" or "git push" would hang forever with nobody to answer it. +What keeps that bounded is the isolated worktree, one claimed session per PR, +CRQ_DISPATCH_MAX_ATTEMPTS per head, and the prompt's stated scope. + +Repositories default to CRQ_REPOS. +`) + case "watch": + fmt.Print(`crq watch [--no-dispatch] [--once] [--interval ] [--max-attempts ] [...] [-- ...] + +Drive every open PR through the same crq next oracle an agent uses, emit one JSON +line per PR per pass, and start a fix session for each PR whose action is "fix". + +Fixing is the default — watching a pull request nobody fixes is a queue that +reports work and does none. Three things turn it off: --no-dispatch for one run, +"crq drain off " for one repository, and having no fix command configured +at all, which observes and says so rather than refusing to start. + +A dispatched session runs in a worktree crq checked out at that head, with: + + CRQ_DISPATCH_REPO owner/name + CRQ_DISPATCH_PR the number + CRQ_DISPATCH_HEAD the commit the findings are about + CRQ_DISPATCH_FINDINGS path to the findings as JSON + +The command comes from CRQ_DISPATCH_CMD or from everything after --. It is run +directly, not through a shell, so nothing expands that you did not write. + +Sessions run concurrently and OFF the decision loop, with no cap by default. The +queue exists for the account-metered review and nothing else; fixing findings +spends none of that allowance, so a PR whose findings are ready gets a session +now rather than a place in a line. CRQ_DISPATCH_CONCURRENCY (or --concurrency) +sets a cap if a machine cannot take the load — a resource valve, not a queue. +--concurrency 0 turns a configured cap off for one run. + +The decisions themselves stay serial, because that is what keeps the metered +review in one queue. + +crq still does not decide which findings are real — it starts the session and +says which PR to look at; the session judges. Every dispatch is claimed under +compare-and-swap, so two watchers cannot both work one PR, and bounded per head +(CRQ_DISPATCH_MAX_ATTEMPTS, default 3) so a fix that keeps not working stops +instead of spending a review round each time. + +Repositories default to CRQ_REPOS. `) case "hold", "unhold": fmt.Print(`crq hold --reason "" @@ -1253,10 +1487,19 @@ type doctorReport struct { CodeRabbitCLI doctorCodeRabbitCLI `json:"coderabbit_cli"` Tools map[string]toolInfo `json:"tools"` Environment doctorEnvironment `json:"environment"` + OtherInstalls []otherInstall `json:"other_installs"` AgentCommands []string `json:"agent_commands"` Recommendations []string `json:"recommendations"` } +// otherInstall is another crq executable this host can run. Every one of them +// writes the same state ref, and an older one erases what a newer one recorded. +type otherInstall struct { + Path string `json:"path"` + SameBuild bool `json:"same_build"` + ModTime string `json:"mod_time,omitempty"` +} + type doctorConfig struct { GateRepo string `json:"gate_repo,omitempty"` DashboardIssue int `json:"dashboard_issue,omitempty"` @@ -1352,9 +1595,87 @@ func doctor(ctx context.Context) doctorReport { if (report.Tools["cr"].Found || report.Tools["coderabbit"].Found) && !report.Environment.CodeRabbitAPIKey && !report.CodeRabbitCLI.Authenticated { report.Recommendations = append(report.Recommendations, "optional: set CODERABBIT_API_KEY or run coderabbit auth login for headless local reviews") } + report.OtherInstalls = otherInstalls() + for _, other := range report.OtherInstalls { + if !other.SameBuild { + report.Recommendations = append(report.Recommendations, + "replace or remove "+other.Path+": a different crq build writes the same state ref, and an older one erases fields this one records") + } + } return report } +// otherInstalls finds every other crq this host can run. +// +// They all write one state ref, and a build old enough to predate a field +// simply drops it: one stale binary quietly erased every dispatch claim in the +// fleet, so three fix sessions ran on one pull request at once. Version +// tolerance carries unknown members, but only for a binary that HAS it — the one +// that does the damage is by definition older than the mechanism meant to stop +// it. Nothing in this build can prevent that, which is why it is worth naming. +// +// Compared by content, not by version: every build reports the same +// "2.0.0-dev", so the version string cannot tell two of them apart. GOPATH/bin +// is included whether or not it is on PATH — the binary that caused this was +// not on the daemon's PATH and ran anyway. +func otherInstalls() []otherInstall { + self, err := os.Executable() + if err != nil { + return nil + } + if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { + self = resolved + } + mine, err := fileDigest(self) + if err != nil { + return nil + } + dirs := filepath.SplitList(os.Getenv("PATH")) + if gopath := strings.TrimSpace(os.Getenv("GOPATH")); gopath != "" { + dirs = append(dirs, filepath.Join(gopath, "bin")) + } else if home, herr := os.UserHomeDir(); herr == nil { + dirs = append(dirs, filepath.Join(home, "go", "bin")) + } + var found []otherInstall + seen := map[string]bool{self: true} + for _, dir := range dirs { + if strings.TrimSpace(dir) == "" { + continue + } + path := filepath.Join(dir, "crq") + if resolved, rerr := filepath.EvalSymlinks(path); rerr == nil { + path = resolved + } + if seen[path] { + continue + } + info, serr := os.Stat(path) + if serr != nil || info.IsDir() || info.Mode().Perm()&0o111 == 0 { + continue + } + seen[path] = true + entry := otherInstall{Path: path, ModTime: info.ModTime().UTC().Format(time.RFC3339)} + if digest, derr := fileDigest(path); derr == nil { + entry.SameBuild = digest == mine + } + found = append(found, entry) + } + return found +} + +func fileDigest(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + sum := sha256.New() + if _, err := io.Copy(sum, f); err != nil { + return "", err + } + return hex.EncodeToString(sum.Sum(nil)), nil +} + func checkCodeRabbitAuth(ctx context.Context, tools map[string]toolInfo) doctorCodeRabbitCLI { binary := "" if tools["cr"].Found { @@ -1454,3 +1775,73 @@ func configPath() string { } return home + "/.config/crq/env" } + +// drainArgs is `crq drain install`'s parsed command line. +type drainArgs struct { + agent string + agentArgs string + dryRun bool + repos []string +} + +// parseDrainArgs is shared by the pre-authentication dry-run path and the +// install itself, so the two cannot disagree about what was asked for. +func parseDrainArgs(args []string) (drainArgs, error) { + fs := flag.NewFlagSet("drain", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + agent := fs.String("agent", "", "fix agent to run: claude or codex (default: claude on PATH)") + agentArgs := fs.String("agent-args", "", "extra flags for the agent, e.g. model and reasoning effort") + dryRun := fs.Bool("dry-run", false, "print what would be written and run") + sub, rest := "", args + if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { + sub, rest = rest[0], rest[1:] + } + if err := fs.Parse(rest); err != nil { + return drainArgs{}, err + } + if sub != "install" { + return drainArgs{}, errors.New("usage: crq drain install [--agent ] [--dry-run] [...]") + } + return drainArgs{agent: *agent, agentArgs: *agentArgs, dryRun: *dryRun, repos: fs.Args()}, nil +} + +// drainSubcommand names what `crq drain ...` was asked to do. An empty string +// means the bare command, which lists; "?" means something this command does not +// have. +// +// A typo must not list. `crq drain of owner/name` reading as the bare listing +// would report the repository as drained and leave it drained — an answer to a +// question nobody asked, in place of the instruction that was meant. +func drainSubcommand(args []string) string { + if len(args) == 0 { + return "" + } + switch args[0] { + case "on", "off", "default", "list", "install": + return args[0] + } + return "?" +} + +// parseDrainReason splits `` from an optional --reason value. An +// unrecognized flag is an error rather than a positional: a typo like --resaon +// must fail loudly, not silently become the repository name. +func parseDrainReason(args []string) (rest []string, reason string, ok bool) { + for i := 0; i < len(args); i++ { + switch arg := args[i]; { + case arg == "--reason": + if i+1 >= len(args) { + return nil, "", false + } + reason = args[i+1] + i++ + case strings.HasPrefix(arg, "--reason="): + reason = strings.TrimPrefix(arg, "--reason=") + case strings.HasPrefix(arg, "-"): + return nil, "", false + default: + rest = append(rest, arg) + } + } + return rest, reason, true +} diff --git a/cmd/crq/main_test.go b/cmd/crq/main_test.go index 1eb6a8f4..351cec7c 100644 --- a/cmd/crq/main_test.go +++ b/cmd/crq/main_test.go @@ -5,6 +5,28 @@ import ( "testing" ) +func TestWatchDispatchOptionHonorsFalse(t *testing.T) { + if got := watchDispatchOption(true, false); got != nil { + t.Errorf("default dispatch = %v, want the configured default", *got) + } + for _, tc := range []struct { + name string + dispatch, noDispatch bool + }{ + {name: "standard false form", dispatch: false}, + {name: "observer alias", dispatch: true, noDispatch: true}, + {name: "false plus observer alias", dispatch: false, noDispatch: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got := watchDispatchOption(tc.dispatch, tc.noDispatch) + if got == nil || *got { + t.Fatalf("watchDispatchOption(%t, %t) = %v, want explicit false", + tc.dispatch, tc.noDispatch, got) + } + }) + } +} + // The thread commands take IDs bare so a caller can clear a whole round in one // process. The transcripts that motivated this were full of shell loops running // one `crq resolve` subprocess per thread, every round. diff --git a/examples/dispatch/README.md b/examples/dispatch/README.md new file mode 100644 index 00000000..edc73b92 --- /dev/null +++ b/examples/dispatch/README.md @@ -0,0 +1,50 @@ +# Unattended review drain + +`crq watch --dispatch` turns "this PR needs fixing" into a session that fixes it. + +```bash +crq drain install # prompt + wrapper + service, started +crq drain install --dry-run # print what it would write first + +# a different agent, and its own model/effort settings +crq drain install --agent codex --agent-args '-c model_reasoning_effort=high' +``` + +crq knows how to CALL claude and codex, and nothing about which model either +should use — that is the agent's own configuration, or `--agent-args`. A queue +choosing your model would be the wrong thing owning that decision. + +That writes the fix prompt, a wrapper, and a systemd user unit (or a launchd +agent on macOS), enables lingering so it survives a logout, and starts it. There +are no files in this directory to copy: the prompt crq installs is embedded in +the binary, so it cannot drift from the one documented here. + +## Two rules the prompt earned the hard way + +**Sessions stay on a detached HEAD and push by ref.** crq's worktrees are backed +by one mirror shared by every PR in the repository. A session that ran +`git checkout -B ` put that branch in the mirror, and git then refused to +fetch it — for *every* PR. One session's branch stopped every dispatch for hours. + +```bash +git push "https://github.com/$head_repo.git" "HEAD:refs/heads/$branch" +``` + +The push names the repository the PR's branch lives in rather than `origin`. +crq's mirror is a clone of the *base* repository, so for a fork PR `origin` is +the wrong place: the commit would land on a same-named branch there and the PR +would never see it. + +**Threads are resolved after pushing, and the session survives to do it.** A +push moves the head, which supersedes the round and drops its dispatch claim. +crq used to read that as "another watcher took this round" and kill the session +between the push and the resolve — every time it succeeded. Only a claim +somebody else actively holds stops a session now. + +## Where to look when it misbehaves + +- `$CRQ_WORKSPACE/logs///--